feat: add --no-workspace-macros opt-out for workspace macro loading

Mirrors --no-workspace-mcp exactly: a CLI-only flag backed by an
AppConfig field (default false) that disables .coyote/macros in both
the resolved macro policy and Macro::load's workspace-then-global
preference, so the two always agree (custom-commands design §5).
This commit is contained in:
2026-08-21 11:55:13 -06:00
parent 5478c5a239
commit 4323d4823c
6 changed files with 111 additions and 4 deletions
+3
View File
@@ -96,6 +96,9 @@ pub struct Cli {
/// Disable loading workspace MCP servers from .coyote/mcp.json, .coyote/.mcp.json, or .mcp.json /// Disable loading workspace MCP servers from .coyote/mcp.json, .coyote/.mcp.json, or .mcp.json
#[arg(long)] #[arg(long)]
pub no_workspace_mcp: bool, pub no_workspace_mcp: bool,
/// Disable loading workspace macros from .coyote/macros
#[arg(long)]
pub no_workspace_macros: bool,
/// Disable memory for this invocation /// Disable memory for this invocation
#[arg(long)] #[arg(long)]
pub no_memory: bool, pub no_memory: bool,
+3
View File
@@ -98,6 +98,7 @@ pub struct AppConfig {
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub save_shell_history: bool, pub save_shell_history: bool,
pub no_workspace_mcp: bool, pub no_workspace_mcp: bool,
pub no_workspace_macros: bool,
pub sync_models_url: Option<String>, pub sync_models_url: Option<String>,
pub clients: Vec<ClientConfig>, pub clients: Vec<ClientConfig>,
@@ -181,6 +182,7 @@ impl Default for AppConfig {
user_agent: None, user_agent: None,
save_shell_history: true, save_shell_history: true,
no_workspace_mcp: false, no_workspace_mcp: false,
no_workspace_macros: false,
sync_models_url: None, sync_models_url: None,
clients: vec![], clients: vec![],
@@ -266,6 +268,7 @@ impl AppConfig {
user_agent: config.user_agent, user_agent: config.user_agent,
save_shell_history: config.save_shell_history, save_shell_history: config.save_shell_history,
no_workspace_mcp: false, no_workspace_mcp: false,
no_workspace_macros: false,
sync_models_url: config.sync_models_url, sync_models_url: config.sync_models_url,
clients: config.clients, clients: config.clients,
+33
View File
@@ -855,6 +855,39 @@ mod tests {
}); });
} }
#[test]
#[serial]
fn effective_honors_no_workspace_macros() {
with_macro_dirs(|workspace_root, global| {
let macros_subdir = workspace_root.join("macros");
fs::create_dir_all(&macros_subdir).unwrap();
write_macro(&macros_subdir, "shared", VALID_YAML);
write_macro(&macros_subdir, "ws-only", VALID_YAML);
write_macro(global, "shared", VALID_YAML);
write_macro(global, "global-only", VALID_YAML);
with_macro_dir_envs(workspace_root, global, || {
let config = AppConfig::default();
let policy = MacroPolicy::effective(&config, None, None, None, &[], false);
assert_eq!(
policy.find("shared").unwrap().source,
Some(MacroSource::Workspace)
);
assert!(policy.find("ws-only").is_some());
assert!(policy.find("global-only").is_some());
let policy = MacroPolicy::effective(&config, None, None, None, &[], true);
assert_eq!(
policy.find("shared").unwrap().source,
Some(MacroSource::Global)
);
assert!(policy.find("ws-only").is_none());
assert!(policy.find("global-only").is_some());
});
});
}
#[test] #[test]
#[serial] #[serial]
fn effective_resolves_role_session_and_global_levels() { fn effective_resolves_role_session_and_global_levels() {
+68 -3
View File
@@ -25,7 +25,7 @@ pub async fn macro_execute(
if ctx.in_non_isolated_macro() { if ctx.in_non_isolated_macro() {
bail!("nested macros not allowed in non-isolated mode"); bail!("nested macros not allowed in non-isolated mode");
} }
let macro_value = Macro::load(name)?; let macro_value = Macro::load(name, ctx.app.config.no_workspace_macros)?;
let (mut new_args, text) = split_args_text(args.unwrap_or_default(), cfg!(windows)); let (mut new_args, text) = split_args_text(args.unwrap_or_default(), cfg!(windows));
if !text.is_empty() { if !text.is_empty() {
new_args.push(text.to_string()); new_args.push(text.to_string());
@@ -140,9 +140,9 @@ pub struct Macro {
} }
impl Macro { impl Macro {
pub fn load(name: &str) -> Result<Macro> { pub fn load(name: &str, no_workspace_macros: bool) -> Result<Macro> {
let workspace_path = paths::workspace_macros_dir().join(format!("{name}.yaml")); let workspace_path = paths::workspace_macros_dir().join(format!("{name}.yaml"));
let path = if workspace_path.exists() { let path = if !no_workspace_macros && workspace_path.exists() {
workspace_path workspace_path
} else { } else {
paths::macro_file(name) paths::macro_file(name)
@@ -307,6 +307,71 @@ mod tests {
write(&path, content).unwrap(); write(&path, content).unwrap();
} }
/// Sets up a temp workspace macros dir and a temp global macros dir, each
/// containing a `shared` macro whose `description` names its source, and
/// points the workspace/global dir env overrides at them for `f`.
fn with_macro_load_envs<F: FnOnce()>(f: F) {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-macro-load-tests-{unique}"));
let workspace_root = root.join("workspace");
let workspace_macros = workspace_root.join("macros");
let global = root.join("global");
create_dir_all(&workspace_macros).unwrap();
create_dir_all(&global).unwrap();
write(
workspace_macros.join("shared.yaml"),
"description: workspace\nsteps:\n - \".help\"\n",
)
.unwrap();
write(
global.join("shared.yaml"),
"description: global\nsteps:\n - \".help\"\n",
)
.unwrap();
let ws_env = get_env_name("workspace_config_dir");
let global_env = get_env_name("macros_dir");
let prev_ws = env::var_os(&ws_env);
let prev_global = env::var_os(&global_env);
unsafe {
env::set_var(&ws_env, &workspace_root);
env::set_var(&global_env, &global);
}
f();
unsafe {
match prev_ws {
Some(v) => env::set_var(&ws_env, v),
None => env::remove_var(&ws_env),
}
match prev_global {
Some(v) => env::set_var(&global_env, v),
None => env::remove_var(&global_env),
}
}
let _ = remove_dir_all(&root);
}
#[test]
#[serial]
fn load_prefers_workspace_over_global_by_default() {
with_macro_load_envs(|| {
let loaded = Macro::load("shared", false).unwrap();
assert_eq!(loaded.description.as_deref(), Some("workspace"));
});
}
#[test]
#[serial]
fn load_skips_workspace_when_no_workspace_macros() {
with_macro_load_envs(|| {
let loaded = Macro::load("shared", true).unwrap();
assert_eq!(loaded.description.as_deref(), Some("global"));
});
}
/// Drives a macro-execution future to completion on a thread with extra /// Drives a macro-execution future to completion on a thread with extra
/// stack headroom: nested `run_repl_command` poll frames are deep in /// stack headroom: nested `run_repl_command` poll frames are deep in
/// debug builds and overflow the 2 MiB default test-thread stack. /// debug builds and overflow the 2 MiB default test-thread stack.
+1 -1
View File
@@ -2505,7 +2505,7 @@ impl RequestContext {
self.agent.as_ref(), self.agent.as_ref(),
self.session.as_ref(), self.session.as_ref(),
&crate::repl::builtin_command_names(), &crate::repl::builtin_command_names(),
false, self.app.config.no_workspace_macros,
) )
} }
+3
View File
@@ -222,6 +222,9 @@ async fn main() -> Result<()> {
if cli.no_workspace_mcp { if cli.no_workspace_mcp {
app_config.no_workspace_mcp = true; app_config.no_workspace_mcp = true;
} }
if cli.no_workspace_macros {
app_config.no_workspace_macros = true;
}
let app_config: Arc<AppConfig> = Arc::new(app_config); let app_config: Arc<AppConfig> = Arc::new(app_config);
let app_state: Arc<AppState> = Arc::new( let app_state: Arc<AppState> = Arc::new(
AppState::init( AppState::init(