synaps_cli/skills/
tool.rs1use 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 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 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}