feat: surface macros as first-class custom commands in the REPL

Implements the invocation and management surfaces from
plans/custom-commands-design.md §4 and §6:

- Top-level dispatch: an enabled macro <name> now runs as ".<name> [args]"
  from the command catch-all; runtime-disabled macros point at
  ".macro enable <name>", locked macros name the owning config, and
  unknown commands keep the existing error verbatim
- .macro enable|disable <name>: 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 <csv|null> 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 ".<TAB>" without touching the static command registry;
  ".macro <TAB>" 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
This commit is contained in:
2026-08-21 11:55:13 -06:00
parent e8ddb61518
commit e94bd450cd
8 changed files with 537 additions and 53 deletions
+89 -10
View File
@@ -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} <name>"),
}
}
None => println!("Usage: .macro <name> <text>..."),
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 <name> [text]... # Execute a macro
.macro enable <name> # Re-enable a runtime-disabled macro
.macro disable <name> # 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 <add|get|update|delete|list> [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 <name>`.
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 <name> runs top-level as .<name> [args...], equivalent to ".macro <name>".
List them with ".list macros"; toggle them with ".macro enable|disable <name>".
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() {