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                "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    /// Load skills from a directory (each subdirectory with a SKILL.md).
162    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    /// List all discovered skills (name + description).
183    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    /// List only user-invocable skills (excludes model-only skills).
190    ///
191    /// Used by the `/skills` REPL command and `ListSkills` when called
192    /// by the user (vs. the model discovering skills autonomously).
193    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    /// Search skills by query (matches name, description, tags).
205    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    /// Activate a skill by name — returns the full content for context injection.
222    pub fn activate(&self, name: &str) -> Option<&str> {
223        self.skills.get(name).map(|s| s.content.as_str())
224    }
225
226    /// Get the full skill metadata + content by name.
227    ///
228    /// Used when activation needs to inspect `allowed_tools` or other
229    /// metadata beyond just the content string.
230    pub fn get(&self, name: &str) -> Option<&Skill> {
231        self.skills.get(name)
232    }
233
234    /// Inject a built-in skill programmatically (e.g. from a downstream CLI).
235    ///
236    /// This lets host applications (like `koda-cli`) embed their own
237    /// documentation as a skill without coupling `koda-core` to any
238    /// application-specific content.  Call after [`Self::discover`].
239    ///
240    /// `when_to_use` is shown in `ListSkills` output so the model knows
241    /// when to activate this skill without hard-coded `instructions.md` hints.
242    ///
243    /// Overwrites any previously registered skill with the same name.
244    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    /// Number of discovered skills.
268    pub fn len(&self) -> usize {
269        self.skills.len()
270    }
271
272    /// Returns `true` if no skills were discovered.
273    pub fn is_empty(&self) -> bool {
274        self.skills.is_empty()
275    }
276}
277
278/// Parse a SKILL.md file with YAML frontmatter.
279///
280/// Format:
281/// ```text
282/// ---
283/// name: code-review
284/// description: Senior code review
285/// tags: [review, quality]
286/// ---
287///
288/// # Skill content here...
289/// ```
290fn 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    // Find closing ---
297    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    // Simple YAML parsing (no serde_yaml dependency).
303    // Supported keys: name, description, tags, when_to_use, allowed_tools,
304    //   user_invocable, argument_hint.
305    // Multi-line YAML values and complex types are intentionally not supported.
306    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            // Parse [Tool1, Tool2] or comma-separated
327            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            // Parse [tag1, tag2, tag3]
356            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        // Manually insert a model-only skill
468        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    /// Pin the create-agent + create-skill bundled skills so they don't get
531    /// silently broken. Each assertion maps to a specific user-facing failure:
532    /// missing front-matter field => skill won't load; missing key guidance
533    /// in the body => generated agents/skills will have known footguns.
534    #[test]
535    fn test_creation_skills_are_complete() {
536        let mut registry = SkillRegistry::default();
537        registry.load_builtin();
538
539        // ── create-agent ────────────────────────────────────────
540        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        // The body must include the write_access footgun warning — this is
552        // the #1 thing that breaks generated agents if missing.
553        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        // Both scope paths documented + correct personal path.
563        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        // Reference to a canonical example so the model can crib.
572        assert!(
573            agent_body.contains("koda-core/agents/explore.json"),
574            "create-agent must point at a reference example"
575        );
576
577        // ── create-skill ────────────────────────────────────────
578        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        // Frontmatter fields the model must teach.
591        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        // Both scope paths documented + correct personal path.
600        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        // Reference to a canonical example so the model can crib.
609        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        // code-review, simplify, and remember all contain "review" in their metadata
623        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        // Source must be BuiltIn
654        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        // Sorted alphabetically: code-review, create-agent, create-skill,
679        // debug, remember, security-audit, simplify
680        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}