1use crate::tool::{Tool, ToolResult, ToolSchema};
2use async_trait::async_trait;
3use serde::Serialize;
4use serde_json::{Value, json};
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8pub struct SkillTool {
9 skills: BTreeMap<String, SkillEntry>,
10}
11
12#[derive(Debug, Clone, Serialize)]
13struct SkillEntry {
14 name: String,
15 description: String,
16 path: PathBuf,
17}
18
19impl SkillTool {
20 pub fn new(workspace_root: PathBuf) -> Self {
21 let skills = discover_skills(&workspace_root, dirs::home_dir().as_deref());
22 Self { skills }
23 }
24}
25
26#[async_trait]
27impl Tool for SkillTool {
28 fn schema(&self) -> ToolSchema {
29 ToolSchema {
30 name: "skill".to_string(),
31 description: format_skill_description(&self.skills),
32 capability: Some("read".to_string()),
33 mutating: Some(false),
34 parameters: json!({
35 "type": "object",
36 "properties": {
37 "name": {"type": "string"}
38 },
39 "required": ["name"]
40 }),
41 }
42 }
43
44 async fn execute(&self, args: Value) -> ToolResult {
45 let requested_name = args
46 .get("name")
47 .and_then(Value::as_str)
48 .map(str::trim)
49 .filter(|value| !value.is_empty());
50 let Some(name) = requested_name else {
51 return ToolResult::error("missing required argument: name");
52 };
53
54 let Some(entry) = self.skills.get(name) else {
55 let available = if self.skills.is_empty() {
56 "none".to_string()
57 } else {
58 self.skills.keys().cloned().collect::<Vec<_>>().join(", ")
59 };
60 return ToolResult::error(format!("unknown skill '{name}'. available: {available}"));
61 };
62
63 let content = match std::fs::read_to_string(&entry.path) {
64 Ok(content) => content,
65 Err(err) => {
66 return ToolResult::error(format!(
67 "failed to read skill at {}: {err}",
68 entry.path.display()
69 ));
70 }
71 };
72
73 ToolResult::ok_text(
74 format!("loaded skill {}", entry.name),
75 format!(
76 "<skill_content name=\"{}\">\n{}\n</skill_content>",
77 entry.name, content
78 ),
79 )
80 }
81}
82
83fn format_skill_description(skills: &BTreeMap<String, SkillEntry>) -> String {
84 let mut description =
85 "Load a specialized skill that provides domain-specific instructions and workflows."
86 .to_string();
87
88 if skills.is_empty() {
89 description.push_str("\n\nNo skills were found in supported skill directories.");
90 return description;
91 }
92
93 description.push_str("\n\n<available_skills>");
94 for skill in skills.values() {
95 description.push_str("\n<skill>");
96 description.push_str("\n<name>");
97 description.push_str(&skill.name);
98 description.push_str("</name>");
99 description.push_str("\n<description>");
100 description.push_str(&skill.description);
101 description.push_str("</description>");
102 description.push_str("\n<location>");
103 description.push_str(&skill.path.display().to_string());
104 description.push_str("</location>");
105 description.push_str("\n</skill>");
106 }
107 description.push_str("\n</available_skills>");
108 description
109}
110
111fn discover_skills(workspace_root: &Path, home_dir: Option<&Path>) -> BTreeMap<String, SkillEntry> {
112 let mut skills = BTreeMap::new();
113 for root in candidate_skill_roots(workspace_root, home_dir) {
114 let discovered = discover_skills_in_root(&root);
115 for skill in discovered {
116 if !skills.contains_key(&skill.name) {
117 skills.insert(skill.name.clone(), skill);
118 }
119 }
120 }
121 skills
122}
123
124fn candidate_skill_roots(workspace_root: &Path, home_dir: Option<&Path>) -> Vec<PathBuf> {
125 let mut roots = vec![
126 workspace_root.join(".claude/skills"),
127 workspace_root.join(".agents/skills"),
128 ];
129
130 if let Some(home) = home_dir {
131 roots.push(home.join(".claude/skills"));
132 roots.push(home.join(".agents/skills"));
133 }
134
135 roots
136}
137
138fn discover_skills_in_root(root: &Path) -> Vec<SkillEntry> {
139 let Ok(entries) = std::fs::read_dir(root) else {
140 return Vec::new();
141 };
142
143 let mut entry_paths = entries
144 .filter_map(Result::ok)
145 .map(|entry| entry.path())
146 .filter(|path| path.is_dir())
147 .collect::<Vec<_>>();
148
149 entry_paths.sort_by(|left, right| {
150 left.file_name()
151 .unwrap_or_default()
152 .cmp(right.file_name().unwrap_or_default())
153 });
154
155 let mut discovered = Vec::new();
156 for entry_path in entry_paths {
157 let skill_path = entry_path.join("SKILL.md");
158 if !skill_path.is_file() {
159 continue;
160 }
161
162 let content = match std::fs::read_to_string(&skill_path) {
163 Ok(content) => content,
164 Err(_) => continue,
165 };
166
167 let metadata = parse_frontmatter(&content);
168 let name = metadata
169 .name
170 .or_else(|| {
171 entry_path
172 .file_name()
173 .and_then(|value| value.to_str())
174 .map(str::to_string)
175 })
176 .map(|value| value.trim().to_string())
177 .filter(|value| !value.is_empty());
178
179 let Some(name) = name else {
180 continue;
181 };
182
183 let description = metadata
184 .description
185 .map(|value| value.trim().to_string())
186 .filter(|value| !value.is_empty())
187 .unwrap_or_else(|| "No description provided".to_string());
188
189 discovered.push(SkillEntry {
190 name,
191 description,
192 path: skill_path,
193 });
194 }
195
196 discovered
197}
198
199#[derive(Default)]
200struct Frontmatter {
201 name: Option<String>,
202 description: Option<String>,
203}
204
205fn parse_frontmatter(content: &str) -> Frontmatter {
206 let mut lines = content.lines();
207 if lines.next() != Some("---") {
208 return Frontmatter::default();
209 }
210
211 let mut metadata = Frontmatter::default();
212 for line in lines {
213 if line == "---" {
214 break;
215 }
216
217 if let Some(value) = line.strip_prefix("name:") {
218 metadata.name = Some(trim_yaml_scalar(value));
219 continue;
220 }
221
222 if let Some(value) = line.strip_prefix("description:") {
223 metadata.description = Some(trim_yaml_scalar(value));
224 }
225 }
226
227 metadata
228}
229
230fn trim_yaml_scalar(raw: &str) -> String {
231 raw.trim().trim_matches('"').trim_matches('\'').to_string()
232}
233
234#[cfg(test)]
235mod tests {
236 use super::{candidate_skill_roots, discover_skills, parse_frontmatter};
237 use std::fs;
238 use tempfile::tempdir;
239
240 #[test]
241 fn candidate_roots_include_project_then_home() {
242 let workspace = tempdir().expect("workspace tempdir");
243 let home = tempdir().expect("home tempdir");
244
245 let roots = candidate_skill_roots(workspace.path(), Some(home.path()));
246 assert_eq!(roots.len(), 4);
247 assert_eq!(roots[0], workspace.path().join(".claude/skills"));
248 assert_eq!(roots[1], workspace.path().join(".agents/skills"));
249 assert_eq!(roots[2], home.path().join(".claude/skills"));
250 assert_eq!(roots[3], home.path().join(".agents/skills"));
251 }
252
253 #[test]
254 fn project_skill_overrides_home_skill_with_same_name() {
255 let workspace = tempdir().expect("workspace tempdir");
256 let home = tempdir().expect("home tempdir");
257
258 let project_skill_dir = workspace.path().join(".claude/skills/build-release");
259 fs::create_dir_all(&project_skill_dir).expect("create project skill directory");
260 fs::write(
261 project_skill_dir.join("SKILL.md"),
262 "---\nname: build-release\ndescription: project\n---\nproject body",
263 )
264 .expect("write project skill");
265
266 let home_skill_dir = home.path().join(".agents/skills/build-release");
267 fs::create_dir_all(&home_skill_dir).expect("create home skill directory");
268 fs::write(
269 home_skill_dir.join("SKILL.md"),
270 "---\nname: build-release\ndescription: home\n---\nhome body",
271 )
272 .expect("write home skill");
273
274 let skills = discover_skills(workspace.path(), Some(home.path()));
275 let chosen = skills
276 .get("build-release")
277 .expect("expected discovered skill");
278
279 assert!(chosen.path.starts_with(workspace.path()));
280 assert_eq!(chosen.description, "project");
281 }
282
283 #[test]
284 fn parse_frontmatter_extracts_name_and_description() {
285 let metadata = parse_frontmatter(
286 "---\nname: test-skill\ndescription: \"does useful work\"\n---\n# Body",
287 );
288
289 assert_eq!(metadata.name.as_deref(), Some("test-skill"));
290 assert_eq!(metadata.description.as_deref(), Some("does useful work"));
291 }
292}