Skip to main content

dot/
skills.rs

1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::tools::Tool;
7
8#[derive(Debug, Clone)]
9pub struct SkillInfo {
10    pub name: String,
11    pub description: String,
12    pub path: PathBuf,
13}
14
15pub struct SkillRegistry {
16    skills: Vec<SkillInfo>,
17}
18
19impl SkillRegistry {
20    pub fn discover() -> Self {
21        let mut skills = Vec::new();
22        let mut seen_names = std::collections::HashSet::new();
23
24        for base in Self::search_paths() {
25            if !base.exists() {
26                continue;
27            }
28            let entries = match fs::read_dir(&base) {
29                Ok(e) => e,
30                Err(_) => continue,
31            };
32            for entry in entries.flatten() {
33                let skill_dir = entry.path();
34                if !skill_dir.is_dir() {
35                    continue;
36                }
37                let skill_file = skill_dir.join("SKILL.md");
38                if skill_file.exists()
39                    && let Some(info) = Self::parse_skill(&skill_file)
40                    && seen_names.insert(info.name.clone())
41                {
42                    skills.push(info);
43                }
44            }
45        }
46
47        tracing::info!("Discovered {} skills", skills.len());
48        SkillRegistry { skills }
49    }
50
51    fn search_paths() -> Vec<PathBuf> {
52        let mut paths = Vec::new();
53        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
54        let config_dir = crate::config::Config::config_dir();
55        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
56
57        paths.push(config_dir.join("skills"));
58        paths.push(home.join(".agents").join("skills"));
59        paths.push(home.join(".claude").join("skills"));
60
61        paths.push(cwd.join(".dot").join("skills"));
62        paths.push(cwd.join(".agents").join("skills"));
63        paths.push(cwd.join(".claude").join("skills"));
64
65        paths
66    }
67
68    fn parse_skill(path: &Path) -> Option<SkillInfo> {
69        let content = fs::read_to_string(path).ok()?;
70        let name = path.parent()?.file_name()?.to_string_lossy().to_string();
71
72        let description = if let Some(stripped) = content.strip_prefix("---") {
73            if let Some(end) = stripped.find("---") {
74                let frontmatter = &stripped[..end];
75                Self::extract_field(frontmatter, "description")
76                    .unwrap_or_else(|| Self::first_meaningful_line(&content, 3 + end + 3))
77            } else {
78                Self::first_meaningful_line(&content, 0)
79            }
80        } else {
81            Self::first_meaningful_line(&content, 0)
82        };
83
84        Some(SkillInfo {
85            name,
86            description,
87            path: path.to_path_buf(),
88        })
89    }
90
91    fn extract_field(frontmatter: &str, field: &str) -> Option<String> {
92        let prefix = format!("{}:", field);
93        for line in frontmatter.lines() {
94            let trimmed = line.trim();
95            if let Some(value) = trimmed.strip_prefix(&prefix) {
96                let value = value.trim().trim_matches('"').trim_matches('\'');
97                if !value.is_empty() {
98                    return Some(value.to_string());
99                }
100            }
101        }
102        None
103    }
104
105    fn first_meaningful_line(content: &str, skip: usize) -> String {
106        content
107            .get(skip..)
108            .unwrap_or("")
109            .lines()
110            .find(|l| {
111                let t = l.trim();
112                !t.is_empty() && !t.starts_with('#') && !t.starts_with("---")
113            })
114            .unwrap_or("No description")
115            .trim()
116            .chars()
117            .take(120)
118            .collect()
119    }
120
121    pub fn skills(&self) -> &[SkillInfo] {
122        &self.skills
123    }
124
125    pub fn is_empty(&self) -> bool {
126        self.skills.is_empty()
127    }
128
129    pub fn into_tool(self) -> Option<SkillTool> {
130        if self.skills.is_empty() {
131            return None;
132        }
133        Some(SkillTool {
134            skills: self.skills,
135        })
136    }
137}
138
139pub struct SkillTool {
140    skills: Vec<SkillInfo>,
141}
142
143impl Tool for SkillTool {
144    fn name(&self) -> &str {
145        "skill"
146    }
147
148    fn description(&self) -> &str {
149        "Load a skill by name for specialized domain guidance. Use this when the task matches an available skill."
150    }
151
152    fn input_schema(&self) -> Value {
153        let skill_names: Vec<&str> = self.skills.iter().map(|s| s.name.as_str()).collect();
154        let desc_list: Vec<String> = self
155            .skills
156            .iter()
157            .map(|s| format!("{}: {}", s.name, s.description))
158            .collect();
159
160        serde_json::json!({
161            "type": "object",
162            "properties": {
163                "name": {
164                    "type": "string",
165                    "description": format!("Skill to load. Available: {}", desc_list.join("; ")),
166                    "enum": skill_names
167                }
168            },
169            "required": ["name"]
170        })
171    }
172
173    fn execute(&self, input: Value) -> Result<String> {
174        let name = input["name"]
175            .as_str()
176            .context("Missing required parameter 'name'")?;
177
178        let info = self
179            .skills
180            .iter()
181            .find(|s| s.name == name)
182            .with_context(|| {
183                format!(
184                    "Unknown skill '{}'. Available: {}",
185                    name,
186                    self.skills
187                        .iter()
188                        .map(|s| s.name.as_str())
189                        .collect::<Vec<_>>()
190                        .join(", ")
191                )
192            })?;
193
194        fs::read_to_string(&info.path).with_context(|| format!("Failed to read skill '{}'", name))
195    }
196}