Skip to main content

mcp_methods/server/
skills.rs

1//! Skills-aware MCP — runtime types, frontmatter parsing, three-layer
2//! resolution, and the [`Registry`] builder downstream binaries
3//! consume to wire skills into their MCP server.
4//!
5//! # The shape downstream binaries adopt
6//!
7//! ```ignore
8//! use mcp_methods::server::skills::{Registry, BundledSkill};
9//! use mcp_methods::server::manifest::load;
10//!
11//! let manifest = load(yaml_path)?;
12//! let registry = Registry::new()
13//!     // Domain-specific bundled skills (one per custom tool):
14//!     .add_bundled(BundledSkill {
15//!         name: "cypher_query",
16//!         body: include_str!("skills/cypher_query.md"),
17//!     })
18//!     .add_bundled(BundledSkill {
19//!         name: "graph_overview",
20//!         body: include_str!("skills/graph_overview.md"),
21//!     })
22//!     // Framework defaults (ripgrep, github_discussions, etc.):
23//!     .merge_framework_defaults()
24//!     // Operator-declared paths from the manifest's `skills:` field:
25//!     .layer_dirs(&manifest.skills, &manifest.yaml_path)?
26//!     // Project-local <basename>.skills/ adjacent to the YAML:
27//!     .auto_detect_project_layer(&manifest.yaml_path)
28//!     // Resolve all layers, run lint, return the resolved registry:
29//!     .finalise()?;
30//!
31//! // Phase 1c wires this into `serve_prompts(&registry, &mut server)`.
32//! ```
33//!
34//! # Three-layer composition
35//!
36//! 1. **Project layer (top priority).** Auto-detected from
37//!    `<manifest_basename>.skills/` adjacent to the YAML. Files there
38//!    override every other layer per skill name. This is the operator's
39//!    per-deployment tweak zone.
40//! 2. **Root layer (middle).** Each entry in the manifest's `skills:`
41//!    list, walked in declaration order. First-match-per-name wins.
42//!    This is where operator-curated domain skill-packs sit
43//!    (`kglite-skills-legal/`, etc.).
44//! 3. **Bundled layer (bottom).** Compile-time defaults shipped with
45//!    `mcp-methods` plus any added by the downstream binary via
46//!    [`Registry::add_bundled`]. Library authors ship protocol-level
47//!    methodology here; operators inherit it.
48//!
49//! Within the bundled layer, the downstream binary's skills win over
50//! the framework's defaults when names collide.
51//!
52//! # Static markdown — no dynamic rendering
53//!
54//! Skills are pure markdown bodies with YAML frontmatter. The framework
55//! does NOT splice tool output, run shell commands, or evaluate
56//! templates server-side. Skills teach the agent *how* to use tools;
57//! tools provide dynamic content when invoked. This keeps skill loading
58//! deterministic and cheap, and matches Anthropic's own skill format.
59//!
60//! See `dev-documentation/skills-aware-mcp.md` for the full design.
61
62use std::collections::HashMap;
63use std::fs;
64use std::path::{Path, PathBuf};
65
66use serde::Deserialize;
67
68use super::manifest::{SkillSource, SkillsSource};
69
70// ─── Public types ─────────────────────────────────────────────────
71
72/// A compile-time bundled skill, embedded into the binary via
73/// `include_str!`. Downstream binaries (e.g. `kglite-mcp-server`)
74/// construct these for their custom tools; the framework constructs
75/// them for its own (`grep`, `read_source`, etc.).
76///
77/// Bundled skills sit at the bottom of the three-layer composition —
78/// project and root-layer entries override them when names collide.
79#[derive(Debug, Clone)]
80pub struct BundledSkill {
81    /// Skill name. Must match the `name` field in the markdown
82    /// frontmatter. Used as the lookup key in `prompts/get`.
83    pub name: &'static str,
84    /// The full SKILL.md content — frontmatter + body. Parsed at
85    /// `Registry::add_bundled` time; malformed bundled skills are
86    /// errors (caught by the framework's CI tests), not warnings.
87    pub body: &'static str,
88}
89
90/// Parsed YAML frontmatter of a SKILL.md file.
91///
92/// Phase 1b stores all declared fields as raw values. Phase 1f / 2a
93/// will add validation (`applies_to` semver checks, `references_tools`
94/// against the active tool catalogue, `references_arguments` against
95/// each tool's input schema). For now: parse and preserve; the lint
96/// step in `Registry::finalise()` walks these and surfaces issues as
97/// log warnings.
98#[derive(Debug, Clone, Default, Deserialize)]
99pub struct SkillFrontmatter {
100    /// Skill name. Must match the lookup key used in `prompts/get`.
101    /// Required; empty after deserialization triggers a clear
102    /// [`SkillError::MissingRequiredField`] rather than a generic
103    /// YAML parse failure.
104    #[serde(default)]
105    pub name: String,
106    /// One-line description shown in `prompts/list`. Required —
107    /// the agent uses this to decide whether to load the full body.
108    #[serde(default)]
109    pub description: String,
110
111    /// Version constraints. Parsed lazily — Phase 1b stores raw
112    /// values, Phase 1f adds semver validation.
113    #[serde(default)]
114    pub applies_to: Option<HashMap<String, String>>,
115
116    /// Tools this skill teaches or references in prose. Used for
117    /// auto-inject discoverability hints (Phase 1c) and staleness
118    /// detection (Phase 1f).
119    #[serde(default)]
120    pub references_tools: Vec<String>,
121
122    /// Specific tool argument names referenced in the skill body
123    /// (e.g. `"cypher_query.format"`). Lint warns when references
124    /// don't match the tool's actual input schema.
125    #[serde(default)]
126    pub references_arguments: Vec<String>,
127
128    /// Graph properties / domain-specific references the skill calls
129    /// out (e.g. `"Function.module"`). For domain skill-packs to
130    /// declare their domain assumptions. The framework can't validate
131    /// these statically; they're documentation-grade metadata.
132    #[serde(default)]
133    pub references_properties: Vec<String>,
134
135    /// When `true` (the default) AND the skill's name matches a
136    /// registered MCP tool, the framework injects a "see `prompts/get`
137    /// `<name>` for full methodology" pointer into the tool's
138    /// description. Phase 1c wires this up.
139    #[serde(default = "default_auto_inject_hint")]
140    pub auto_inject_hint: bool,
141
142    /// `applies_when:` predicates — Phase 2 / 3 territory. Parsed
143    /// and stored verbatim for now; the predicate evaluator isn't
144    /// wired up in Phase 1.
145    #[serde(default)]
146    pub applies_when: Vec<serde_yaml::Value>,
147}
148
149fn default_auto_inject_hint() -> bool {
150    true
151}
152
153/// Where a [`Skill`] came from. Used for the boot-time collision-
154/// resolution log and surfaced via the JSON shape kglite consumes
155/// from `to_json()` (in Phase 1d).
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum SkillProvenance {
158    /// Auto-detected from `<basename>.skills/` adjacent to the
159    /// manifest YAML — top-priority operator overrides.
160    Project,
161    /// Loaded from an operator-declared path in the manifest's
162    /// `skills:` list (a domain skill-pack or shared library).
163    DomainPack(PathBuf),
164    /// Compile-time bundled — shipped with `mcp-methods` (framework
165    /// defaults) or with a downstream binary like `kglite-mcp-server`.
166    Bundled,
167}
168
169/// A loaded skill, post-parse + post-resolution. The body is the
170/// markdown content after the closing `---` frontmatter delimiter.
171#[derive(Debug, Clone)]
172pub struct Skill {
173    pub frontmatter: SkillFrontmatter,
174    pub body: String,
175    pub provenance: SkillProvenance,
176}
177
178impl Skill {
179    /// Convenience accessor for the skill's name (read from
180    /// frontmatter at parse time).
181    pub fn name(&self) -> &str {
182        &self.frontmatter.name
183    }
184
185    /// One-line description for `prompts/list` responses.
186    pub fn description(&self) -> &str {
187        &self.frontmatter.description
188    }
189}
190
191// ─── Errors ───────────────────────────────────────────────────────
192
193/// Errors surfaced during skill loading + resolution. Variants are
194/// kept distinct so downstream binaries (and the future skills-lint
195/// CLI) can report locations and surface fixes precisely.
196#[derive(Debug)]
197pub enum SkillError {
198    /// Filesystem error reading the skill file.
199    Io {
200        path: PathBuf,
201        source: std::io::Error,
202    },
203    /// Missing or malformed frontmatter delimiters.
204    MissingFrontmatter { path: PathBuf },
205    /// Frontmatter present but invalid YAML.
206    InvalidFrontmatter { path: PathBuf, message: String },
207    /// Required frontmatter field missing (name or description).
208    MissingRequiredField { path: PathBuf, field: &'static str },
209    /// Skill body exceeds the hard size limit (16 KB by default).
210    SkillTooLarge {
211        path: PathBuf,
212        bytes: usize,
213        limit: usize,
214    },
215    /// Path declared in the manifest's `skills:` list doesn't exist
216    /// or isn't a directory.
217    PathNotFound { raw: String, resolved: PathBuf },
218    /// Compile-time bundled skill (added via `add_bundled`) failed to
219    /// parse. This is a framework-author or downstream-binary-author
220    /// bug — the bundled skill files should round-trip through their
221    /// own CI tests before shipping.
222    BundledSkillInvalid { name: &'static str, message: String },
223}
224
225impl std::fmt::Display for SkillError {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        match self {
228            SkillError::Io { path, source } => {
229                write!(f, "skill I/O error at {}: {source}", path.display())
230            }
231            SkillError::MissingFrontmatter { path } => write!(
232                f,
233                "skill at {} is missing the `---` YAML frontmatter delimiter at the start of the file",
234                path.display()
235            ),
236            SkillError::InvalidFrontmatter { path, message } => {
237                write!(
238                    f,
239                    "skill frontmatter at {} is not valid YAML: {message}",
240                    path.display()
241                )
242            }
243            SkillError::MissingRequiredField { path, field } => write!(
244                f,
245                "skill at {} is missing required frontmatter field `{field}`",
246                path.display()
247            ),
248            SkillError::SkillTooLarge {
249                path,
250                bytes,
251                limit,
252            } => write!(
253                f,
254                "skill at {} is {bytes} bytes; exceeds the {limit} byte hard limit",
255                path.display()
256            ),
257            SkillError::PathNotFound { raw, resolved } => write!(
258                f,
259                "skill path {raw:?} (resolved to {}) does not exist or is not a directory",
260                resolved.display()
261            ),
262            SkillError::BundledSkillInvalid { name, message } => write!(
263                f,
264                "bundled skill `{name}` is malformed: {message}"
265            ),
266        }
267    }
268}
269
270impl std::error::Error for SkillError {
271    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
272        match self {
273            SkillError::Io { source, .. } => Some(source),
274            _ => None,
275        }
276    }
277}
278
279// ─── Size limits ──────────────────────────────────────────────────
280
281/// Per-skill soft limit. Loading a skill larger than this logs a
282/// warning via `tracing::warn!` but does not fail.
283pub const SOFT_SIZE_LIMIT_BYTES: usize = 4 * 1024;
284/// Per-skill hard limit. Loading a skill larger than this returns
285/// [`SkillError::SkillTooLarge`]. Forces authors to keep skills
286/// tight and prevents accidental dump-the-whole-onboarding-doc.
287pub const HARD_SIZE_LIMIT_BYTES: usize = 16 * 1024;
288/// Total session limit across all resolved skills. Exceeding this
289/// logs a warning at `Registry::finalise` time but does not drop
290/// skills automatically — operators stay in control of which skills
291/// they want loaded.
292pub const SESSION_TOTAL_LIMIT_BYTES: usize = 64 * 1024;
293
294// ─── Frontmatter parser ───────────────────────────────────────────
295
296/// Split a SKILL.md file into its YAML frontmatter and markdown body.
297///
298/// Returns the frontmatter content (without the `---` delimiters) and
299/// the body (everything after the closing `---`).
300///
301/// The frontmatter MUST start at byte 0 of the file with the opening
302/// `---` on its own line, and MUST be terminated by a `---` on its
303/// own line. This matches Jekyll / Hugo / Anthropic-skills convention.
304fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
305    let trimmed = content.strip_prefix("---\n").or_else(|| {
306        // Handle CRLF line endings.
307        content.strip_prefix("---\r\n")
308    })?;
309    // Find the closing `---` on its own line.
310    let mut search_start = 0;
311    while let Some(idx) = trimmed[search_start..].find("---") {
312        let abs = search_start + idx;
313        // Must be at the start of a line.
314        let at_line_start = abs == 0 || trimmed.as_bytes().get(abs - 1) == Some(&b'\n');
315        // Must be followed by `\n`, `\r\n`, or end of file.
316        let after = &trimmed[abs + 3..];
317        let line_end_ok = after.is_empty() || after.starts_with('\n') || after.starts_with("\r\n");
318        if at_line_start && line_end_ok {
319            let frontmatter = &trimmed[..abs];
320            let body_start = if after.starts_with("\r\n") {
321                abs + 3 + 2
322            } else if after.starts_with('\n') {
323                abs + 3 + 1
324            } else {
325                abs + 3
326            };
327            let body = &trimmed[body_start..];
328            return Some((frontmatter, body));
329        }
330        search_start = abs + 3;
331    }
332    None
333}
334
335/// Parse a SKILL.md content blob into its frontmatter struct and
336/// markdown body.
337pub fn parse_skill(content: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
338    let (frontmatter_str, body) =
339        split_frontmatter(content).ok_or_else(|| SkillError::MissingFrontmatter {
340            path: path.to_path_buf(),
341        })?;
342
343    let frontmatter: SkillFrontmatter =
344        serde_yaml::from_str(frontmatter_str).map_err(|e| SkillError::InvalidFrontmatter {
345            path: path.to_path_buf(),
346            message: e.to_string(),
347        })?;
348
349    if frontmatter.name.is_empty() {
350        return Err(SkillError::MissingRequiredField {
351            path: path.to_path_buf(),
352            field: "name",
353        });
354    }
355    if frontmatter.description.is_empty() {
356        return Err(SkillError::MissingRequiredField {
357            path: path.to_path_buf(),
358            field: "description",
359        });
360    }
361
362    Ok((frontmatter, body.to_string()))
363}
364
365// ─── Skill loaders ────────────────────────────────────────────────
366
367/// Load a single skill from a file path.
368pub fn load_skill_from_file(path: &Path, provenance: SkillProvenance) -> Result<Skill, SkillError> {
369    let content = fs::read_to_string(path).map_err(|e| SkillError::Io {
370        path: path.to_path_buf(),
371        source: e,
372    })?;
373
374    if content.len() > HARD_SIZE_LIMIT_BYTES {
375        return Err(SkillError::SkillTooLarge {
376            path: path.to_path_buf(),
377            bytes: content.len(),
378            limit: HARD_SIZE_LIMIT_BYTES,
379        });
380    }
381    if content.len() > SOFT_SIZE_LIMIT_BYTES {
382        tracing::warn!(
383            path = %path.display(),
384            bytes = content.len(),
385            soft_limit = SOFT_SIZE_LIMIT_BYTES,
386            "skill exceeds the soft size limit; consider splitting"
387        );
388    }
389
390    let (frontmatter, body) = parse_skill(&content, path)?;
391    Ok(Skill {
392        frontmatter,
393        body,
394        provenance,
395    })
396}
397
398/// Walk a directory for `*.md` files, loading each as a skill.
399///
400/// Files that fail to parse log warnings via `tracing::warn!` and
401/// are skipped — one malformed skill in a domain pack shouldn't take
402/// down the rest. The lint pass surfaces these for fix-it-later.
403pub fn load_skills_from_dir(
404    dir: &Path,
405    provenance: SkillProvenance,
406) -> Result<Vec<Skill>, SkillError> {
407    if !dir.is_dir() {
408        return Ok(Vec::new());
409    }
410
411    let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
412        path: dir.to_path_buf(),
413        source: e,
414    })?;
415
416    let mut skills = Vec::new();
417    for entry in entries {
418        let entry = match entry {
419            Ok(e) => e,
420            Err(e) => {
421                tracing::warn!(
422                    dir = %dir.display(),
423                    error = %e,
424                    "failed to read directory entry; skipping"
425                );
426                continue;
427            }
428        };
429        let path = entry.path();
430        // Only `.md` files. Subdirectories and other extensions are
431        // ignored (no recursion — keeps the model simple).
432        if path.extension().map(|e| e == "md").unwrap_or(false) {
433            match load_skill_from_file(&path, provenance.clone()) {
434                Ok(skill) => skills.push(skill),
435                Err(e) => {
436                    tracing::warn!(
437                        path = %path.display(),
438                        error = %e,
439                        "failed to load skill; skipping"
440                    );
441                }
442            }
443        }
444    }
445    Ok(skills)
446}
447
448// ─── Path resolution ──────────────────────────────────────────────
449
450/// Resolve a skill path declaration against the manifest's parent
451/// directory, applying the same conventions used by other manifest
452/// fields:
453///
454/// - `./foo` or `foo` → relative to the manifest's parent dir
455/// - `~/foo` → home-relative (POSIX `$HOME` expansion)
456/// - `/foo` or `C:\foo` → absolute
457///
458/// Public so downstream binaries can resolve paths consistently if
459/// they need to.
460pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
461    let p = Path::new(raw);
462    if p.is_absolute() {
463        return p.to_path_buf();
464    }
465    if let Some(rest) = raw.strip_prefix("~/") {
466        if let Some(home) = std::env::var_os("HOME") {
467            return PathBuf::from(home).join(rest);
468        }
469        // No HOME — fall through to manifest-relative.
470    }
471    manifest_dir.join(raw)
472}
473
474/// Project layer path for a manifest: `<manifest_stem>.skills/` next
475/// to the manifest YAML.
476///
477/// For a manifest at `mcp-servers/legal_mcp.yaml`, the project layer
478/// lives at `mcp-servers/legal_mcp.skills/`.
479pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
480    let stem = yaml_path
481        .file_stem()
482        .map(|s| s.to_string_lossy().into_owned())
483        .unwrap_or_else(|| "manifest".to_string());
484    let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
485    parent.join(format!("{stem}.skills"))
486}
487
488// ─── Library-bundled framework defaults ───────────────────────────
489
490/// Return the framework's own bundled skills.
491///
492/// The five SKILL.md files are embedded at compile time via the
493/// [`bundled_skills_index`](crate::server::bundled_skills_index)
494/// submodule. Downstream binaries call this through
495/// [`Registry::merge_framework_defaults`] when they want the
496/// framework defaults at the bottom of their three-layer stack.
497pub fn library_bundled_skills() -> Vec<BundledSkill> {
498    crate::server::bundled_skills_index::library_bundled_skills()
499}
500
501// ─── Authoring template ───────────────────────────────────────────
502
503/// Render a starter SKILL.md body as a string.
504///
505/// The returned text is a complete, parse-valid SKILL.md file with
506/// the supplied `name` and `description` filled into the frontmatter
507/// and the rest of the optional extension fields commented out.
508/// The body follows the anatomy documented in
509/// `docs/guides/writing-effective-skills.md` — Overview, Quick
510/// Reference table, a placeholder major-topic section, Common
511/// Pitfalls, and a "When wrong" section — all with `<TODO>`-style
512/// placeholders the operator fills in.
513///
514/// Use [`write_skill_template`] for the on-disk version.
515pub fn render_skill_template(name: &str, description: &str) -> String {
516    format!(
517        "---\n\
518         name: {name}\n\
519         description: {description}\n\
520         # Optional mcp-methods extension fields (uncomment as needed):\n\
521         # applies_to:\n\
522         #   mcp_methods: \">=0.3.35\"\n\
523         # references_tools:\n\
524         #   - {name}\n\
525         # references_arguments:\n\
526         #   - {name}.<arg_name>\n\
527         # auto_inject_hint: true\n\
528         ---\n\
529         \n\
530         # `{name}` methodology\n\
531         \n\
532         ## Overview\n\
533         \n\
534         <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
535         what comes before and after it in the typical workflow.>\n\
536         \n\
537         ## Quick Reference\n\
538         \n\
539         | Task | Approach |\n\
540         |---|---|\n\
541         | <TODO: common task A> | <TODO: one-line pattern> |\n\
542         | <TODO: common task B> | <TODO: one-line pattern> |\n\
543         \n\
544         ## <TODO: Major topic>\n\
545         \n\
546         <TODO: concrete prose, code blocks, examples.>\n\
547         \n\
548         ## Common Pitfalls\n\
549         \n\
550         ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
551         \n\
552         ✅ <TODO: positive guidance, often a heuristic>\n\
553         \n\
554         ## When `{name}` is the wrong tool\n\
555         \n\
556         - **<TODO: scenario>** — use <other tool> because <reason>.\n"
557    )
558}
559
560/// Resolve where a template write should land and write it.
561///
562/// `dest` interpretation:
563/// - If `dest` is an existing directory, the file is written to
564///   `dest/<name>.md`.
565/// - If `dest` ends in `.md`, it is used verbatim and its parent
566///   must already exist.
567/// - Otherwise `dest` is treated as a directory that should be
568///   created (and its parents created with `create_dir_all`) before
569///   writing `dest/<name>.md`.
570///
571/// Existing files are never overwritten — if the destination already
572/// exists, returns a `SkillError::Io` wrapping `AlreadyExists`. The
573/// caller should delete first if they really want to replace.
574pub fn write_skill_template(
575    dest: &Path,
576    name: &str,
577    description: &str,
578) -> Result<PathBuf, SkillError> {
579    let path = resolve_template_dest(dest, name);
580
581    if path.exists() {
582        return Err(SkillError::Io {
583            path: path.clone(),
584            source: std::io::Error::new(
585                std::io::ErrorKind::AlreadyExists,
586                "destination already exists; delete it before re-running",
587            ),
588        });
589    }
590
591    if let Some(parent) = path.parent() {
592        if !parent.as_os_str().is_empty() && !parent.exists() {
593            fs::create_dir_all(parent).map_err(|e| SkillError::Io {
594                path: parent.to_path_buf(),
595                source: e,
596            })?;
597        }
598    }
599
600    let body = render_skill_template(name, description);
601    fs::write(&path, body).map_err(|e| SkillError::Io {
602        path: path.clone(),
603        source: e,
604    })?;
605    Ok(path)
606}
607
608fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
609    if dest.is_dir() {
610        return dest.join(format!("{name}.md"));
611    }
612    if dest
613        .extension()
614        .map(|e| e.eq_ignore_ascii_case("md"))
615        .unwrap_or(false)
616    {
617        return dest.to_path_buf();
618    }
619    dest.join(format!("{name}.md"))
620}
621
622// ─── Registry builder ─────────────────────────────────────────────
623
624/// Builder for a skills [`ResolvedRegistry`]. Downstream binaries
625/// (`kglite-mcp-server`, etc.) construct one of these in their
626/// boot path, layer in their bundled + operator-declared skills,
627/// then call [`Registry::finalise`] to get the resolved set
628/// ready for MCP `prompts/list` + `prompts/get` wiring.
629///
630/// See the module docs for the canonical usage pattern.
631#[derive(Debug, Default)]
632pub struct Registry {
633    bundled: Vec<BundledSkill>,
634    /// Sources from the manifest's `skills:` list, in declaration
635    /// order. Each entry contributes a layer; later entries within
636    /// the root layer have lower priority than earlier ones.
637    root_dirs: Vec<(PathBuf, String)>, // (resolved_path, raw_decl_string)
638    root_includes_bundled: bool,
639    /// Project layer — auto-detected `<basename>.skills/` adjacent
640    /// to the manifest YAML. Set via `auto_detect_project_layer`.
641    project_dir: Option<PathBuf>,
642}
643
644impl Registry {
645    /// Construct an empty registry. Chain in `add_bundled`,
646    /// `merge_framework_defaults`, `layer_dirs`, and
647    /// `auto_detect_project_layer` calls, then call `finalise()`.
648    pub fn new() -> Self {
649        Self::default()
650    }
651
652    /// Add a compile-time bundled skill. Typically called by
653    /// downstream binaries with their own `include_str!`'d skills,
654    /// once per custom tool.
655    ///
656    /// Bundled skills sit at the bottom of the three-layer
657    /// composition; later layers override them when names collide.
658    /// Within the bundled set, the downstream binary's skills win
659    /// over framework defaults (the downstream calls `add_bundled`
660    /// before or after `merge_framework_defaults` — order doesn't
661    /// matter; resolution dedupes by name with downstream-first
662    /// priority).
663    ///
664    /// Malformed bundled skills are reported at `finalise()` time
665    /// via [`SkillError::BundledSkillInvalid`]. The framework's
666    /// own bundled-skill CI test should catch this for the library
667    /// defaults; downstream binaries should write equivalent tests
668    /// for their own bundled set.
669    pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
670        self.bundled.push(skill);
671        self
672    }
673
674    /// Add a batch of compile-time bundled skills.
675    pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
676        self.bundled.extend(skills);
677        self
678    }
679
680    /// Merge in the framework's own bundled defaults (returned by
681    /// [`library_bundled_skills`]). Idempotent — calling twice is
682    /// harmless (later calls add duplicates which the finalise
683    /// deduper drops, downstream-first).
684    pub fn merge_framework_defaults(self) -> Self {
685        let defaults = library_bundled_skills();
686        self.add_bundled_many(defaults)
687    }
688
689    /// Layer in skill directories declared in the manifest's
690    /// `skills:` field, walked in declaration order. Each path
691    /// becomes a domain-pack-layer source; the bundled marker
692    /// `true` is acknowledged but its skills are already in the
693    /// bundled layer via `add_bundled`/`merge_framework_defaults`.
694    ///
695    /// Path resolution uses the same conventions as the rest of the
696    /// manifest (`./foo` relative to YAML dir, `~/foo` home-relative,
697    /// `/foo` absolute). Non-existent paths are reported as
698    /// [`SkillError::PathNotFound`] at this call site so operators
699    /// see typos immediately.
700    pub fn layer_dirs(
701        mut self,
702        source: &SkillsSource,
703        yaml_path: &Path,
704    ) -> Result<Self, SkillError> {
705        let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
706
707        match source {
708            SkillsSource::Disabled => {
709                // Skills disabled entirely — return the registry
710                // unchanged. Downstream may still have called
711                // add_bundled, but those won't be reachable without
712                // a layer telling us skills are enabled.
713                self.root_includes_bundled = false;
714            }
715            SkillsSource::Sources(sources) => {
716                for src in sources {
717                    match src {
718                        SkillSource::Bundled => {
719                            self.root_includes_bundled = true;
720                        }
721                        SkillSource::Path(raw) => {
722                            let resolved = resolve_skill_path(raw, manifest_dir);
723                            if !resolved.is_dir() {
724                                return Err(SkillError::PathNotFound {
725                                    raw: raw.clone(),
726                                    resolved,
727                                });
728                            }
729                            self.root_dirs.push((resolved, raw.clone()));
730                        }
731                    }
732                }
733            }
734        }
735
736        Ok(self)
737    }
738
739    /// Auto-detect the project layer at `<basename>.skills/`
740    /// adjacent to the manifest YAML. Always called; the directory
741    /// is optional — if it doesn't exist, the project layer is
742    /// simply empty.
743    pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
744        let candidate = project_skills_dir(yaml_path);
745        if candidate.is_dir() {
746            self.project_dir = Some(candidate);
747        }
748        self
749    }
750
751    /// Resolve all three layers and return the final registry.
752    ///
753    /// Resolution order per skill name: project > root layer
754    /// (in declaration order) > bundled. The first source that
755    /// contributes a skill with the given name wins; later sources
756    /// are ignored for that name (no merging, no inheritance —
757    /// full-file replacement).
758    ///
759    /// At this point the framework:
760    /// - Parses all skill files (frontmatter validation)
761    /// - Logs collision-resolution info via `tracing::info!` per skill
762    /// - Enforces per-skill hard size limits ([`HARD_SIZE_LIMIT_BYTES`])
763    /// - Warns on per-skill soft size limit ([`SOFT_SIZE_LIMIT_BYTES`])
764    /// - Warns on session total exceeding [`SESSION_TOTAL_LIMIT_BYTES`]
765    pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
766        let Self {
767            bundled,
768            root_dirs,
769            root_includes_bundled,
770            project_dir,
771        } = self;
772
773        // Parse bundled skills first. These are the lowest-priority
774        // layer; they get overridden by anything declared above.
775        let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
776        if root_includes_bundled {
777            for b in &bundled {
778                let path = PathBuf::from(format!("<bundled:{}>", b.name));
779                let (frontmatter, body) =
780                    parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
781                        name: b.name,
782                        message: e.to_string(),
783                    })?;
784                if frontmatter.name != b.name {
785                    return Err(SkillError::BundledSkillInvalid {
786                        name: b.name,
787                        message: format!(
788                            "frontmatter name {:?} does not match the bundled key {:?}",
789                            frontmatter.name, b.name
790                        ),
791                    });
792                }
793                bundled_skills.push(Skill {
794                    frontmatter,
795                    body,
796                    provenance: SkillProvenance::Bundled,
797                });
798            }
799        }
800
801        // Root layer: walk each declared path; first wins per name.
802        let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
803        for (resolved, _raw) in &root_dirs {
804            let provenance = SkillProvenance::DomainPack(resolved.clone());
805            let skills = load_skills_from_dir(resolved, provenance)?;
806            root_skills_per_dir.push(skills);
807        }
808
809        // Project layer: auto-detected adjacent dir.
810        let project_skills: Vec<Skill> = match &project_dir {
811            Some(dir) => load_skills_from_dir(dir, SkillProvenance::Project)?,
812            None => Vec::new(),
813        };
814
815        // Resolve per skill name. Priority:
816        //   1. Project layer
817        //   2. Root layer entries in declaration order
818        //   3. Bundled (downstream entries first, then framework)
819        //
820        // The bundled list is already in downstream-first order
821        // because downstream binaries call `add_bundled` before
822        // `merge_framework_defaults` by convention.
823
824        let mut resolved: HashMap<String, Skill> = HashMap::new();
825        let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
826
827        // Lowest priority first: bundled, then root in reverse
828        // declaration order, then project. Later inserts overwrite.
829        // We track collisions for the boot log.
830        for skill in &bundled_skills {
831            let name = skill.name().to_string();
832            collisions
833                .entry(name.clone())
834                .or_default()
835                .push(skill.provenance.clone());
836            resolved.insert(name, skill.clone());
837        }
838        for skills in root_skills_per_dir.iter().rev() {
839            for skill in skills {
840                let name = skill.name().to_string();
841                collisions
842                    .entry(name.clone())
843                    .or_default()
844                    .push(skill.provenance.clone());
845                resolved.insert(name, skill.clone());
846            }
847        }
848        for skill in &project_skills {
849            let name = skill.name().to_string();
850            collisions
851                .entry(name.clone())
852                .or_default()
853                .push(skill.provenance.clone());
854            resolved.insert(name, skill.clone());
855        }
856
857        // Log collision resolution for skills with more than one
858        // candidate. Single-candidate skills don't need a log line.
859        for (name, candidates) in &collisions {
860            if candidates.len() > 1 {
861                let winner = resolved
862                    .get(name)
863                    .map(|s| format_provenance(&s.provenance))
864                    .unwrap_or_else(|| "<none>".to_string());
865                let all_candidates: Vec<String> =
866                    candidates.iter().map(format_provenance).collect();
867                tracing::info!(
868                    skill = %name,
869                    candidates = ?all_candidates,
870                    winner = %winner,
871                    "skill resolved across multiple layers"
872                );
873            }
874        }
875
876        // Check session-total size limit.
877        let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
878        if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
879            tracing::warn!(
880                total_bytes,
881                limit = SESSION_TOTAL_LIMIT_BYTES,
882                skill_count = resolved.len(),
883                "total resolved skill body size exceeds session limit; \
884                 consider trimming or splitting skills"
885            );
886        }
887
888        Ok(ResolvedRegistry { skills: resolved })
889    }
890}
891
892fn format_provenance(p: &SkillProvenance) -> String {
893    match p {
894        SkillProvenance::Project => "project".to_string(),
895        SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
896        SkillProvenance::Bundled => "bundled".to_string(),
897    }
898}
899
900// ─── ResolvedRegistry ─────────────────────────────────────────────
901
902/// The post-resolution skill set. Consumed by `serve_prompts`
903/// (Phase 1c) to wire `prompts/list` and `prompts/get` on the
904/// MCP server.
905#[derive(Debug, Default)]
906pub struct ResolvedRegistry {
907    skills: HashMap<String, Skill>,
908}
909
910impl ResolvedRegistry {
911    /// All resolved skill names, sorted alphabetically for stable
912    /// output in `prompts/list`.
913    pub fn skill_names(&self) -> Vec<String> {
914        let mut names: Vec<String> = self.skills.keys().cloned().collect();
915        names.sort();
916        names
917    }
918
919    /// Look up a skill by name. Used by `prompts/get` to fetch the
920    /// full body when the agent requests it.
921    pub fn get(&self, name: &str) -> Option<&Skill> {
922        self.skills.get(name)
923    }
924
925    /// Iterate all resolved skills. Order is unspecified — use
926    /// `skill_names()` first if a deterministic iteration is needed.
927    pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
928        self.skills.iter()
929    }
930
931    /// Number of resolved skills.
932    pub fn len(&self) -> usize {
933        self.skills.len()
934    }
935
936    /// Whether the registry contains any skills.
937    pub fn is_empty(&self) -> bool {
938        self.skills.is_empty()
939    }
940}
941
942// ─── Tests ────────────────────────────────────────────────────────
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947    use std::io::Write;
948
949    fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
950        let path = dir.join(format!("{name}.md"));
951        let mut f = fs::File::create(&path).unwrap();
952        f.write_all(content.as_bytes()).unwrap();
953        path
954    }
955
956    fn minimal_skill(name: &str) -> String {
957        format!(
958            "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
959        )
960    }
961
962    // ─── Frontmatter parsing ──────────────────────────────────────
963
964    #[test]
965    fn parse_frontmatter_basic() {
966        let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
967        let path = PathBuf::from("test.md");
968        let (fm, body) = parse_skill(content, &path).unwrap();
969        assert_eq!(fm.name, "foo");
970        assert_eq!(fm.description, "A foo skill.");
971        assert_eq!(body, "\nBody here.\n");
972        assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
973    }
974
975    #[test]
976    fn parse_frontmatter_missing_delimiters_rejected() {
977        let content = "name: foo\ndescription: bar\n";
978        let path = PathBuf::from("test.md");
979        let err = parse_skill(content, &path).unwrap_err();
980        assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
981    }
982
983    #[test]
984    fn parse_frontmatter_invalid_yaml_rejected() {
985        let content = "---\nname: foo\n  bad: yaml: nesting\n---\nbody\n";
986        let path = PathBuf::from("test.md");
987        let err = parse_skill(content, &path).unwrap_err();
988        assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
989    }
990
991    #[test]
992    fn parse_frontmatter_missing_name_rejected() {
993        let content = "---\ndescription: bar\n---\nbody\n";
994        let path = PathBuf::from("test.md");
995        let err = parse_skill(content, &path).unwrap_err();
996        assert!(matches!(
997            err,
998            SkillError::MissingRequiredField { field: "name", .. }
999        ));
1000    }
1001
1002    #[test]
1003    fn parse_frontmatter_missing_description_rejected() {
1004        let content = "---\nname: foo\n---\nbody\n";
1005        let path = PathBuf::from("test.md");
1006        let err = parse_skill(content, &path).unwrap_err();
1007        assert!(matches!(
1008            err,
1009            SkillError::MissingRequiredField {
1010                field: "description",
1011                ..
1012            }
1013        ));
1014    }
1015
1016    #[test]
1017    fn parse_frontmatter_all_optional_fields() {
1018        let content = "---\n\
1019name: foo\n\
1020description: Full surface.\n\
1021references_tools: [grep, list_source]\n\
1022references_arguments: [grep.pattern]\n\
1023references_properties: [Function.module]\n\
1024auto_inject_hint: false\n\
1025applies_to:\n  mcp_methods: \">=0.3.35\"\n\
1026---\n\
1027Body.\n";
1028        let path = PathBuf::from("test.md");
1029        let (fm, _) = parse_skill(content, &path).unwrap();
1030        assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1031        assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1032        assert_eq!(fm.references_properties, vec!["Function.module"]);
1033        assert!(!fm.auto_inject_hint);
1034        assert_eq!(
1035            fm.applies_to.unwrap().get("mcp_methods"),
1036            Some(&">=0.3.35".to_string())
1037        );
1038    }
1039
1040    // ─── Loading from files + dirs ────────────────────────────────
1041
1042    #[test]
1043    fn load_skill_from_file_basic() {
1044        let dir = tempfile::tempdir().unwrap();
1045        let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1046        let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1047        assert_eq!(skill.name(), "foo");
1048        assert_eq!(skill.provenance, SkillProvenance::Project);
1049    }
1050
1051    #[test]
1052    fn load_skill_too_large_rejected() {
1053        let dir = tempfile::tempdir().unwrap();
1054        // Build a body just over the hard limit.
1055        let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1056        let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1057        let path = write_skill(dir.path(), "big", &content);
1058        let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1059        assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1060    }
1061
1062    #[test]
1063    fn load_skills_from_dir_walks_markdown_only() {
1064        let dir = tempfile::tempdir().unwrap();
1065        write_skill(dir.path(), "a", &minimal_skill("a"));
1066        write_skill(dir.path(), "b", &minimal_skill("b"));
1067        // Non-markdown file — ignored.
1068        fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1069        // Subdirectory — ignored.
1070        let sub = dir.path().join("sub");
1071        fs::create_dir(&sub).unwrap();
1072        write_skill(&sub, "c", &minimal_skill("c"));
1073
1074        let skills = load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1075        assert_eq!(skills.len(), 2);
1076        let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1077        names.sort();
1078        assert_eq!(names, vec!["a", "b"]);
1079    }
1080
1081    #[test]
1082    fn load_skills_from_dir_missing_returns_empty() {
1083        let dir = tempfile::tempdir().unwrap();
1084        let nonexistent = dir.path().join("does-not-exist");
1085        let skills = load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1086        assert!(skills.is_empty());
1087    }
1088
1089    // ─── Path resolution ──────────────────────────────────────────
1090
1091    #[test]
1092    fn resolve_skill_path_relative() {
1093        let manifest_dir = Path::new("/a/b");
1094        assert_eq!(
1095            resolve_skill_path("./skills", manifest_dir),
1096            PathBuf::from("/a/b/./skills")
1097        );
1098        assert_eq!(
1099            resolve_skill_path("skills", manifest_dir),
1100            PathBuf::from("/a/b/skills")
1101        );
1102    }
1103
1104    #[test]
1105    fn resolve_skill_path_absolute() {
1106        let manifest_dir = Path::new("/a/b");
1107        assert_eq!(
1108            resolve_skill_path("/abs/skills", manifest_dir),
1109            PathBuf::from("/abs/skills")
1110        );
1111    }
1112
1113    #[test]
1114    fn resolve_skill_path_home_relative() {
1115        let manifest_dir = Path::new("/a/b");
1116        // Set HOME explicitly for the test.
1117        // SAFETY: tests run single-threaded for env mutation; this is
1118        // a known stylistic exception in Rust's 1.83+ unsafe-env API.
1119        unsafe {
1120            std::env::set_var("HOME", "/home/test");
1121        }
1122        assert_eq!(
1123            resolve_skill_path("~/skills", manifest_dir),
1124            PathBuf::from("/home/test/skills")
1125        );
1126    }
1127
1128    #[test]
1129    fn project_skills_dir_naming() {
1130        assert_eq!(
1131            project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1132            PathBuf::from("/a/b/legal_mcp.skills")
1133        );
1134        assert_eq!(
1135            project_skills_dir(Path::new("workspace_mcp.yaml")),
1136            PathBuf::from("workspace_mcp.skills")
1137        );
1138    }
1139
1140    // ─── Registry builder ─────────────────────────────────────────
1141
1142    #[test]
1143    fn registry_disabled_resolves_empty() {
1144        let dir = tempfile::tempdir().unwrap();
1145        let yaml = dir.path().join("test_mcp.yaml");
1146        fs::write(&yaml, "name: x\n").unwrap();
1147
1148        let registry = Registry::new()
1149            .layer_dirs(&SkillsSource::Disabled, &yaml)
1150            .unwrap()
1151            .auto_detect_project_layer(&yaml)
1152            .finalise()
1153            .unwrap();
1154        assert!(registry.is_empty());
1155    }
1156
1157    #[test]
1158    fn registry_add_bundled_only_visible_when_opted_in() {
1159        let dir = tempfile::tempdir().unwrap();
1160        let yaml = dir.path().join("test_mcp.yaml");
1161        fs::write(&yaml, "name: x\n").unwrap();
1162
1163        let bundled = BundledSkill {
1164            name: "foo",
1165            // Static body for testing — needs to be 'static, which is
1166            // why BundledSkill uses &'static str. For the test we
1167            // leak. Production code uses include_str!.
1168            body: Box::leak(minimal_skill("foo").into_boxed_str()),
1169        };
1170
1171        // Disabled → bundled is NOT visible, even if added.
1172        let registry = Registry::new()
1173            .add_bundled(bundled.clone())
1174            .layer_dirs(&SkillsSource::Disabled, &yaml)
1175            .unwrap()
1176            .finalise()
1177            .unwrap();
1178        assert!(registry.is_empty(), "disabled must short-circuit bundled");
1179
1180        // skills: [true] → bundled IS visible.
1181        let registry = Registry::new()
1182            .add_bundled(bundled)
1183            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1184            .unwrap()
1185            .finalise()
1186            .unwrap();
1187        assert_eq!(registry.len(), 1);
1188        assert!(registry.get("foo").is_some());
1189        assert_eq!(
1190            registry.get("foo").unwrap().provenance,
1191            SkillProvenance::Bundled
1192        );
1193    }
1194
1195    #[test]
1196    fn registry_three_layer_resolution_project_wins_over_bundled() {
1197        let dir = tempfile::tempdir().unwrap();
1198        let yaml = dir.path().join("test_mcp.yaml");
1199        fs::write(&yaml, "name: x\n").unwrap();
1200
1201        // Bundled `foo`:
1202        let bundled = BundledSkill {
1203            name: "foo",
1204            body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1205        };
1206
1207        // Project layer `foo`:
1208        let project_dir = dir.path().join("test_mcp.skills");
1209        fs::create_dir(&project_dir).unwrap();
1210        fs::write(
1211            project_dir.join("foo.md"),
1212            "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1213        )
1214        .unwrap();
1215
1216        let registry = Registry::new()
1217            .add_bundled(bundled)
1218            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1219            .unwrap()
1220            .auto_detect_project_layer(&yaml)
1221            .finalise()
1222            .unwrap();
1223
1224        assert_eq!(registry.len(), 1);
1225        let skill = registry.get("foo").unwrap();
1226        assert_eq!(skill.description(), "from project.");
1227        assert_eq!(skill.provenance, SkillProvenance::Project);
1228    }
1229
1230    #[test]
1231    fn registry_root_layer_first_declaration_wins() {
1232        let dir = tempfile::tempdir().unwrap();
1233        let yaml = dir.path().join("test_mcp.yaml");
1234        fs::write(&yaml, "name: x\n").unwrap();
1235
1236        // First domain pack: foo (from "primary").
1237        let primary = dir.path().join("primary");
1238        fs::create_dir(&primary).unwrap();
1239        fs::write(
1240            primary.join("foo.md"),
1241            "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1242        )
1243        .unwrap();
1244
1245        // Second domain pack: foo (from "secondary") — should LOSE.
1246        let secondary = dir.path().join("secondary");
1247        fs::create_dir(&secondary).unwrap();
1248        fs::write(
1249            secondary.join("foo.md"),
1250            "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1251        )
1252        .unwrap();
1253
1254        let registry = Registry::new()
1255            .layer_dirs(
1256                &SkillsSource::Sources(vec![
1257                    SkillSource::Path("./primary".into()),
1258                    SkillSource::Path("./secondary".into()),
1259                ]),
1260                &yaml,
1261            )
1262            .unwrap()
1263            .finalise()
1264            .unwrap();
1265
1266        assert_eq!(registry.len(), 1);
1267        assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1268    }
1269
1270    #[test]
1271    fn registry_root_layer_nonexistent_path_rejected() {
1272        let dir = tempfile::tempdir().unwrap();
1273        let yaml = dir.path().join("test_mcp.yaml");
1274        fs::write(&yaml, "name: x\n").unwrap();
1275
1276        let err = Registry::new()
1277            .layer_dirs(
1278                &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1279                &yaml,
1280            )
1281            .unwrap_err();
1282        assert!(matches!(err, SkillError::PathNotFound { .. }));
1283    }
1284
1285    #[test]
1286    fn registry_empty_list_opts_in_without_root_sources() {
1287        let dir = tempfile::tempdir().unwrap();
1288        let yaml = dir.path().join("test_mcp.yaml");
1289        fs::write(&yaml, "name: x\n").unwrap();
1290
1291        // No bundled, no paths — but project layer DOES exist.
1292        let project_dir = dir.path().join("test_mcp.skills");
1293        fs::create_dir(&project_dir).unwrap();
1294        fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1295
1296        let registry = Registry::new()
1297            .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1298            .unwrap()
1299            .auto_detect_project_layer(&yaml)
1300            .finalise()
1301            .unwrap();
1302
1303        assert_eq!(registry.len(), 1);
1304        assert_eq!(
1305            registry.get("only").unwrap().provenance,
1306            SkillProvenance::Project
1307        );
1308    }
1309
1310    #[test]
1311    fn registry_bundled_name_mismatch_rejected_at_finalise() {
1312        let dir = tempfile::tempdir().unwrap();
1313        let yaml = dir.path().join("test_mcp.yaml");
1314        fs::write(&yaml, "name: x\n").unwrap();
1315
1316        // BundledSkill says name="foo" but the frontmatter says name="bar".
1317        let bundled = BundledSkill {
1318            name: "foo",
1319            body: Box::leak(
1320                "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1321                    .to_string()
1322                    .into_boxed_str(),
1323            ),
1324        };
1325
1326        let err = Registry::new()
1327            .add_bundled(bundled)
1328            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1329            .unwrap()
1330            .finalise()
1331            .unwrap_err();
1332        assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1333    }
1334
1335    #[test]
1336    fn registry_library_bundled_skills_returns_vec() {
1337        // Five framework defaults ship from Phase 1d onward. The
1338        // exhaustive shape + uniqueness checks live in
1339        // `bundled_skills_index::tests`; here we just confirm the
1340        // re-export points downstream callers at the populated Vec.
1341        let skills = library_bundled_skills();
1342        assert!(
1343            !skills.is_empty(),
1344            "library_bundled_skills should return framework defaults from Phase 1d onward"
1345        );
1346    }
1347
1348    #[test]
1349    fn registry_skill_names_sorted() {
1350        let dir = tempfile::tempdir().unwrap();
1351        let yaml = dir.path().join("test_mcp.yaml");
1352        fs::write(&yaml, "name: x\n").unwrap();
1353
1354        let pack = dir.path().join("pack");
1355        fs::create_dir(&pack).unwrap();
1356        fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1357        fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1358        fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1359
1360        let registry = Registry::new()
1361            .layer_dirs(
1362                &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1363                &yaml,
1364            )
1365            .unwrap()
1366            .finalise()
1367            .unwrap();
1368
1369        assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1370    }
1371
1372    // ─── Authoring template ───────────────────────────────────────
1373
1374    #[test]
1375    fn render_skill_template_is_parse_valid() {
1376        // Round-trip: a freshly-rendered template must parse cleanly
1377        // through `parse_skill` so the operator's starting point is
1378        // never broken.
1379        let body = render_skill_template("custom_method", "A test description for the skill.");
1380        let (fm, _body) =
1381            parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1382        assert_eq!(fm.name, "custom_method");
1383        assert_eq!(fm.description, "A test description for the skill.");
1384    }
1385
1386    #[test]
1387    fn render_skill_template_substitutes_name_into_body_headings() {
1388        let body = render_skill_template("my_skill", "desc");
1389        assert!(body.contains("# `my_skill` methodology"));
1390        assert!(body.contains("## When `my_skill` is the wrong tool"));
1391    }
1392
1393    #[test]
1394    fn write_skill_template_writes_into_directory() {
1395        let dir = tempfile::tempdir().unwrap();
1396        let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1397        assert_eq!(dest, dir.path().join("alpha.md"));
1398        let content = fs::read_to_string(&dest).unwrap();
1399        assert!(content.contains("name: alpha"));
1400    }
1401
1402    #[test]
1403    fn write_skill_template_writes_to_explicit_md_path() {
1404        let dir = tempfile::tempdir().unwrap();
1405        let explicit = dir.path().join("renamed.md");
1406        let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1407        assert_eq!(dest, explicit);
1408        assert!(explicit.is_file());
1409    }
1410
1411    #[test]
1412    fn write_skill_template_creates_missing_parents() {
1413        let dir = tempfile::tempdir().unwrap();
1414        let nested = dir.path().join("a/b/c");
1415        let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1416        assert_eq!(dest, nested.join("alpha.md"));
1417        assert!(dest.is_file());
1418    }
1419
1420    #[test]
1421    fn write_skill_template_refuses_to_overwrite() {
1422        let dir = tempfile::tempdir().unwrap();
1423        let path = dir.path().join("alpha.md");
1424        fs::write(&path, "existing").unwrap();
1425        let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1426        assert!(matches!(err, SkillError::Io { .. }));
1427        // Original content preserved.
1428        assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1429    }
1430
1431    #[test]
1432    fn write_skill_template_round_trips_through_registry() {
1433        // End-to-end: write a template, build a registry that
1434        // auto-detects it as a project skill, confirm it resolves.
1435        let dir = tempfile::tempdir().unwrap();
1436        let yaml = dir.path().join("test_mcp.yaml");
1437        fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1438        let skills_dir = dir.path().join("test_mcp.skills");
1439        write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1440
1441        let registry = Registry::new()
1442            .auto_detect_project_layer(&yaml)
1443            .finalise()
1444            .unwrap();
1445        let skill = registry
1446            .get("custom_method")
1447            .expect("template should resolve");
1448        assert_eq!(skill.description(), "Project-layer skill body.");
1449    }
1450}