Skip to main content

mermaid_cli/app/
skills.rs

1//! SKILL.md discovery + the always-injected skills index.
2//!
3//! Progressive disclosure without a synthetic tool: at startup we discover
4//! `SKILL.md` playbooks (project > user > enabled plugins), render a compact
5//! index (name, one-line description, absolute path), and inject it into the
6//! instructions channel — the same pattern as the memory index. The model
7//! activates a skill by reading its `SKILL.md` with the existing policy-gated
8//! `read_file`, so activation is honest in the transcript and costs zero
9//! per-request tool-schema bytes.
10//!
11//! Loading is startup-only (no watcher): skills are rarely-edited authored
12//! artifacts; restart to pick up changes.
13
14use std::path::{Path, PathBuf};
15
16/// Hard cap on indexed skills — the index is prompt real estate.
17pub const MAX_SKILLS: usize = 64;
18/// Per-entry description clamp (chars) so one verbose skill can't hog the index.
19const MAX_DESCRIPTION_CHARS: usize = 200;
20/// Byte budget for the rendered index block.
21const MAX_INDEX_BYTES: usize = 8 * 1024;
22/// Bounded read for each SKILL.md — only the frontmatter matters here, and the
23/// model reads the full body itself on activation.
24const MAX_SKILL_FILE_BYTES: usize = 8 * 1024;
25
26/// Where a skill was discovered — also its precedence class (project wins).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SkillSource {
29    /// `<git-root>/.mermaid/skills/<name>/SKILL.md` — shared with the team.
30    Project,
31    /// `<config_dir>/skills/<name>/SKILL.md` — this machine's user.
32    User,
33    /// Declared by an enabled plugin's manifest `skills` list.
34    Plugin,
35}
36
37impl SkillSource {
38    /// Short label rendered in the index so the model (and the user reading a
39    /// transcript) can see where each playbook comes from.
40    fn label(self) -> &'static str {
41        match self {
42            SkillSource::Project => "project",
43            SkillSource::User => "user",
44            SkillSource::Plugin => "plugin",
45        }
46    }
47}
48
49/// One discovered skill: index metadata plus the absolute SKILL.md path the
50/// model reads on activation.
51#[derive(Debug, Clone)]
52pub struct SkillEntry {
53    /// Frontmatter `name:`, falling back to the skill's directory name.
54    pub name: String,
55    /// Frontmatter `description:`, falling back to the first body line.
56    pub description: String,
57    /// Absolute path to the SKILL.md file.
58    pub path: PathBuf,
59    /// Discovery origin (and precedence class).
60    pub source: SkillSource,
61}
62
63/// Snapshot of all discovered skills plus the pre-rendered index block that
64/// `build_chat_request` injects into the instructions suffix.
65#[derive(Debug, Clone)]
66pub struct LoadedSkills {
67    /// Deduplicated entries in precedence order (project, user, plugin).
68    pub entries: Vec<SkillEntry>,
69    /// The rendered `# Skills` block (capped; see `render_index`).
70    pub index: String,
71}
72
73/// Discover every skill visible from `cwd`, or `None` when there are none.
74/// Never errors: an unreadable root or file is skipped (per-file tolerance) —
75/// a broken skill must not take down startup.
76pub fn load(cwd: &Path) -> Option<LoadedSkills> {
77    let project_root = crate::app::memory::find_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
78    let project = discover_dir(
79        &project_root.join(".mermaid").join("skills"),
80        SkillSource::Project,
81    );
82    let user = match crate::app::get_config_dir() {
83        Ok(dir) => discover_dir(&dir.join("skills"), SkillSource::User),
84        Err(_) => Vec::new(),
85    };
86    let plugin = plugin_entries();
87    let entries = merge_by_precedence(vec![project, user, plugin]);
88    if entries.is_empty() {
89        return None;
90    }
91    let index = render_index(&entries);
92    Some(LoadedSkills { entries, index })
93}
94
95/// Read `root/<name>/SKILL.md` for every subdirectory of `root`, sorted by
96/// skill name for a deterministic index. Missing root ⇒ empty.
97fn discover_dir(root: &Path, source: SkillSource) -> Vec<SkillEntry> {
98    let mut entries = Vec::new();
99    let Ok(read) = std::fs::read_dir(root) else {
100        return entries;
101    };
102    for dir in read.flatten() {
103        let path = dir.path();
104        if !dir.file_type().map(|t| t.is_dir()).unwrap_or(false) {
105            continue;
106        }
107        if let Some(entry) = read_skill_entry(&path.join("SKILL.md"), source) {
108            entries.push(entry);
109        }
110    }
111    entries.sort_by(|a, b| a.name.cmp(&b.name));
112    entries
113}
114
115/// Parse one SKILL.md into an entry. `None` when the file is missing or
116/// unreadable — discovery is tolerant so one broken skill never hides the rest.
117fn read_skill_entry(path: &Path, source: SkillSource) -> Option<SkillEntry> {
118    let raw = match crate::utils::read_file_capped(path, MAX_SKILL_FILE_BYTES) {
119        Ok((bytes, _truncated)) => String::from_utf8_lossy(&bytes).into_owned(),
120        Err(e) => {
121            tracing::warn!(path = %path.display(), error = %e, "skills: skipping unreadable SKILL.md");
122            return None;
123        },
124    };
125    let (name, description) = parse_skill_frontmatter(&raw);
126    // Name falls back to the containing directory (the conventional skill id).
127    let name = name.or_else(|| {
128        path.parent()
129            .and_then(|p| p.file_name())
130            .and_then(|n| n.to_str())
131            .map(str::to_string)
132    })?;
133    let mut description = description.unwrap_or_default();
134    if description.chars().count() > MAX_DESCRIPTION_CHARS {
135        description = description.chars().take(MAX_DESCRIPTION_CHARS).collect();
136        description.push_str("...");
137    }
138    Some(SkillEntry {
139        name,
140        description,
141        path: path.to_path_buf(),
142        source,
143    })
144}
145
146/// Extract `name:` / `description:` from a leading `---` frontmatter fence,
147/// with the first non-empty body line as the description fallback. Simple
148/// line-based parsing (Claude Code-compatible frontmatter needs no YAML dep);
149/// a missing or unclosed fence means the whole file is body.
150fn parse_skill_frontmatter(raw: &str) -> (Option<String>, Option<String>) {
151    let (name, description, _) = parse_frontmatter_with_body(raw);
152    (name, description)
153}
154
155/// [`parse_skill_frontmatter`] plus the body after the fence — the shared
156/// dialect for skills AND plugin prompt commands (`app::plugin_assets`).
157pub(crate) fn parse_frontmatter_with_body(raw: &str) -> (Option<String>, Option<String>, String) {
158    let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
159    let mut name = None;
160    let mut description = None;
161    let mut body_lines: Vec<&str> = Vec::new();
162    let mut lines = raw.lines();
163    if lines.next().map(str::trim) == Some("---") {
164        let mut in_fm = true;
165        for line in lines {
166            if in_fm {
167                if line.trim() == "---" {
168                    in_fm = false;
169                    continue;
170                }
171                if let Some((key, value)) = line.split_once(':') {
172                    let value = value.trim().trim_matches('"').to_string();
173                    match key.trim() {
174                        "name" if !value.is_empty() => name = Some(value),
175                        "description" if !value.is_empty() => description = Some(value),
176                        _ => {},
177                    }
178                }
179            } else {
180                body_lines.push(line);
181            }
182        }
183        if in_fm {
184            // Unclosed fence — not real frontmatter; treat the file as body.
185            name = None;
186            description = None;
187            body_lines = raw.lines().collect();
188        }
189    } else {
190        body_lines = raw.lines().collect();
191    }
192    let first_body_line = body_lines
193        .iter()
194        .map(|l| l.trim())
195        .find(|l| !l.is_empty())
196        .map(str::to_string);
197    (name, description.or(first_body_line), body_lines.join("\n"))
198}
199
200/// Resolve a plugin's declared skill paths to canonical SKILL.md files,
201/// enforcing the same canonicalize + containment check as hooks: a symlink
202/// inside the plugin root must not reach files outside it. A declared entry
203/// may be the SKILL.md itself or its containing directory.
204fn plugin_skill_paths(canonical_root: &Path, declared: &[String]) -> Vec<PathBuf> {
205    let mut out = Vec::new();
206    for entry in declared {
207        let Ok(mut resolved) = std::fs::canonicalize(canonical_root.join(entry)) else {
208            continue; // missing skill: nothing to index
209        };
210        if resolved.is_dir() {
211            let Ok(inner) = std::fs::canonicalize(resolved.join("SKILL.md")) else {
212                continue;
213            };
214            resolved = inner;
215        }
216        if !resolved.starts_with(canonical_root) {
217            tracing::warn!(entry = %entry, "plugin skill escapes plugin root; skipping");
218            continue;
219        }
220        out.push(resolved);
221    }
222    out
223}
224
225/// Skills declared by enabled plugins. Store/parse failures degrade to empty —
226/// skills are additive context, never a startup blocker.
227fn plugin_entries() -> Vec<SkillEntry> {
228    let Ok(store) = crate::runtime::RuntimeStore::open_default() else {
229        return Vec::new();
230    };
231    let Ok(plugins) = store.plugins().list() else {
232        return Vec::new();
233    };
234    let mut entries = Vec::new();
235    for plugin in plugins {
236        // Same trust boundary as hooks: only explicitly enabled plugins
237        // contribute (a disabled plugin's text still steers the model).
238        if !plugin.enabled {
239            continue;
240        }
241        let Ok(manifest) =
242            serde_json::from_str::<crate::runtime::PluginManifest>(&plugin.manifest_json)
243        else {
244            continue;
245        };
246        if manifest.skills.is_empty() {
247            continue;
248        }
249        let Ok(root) = std::fs::canonicalize(&plugin.source) else {
250            continue;
251        };
252        for path in plugin_skill_paths(&root, &manifest.skills) {
253            if let Some(entry) = read_skill_entry(&path, SkillSource::Plugin) {
254                entries.push(entry);
255            }
256        }
257    }
258    entries.sort_by(|a, b| a.name.cmp(&b.name));
259    entries
260}
261
262/// Merge discovery groups (given in precedence order, highest first) into one
263/// list, deduplicating by name — the first occurrence wins, so a project skill
264/// shadows a same-named user or plugin skill. Pure; unit-testable.
265pub fn merge_by_precedence(groups: Vec<Vec<SkillEntry>>) -> Vec<SkillEntry> {
266    let mut seen = std::collections::HashSet::new();
267    let mut merged = Vec::new();
268    for group in groups {
269        for entry in group {
270            if seen.insert(entry.name.clone()) {
271                merged.push(entry);
272            }
273        }
274    }
275    merged
276}
277
278/// Render the always-injected `# Skills` block: header with the activation
279/// instruction, then one line per skill, capped at [`MAX_SKILLS`] entries and
280/// [`MAX_INDEX_BYTES`] bytes with a `(+N more not listed)` overflow line.
281/// Entries arrive precedence-ordered, so overflow drops plugin/user tails
282/// before any project skill. Pure; unit-testable.
283pub fn render_index(entries: &[SkillEntry]) -> String {
284    let mut out = String::from(
285        "# Skills\n\nTask-specific playbooks available on this machine. When a skill's \
286         description matches the task at hand, read its SKILL.md with `read_file` \
287         before proceeding.\n\n",
288    );
289    // Reserve room for the overflow line so the byte cap can't orphan it.
290    const OVERFLOW_RESERVE: usize = 32;
291    let mut listed = 0;
292    for entry in entries {
293        if listed == MAX_SKILLS {
294            break;
295        }
296        let line = format!(
297            "- [{}] {} — {} ({})\n",
298            entry.name,
299            entry.description,
300            entry.path.display(),
301            entry.source.label()
302        );
303        if out.len() + line.len() > MAX_INDEX_BYTES.saturating_sub(OVERFLOW_RESERVE) {
304            break;
305        }
306        out.push_str(&line);
307        listed += 1;
308    }
309    let skipped = entries.len() - listed;
310    if skipped > 0 {
311        out.push_str(&format!("(+{skipped} more not listed)\n"));
312    }
313    out
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use std::fs;
320
321    /// Fresh temp dir per test, mirroring the memory.rs convention (no
322    /// tempfile crate — deliberate).
323    fn temp_dir(tag: &str) -> PathBuf {
324        let dir = std::env::temp_dir().join(format!(
325            "mermaid-skills-{tag}-{}-{:?}",
326            std::process::id(),
327            std::thread::current().id()
328        ));
329        let _ = fs::remove_dir_all(&dir);
330        fs::create_dir_all(&dir).unwrap();
331        dir
332    }
333
334    fn entry(name: &str, source: SkillSource) -> SkillEntry {
335        SkillEntry {
336            name: name.to_string(),
337            description: format!("{name} description"),
338            path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
339            source,
340        }
341    }
342
343    #[test]
344    fn frontmatter_parses_name_and_description() {
345        let (name, desc) =
346            parse_skill_frontmatter("---\nname: deploy\ndescription: \"Ship it\"\n---\n\nBody.\n");
347        assert_eq!(name.as_deref(), Some("deploy"));
348        assert_eq!(desc.as_deref(), Some("Ship it"));
349    }
350
351    #[test]
352    fn frontmatter_missing_description_falls_back_to_first_body_line() {
353        let (name, desc) =
354            parse_skill_frontmatter("---\nname: deploy\n---\n\nFirst body line.\nSecond.\n");
355        assert_eq!(name.as_deref(), Some("deploy"));
356        assert_eq!(desc.as_deref(), Some("First body line."));
357    }
358
359    #[test]
360    fn frontmatter_absent_treats_whole_file_as_body() {
361        let (name, desc) = parse_skill_frontmatter("Just a body.\n");
362        assert_eq!(name, None);
363        assert_eq!(desc.as_deref(), Some("Just a body."));
364    }
365
366    #[test]
367    fn frontmatter_unclosed_fence_is_body() {
368        let (name, desc) = parse_skill_frontmatter("---\nname: broken\nno closing fence\n");
369        assert_eq!(name, None);
370        // The whole raw text is body; its first non-empty line is `---`.
371        assert_eq!(desc.as_deref(), Some("---"));
372    }
373
374    #[test]
375    fn merge_dedupes_by_name_project_wins() {
376        let merged = merge_by_precedence(vec![
377            vec![entry("deploy", SkillSource::Project)],
378            vec![
379                entry("deploy", SkillSource::User),
380                entry("review", SkillSource::User),
381            ],
382            vec![entry("review", SkillSource::Plugin)],
383        ]);
384        assert_eq!(merged.len(), 2);
385        assert_eq!(merged[0].name, "deploy");
386        assert_eq!(merged[0].source, SkillSource::Project);
387        assert_eq!(merged[1].name, "review");
388        assert_eq!(merged[1].source, SkillSource::User);
389    }
390
391    #[test]
392    fn render_index_lists_entries_with_source_labels() {
393        let index = render_index(&[
394            entry("deploy", SkillSource::Project),
395            entry("review", SkillSource::User),
396        ]);
397        assert!(index.starts_with("# Skills\n"));
398        assert!(index.contains("read its SKILL.md with `read_file`"));
399        assert!(
400            index.contains("- [deploy] deploy description — /skills/deploy/SKILL.md (project)")
401        );
402        assert!(index.contains("- [review] review description — /skills/review/SKILL.md (user)"));
403        assert!(!index.contains("more not listed"));
404    }
405
406    #[test]
407    fn render_index_caps_entry_count_with_overflow_line() {
408        let entries: Vec<SkillEntry> = (0..MAX_SKILLS + 5)
409            .map(|i| entry(&format!("skill-{i:03}"), SkillSource::User))
410            .collect();
411        let index = render_index(&entries);
412        assert!(index.contains("skill-000"));
413        assert!(index.contains(&format!("skill-{:03}", MAX_SKILLS - 1)));
414        assert!(!index.contains(&format!("skill-{:03}", MAX_SKILLS)));
415        assert!(index.contains("(+5 more not listed)"));
416    }
417
418    #[test]
419    fn render_index_caps_bytes_with_overflow_line() {
420        let entries: Vec<SkillEntry> = (0..MAX_SKILLS)
421            .map(|i| SkillEntry {
422                name: format!("skill-{i:03}"),
423                description: "d".repeat(MAX_DESCRIPTION_CHARS),
424                path: PathBuf::from(format!("/skills/skill-{i:03}/SKILL.md")),
425                source: SkillSource::User,
426            })
427            .collect();
428        let index = render_index(&entries);
429        assert!(index.len() <= MAX_INDEX_BYTES);
430        assert!(index.contains("more not listed"));
431    }
432
433    #[test]
434    fn discover_reads_skill_dirs_sorted_and_tolerates_junk() {
435        let dir = temp_dir("discover");
436        let root = dir.join("skills");
437        fs::create_dir_all(root.join("zeta")).unwrap();
438        fs::write(
439            root.join("zeta").join("SKILL.md"),
440            "---\nname: zeta\ndescription: Last alphabetically\n---\nBody",
441        )
442        .unwrap();
443        fs::create_dir_all(root.join("alpha")).unwrap();
444        fs::write(root.join("alpha").join("SKILL.md"), "No frontmatter body.").unwrap();
445        // A dir without SKILL.md and a stray file are both skipped.
446        fs::create_dir_all(root.join("empty")).unwrap();
447        fs::write(root.join("README.md"), "not a skill").unwrap();
448        let entries = discover_dir(&root, SkillSource::Project);
449        assert_eq!(entries.len(), 2);
450        assert_eq!(entries[0].name, "alpha"); // dir-name fallback
451        assert_eq!(entries[0].description, "No frontmatter body.");
452        assert_eq!(entries[1].name, "zeta");
453        assert!(entries[1].path.ends_with("zeta/SKILL.md"));
454        let _ = fs::remove_dir_all(&dir);
455    }
456
457    #[test]
458    fn discover_missing_root_is_empty() {
459        let dir = temp_dir("missing-root");
460        assert!(discover_dir(&dir.join("nope"), SkillSource::User).is_empty());
461        let _ = fs::remove_dir_all(&dir);
462    }
463
464    #[test]
465    fn load_discovers_project_skills_from_git_root() {
466        let dir = temp_dir("load-project");
467        fs::create_dir(dir.join(".git")).unwrap();
468        let skill_dir = dir.join(".mermaid").join("skills").join("demo");
469        fs::create_dir_all(&skill_dir).unwrap();
470        fs::write(
471            skill_dir.join("SKILL.md"),
472            "---\nname: demo\ndescription: Demo skill\n---\nSteps.",
473        )
474        .unwrap();
475        // Load from a SUBDIRECTORY so the git-root walk is exercised.
476        let sub = dir.join("src");
477        fs::create_dir_all(&sub).unwrap();
478        let loaded = load(&sub).expect("project skill should be discovered");
479        assert!(loaded.entries.iter().any(|e| e.name == "demo"));
480        assert!(loaded.index.contains("[demo] Demo skill"));
481        let _ = fs::remove_dir_all(&dir);
482    }
483
484    #[cfg(unix)]
485    #[test]
486    fn plugin_skill_paths_enforce_containment() {
487        let dir = temp_dir("plugin-containment");
488        let root = dir.join("plugin");
489        let outside = dir.join("outside");
490        fs::create_dir_all(&root).unwrap();
491        fs::create_dir_all(&outside).unwrap();
492        fs::write(outside.join("SKILL.md"), "escaped").unwrap();
493        // In-root skill as a direct file.
494        let inside = root.join("skills").join("ok");
495        fs::create_dir_all(&inside).unwrap();
496        fs::write(inside.join("SKILL.md"), "---\nname: ok\n---\nBody").unwrap();
497        // Symlink pointing outside the root must be rejected.
498        std::os::unix::fs::symlink(&outside, root.join("evil")).unwrap();
499        let canonical_root = fs::canonicalize(&root).unwrap();
500        let paths = plugin_skill_paths(
501            &canonical_root,
502            &["skills/ok".to_string(), "evil".to_string()],
503        );
504        assert_eq!(paths.len(), 1);
505        assert!(paths[0].ends_with("ok/SKILL.md"));
506        let _ = fs::remove_dir_all(&dir);
507    }
508}