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 "create-agent",
146 include_str!("../skills/create-agent/SKILL.md"),
147 ),
148 (
149 "create-skill",
150 include_str!("../skills/create-skill/SKILL.md"),
151 ),
152 ];
153
154 for (name, content) in builtins {
155 if let Some(skill) = parse_skill_md(content, SkillSource::BuiltIn) {
156 self.skills.insert(name.to_string(), skill);
157 }
158 }
159 }
160
161 fn load_directory(&mut self, dir: &Path, source: SkillSource) {
163 let entries = match std::fs::read_dir(dir) {
164 Ok(e) => e,
165 Err(_) => return,
166 };
167
168 for entry in entries.flatten() {
169 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
170 continue;
171 }
172 let skill_file = entry.path().join("SKILL.md");
173 if let Some(skill) = std::fs::read_to_string(&skill_file)
174 .ok()
175 .and_then(|content| parse_skill_md(&content, source.clone()))
176 {
177 self.skills.insert(skill.meta.name.clone(), skill);
178 }
179 }
180 }
181
182 pub fn list(&self) -> Vec<&SkillMeta> {
184 let mut metas: Vec<&SkillMeta> = self.skills.values().map(|s| &s.meta).collect();
185 metas.sort_by_key(|m| &m.name);
186 metas
187 }
188
189 pub fn list_user_invocable(&self) -> Vec<&SkillMeta> {
194 let mut metas: Vec<&SkillMeta> = self
195 .skills
196 .values()
197 .filter(|s| s.meta.user_invocable)
198 .map(|s| &s.meta)
199 .collect();
200 metas.sort_by_key(|m| &m.name);
201 metas
202 }
203
204 pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
206 let q = query.to_lowercase();
207 let mut results: Vec<&SkillMeta> = self
208 .skills
209 .values()
210 .filter(|s| {
211 s.meta.name.to_lowercase().contains(&q)
212 || s.meta.description.to_lowercase().contains(&q)
213 || s.meta.tags.iter().any(|t| t.to_lowercase().contains(&q))
214 })
215 .map(|s| &s.meta)
216 .collect();
217 results.sort_by_key(|m| &m.name);
218 results
219 }
220
221 pub fn activate(&self, name: &str) -> Option<&str> {
223 self.skills.get(name).map(|s| s.content.as_str())
224 }
225
226 pub fn get(&self, name: &str) -> Option<&Skill> {
231 self.skills.get(name)
232 }
233
234 pub fn add_builtin(
245 &mut self,
246 name: &str,
247 description: &str,
248 when_to_use: Option<&str>,
249 content: &str,
250 ) {
251 let skill = Skill {
252 meta: SkillMeta {
253 name: name.to_string(),
254 description: description.to_string(),
255 tags: vec![],
256 when_to_use: when_to_use.map(str::to_string),
257 allowed_tools: vec![],
258 user_invocable: true,
259 argument_hint: None,
260 source: SkillSource::BuiltIn,
261 },
262 content: content.to_string(),
263 };
264 self.skills.insert(name.to_string(), skill);
265 }
266
267 pub fn len(&self) -> usize {
269 self.skills.len()
270 }
271
272 pub fn is_empty(&self) -> bool {
274 self.skills.is_empty()
275 }
276}
277
278fn parse_skill_md(raw: &str, source: SkillSource) -> Option<Skill> {
291 let trimmed = raw.trim_start();
292 if !trimmed.starts_with("---") {
293 return None;
294 }
295
296 let after_open = &trimmed[3..];
298 let close_pos = after_open.find("\n---")?;
299 let frontmatter = &after_open[..close_pos].trim();
300 let content = after_open[close_pos + 4..].trim_start().to_string();
301
302 let mut name = String::new();
307 let mut description = String::new();
308 let mut tags = Vec::new();
309 let mut when_to_use: Option<String> = None;
310 let mut allowed_tools: Vec<String> = Vec::new();
311 let mut user_invocable = true;
312 let mut argument_hint: Option<String> = None;
313
314 for line in frontmatter.lines() {
315 let line = line.trim();
316 if let Some(val) = line.strip_prefix("name:") {
317 name = val.trim().to_string();
318 } else if let Some(val) = line.strip_prefix("description:") {
319 description = val.trim().to_string();
320 } else if let Some(val) = line.strip_prefix("when_to_use:") {
321 when_to_use = Some(val.trim().to_string());
322 } else if let Some(val) = line
323 .strip_prefix("allowed_tools:")
324 .or_else(|| line.strip_prefix("allowed-tools:"))
325 {
326 let val = val.trim();
328 if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
329 allowed_tools = inner
330 .split(',')
331 .map(|t| t.trim().to_string())
332 .filter(|t| !t.is_empty())
333 .collect();
334 } else if !val.is_empty() {
335 allowed_tools = val
336 .split(',')
337 .map(|t| t.trim().to_string())
338 .filter(|t| !t.is_empty())
339 .collect();
340 }
341 } else if let Some(val) = line
342 .strip_prefix("user_invocable:")
343 .or_else(|| line.strip_prefix("user-invocable:"))
344 {
345 user_invocable = val.trim() != "false";
346 } else if let Some(val) = line
347 .strip_prefix("argument_hint:")
348 .or_else(|| line.strip_prefix("argument-hint:"))
349 {
350 let val = val.trim();
351 if !val.is_empty() {
352 argument_hint = Some(val.to_string());
353 }
354 } else if let Some(val) = line.strip_prefix("tags:") {
355 let val = val.trim();
357 if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
358 tags = inner.split(',').map(|t| t.trim().to_string()).collect();
359 }
360 }
361 }
362
363 if name.is_empty() {
364 return None;
365 }
366
367 Some(Skill {
368 meta: SkillMeta {
369 name,
370 description,
371 tags,
372 when_to_use,
373 allowed_tools,
374 user_invocable,
375 argument_hint,
376 source,
377 },
378 content,
379 })
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn test_parse_skill_md() {
388 let raw = r#"---
389name: code-review
390description: Senior code review
391tags: [review, quality]
392when_to_use: Use when asked to review code or a PR.
393---
394
395# Code Review
396
397Do the review.
398"#;
399 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
400 assert_eq!(skill.meta.name, "code-review");
401 assert_eq!(skill.meta.description, "Senior code review");
402 assert_eq!(skill.meta.tags, vec!["review", "quality"]);
403 assert_eq!(
404 skill.meta.when_to_use.as_deref(),
405 Some("Use when asked to review code or a PR.")
406 );
407 assert!(skill.meta.allowed_tools.is_empty());
408 assert!(skill.meta.user_invocable);
409 assert!(skill.meta.argument_hint.is_none());
410 assert!(skill.content.contains("# Code Review"));
411 assert!(skill.content.contains("Do the review."));
412 }
413
414 #[test]
415 fn test_parse_allowed_tools() {
416 let raw = "---\nname: scoped\ndescription: Scoped skill\ntags: []\nallowed_tools: [Read, Grep, Glob]\n---\ncontent";
417 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
418 assert_eq!(skill.meta.allowed_tools, vec!["Read", "Grep", "Glob"]);
419 }
420
421 #[test]
422 fn test_parse_allowed_tools_hyphenated() {
423 let raw = "---\nname: scoped\ndescription: Scoped skill\ntags: []\nallowed-tools: [Read, Write]\n---\ncontent";
424 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
425 assert_eq!(skill.meta.allowed_tools, vec!["Read", "Write"]);
426 }
427
428 #[test]
429 fn test_parse_user_invocable_false() {
430 let raw = "---\nname: model-only\ndescription: hidden\ntags: []\nuser_invocable: false\n---\ncontent";
431 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
432 assert!(!skill.meta.user_invocable);
433 }
434
435 #[test]
436 fn test_parse_user_invocable_hyphenated() {
437 let raw = "---\nname: model-only\ndescription: hidden\ntags: []\nuser-invocable: false\n---\ncontent";
438 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
439 assert!(!skill.meta.user_invocable);
440 }
441
442 #[test]
443 fn test_parse_user_invocable_default_true() {
444 let raw = "---\nname: visible\ndescription: shown\ntags: []\n---\ncontent";
445 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
446 assert!(skill.meta.user_invocable);
447 }
448
449 #[test]
450 fn test_parse_argument_hint() {
451 let raw = "---\nname: pdf\ndescription: Generate PDF\ntags: []\nargument_hint: <file_path>\n---\ncontent";
452 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
453 assert_eq!(skill.meta.argument_hint.as_deref(), Some("<file_path>"));
454 }
455
456 #[test]
457 fn test_parse_argument_hint_hyphenated() {
458 let raw = "---\nname: pdf\ndescription: Generate PDF\ntags: []\nargument-hint: <output_dir>\n---\ncontent";
459 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
460 assert_eq!(skill.meta.argument_hint.as_deref(), Some("<output_dir>"));
461 }
462
463 #[test]
464 fn test_list_user_invocable_excludes_model_only() {
465 let mut registry = SkillRegistry::default();
466 registry.add_builtin("user-skill", "for users", None, "content");
467 registry.skills.insert(
469 "model-skill".to_string(),
470 Skill {
471 meta: SkillMeta {
472 name: "model-skill".to_string(),
473 description: "model only".to_string(),
474 tags: vec![],
475 when_to_use: None,
476 allowed_tools: vec![],
477 user_invocable: false,
478 argument_hint: None,
479 source: SkillSource::BuiltIn,
480 },
481 content: "secret".to_string(),
482 },
483 );
484 assert_eq!(registry.list().len(), 2);
485 assert_eq!(registry.list_user_invocable().len(), 1);
486 assert_eq!(registry.list_user_invocable()[0].name, "user-skill");
487 }
488
489 #[test]
490 fn test_get_returns_full_skill() {
491 let mut registry = SkillRegistry::default();
492 registry.add_builtin("test", "desc", None, "body");
493 let skill = registry.get("test").unwrap();
494 assert_eq!(skill.meta.name, "test");
495 assert_eq!(skill.content, "body");
496 }
497
498 #[test]
499 fn test_parse_when_to_use_absent() {
500 let raw = "---\nname: minimal\ndescription: minimal skill\ntags: []\n---\ncontent";
501 let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
502 assert!(skill.meta.when_to_use.is_none());
503 }
504
505 #[test]
506 fn test_parse_no_frontmatter() {
507 assert!(parse_skill_md("# Just markdown", SkillSource::BuiltIn).is_none());
508 }
509
510 #[test]
511 fn test_parse_no_name() {
512 let raw = "---\ndescription: no name\n---\ncontent";
513 assert!(parse_skill_md(raw, SkillSource::BuiltIn).is_none());
514 }
515
516 #[test]
517 fn test_builtin_skills_load() {
518 let mut registry = SkillRegistry::default();
519 registry.load_builtin();
520 assert!(registry.len() >= 2);
521 assert!(registry.activate("code-review").is_some());
522 assert!(registry.activate("security-audit").is_some());
523 assert!(registry.activate("simplify").is_some());
524 assert!(registry.activate("debug").is_some());
525 assert!(registry.activate("remember").is_some());
526 assert!(registry.activate("create-agent").is_some());
527 assert!(registry.activate("create-skill").is_some());
528 }
529
530 #[test]
535 fn test_creation_skills_are_complete() {
536 let mut registry = SkillRegistry::default();
537 registry.load_builtin();
538
539 let agent = registry
541 .get("create-agent")
542 .expect("create-agent skill must load");
543 assert!(
544 agent.meta.when_to_use.is_some(),
545 "create-agent needs when_to_use for auto-activation"
546 );
547 assert!(
548 !agent.meta.allowed_tools.is_empty(),
549 "create-agent should scope its tools (least privilege)"
550 );
551 let agent_body = registry.activate("create-agent").unwrap();
554 assert!(
555 agent_body.contains("write_access"),
556 "create-agent must teach the write_access field"
557 );
558 assert!(
559 agent_body.contains("footgun") || agent_body.contains("silently"),
560 "create-agent must warn about the write_access default-false footgun"
561 );
562 assert!(
564 agent_body.contains(".koda/agents/"),
565 "create-agent must document project-scope path"
566 );
567 assert!(
568 agent_body.contains("~/.config/koda/agents/"),
569 "create-agent must document personal-scope path (~/.config/koda/, NOT ~/.koda/)"
570 );
571 assert!(
573 agent_body.contains("koda-core/agents/explore.json"),
574 "create-agent must point at a reference example"
575 );
576
577 let skill = registry
579 .get("create-skill")
580 .expect("create-skill skill must load");
581 assert!(
582 skill.meta.when_to_use.is_some(),
583 "create-skill needs when_to_use for auto-activation"
584 );
585 assert!(
586 !skill.meta.allowed_tools.is_empty(),
587 "create-skill should scope its tools (least privilege)"
588 );
589 let skill_body = registry.activate("create-skill").unwrap();
590 assert!(
592 skill_body.contains("when_to_use"),
593 "create-skill must teach the when_to_use field"
594 );
595 assert!(
596 skill_body.contains("allowed_tools"),
597 "create-skill must teach allowed_tools scoping"
598 );
599 assert!(
601 skill_body.contains(".koda/skills/"),
602 "create-skill must document project-scope path"
603 );
604 assert!(
605 skill_body.contains("~/.config/koda/skills/"),
606 "create-skill must document personal-scope path"
607 );
608 assert!(
610 skill_body.contains("koda-core/skills/code-review/SKILL.md")
611 || skill_body.contains("koda-core/skills/debug/SKILL.md"),
612 "create-skill must point at a reference example"
613 );
614 }
615
616 #[test]
617 fn test_search() {
618 let mut registry = SkillRegistry::default();
619 registry.load_builtin();
620
621 let results = registry.search("review");
622 assert!(!results.is_empty());
624 assert!(results.iter().any(|s| s.name == "code-review"));
625
626 let results = registry.search("security");
627 assert_eq!(results.len(), 1);
628 assert_eq!(results[0].name, "security-audit");
629 }
630
631 #[test]
632 fn test_search_by_tag() {
633 let mut registry = SkillRegistry::default();
634 registry.load_builtin();
635
636 let results = registry.search("owasp");
637 assert_eq!(results.len(), 1);
638 assert_eq!(results[0].name, "security-audit");
639 }
640
641 #[test]
642 fn test_add_builtin_injects_skill() {
643 let mut registry = SkillRegistry::default();
644 registry.add_builtin(
645 "my-app-docs",
646 "My app user manual",
647 Some("Use when the user asks about the app."),
648 "# My App\n\nDo stuff.",
649 );
650 assert_eq!(registry.len(), 1);
651 let content = registry.activate("my-app-docs").unwrap();
652 assert!(content.contains("Do stuff."));
653 let meta = registry.list();
655 assert!(matches!(meta[0].source, SkillSource::BuiltIn));
656 assert_eq!(
657 meta[0].when_to_use.as_deref(),
658 Some("Use when the user asks about the app.")
659 );
660 }
661
662 #[test]
663 fn test_add_builtin_overwrites_same_name() {
664 let mut registry = SkillRegistry::default();
665 registry.add_builtin("docs", "v1", None, "version one");
666 registry.add_builtin("docs", "v2", None, "version two");
667 assert_eq!(registry.len(), 1);
668 assert!(registry.activate("docs").unwrap().contains("version two"));
669 }
670
671 #[test]
672 fn test_list_sorted() {
673 let mut registry = SkillRegistry::default();
674 registry.load_builtin();
675
676 let list = registry.list();
677 let names: Vec<&str> = list.iter().map(|s| s.name.as_str()).collect();
678 assert!(list.len() >= 7);
681 assert_eq!(names[0], "code-review");
682 assert_eq!(names[1], "create-agent");
683 assert_eq!(names[2], "create-skill");
684 assert_eq!(names[3], "debug");
685 assert_eq!(names[4], "remember");
686 assert_eq!(names[5], "security-audit");
687 assert_eq!(names[6], "simplify");
688 }
689
690 #[test]
691 fn test_directory_discovery() {
692 let tmp = tempfile::TempDir::new().unwrap();
693 let skill_dir = tmp.path().join("my-skill");
694 std::fs::create_dir_all(&skill_dir).unwrap();
695 std::fs::write(
696 skill_dir.join("SKILL.md"),
697 "---\nname: my-skill\ndescription: test\ntags: []\n---\n# Test",
698 )
699 .unwrap();
700
701 let mut registry = SkillRegistry::default();
702 registry.load_directory(tmp.path(), SkillSource::Project);
703 assert_eq!(registry.len(), 1);
704 assert!(registry.activate("my-skill").is_some());
705 }
706}