From e94bd450cd69333d5a3ad13d696f053e0176bc16 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 16:07:34 -0600 Subject: [PATCH] feat: surface macros as first-class custom commands in the REPL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the invocation and management surfaces from plans/custom-commands-design.md §4 and §6: - Top-level dispatch: an enabled macro now runs as ". [args]" from the command catch-all; runtime-disabled macros point at ".macro enable ", locked macros name the owning config, and unknown commands keep the existing error verbatim - .macro enable|disable : runtime toggles over the in-memory global-level enabled_macros list (disable with no list materializes all-active-minus-name); toggles error when a role/agent/session allowlist owns the field - .set enabled_macros with workspace-then-global existence validation; .set key completion gains enabled_macros and the previously missing enabled_skills - Dynamic completion: enabled macros (with descriptions) join built-ins on "." without touching the static command registry; ".macro " lists invocable macros (incl. built-in-shadowed ones) plus the enable/disable subcommands; second-arg completion offers toggle-eligible names - .list macros: enriched table (name, source, isolated, state, description) covering every resolver state incl. missing and shadowed rows; .help gains a custom-commands section - Session info/render and sysinfo display enabled_macros; Macro::load resolves workspace-then-global; enable/disable rejected as macro names in the creator --- src/config/macro_policy.rs | 4 - src/config/macros.rs | 7 +- src/config/mod.rs | 4 +- src/config/paths.rs | 5 - src/config/request_context.rs | 445 +++++++++++++++++++++++++++++++--- src/config/session.rs | 7 + src/repl/completer.rs | 19 +- src/repl/mod.rs | 99 +++++++- 8 files changed, 537 insertions(+), 53 deletions(-) diff --git a/src/config/macro_policy.rs b/src/config/macro_policy.rs index bee02e6..93f4c10 100644 --- a/src/config/macro_policy.rs +++ b/src/config/macro_policy.rs @@ -75,7 +75,6 @@ pub enum MacroState { } impl MacroState { - #[allow(dead_code)] pub fn is_invocable(&self) -> bool { matches!(self, MacroState::Enabled | MacroState::ShadowedBuiltin) } @@ -95,7 +94,6 @@ pub struct DiscoveredMacro { /// One row of the resolved macro set. Missing allowlist entries produce rows /// with `source: None`. #[derive(Debug, Clone)] -#[allow(dead_code)] pub struct ResolvedMacro { pub name: String, pub source: Option, @@ -116,7 +114,6 @@ pub struct MacroPolicy { } impl MacroPolicy { - #[allow(dead_code)] pub fn effective( global: &AppConfig, role: Option<&Role>, @@ -199,7 +196,6 @@ impl MacroPolicy { /// The invocation target for `name`: the workspace entry when one shadows /// a global entry, otherwise the single discovered entry. Missing rows /// are never returned. - #[allow(dead_code)] pub fn find(&self, name: &str) -> Option<&ResolvedMacro> { self.macros .iter() diff --git a/src/config/macros.rs b/src/config/macros.rs index 3a1ceed..bedceb9 100644 --- a/src/config/macros.rs +++ b/src/config/macros.rs @@ -82,7 +82,12 @@ pub struct Macro { impl Macro { pub fn load(name: &str) -> Result { - let path = paths::macro_file(name); + let workspace_path = paths::workspace_macros_dir().join(format!("{name}.yaml")); + let path = if workspace_path.exists() { + workspace_path + } else { + paths::macro_file(name) + }; let err = || format!("Failed to load macro '{name}' at '{}'", path.display()); let content = read_to_string(&path).with_context(err)?; let value: Macro = serde_yaml::from_str(&content).with_context(err)?; diff --git a/src/config/mod.rs b/src/config/mod.rs index bb0c408..f36cc99 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -31,10 +31,8 @@ pub use self::app_config::AppConfig; pub use self::app_state::AppState; pub use self::input::Input; pub use self::install_remote::{install_remote, install_remote_from_repl_args}; -#[allow(unused_imports)] pub use self::macro_policy::{ - DiscoveredMacro, MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, - RESERVED_MACRO_NAMES, ResolvedMacro, discover_macros, + MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro, }; #[allow(unused_imports)] pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_instructions}; diff --git a/src/config/paths.rs b/src/config/paths.rs index 10fe403..6a0ecfe 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -474,11 +474,6 @@ pub fn list_macros() -> Vec { list_file_names(macros_dir(), ".yaml") } -pub fn has_macro(name: &str) -> bool { - let names = list_macros(); - names.contains(&name.to_string()) -} - pub fn list_skills() -> Vec { let mut names = Vec::new(); let mut seen = HashSet::new(); diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 606cc48..d75bcca 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -7,7 +7,8 @@ use super::todo::TodoList; use super::tool_scope::{McpRuntime, ToolScope}; use super::{ AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE, - Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, RIGHT_PROMPT, Role, + Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, MacroAllowlistLevel, + MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, RIGHT_PROMPT, ResolvedMacro, Role, RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags, TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents_with_descriptions, memory, paths, @@ -123,6 +124,104 @@ fn complete_skills_with_descriptions(names: Vec) -> Vec<(String, Option< .collect() } +/// Keys offered by `.set ` completion. `reasoning_effort` is appended at +/// completion time only when the current model supports reasoning levels. +const SET_COMPLETION_KEYS: [&str; 26] = [ + "auto_continue", + "continuation_prompt", + "temperature", + "top_p", + "enabled_macros", + "enabled_skills", + "enabled_tools", + "enabled_mcp_servers", + "inject_todo_instructions", + "inject_skill_instructions", + "skill_instructions", + "max_auto_continues", + "memory", + "save_session", + "compression_threshold", + "rag_reranker_model", + "rag_top_k", + "max_output_tokens", + "dry_run", + "function_calling_support", + "mcp_server_support", + "skills_enabled", + "stream", + "save", + "highlight", + "raw_markdown", +]; + +/// The new global-level `enabled_macros` list after toggling `name`, or +/// `None` when the toggle is a no-op (already in the requested state). +/// Disabling with no current list (all macros visible) materializes the list +/// as every active macro name minus `name`. +fn toggled_enabled_macros( + current: Option<&[String]>, + all_active: &[String], + name: &str, + enable: bool, +) -> Option> { + match (current, enable) { + (None, true) => None, + (Some(list), true) => { + if list.iter().any(|v| v == name) { + None + } else { + let mut list = list.to_vec(); + list.push(name.to_string()); + Some(list) + } + } + (None, false) => Some( + all_active + .iter() + .filter(|v| v.as_str() != name) + .cloned() + .collect(), + ), + (Some(list), false) => { + if list.iter().any(|v| v == name) { + Some( + list.iter() + .filter(|v| v.as_str() != name) + .cloned() + .collect(), + ) + } else { + None + } + } + } +} + +/// The `.list macros` state column for a resolved row. +fn macro_state_display( + row: &ResolvedMacro, + lock_owner: impl Fn(MacroAllowlistLevel) -> String, +) -> String { + match &row.state { + MacroState::Enabled => "enabled".to_string(), + MacroState::DisabledRuntime => "disabled (runtime)".to_string(), + MacroState::Locked { level } => format!("locked ({} enabled_macros)", lock_owner(*level)), + MacroState::Missing => "missing".to_string(), + MacroState::ShadowedBuiltin => "shadowed (built-in)".to_string(), + MacroState::Invalid { reason } => format!("invalid ({reason})"), + } +} + +/// The `.list macros` source column: where the definition file lives, or `-` +/// for allowlist entries with no installed file. +fn macro_source_display(source: Option) -> String { + match source { + Some(source) => source.to_string(), + None => "-".to_string(), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum RenderMode { #[default] @@ -1577,6 +1676,10 @@ impl RequestContext { "enabled_skills", super::format_option_value(&role.enabled_skills().map(|v| v.join(","))), ), + ( + "enabled_macros", + super::format_option_value(&role.enabled_macros().map(|v| v.join(","))), + ), ( "max_output_tokens", role.model() @@ -2357,6 +2460,9 @@ impl RequestContext { } pub fn new_macro(&self, app: &AppConfig, name: &str) -> Result<()> { + if RESERVED_MACRO_NAMES.contains(&name) { + bail!("'{name}' is a reserved macro name"); + } if self.macro_flag { bail!("No macro"); } @@ -2374,12 +2480,150 @@ impl RequestContext { Ok(()) } + /// The resolved macro set for the active context (workspace + global + /// discovery, effective `enabled_macros` allowlist, built-in shadowing). + pub fn macro_policy(&self) -> MacroPolicy { + MacroPolicy::effective( + &self.app.config, + self.role.as_ref(), + self.agent.as_ref(), + self.session.as_ref(), + &crate::repl::builtin_command_names(), + false, + ) + } + + /// A human-readable name for the config level whose `enabled_macros` + /// allowlist restricts a macro, e.g. `agent:oracle` or `role:coder`. + pub fn macro_lock_owner(&self, level: MacroAllowlistLevel) -> String { + let name = match level { + MacroAllowlistLevel::Session => self.session.as_ref().map(|s| s.name()), + MacroAllowlistLevel::Agent => self.agent.as_ref().map(|a| a.name()), + MacroAllowlistLevel::Role => self.role.as_ref().map(|r| r.name()), + MacroAllowlistLevel::Global => return "global config".to_string(), + }; + match name { + Some(name) => format!("{level}:{name}"), + None => level.to_string(), + } + } + + /// Enables or disables a macro by editing the in-memory global-level + /// `enabled_macros` list. Errors when a role/agent/session allowlist is + /// active, since a global-level write would be silently shadowed. + pub fn macro_toggle(&mut self, name: &str, enable: bool) -> Result<()> { + let restricting_level = if self + .session + .as_ref() + .and_then(|s| s.enabled_macros()) + .is_some() + { + Some(MacroAllowlistLevel::Session) + } else if self + .agent + .as_ref() + .and_then(|a| a.enabled_macros()) + .is_some() + { + Some(MacroAllowlistLevel::Agent) + } else if self + .role + .as_ref() + .and_then(|r| r.enabled_macros()) + .is_some() + { + Some(MacroAllowlistLevel::Role) + } else { + None + }; + if let Some(level) = restricting_level { + bail!( + "Macro toggles are restricted by {} enabled_macros; edit enabled_macros there", + self.macro_lock_owner(level) + ); + } + + let policy = self.macro_policy(); + match policy.find(name).map(|row| &row.state) { + None => bail!("Unknown macro '{name}'"), + Some(MacroState::Invalid { reason }) => bail!("Macro '{name}' is invalid: {reason}"), + Some(_) => {} + } + let all_active: Vec = policy + .macros + .iter() + .filter(|row| { + row.source.is_some() + && !row.shadowed_by_workspace + && !matches!(row.state, MacroState::Missing | MacroState::Invalid { .. }) + }) + .map(|row| row.name.clone()) + .collect(); + + let action = if enable { "enabled" } else { "disabled" }; + match toggled_enabled_macros( + self.app.config.enabled_macros.as_deref(), + &all_active, + name, + enable, + ) { + Some(list) => { + self.update_app_config(|app| app.enabled_macros = Some(list)); + println!("Macro '{name}' {action}"); + } + None => println!("Macro '{name}' is already {action}"), + } + Ok(()) + } + + /// The macros offered by top-level `.` completion: enabled rows + /// only. Macros shadowed by a built-in command never appear here; they + /// stay reachable via `.macro `. + pub fn visible_macro_completions(&self) -> Vec<(String, Option)> { + self.macro_policy() + .macros + .into_iter() + .filter(|row| row.state == MacroState::Enabled && !row.shadowed_by_workspace) + .map(|row| (row.name, row.description)) + .collect() + } + pub fn list_assets(&self, kind: &str) -> Result<()> { match kind { "roles" => print_asset_names("roles", &paths::list_roles(true)), "sessions" => print_asset_names("sessions", &self.list_sessions()), "rags" => print_asset_names("RAGs", &paths::list_rags()), - "macros" => print_asset_names("macros", &paths::list_macros()), + "macros" => { + let policy = self.macro_policy(); + if policy.macros.is_empty() { + println!("No macros found."); + return Ok(()); + } + + println!("Macros:"); + let header = format!( + " {:<24} {:<10} {:<9} {:<40} {}", + "name", "source", "isolated", "state", "description" + ); + println!("{header}"); + for row in &policy.macros { + let source = macro_source_display(row.source); + let isolated = match row.isolated { + Some(true) => "yes", + Some(false) => "no", + None => "-", + }; + let state = macro_state_display(row, |level| self.macro_lock_owner(level)); + let description = row.description.as_deref().unwrap_or_default(); + let line = format!( + " {:<24} {:<10} {:<9} {:<40} {}", + row.name, source, isolated, state, description + ); + println!("{}", line.trim_end()); + } + + Ok(()) + } "agents" => { let entries = list_agents_with_descriptions(); if entries.is_empty() { @@ -2728,6 +2972,23 @@ impl RequestContext { } self.update_app_config(|app| app.enabled_skills = parsed.clone()); } + "enabled_macros" => { + let raw: Option = super::parse_value(value)?; + let parsed: Option> = raw.map(|s| super::csv_to_vec(&s)); + if let Some(names) = parsed.as_ref() { + let policy = self.macro_policy(); + for name in names { + if !policy + .macros + .iter() + .any(|m| m.source.is_some() && &m.name == name) + { + bail!("macro '{name}' is not installed"); + } + } + } + self.update_app_config(|app| app.enabled_macros = parsed.clone()); + } "skills_enabled" => { let value: Option = super::parse_value(value)?; if let Some(session) = self.session.as_mut() { @@ -3001,7 +3262,28 @@ impl RequestContext { values.push("remote".to_string()); super::map_completion_values(values) } - ".macro" => super::map_completion_values(paths::list_macros()), + ".macro" => { + let policy = self.macro_policy(); + let mut values: Vec<(String, Option)> = policy + .macros + .iter() + .filter(|row| { + row.source.is_some() + && !row.shadowed_by_workspace + && row.state.is_invocable() + }) + .map(|row| (row.name.clone(), row.description.clone())) + .collect(); + values.push(( + "enable ".to_string(), + Some("Re-enable a runtime-disabled macro".to_string()), + )); + values.push(( + "disable ".to_string(), + Some("Disable a macro for the rest of this process".to_string()), + )); + values + } ".reasoning" => { let levels = self.current_model().reasoning_levels(); levels.iter().map(|v| (v.clone(), None)).collect() @@ -3016,32 +3298,7 @@ impl RequestContext { None => vec![], }, ".set" => { - let mut values = vec![ - "auto_continue", - "continuation_prompt", - "temperature", - "top_p", - "enabled_tools", - "enabled_mcp_servers", - "inject_todo_instructions", - "inject_skill_instructions", - "skill_instructions", - "max_auto_continues", - "memory", - "save_session", - "compression_threshold", - "rag_reranker_model", - "rag_top_k", - "max_output_tokens", - "dry_run", - "function_calling_support", - "mcp_server_support", - "skills_enabled", - "stream", - "save", - "highlight", - "raw_markdown", - ]; + let mut values = SET_COMPLETION_KEYS.to_vec(); if !self.current_model().reasoning_levels().is_empty() { values.push("reasoning_effort"); } @@ -3185,6 +3442,25 @@ impl RequestContext { .collect() }; values = super::map_completion_values(candidates); + } else if cmd == ".macro" + && (args.first() == Some(&"enable") || args.first() == Some(&"disable")) + && args.len() == 2 + { + let enable = args.first() == Some(&"enable"); + values = self + .macro_policy() + .macros + .into_iter() + .filter(|row| row.source.is_some() && !row.shadowed_by_workspace) + .filter(|row| { + if enable { + row.state == MacroState::DisabledRuntime + } else { + row.state.is_invocable() + } + }) + .map(|row| (row.name, row.description)) + .collect(); } else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2) || (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2) { @@ -6463,4 +6739,115 @@ mod tests { "install_mcp_config must add new bundled servers" ); } + + fn strings(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn toggled_enabled_macros_covers_all_transitions() { + let all_active = strings(&["a", "b", "c"]); + type ToggleCase = (Option>, &'static str, bool, Option>); + let cases: Vec = vec![ + (None, "a", true, None), + (Some(strings(&["a"])), "a", true, None), + (Some(strings(&["a"])), "b", true, Some(strings(&["a", "b"]))), + (None, "b", false, Some(strings(&["a", "c"]))), + ( + Some(strings(&["a", "b"])), + "b", + false, + Some(strings(&["a"])), + ), + (Some(strings(&["a"])), "b", false, None), + ]; + for (current, name, enable, expected) in cases { + let result = toggled_enabled_macros(current.as_deref(), &all_active, name, enable); + assert_eq!( + result, expected, + "current={current:?} name={name} enable={enable}" + ); + } + } + + fn resolved(state: MacroState) -> ResolvedMacro { + ResolvedMacro { + name: "m".to_string(), + source: Some(MacroSource::Global), + description: None, + isolated: None, + shadowed_by_workspace: false, + state, + } + } + + #[test] + fn macro_state_display_covers_all_states() { + let owner = |level: MacroAllowlistLevel| format!("{level}:test"); + let cases = vec![ + (MacroState::Enabled, "enabled"), + (MacroState::DisabledRuntime, "disabled (runtime)"), + ( + MacroState::Locked { + level: MacroAllowlistLevel::Agent, + }, + "locked (agent:test enabled_macros)", + ), + (MacroState::Missing, "missing"), + (MacroState::ShadowedBuiltin, "shadowed (built-in)"), + ( + MacroState::Invalid { + reason: "boom".to_string(), + }, + "invalid (boom)", + ), + ]; + for (state, expected) in cases { + assert_eq!(macro_state_display(&resolved(state), owner), expected); + } + } + + #[test] + fn macro_source_display_names_source_or_dash() { + assert_eq!( + macro_source_display(Some(MacroSource::Workspace)), + "workspace" + ); + assert_eq!(macro_source_display(Some(MacroSource::Global)), "global"); + assert_eq!(macro_source_display(None), "-"); + } + + #[test] + fn set_completion_keys_include_enabled_skills_and_macros() { + assert!(SET_COMPLETION_KEYS.contains(&"enabled_skills")); + assert!(SET_COMPLETION_KEYS.contains(&"enabled_macros")); + } + + #[test] + fn new_macro_rejects_reserved_names() { + let ctx = create_test_ctx(); + let app = ctx.app.config.clone(); + for name in RESERVED_MACRO_NAMES { + let err = ctx.new_macro(&app, name).unwrap_err(); + assert_eq!( + err.to_string(), + format!("'{name}' is a reserved macro name") + ); + } + } + + #[test] + fn macro_lock_owner_names_the_owning_config() { + let mut ctx = create_test_ctx(); + assert_eq!(ctx.macro_lock_owner(MacroAllowlistLevel::Role), "role"); + ctx.role = Some(Role::new("coder", "prompt")); + assert_eq!( + ctx.macro_lock_owner(MacroAllowlistLevel::Role), + "role:coder" + ); + assert_eq!( + ctx.macro_lock_owner(MacroAllowlistLevel::Global), + "global config" + ); + } } diff --git a/src/config/session.rs b/src/config/session.rs index 59d334f..728b100 100644 --- a/src/config/session.rs +++ b/src/config/session.rs @@ -246,6 +246,9 @@ impl Session { if let Some(enabled_skills) = self.enabled_skills() { data["enabled_skills"] = json!(enabled_skills); } + if let Some(enabled_macros) = self.enabled_macros() { + data["enabled_macros"] = json!(enabled_macros); + } if let Some(save_session) = self.save_session() { data["save_session"] = save_session.into(); } @@ -325,6 +328,10 @@ impl Session { items.push(("enabled_skills", enabled_skills.join(","))); } + if let Some(enabled_macros) = self.enabled_macros() { + items.push(("enabled_macros", enabled_macros.join(","))); + } + if let Some(save_session) = self.save_session() { items.push(("save_session", save_session.to_string())); } diff --git a/src/repl/completer.rs b/src/repl/completer.rs index 880c789..17b4825 100644 --- a/src/repl/completer.rs +++ b/src/repl/completer.rs @@ -74,7 +74,24 @@ impl Completer for ReplCompleter { format!("{name} ") }; create_suggestion(&name, description, span) - })) + })); + + let macros: Vec<(String, Option)> = ctx + .visible_macro_completions() + .into_iter() + .map(|(name, description)| (format!(".{name}"), description)) + .filter(|(name, _)| { + command_filter.len() == 1 || name.starts_with(&command_filter[..2]) + }) + .collect(); + let macros = fuzzy_filter(macros, |(name, _)| name.as_str(), &command_filter); + suggestions.extend(macros.iter().map(|(name, description)| { + create_suggestion( + &format!("{name} "), + description.as_deref().unwrap_or_default(), + span, + ) + })); } suggestions } diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 47e95c0..21bd991 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -12,8 +12,8 @@ use crate::client::{ oauth, }; use crate::config::{ - AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags, - macro_execute, + AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext, + StateFlags, macro_execute, }; use crate::config::{AssetCategory, paths}; use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail}; @@ -1073,15 +1073,44 @@ pub async fn run_repl_command( } }, ".macro" => match split_first_arg(args) { - Some((name, extra)) => { - let app = Arc::clone(&ctx.app.config); - if !paths::has_macro(name) && extra.is_none() { - ctx.new_macro(app.as_ref(), name)?; - } else { - macro_execute(ctx, name, extra, abort_signal.clone()).await?; + Some((sub @ ("enable" | "disable"), rest)) => { + match rest.and_then(|v| v.split_whitespace().next()) { + Some(name) => ctx.macro_toggle(name, sub == "enable")?, + None => println!("Usage: .macro {sub} "), } } - None => println!("Usage: .macro ..."), + Some((name, extra)) => { + let policy = ctx.macro_policy(); + match policy.find(name).map(|row| &row.state) { + Some(state) if state.is_invocable() => { + macro_execute(ctx, name, extra, abort_signal.clone()).await?; + } + Some(MacroState::DisabledRuntime) => bail!( + r#"Macro '{name}' is disabled. Re-enable it with ".macro enable {name}""# + ), + Some(MacroState::Locked { level }) => bail!( + "Macro '{name}' is restricted by {} enabled_macros", + ctx.macro_lock_owner(*level) + ), + Some(MacroState::Invalid { reason }) => { + bail!("Macro '{name}' is invalid: {reason}") + } + Some(_) | None => { + if extra.is_none() { + let app = Arc::clone(&ctx.app.config); + ctx.new_macro(app.as_ref(), name)?; + } else { + macro_execute(ctx, name, extra, abort_signal.clone()).await?; + } + } + } + } + None => println!( + r#"Usage: + .macro [text]... # Execute a macro + .macro enable # Re-enable a runtime-disabled macro + .macro disable # Disable a macro for the rest of this process"# + ), }, ".file" => match args { Some(args) => { @@ -1294,7 +1323,23 @@ pub async fn run_repl_command( println!("Usage: .vault [name]") } }, - _ => unknown_command()?, + _ => { + let name = cmd.strip_prefix('.').unwrap_or(cmd); + let policy = ctx.macro_policy(); + match policy.find(name).map(|row| &row.state) { + Some(MacroState::Enabled) => { + macro_execute(ctx, name, args, abort_signal.clone()).await?; + } + Some(MacroState::DisabledRuntime) => bail!( + r#"Macro '{name}' is disabled. Re-enable it with ".macro enable {name}""# + ), + Some(MacroState::Locked { level }) => bail!( + "Macro '{name}' is restricted by {} enabled_macros", + ctx.macro_lock_owner(*level) + ), + _ => unknown_command()?, + } + } }, None => { if let Some(cmd) = try_extract_shell_command(line) { @@ -1522,6 +1567,20 @@ fn unknown_command() -> Result<()> { bail!(r#"Unknown command. Type ".help" for additional help."#); } +/// The name of every built-in REPL command (first word, without the leading +/// dot), sorted and deduplicated. Macros with one of these names are shadowed +/// by the built-in and stay reachable only via `.macro `. +pub fn builtin_command_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = REPL_COMMANDS + .iter() + .filter_map(|cmd| cmd.name.split_whitespace().next()) + .filter_map(|name| name.strip_prefix('.')) + .collect(); + names.sort_unstable(); + names.dedup(); + names +} + fn dump_repl_help() { let head = REPL_COMMANDS .iter() @@ -1532,6 +1591,10 @@ fn dump_repl_help() { r###"{head} {:<24} Run an arbitrary shell command (stdout/stderr stream to your terminal; Ctrl+C interrupts) +Custom commands (macros): macros are coyote's custom commands. An enabled +macro runs top-level as . [args...], equivalent to ".macro ". +List them with ".list macros"; toggle them with ".macro enable|disable ". + Type ::: to start multi-line editing, type ::: to finish it. Press Ctrl+O to open an editor for editing the input buffer. Press Ctrl+C to cancel the response, Ctrl+D to exit the REPL."###, @@ -1732,6 +1795,22 @@ mod tests { assert_eq!(REPL_COMMANDS.len(), 60); } + #[test] + fn builtin_command_names_are_sorted_deduped_first_words_without_dots() { + let names = builtin_command_names(); + assert!(!names.is_empty()); + for name in &names { + assert!(!name.starts_with('.'), "'{name}' should not keep the dot"); + assert!(!name.contains(' '), "'{name}' should be a single word"); + } + assert!( + names.windows(2).all(|w| w[0] < w[1]), + "names should be sorted and deduplicated: {names:?}" + ); + assert!(names.contains(&"help")); + assert!(names.contains(&"macro")); + } + #[test] fn repl_commands_all_start_with_dot() { for cmd in REPL_COMMANDS.iter() {