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:
@@ -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
@@ -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![]);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user