1use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::path::Path;
21use std::sync::RwLock;
22
23#[derive(Debug, Deserialize)]
25struct SkillFrontmatter {
26 name: String,
27 description: String,
28 #[serde(default)]
29 license: Option<String>,
30 #[serde(default)]
31 compatibility: Option<String>,
32 #[serde(default)]
33 metadata: SkillFrontmatterMetadata,
34}
35
36#[derive(Debug, Default, Deserialize)]
37struct SkillFrontmatterMetadata {
38 #[serde(default, rename = "short-description")]
39 short_description: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct SkillDefinition {
46 pub name: String,
47 pub description: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub short_description: Option<String>,
50 pub content: String,
51 pub source: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub license: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub compatibility: Option<String>,
56 #[serde(default)]
57 pub metadata: HashMap<String, String>,
58}
59
60const SKILL_DIRS: &[&str] = &[
62 ".opencode/skills",
63 ".claude/skills",
64 ".agents/skills",
65 ".codex/skills",
66 ".cursor/skills",
67 ".gemini/skills",
68];
69
70const SKILL_FILENAME: &str = "SKILL.md";
71
72pub struct SkillRegistry {
74 skills: RwLock<HashMap<String, SkillDefinition>>,
75}
76
77impl Default for SkillRegistry {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl SkillRegistry {
84 pub fn new() -> Self {
85 Self {
86 skills: RwLock::new(HashMap::new()),
87 }
88 }
89
90 pub fn reload(&self, cwd: &str) {
92 let mut discovered = HashMap::new();
93
94 let cwd_path = Path::new(cwd);
95
96 for dir_pattern in SKILL_DIRS {
98 let skill_dir = cwd_path.join(dir_pattern);
99 if skill_dir.is_dir() {
100 discover_skills_in_dir(&skill_dir, &mut discovered);
101 }
102 }
103
104 if let Some(home) = dirs::home_dir() {
106 for dir_pattern in SKILL_DIRS {
107 let skill_dir = home.join(dir_pattern);
108 if skill_dir.is_dir() {
109 discover_skills_in_dir(&skill_dir, &mut discovered);
110 }
111 }
112 }
113
114 let count = discovered.len();
115 if let Ok(mut skills) = self.skills.write() {
116 *skills = discovered;
117 }
118 tracing::info!("Discovered {} skills", count);
119 }
120
121 pub fn get_skill(&self, name: &str) -> Option<SkillDefinition> {
123 self.skills.read().ok().and_then(|s| s.get(name).cloned())
124 }
125
126 pub fn list_skills(&self) -> Vec<SkillDefinition> {
128 self.skills
129 .read()
130 .map(|s| s.values().cloned().collect())
131 .unwrap_or_default()
132 }
133}
134
135fn discover_skills_in_dir(dir: &Path, out: &mut HashMap<String, SkillDefinition>) {
137 discover_skills_recursive(dir, out, 0, 2);
138}
139
140fn discover_skills_recursive(
141 dir: &Path,
142 out: &mut HashMap<String, SkillDefinition>,
143 depth: usize,
144 max_depth: usize,
145) {
146 if depth > max_depth {
147 return;
148 }
149
150 let entries = match std::fs::read_dir(dir) {
151 Ok(entries) => entries,
152 Err(_) => return,
153 };
154
155 for entry in entries.flatten() {
156 let path = entry.path();
157 if path.is_dir() {
158 let skill_file = path.join(SKILL_FILENAME);
159 if skill_file.is_file() {
160 if let Some(skill) = parse_skill_file(&skill_file) {
161 out.insert(skill.name.clone(), skill);
162 }
163 }
164 discover_skills_recursive(&path, out, depth + 1, max_depth);
166 } else if path
167 .file_name()
168 .map(|f| f == SKILL_FILENAME)
169 .unwrap_or(false)
170 {
171 if let Some(skill) = parse_skill_file(&path) {
172 out.insert(skill.name.clone(), skill);
173 }
174 }
175 }
176}
177
178fn extract_frontmatter(contents: &str) -> Option<(String, String)> {
180 let mut lines = contents.lines();
181 if !matches!(lines.next(), Some(line) if line.trim() == "---") {
182 return None;
183 }
184
185 let mut frontmatter_lines: Vec<&str> = Vec::new();
186 let mut body_start = false;
187 let mut body_lines: Vec<&str> = Vec::new();
188
189 for line in lines {
190 if !body_start {
191 if line.trim() == "---" {
192 body_start = true;
193 } else {
194 frontmatter_lines.push(line);
195 }
196 } else {
197 body_lines.push(line);
198 }
199 }
200
201 if frontmatter_lines.is_empty() || !body_start {
202 return None;
203 }
204
205 Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
206}
207
208fn parse_skill_file(path: &Path) -> Option<SkillDefinition> {
214 let raw = std::fs::read_to_string(path).ok()?;
215
216 if let Some((frontmatter_str, body)) = extract_frontmatter(&raw) {
218 if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&frontmatter_str) {
219 let short_desc = fm.metadata.short_description.filter(|s| !s.is_empty());
220
221 return Some(SkillDefinition {
222 name: fm.name,
223 description: fm.description,
224 short_description: short_desc,
225 content: body.trim().to_string(),
226 source: path.to_string_lossy().to_string(),
227 license: fm.license,
228 compatibility: fm.compatibility,
229 metadata: HashMap::new(),
230 });
231 }
232 }
233
234 let name = path
236 .parent()
237 .and_then(|p| p.file_name())
238 .map(|n| n.to_string_lossy().to_string())
239 .unwrap_or_else(|| "unknown".to_string());
240
241 let description = raw
242 .lines()
243 .skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
244 .take_while(|l| !l.trim().is_empty())
245 .collect::<Vec<_>>()
246 .join(" ");
247
248 Some(SkillDefinition {
249 name,
250 description: if description.is_empty() {
251 "No description".to_string()
252 } else {
253 description
254 },
255 short_description: None,
256 content: raw,
257 source: path.to_string_lossy().to_string(),
258 license: None,
259 compatibility: None,
260 metadata: HashMap::new(),
261 })
262}