1use std::collections::HashMap;
40use std::path::Path;
41
42#[derive(Debug, Clone)]
56pub struct SkillMeta {
57 pub name: String,
59 pub description: String,
61 pub tags: Vec<String>,
63 pub when_to_use: Option<String>,
67 pub allowed_tools: Vec<String>,
71 pub user_invocable: bool,
76 pub argument_hint: Option<String>,
79 pub source: SkillSource,
81}
82
83#[derive(Debug, Clone)]
85pub enum SkillSource {
86 BuiltIn,
88 User,
90 Project,
92}
93
94#[derive(Debug, Clone)]
96pub struct Skill {
97 pub meta: SkillMeta,
99 pub content: String,
101}
102
103#[derive(Debug, Default)]
105pub struct SkillRegistry {
106 pub(crate) skills: HashMap<String, Skill>,
107}
108
109impl SkillRegistry {
110 pub fn discover(project_root: &Path) -> Self {
112 let mut registry = Self::default();
113
114 registry.load_builtin();
116
117 if let Ok(config_dir) = crate::db::config_dir() {
119 let user_dir = config_dir.join("skills");
120 registry.load_directory(&user_dir, SkillSource::User);
121 }
122
123 let project_dir = project_root.join(".koda").join("skills");
125 registry.load_directory(&project_dir, SkillSource::Project);
126
127 registry
128 }
129
130 fn load_builtin(&mut self) {
132 let builtins: &[(&str, &str)] = &[
133 (
134 "code-review",
135 include_str!("../skills/code-review/SKILL.md"),
136 ),
137 (
138 "security-audit",
139 include_str!("../skills/security-audit/SKILL.md"),
140 ),
141 ("simplify", include_str!("../skills/simplify/SKILL.md")),
142 ("debug", include_str!("../skills/debug/SKILL.md")),
143 ("remember", include_str!("../skills/remember/SKILL.md")),
144 ];
145
146 for (name, content) in builtins {
147 if let Some(skill) = parse_skill_md(content, SkillSource::BuiltIn) {
148 self.skills.insert(name.to_string(), skill);
149 }
150 }
151 }
152
153 fn load_directory(&mut self, dir: &Path, source: SkillSource) {
155 let entries = match std::fs::read_dir(dir) {
156 Ok(e) => e,
157 Err(_) => return,
158 };
159
160 for entry in entries.flatten() {
161 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
162 continue;
163 }
164 let skill_file = entry.path().join("SKILL.md");
165 if let Some(skill) = std::fs::read_to_string(&skill_file)
166 .ok()
167 .and_then(|content| parse_skill_md(&content, source.clone()))
168 {
169 self.skills.insert(skill.meta.name.clone(), skill);
170 }
171 }
172 }
173
174 pub fn list(&self) -> Vec<&SkillMeta> {
176 let mut metas: Vec<&SkillMeta> = self.skills.values().map(|s| &s.meta).collect();
177 metas.sort_by_key(|m| &m.name);
178 metas
179 }
180
181 pub fn list_user_invocable(&self) -> Vec<&SkillMeta> {
186 let mut metas: Vec<&SkillMeta> = self
187 .skills
188 .values()
189 .filter(|s| s.meta.user_invocable)
190 .map(|s| &s.meta)
191 .collect();
192 metas.sort_by_key(|m| &m.name);
193 metas
194 }
195
196 pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
198 let q = query.to_lowercase();
199 let mut results: Vec<&SkillMeta> = self
200 .skills
201 .values()
202 .filter(|s| {
203 s.meta.name.to_lowercase().contains(&q)
204 || s.meta.description.to_lowercase().contains(&q)
205 || s.meta.tags.iter().any(|t| t.to_lowercase().contains(&q))
206 })
207 .map(|s| &s.meta)
208 .collect();
209 results.sort_by_key(|m| &m.name);
210 results
211 }
212
213 pub fn activate(&self, name: &str) -> Option<&str> {
215 self.skills.get(name).map(|s| s.content.as_str())
216 }
217
218 pub fn get(&self, name: &str) -> Option<&Skill> {
223 self.skills.get(name)
224 }
225
226 pub fn add_builtin(
237 &mut self,
238 name: &str,
239 description: &str,
240 when_to_use: Option<&str>,
241 content: &str,
242 ) {
243 let skill = Skill {
244 meta: SkillMeta {
245 name: name.to_string(),
246 description: description.to_string(),
247 tags: vec![],
248 when_to_use: when_to_use.map(str::to_string),
249 allowed_tools: vec![],
250 user_invocable: true,
251 argument_hint: None,
252 source: SkillSource::BuiltIn,
253 },
254 content: content.to_string(),
255 };
256 self.skills.insert(name.to_string(), skill);
257 }
258
259 pub fn len(&self) -> usize {
261 self.skills.len()
262 }
263
264 pub fn is_empty(&self) -> bool {
266 self.skills.is_empty()
267 }
268}
269
270fn parse_skill_md(raw: &str, source: SkillSource) -> Option<Skill> {
283 let trimmed = raw.trim_start();
284 if !trimmed.starts_with("---") {
285 return None;
286 }
287
288 let after_open = &trimmed[3..];
290 let close_pos = after_open.find("\n---")?;
291 let frontmatter = &after_open[..close_pos].trim();
292 let content = after_open[close_pos + 4..].trim_start().to_string();
293
294 let mut name = String::new();
299 let mut description = String::new();
300 let mut tags = Vec::new();
301 let mut when_to_use: Option<String> = None;
302 let mut allowed_tools: Vec<String> = Vec::new();
303 let mut user_invocable = true;
304 let mut argument_hint: Option<String> = None;
305
306 for line in frontmatter.lines() {
307 let line = line.trim();
308 if let Some(val) = line.strip_prefix("name:") {
309 name = val.trim().to_string();
310 } else if let Some(val) = line.strip_prefix("description:") {
311 description = val.trim().to_string();
312 } else if let Some(val) = line.strip_prefix("when_to_use:") {
313 when_to_use = Some(val.trim().to_string());
314 } else if let Some(val) = line
315 .strip_prefix("allowed_tools:")
316 .or_else(|| line.strip_prefix("allowed-tools:"))
317 {
318 let val = val.trim();
320 if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
321 allowed_tools = inner
322 .split(',')
323 .map(|t| t.trim().to_string())
324 .filter(|t| !t.is_empty())
325 .collect();
326 } else if !val.is_empty() {
327 allowed_tools = val
328 .split(',')
329 .map(|t| t.trim().to_string())
330 .filter(|t| !t.is_empty())
331 .collect();
332 }
333 } else if let Some(val) = line
334 .strip_prefix("user_invocable:")
335 .or_else(|| line.strip_prefix("user-invocable:"))
336 {
337 user_invocable = val.trim() != "false";
338 } else if let Some(val) = line
339 .strip_prefix("argument_hint:")
340 .or_else(|| line.strip_prefix("argument-hint:"))
341 {
342 let val = val.trim();
343 if !val.is_empty() {
344 argument_hint = Some(val.to_string());
345 }
346 } else if let Some(val) = line.strip_prefix("tags:") {
347 let val = val.trim();
349 if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
350 tags = inner.split(',').map(|t| t.trim().to_string()).collect();
351 }
352 }
353 }
354
355 if name.is_empty() {
356 return None;
357 }
358
359 Some(Skill {
360 meta: SkillMeta {
361 name,
362 description,
363 tags,
364 when_to_use,
365 allowed_tools,
366 user_invocable,
367 argument_hint,
368 source,
369 },
370 content,
371 })
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_parse_skill_md() {
380 let raw = r#"---
381name: code-review
382description: Senior code review
383tags: [review, quality]
384when_to_use: Use when asked to review code or a PR.
385---
386
387# Code Review
388
389Do the review.
390"#;
391 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
392 assert_eq!(skill.meta.name, "code-review");
393 assert_eq!(skill.meta.description, "Senior code review");
394 assert_eq!(skill.meta.tags, vec!["review", "quality"]);
395 assert_eq!(
396 skill.meta.when_to_use.as_deref(),
397 Some("Use when asked to review code or a PR.")
398 );
399 assert!(skill.meta.allowed_tools.is_empty());
400 assert!(skill.meta.user_invocable);
401 assert!(skill.meta.argument_hint.is_none());
402 assert!(skill.content.contains("# Code Review"));
403 assert!(skill.content.contains("Do the review."));
404 }
405
406 #[test]
407 fn test_parse_allowed_tools() {
408 let raw = "---\nname: scoped\ndescription: Scoped skill\ntags: []\nallowed_tools: [Read, Grep, Glob]\n---\ncontent";
409 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
410 assert_eq!(skill.meta.allowed_tools, vec!["Read", "Grep", "Glob"]);
411 }
412
413 #[test]
414 fn test_parse_allowed_tools_hyphenated() {
415 let raw = "---\nname: scoped\ndescription: Scoped skill\ntags: []\nallowed-tools: [Read, Write]\n---\ncontent";
416 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
417 assert_eq!(skill.meta.allowed_tools, vec!["Read", "Write"]);
418 }
419
420 #[test]
421 fn test_parse_user_invocable_false() {
422 let raw = "---\nname: model-only\ndescription: hidden\ntags: []\nuser_invocable: false\n---\ncontent";
423 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
424 assert!(!skill.meta.user_invocable);
425 }
426
427 #[test]
428 fn test_parse_user_invocable_hyphenated() {
429 let raw = "---\nname: model-only\ndescription: hidden\ntags: []\nuser-invocable: false\n---\ncontent";
430 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
431 assert!(!skill.meta.user_invocable);
432 }
433
434 #[test]
435 fn test_parse_user_invocable_default_true() {
436 let raw = "---\nname: visible\ndescription: shown\ntags: []\n---\ncontent";
437 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
438 assert!(skill.meta.user_invocable);
439 }
440
441 #[test]
442 fn test_parse_argument_hint() {
443 let raw = "---\nname: pdf\ndescription: Generate PDF\ntags: []\nargument_hint: <file_path>\n---\ncontent";
444 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
445 assert_eq!(skill.meta.argument_hint.as_deref(), Some("<file_path>"));
446 }
447
448 #[test]
449 fn test_parse_argument_hint_hyphenated() {
450 let raw = "---\nname: pdf\ndescription: Generate PDF\ntags: []\nargument-hint: <output_dir>\n---\ncontent";
451 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
452 assert_eq!(skill.meta.argument_hint.as_deref(), Some("<output_dir>"));
453 }
454
455 #[test]
456 fn test_list_user_invocable_excludes_model_only() {
457 let mut registry = SkillRegistry::default();
458 registry.add_builtin("user-skill", "for users", None, "content");
459 registry.skills.insert(
461 "model-skill".to_string(),
462 Skill {
463 meta: SkillMeta {
464 name: "model-skill".to_string(),
465 description: "model only".to_string(),
466 tags: vec![],
467 when_to_use: None,
468 allowed_tools: vec![],
469 user_invocable: false,
470 argument_hint: None,
471 source: SkillSource::BuiltIn,
472 },
473 content: "secret".to_string(),
474 },
475 );
476 assert_eq!(registry.list().len(), 2);
477 assert_eq!(registry.list_user_invocable().len(), 1);
478 assert_eq!(registry.list_user_invocable()[0].name, "user-skill");
479 }
480
481 #[test]
482 fn test_get_returns_full_skill() {
483 let mut registry = SkillRegistry::default();
484 registry.add_builtin("test", "desc", None, "body");
485 let skill = registry.get("test").unwrap();
486 assert_eq!(skill.meta.name, "test");
487 assert_eq!(skill.content, "body");
488 }
489
490 #[test]
491 fn test_parse_when_to_use_absent() {
492 let raw = "---\nname: minimal\ndescription: minimal skill\ntags: []\n---\ncontent";
493 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
494 assert!(skill.meta.when_to_use.is_none());
495 }
496
497 #[test]
498 fn test_parse_no_frontmatter() {
499 assert!(parse_skill_md("# Just markdown", SkillSource::BuiltIn).is_none());
500 }
501
502 #[test]
503 fn test_parse_no_name() {
504 let raw = "---\ndescription: no name\n---\ncontent";
505 assert!(parse_skill_md(raw, SkillSource::BuiltIn).is_none());
506 }
507
508 #[test]
509 fn test_builtin_skills_load() {
510 let mut registry = SkillRegistry::default();
511 registry.load_builtin();
512 assert!(registry.len() >= 2);
513 assert!(registry.activate("code-review").is_some());
514 assert!(registry.activate("security-audit").is_some());
515 assert!(registry.activate("simplify").is_some());
516 assert!(registry.activate("debug").is_some());
517 assert!(registry.activate("remember").is_some());
518 }
519
520 #[test]
521 fn test_search() {
522 let mut registry = SkillRegistry::default();
523 registry.load_builtin();
524
525 let results = registry.search("review");
526 assert!(!results.is_empty());
528 assert!(results.iter().any(|s| s.name == "code-review"));
529
530 let results = registry.search("security");
531 assert_eq!(results.len(), 1);
532 assert_eq!(results[0].name, "security-audit");
533 }
534
535 #[test]
536 fn test_search_by_tag() {
537 let mut registry = SkillRegistry::default();
538 registry.load_builtin();
539
540 let results = registry.search("owasp");
541 assert_eq!(results.len(), 1);
542 assert_eq!(results[0].name, "security-audit");
543 }
544
545 #[test]
546 fn test_add_builtin_injects_skill() {
547 let mut registry = SkillRegistry::default();
548 registry.add_builtin(
549 "my-app-docs",
550 "My app user manual",
551 Some("Use when the user asks about the app."),
552 "# My App\n\nDo stuff.",
553 );
554 assert_eq!(registry.len(), 1);
555 let content = registry.activate("my-app-docs").unwrap();
556 assert!(content.contains("Do stuff."));
557 let meta = registry.list();
559 assert!(matches!(meta[0].source, SkillSource::BuiltIn));
560 assert_eq!(
561 meta[0].when_to_use.as_deref(),
562 Some("Use when the user asks about the app.")
563 );
564 }
565
566 #[test]
567 fn test_add_builtin_overwrites_same_name() {
568 let mut registry = SkillRegistry::default();
569 registry.add_builtin("docs", "v1", None, "version one");
570 registry.add_builtin("docs", "v2", None, "version two");
571 assert_eq!(registry.len(), 1);
572 assert!(registry.activate("docs").unwrap().contains("version two"));
573 }
574
575 #[test]
576 fn test_list_sorted() {
577 let mut registry = SkillRegistry::default();
578 registry.load_builtin();
579
580 let list = registry.list();
581 let names: Vec<&str> = list.iter().map(|s| s.name.as_str()).collect();
582 assert!(list.len() >= 5);
584 assert_eq!(names[0], "code-review");
585 assert_eq!(names[1], "debug");
586 assert_eq!(names[2], "remember");
587 assert_eq!(names[3], "security-audit");
588 assert_eq!(names[4], "simplify");
589 }
590
591 #[test]
592 fn test_directory_discovery() {
593 let tmp = tempfile::TempDir::new().unwrap();
594 let skill_dir = tmp.path().join("my-skill");
595 std::fs::create_dir_all(&skill_dir).unwrap();
596 std::fs::write(
597 skill_dir.join("SKILL.md"),
598 "---\nname: my-skill\ndescription: test\ntags: []\n---\n# Test",
599 )
600 .unwrap();
601
602 let mut registry = SkillRegistry::default();
603 registry.load_directory(tmp.path(), SkillSource::Project);
604 assert_eq!(registry.len(), 1);
605 assert!(registry.activate("my-skill").is_some());
606 }
607}