feat: add enabled_macros config field at global, role, agent, and session levels

Mirrors the enabled_skills plumbing per plans/custom-commands-design.md §5:
- global: Config + AppConfig structs, from_config copy, and the
  COYOTE_ENABLED_MACROS env-override arm (csv_to_vec parsing)
- role: frontmatter via parse_string_or_array (list or csv string),
  plus the export() mirror so Role::save round-trips the field
- agent (non-graph): plain serde on AgentConfig; graph.yaml silently
  ignores the key (pinned by test, no field on Graph by design)
- session: plain serde with csv-or-vec deserializer

Empty list/string deserializes to Some([]) (explicit zero), distinct
from absent/null (None) — pinned by tests at every level, including
the env arm (serial-fenced against the from_config tests, which read
the process env via load_envs).
This commit is contained in:
2026-08-21 11:55:13 -06:00
parent e8b55bba15
commit f39381aa9d
6 changed files with 250 additions and 0 deletions
+72
View File
@@ -43,6 +43,8 @@ pub struct AppConfig {
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
pub enabled_skills: Option<Vec<String>>,
pub visible_skills: Option<Vec<String>>,
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
pub enabled_macros: Option<Vec<String>>,
pub mcp_server_support: bool,
pub mapping_mcp_servers: IndexMap<String, String>,
@@ -127,6 +129,7 @@ impl Default for AppConfig {
skills_enabled: true,
enabled_skills: None,
visible_skills: None,
enabled_macros: None,
mcp_server_support: true,
mapping_mcp_servers: Default::default(),
@@ -211,6 +214,7 @@ impl AppConfig {
skills_enabled: config.skills_enabled,
enabled_skills: config.enabled_skills,
visible_skills: config.visible_skills,
enabled_macros: config.enabled_macros,
mcp_server_support: config.mcp_server_support,
mapping_mcp_servers: config.mapping_mcp_servers,
@@ -533,6 +537,10 @@ impl AppConfig {
self.enabled_skills = v.map(|raw| super::csv_to_vec(&raw));
}
if let Some(v) = super::read_env_value::<String>(&get_env_name("enabled_macros")) {
self.enabled_macros = v.map(|raw| super::csv_to_vec(&raw));
}
if let Some(Some(v)) = super::read_env_bool(&get_env_name("mcp_server_support")) {
self.mcp_server_support = v;
}
@@ -769,6 +777,70 @@ mod tests {
);
}
#[test]
#[serial_test::serial]
fn from_config_copies_enabled_macros() {
let cfg = Config {
model_id: "provider:test".to_string(),
enabled_macros: Some(vec!["a".to_string()]),
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.enabled_macros, Some(vec!["a".to_string()]));
}
#[test]
#[serial_test::serial]
fn from_config_preserves_explicit_empty_enabled_macros() {
let cfg = Config {
model_id: "provider:test".to_string(),
enabled_macros: Some(vec![]),
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.enabled_macros, Some(vec![]));
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_enabled_macros() {
let env_name = get_env_name("enabled_macros");
let prev = std::env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { std::env::set_var(&env_name, "a,b") };
app.load_envs();
assert_eq!(
app.enabled_macros,
Some(vec!["a".to_string(), "b".to_string()])
);
unsafe { std::env::set_var(&env_name, "") };
app.load_envs();
assert_eq!(app.enabled_macros, Some(vec![]));
unsafe { std::env::set_var(&env_name, "null") };
app.load_envs();
assert_eq!(app.enabled_macros, None);
unsafe { std::env::remove_var(&env_name) };
app.enabled_macros = Some(vec!["keep".to_string()]);
app.load_envs();
assert_eq!(app.enabled_macros, Some(vec!["keep".to_string()]));
unsafe {
match prev {
Some(v) => std::env::set_var(&env_name, v),
None => std::env::remove_var(&env_name),
}
}
}
#[test]
fn editor_returns_configured_value() {
let configured = cached_editor()