From 75a6a5e145c1e0f30293968fd1f330217d85a52c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 1 Jun 2026 11:41:04 -0600 Subject: [PATCH] feat: created the skill registry --- src/config/mod.rs | 3 + src/config/skill.rs | 8 + src/config/skill_registry.rs | 320 +++++++++++++++++++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 src/config/skill_registry.rs diff --git a/src/config/mod.rs b/src/config/mod.rs index 8b94b42..a77572f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -12,6 +12,7 @@ mod request_context; mod role; mod session; mod skill; +mod skill_registry; pub(crate) mod todo; mod tool_scope; mod update; @@ -33,6 +34,8 @@ pub use self::role::{ use self::session::Session; #[allow(unused_imports)] pub use self::skill::Skill; +#[allow(unused_imports)] +pub use self::skill_registry::SkillRegistry; pub use self::update::run_self_update; use crate::client::{ ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS, diff --git a/src/config/skill.rs b/src/config/skill.rs index 9d7ed3c..23f85e6 100644 --- a/src/config/skill.rs +++ b/src/config/skill.rs @@ -81,6 +81,14 @@ impl Skill { Ok(Skill::new(name, content)) } + pub fn load(name: &str) -> Result { + let path = paths::skill_file(name); + let content = read_to_string(&path).with_context(|| { + format!("Failed to read skill '{name}' at {}", path.display()) + })?; + Ok(Skill::new(name, &content)) + } + pub fn list_builtin_skill_names() -> Vec { SkillsAsset::iter() .filter_map(|v| v.strip_suffix("/SKILL.md").map(|v| v.to_string())) diff --git a/src/config/skill_registry.rs b/src/config/skill_registry.rs new file mode 100644 index 0000000..0cd2001 --- /dev/null +++ b/src/config/skill_registry.rs @@ -0,0 +1,320 @@ +use super::role::{Role, RoleLike}; +use super::skill::Skill; + +use anyhow::{Result, bail}; +use indexmap::IndexMap; +use std::collections::BTreeSet; + +#[allow(dead_code)] +pub struct SkillRegistry { + loaded: IndexMap, +} + +#[allow(dead_code)] +impl SkillRegistry { + pub fn new() -> Self { + Self { + loaded: IndexMap::new(), + } + } + + pub fn load(&mut self, name: &str) -> Result<()> { + if self.loaded.contains_key(name) { + bail!("Skill '{name}' is already loaded"); + } + + let skill = Skill::load(name)?; + self.loaded.insert(name.to_string(), skill); + + Ok(()) + } + + pub fn unload(&mut self, name: &str) -> Result<()> { + if self.loaded.shift_remove(name).is_none() { + bail!("Skill '{name}' is not loaded"); + } + + Ok(()) + } + + pub fn loaded_names(&self) -> Vec { + self.loaded.keys().cloned().collect() + } + + pub fn is_loaded(&self, name: &str) -> bool { + self.loaded.contains_key(name) + } + + pub fn sweep_auto_unload(&mut self) { + self.loaded.retain(|_, skill| !skill.auto_unload()); + } + + pub fn effective_role(&self, base: &Role) -> Role { + if self.loaded.is_empty() { + return base.clone(); + } + + let mut effective = base.clone(); + let skip_body = effective.is_embedded_prompt(); + + let base_tools_set = effective.enabled_tools().is_some(); + let base_mcps_set = effective.enabled_mcp_servers().is_some(); + + let mut tools = parse_csv(effective.enabled_tools().as_deref()); + let mut mcps = parse_csv(effective.enabled_mcp_servers().as_deref()); + + for (_, skill) in &self.loaded { + tools.extend(parse_csv(skill.enabled_tools())); + mcps.extend(parse_csv(skill.enabled_mcp_servers())); + if !skip_body && !skill.body().is_empty() { + let separator = if effective.is_empty_prompt() { "" } else { "\n\n" }; + effective.append_to_prompt(separator); + effective.append_to_prompt(skill.body()); + } + } + + if base_tools_set || !tools.is_empty() { + effective.set_enabled_tools(Some(join_csv(&tools))); + } + + if base_mcps_set || !mcps.is_empty() { + effective.set_enabled_mcp_servers(Some(join_csv(&mcps))); + } + + effective + } +} + +impl Default for SkillRegistry { + fn default() -> Self { + Self::new() + } +} + +fn parse_csv(s: Option<&str>) -> BTreeSet { + let mut set = BTreeSet::new(); + if let Some(raw) = s { + for token in raw.split(',') { + let trimmed = token.trim(); + if !trimmed.is_empty() { + set.insert(trimmed.to_string()); + } + } + } + set +} + +fn join_csv(set: &BTreeSet) -> String { + set.iter().cloned().collect::>().join(",") +} + +#[cfg(test)] +impl SkillRegistry { + fn insert_for_test(&mut self, skill: Skill) { + self.loaded.insert(skill.name().to_string(), skill); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_skill(name: &str, frontmatter: &str, body: &str) -> Skill { + let content = if frontmatter.is_empty() { + body.to_string() + } else { + format!("---\n{frontmatter}\n---\n{body}") + }; + Skill::new(name, &content) + } + + #[test] + fn empty_registry_returns_base_clone() { + let base = Role::new("test", "You are a helper"); + let registry = SkillRegistry::new(); + + let effective = registry.effective_role(&base); + + assert_eq!(effective.prompt(), base.prompt()); + } + + #[test] + fn one_skill_appends_body_after_base_with_separator() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("git-master", "description: D", "Git knowledge")); + + let base = Role::new("test", "You are a helper"); + let effective = registry.effective_role(&base); + + assert_eq!(effective.prompt(), "You are a helper\n\nGit knowledge"); + } + + #[test] + fn two_skills_compose_bodies_in_insertion_order() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("a", "", "Alpha body")); + registry.insert_for_test(make_skill("b", "", "Beta body")); + + let base = Role::new("test", "Base"); + let effective = registry.effective_role(&base); + + assert_eq!(effective.prompt(), "Base\n\nAlpha body\n\nBeta body"); + } + + #[test] + fn empty_base_prompt_omits_leading_separator() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("a", "", "Alpha")); + registry.insert_for_test(make_skill("b", "", "Beta")); + + let base = Role::new("test", ""); + let effective = registry.effective_role(&base); + + assert_eq!(effective.prompt(), "Alpha\n\nBeta"); + } + + #[test] + fn embedded_prompt_base_skips_body_composition() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill( + "git-master", + "enabled_tools: shell", + "should not appear", + )); + + let base = Role::new("test", "Process: __INPUT__"); + let effective = registry.effective_role(&base); + + assert_eq!(effective.prompt(), "Process: __INPUT__"); + let tools = effective.enabled_tools().expect("tools set by skill"); + assert!(tools.contains("shell")); + } + + #[test] + fn skills_with_empty_body_do_not_inject_separator() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("knowledge", "enabled_tools: fs", "")); + + let base = Role::new("test", "Base"); + let effective = registry.effective_role(&base); + + assert_eq!(effective.prompt(), "Base"); + } + + #[test] + fn tools_and_mcps_are_unioned_and_deduplicated() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill( + "a", + "enabled_tools: shell,fs\nenabled_mcp_servers: github", + "body", + )); + registry.insert_for_test(make_skill( + "b", + "enabled_tools: fs,git\nenabled_mcp_servers: github,jira", + "body", + )); + + let mut base = Role::new("test", "body"); + base.set_enabled_tools(Some("web_search".to_string())); + + let effective = registry.effective_role(&base); + + let tools_str = effective.enabled_tools().unwrap(); + let tools: BTreeSet<&str> = tools_str.split(',').collect(); + assert_eq!( + tools, + BTreeSet::from(["fs", "git", "shell", "web_search"]) + ); + + let mcps_str = effective.enabled_mcp_servers().unwrap(); + let mcps: BTreeSet<&str> = mcps_str.split(',').collect(); + assert_eq!(mcps, BTreeSet::from(["github", "jira"])); + } + + #[test] + fn no_skill_tool_contributions_preserves_base_none() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge")); + + let base = Role::new("test", "Base"); + let effective = registry.effective_role(&base); + + assert!(effective.enabled_tools().is_none()); + assert!(effective.enabled_mcp_servers().is_none()); + } + + #[test] + fn base_some_empty_tools_is_preserved() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge")); + + let mut base = Role::new("test", "Base"); + base.set_enabled_tools(Some(String::new())); + let effective = registry.effective_role(&base); + + assert_eq!(effective.enabled_tools().as_deref(), Some("")); + } + + #[test] + fn load_already_loaded_returns_error() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("git-master", "", "body")); + + let err = registry.load("git-master").unwrap_err(); + + assert!(err.to_string().contains("already loaded")); + } + + #[test] + fn unload_not_loaded_returns_error() { + let mut registry = SkillRegistry::new(); + + let err = registry.unload("missing").unwrap_err(); + + assert!(err.to_string().contains("not loaded")); + } + + #[test] + fn unload_existing_succeeds_and_removes() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("git-master", "", "body")); + assert!(registry.is_loaded("git-master")); + + registry.unload("git-master").unwrap(); + assert!(!registry.is_loaded("git-master")); + } + + #[test] + fn loaded_names_returns_insertion_order() { + let mut registry = SkillRegistry::new(); + + registry.insert_for_test(make_skill("zulu", "", "body")); + registry.insert_for_test(make_skill("alpha", "", "body")); + registry.insert_for_test(make_skill("mike", "", "body")); + + assert_eq!( + registry.loaded_names(), + vec!["zulu".to_string(), "alpha".to_string(), "mike".to_string()] + ); + } + + #[test] + fn sweep_removes_only_auto_unload_skills() { + let mut registry = SkillRegistry::new(); + registry.insert_for_test(make_skill("ephemeral", "auto_unload: true", "body")); + registry.insert_for_test(make_skill("persistent", "", "body")); + + registry.sweep_auto_unload(); + + assert!(!registry.is_loaded("ephemeral")); + assert!(registry.is_loaded("persistent")); + } + + #[test] + fn is_loaded_returns_false_for_unknown() { + let registry = SkillRegistry::new(); + + assert!(!registry.is_loaded("nothing")); + } +}