Skip to main content

recursive/
skills.rs

1//! Skill system: file-based capability extension.
2//!
3//! Skills are markdown files in specific directories that can be loaded
4//! on-demand to extend the agent's capabilities.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Injection mode for a skill.
10#[derive(Debug, Clone, PartialEq, Default)]
11pub enum SkillMode {
12    /// Inject into system prompt at session start.
13    Always,
14    /// Auto-load when trigger words appear in the user goal.
15    Trigger,
16    /// Agent must explicitly call load_skill (current behavior).
17    #[default]
18    Manual,
19}
20
21/// A discovered skill with metadata.
22#[derive(Debug, Clone)]
23pub struct Skill {
24    /// Human-readable name, e.g., "rust-traits".
25    pub name: String,
26    /// Brief description for the skill index.
27    pub description: String,
28    /// Absolute path to the SKILL.md file.
29    pub path: PathBuf,
30    /// Injection mode.
31    pub mode: SkillMode,
32    /// Trigger words (only relevant when mode == Trigger).
33    pub triggers: Vec<String>,
34    /// Hint string for trigger-mode skills (short description for injection).
35    /// Auto-generated from description if absent for trigger mode.
36    pub hint: String,
37    /// Skills that should be auto-loaded before this one.
38    pub depends_on: Vec<String>,
39    /// Reference documents found in <skill_dir>/refs/
40    pub refs: Vec<SkillRef>,
41    /// Parameters declared in frontmatter.
42    pub params: Vec<SkillParam>,
43    /// Executable scripts found in <skill_dir>/scripts/
44    pub scripts: Vec<SkillScript>,
45    /// Named sections within the skill body (parsed from ## headings).
46    pub sections: Vec<SkillSection>,
47}
48
49/// A named section within a skill's body, delimited by `## Section Name`.
50#[derive(Debug, Clone)]
51pub struct SkillSection {
52    /// Section name (the text after `## `, trimmed).
53    pub name: String,
54    /// Content of the section (everything between this heading and the next
55    /// heading of the same or higher level, trimmed).
56    pub content: String,
57}
58
59/// A reference document within a skill's `refs/` directory.
60#[derive(Debug, Clone)]
61pub struct SkillRef {
62    /// Filename without extension, e.g. "api-spec"
63    pub name: String,
64    /// Absolute path to the ref file
65    pub path: PathBuf,
66}
67
68/// A parameter declared in a skill's frontmatter.
69#[derive(Debug, Clone)]
70pub struct SkillParam {
71    /// Parameter name, e.g. "language"
72    pub name: String,
73    /// Brief description
74    pub description: String,
75    /// Default value (None if required)
76    pub default: Option<String>,
77}
78
79/// An executable script within a skill's `scripts/` directory.
80#[derive(Debug, Clone)]
81pub struct SkillScript {
82    /// Script name (filename without extension), e.g. "lint"
83    pub name: String,
84    /// Absolute path to the script file
85    pub path: PathBuf,
86    /// Brief description from the first comment line (if present)
87    pub description: String,
88}
89
90/// Discover skills in the given search paths.
91///
92/// For each `<path>/<name>/SKILL.md`, parses optional YAML frontmatter.
93/// If absent, uses the parent directory name as `name` and the first
94/// non-empty line of body as `description`.
95///
96/// Also scans `<skill_dir>/refs/` for `.md` and `.txt` files and populates
97/// `Skill::refs` with what's found. Also scans `<skill_dir>/scripts/` for
98/// executable scripts and populates `Skill::scripts`.
99pub fn discover_skills(search_paths: &[PathBuf]) -> Vec<Skill> {
100    let mut skills = Vec::new();
101
102    for base in search_paths {
103        if !base.is_dir() {
104            continue;
105        }
106
107        if let Ok(entries) = fs::read_dir(base) {
108            for entry in entries.flatten() {
109                let dir_path = entry.path();
110                if !dir_path.is_dir() {
111                    continue;
112                }
113
114                let skill_file = dir_path.join("SKILL.md");
115                if !skill_file.is_file() {
116                    continue;
117                }
118
119                if let Ok(content) = fs::read_to_string(&skill_file) {
120                    let (name, description, mode, triggers, hint, depends_on, params) =
121                        parse_skill_meta(&content, &dir_path);
122                    let refs = discover_refs(&dir_path);
123                    let scripts = discover_scripts(&dir_path);
124                    let sections = parse_sections(&content);
125                    skills.push(Skill {
126                        name,
127                        description,
128                        path: skill_file,
129                        mode,
130                        triggers,
131                        hint,
132                        depends_on,
133                        refs,
134                        params,
135                        scripts,
136                        sections,
137                    });
138                }
139            }
140        }
141    }
142
143    skills
144}
145
146/// Parse named sections from a skill's body content.
147///
148/// Sections are delimited by `## Section Name` headings (level-2 markdown).
149/// The content of each section runs from after the heading line until the
150/// next heading of the same or higher level, or end of body.
151/// Frontmatter is stripped before parsing.
152fn parse_sections(content: &str) -> Vec<SkillSection> {
153    let body = extract_body(content);
154    let mut sections = Vec::new();
155    let mut lines = body.lines().peekable();
156
157    while let Some(line) = lines.next() {
158        if let Some(heading) = line.strip_prefix("## ") {
159            let name = heading.trim().to_string();
160            let mut section_lines = Vec::new();
161
162            // Collect content until next ## heading or end
163            while let Some(next) = lines.peek() {
164                if next.starts_with("## ") {
165                    break;
166                }
167                section_lines.push(lines.next().unwrap());
168            }
169
170            let content = section_lines.join("\n").trim().to_string();
171            sections.push(SkillSection { name, content });
172        }
173    }
174
175    sections
176}
177
178/// Scan `<skill_dir>/refs/` for `.md` and `.txt` files.
179fn discover_refs(skill_dir: &Path) -> Vec<SkillRef> {
180    let refs_dir = skill_dir.join("refs");
181    if !refs_dir.is_dir() {
182        return Vec::new();
183    }
184
185    let mut refs = Vec::new();
186    if let Ok(entries) = fs::read_dir(&refs_dir) {
187        for entry in entries.flatten() {
188            let path = entry.path();
189            if path.is_file() {
190                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
191                    if ext == "md" || ext == "txt" {
192                        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
193                            refs.push(SkillRef {
194                                name: stem.to_string(),
195                                path,
196                            });
197                        }
198                    }
199                }
200            }
201        }
202    }
203
204    refs
205}
206
207/// Scan `<skill_dir>/scripts/` for executable files.
208///
209/// Accepts files with execute permission (Unix) or common script extensions:
210/// `.sh`, `.py`, `.rb`, `.js`. Extracts a description from the first comment
211/// line (shebang excluded).
212fn discover_scripts(skill_dir: &Path) -> Vec<SkillScript> {
213    let scripts_dir = skill_dir.join("scripts");
214    if !scripts_dir.is_dir() {
215        return Vec::new();
216    }
217
218    let mut scripts = Vec::new();
219    if let Ok(entries) = fs::read_dir(&scripts_dir) {
220        for entry in entries.flatten() {
221            let path = entry.path();
222            if !path.is_file() {
223                continue;
224            }
225
226            // Check if it's executable (Unix) or has a known script extension
227            let is_exec = is_executable(&path);
228            let has_script_ext = path
229                .extension()
230                .and_then(|e| e.to_str())
231                .map(|e| matches!(e, "sh" | "py" | "rb" | "js"))
232                .unwrap_or(false);
233
234            if !is_exec && !has_script_ext {
235                continue;
236            }
237
238            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
239                let description = extract_script_description(&path);
240                scripts.push(SkillScript {
241                    name: stem.to_string(),
242                    path,
243                    description,
244                });
245            }
246        }
247    }
248
249    scripts
250}
251
252/// Check if a file has execute permission (Unix-only).
253#[cfg(unix)]
254fn is_executable(path: &Path) -> bool {
255    use std::os::unix::fs::PermissionsExt;
256    std::fs::metadata(path)
257        .map(|m| m.permissions().mode() & 0o111 != 0)
258        .unwrap_or(false)
259}
260
261#[cfg(not(unix))]
262fn is_executable(_path: &Path) -> bool {
263    false
264}
265
266/// Extract a description from the first comment line of a script.
267///
268/// Reads the first line. If it starts with `#!` (shebang), reads the second
269/// line. If a line starts with `#` or `//`, returns it (with the comment
270/// prefix stripped). Otherwise returns empty string.
271fn extract_script_description(path: &Path) -> String {
272    let content = match fs::read_to_string(path) {
273        Ok(c) => c,
274        Err(_) => return String::new(),
275    };
276
277    let mut lines = content.lines();
278    let first = lines.next();
279
280    // Skip shebang line
281    let target = match first {
282        Some(l) if l.starts_with("#!") => lines.next(),
283        other => other,
284    };
285
286    match target {
287        Some(l) if l.starts_with("# ") || l.starts_with("#") => l
288            .trim_start_matches("# ")
289            .trim_start_matches('#')
290            .trim()
291            .to_string(),
292        Some(l) if l.starts_with("// ") || l.starts_with("//") => l
293            .trim_start_matches("// ")
294            .trim_start_matches("//")
295            .trim()
296            .to_string(),
297        _ => String::new(),
298    }
299}
300
301/// Parse YAML frontmatter (if present) from skill content.
302///
303/// Returns (name, description, mode, triggers, hint, params). If frontmatter is
304/// absent, falls back to using the parent directory name and first non-empty
305/// Returns (name, description, mode, triggers, hint, depends_on, params). If frontmatter is
306/// absent, falls back to using the parent directory name and first non-empty
307/// line, with default mode (Manual), empty triggers, empty hint, and empty depends_on.
308pub fn parse_skill_meta(
309    content: &str,
310    dir_path: &Path,
311) -> (
312    String,
313    String,
314    SkillMode,
315    Vec<String>,
316    String,
317    Vec<String>,
318    Vec<SkillParam>,
319) {
320    // Try to extract YAML frontmatter: --- ... ---
321    if let Some(frontmatter) = content.strip_prefix("---") {
322        if let Some(end) = frontmatter.find("---") {
323            let yaml = &frontmatter[..end];
324            let body = frontmatter[end + 3..].trim();
325
326            // Parse naive key: value pairs
327            let mut name = None;
328            let mut description = None;
329            let mut mode = SkillMode::Manual;
330            let mut triggers = Vec::new();
331            let mut hint = String::new();
332            let mut params = Vec::new();
333            let mut depends_on = Vec::new();
334
335            let lines: Vec<&str> = yaml.lines().collect();
336            let mut i = 0;
337            while i < lines.len() {
338                let line = lines[i].trim();
339                if let Some(stripped) = line.strip_prefix("name:") {
340                    name = Some(stripped.trim().to_string());
341                } else if let Some(stripped) = line.strip_prefix("description:") {
342                    description = Some(stripped.trim().to_string());
343                } else if let Some(stripped) = line.strip_prefix("hint:") {
344                    hint = stripped.trim().to_string();
345                } else if let Some(stripped) = line.strip_prefix("mode:") {
346                    let raw = stripped.trim().to_lowercase();
347                    mode = match raw.as_str() {
348                        "always" => SkillMode::Always,
349                        "trigger" => SkillMode::Trigger,
350                        _ => SkillMode::Manual,
351                    };
352                } else if let Some(stripped) = line.strip_prefix("triggers:") {
353                    // Parse comma-separated trigger words
354                    triggers = stripped
355                        .split(',')
356                        .map(|s| s.trim().to_string())
357                        .filter(|s| !s.is_empty())
358                        .collect();
359                } else if let Some(stripped) = line.strip_prefix("depends_on:") {
360                    // Parse comma-separated dependency names
361                    depends_on = stripped
362                        .split(',')
363                        .map(|s| s.trim().to_string())
364                        .filter(|s| !s.is_empty())
365                        .collect();
366                } else if line == "params:" {
367                    // Parse params list: each entry starts with "  - name: xxx"
368                    i += 1;
369                    while i < lines.len() {
370                        let entry_line = lines[i];
371                        // Check if this line starts a new param entry: "  - name: xxx"
372                        if let Some(after_dash) = entry_line.trim().strip_prefix("- name:") {
373                            let param_name = after_dash.trim().to_string();
374                            let mut param_description = String::new();
375                            let mut param_default = None;
376
377                            // Look at subsequent lines for description and default
378                            i += 1;
379                            while i < lines.len() {
380                                let sub_line = lines[i];
381                                let trimmed = sub_line.trim();
382                                // Stop if we hit a new top-level key (no indent) or a new list entry
383                                if !sub_line.starts_with(' ') && !sub_line.starts_with('\t') {
384                                    break;
385                                }
386                                if let Some(val) = trimmed.strip_prefix("description:") {
387                                    param_description = val.trim().to_string();
388                                } else if let Some(val) = trimmed.strip_prefix("default:") {
389                                    param_default = Some(val.trim().to_string());
390                                } else if trimmed.starts_with("- name:") {
391                                    // Next param entry — back up so outer loop re-processes
392                                    i -= 1;
393                                    break;
394                                } else if !trimmed.is_empty() && !trimmed.starts_with('-') {
395                                    // Unknown indented line — skip
396                                }
397                                i += 1;
398                            }
399
400                            params.push(SkillParam {
401                                name: param_name,
402                                description: param_description,
403                                default: param_default,
404                            });
405                        } else {
406                            // Not a param entry — skip to next line
407                            i += 1;
408                        }
409                    }
410                    // After params block, break out of top-level loop
411                    break;
412                }
413                i += 1;
414            }
415
416            // Use frontmatter values if present, otherwise fall back to defaults
417            let final_name = name.unwrap_or_else(|| {
418                dir_path
419                    .file_name()
420                    .and_then(|n| n.to_str())
421                    .unwrap_or("unnamed")
422                    .to_string()
423            });
424
425            let final_description = description.unwrap_or_else(|| {
426                body.lines()
427                    .find(|l| !l.trim().is_empty())
428                    .map(|l| l.trim().to_string())
429                    .unwrap_or_default()
430            });
431
432            // Auto-generate hint for trigger-mode skills if not explicitly set
433            if hint.is_empty() && mode == SkillMode::Trigger {
434                hint = format!("{}: {}", final_name, final_description);
435            }
436
437            return (
438                final_name,
439                final_description,
440                mode,
441                triggers,
442                hint,
443                depends_on,
444                params,
445            );
446        }
447    }
448
449    // No frontmatter - use directory name and first non-empty line
450    let name = dir_path
451        .file_name()
452        .and_then(|n| n.to_str())
453        .unwrap_or("unnamed")
454        .to_string();
455
456    let description = content
457        .lines()
458        .find(|l| !l.trim().is_empty())
459        .map(|l| l.trim().to_string())
460        .unwrap_or_default();
461
462    (
463        name,
464        description,
465        SkillMode::Manual,
466        Vec::new(),
467        String::new(),
468        Vec::new(),
469        Vec::new(),
470    )
471}
472
473/// Select skills whose mode is `Always`, plus any `Trigger` skills whose
474/// triggers match the given goal text.
475///
476/// Returns `Vec<(name, body_or_hint)>` of matching skills.
477/// For `Always` mode, the full body is returned.
478/// For `Trigger` mode, the hint is returned (short description).
479pub fn skills_for_injection(skills: &[Skill], goal: &str) -> Vec<(String, String)> {
480    let mut result: Vec<(String, String)> = Vec::new();
481
482    for skill in skills {
483        match skill.mode {
484            SkillMode::Always => {
485                // Read the body from the SKILL.md file
486                if let Ok(content) = fs::read_to_string(&skill.path) {
487                    let body = extract_body(&content);
488                    result.push((skill.name.clone(), body.to_string()));
489                }
490            }
491            SkillMode::Trigger => {
492                // Check if any trigger matches the goal (case-insensitive)
493                let goal_lower = goal.to_lowercase();
494                let matched = skill
495                    .triggers
496                    .iter()
497                    .any(|t| goal_lower.contains(&t.to_lowercase()));
498                if matched {
499                    // Inject the hint (short description) instead of full body
500                    result.push((skill.name.clone(), skill.hint.clone()));
501                }
502            }
503            SkillMode::Manual => {
504                // Never auto-injected; agent must call load_skill
505            }
506        }
507    }
508
509    result
510}
511
512/// Extract the body of a SKILL.md file, stripping YAML frontmatter if present.
513fn extract_body(content: &str) -> &str {
514    if let Some(frontmatter) = content.strip_prefix("---") {
515        if let Some(end) = frontmatter.find("---") {
516            return frontmatter[end + 3..].trim();
517        }
518    }
519    content.trim()
520}
521
522/// Render a compact "available skills" block for the system prompt.
523///
524/// Returns empty string if no skills found.
525pub fn skill_index(skills: &[Skill]) -> String {
526    if skills.is_empty() {
527        return String::new();
528    }
529
530    let mut lines = vec![
531        "".to_string(),
532        "Available skills (use `load_skill` to activate):".to_string(),
533    ];
534
535    for skill in skills {
536        let mut suffix_parts = Vec::new();
537
538        // Mode tag
539        let mode_tag = match skill.mode {
540            SkillMode::Always => "[always]",
541            SkillMode::Trigger => "[trigger]",
542            SkillMode::Manual => "",
543        };
544
545        // Ref count
546        let ref_count = skill.refs.len();
547        if ref_count > 0 {
548            suffix_parts.push(format!("{ref_count} refs"));
549        }
550
551        // Params
552        if !skill.params.is_empty() {
553            let param_strs: Vec<String> = skill
554                .params
555                .iter()
556                .map(|p| {
557                    if let Some(ref default) = p.default {
558                        format!("{}={}", p.name, default)
559                    } else {
560                        p.name.clone()
561                    }
562                })
563                .collect();
564            suffix_parts.push(format!("params: {}", param_strs.join(", ")));
565        }
566
567        // Sections
568        if !skill.sections.is_empty() {
569            let section_names: Vec<&str> = skill.sections.iter().map(|s| s.name.as_str()).collect();
570            suffix_parts.push(format!("sections: {}", section_names.join(", ")));
571        }
572
573        // Depends on
574        if !skill.depends_on.is_empty() {
575            suffix_parts.push(format!("depends_on: {}", skill.depends_on.join(", ")));
576        }
577
578        // Scripts
579        let script_names: Vec<&str> = skill.scripts.iter().map(|s| s.name.as_str()).collect();
580
581        let prefix = if mode_tag.is_empty() {
582            format!("- {}: {}", skill.name, skill.description)
583        } else {
584            format!("- {} {}: {}", mode_tag, skill.name, skill.description)
585        };
586
587        if suffix_parts.is_empty() {
588            lines.push(prefix);
589        } else {
590            lines.push(format!("{} ({})", prefix, suffix_parts.join(", ")));
591        }
592
593        // Append scripts suffix if present (after the main line)
594        if !script_names.is_empty() {
595            let last = lines.last_mut().unwrap();
596            *last = format!("{} [scripts: {}]", last, script_names.join(", "));
597        }
598    }
599
600    lines.push("".to_string());
601    lines.join("\n")
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use std::io::Write;
608
609    #[test]
610    fn discover_skills_parses_frontmatter() {
611        let tmp = tempfile::TempDir::new().unwrap();
612        let base = tmp.path();
613
614        // Create skill with YAML frontmatter
615        let rust_dir = base.join("rust-traits");
616        fs::create_dir(&rust_dir).unwrap();
617        let mut file = fs::File::create(rust_dir.join("SKILL.md")).unwrap();
618        writeln!(
619            file,
620            "---\
621             \nname: rust-traits\
622             \ndescription: Explain Rust trait design patterns\
623             \n---\
624             \n\nWhen asked about Rust traits, walk the codebase..."
625        )
626        .unwrap();
627
628        // Create skill without frontmatter (name from dir, desc from body)
629        let python_dir = base.join("python-api");
630        fs::create_dir(&python_dir).unwrap();
631        let mut file = fs::File::create(python_dir.join("SKILL.md")).unwrap();
632        writeln!(file, "First line of description\n\nMore content...").unwrap();
633
634        let skills = discover_skills(&[base.to_path_buf()]);
635        assert_eq!(skills.len(), 2);
636
637        let rust = skills.iter().find(|s| s.name == "rust-traits").unwrap();
638        assert_eq!(rust.description, "Explain Rust trait design patterns");
639
640        let python = skills.iter().find(|s| s.name == "python-api").unwrap();
641        assert_eq!(python.description, "First line of description");
642    }
643
644    #[test]
645    fn skill_index_empty_for_no_skills() {
646        let result = skill_index(&[]);
647        assert_eq!(result, "");
648    }
649
650    #[test]
651    fn skill_index_renders_correctly() {
652        let skills = vec![
653            Skill {
654                name: "rust-traits".to_string(),
655                description: "Explain Rust trait design".to_string(),
656                path: PathBuf::from("/tmp/skills/rust-traits/SKILL.md"),
657                mode: SkillMode::Manual,
658                triggers: vec![],
659                hint: String::new(),
660                depends_on: vec![],
661                refs: vec![],
662                params: vec![],
663                scripts: vec![],
664                sections: vec![],
665            },
666            Skill {
667                name: "python-api".to_string(),
668                description: "Python API patterns".to_string(),
669                path: PathBuf::from("/tmp/skills/python-api/SKILL.md"),
670                mode: SkillMode::Manual,
671                triggers: vec![],
672                hint: String::new(),
673                depends_on: vec![],
674                refs: vec![],
675                params: vec![],
676                scripts: vec![],
677                sections: vec![],
678            },
679        ];
680
681        let result = skill_index(&skills);
682        assert!(result.contains("Available skills"));
683        assert!(result.contains("- rust-traits: Explain Rust trait design"));
684        assert!(result.contains("- python-api: Python API patterns"));
685    }
686
687    #[test]
688    fn discover_skills_populates_refs() {
689        let tmp = tempfile::TempDir::new().unwrap();
690        let base = tmp.path();
691
692        // Create a skill with refs
693        let skill_dir = base.join("my-skill");
694        fs::create_dir(&skill_dir).unwrap();
695        fs::write(
696            skill_dir.join("SKILL.md"),
697            "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
698        )
699        .unwrap();
700
701        // Create refs directory with some files
702        let refs_dir = skill_dir.join("refs");
703        fs::create_dir(&refs_dir).unwrap();
704        fs::write(refs_dir.join("api-spec.md"), "# API Spec\n\nDetails here.").unwrap();
705        fs::write(refs_dir.join("examples.txt"), "Example 1\nExample 2").unwrap();
706        // Non-matching extension should be ignored
707        fs::write(refs_dir.join("notes.json"), "{}").unwrap();
708
709        let skills = discover_skills(&[base.to_path_buf()]);
710        assert_eq!(skills.len(), 1);
711
712        let skill = &skills[0];
713        assert_eq!(skill.name, "my-skill");
714        assert_eq!(
715            skill.refs.len(),
716            2,
717            "should find 2 ref files (md + txt), ignoring json"
718        );
719
720        let api_spec = skill.refs.iter().find(|r| r.name == "api-spec").unwrap();
721        assert!(api_spec.path.ends_with("api-spec.md"));
722
723        let examples = skill.refs.iter().find(|r| r.name == "examples").unwrap();
724        assert!(examples.path.ends_with("examples.txt"));
725    }
726
727    #[test]
728    fn discover_skills_handles_no_refs_dir() {
729        let tmp = tempfile::TempDir::new().unwrap();
730        let base = tmp.path();
731
732        // Create a skill without refs directory
733        let skill_dir = base.join("simple-skill");
734        fs::create_dir(&skill_dir).unwrap();
735        fs::write(
736            skill_dir.join("SKILL.md"),
737            "---\nname: simple-skill\ndescription: No refs\n---\n\nBody",
738        )
739        .unwrap();
740
741        let skills = discover_skills(&[base.to_path_buf()]);
742        assert_eq!(skills.len(), 1);
743        assert!(
744            skills[0].refs.is_empty(),
745            "skill with no refs/ dir should have empty refs"
746        );
747    }
748
749    #[test]
750    fn skill_index_shows_ref_count() {
751        let skills = vec![
752            Skill {
753                name: "with-refs".to_string(),
754                description: "Has references".to_string(),
755                path: PathBuf::from("/tmp/skills/with-refs/SKILL.md"),
756                mode: SkillMode::Manual,
757                triggers: vec![],
758                hint: String::new(),
759                depends_on: vec![],
760                refs: vec![
761                    SkillRef {
762                        name: "api-spec".to_string(),
763                        path: PathBuf::from("/tmp/skills/with-refs/refs/api-spec.md"),
764                    },
765                    SkillRef {
766                        name: "examples".to_string(),
767                        path: PathBuf::from("/tmp/skills/with-refs/refs/examples.txt"),
768                    },
769                ],
770                params: vec![],
771                scripts: vec![],
772                sections: vec![],
773            },
774            Skill {
775                name: "no-refs".to_string(),
776                description: "No references".to_string(),
777                path: PathBuf::from("/tmp/skills/no-refs/SKILL.md"),
778                mode: SkillMode::Manual,
779                triggers: vec![],
780                hint: String::new(),
781                depends_on: vec![],
782                refs: vec![],
783                params: vec![],
784                scripts: vec![],
785                sections: vec![],
786            },
787        ];
788
789        let result = skill_index(&skills);
790        assert!(result.contains("- with-refs: Has references (2 refs)"));
791        assert!(result.contains("- no-refs: No references"));
792        assert!(
793            !result.contains("(0 refs)"),
794            "should not show count for 0 refs"
795        );
796    }
797
798    #[test]
799    fn discover_skills_parses_params() {
800        let tmp = tempfile::TempDir::new().unwrap();
801        let base = tmp.path();
802
803        let skill_dir = base.join("code-review");
804        fs::create_dir(&skill_dir).unwrap();
805        fs::write(
806            skill_dir.join("SKILL.md"),
807            "---\n\
808             name: code-review\n\
809             description: Review code for quality issues\n\
810             params:\n\
811             \x20\x20- name: language\n\
812             \x20\x20  description: Target language\n\
813             \x20\x20  default: rust\n\
814             \x20\x20- name: strict\n\
815             \x20\x20  description: Enable strict mode\n\
816             ---\n\
817             \n\
818             Review {{language}} code {{#if strict}}strictly{{/if}}.",
819        )
820        .unwrap();
821
822        let skills = discover_skills(&[base.to_path_buf()]);
823        assert_eq!(skills.len(), 1);
824
825        let skill = &skills[0];
826        assert_eq!(skill.name, "code-review");
827        assert_eq!(skill.params.len(), 2);
828
829        let lang = skill.params.iter().find(|p| p.name == "language").unwrap();
830        assert_eq!(lang.description, "Target language");
831        assert_eq!(lang.default.as_deref(), Some("rust"));
832
833        let strict = skill.params.iter().find(|p| p.name == "strict").unwrap();
834        assert_eq!(strict.description, "Enable strict mode");
835        assert_eq!(strict.default, None);
836    }
837
838    #[test]
839    fn discover_skills_handles_no_params() {
840        let tmp = tempfile::TempDir::new().unwrap();
841        let base = tmp.path();
842
843        let skill_dir = base.join("simple");
844        fs::create_dir(&skill_dir).unwrap();
845        fs::write(
846            skill_dir.join("SKILL.md"),
847            "---\nname: simple\ndescription: No params\n---\n\nBody",
848        )
849        .unwrap();
850
851        let skills = discover_skills(&[base.to_path_buf()]);
852        assert_eq!(skills.len(), 1);
853        assert!(
854            skills[0].params.is_empty(),
855            "skill without params should have empty params"
856        );
857    }
858
859    #[test]
860    fn skill_index_shows_params() {
861        let skills = vec![
862            Skill {
863                name: "code-review".to_string(),
864                description: "Review code".to_string(),
865                path: PathBuf::from("/tmp/skills/code-review/SKILL.md"),
866                mode: SkillMode::Manual,
867                triggers: vec![],
868                hint: String::new(),
869                depends_on: vec![],
870                refs: vec![],
871                params: vec![
872                    SkillParam {
873                        name: "language".to_string(),
874                        description: "Target language".to_string(),
875                        default: Some("rust".to_string()),
876                    },
877                    SkillParam {
878                        name: "strict".to_string(),
879                        description: "Enable strict mode".to_string(),
880                        default: None,
881                    },
882                ],
883                scripts: vec![],
884                sections: vec![],
885            },
886            Skill {
887                name: "simple".to_string(),
888                description: "No params".to_string(),
889                path: PathBuf::from("/tmp/skills/simple/SKILL.md"),
890                mode: SkillMode::Manual,
891                triggers: vec![],
892                hint: String::new(),
893                depends_on: vec![],
894                refs: vec![],
895                params: vec![],
896                scripts: vec![],
897                sections: vec![],
898            },
899        ];
900
901        let result = skill_index(&skills);
902        assert!(
903            result.contains("params: language=rust, strict"),
904            "should show params with defaults: {result}"
905        );
906        assert!(
907            result.contains("- simple: No params"),
908            "should show skill without params normally"
909        );
910    }
911
912    #[test]
913    fn discover_skills_populates_scripts() {
914        let tmp = tempfile::TempDir::new().unwrap();
915        let base = tmp.path();
916
917        // Create a skill with scripts
918        let skill_dir = base.join("my-skill");
919        fs::create_dir(&skill_dir).unwrap();
920        fs::write(
921            skill_dir.join("SKILL.md"),
922            "---\nname: my-skill\ndescription: A skill with scripts\n---\n\nBody",
923        )
924        .unwrap();
925
926        // Create scripts directory
927        let scripts_dir = skill_dir.join("scripts");
928        fs::create_dir(&scripts_dir).unwrap();
929        fs::write(
930            scripts_dir.join("lint.sh"),
931            "#!/bin/sh\n# Run the linter\necho 'linting...'\n",
932        )
933        .unwrap();
934        fs::write(
935            scripts_dir.join("format.py"),
936            "#!/usr/bin/env python3\n# Format the code\nprint('formatting')\n",
937        )
938        .unwrap();
939        // Non-script extension should be ignored
940        fs::write(scripts_dir.join("notes.txt"), "not a script").unwrap();
941
942        let skills = discover_skills(&[base.to_path_buf()]);
943        assert_eq!(skills.len(), 1);
944
945        let skill = &skills[0];
946        assert_eq!(skill.name, "my-skill");
947        assert_eq!(
948            skill.scripts.len(),
949            2,
950            "should find 2 scripts (sh + py), ignoring txt"
951        );
952
953        let lint = skill.scripts.iter().find(|s| s.name == "lint").unwrap();
954        assert!(lint.path.ends_with("lint.sh"));
955        assert_eq!(lint.description, "Run the linter");
956
957        let format = skill.scripts.iter().find(|s| s.name == "format").unwrap();
958        assert!(format.path.ends_with("format.py"));
959        assert_eq!(format.description, "Format the code");
960    }
961
962    #[test]
963    fn discover_skills_handles_no_scripts_dir() {
964        let tmp = tempfile::TempDir::new().unwrap();
965        let base = tmp.path();
966
967        // Create a skill without scripts directory
968        let skill_dir = base.join("simple-skill");
969        fs::create_dir(&skill_dir).unwrap();
970        fs::write(
971            skill_dir.join("SKILL.md"),
972            "---\nname: simple-skill\ndescription: No scripts\n---\n\nBody",
973        )
974        .unwrap();
975
976        let skills = discover_skills(&[base.to_path_buf()]);
977        assert_eq!(skills.len(), 1);
978        assert!(
979            skills[0].scripts.is_empty(),
980            "skill with no scripts/ dir should have empty scripts"
981        );
982    }
983
984    #[test]
985    fn skill_index_shows_script_names() {
986        let skills = vec![
987            Skill {
988                name: "with-scripts".to_string(),
989                description: "Has scripts".to_string(),
990                path: PathBuf::from("/tmp/skills/with-scripts/SKILL.md"),
991                mode: SkillMode::Manual,
992                triggers: vec![],
993                hint: String::new(),
994                depends_on: vec![],
995                refs: vec![],
996                params: vec![],
997                scripts: vec![
998                    SkillScript {
999                        name: "lint".to_string(),
1000                        path: PathBuf::from("/tmp/skills/with-scripts/scripts/lint.sh"),
1001                        description: "Run the linter".to_string(),
1002                    },
1003                    SkillScript {
1004                        name: "format".to_string(),
1005                        path: PathBuf::from("/tmp/skills/with-scripts/scripts/format.py"),
1006                        description: "Format the code".to_string(),
1007                    },
1008                ],
1009                sections: vec![],
1010            },
1011            Skill {
1012                name: "no-scripts".to_string(),
1013                description: "No scripts".to_string(),
1014                path: PathBuf::from("/tmp/skills/no-scripts/SKILL.md"),
1015                mode: SkillMode::Manual,
1016                triggers: vec![],
1017                hint: String::new(),
1018                depends_on: vec![],
1019                refs: vec![],
1020                params: vec![],
1021                scripts: vec![],
1022                sections: vec![],
1023            },
1024        ];
1025
1026        let result = skill_index(&skills);
1027        assert!(result.contains("- with-scripts: Has scripts [scripts: lint, format]"));
1028        assert!(result.contains("- no-scripts: No scripts"));
1029    }
1030
1031    #[test]
1032    fn extract_script_description_shebang_then_comment() {
1033        let tmp = tempfile::TempDir::new().unwrap();
1034        let path = tmp.path().join("test.sh");
1035        fs::write(&path, "#!/bin/sh\n# Run the linter\necho hi\n").unwrap();
1036        assert_eq!(extract_script_description(&path), "Run the linter");
1037    }
1038
1039    #[test]
1040    fn extract_script_description_no_shebang() {
1041        let tmp = tempfile::TempDir::new().unwrap();
1042        let path = tmp.path().join("test.py");
1043        fs::write(&path, "# Format the code\nprint('hi')\n").unwrap();
1044        assert_eq!(extract_script_description(&path), "Format the code");
1045    }
1046
1047    #[test]
1048    fn extract_script_description_js_comment() {
1049        let tmp = tempfile::TempDir::new().unwrap();
1050        let path = tmp.path().join("test.js");
1051        fs::write(&path, "// Run the build\nconsole.log('hi')\n").unwrap();
1052        assert_eq!(extract_script_description(&path), "Run the build");
1053    }
1054
1055    #[test]
1056    fn extract_script_description_empty_when_no_comment() {
1057        let tmp = tempfile::TempDir::new().unwrap();
1058        let path = tmp.path().join("test.sh");
1059        fs::write(&path, "#!/bin/sh\necho hi\n").unwrap();
1060        assert_eq!(extract_script_description(&path), "");
1061    }
1062
1063    // --- Mode & trigger tests ---
1064
1065    #[test]
1066    fn parse_skill_meta_parses_mode_always() {
1067        let tmp = tempfile::TempDir::new().unwrap();
1068        let dir = tmp.path().join("test-skill");
1069        fs::create_dir(&dir).unwrap();
1070        let content = "---\nname: test-skill\ndescription: A test\nmode: always\n---\n\nBody text";
1071        let (name, desc, mode, triggers, hint, _depends_on, params) =
1072            parse_skill_meta(content, &dir);
1073        assert_eq!(name, "test-skill");
1074        assert_eq!(desc, "A test");
1075        assert_eq!(mode, SkillMode::Always);
1076        assert!(triggers.is_empty());
1077        assert!(hint.is_empty());
1078        assert!(params.is_empty());
1079    }
1080
1081    #[test]
1082    fn parse_skill_meta_parses_mode_trigger_with_triggers() {
1083        let tmp = tempfile::TempDir::new().unwrap();
1084        let dir = tmp.path().join("test-skill");
1085        fs::create_dir(&dir).unwrap();
1086        let content =
1087            "---\nname: test-skill\ndescription: A test\nmode: trigger\ntriggers: rust, trait\n---\n\nBody text";
1088        let (name, desc, mode, triggers, hint, _depends_on, params) =
1089            parse_skill_meta(content, &dir);
1090        assert_eq!(name, "test-skill");
1091        assert_eq!(desc, "A test");
1092        assert_eq!(mode, SkillMode::Trigger);
1093        assert_eq!(triggers, vec!["rust", "trait"]);
1094        // Hint should be auto-generated for trigger mode
1095        assert_eq!(hint, "test-skill: A test");
1096        assert!(params.is_empty());
1097    }
1098
1099    #[test]
1100    fn parse_skill_meta_parses_explicit_hint() {
1101        let tmp = tempfile::TempDir::new().unwrap();
1102        let dir = tmp.path().join("test-skill");
1103        fs::create_dir(&dir).unwrap();
1104        let content =
1105            "---\nname: test-skill\ndescription: A test\nmode: trigger\ntriggers: rust\nhint: Rust-related helper\n---\n\nBody text";
1106        let (name, desc, mode, triggers, hint, _depends_on, params) =
1107            parse_skill_meta(content, &dir);
1108        assert_eq!(name, "test-skill");
1109        assert_eq!(desc, "A test");
1110        assert_eq!(mode, SkillMode::Trigger);
1111        assert_eq!(triggers, vec!["rust"]);
1112        assert_eq!(hint, "Rust-related helper");
1113        assert!(params.is_empty());
1114    }
1115
1116    #[test]
1117    fn parse_skill_meta_defaults_to_manual() {
1118        let tmp = tempfile::TempDir::new().unwrap();
1119        let dir = tmp.path().join("test-skill");
1120        fs::create_dir(&dir).unwrap();
1121        let content = "---\nname: test-skill\ndescription: A test\n---\n\nBody text";
1122        let (_, _, mode, triggers, hint, _, _) = parse_skill_meta(content, &dir);
1123        assert_eq!(mode, SkillMode::Manual);
1124        assert!(triggers.is_empty());
1125        assert!(hint.is_empty());
1126    }
1127
1128    #[test]
1129    fn parse_skill_meta_no_frontmatter_defaults_manual() {
1130        let tmp = tempfile::TempDir::new().unwrap();
1131        let dir = tmp.path().join("test-skill");
1132        fs::create_dir(&dir).unwrap();
1133        let content = "Body text";
1134        let (_, _, mode, triggers, hint, _, _) = parse_skill_meta(content, &dir);
1135        assert_eq!(mode, SkillMode::Manual);
1136        assert!(triggers.is_empty());
1137        assert!(hint.is_empty());
1138    }
1139
1140    #[test]
1141    fn skills_for_injection_always_mode() {
1142        let tmp = tempfile::TempDir::new().unwrap();
1143        let dir = tmp.path().join("always-skill");
1144        fs::create_dir(&dir).unwrap();
1145        fs::write(
1146            dir.join("SKILL.md"),
1147            "---\nname: always-skill\ndescription: Always injected\nmode: always\n---\n\nAlways body content",
1148        )
1149        .unwrap();
1150
1151        let skills = discover_skills(&[tmp.path().to_path_buf()]);
1152        let result = skills_for_injection(&skills, "anything");
1153        assert_eq!(result.len(), 1);
1154        assert_eq!(result[0].0, "always-skill");
1155        assert_eq!(result[0].1, "Always body content");
1156    }
1157
1158    #[test]
1159    fn skills_for_injection_trigger_matches() {
1160        let tmp = tempfile::TempDir::new().unwrap();
1161        let dir = tmp.path().join("rust-skill");
1162        fs::create_dir(&dir).unwrap();
1163        fs::write(
1164            dir.join("SKILL.md"),
1165            "---\nname: rust-skill\ndescription: Rust helper\nmode: trigger\ntriggers: rust, trait\n---\n\nRust body content",
1166        )
1167        .unwrap();
1168
1169        let skills = discover_skills(&[tmp.path().to_path_buf()]);
1170        let result = skills_for_injection(&skills, "I need help with Rust traits");
1171        assert_eq!(result.len(), 1);
1172        assert_eq!(result[0].0, "rust-skill");
1173        // Trigger mode should return hint, not full body
1174        assert_eq!(result[0].1, "rust-skill: Rust helper");
1175    }
1176
1177    #[test]
1178    fn skills_for_injection_trigger_no_match() {
1179        let tmp = tempfile::TempDir::new().unwrap();
1180        let dir = tmp.path().join("rust-skill");
1181        fs::create_dir(&dir).unwrap();
1182        fs::write(
1183            dir.join("SKILL.md"),
1184            "---\nname: rust-skill\ndescription: Rust helper\nmode: trigger\ntriggers: rust, trait\n---\n\nRust body content",
1185        )
1186        .unwrap();
1187
1188        let skills = discover_skills(&[tmp.path().to_path_buf()]);
1189        let result = skills_for_injection(&skills, "I need help with Python");
1190        assert!(result.is_empty());
1191    }
1192
1193    #[test]
1194    fn skills_for_injection_manual_never_injected() {
1195        let tmp = tempfile::TempDir::new().unwrap();
1196        let dir = tmp.path().join("manual-skill");
1197        fs::create_dir(&dir).unwrap();
1198        fs::write(
1199            dir.join("SKILL.md"),
1200            "---\nname: manual-skill\ndescription: Manual only\n---\n\nManual body",
1201        )
1202        .unwrap();
1203
1204        let skills = discover_skills(&[tmp.path().to_path_buf()]);
1205        let result = skills_for_injection(&skills, "anything");
1206        assert!(result.is_empty());
1207    }
1208
1209    #[test]
1210    fn skill_index_shows_mode_tags() {
1211        let skills = vec![
1212            Skill {
1213                name: "always-skill".to_string(),
1214                description: "Always injected".to_string(),
1215                path: PathBuf::from("/tmp/skills/always-skill/SKILL.md"),
1216                mode: SkillMode::Always,
1217                triggers: vec![],
1218                hint: String::new(),
1219                depends_on: vec![],
1220                refs: vec![],
1221                params: vec![],
1222                scripts: vec![],
1223                sections: vec![],
1224            },
1225            Skill {
1226                name: "trigger-skill".to_string(),
1227                description: "Trigger based".to_string(),
1228                path: PathBuf::from("/tmp/skills/trigger-skill/SKILL.md"),
1229                mode: SkillMode::Trigger,
1230                triggers: vec!["rust".to_string()],
1231                hint: "trigger-skill: Trigger based".to_string(),
1232                depends_on: vec![],
1233                refs: vec![],
1234                params: vec![],
1235                scripts: vec![],
1236                sections: vec![],
1237            },
1238            Skill {
1239                name: "manual-skill".to_string(),
1240                description: "Manual only".to_string(),
1241                path: PathBuf::from("/tmp/skills/manual-skill/SKILL.md"),
1242                mode: SkillMode::Manual,
1243                triggers: vec![],
1244                hint: String::new(),
1245                depends_on: vec![],
1246                refs: vec![],
1247                params: vec![],
1248                scripts: vec![],
1249                sections: vec![],
1250            },
1251        ];
1252
1253        let result = skill_index(&skills);
1254        assert!(result.contains("[always] always-skill"));
1255        assert!(result.contains("[trigger] trigger-skill"));
1256        assert!(result.contains("- manual-skill: Manual only"));
1257    }
1258
1259    // --- Section parsing tests ---
1260
1261    #[test]
1262    fn parse_sections_extracts_named_sections() {
1263        let content = "---\nname: test\ndescription: Test\n---\n\n## Overview\n\nGeneral info here.\n\n## Details\n\nMore specific content.\n\n## Examples\n\nExample 1\nExample 2";
1264        let sections = parse_sections(content);
1265        assert_eq!(sections.len(), 3);
1266        assert_eq!(sections[0].name, "Overview");
1267        assert_eq!(sections[0].content, "General info here.");
1268        assert_eq!(sections[1].name, "Details");
1269        assert_eq!(sections[1].content, "More specific content.");
1270        assert_eq!(sections[2].name, "Examples");
1271        assert_eq!(sections[2].content, "Example 1\nExample 2");
1272    }
1273
1274    #[test]
1275    fn parse_sections_returns_empty_for_no_headings() {
1276        let content = "---\nname: test\ndescription: Test\n---\n\nJust a body with no sections.";
1277        let sections = parse_sections(content);
1278        assert!(sections.is_empty());
1279    }
1280
1281    #[test]
1282    fn parse_sections_handles_no_frontmatter() {
1283        let content = "## Intro\n\nHello world\n\n## Conclusion\n\nBye";
1284        let sections = parse_sections(content);
1285        assert_eq!(sections.len(), 2);
1286        assert_eq!(sections[0].name, "Intro");
1287        assert_eq!(sections[0].content, "Hello world");
1288        assert_eq!(sections[1].name, "Conclusion");
1289        assert_eq!(sections[1].content, "Bye");
1290    }
1291
1292    #[test]
1293    fn skill_index_shows_sections() {
1294        let skills = vec![
1295            Skill {
1296                name: "with-sections".to_string(),
1297                description: "Has sections".to_string(),
1298                path: PathBuf::from("/tmp/skills/with-sections/SKILL.md"),
1299                mode: SkillMode::Manual,
1300                triggers: vec![],
1301                hint: String::new(),
1302                depends_on: vec![],
1303                refs: vec![],
1304                params: vec![],
1305                scripts: vec![],
1306                sections: vec![
1307                    SkillSection {
1308                        name: "Overview".to_string(),
1309                        content: "General info".to_string(),
1310                    },
1311                    SkillSection {
1312                        name: "Details".to_string(),
1313                        content: "Specific info".to_string(),
1314                    },
1315                ],
1316            },
1317            Skill {
1318                name: "no-sections".to_string(),
1319                description: "No sections".to_string(),
1320                path: PathBuf::from("/tmp/skills/no-sections/SKILL.md"),
1321                mode: SkillMode::Manual,
1322                triggers: vec![],
1323                hint: String::new(),
1324                depends_on: vec![],
1325                refs: vec![],
1326                params: vec![],
1327                scripts: vec![],
1328                sections: vec![],
1329            },
1330        ];
1331
1332        let result = skill_index(&skills);
1333        assert!(result.contains("sections: Overview, Details"));
1334        assert!(result.contains("- no-sections: No sections"));
1335    }
1336
1337    #[test]
1338    fn discover_skills_populates_sections() {
1339        let tmp = tempfile::TempDir::new().unwrap();
1340        let base = tmp.path();
1341
1342        let skill_dir = base.join("sectioned-skill");
1343        fs::create_dir(&skill_dir).unwrap();
1344        fs::write(
1345            skill_dir.join("SKILL.md"),
1346            "---\nname: sectioned-skill\ndescription: A skill with sections\n---\n\n## Setup\n\nInstallation steps.\n\n## Usage\n\nHow to use it.\n\n## API\n\nReference docs.",
1347        )
1348        .unwrap();
1349
1350        let skills = discover_skills(&[base.to_path_buf()]);
1351        assert_eq!(skills.len(), 1);
1352        let skill = &skills[0];
1353        assert_eq!(skill.sections.len(), 3);
1354        assert_eq!(skill.sections[0].name, "Setup");
1355        assert_eq!(skill.sections[1].name, "Usage");
1356        assert_eq!(skill.sections[2].name, "API");
1357    }
1358}