1use std::path::{Path, PathBuf};
33
34use anyhow::{Context, Result};
35
36#[derive(Debug, Clone)]
38pub struct Skill {
39 pub name: String,
42 pub description: String,
44 pub dir: PathBuf,
46 pub body: String,
48 pub source_file: PathBuf,
50}
51
52impl Skill {
53 pub fn skill_md(&self) -> PathBuf {
55 self.source_file.clone()
56 }
57}
58
59pub fn discover_skills(roots: &[PathBuf]) -> Vec<Skill> {
68 let mut out: Vec<Skill> = Vec::new();
69
70 for root in roots {
71 if !root.is_dir() {
72 continue;
73 }
74 let entries = match std::fs::read_dir(root) {
75 Ok(e) => e,
76 Err(e) => {
77 eprintln!("[warn] could not read skills dir {}: {e}", root.display());
78 continue;
79 }
80 };
81 for entry in entries.flatten() {
82 let path = entry.path();
83 if !path.is_dir() {
84 continue;
85 }
86
87 let skill_md = path.join("SKILL.md");
89 if skill_md.is_file() {
90 match load_skill_from_file(&skill_md, &path) {
91 Ok(skill) => {
92 add_skill(&mut out, skill);
93 }
94 Err(e) => {
95 eprintln!("[warn] skipping skill at {}: {e}", path.display());
96 }
97 }
98 continue;
99 }
100
101 load_multi_file_skills(&path, &mut out);
103 }
104 }
105
106 out.sort_by(|a, b| a.name.cmp(&b.name));
107 out
108}
109
110fn add_skill(out: &mut Vec<Skill>, skill: Skill) {
112 if out.iter().any(|s| s.name == skill.name) {
113 eprintln!(
114 "[warn] duplicate skill name '{}' at {} (ignored)",
115 skill.name,
116 skill.source_file.display()
117 );
118 return;
119 }
120 out.push(skill);
121}
122
123fn load_multi_file_skills(dir: &Path, out: &mut Vec<Skill>) {
126 let entries = match std::fs::read_dir(dir) {
127 Ok(e) => e,
128 Err(e) => {
129 eprintln!("[warn] could not read skill dir {}: {e}", dir.display());
130 return;
131 }
132 };
133
134 for entry in entries.flatten() {
135 let path = entry.path();
136 if !path.is_file() {
137 continue;
138 }
139
140 let ext = path.extension().and_then(|e| e.to_str());
142 if ext != Some("md") {
143 continue;
144 }
145
146 if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
148 continue;
149 }
150
151 match load_skill_from_file(&path, dir) {
152 Ok(skill) => {
153 add_skill(out, skill);
154 }
155 Err(e) => {
156 let raw = std::fs::read_to_string(&path).unwrap_or_default();
158 if raw.trim_start().starts_with("---") {
159 eprintln!("[warn] skipping skill file {}: {e}", path.display());
160 }
161 }
162 }
163 }
164}
165
166pub fn load_skill_from_file(md_path: &Path, dir: &Path) -> Result<Skill> {
169 let raw = std::fs::read_to_string(md_path)
170 .with_context(|| format!("reading {}", md_path.display()))?;
171 let (front, body) = split_frontmatter(&raw)
172 .with_context(|| format!("parsing frontmatter of {}", md_path.display()))?;
173
174 let name = front
176 .get("name")
177 .cloned()
178 .filter(|s| !s.is_empty())
179 .or_else(|| {
180 md_path
181 .file_stem()
182 .and_then(|n| n.to_str())
183 .map(|s| s.to_string())
184 })
185 .or_else(|| {
186 dir.file_name()
187 .and_then(|n| n.to_str())
188 .map(|s| s.to_string())
189 })
190 .ok_or_else(|| anyhow::anyhow!("skill has no 'name' in frontmatter"))?;
191
192 let description = front
193 .get("description")
194 .cloned()
195 .unwrap_or_else(|| "(no description)".to_string());
196
197 Ok(Skill {
198 name,
199 description,
200 dir: dir.to_path_buf(),
201 body: body.to_string(),
202 source_file: md_path.to_path_buf(),
203 })
204}
205
206pub fn load_skill(dir: &Path) -> Result<Skill> {
208 let md_path = dir.join("SKILL.md");
209 load_skill_from_file(&md_path, dir)
210}
211
212fn split_frontmatter(raw: &str) -> Result<(std::collections::BTreeMap<String, String>, &str)> {
226 let mut front = std::collections::BTreeMap::new();
227
228 let trimmed = raw.trim_start_matches('\u{feff}'); let Some(rest) = trimmed.strip_prefix("---") else {
230 return Ok((front, trimmed));
231 };
232 let rest = rest.strip_prefix('\n').or_else(|| rest.strip_prefix("\r\n"));
234 let Some(rest) = rest else {
235 return Ok((front, trimmed));
236 };
237
238 let mut end_idx: Option<usize> = None;
240 let mut cursor = 0usize;
241 for line in rest.split_inclusive('\n') {
242 let trimmed_line = line.trim_end_matches(['\n', '\r']);
243 if trimmed_line == "---" {
244 end_idx = Some(cursor + line.len());
245 break;
246 }
247 cursor += line.len();
248 }
249 let Some(end) = end_idx else {
250 return Ok((front, trimmed));
252 };
253
254 let front_block = &rest[..cursor];
255 let body = rest[end..].trim_start_matches(['\n', '\r']);
256
257 for line in front_block.lines() {
258 let line = line.trim();
259 if line.is_empty() || line.starts_with('#') {
260 continue;
261 }
262 let Some((k, v)) = line.split_once(':') else {
263 continue;
264 };
265 let key = k.trim().to_string();
266 let val = unquote(v.trim());
267 if !key.is_empty() {
268 front.insert(key, val);
269 }
270 }
271
272 Ok((front, body))
273}
274
275fn unquote(s: &str) -> String {
276 let bytes = s.as_bytes();
277 if bytes.len() >= 2
278 && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
279 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
280 {
281 return s[1..s.len() - 1].to_string();
282 }
283 s.to_string()
284}
285
286pub fn format_catalogue(skills: &[Skill]) -> Option<String> {
290 if skills.is_empty() {
291 return None;
292 }
293 let mut s = String::from(
294 "Use the `skill` tool with the skill's name to load its full instructions:
295",
296 );
297 for sk in skills {
298 s.push_str(&format!("- {}: {}
299", sk.name, sk.description));
300 }
301 Some(s)
302}
303
304pub fn list_skill_files(dir: &Path) -> Vec<String> {
308 let mut out = Vec::new();
309 walk(dir, dir, &mut out);
310 out.sort();
311 out
312}
313
314fn walk(root: &Path, cur: &Path, out: &mut Vec<String>) {
315 let entries = match std::fs::read_dir(cur) {
316 Ok(e) => e,
317 Err(_) => return,
318 };
319 for entry in entries.flatten() {
320 let p = entry.path();
321 let file_type = match entry.file_type() {
322 Ok(t) => t,
323 Err(_) => continue,
324 };
325 if file_type.is_dir() {
326 walk(root, &p, out);
327 } else if file_type.is_file()
328 && let Ok(rel) = p.strip_prefix(root) {
329 out.push(rel.display().to_string());
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use tempfile::tempdir;
338
339 fn write_file(path: &Path, body: &str) {
340 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
341 std::fs::write(path, body).unwrap();
342 }
343
344 #[test]
345 fn parses_basic_frontmatter() {
346 let (front, body) =
347 split_frontmatter("---\nname: foo\ndescription: hi there\n---\nbody text\n").unwrap();
348 assert_eq!(front.get("name").unwrap(), "foo");
349 assert_eq!(front.get("description").unwrap(), "hi there");
350 assert_eq!(body, "body text\n");
351 }
352
353 #[test]
354 fn quoted_values_are_unwrapped() {
355 let (front, _) =
356 split_frontmatter("---\nname: 'foo bar'\ndescription: \"baz\"\n---\nx").unwrap();
357 assert_eq!(front.get("name").unwrap(), "foo bar");
358 assert_eq!(front.get("description").unwrap(), "baz");
359 }
360
361 #[test]
362 fn missing_frontmatter_returns_whole_body() {
363 let (front, body) = split_frontmatter("just markdown\nno front").unwrap();
364 assert!(front.is_empty());
365 assert_eq!(body, "just markdown\nno front");
366 }
367
368 #[test]
369 fn unclosed_frontmatter_falls_back_to_body() {
370 let (front, body) = split_frontmatter("---\nname: foo\nbody without close").unwrap();
371 assert!(front.is_empty());
372 assert!(body.starts_with("---"));
373 }
374
375 #[test]
376 fn discover_loads_skill_directory() {
377 let tmp = tempdir().unwrap();
378 let root = tmp.path().join("skills");
379 write_file(
380 &root.join("greet/SKILL.md"),
381 "---\nname: greet\ndescription: say hi\n---\nSay hello to the user.\n",
382 );
383 write_file(&root.join("greet/extra.txt"), "support file");
384
385 let skills = discover_skills(&[root]);
386 assert_eq!(skills.len(), 1);
387 assert_eq!(skills[0].name, "greet");
388 assert_eq!(skills[0].description, "say hi");
389 assert!(skills[0].body.contains("Say hello"));
390 let files = list_skill_files(&skills[0].dir);
391 assert!(files.iter().any(|f| f == "SKILL.md"));
392 assert!(files.iter().any(|f| f == "extra.txt"));
393 }
394
395 #[test]
396 fn discover_loads_multi_file_skills() {
397 let tmp = tempdir().unwrap();
398 let root = tmp.path().join("skills");
399 write_file(
400 &root.join("om/debug.md"),
401 "---\nname: debug\ndescription: debug issues\n---\nDebug workflow.\n",
402 );
403 write_file(
404 &root.join("om/feature.md"),
405 "---\nname: feature\ndescription: build features\n---\nFeature workflow.\n",
406 );
407
408 let skills = discover_skills(&[root]);
409 assert_eq!(skills.len(), 2);
410
411 let debug_skill = skills.iter().find(|s| s.name == "debug").unwrap();
412 assert_eq!(debug_skill.description, "debug issues");
413 assert!(debug_skill.body.contains("Debug workflow"));
414
415 let feature_skill = skills.iter().find(|s| s.name == "feature").unwrap();
416 assert_eq!(feature_skill.description, "build features");
417 assert!(feature_skill.body.contains("Feature workflow"));
418 }
419
420 #[test]
421 fn multi_file_skill_name_from_filename() {
422 let tmp = tempdir().unwrap();
423 let root = tmp.path().join("skills");
424 write_file(
426 &root.join("utils/helper.md"),
427 "---\ndescription: a helper\n---\nHelper content.\n",
428 );
429
430 let skills = discover_skills(&[root]);
431 assert_eq!(skills.len(), 1);
432 assert_eq!(skills[0].name, "helper");
433 }
434
435 #[test]
436 fn duplicate_names_are_dropped() {
437 let tmp = tempdir().unwrap();
438 let a = tmp.path().join("a");
439 let b = tmp.path().join("b");
440 write_file(
441 &a.join("x/SKILL.md"),
442 "---\nname: x\ndescription: first\n---\nA\n",
443 );
444 write_file(
445 &b.join("x/SKILL.md"),
446 "---\nname: x\ndescription: second\n---\nB\n",
447 );
448 let skills = discover_skills(&[a, b]);
449 assert_eq!(skills.len(), 1);
450 assert_eq!(skills[0].description, "first");
451 }
452
453 #[test]
454 fn missing_root_is_skipped() {
455 let skills = discover_skills(&[PathBuf::from("/definitely/not/here")]);
456 assert!(skills.is_empty());
457 }
458
459 #[test]
460 fn catalogue_renders_or_skips() {
461 assert!(format_catalogue(&[]).is_none());
462 let s = Skill {
463 name: "demo".into(),
464 description: "does stuff".into(),
465 dir: PathBuf::from("/tmp"),
466 body: String::new(),
467 source_file: PathBuf::from("/tmp/demo.md"),
468 };
469 let cat = format_catalogue(&[s]).unwrap();
470 assert!(cat.contains("Use the `skill` tool"));
471 assert!(cat.contains("demo: does stuff"));
472 assert!(!cat.contains("Available skills"));
473 }
474}