Skip to main content

synaps_cli/skills/
tool.rs

1//! `load_skill` tool — model-initiated skill activation.
2
3use std::sync::Arc;
4use serde_json::json;
5use crate::skills::{LoadedSkill, registry::{CommandRegistry, Resolution}};
6
7pub struct LoadSkillTool {
8    registry: Arc<CommandRegistry>,
9}
10
11impl LoadSkillTool {
12    pub fn new(registry: Arc<CommandRegistry>) -> Self {
13        Self { registry }
14    }
15
16    /// Produce the tool-result body for a successfully loaded skill.
17    /// Shared between user-initiated (slash) and model-initiated (tool) paths.
18    pub fn format_body(skill: &LoadedSkill) -> String {
19        format!(
20            "# Skill: {} — {}\n\nFollow these guidelines for the rest of this conversation.\n\n{}",
21            skill.name, skill.description, skill.body
22        )
23    }
24}
25
26#[async_trait::async_trait]
27impl crate::Tool for LoadSkillTool {
28    fn name(&self) -> &str { "load_skill" }
29
30    fn description(&self) -> &str {
31        "Load a skill to guide your behavior for the current conversation. \
32         Skills provide structured guidelines, checklists, and best practices. \
33         Call this when a task would benefit from a specific methodology."
34    }
35
36    fn parameters(&self) -> serde_json::Value {
37        let list: Vec<String> = self.registry.all_skills().iter()
38            .map(|s| {
39                let qualified = match &s.plugin {
40                    Some(p) => format!("{}:{} — {}", p, s.name, s.description),
41                    None => format!("{} — {}", s.name, s.description),
42                };
43                qualified
44            })
45            .collect();
46        json!({
47            "type": "object",
48            "properties": {
49                "skill": {
50                    "type": "string",
51                    "description": format!("Name of the skill to load (bare or plugin:skill). Available:\n{}", list.join("\n"))
52                }
53            },
54            "required": ["skill"]
55        })
56    }
57
58    async fn execute(
59        &self,
60        params: serde_json::Value,
61        _ctx: crate::ToolContext,
62    ) -> crate::Result<String> {
63        let name = params["skill"].as_str()
64            .ok_or_else(|| crate::RuntimeError::Tool("Missing 'skill' parameter".to_string()))?;
65
66        match self.registry.resolve(name) {
67            Resolution::Skill(s) => Ok(Self::format_body(&s)),
68            Resolution::Ambiguous(opts) => Err(crate::RuntimeError::Tool(format!(
69                "ambiguous skill '{}'; specify one of: {}", name, opts.join(", ")
70            ))),
71            Resolution::PluginCommand(_) | Resolution::Builtin | Resolution::Unknown => Err(crate::RuntimeError::Tool(
72                format!("unknown skill '{}'", name)
73            )),
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::path::PathBuf;
82
83    fn test_ctx() -> crate::ToolContext {
84        crate::ToolContext {
85            channels: crate::tools::ToolChannels {
86                tx_delta: None,
87                tx_events: None,
88            },
89            capabilities: crate::tools::ToolCapabilities {
90                watcher_exit_path: None,
91                tool_register_tx: None,
92                session_manager: None,
93                subagent_registry: None,
94                event_queue: None,
95                secret_prompt: None,
96            },
97            limits: crate::tools::ToolLimits {
98                max_tool_output: 30000,
99                bash_timeout: 30,
100                bash_max_timeout: 300,
101                subagent_timeout: 300,
102            },
103        }
104    }
105
106    fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
107        LoadedSkill {
108            name: name.to_string(),
109            description: format!("desc-{name}"),
110            body: format!("body-{name}"),
111            plugin: plugin.map(str::to_string),
112            base_dir: PathBuf::from("/"),
113            source_path: PathBuf::from("/SKILL.md"),
114        }
115    }
116
117    #[test]
118    fn format_body_includes_name_and_description() {
119        let s = LoadedSkill {
120            name: "x".into(),
121            description: "y".into(),
122            body: "z".into(),
123            plugin: None,
124            base_dir: PathBuf::from("/"),
125            source_path: PathBuf::from("/SKILL.md"),
126        };
127        let out = LoadSkillTool::format_body(&s);
128        assert!(out.contains("x"));
129        assert!(out.contains("y"));
130        assert!(out.contains("z"));
131        assert!(out.contains("Follow these guidelines"));
132    }
133
134    #[tokio::test]
135    async fn execute_returns_skill_body_on_unique_match() {
136        use crate::Tool;
137        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
138            &[], vec![mk("search", Some("p1"))]
139        ));
140        let tool = LoadSkillTool::new(reg);
141        let result = tool.execute(
142            serde_json::json!({"skill": "search"}),
143            test_ctx()
144        ).await.unwrap();
145        assert!(result.contains("# Skill: search"));
146        assert!(result.contains("desc-search"));
147        assert!(result.contains("body-search"));
148    }
149
150    #[tokio::test]
151    async fn execute_errors_on_ambiguous() {
152        use crate::Tool;
153        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
154            &[], vec![mk("search", Some("p1")), mk("search", Some("p2"))]
155        ));
156        let tool = LoadSkillTool::new(reg);
157        let err = tool.execute(
158            serde_json::json!({"skill": "search"}),
159            test_ctx()
160        ).await.unwrap_err();
161        let msg = format!("{err}");
162        assert!(msg.contains("ambiguous"));
163        assert!(msg.contains("p1:search"));
164        assert!(msg.contains("p2:search"));
165    }
166
167    #[tokio::test]
168    async fn execute_errors_on_unknown() {
169        use crate::Tool;
170        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
171        let tool = LoadSkillTool::new(reg);
172        let err = tool.execute(
173            serde_json::json!({"skill": "nosuch"}),
174            test_ctx()
175        ).await.unwrap_err();
176        assert!(format!("{err}").contains("unknown skill 'nosuch'"));
177    }
178
179    #[tokio::test]
180    async fn execute_errors_on_builtin() {
181        use crate::Tool;
182        // A built-in is not a skill; load_skill should refuse to load it.
183        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&["clear"], vec![]));
184        let tool = LoadSkillTool::new(reg);
185        let err = tool.execute(
186            serde_json::json!({"skill": "clear"}),
187            test_ctx()
188        ).await.unwrap_err();
189        assert!(format!("{err}").contains("unknown skill 'clear'"));
190    }
191
192    #[tokio::test]
193    async fn execute_errors_on_missing_skill_param() {
194        use crate::Tool;
195        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
196        let tool = LoadSkillTool::new(reg);
197        let err = tool.execute(
198            serde_json::json!({}),
199            test_ctx()
200        ).await.unwrap_err();
201        assert!(format!("{err}").contains("Missing 'skill' parameter"));
202    }
203
204    #[test]
205    fn parameters_schema_is_well_formed() {
206        use crate::Tool;
207        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
208        let tool = LoadSkillTool::new(reg);
209        let schema = tool.parameters();
210        assert_eq!(schema["type"], "object");
211        assert_eq!(schema["properties"]["skill"]["type"], "string");
212        assert_eq!(schema["required"], serde_json::json!(["skill"]));
213    }
214}