Skip to main content

koda_core/
skills.rs

1//! Skill discovery and loading.
2//!
3//! Skills are `SKILL.md` files with YAML frontmatter that inject expertise
4//! into the agent's context. Unlike sub-agents, skills don't spawn a
5//! separate inference loop — they're prompt injection, zero extra LLM cost.
6//!
7//! ## Skill file format
8//!
9//! ```markdown
10//! ---
11//! name: code-review
12//! description: Expert code review with security focus
13//! tags: [review, security]
14//! ---
15//!
16//! You are a code review expert. When reviewing code:
17//! 1. Check for security vulnerabilities
18//! 2. Verify error handling
19//! 3. Assess test coverage
20//! ```
21//!
22//! ## Built-in skills
23//!
24//! - **code-review** — structured code review with security focus
25//! - **security-audit** — OWASP-aligned security analysis
26//!
27//! ## Custom skills
28//!
29//! - **Project**: `.koda/skills/<name>/SKILL.md`
30//! - **Global**: `~/.config/koda/skills/<name>/SKILL.md`
31//!
32//! Use `/skills` to browse, or ask Koda to "use the code review skill."
33//!
34//! Discovery order (later overrides earlier):
35//! 1. Built-in skills (embedded in the binary)
36//! 2. User-global skills (`~/.config/koda/skills/`)
37//! 3. Project-local skills (`.koda/skills/`)
38
39use std::collections::HashMap;
40use std::path::Path;
41
42/// Metadata from a SKILL.md frontmatter.
43///
44/// ## Parity with Claude Code
45///
46/// These fields map to Claude Code's `FrontmatterData`:
47///
48/// | CC field | Koda field | Notes |
49/// |---|---|---|
50/// | `description` | `description` | |
51/// | `when_to_use` | `when_to_use` | |
52/// | `allowed-tools` | `allowed_tools` | Scoped tool access |
53/// | `user-invocable` | `user_invocable` | Default: `true` |
54/// | `argument-hint` | `argument_hint` | Usage guidance |
55#[derive(Debug, Clone)]
56pub struct SkillMeta {
57    /// Skill name (derived from filename or frontmatter).
58    pub name: String,
59    /// One-line description.
60    pub description: String,
61    /// Searchable tags.
62    pub tags: Vec<String>,
63    /// Guidance for the model on when to activate this skill.
64    /// Surfaced in `ListSkills` output so the model can decide without
65    /// hard-coded hints in `instructions.md`.
66    pub when_to_use: Option<String>,
67    /// Tool names allowed when this skill is active.
68    /// Empty = all tools available (default). Non-empty = only these tools.
69    /// Mirrors CC's `allowed-tools` frontmatter field.
70    pub allowed_tools: Vec<String>,
71    /// Whether users can invoke this skill (e.g. via `/skill-name`).
72    /// `true` (default) = shown in user-facing skill list.
73    /// `false` = model-only, not surfaced in `/skills` but still activatable.
74    /// Mirrors CC's `user-invocable` frontmatter field.
75    pub user_invocable: bool,
76    /// Usage hint shown when listing the skill (e.g. `"<file_path>"`)
77    /// Mirrors CC's `argument-hint` frontmatter field.
78    pub argument_hint: Option<String>,
79    /// Where this skill was discovered.
80    pub source: SkillSource,
81}
82
83/// Where a skill was loaded from.
84#[derive(Debug, Clone)]
85pub enum SkillSource {
86    /// Shipped with koda.
87    BuiltIn,
88    /// From `~/.config/koda/skills/`.
89    User,
90    /// From `.koda/skills/` in the project.
91    Project,
92}
93
94/// A fully loaded skill (metadata + content).
95#[derive(Debug, Clone)]
96pub struct Skill {
97    /// Skill metadata (name, description, tags, source).
98    pub meta: SkillMeta,
99    /// The full SKILL.md content (after frontmatter).
100    pub content: String,
101}
102
103/// Registry of discovered skills.
104#[derive(Debug, Default)]
105pub struct SkillRegistry {
106    pub(crate) skills: HashMap<String, Skill>,
107}
108
109impl SkillRegistry {
110    /// Discover skills from all standard locations.
111    pub fn discover(project_root: &Path) -> Self {
112        let mut registry = Self::default();
113
114        // 1. Built-in skills (embedded at compile time)
115        registry.load_builtin();
116
117        // 2. User-global skills
118        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        // 3. Project-local skills
124        let project_dir = project_root.join(".koda").join("skills");
125        registry.load_directory(&project_dir, SkillSource::Project);
126
127        registry
128    }
129
130    /// Load built-in skills embedded at compile time.
131    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    /// Load skills from a directory (each subdirectory with a SKILL.md).
154    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    /// List all discovered skills (name + description).
175    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    /// List only user-invocable skills (excludes model-only skills).
182    ///
183    /// Used by the `/skills` REPL command and `ListSkills` when called
184    /// by the user (vs. the model discovering skills autonomously).
185    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    /// Search skills by query (matches name, description, tags).
197    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    /// Activate a skill by name — returns the full content for context injection.
214    pub fn activate(&self, name: &str) -> Option<&str> {
215        self.skills.get(name).map(|s| s.content.as_str())
216    }
217
218    /// Get the full skill metadata + content by name.
219    ///
220    /// Used when activation needs to inspect `allowed_tools` or other
221    /// metadata beyond just the content string.
222    pub fn get(&self, name: &str) -> Option<&Skill> {
223        self.skills.get(name)
224    }
225
226    /// Inject a built-in skill programmatically (e.g. from a downstream CLI).
227    ///
228    /// This lets host applications (like `koda-cli`) embed their own
229    /// documentation as a skill without coupling `koda-core` to any
230    /// application-specific content.  Call after [`Self::discover`].
231    ///
232    /// `when_to_use` is shown in `ListSkills` output so the model knows
233    /// when to activate this skill without hard-coded `instructions.md` hints.
234    ///
235    /// Overwrites any previously registered skill with the same name.
236    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    /// Number of discovered skills.
260    pub fn len(&self) -> usize {
261        self.skills.len()
262    }
263
264    /// Returns `true` if no skills were discovered.
265    pub fn is_empty(&self) -> bool {
266        self.skills.is_empty()
267    }
268}
269
270/// Parse a SKILL.md file with YAML frontmatter.
271///
272/// Format:
273/// ```text
274/// ---
275/// name: code-review
276/// description: Senior code review
277/// tags: [review, quality]
278/// ---
279///
280/// # Skill content here...
281/// ```
282fn 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    // Find closing ---
289    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    // Simple YAML parsing (no serde_yaml dependency).
295    // Supported keys: name, description, tags, when_to_use, allowed_tools,
296    //   user_invocable, argument_hint.
297    // Multi-line YAML values and complex types are intentionally not supported.
298    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            // Parse [Tool1, Tool2] or comma-separated
319            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            // Parse [tag1, tag2, tag3]
348            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        // Manually insert a model-only skill
460        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        // code-review, simplify, and remember all contain "review" in their metadata
527        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        // Source must be BuiltIn
558        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        // Sorted alphabetically: code-review, debug, remember, security-audit, simplify
583        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}