Skip to main content

hh_cli/tool/
skill.rs

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}