From 4323d4823c21c91526f13b988f9a1b2c17e007a4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 16:38:00 -0600 Subject: [PATCH] feat: add --no-workspace-macros opt-out for workspace macro loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/cli/mod.rs | 3 ++ src/config/app_config.rs | 3 ++ src/config/macro_policy.rs | 33 ++++++++++++++++ src/config/macros.rs | 71 +++++++++++++++++++++++++++++++++-- src/config/request_context.rs | 2 +- src/main.rs | 3 ++ 6 files changed, 111 insertions(+), 4 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9f86023..9c4394d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -96,6 +96,9 @@ pub struct Cli { /// Disable loading workspace MCP servers from .coyote/mcp.json, .coyote/.mcp.json, or .mcp.json #[arg(long)] pub no_workspace_mcp: bool, + /// Disable loading workspace macros from .coyote/macros + #[arg(long)] + pub no_workspace_macros: bool, /// Disable memory for this invocation #[arg(long)] pub no_memory: bool, diff --git a/src/config/app_config.rs b/src/config/app_config.rs index 51a1855..5dd6b2d 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -98,6 +98,7 @@ pub struct AppConfig { pub user_agent: Option, pub save_shell_history: bool, pub no_workspace_mcp: bool, + pub no_workspace_macros: bool, pub sync_models_url: Option, pub clients: Vec, @@ -181,6 +182,7 @@ impl Default for AppConfig { user_agent: None, save_shell_history: true, no_workspace_mcp: false, + no_workspace_macros: false, sync_models_url: None, clients: vec![], @@ -266,6 +268,7 @@ impl AppConfig { user_agent: config.user_agent, save_shell_history: config.save_shell_history, no_workspace_mcp: false, + no_workspace_macros: false, sync_models_url: config.sync_models_url, clients: config.clients, diff --git a/src/config/macro_policy.rs b/src/config/macro_policy.rs index 93f4c10..8dba677 100644 --- a/src/config/macro_policy.rs +++ b/src/config/macro_policy.rs @@ -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(¯os_subdir).unwrap(); + write_macro(¯os_subdir, "shared", VALID_YAML); + write_macro(¯os_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] #[serial] fn effective_resolves_role_session_and_global_levels() { diff --git a/src/config/macros.rs b/src/config/macros.rs index ea981fc..667a020 100644 --- a/src/config/macros.rs +++ b/src/config/macros.rs @@ -25,7 +25,7 @@ pub async fn macro_execute( if ctx.in_non_isolated_macro() { 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)); if !text.is_empty() { new_args.push(text.to_string()); @@ -140,9 +140,9 @@ pub struct Macro { } impl Macro { - pub fn load(name: &str) -> Result { + pub fn load(name: &str, no_workspace_macros: bool) -> Result { 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 } else { paths::macro_file(name) @@ -307,6 +307,71 @@ mod tests { 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: 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 /// stack headroom: nested `run_repl_command` poll frames are deep in /// debug builds and overflow the 2 MiB default test-thread stack. diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 67b6ffa..4762e9c 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -2505,7 +2505,7 @@ impl RequestContext { self.agent.as_ref(), self.session.as_ref(), &crate::repl::builtin_command_names(), - false, + self.app.config.no_workspace_macros, ) } diff --git a/src/main.rs b/src/main.rs index 937d255..68be6c7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -222,6 +222,9 @@ async fn main() -> Result<()> { if cli.no_workspace_mcp { app_config.no_workspace_mcp = true; } + if cli.no_workspace_macros { + app_config.no_workspace_macros = true; + } let app_config: Arc = Arc::new(app_config); let app_state: Arc = Arc::new( AppState::init(