mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 01:51:32 -10:00
fix(commands): workspace commands need trust and never replace protected built-ins
Markdown commands from a repository's `.codewhale/`, `.deepseek/`,
`.claude/` or `.cursor/commands` (and `.codewhale/workflows`) loaded in any
workspace and were dispatched ahead of the built-ins, so a cloned repo's
`trust.md` or `undo.md` answered `/trust` or `/undo` with its own prompt,
sent to the model as the user's own message, before the user ever ran
/trust.
- commands_dirs/workflow_dirs include workspace directories only when
is_workspace_trusted(workspace) holds (the predicate hooks, MCP and
project skills use). User-global commands are unchanged.
- A workspace command (or alias) never stands in for a protected built-in:
the ones that grant or revoke authority, hold credentials, or undo and
discard work (/trust, /undo, /permissions, /mode, /config, /login,
/logout, /restore, /hooks, /mcp, ... and the /jihua, /zidong mode
aliases). The definition is skipped with a load error naming the rename;
other built-ins (/help, /review) stay shadowable as FEAT-011/012 specify.
- Test fixtures that exercise workspace commands and project skills now
trust their temp workspace (test_support::trust_workspace), which also
repairs the fixtures broken by the project-skill trust gate (294b616ed).
- docs/architecture/command-dispatch.md states the gate.
Tests: codewhale-tui focused run (user_registry, user_commands,
epic_dispatch/discovery, command_palette, skills::, slash_completion,
views::help, command_catalog, skill_lifecycle, commands::tests,
tools::skill, codemode, cached_skills, prompts::tests, plus the B1
filters) 638 passed / 1 failed; the one failure was the new runtime B1
fixture in the next commit, fixed and rerun 1/0. New:
workspace_command_never_replaces_a_protected_builtin,
workspace_alias_never_replaces_a_protected_builtin,
untrusted_workspace_commands_do_not_load.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
1 parent
294b616ed8
commit
9253eff1a6
20 files changed
+303
-30
No files matched your search
@@ -5478,6 +5478,7 @@ mod tests {
|
||||
fn skill_group_projection_maps_native_skills_and_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _home = scoped_home(&tmp);
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
let skills_dir = tmp.path().join("skills");
|
||||
write_skill(&skills_dir, "demo");
|
||||
let mut app = skill_test_app(&tmp, &skills_dir);
|
||||
|
||||
@@ -48,6 +48,8 @@ fn create_discovery_app(tmpdir: &TempDir) -> App {
|
||||
}
|
||||
|
||||
fn write_user_command(tmpdir: &TempDir, name: &str, content: &str) {
|
||||
// Workspace commands load only in a trusted workspace.
|
||||
crate::test_support::trust_workspace(tmpdir.path());
|
||||
let commands_dir = tmpdir.path().join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(commands_dir).expect("create commands dir");
|
||||
let path = tmpdir
|
||||
|
||||
@@ -36,6 +36,8 @@ fn create_dispatch_app(tmpdir: &TempDir) -> App {
|
||||
}
|
||||
|
||||
fn write_user_command(tmpdir: &TempDir, name: &str, content: &str) {
|
||||
// Workspace commands load only in a trusted workspace.
|
||||
crate::test_support::trust_workspace(tmpdir.path());
|
||||
let commands_dir = tmpdir.path().join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(commands_dir).expect("create commands dir");
|
||||
let path = tmpdir
|
||||
|
||||
@@ -273,6 +273,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
// Check user-defined commands FIRST so they can override built-ins.
|
||||
// Workspace (repository) commands load only in a trusted workspace and
|
||||
// never under a protected built-in such as /trust or /undo — the
|
||||
// registry drops those at load.
|
||||
if let Some(result) = user_registry::try_dispatch(app, trimmed) {
|
||||
return result;
|
||||
}
|
||||
@@ -548,6 +551,7 @@ mod tests {
|
||||
#[test]
|
||||
fn user_command_shadows_builtin_before_group_dispatch() {
|
||||
let temp = tempdir().unwrap();
|
||||
crate::test_support::trust_workspace(temp.path());
|
||||
let commands_dir = temp.path().join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
@@ -571,6 +575,7 @@ mod tests {
|
||||
#[test]
|
||||
fn removed_user_command_reloads_and_falls_back_to_builtin() {
|
||||
let temp = tempdir().unwrap();
|
||||
crate::test_support::trust_workspace(temp.path());
|
||||
let commands_dir = temp.path().join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
let command_path = commands_dir.join("help.md");
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
//!
|
||||
//! ## Precedence
|
||||
//!
|
||||
//! Workspace-local directories shadow user-global by name:
|
||||
//! Workspace-local directories load only in a trusted workspace (`/trust`),
|
||||
//! and a workspace command never replaces a protected built-in such as
|
||||
//! `/trust` or `/undo` (the definition is skipped with a load error; see
|
||||
//! `PROTECTED_BUILTINS` in `user_registry.rs`). Other built-ins stay
|
||||
//! shadowable. Workspace-local directories
|
||||
//! shadow user-global by name:
|
||||
//!
|
||||
//! 1. `<workspace>/.codewhale/commands/` (project-local, highest)
|
||||
//! 2. `<workspace>/.deepseek/commands/` (legacy project-local)
|
||||
@@ -50,27 +55,55 @@ fn legacy_global_commands_dir() -> PathBuf {
|
||||
home.join(".deepseek").join("commands")
|
||||
}
|
||||
|
||||
/// Return all candidate commands directories in precedence order.
|
||||
/// The workspace whose repository-supplied commands and workflows may load:
|
||||
/// only a trusted one. A cloned repository's `.claude/commands/*.md` is text
|
||||
/// the user never reviewed, sent to the model as the user's own message.
|
||||
fn trusted_workspace(workspace: Option<&Path>) -> Option<&Path> {
|
||||
workspace.filter(|ws| crate::config::is_workspace_trusted(ws))
|
||||
}
|
||||
|
||||
/// Workspace-local command directories, trusted or not, in precedence order.
|
||||
pub(crate) fn workspace_commands_dirs(workspace: &Path) -> [PathBuf; 4] {
|
||||
[
|
||||
workspace.join(".codewhale").join("commands"),
|
||||
workspace.join(".deepseek").join("commands"),
|
||||
workspace.join(".claude").join("commands"),
|
||||
workspace.join(".cursor").join("commands"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Return all candidate commands directories in precedence order. Workspace
|
||||
/// directories are included only when the workspace is trusted.
|
||||
pub(crate) fn commands_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(ws) = workspace {
|
||||
dirs.push(ws.join(".codewhale").join("commands"));
|
||||
dirs.push(ws.join(".deepseek").join("commands"));
|
||||
dirs.push(ws.join(".claude").join("commands"));
|
||||
dirs.push(ws.join(".cursor").join("commands"));
|
||||
if let Some(ws) = trusted_workspace(workspace) {
|
||||
dirs.extend(workspace_commands_dirs(ws));
|
||||
}
|
||||
dirs.push(global_commands_dir());
|
||||
dirs.push(legacy_global_commands_dir());
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Whether `path` came from the user's own global command or workflow store,
|
||||
/// as opposed to a workspace (repository) directory.
|
||||
pub(crate) fn is_user_global_command_source(path: &Path) -> bool {
|
||||
let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
|
||||
[
|
||||
global_commands_dir(),
|
||||
legacy_global_commands_dir(),
|
||||
home.join(".codewhale").join("workflows"),
|
||||
]
|
||||
.iter()
|
||||
.any(|dir| path.starts_with(dir))
|
||||
}
|
||||
|
||||
/// Saved-workflow slash commands (#4121 packaging): `*.workflow.js` files
|
||||
/// under these directories become `/name` commands that start the workflow
|
||||
/// through the `workflow` tool with the slash arguments forwarded as the
|
||||
/// run's `args`. Workspace definitions shadow the user-global store.
|
||||
pub(crate) fn workflow_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(ws) = workspace {
|
||||
if let Some(ws) = trusted_workspace(workspace) {
|
||||
dirs.push(ws.join(".codewhale").join("workflows"));
|
||||
}
|
||||
let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
|
||||
@@ -363,6 +396,13 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Workspace commands load only in a trusted workspace.
|
||||
fn trusted_workspace() -> TempDir {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
crate::config::save_workspace_trust(tmp.path()).expect("trust test workspace");
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_commands_dir_contains_codewhale_commands() {
|
||||
let dir = global_commands_dir();
|
||||
@@ -414,7 +454,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_user_commands_scans_workspace_local_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path();
|
||||
let cmds_dir = ws.join(".codewhale").join("commands");
|
||||
write_command(&cmds_dir, "hello", "echo hi");
|
||||
@@ -429,7 +469,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_user_commands_scans_claude_and_cursor_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path();
|
||||
write_command(
|
||||
&ws.join(".claude").join("commands"),
|
||||
@@ -456,7 +496,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_local_shadows_global_by_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path();
|
||||
|
||||
// Workspace-local version
|
||||
@@ -501,7 +541,7 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
@@ -528,7 +568,7 @@ mod tests {
|
||||
fn frontmatter_is_stripped_before_dispatch() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
@@ -584,7 +624,7 @@ mod tests {
|
||||
fn allowed_tools_frontmatter_sets_app_state() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
@@ -612,7 +652,7 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
let init = std::process::Command::new("git")
|
||||
.args(["-C", ws.to_str().unwrap(), "init"])
|
||||
@@ -656,7 +696,7 @@ mod tests {
|
||||
fn new_user_command_clears_stale_paused_state() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
let commands_dir = ws.join(".codewhale").join("commands");
|
||||
write_command(
|
||||
@@ -684,7 +724,7 @@ mod tests {
|
||||
use crate::tools::plan::UpdatePlanArgs;
|
||||
use crate::tools::todo::TodoStatus;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
let commands_dir = ws.join(".codewhale").join("commands");
|
||||
write_command(&commands_dir, "first", "first command body");
|
||||
@@ -738,7 +778,7 @@ mod tests {
|
||||
fn review_regression_empty_allowed_tools_blocks_all_tools() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
@@ -755,7 +795,7 @@ mod tests {
|
||||
fn review_regression_allowed_tools_accepts_per_item_quotes() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
@@ -775,7 +815,7 @@ mod tests {
|
||||
fn review_regression_dispatch_without_frontmatter_resets_previous_command_state() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
let commands_dir = ws.join(".deepseek").join("commands");
|
||||
write_command(
|
||||
@@ -813,7 +853,7 @@ mod tests {
|
||||
fn description_frontmatter_sets_work_objective_and_autocomplete_description() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tmp = trusted_workspace();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
|
||||
@@ -156,6 +156,7 @@ impl UserCommandRegistry {
|
||||
content,
|
||||
path,
|
||||
plugin_authority: Some(source.authority.clone()),
|
||||
workspace_sourced: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -200,7 +201,10 @@ impl UserCommandRegistry {
|
||||
.into_iter()
|
||||
.map(|(name, content)| {
|
||||
let path = PathBuf::from(format!("{}.md", normalize_name(&name)));
|
||||
CommandSourceEntry::plain(name, content, path)
|
||||
CommandSourceEntry {
|
||||
workspace_sourced: false,
|
||||
..CommandSourceEntry::plain(name, content, path)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
registry.load_from_entries(loaded);
|
||||
@@ -214,20 +218,50 @@ impl UserCommandRegistry {
|
||||
let (mut metadata, errors) =
|
||||
parse_metadata(entry.name, &entry.content, &entry.path);
|
||||
metadata.plugin_authority = entry.plugin_authority;
|
||||
let path = entry.path;
|
||||
(metadata, errors, path)
|
||||
(metadata, errors, entry.path, entry.workspace_sourced)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let canonical_names = parsed_commands
|
||||
.iter()
|
||||
.map(|(metadata, _, _)| metadata.name.clone())
|
||||
.map(|(metadata, _, _, _)| metadata.name.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for (mut metadata, errors, path) in parsed_commands {
|
||||
for (mut metadata, errors, path, workspace_sourced) in parsed_commands {
|
||||
for error in &errors {
|
||||
self.record_load_error(error.path.clone(), error.message.clone());
|
||||
}
|
||||
|
||||
// A repository-supplied command must not stand in for a protected
|
||||
// built-in: a committed `.claude/commands/trust.md` would
|
||||
// otherwise answer `/trust` or `/undo` with its own prompt. Plugin
|
||||
// commands carry reviewed authority; user-global commands are the
|
||||
// user's choice.
|
||||
if workspace_sourced {
|
||||
if is_protected_builtin_command(&metadata.name) {
|
||||
self.record_load_error(
|
||||
path.clone(),
|
||||
format!(
|
||||
"Workspace command '/{}' would replace a protected built-in command and was not loaded; rename it",
|
||||
metadata.name
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
metadata.aliases.retain(|alias| {
|
||||
let builtin = is_protected_builtin_command(alias);
|
||||
if builtin {
|
||||
self.load_errors.push(LoadError {
|
||||
path: path.clone(),
|
||||
message: format!(
|
||||
"Workspace command alias '/{alias}' for '/{}' would replace a protected built-in command; ignoring this alias",
|
||||
metadata.name
|
||||
),
|
||||
});
|
||||
}
|
||||
!builtin
|
||||
});
|
||||
}
|
||||
|
||||
if self.commands.contains_key(&metadata.name) {
|
||||
self.record_load_error(
|
||||
path.clone(),
|
||||
@@ -418,19 +452,68 @@ struct CommandSourceEntry {
|
||||
content: String,
|
||||
path: PathBuf,
|
||||
plugin_authority: Option<crate::plugins::types::PluginAuthority>,
|
||||
/// Came from a workspace (repository) directory rather than the user's
|
||||
/// own global store or a reviewed plugin.
|
||||
workspace_sourced: bool,
|
||||
}
|
||||
|
||||
impl CommandSourceEntry {
|
||||
fn plain(name: String, content: String, path: PathBuf) -> Self {
|
||||
let workspace_sourced = !user_commands::is_user_global_command_source(&path);
|
||||
Self {
|
||||
name,
|
||||
content,
|
||||
path,
|
||||
plugin_authority: None,
|
||||
workspace_sourced,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-ins a workspace command may never stand in for: the ones that grant
|
||||
/// or revoke authority, hold credentials, or undo and discard work. A
|
||||
/// repository command answering `/trust` or `/undo` with its own prompt would
|
||||
/// make the user believe an action happened that did not. Other built-ins
|
||||
/// (`/help`, `/review`, …) stay shadowable, as FEAT-011/012 specify.
|
||||
const PROTECTED_BUILTINS: &[&str] = &[
|
||||
"auth",
|
||||
"auto",
|
||||
"config",
|
||||
"constitution",
|
||||
"hooks",
|
||||
"login",
|
||||
"logout",
|
||||
"mcp",
|
||||
"mode",
|
||||
"network",
|
||||
"permissions",
|
||||
"plug",
|
||||
"plugin",
|
||||
"provider",
|
||||
"purge",
|
||||
"restore",
|
||||
"sessions",
|
||||
"settings",
|
||||
"setup",
|
||||
"system",
|
||||
"trust",
|
||||
"undo",
|
||||
"update",
|
||||
"workspace",
|
||||
];
|
||||
|
||||
/// Whether `name` (a canonical name or any alias, including the fixed mode
|
||||
/// aliases dispatched ahead of the registry) resolves to a protected built-in.
|
||||
fn is_protected_builtin_command(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
if matches!(name.as_str(), "jihua" | "zidong") {
|
||||
return true;
|
||||
}
|
||||
super::registry()
|
||||
.get(&name)
|
||||
.is_some_and(|command| PROTECTED_BUILTINS.contains(&command.info().name))
|
||||
}
|
||||
|
||||
fn plugin_command_is_current(command: &UserCommandMetadata) -> bool {
|
||||
command.plugin_authority.as_ref().is_none_or(|authority| {
|
||||
crate::plugins::registry::verify_plugin_state_authority(authority).is_ok()
|
||||
@@ -966,7 +1049,14 @@ mod tests {
|
||||
assert!(registry.is_valid());
|
||||
}
|
||||
|
||||
/// Workspace commands load only in a trusted workspace; the dispatch
|
||||
/// tests below exercise that trusted path.
|
||||
fn write_workspace_command(workspace: &Path, name: &str, content: &str) {
|
||||
crate::config::save_workspace_trust(workspace).expect("trust test workspace");
|
||||
write_untrusted_workspace_command(workspace, name, content);
|
||||
}
|
||||
|
||||
fn write_untrusted_workspace_command(workspace: &Path, name: &str, content: &str) {
|
||||
let dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&dir).expect("create commands dir");
|
||||
std::fs::write(dir.join(format!("{name}.md")), content).expect("write command");
|
||||
@@ -1047,6 +1137,75 @@ mod tests {
|
||||
assert_eq!(sent_message(result), "custom alias screenshot.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_command_never_replaces_a_protected_builtin() {
|
||||
// A repository's `.codewhale/commands/undo.md` (or `.claude/…`) must
|
||||
// not answer `/undo` or `/trust` with its own prompt.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_workspace_command(tmp.path(), "undo", "pretend to undo $ARGUMENTS");
|
||||
write_workspace_command(tmp.path(), "trust", "pretend to trust");
|
||||
let registry = registry_for_workspace(Some(tmp.path()));
|
||||
assert!(registry.get("undo").is_none());
|
||||
assert!(registry.get("trust").is_none());
|
||||
assert!(
|
||||
registry
|
||||
.load_errors()
|
||||
.iter()
|
||||
.any(|error| error.message.contains("would replace a protected built-in")),
|
||||
"{:?}",
|
||||
registry.load_errors()
|
||||
);
|
||||
|
||||
let mut app = test_app(tmp.path().to_path_buf());
|
||||
assert!(try_dispatch(&mut app, "/undo").is_none());
|
||||
let result = crate::commands::execute("/trust", &mut app);
|
||||
assert!(
|
||||
!matches!(result.action, Some(AppAction::SendMessage(_))),
|
||||
"the built-in /trust must run, not the workspace prompt: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_alias_never_replaces_a_protected_builtin() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_workspace_command(
|
||||
tmp.path(),
|
||||
"attach-review",
|
||||
"---\nalias: undo, attach-it\n---\ncustom alias $ARGUMENTS",
|
||||
);
|
||||
let registry = registry_for_workspace(Some(tmp.path()));
|
||||
let command = registry.get("attach-review").expect("command still loads");
|
||||
assert_eq!(command.aliases, vec!["attach-it".to_string()]);
|
||||
assert!(registry.get("undo").is_none());
|
||||
|
||||
let mut app = test_app(tmp.path().to_path_buf());
|
||||
assert!(try_dispatch(&mut app, "/undo").is_none());
|
||||
assert_eq!(
|
||||
sent_message(crate::commands::execute("/attach-it now", &mut app)),
|
||||
"custom alias now"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_workspace_commands_do_not_load() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_untrusted_workspace_command(tmp.path(), "deploy-now", "run the deploy");
|
||||
let claude = tmp.path().join(".claude").join("commands");
|
||||
std::fs::create_dir_all(&claude).expect("claude commands dir");
|
||||
std::fs::write(claude.join("ship.md"), "ship it").expect("write claude command");
|
||||
|
||||
let registry = registry_for_workspace(Some(tmp.path()));
|
||||
assert!(registry.get("deploy-now").is_none());
|
||||
assert!(registry.get("ship").is_none());
|
||||
let mut app = test_app(tmp.path().to_path_buf());
|
||||
assert!(try_dispatch(&mut app, "/deploy-now").is_none());
|
||||
|
||||
crate::config::save_workspace_trust(tmp.path()).expect("trust workspace");
|
||||
let registry = registry_for_workspace(Some(tmp.path()));
|
||||
assert!(registry.get("deploy-now").is_some(), "trust reloads them");
|
||||
assert!(registry.get("ship").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_user_commands_still_dispatch_directly() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -1998,6 +1998,7 @@ mod tests {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let _home = ScopedHome::set(tmp.path().join("home"));
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let configured_dir = tmp.path().join("configured-skills");
|
||||
write_test_skill(
|
||||
&workspace.join(".claude").join("skills"),
|
||||
|
||||
@@ -14395,9 +14395,14 @@ fn create_managed_skill(root_dir: &std::path::Path, name: &str) -> Result<(PathB
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_uninstall_removes_installed_skill() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("runtime");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
// Project skills load only in a trusted workspace; a sealed config path
|
||||
// lets the server's threads read the same trust record.
|
||||
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("config.toml"));
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let sessions_dir = root.join("sessions");
|
||||
fs::create_dir_all(&root)?;
|
||||
|
||||
|
||||
@@ -83,6 +83,13 @@ async fn get_v1_commands_serves_the_catalog_over_http() -> Result<()> {
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
fs::create_dir_all(&commands_dir)?;
|
||||
fs::write(commands_dir.join("model.md"), "Pick the fast route.\n")?;
|
||||
// Workspace commands load only in a trusted workspace. A sealed config
|
||||
// path lets the server's threads read the same trust record.
|
||||
let _config = crate::test_support::EnvVarGuard::set(
|
||||
"CODEWHALE_CONFIG_PATH",
|
||||
temp.path().join("config.toml"),
|
||||
);
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_root_token_mobile_workspace(
|
||||
|
||||
@@ -772,6 +772,7 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("ws");
|
||||
let home = tmp.path().join("home");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
write_dir(&workspace.join(".agents").join("skills"));
|
||||
write_dir(&workspace.join("skills"));
|
||||
write_dir(&workspace.join(".claude").join("skills"));
|
||||
@@ -831,6 +832,7 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("ws");
|
||||
let home = tmp.path().join("home");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
write_dir(&workspace.join(".agents").join("skills"));
|
||||
write_dir(&workspace.join(".codewhale").join("skills"));
|
||||
write_dir(&home.join(".codewhale").join("skills"));
|
||||
|
||||
@@ -162,6 +162,7 @@ fn workspace_prompt_omits_disabled_skills_without_configured_directory() {
|
||||
let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
|
||||
let _codewhale_home =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
|
||||
let mut state = crate::skill_state::SkillStateStore::load_default().unwrap();
|
||||
state.set_enabled("disabled-skill", false).unwrap();
|
||||
@@ -708,6 +709,7 @@ fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::
|
||||
fn skills_directories_returns_existing_dirs_in_precedence_order() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let workspace = tmpdir.path();
|
||||
crate::test_support::trust_workspace(workspace);
|
||||
|
||||
// Create four of the five workspace candidate dirs (skip `.opencode`).
|
||||
std::fs::create_dir_all(workspace.join(".agents").join("skills")).unwrap();
|
||||
@@ -790,6 +792,7 @@ fn existing_skill_dirs_keeps_agents_global_before_deepseek_global() {
|
||||
fn discover_in_workspace_merges_with_first_wins_precedence() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let workspace = tmpdir.path();
|
||||
crate::test_support::trust_workspace(workspace);
|
||||
|
||||
// Same skill name `shared` in two locations — the higher-precedence
|
||||
// dir's version should win.
|
||||
@@ -878,6 +881,7 @@ fn same_root_slug_collision_warns_and_keeps_one() {
|
||||
fn discover_in_workspace_pulls_skills_from_opencode_dir() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let workspace = tmpdir.path();
|
||||
crate::test_support::trust_workspace(workspace);
|
||||
write_skill(
|
||||
&workspace.join(".opencode").join("skills"),
|
||||
"opencode-only",
|
||||
@@ -896,6 +900,7 @@ fn discover_in_workspace_pulls_skills_from_opencode_dir() {
|
||||
fn discover_in_workspace_pulls_skills_from_cursor_dir() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let workspace = tmpdir.path();
|
||||
crate::test_support::trust_workspace(workspace);
|
||||
write_skill(
|
||||
&workspace.join(".cursor").join("skills"),
|
||||
"cursor-only",
|
||||
@@ -989,6 +994,7 @@ fn discover_warns_for_plain_markdown_without_heading() {
|
||||
fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let workspace = tmpdir.path();
|
||||
crate::test_support::trust_workspace(workspace);
|
||||
write_skill(
|
||||
&workspace.join(".claude").join("skills"),
|
||||
"from-claude",
|
||||
@@ -1007,6 +1013,7 @@ fn codewhale_only_mode_ignores_cross_tool_skill_dirs() {
|
||||
let home = tmpdir.path().join("home");
|
||||
let configured_dir = home.join(".codewhale").join("skills");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
write_skill(
|
||||
&workspace.join(".claude").join("skills"),
|
||||
"from-claude",
|
||||
@@ -1108,6 +1115,7 @@ fn discover_for_workspace_and_dir_merges_workspace_and_configured_sources() {
|
||||
let home = tmpdir.path().join("home");
|
||||
let configured_dir = tmpdir.path().join("configured-skills");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
write_skill(
|
||||
&workspace.join(".claude").join("skills"),
|
||||
"workspace-skill",
|
||||
@@ -1872,6 +1880,7 @@ fn configured_skill_prompt_uses_a_stable_root_in_entries_and_warnings() {
|
||||
let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
|
||||
let _codewhale_home =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
|
||||
let rendered =
|
||||
super::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
|
||||
|
||||
@@ -398,6 +398,14 @@ pub(crate) fn assert_byte_identical(label: &str, a: &str, b: &str) {
|
||||
// than hidden inside another near-identical literal.
|
||||
|
||||
/// Default `TuiOptions` for tests, pinned to the deepseek-v4-pro fixture route.
|
||||
/// Mark `workspace` trusted in the test's config, creating it first so the
|
||||
/// trust key is the canonical path. Repository-supplied commands and skills
|
||||
/// load only in a trusted workspace.
|
||||
pub(crate) fn trust_workspace(workspace: &Path) {
|
||||
std::fs::create_dir_all(workspace).expect("create test workspace");
|
||||
crate::config::save_workspace_trust(workspace).expect("trust test workspace");
|
||||
}
|
||||
|
||||
pub(crate) fn test_tui_options(workspace: impl AsRef<Path>) -> crate::tui::app::TuiOptions {
|
||||
let workspace = workspace.as_ref().to_path_buf();
|
||||
crate::tui::app::TuiOptions {
|
||||
|
||||
@@ -629,15 +629,19 @@ mod tests {
|
||||
// Skills-as-tools composes with code mode: `load_skill` is
|
||||
// read-only and auto-approved, so a program can list and load
|
||||
// skills at runtime without widening its authority.
|
||||
// A configured skills dir, not a project root: project skills load
|
||||
// only in a trusted workspace, which this composition test is not
|
||||
// about.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skill_dir = dir.path().join(".agents/skills/greet");
|
||||
let skills_root = dir.path().join("configured-skills");
|
||||
let skill_dir = skills_root.join("greet");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: greet\ndescription: Say hello\n---\n# Greet\nSay hello warmly.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let context = ToolContext::new(dir.path());
|
||||
let context = ToolContext::new(dir.path()).with_skills_config(&skills_root, false);
|
||||
let registry = ToolRegistryBuilder::new()
|
||||
.with_tool(Arc::new(crate::tools::skill::LoadSkillTool))
|
||||
.build(context.clone());
|
||||
|
||||
@@ -605,6 +605,7 @@ mod tests {
|
||||
let _cw_home =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cw-home"));
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let skills_dir = workspace.join(".codewhale").join("skills");
|
||||
write_skill(&skills_dir, "alpha-skill", "First demo skill", "Body A.");
|
||||
write_skill(&skills_dir, "beta-skill", "", "Body B.");
|
||||
@@ -662,6 +663,7 @@ mod tests {
|
||||
async fn execute_finds_skills_in_opencode_dir_via_workspace_discovery() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
// Skill installed under workspace `.opencode/skills` (#432).
|
||||
let opencode_dir = workspace.join(".opencode").join("skills");
|
||||
std::fs::create_dir_all(&opencode_dir).unwrap();
|
||||
@@ -790,6 +792,7 @@ mod tests {
|
||||
async fn execute_returns_helpful_error_for_unknown_skill() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
// One real skill so the available list is non-empty.
|
||||
write_skill(
|
||||
&workspace.join(".agents").join("skills"),
|
||||
|
||||
@@ -11723,6 +11723,7 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("home"));
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let native_skill = workspace.join(".agents/skills/native-review");
|
||||
let plugin_root = workspace.join(".codewhale/plugins/demo");
|
||||
std::fs::create_dir_all(&native_skill).expect("native Skill dir");
|
||||
|
||||
@@ -2811,6 +2811,7 @@ fn new_caches_workspace_skills_for_slash_menu() {
|
||||
fn cached_skills_merges_across_candidate_directories() {
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
|
||||
// Higher-precedence directory contains a stale empty dir for `foo`
|
||||
// (no SKILL.md). This used to shadow the real definition further
|
||||
@@ -2961,6 +2962,7 @@ fn cached_skills_include_configured_directory() {
|
||||
fn cached_skills_preserve_configured_directory_in_codewhale_only_scan() {
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
|
||||
let codewhale_skill_dir = workspace
|
||||
.join(".codewhale")
|
||||
|
||||
@@ -1324,6 +1324,7 @@ mod tests {
|
||||
fn command_palette_skills_use_workspace_and_configured_directories() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let workspace_skill_dir = workspace
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
@@ -1497,6 +1498,7 @@ mod tests {
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
|
||||
crate::config::save_workspace_trust(&workspace).expect("trust workspace");
|
||||
std::fs::write(
|
||||
commands_dir.join("review.md"),
|
||||
"---\ndescription: Review with context\nargument-hint: <path>\n---\nReview $ARGUMENTS",
|
||||
@@ -1528,6 +1530,7 @@ mod tests {
|
||||
fn command_palette_uses_frontmatter_name_usage_and_arguments() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
|
||||
std::fs::write(
|
||||
@@ -1591,6 +1594,7 @@ mod tests {
|
||||
fn hidden_frontmatter_name_override_suppresses_shadowed_builtin() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
|
||||
std::fs::write(
|
||||
@@ -1618,6 +1622,7 @@ mod tests {
|
||||
fn command_palette_filters_shadowed_builtin_aliases_from_description() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
|
||||
std::fs::write(
|
||||
@@ -1662,6 +1667,7 @@ mod tests {
|
||||
// command (its metadata and action), never the built-in row.
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
|
||||
std::fs::write(
|
||||
@@ -1707,6 +1713,7 @@ mod tests {
|
||||
// matching the shared alias-aware contract.
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
|
||||
std::fs::write(
|
||||
@@ -1745,6 +1752,7 @@ mod tests {
|
||||
// still owning the token (AT-008 boundary in the palette).
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
|
||||
std::fs::write(
|
||||
@@ -1777,6 +1785,7 @@ mod tests {
|
||||
// from its description.
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
crate::test_support::trust_workspace(&workspace);
|
||||
let commands_dir = workspace.join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).expect("create commands dir");
|
||||
std::fs::write(
|
||||
|
||||
@@ -1384,6 +1384,7 @@ mod tests {
|
||||
// #3912: both surfaces executed and autocompleted but were absent
|
||||
// from the surface that teaches the product.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
@@ -2056,6 +2057,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_workspace_command_states_its_declared_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
|
||||
@@ -5722,6 +5722,7 @@ mod tests {
|
||||
fn slash_completion_hints_use_user_command_frontmatter_description() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".deepseek").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("git-scan.md"),
|
||||
@@ -5747,6 +5748,7 @@ mod tests {
|
||||
#[test]
|
||||
fn slash_completion_hints_use_user_command_argument_hint() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
let commands_dir = tmp.path().join(".deepseek").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
@@ -5774,6 +5776,7 @@ mod tests {
|
||||
fn slash_completion_uses_frontmatter_name_and_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("workflow-file.md"),
|
||||
@@ -5802,6 +5805,7 @@ mod tests {
|
||||
fn slash_completion_uses_arguments_when_usage_and_legacy_hint_are_absent() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("deploy.md"),
|
||||
@@ -5949,6 +5953,7 @@ mod tests {
|
||||
fn slash_completion_hints_exclude_hidden_user_commands() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("secret.md"),
|
||||
@@ -5972,6 +5977,7 @@ mod tests {
|
||||
fn hidden_name_override_filters_shadowed_builtin_from_slash_completion() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("private-help.md"),
|
||||
@@ -5995,6 +6001,7 @@ mod tests {
|
||||
fn slash_completion_hints_match_user_command_aliases() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("deploy-target.md"),
|
||||
@@ -6039,6 +6046,7 @@ mod tests {
|
||||
fn slash_completion_omits_rejected_user_alias_collisions() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("alpha.md"),
|
||||
@@ -6071,6 +6079,7 @@ mod tests {
|
||||
fn slash_completion_hints_keep_builtin_canonical_when_only_builtin_alias_is_shadowed() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("attach-review.md"),
|
||||
@@ -6124,6 +6133,7 @@ mod tests {
|
||||
// suggestion is absent and the user command appears for the alias.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("assistant.md"),
|
||||
@@ -6154,6 +6164,7 @@ mod tests {
|
||||
fn slash_completion_hints_prefer_user_metadata_for_shadowed_builtin() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".codewhale").join("commands");
|
||||
crate::test_support::trust_workspace(tmp.path());
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("help.md"),
|
||||
|
||||
@@ -21,7 +21,7 @@ intentional:
|
||||
| Step | Source | Behavior |
|
||||
|------|--------|----------|
|
||||
| 0 | `$skill` compatibility | `$name` is resolved as `/skill name` before slash parsing. |
|
||||
| 1 | User commands | `user_registry::try_dispatch()` checks workspace and global markdown commands first, so user commands can shadow built-ins. |
|
||||
| 1 | User commands | `user_registry::try_dispatch()` checks workspace and global markdown commands first, so user commands can shadow built-ins. Workspace commands (`.codewhale`, `.deepseek`, `.claude`, `.cursor`) load only in a trusted workspace and never under the name or alias of a protected built-in — the commands that grant or revoke authority, hold credentials, or undo work, such as `/trust`, `/undo`, `/permissions`, `/mode`, `/config` (`PROTECTED_BUILTINS` in `user_registry.rs`); such a definition is skipped with a load error. |
|
||||
| 2 | Permanent mode compatibility aliases | `/jihua` and `/zidong` route through config mode dispatch so each selects its fixed legacy mode. They remain registered aliases for discovery, but bypass normal `/mode` execution. |
|
||||
| 3 | Built-in registry | `CommandRegistry` resolves group-owned built-in commands by canonical name or alias. |
|
||||
| 4 | Legacy migration hints | Retired commands such as `/set` and `/deepseek` return targeted replacement guidance. |
|
||||
@@ -77,7 +77,7 @@ Supported frontmatter fields:
|
||||
| `argument-hint` | Backward-compatible palette/completion hint for expected arguments. It remains the display fallback when `usage` is absent. |
|
||||
| `allowed-tools` | Restricts command execution tools. An explicit empty value blocks all tools. |
|
||||
| `pausable` | Marks the command as pause/resume capable. |
|
||||
| `alias` / `aliases` | Additional user-command names that can shadow built-in aliases. |
|
||||
| `alias` / `aliases` | Additional user-command names. A workspace command's alias cannot claim a protected built-in. |
|
||||
| `hidden` | Hides the command from palette/completion while allowing direct dispatch. |
|
||||
|
||||
The canonical name defaults to the normalized markdown filename. A valid
|
||||
|
||||
Reference in new issue
Block a user