feat: support name=value macro arguments with variable tab completion

Macro invocations (.name and .macro name) accept leading name=value
assignments before positional args: assignments set declared variables
directly so earlier variables can keep their defaults, remaining
positionals fill unassigned variables in declaration order, and the
free text after -- is never scanned for assignments. Identifier-shaped
keys that match no declared variable error with the declared list to
catch typos; non-identifier tokens containing = stay positional.
MacroVariable gains an optional description field, and tab completion
after a macro name offers name= candidates showing each variable's
description and default until the assignment prefix ends.
This commit is contained in:
2026-08-24 14:20:21 -06:00
parent b6721d6a15
commit e7307da6a9
3 changed files with 317 additions and 22 deletions
+1
View File
@@ -39,6 +39,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [Macros](https://github.com/Dark-Alex-17/coyote/wiki/Macros): Automate repetitive tasks and workflows with Coyote "scripts" (macros). Macros are Coyote's custom commands: invoke any macro directly by name (e.g. `.review main`), with tab-completion, right alongside the built-in REPL commands.
* Give a macro a `description` (shown in `.list macros` and completions) and set `isolated: false` to run its steps on the live session, exactly as if you typed them. Note that non-isolated steps are recorded in the session, and mutating steps (`.role`, `.model`, ...) persist after the macro ends — by design. Steps are fail-fast: an error aborts the remaining steps, but completed steps' effects remain. A non-isolated macro step cannot invoke another macro, and a `.exit` step never exits the REPL.
* Commit project-specific macros to `.coyote/macros/` in your repo — they shadow same-named global macros (opt out with `--no-workspace-macros`).
* Pass variables positionally or by name: leading `name=value` args set declared variables directly (letting earlier variables keep their defaults), and remaining args fill the rest in order. Tab completion after a macro name lists each variable with its description and default.
* Scope which macros are invocable with `enabled_macros` in the global config, a role, an agent, or a session (most specific wins; an empty list disables all macros), and toggle at runtime with `.macro enable|disable <name>`.
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
+283 -21
View File
@@ -26,13 +26,17 @@ pub async fn macro_execute(
bail!("nested macros not allowed in non-isolated mode");
}
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());
}
let (new_args, text) = split_args_text(args.unwrap_or_default(), cfg!(windows));
let variables = macro_value
.resolve_variables(&new_args)
.map_err(|err| anyhow!("{err}. Usage: {}", macro_value.usage(name)))?;
.resolve_variables(&new_args, text)
.map_err(|err| {
let kv_hint = if macro_value.variables.is_empty() {
""
} else {
" (variables can also be set by name: name=value, before any positional args)"
};
anyhow!("{err}. Usage: {}{kv_hint}", macro_value.usage(name))
})?;
if !macro_value.isolated {
let mut live = MacroModeGuard::new(ctx);
@@ -181,19 +185,43 @@ impl Macro {
Ok(())
}
pub fn resolve_variables(&self, args: &[String]) -> Result<IndexMap<String, String>> {
/// Leading `name=value` tokens assign declared variables by name; the
/// first token that is not such an assignment starts the positional
/// args, which fill the remaining unassigned variables in declaration
/// order. `trailing_text` (the free text after `--`) is always the last
/// positional and is never scanned for assignments.
pub fn resolve_variables(
&self,
args: &[String],
trailing_text: &str,
) -> Result<IndexMap<String, String>> {
let (assignments, positional_start) = self.leading_assignments(args)?;
let mut positionals: Vec<&str> = args[positional_start..]
.iter()
.map(|s| s.as_str())
.collect();
if !trailing_text.is_empty() {
positionals.push(trailing_text);
}
let mut output = IndexMap::new();
let mut pos_index = 0;
for (i, variable) in self.variables.iter().enumerate() {
let value = if variable.rest && i == self.variables.len() - 1 {
if args.len() > i {
Some(args[i..].join(" "))
let is_rest = variable.rest && i == self.variables.len() - 1;
let value = if let Some(value) = assignments.get(variable.name.as_str()) {
Some(value.clone())
} else if is_rest {
if pos_index < positionals.len() {
Some(positionals[pos_index..].join(" "))
} else {
variable.default.clone()
}
} else {
args.get(i)
.map(|v| v.to_string())
.or_else(|| variable.default.clone())
let positional = positionals.get(pos_index).map(|v| v.to_string());
if positional.is_some() {
pos_index += 1;
}
positional.or_else(|| variable.default.clone())
};
let value =
value.ok_or_else(|| anyhow!("Missing value for variable '{}'", variable.name))?;
@@ -202,6 +230,61 @@ impl Macro {
Ok(output)
}
fn leading_assignments(&self, args: &[String]) -> Result<(IndexMap<String, String>, usize)> {
let mut assignments = IndexMap::new();
let mut positional_start = args.len();
for (i, arg) in args.iter().enumerate() {
let Some((key, value)) = parse_assignment(arg) else {
positional_start = i;
break;
};
if !self.variables.iter().any(|v| v.name == key) {
let declared: Vec<&str> = self.variables.iter().map(|v| v.name.as_str()).collect();
bail!(
"Unknown variable '{key}' (declared variables: {})",
declared.join(", ")
);
}
if assignments
.insert(key.to_string(), value.to_string())
.is_some()
{
bail!("Variable '{key}' was assigned more than once");
}
}
Ok((assignments, positional_start))
}
/// Completion candidates for the assignment prefix: one `name=` entry per
/// variable not yet assigned in `completed_args`. Empty once a
/// non-assignment token has ended the prefix.
pub fn variable_completions(&self, completed_args: &[&str]) -> Vec<(String, Option<String>)> {
let mut assigned: Vec<&str> = Vec::new();
for arg in completed_args {
match parse_assignment(arg) {
Some((key, _)) if self.variables.iter().any(|v| v.name == key) => {
assigned.push(key);
}
_ => return vec![],
}
}
self.variables
.iter()
.filter(|v| !assigned.contains(&v.name.as_str()))
.map(|v| {
let requirement = match &v.default {
Some(default) => format!("(default: {default})"),
None => "(required)".to_string(),
};
let hint = match &v.description {
Some(description) => format!("{description} {requirement}"),
None => requirement,
};
(format!("{}=", v.name), Some(hint))
})
.collect()
}
pub fn usage(&self, name: &str) -> String {
let mut parts = vec![name.to_string()];
for (i, variable) in self.variables.iter().enumerate() {
@@ -233,12 +316,30 @@ impl Macro {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MacroVariable {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub rest: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
/// A token is an assignment only when the key before `=` is
/// identifier-shaped: a letter or underscore followed by letters, digits,
/// underscores, or hyphens. Anything else (paths, URLs, prose) is positional.
pub(crate) fn parse_assignment(arg: &str) -> Option<(&str, &str)> {
let (key, value) = arg.split_once('=')?;
let mut chars = key.chars();
let first = chars.next()?;
if !first.is_ascii_alphabetic() && first != '_' {
return None;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return None;
}
Some((key, value))
}
fn default_true() -> bool {
true
}
@@ -399,6 +500,7 @@ mod tests {
fn var(name: &str, rest: bool, default: Option<&str>) -> MacroVariable {
MacroVariable {
name: name.to_string(),
description: None,
rest,
default: default.map(String::from),
}
@@ -417,7 +519,7 @@ mod tests {
fn resolve_no_variables() {
let m = macro_with_vars(vec![]);
let result = m.resolve_variables(&[]).unwrap();
let result = m.resolve_variables(&[], "").unwrap();
assert!(result.is_empty());
}
@@ -426,7 +528,7 @@ mod tests {
fn resolve_required_variable_provided() {
let m = macro_with_vars(vec![var("name", false, None)]);
let result = m.resolve_variables(&["Alice".into()]).unwrap();
let result = m.resolve_variables(&["Alice".into()], "").unwrap();
assert_eq!(result["name"], "Alice");
}
@@ -435,7 +537,7 @@ mod tests {
fn resolve_required_variable_missing_errors() {
let m = macro_with_vars(vec![var("name", false, None)]);
let result = m.resolve_variables(&[]);
let result = m.resolve_variables(&[], "");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("name"));
@@ -445,7 +547,7 @@ mod tests {
fn resolve_default_variable_uses_default() {
let m = macro_with_vars(vec![var("color", false, Some("blue"))]);
let result = m.resolve_variables(&[]).unwrap();
let result = m.resolve_variables(&[], "").unwrap();
assert_eq!(result["color"], "blue");
}
@@ -454,7 +556,7 @@ mod tests {
fn resolve_default_variable_overridden() {
let m = macro_with_vars(vec![var("color", false, Some("blue"))]);
let result = m.resolve_variables(&["red".into()]).unwrap();
let result = m.resolve_variables(&["red".into()], "").unwrap();
assert_eq!(result["color"], "red");
}
@@ -464,7 +566,7 @@ mod tests {
let m = macro_with_vars(vec![var("first", false, None), var("rest", true, None)]);
let result = m
.resolve_variables(&["a".into(), "b".into(), "c".into()])
.resolve_variables(&["a".into(), "b".into(), "c".into()], "")
.unwrap();
assert_eq!(result["first"], "a");
@@ -475,7 +577,7 @@ mod tests {
fn resolve_rest_variable_with_default() {
let m = macro_with_vars(vec![var("args", true, Some("default text"))]);
let result = m.resolve_variables(&[]).unwrap();
let result = m.resolve_variables(&[], "").unwrap();
assert_eq!(result["args"], "default text");
}
@@ -488,13 +590,173 @@ mod tests {
var("c", false, Some("default_c")),
]);
let result = m.resolve_variables(&["x".into(), "y".into()]).unwrap();
let result = m.resolve_variables(&["x".into(), "y".into()], "").unwrap();
assert_eq!(result["a"], "x");
assert_eq!(result["b"], "y");
assert_eq!(result["c"], "default_c");
}
#[test]
fn resolve_assignment_skips_earlier_defaults() {
let m = macro_with_vars(vec![
var("a", false, Some("da")),
var("b", false, Some("db")),
var("c", false, None),
]);
let result = m.resolve_variables(&["c=x".into()], "").unwrap();
assert_eq!(result["a"], "da");
assert_eq!(result["b"], "db");
assert_eq!(result["c"], "x");
}
#[test]
fn resolve_positionals_fill_unassigned_variables_after_assignments() {
let m = macro_with_vars(vec![
var("a", false, None),
var("b", false, None),
var("c", false, None),
]);
let result = m
.resolve_variables(&["b=middle".into(), "first".into(), "last".into()], "")
.unwrap();
assert_eq!(result["a"], "first");
assert_eq!(result["b"], "middle");
assert_eq!(result["c"], "last");
}
#[test]
fn resolve_rest_variable_by_assignment_and_by_positionals() {
let m = macro_with_vars(vec![
var("scope", false, Some("all")),
var("text", true, None),
]);
let result = m
.resolve_variables(&["text=hello world".into()], "")
.unwrap();
assert_eq!(result["scope"], "all");
assert_eq!(result["text"], "hello world");
let result = m
.resolve_variables(
&["scope=one".into(), "fix".into(), "the".into(), "bug".into()],
"",
)
.unwrap();
assert_eq!(result["scope"], "one");
assert_eq!(result["text"], "fix the bug");
}
#[test]
fn resolve_assignment_prefix_ends_at_first_positional() {
let m = macro_with_vars(vec![var("a", false, None), var("b", true, Some(""))]);
let result = m
.resolve_variables(&["value".into(), "a=literal".into()], "")
.unwrap();
assert_eq!(result["a"], "value");
assert_eq!(result["b"], "a=literal");
}
#[test]
fn resolve_trailing_text_is_never_scanned_for_assignments() {
let m = macro_with_vars(vec![var("text", true, None)]);
let result = m
.resolve_variables(&[], "text=looks like an assignment")
.unwrap();
assert_eq!(result["text"], "text=looks like an assignment");
}
#[test]
fn resolve_rejects_unknown_and_duplicate_assignments() {
let m = macro_with_vars(vec![var("scope", false, None)]);
let err = m.resolve_variables(&["scpe=all".into()], "").unwrap_err();
assert!(err.to_string().contains("Unknown variable 'scpe'"));
assert!(err.to_string().contains("scope"));
let err = m
.resolve_variables(&["scope=a".into(), "scope=b".into()], "")
.unwrap_err();
assert!(err.to_string().contains("assigned more than once"));
}
#[test]
fn resolve_non_identifier_equals_tokens_are_positional() {
let m = macro_with_vars(vec![var("a", false, None)]);
let result = m.resolve_variables(&["path/x=1".into()], "").unwrap();
assert_eq!(result["a"], "path/x=1");
let result = m.resolve_variables(&["1x=2".into()], "").unwrap();
assert_eq!(result["a"], "1x=2");
}
#[test]
fn parse_assignment_requires_an_identifier_shaped_key() {
assert_eq!(parse_assignment("scope=all"), Some(("scope", "all")));
assert_eq!(parse_assignment("_x-1=v"), Some(("_x-1", "v")));
assert_eq!(parse_assignment("k="), Some(("k", "")));
assert_eq!(parse_assignment("noequals"), None);
assert_eq!(parse_assignment("=v"), None);
assert_eq!(parse_assignment("1a=v"), None);
assert_eq!(parse_assignment("a/b=v"), None);
}
#[test]
fn variable_completions_list_unassigned_variables_with_hints() {
let mut m = macro_with_vars(vec![
var("scope", false, Some("all")),
var("text", true, None),
]);
m.variables[0].description = Some("What to review".to_string());
let values = m.variable_completions(&[]);
assert_eq!(
values,
vec![
(
"scope=".to_string(),
Some("What to review (default: all)".to_string())
),
("text=".to_string(), Some("(required)".to_string())),
]
);
let values = m.variable_completions(&["scope=one"]);
assert_eq!(
values,
vec![("text=".to_string(), Some("(required)".to_string()))]
);
assert!(m.variable_completions(&["positional"]).is_empty());
assert!(
m.variable_completions(&["scope=one", "free", "text=x"])
.is_empty()
);
}
#[test]
fn macro_variable_description_parses_from_yaml_and_defaults_to_none() {
let parsed: Macro = serde_yaml::from_str(
"variables:\n - name: scope\n description: What to review\n default: all\n - name: text\n rest: true\nsteps:\n - .file {{scope}} -- {{text}}\n",
)
.unwrap();
assert_eq!(
parsed.variables[0].description.as_deref(),
Some("What to review")
);
assert!(parsed.variables[1].description.is_none());
}
#[test]
fn usage_no_variables() {
let m = macro_with_vars(vec![]);
+33 -1
View File
@@ -38,6 +38,7 @@ use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL};
use super::install_remote::DEFAULT_GIT_HOST;
use super::instructions;
use super::macros::Macro;
use super::memory::{
DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory,
};
@@ -2518,6 +2519,27 @@ impl RequestContext {
)
}
fn macro_variable_completions(
&self,
cmd: &str,
completed_args: &[&str],
) -> Vec<(String, Option<String>)> {
let Some(name) = cmd.strip_prefix('.') else {
return vec![];
};
if !self
.visible_macro_completions()
.iter()
.any(|(macro_name, _)| macro_name == name)
{
return vec![];
}
match Macro::load(name, self.app.config.no_workspace_macros) {
Ok(macro_value) => macro_value.variable_completions(completed_args),
Err(_) => vec![],
}
}
pub fn macro_lock_owner(&self, level: MacroAllowlistLevel) -> String {
let name = match level {
MacroAllowlistLevel::Session => self.session.as_ref().map(|s| s.name()),
@@ -3378,7 +3400,7 @@ impl RequestContext {
.map(|v| (format!("{v} "), None))
.collect()
}
_ => vec![],
_ => self.macro_variable_completions(cmd, &[]),
};
} else if cmd == ".mcp" && args.first() == Some(&"auth") && args.len() == 2 {
if let Some(mcp_config) = &self.app.mcp_config {
@@ -3505,6 +3527,14 @@ impl RequestContext {
})
.map(|row| (row.name, row.description))
.collect();
} else if cmd == ".macro"
&& args.len() >= 2
&& args.first() != Some(&"enable")
&& args.first() != Some(&"disable")
{
if let Ok(macro_value) = Macro::load(args[0], app.no_workspace_macros) {
values = macro_value.variable_completions(&args[1..args.len() - 1]);
}
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
{
@@ -3676,6 +3706,8 @@ impl RequestContext {
.collect();
}
values.extend(super::complete_agent_variables(args[0]));
} else if args.len() >= 2 {
values = self.macro_variable_completions(cmd, &args[..args.len() - 1]);
};
fuzzy_filter(values, |v| v.0.as_str(), filter)
}