From 1dff08893a33ff69a3047823a17afed83c4b6a86 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 1 Jun 2026 10:22:46 -0600 Subject: [PATCH] feat: scaffold skill module --- src/config/mod.rs | 5 + src/config/skill.rs | 274 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/config/skill.rs diff --git a/src/config/mod.rs b/src/config/mod.rs index 68339c3..1bdd158 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,6 +11,7 @@ mod rag_cache; mod request_context; mod role; mod session; +mod skill; pub(crate) mod todo; mod tool_scope; mod update; @@ -30,6 +31,8 @@ pub use self::role::{ CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE, }; use self::session::Session; +#[allow(unused_imports)] +pub use self::skill::Skill; pub use self::update::run_self_update; use crate::client::{ ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS, @@ -74,6 +77,8 @@ const LIGHT_THEME: &[u8] = include_bytes!("../../assets/monokai-extended-light.t const CONFIG_FILE_NAME: &str = "config.yaml"; const AGENT_GRAPH_FILE_NAME: &str = "graph.yaml"; const ROLES_DIR_NAME: &str = "roles"; +#[allow(dead_code)] +const SKILLS_DIR_NAME: &str = "skills"; const MACROS_DIR_NAME: &str = "macros"; const ENV_FILE_NAME: &str = ".env"; const MESSAGES_FILE_NAME: &str = "messages.md"; diff --git a/src/config/skill.rs b/src/config/skill.rs new file mode 100644 index 0000000..b80fb70 --- /dev/null +++ b/src/config/skill.rs @@ -0,0 +1,274 @@ +use super::*; + +use anyhow::Result; +use fancy_regex::Regex; +use rust_embed::Embed; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::LazyLock; + +#[derive(Embed)] +#[folder = "assets/skills/"] +struct SkillsAsset; + +static RE_METADATA: LazyLock = + LazyLock::new(|| Regex::new(r"(?s)-{3,}\s*(.*?)\s*-{3,}\s*(.*)").unwrap()); + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Skill { + name: String, + #[serde(default)] + description: String, + #[serde(default)] + body: String, + #[serde(skip_serializing_if = "Option::is_none")] + enabled_tools: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enabled_mcp_servers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auto_unload: Option, +} + +#[allow(dead_code)] +impl Skill { + pub fn new(name: &str, content: &str) -> Self { + let mut metadata = ""; + let mut body = content.trim(); + if let Ok(Some(caps)) = RE_METADATA.captures(content) + && let (Some(metadata_value), Some(body_value)) = (caps.get(1), caps.get(2)) + { + metadata = metadata_value.as_str().trim(); + body = body_value.as_str().trim(); + } + let mut body = body.to_string(); + interpolate_variables(&mut body); + let mut skill = Self { + name: name.to_string(), + body, + ..Default::default() + }; + if !metadata.is_empty() + && let Ok(value) = serde_yaml::from_str::(metadata) + && let Some(value) = value.as_object() + { + for (key, value) in value { + match key.as_str() { + "description" => { + if let Some(v) = value.as_str() { + skill.description = v.to_string(); + } + } + "enabled_tools" => { + skill.enabled_tools = value.as_str().map(|v| v.to_string()); + } + "enabled_mcp_servers" => { + skill.enabled_mcp_servers = value.as_str().map(|v| v.to_string()); + } + "auto_unload" => { + skill.auto_unload = value.as_bool(); + } + _ => (), + } + } + } + skill + } + + pub fn builtin(name: &str) -> Result { + let content = SkillsAsset::get(&format!("{name}/SKILL.md")) + .ok_or_else(|| anyhow!("Unknown skill `{name}`"))?; + let content = unsafe { std::str::from_utf8_unchecked(&content.data) }; + 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())) + .collect() + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn body(&self) -> &str { + &self.body + } + + pub fn enabled_tools(&self) -> Option<&str> { + self.enabled_tools.as_deref() + } + + pub fn enabled_mcp_servers(&self) -> Option<&str> { + self.enabled_mcp_servers.as_deref() + } + + pub fn auto_unload(&self) -> bool { + self.auto_unload.unwrap_or(false) + } + + pub fn is_compatible(&self, function_calling_enabled: bool, mcp_enabled: bool) -> bool { + if self.declares_tools() && !function_calling_enabled { + return false; + } + if self.declares_mcp_servers() && !mcp_enabled { + return false; + } + true + } + + fn declares_tools(&self) -> bool { + self.enabled_tools + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + } + + fn declares_mcp_servers(&self) -> bool { + self.enabled_mcp_servers + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skill_new_parses_body() { + let skill = Skill::new("test", "You are a git expert"); + assert_eq!(skill.name(), "test"); + assert_eq!(skill.body(), "You are a git expert"); + assert_eq!(skill.description(), ""); + } + + #[test] + fn skill_new_parses_full_metadata() { + let content = "---\n\ + description: Atomic commits, rebase surgery\n\ + enabled_tools: shell,fs\n\ + enabled_mcp_servers: github\n\ + auto_unload: true\n\ + ---\n\ + You are a git expert"; + let skill = Skill::new("git-master", content); + assert_eq!(skill.name(), "git-master"); + assert_eq!(skill.description(), "Atomic commits, rebase surgery"); + assert_eq!(skill.enabled_tools(), Some("shell,fs")); + assert_eq!(skill.enabled_mcp_servers(), Some("github")); + assert!(skill.auto_unload()); + assert_eq!(skill.body(), "You are a git expert"); + } + + #[test] + fn skill_new_no_metadata_has_defaults() { + let skill = Skill::new("test", "Just a body"); + assert_eq!(skill.description(), ""); + assert_eq!(skill.enabled_tools(), None); + assert_eq!(skill.enabled_mcp_servers(), None); + assert!(!skill.auto_unload()); + } + + #[test] + fn skill_new_metadata_only() { + let content = "---\ndescription: Just metadata\n---"; + let skill = Skill::new("test", content); + assert_eq!(skill.description(), "Just metadata"); + assert_eq!(skill.body(), ""); + } + + #[test] + fn skill_new_partial_metadata_leaves_others_none() { + let content = "---\ndescription: Partial\n---\nthe body"; + let skill = Skill::new("test", content); + assert_eq!(skill.description(), "Partial"); + assert_eq!(skill.enabled_tools(), None); + assert_eq!(skill.enabled_mcp_servers(), None); + assert!(!skill.auto_unload()); + assert_eq!(skill.body(), "the body"); + } + + #[test] + fn skill_new_ignores_unknown_keys() { + let content = "---\ndescription: D\nbogus_field: 42\n---\nbody"; + let skill = Skill::new("test", content); + assert_eq!(skill.description(), "D"); + assert_eq!(skill.body(), "body"); + } + + #[test] + fn skill_new_trims_body_whitespace() { + let content = "---\ndescription: D\n---\n\n\n body content \n\n"; + let skill = Skill::new("test", content); + assert_eq!(skill.body(), "body content"); + } + + #[test] + fn skill_default_has_empty_fields() { + let skill = Skill::default(); + assert_eq!(skill.name(), ""); + assert_eq!(skill.body(), ""); + assert_eq!(skill.description(), ""); + assert_eq!(skill.enabled_tools(), None); + assert_eq!(skill.enabled_mcp_servers(), None); + assert!(!skill.auto_unload()); + } + + #[test] + fn is_compatible_knowledge_only_passes_all_combinations() { + let skill = Skill::new("test", "Just knowledge"); + assert!(skill.is_compatible(false, false)); + assert!(skill.is_compatible(true, false)); + assert!(skill.is_compatible(false, true)); + assert!(skill.is_compatible(true, true)); + } + + #[test] + fn is_compatible_with_tools_requires_function_calling() { + let content = "---\nenabled_tools: shell\n---\nbody"; + let skill = Skill::new("test", content); + assert!(!skill.is_compatible(false, true)); + assert!(!skill.is_compatible(false, false)); + assert!(skill.is_compatible(true, true)); + assert!(skill.is_compatible(true, false)); + } + + #[test] + fn is_compatible_with_mcp_requires_mcp_enabled() { + let content = "---\nenabled_mcp_servers: github\n---\nbody"; + let skill = Skill::new("test", content); + assert!(!skill.is_compatible(true, false)); + assert!(!skill.is_compatible(false, false)); + assert!(skill.is_compatible(true, true)); + } + + #[test] + fn is_compatible_requires_both_when_both_declared() { + let content = + "---\nenabled_tools: shell\nenabled_mcp_servers: github\n---\nbody"; + let skill = Skill::new("test", content); + assert!(!skill.is_compatible(true, false)); + assert!(!skill.is_compatible(false, true)); + assert!(!skill.is_compatible(false, false)); + assert!(skill.is_compatible(true, true)); + } + + #[test] + fn is_compatible_empty_string_tools_is_knowledge_only() { + let content = "---\nenabled_tools: \"\"\n---\nbody"; + let skill = Skill::new("test", content); + assert!(skill.is_compatible(false, false)); + } + + #[test] + fn builtin_returns_err_for_unknown_skill() { + let result = Skill::builtin("nonexistent_skill_xyz"); + assert!(result.is_err()); + } +}