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};
65use std::sync::Arc;
66
67use serde::Deserialize;
68
69use super::manifest::{load as load_manifest, SkillSource, SkillsSource};
70
71// ─── Public types ─────────────────────────────────────────────────
72
73/// A compile-time bundled skill, embedded into the binary via
74/// `include_str!`. Downstream binaries (e.g. `kglite-mcp-server`)
75/// construct these for their custom tools; the framework constructs
76/// them for its own (`grep`, `read_source`, etc.).
77///
78/// Bundled skills sit at the bottom of the three-layer composition —
79/// project and root-layer entries override them when names collide.
80#[derive(Debug, Clone)]
81pub struct BundledSkill {
82    /// Skill name. Must match the `name` field in the markdown
83    /// frontmatter. Used as the lookup key in `prompts/get`.
84    pub name: &'static str,
85    /// The full SKILL.md content — frontmatter + body. Parsed at
86    /// `Registry::add_bundled` time; malformed bundled skills are
87    /// errors (caught by the framework's CI tests), not warnings.
88    pub body: &'static str,
89}
90
91/// Parsed YAML frontmatter of a SKILL.md file.
92///
93/// Phase 1b stores all declared fields as raw values. Phase 1f / 2a
94/// will add validation (`applies_to` semver checks, `references_tools`
95/// against the active tool catalogue, `references_arguments` against
96/// each tool's input schema). For now: parse and preserve; the lint
97/// step in `Registry::finalise()` walks these and surfaces issues as
98/// log warnings.
99#[derive(Debug, Clone, Default, Deserialize)]
100pub struct SkillFrontmatter {
101    /// Skill name. Must match the lookup key used in `prompts/get`.
102    /// Required; empty after deserialization triggers a clear
103    /// [`SkillError::MissingRequiredField`] rather than a generic
104    /// YAML parse failure.
105    #[serde(default)]
106    pub name: String,
107    /// One-line description shown in `prompts/list`. Required —
108    /// the agent uses this to decide whether to load the full body.
109    #[serde(default)]
110    pub description: String,
111
112    /// Version constraints. Parsed lazily — Phase 1b stores raw
113    /// values, Phase 1f adds semver validation.
114    #[serde(default)]
115    pub applies_to: Option<HashMap<String, String>>,
116
117    /// Tools this skill teaches or references in prose. When
118    /// `auto_inject_hint` is set, the skill's routing + methodology is
119    /// injected into the description of every tool listed here, in
120    /// addition to its name-match tool — the only way to express a
121    /// cross-tool skill. Also used for staleness detection (Phase 1f).
122    #[serde(default)]
123    pub references_tools: Vec<String>,
124
125    /// Specific tool argument names referenced in the skill body
126    /// (e.g. `"cypher_query.format"`). Lint warns when references
127    /// don't match the tool's actual input schema.
128    #[serde(default)]
129    pub references_arguments: Vec<String>,
130
131    /// Graph properties / domain-specific references the skill calls
132    /// out (e.g. `"Function.module"`). For domain skill-packs to
133    /// declare their domain assumptions. The framework can't validate
134    /// these statically; they're documentation-grade metadata.
135    #[serde(default)]
136    pub references_properties: Vec<String>,
137
138    /// When `true` (the default), the framework injects the skill's
139    /// `description` (under `## When to use`) and `body` (under
140    /// `## Methodology`) into the live tool-description channel — for
141    /// the tool whose name matches the skill plus every tool in
142    /// `references_tools`. Set `false` to keep the skill on the
143    /// `prompts/*` plane only (no tool-description injection).
144    #[serde(default = "default_auto_inject_hint")]
145    pub auto_inject_hint: bool,
146
147    /// `applies_when:` predicate set. Bounded — not a DSL. All
148    /// populated fields must evaluate true (AND semantics) for the
149    /// skill to surface in `prompts/list` and `prompts/get`. The
150    /// framework dispatches `tool_registered` and `extension_enabled`
151    /// itself; domain predicates (`graph_has_node_type`,
152    /// `graph_has_property`) are evaluated via the optional
153    /// [`SkillPredicateEvaluator`] registered on the
154    /// [`Registry`].
155    ///
156    /// `None` (the default) means "always active" — the skill applies
157    /// regardless of runtime state.
158    #[serde(default)]
159    pub applies_when: Option<AppliesWhen>,
160}
161
162fn default_auto_inject_hint() -> bool {
163    true
164}
165
166/// The parsed shape of a SKILL.md's `applies_when:` block. Each field
167/// is one predicate; `None` means "this predicate is not applied".
168/// All populated fields are ANDed.
169///
170/// Adding a new predicate requires extending this struct and the
171/// matching arm in [`Registry::evaluate_clause`]. The bounded-set
172/// design is intentional — operators get type-checked semantics
173/// instead of an open-ended DSL.
174#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
175pub struct AppliesWhen {
176    /// Active when the running graph has *any* of the listed node
177    /// types in its schema. Domain predicate — evaluated via the
178    /// consumer's [`SkillPredicateEvaluator`].
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub graph_has_node_type: Option<Vec<String>>,
181
182    /// Active when the running graph has the named property on the
183    /// named node type. Domain predicate — evaluated via the
184    /// consumer's [`SkillPredicateEvaluator`].
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub graph_has_property: Option<GraphPropertyCheck>,
187
188    /// Active when the named tool is in the registered catalogue
189    /// at boot. Framework-internal — dispatched against
190    /// `server.tool_router` without consulting any evaluator.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub tool_registered: Option<String>,
193
194    /// Active when the manifest's `extensions:` block has the named
195    /// key set to a truthy value (not absent, not null, not `false`).
196    /// Framework-internal — dispatched against `manifest.extensions`.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub extension_enabled: Option<String>,
199}
200
201/// Nested shape for the `graph_has_property:` predicate.
202#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
203pub struct GraphPropertyCheck {
204    pub node_type: String,
205    pub prop_name: String,
206}
207
208/// A single predicate clause, passed to a
209/// [`SkillPredicateEvaluator`] one at a time. Borrowed slices so
210/// the evaluator doesn't have to allocate.
211#[derive(Debug)]
212pub enum PredicateClause<'a> {
213    /// `graph_has_node_type: [Function, Class]`
214    GraphHasNodeType(&'a [String]),
215    /// `graph_has_property: { node_type: Function, prop_name: module }`
216    GraphHasProperty {
217        node_type: &'a str,
218        prop_name: &'a str,
219    },
220    /// `tool_registered: cypher_query`
221    ToolRegistered(&'a str),
222    /// `extension_enabled: csv_http_server`
223    ExtensionEnabled(&'a str),
224}
225
226/// Per-clause result of evaluating an `applies_when:` block. Surfaced
227/// via [`SkillActivation`] so the operator-facing `skills-list` and
228/// boot log can show *which* predicate suppressed a skill.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum PredicateOutcome {
231    /// Predicate evaluated to true.
232    Satisfied,
233    /// Predicate evaluated to false. The skill is inactive.
234    Unsatisfied,
235    /// No evaluator recognized the predicate. Treated as
236    /// `Unsatisfied` for safety — a typo'd predicate must not
237    /// silently activate the skill against the wrong domain.
238    Unknown,
239}
240
241/// Activation state for a single skill, post-predicate-evaluation.
242/// Skills without an `applies_when:` block resolve to `Active` with
243/// an empty `clauses` vec.
244#[derive(Debug, Clone, Default)]
245pub struct SkillActivation {
246    /// Whether the skill should appear in `prompts/list` /
247    /// `prompts/get`.
248    pub active: bool,
249    /// Per-clause evaluation outcomes, in declaration order. Empty
250    /// for skills without an `applies_when:` block.
251    pub clauses: Vec<(String, PredicateOutcome)>,
252}
253
254/// Trait downstream binaries implement to evaluate domain-specific
255/// predicates. Framework-internal predicates (`tool_registered`,
256/// `extension_enabled`) are dispatched without consulting this trait;
257/// you only handle the domain ones (`graph_has_node_type`,
258/// `graph_has_property`).
259///
260/// Return `Some(true)` / `Some(false)` when you have an answer;
261/// return `None` when the predicate doesn't apply to your domain
262/// (the framework will mark it `Unknown` and the skill will be
263/// inactive — safer than silently activating the wrong skill).
264///
265/// # Example
266///
267/// ```ignore
268/// struct KgliteEvaluator {
269///     graph: Arc<Graph>,
270/// }
271///
272/// impl SkillPredicateEvaluator for KgliteEvaluator {
273///     fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
274///         match clause {
275///             PredicateClause::GraphHasNodeType(types) => {
276///                 Some(types.iter().any(|t| self.graph.has_node_type(t)))
277///             }
278///             PredicateClause::GraphHasProperty { node_type, prop_name } => {
279///                 Some(self.graph.has_property(node_type, prop_name))
280///             }
281///             _ => None,   // framework dispatches the rest
282///         }
283///     }
284/// }
285/// ```
286pub trait SkillPredicateEvaluator: Send + Sync {
287    fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool>;
288}
289
290/// Where a [`Skill`] came from. Used for the boot-time collision-
291/// resolution log and surfaced via the JSON shape kglite consumes
292/// from `to_json()` (in Phase 1d).
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum SkillProvenance {
295    /// Auto-detected from `<basename>.skills/` adjacent to the
296    /// manifest YAML — top-priority operator overrides.
297    Project,
298    /// Loaded from an operator-declared path in the manifest's
299    /// `skills:` list (a domain skill-pack or shared library).
300    DomainPack(PathBuf),
301    /// Compile-time bundled — shipped with `mcp-methods` (framework
302    /// defaults) or with a downstream binary like `kglite-mcp-server`.
303    Bundled,
304}
305
306/// A loaded skill, post-parse + post-resolution. The body is the
307/// markdown content after the closing `---` frontmatter delimiter.
308#[derive(Debug, Clone)]
309pub struct Skill {
310    pub frontmatter: SkillFrontmatter,
311    pub body: String,
312    pub provenance: SkillProvenance,
313}
314
315impl Skill {
316    /// Convenience accessor for the skill's name (read from
317    /// frontmatter at parse time).
318    pub fn name(&self) -> &str {
319        &self.frontmatter.name
320    }
321
322    /// One-line description for `prompts/list` responses.
323    pub fn description(&self) -> &str {
324        &self.frontmatter.description
325    }
326}
327
328// ─── Errors ───────────────────────────────────────────────────────
329
330/// Errors surfaced during skill loading + resolution. Variants are
331/// kept distinct so downstream binaries (and the future skills-lint
332/// CLI) can report locations and surface fixes precisely.
333#[derive(Debug)]
334pub enum SkillError {
335    /// Filesystem error reading the skill file.
336    Io {
337        path: PathBuf,
338        source: std::io::Error,
339    },
340    /// Missing or malformed frontmatter delimiters.
341    MissingFrontmatter { path: PathBuf },
342    /// Frontmatter present but invalid YAML.
343    InvalidFrontmatter { path: PathBuf, message: String },
344    /// Required frontmatter field missing (name or description).
345    MissingRequiredField { path: PathBuf, field: &'static str },
346    /// Skill body exceeds the hard size limit (16 KB by default).
347    SkillTooLarge {
348        path: PathBuf,
349        bytes: usize,
350        limit: usize,
351    },
352    /// Path declared in the manifest's `skills:` list doesn't exist
353    /// or isn't a directory.
354    PathNotFound { raw: String, resolved: PathBuf },
355    /// Compile-time bundled skill (added via `add_bundled`) failed to
356    /// parse. This is a framework-author or downstream-binary-author
357    /// bug — the bundled skill files should round-trip through their
358    /// own CI tests before shipping.
359    BundledSkillInvalid { name: &'static str, message: String },
360    /// Manifest YAML at `path` failed to load while resolving skills
361    /// from a manifest (e.g. via [`Registry::from_manifest`]).
362    Manifest { path: PathBuf, message: String },
363}
364
365impl std::fmt::Display for SkillError {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        match self {
368            SkillError::Io { path, source } => {
369                write!(f, "skill I/O error at {}: {source}", path.display())
370            }
371            SkillError::MissingFrontmatter { path } => write!(
372                f,
373                "skill at {} is missing the `---` YAML frontmatter delimiter at the start of the file",
374                path.display()
375            ),
376            SkillError::InvalidFrontmatter { path, message } => {
377                write!(
378                    f,
379                    "skill frontmatter at {} is not valid YAML: {message}",
380                    path.display()
381                )
382            }
383            SkillError::MissingRequiredField { path, field } => write!(
384                f,
385                "skill at {} is missing required frontmatter field `{field}`",
386                path.display()
387            ),
388            SkillError::SkillTooLarge {
389                path,
390                bytes,
391                limit,
392            } => write!(
393                f,
394                "skill at {} is {bytes} bytes; exceeds the {limit} byte hard limit",
395                path.display()
396            ),
397            SkillError::PathNotFound { raw, resolved } => write!(
398                f,
399                "skill path {raw:?} (resolved to {}) does not exist or is not a directory",
400                resolved.display()
401            ),
402            SkillError::BundledSkillInvalid { name, message } => write!(
403                f,
404                "bundled skill `{name}` is malformed: {message}"
405            ),
406            SkillError::Manifest { path, message } => write!(
407                f,
408                "manifest load failed at {}: {message}",
409                path.display()
410            ),
411        }
412    }
413}
414
415impl std::error::Error for SkillError {
416    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
417        match self {
418            SkillError::Io { source, .. } => Some(source),
419            _ => None,
420        }
421    }
422}
423
424// ─── Size limits ──────────────────────────────────────────────────
425
426/// Per-skill soft limit. Loading a skill larger than this logs a
427/// warning via `tracing::warn!` but does not fail.
428pub const SOFT_SIZE_LIMIT_BYTES: usize = 4 * 1024;
429/// Per-skill hard limit. Loading a skill larger than this returns
430/// [`SkillError::SkillTooLarge`]. Forces authors to keep skills
431/// tight and prevents accidental dump-the-whole-onboarding-doc.
432pub const HARD_SIZE_LIMIT_BYTES: usize = 16 * 1024;
433/// Total session limit across all resolved skills. Exceeding this
434/// logs a warning at `Registry::finalise` time but does not drop
435/// skills automatically — operators stay in control of which skills
436/// they want loaded.
437pub const SESSION_TOTAL_LIMIT_BYTES: usize = 64 * 1024;
438
439// ─── Frontmatter parser ───────────────────────────────────────────
440
441/// Split a SKILL.md file into its YAML frontmatter and markdown body.
442///
443/// Returns the frontmatter content (without the `---` delimiters) and
444/// the body (everything after the closing `---`).
445///
446/// The frontmatter MUST start at byte 0 of the file with the opening
447/// `---` on its own line, and MUST be terminated by a `---` on its
448/// own line. This matches Jekyll / Hugo / Anthropic-skills convention.
449fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
450    let trimmed = content.strip_prefix("---\n").or_else(|| {
451        // Handle CRLF line endings.
452        content.strip_prefix("---\r\n")
453    })?;
454    // Find the closing `---` on its own line.
455    let mut search_start = 0;
456    while let Some(idx) = trimmed[search_start..].find("---") {
457        let abs = search_start + idx;
458        // Must be at the start of a line.
459        let at_line_start = abs == 0 || trimmed.as_bytes().get(abs - 1) == Some(&b'\n');
460        // Must be followed by `\n`, `\r\n`, or end of file.
461        let after = &trimmed[abs + 3..];
462        let line_end_ok = after.is_empty() || after.starts_with('\n') || after.starts_with("\r\n");
463        if at_line_start && line_end_ok {
464            let frontmatter = &trimmed[..abs];
465            let body_start = if after.starts_with("\r\n") {
466                abs + 3 + 2
467            } else if after.starts_with('\n') {
468                abs + 3 + 1
469            } else {
470                abs + 3
471            };
472            let body = &trimmed[body_start..];
473            return Some((frontmatter, body));
474        }
475        search_start = abs + 3;
476    }
477    None
478}
479
480/// Parse a SKILL.md content blob into its frontmatter struct and
481/// markdown body.
482pub fn parse_skill(content: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
483    let (frontmatter_str, body) =
484        split_frontmatter(content).ok_or_else(|| SkillError::MissingFrontmatter {
485            path: path.to_path_buf(),
486        })?;
487
488    let frontmatter: SkillFrontmatter =
489        serde_yaml::from_str(frontmatter_str).map_err(|e| SkillError::InvalidFrontmatter {
490            path: path.to_path_buf(),
491            message: e.to_string(),
492        })?;
493
494    if frontmatter.name.is_empty() {
495        return Err(SkillError::MissingRequiredField {
496            path: path.to_path_buf(),
497            field: "name",
498        });
499    }
500    if frontmatter.description.is_empty() {
501        return Err(SkillError::MissingRequiredField {
502            path: path.to_path_buf(),
503            field: "description",
504        });
505    }
506
507    Ok((frontmatter, body.to_string()))
508}
509
510// ─── Skill loaders ────────────────────────────────────────────────
511
512/// Load a single skill from a file path.
513pub fn load_skill_from_file(path: &Path, provenance: SkillProvenance) -> Result<Skill, SkillError> {
514    let content = fs::read_to_string(path).map_err(|e| SkillError::Io {
515        path: path.to_path_buf(),
516        source: e,
517    })?;
518
519    if content.len() > HARD_SIZE_LIMIT_BYTES {
520        return Err(SkillError::SkillTooLarge {
521            path: path.to_path_buf(),
522            bytes: content.len(),
523            limit: HARD_SIZE_LIMIT_BYTES,
524        });
525    }
526    if content.len() > SOFT_SIZE_LIMIT_BYTES {
527        tracing::warn!(
528            path = %path.display(),
529            bytes = content.len(),
530            soft_limit = SOFT_SIZE_LIMIT_BYTES,
531            "skill exceeds the soft size limit; consider splitting"
532        );
533    }
534
535    let (frontmatter, body) = parse_skill(&content, path)?;
536    Ok(Skill {
537        frontmatter,
538        body,
539        provenance,
540    })
541}
542
543/// A non-fatal warning emitted while loading skills — a single file
544/// failed to parse, but the rest of the directory was loaded
545/// successfully.
546///
547/// Surfaced on [`ResolvedRegistry::parse_warnings`] so downstream
548/// binaries can render warnings in their boot summary. Operators
549/// previously had to set up tracing-subscriber filters to see these;
550/// the structured surface makes them visible without log plumbing.
551///
552/// Lands in 0.3.37 in response to an operator hitting an unquoted
553/// colon in a description (`First clause: second clause`) — PyYAML
554/// raised `mapping values are not allowed here` and the loader
555/// silently skipped the file. 25-minute debug session later, the
556/// operator switched to a folded scalar. The lesson: silent skip is
557/// the worst failure mode for a new authoring surface.
558#[derive(Debug, Clone)]
559pub struct ParseWarning {
560    /// The file that failed to load.
561    pub path: PathBuf,
562    /// Human-readable description of why it failed.
563    pub error: String,
564}
565
566/// Walk a directory for `*.md` files, loading each as a skill.
567///
568/// Files that fail to parse are skipped (one malformed skill in a
569/// domain pack shouldn't take down the rest) but their errors are
570/// **both** logged via `tracing::warn!` AND collected for the caller
571/// to surface via [`ResolvedRegistry::parse_warnings`]. Operators
572/// using stdio transport — where tracing output may not be visible —
573/// can still see the warnings through the structured channel.
574pub fn load_skills_from_dir(
575    dir: &Path,
576    provenance: SkillProvenance,
577) -> Result<(Vec<Skill>, Vec<ParseWarning>), SkillError> {
578    if !dir.is_dir() {
579        return Ok((Vec::new(), Vec::new()));
580    }
581
582    let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
583        path: dir.to_path_buf(),
584        source: e,
585    })?;
586
587    let mut skills = Vec::new();
588    let mut warnings = Vec::new();
589    for entry in entries {
590        let entry = match entry {
591            Ok(e) => e,
592            Err(e) => {
593                tracing::warn!(
594                    dir = %dir.display(),
595                    error = %e,
596                    "failed to read directory entry; skipping"
597                );
598                warnings.push(ParseWarning {
599                    path: dir.to_path_buf(),
600                    error: format!("failed to read directory entry: {e}"),
601                });
602                continue;
603            }
604        };
605        let path = entry.path();
606        // Only `.md` files. Subdirectories and other extensions are
607        // ignored (no recursion — keeps the model simple).
608        if path.extension().map(|e| e == "md").unwrap_or(false) {
609            match load_skill_from_file(&path, provenance.clone()) {
610                Ok(skill) => skills.push(skill),
611                Err(e) => {
612                    tracing::warn!(
613                        path = %path.display(),
614                        error = %e,
615                        "failed to load skill; skipping"
616                    );
617                    warnings.push(ParseWarning {
618                        path: path.clone(),
619                        error: e.to_string(),
620                    });
621                }
622            }
623        }
624    }
625    Ok((skills, warnings))
626}
627
628// ─── Path resolution ──────────────────────────────────────────────
629
630/// Resolve a skill path declaration against the manifest's parent
631/// directory, applying the same conventions used by other manifest
632/// fields:
633///
634/// - `./foo` or `foo` → relative to the manifest's parent dir
635/// - `~/foo` → home-relative (POSIX `$HOME` expansion)
636/// - `/foo` or `C:\foo` → absolute
637///
638/// Public so downstream binaries can resolve paths consistently if
639/// they need to.
640pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
641    let p = Path::new(raw);
642    if p.is_absolute() {
643        return p.to_path_buf();
644    }
645    if let Some(rest) = raw.strip_prefix("~/") {
646        if let Some(home) = std::env::var_os("HOME") {
647            return PathBuf::from(home).join(rest);
648        }
649        // No HOME — fall through to manifest-relative.
650    }
651    manifest_dir.join(raw)
652}
653
654/// Project layer path for a manifest: `<manifest_stem>.skills/` next
655/// to the manifest YAML.
656///
657/// For a manifest at `mcp-servers/legal_mcp.yaml`, the project layer
658/// lives at `mcp-servers/legal_mcp.skills/`.
659pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
660    let stem = yaml_path
661        .file_stem()
662        .map(|s| s.to_string_lossy().into_owned())
663        .unwrap_or_else(|| "manifest".to_string());
664    let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
665    parent.join(format!("{stem}.skills"))
666}
667
668// ─── Library-bundled framework defaults ───────────────────────────
669
670/// Return the framework's own bundled skills.
671///
672/// The five SKILL.md files are embedded at compile time via the
673/// [`bundled_skills_index`](crate::server::bundled_skills_index)
674/// submodule. Downstream binaries call this through
675/// [`Registry::merge_framework_defaults`] when they want the
676/// framework defaults at the bottom of their three-layer stack.
677pub fn library_bundled_skills() -> Vec<BundledSkill> {
678    crate::server::bundled_skills_index::library_bundled_skills()
679}
680
681// ─── Authoring template ───────────────────────────────────────────
682
683/// Render a starter SKILL.md body as a string.
684///
685/// The returned text is a complete, parse-valid SKILL.md file with
686/// the supplied `name` and `description` filled into the frontmatter
687/// and the rest of the optional extension fields commented out.
688/// The body follows the anatomy documented in
689/// `docs/guides/writing-effective-skills.md` — Overview, Quick
690/// Reference table, a placeholder major-topic section, Common
691/// Pitfalls, and a "When wrong" section — all with `<TODO>`-style
692/// placeholders the operator fills in.
693///
694/// Use [`write_skill_template`] for the on-disk version.
695pub fn render_skill_template(name: &str, description: &str) -> String {
696    format!(
697        "---\n\
698         name: {name}\n\
699         description: {description}\n\
700         # Optional mcp-methods extension fields (uncomment as needed):\n\
701         # applies_to:\n\
702         #   mcp_methods: \">=0.3.35\"\n\
703         # references_tools:\n\
704         #   - {name}\n\
705         # references_arguments:\n\
706         #   - {name}.<arg_name>\n\
707         # auto_inject_hint: true\n\
708         ---\n\
709         \n\
710         # `{name}` methodology\n\
711         \n\
712         ## Overview\n\
713         \n\
714         <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
715         what comes before and after it in the typical workflow.>\n\
716         \n\
717         ## Quick Reference\n\
718         \n\
719         | Task | Approach |\n\
720         |---|---|\n\
721         | <TODO: common task A> | <TODO: one-line pattern> |\n\
722         | <TODO: common task B> | <TODO: one-line pattern> |\n\
723         \n\
724         ## <TODO: Major topic>\n\
725         \n\
726         <TODO: concrete prose, code blocks, examples.>\n\
727         \n\
728         ## Common Pitfalls\n\
729         \n\
730         ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
731         \n\
732         ✅ <TODO: positive guidance, often a heuristic>\n\
733         \n\
734         ## When `{name}` is the wrong tool\n\
735         \n\
736         - **<TODO: scenario>** — use <other tool> because <reason>.\n"
737    )
738}
739
740/// Resolve where a template write should land and write it.
741///
742/// `dest` interpretation:
743/// - If `dest` is an existing directory, the file is written to
744///   `dest/<name>.md`.
745/// - If `dest` ends in `.md`, it is used verbatim and its parent
746///   must already exist.
747/// - Otherwise `dest` is treated as a directory that should be
748///   created (and its parents created with `create_dir_all`) before
749///   writing `dest/<name>.md`.
750///
751/// Existing files are never overwritten — if the destination already
752/// exists, returns a `SkillError::Io` wrapping `AlreadyExists`. The
753/// caller should delete first if they really want to replace.
754pub fn write_skill_template(
755    dest: &Path,
756    name: &str,
757    description: &str,
758) -> Result<PathBuf, SkillError> {
759    let path = resolve_template_dest(dest, name);
760
761    if path.exists() {
762        return Err(SkillError::Io {
763            path: path.clone(),
764            source: std::io::Error::new(
765                std::io::ErrorKind::AlreadyExists,
766                "destination already exists; delete it before re-running",
767            ),
768        });
769    }
770
771    if let Some(parent) = path.parent() {
772        if !parent.as_os_str().is_empty() && !parent.exists() {
773            fs::create_dir_all(parent).map_err(|e| SkillError::Io {
774                path: parent.to_path_buf(),
775                source: e,
776            })?;
777        }
778    }
779
780    let body = render_skill_template(name, description);
781    fs::write(&path, body).map_err(|e| SkillError::Io {
782        path: path.clone(),
783        source: e,
784    })?;
785    Ok(path)
786}
787
788fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
789    if dest.is_dir() {
790        return dest.join(format!("{name}.md"));
791    }
792    if dest
793        .extension()
794        .map(|e| e.eq_ignore_ascii_case("md"))
795        .unwrap_or(false)
796    {
797        return dest.to_path_buf();
798    }
799    dest.join(format!("{name}.md"))
800}
801
802// ─── Registry builder ─────────────────────────────────────────────
803
804/// Builder for a skills [`ResolvedRegistry`]. Downstream binaries
805/// (`kglite-mcp-server`, etc.) construct one of these in their
806/// boot path, layer in their bundled + operator-declared skills,
807/// then call [`Registry::finalise`] to get the resolved set
808/// ready for MCP `prompts/list` + `prompts/get` wiring.
809///
810/// See the module docs for the canonical usage pattern.
811#[derive(Default)]
812pub struct Registry {
813    bundled: Vec<BundledSkill>,
814    /// Sources from the manifest's `skills:` list, in declaration
815    /// order. Each entry contributes a layer; later entries within
816    /// the root layer have lower priority than earlier ones.
817    root_dirs: Vec<(PathBuf, String)>, // (resolved_path, raw_decl_string)
818    root_includes_bundled: bool,
819    /// Project layer — auto-detected `<basename>.skills/` adjacent
820    /// to the manifest YAML. Set via `auto_detect_project_layer`.
821    project_dir: Option<PathBuf>,
822    /// Optional consumer-supplied evaluator for domain predicates
823    /// (`graph_has_node_type`, `graph_has_property`). Wired in via
824    /// [`Registry::with_predicate_evaluator`]; framework-internal
825    /// predicates (`tool_registered`, `extension_enabled`) are
826    /// dispatched without consulting the evaluator.
827    evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
828}
829
830impl std::fmt::Debug for Registry {
831    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
832        f.debug_struct("Registry")
833            .field("bundled", &self.bundled)
834            .field("root_dirs", &self.root_dirs)
835            .field("root_includes_bundled", &self.root_includes_bundled)
836            .field("project_dir", &self.project_dir)
837            .field(
838                "evaluator",
839                &self
840                    .evaluator
841                    .as_ref()
842                    .map(|_| "<dyn SkillPredicateEvaluator>"),
843            )
844            .finish()
845    }
846}
847
848impl Registry {
849    /// Construct an empty registry. Chain in `add_bundled`,
850    /// `merge_framework_defaults`, `layer_dirs`,
851    /// `auto_detect_project_layer`, and optionally
852    /// `with_predicate_evaluator`, then call `finalise()`.
853    pub fn new() -> Self {
854        Self::default()
855    }
856
857    /// One-shot resolution of a [`ResolvedRegistry`] from a manifest
858    /// YAML — loads the manifest, merges framework defaults (when
859    /// `include_bundled` is true), auto-detects the project layer at
860    /// `<basename>.skills/`, layers in operator-declared `skills:`
861    /// paths, and finalises in a single call.
862    ///
863    /// This is the canonical shape consumed by pyo3 wrappers
864    /// (`mcp-methods-py::SkillRegistry.from_manifest` and downstream
865    /// equivalents like kglite's). Owning it here keeps the
866    /// orchestration single-sourced so layering tweaks don't need to
867    /// be replicated in every wrapper.
868    ///
869    /// For bespoke layering — e.g. supplying a custom predicate
870    /// evaluator via [`Registry::with_predicate_evaluator`], or
871    /// `include_str!`'d downstream bundled skills via
872    /// [`Registry::add_bundled`] — drive the builder directly
873    /// instead of calling this.
874    ///
875    /// Pass `include_bundled=false` to skip framework defaults; useful
876    /// for tests or downstream binaries supplying their own bundled
877    /// layer.
878    pub fn from_manifest(
879        manifest_path: &Path,
880        include_bundled: bool,
881    ) -> Result<ResolvedRegistry, SkillError> {
882        let manifest = load_manifest(manifest_path).map_err(|e| SkillError::Manifest {
883            path: manifest_path.to_path_buf(),
884            message: e.message,
885        })?;
886        let mut builder = Registry::new();
887        if include_bundled {
888            builder = builder.merge_framework_defaults();
889        }
890        builder = builder.auto_detect_project_layer(manifest_path);
891        builder = builder.layer_dirs(&manifest.skills, manifest_path)?;
892        builder.finalise()
893    }
894
895    /// Register a domain-specific predicate evaluator for the
896    /// `applies_when:` machinery. The evaluator only sees domain
897    /// predicates (`graph_has_node_type`, `graph_has_property`);
898    /// framework-internal ones (`tool_registered`,
899    /// `extension_enabled`) are dispatched against the
900    /// [`McpServer`](crate::server::McpServer)'s runtime state at
901    /// [`serve_prompts`](crate::server::serve_prompts) time.
902    ///
903    /// Without an evaluator, skills using domain predicates resolve
904    /// to inactive (predicate `Unknown` → skill suppressed). This
905    /// is the safe default: a typo'd predicate or a missing
906    /// evaluator must not silently activate the wrong-domain skill.
907    pub fn with_predicate_evaluator(
908        mut self,
909        evaluator: impl SkillPredicateEvaluator + 'static,
910    ) -> Self {
911        self.evaluator = Some(Arc::new(evaluator));
912        self
913    }
914
915    /// Add a compile-time bundled skill. Typically called by
916    /// downstream binaries with their own `include_str!`'d skills,
917    /// once per custom tool.
918    ///
919    /// Bundled skills sit at the bottom of the three-layer
920    /// composition; later layers override them when names collide.
921    /// Within the bundled set, the downstream binary's skills win
922    /// over framework defaults (the downstream calls `add_bundled`
923    /// before or after `merge_framework_defaults` — order doesn't
924    /// matter; resolution dedupes by name with downstream-first
925    /// priority).
926    ///
927    /// Malformed bundled skills are reported at `finalise()` time
928    /// via [`SkillError::BundledSkillInvalid`]. The framework's
929    /// own bundled-skill CI test should catch this for the library
930    /// defaults; downstream binaries should write equivalent tests
931    /// for their own bundled set.
932    pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
933        self.bundled.push(skill);
934        self
935    }
936
937    /// Add a batch of compile-time bundled skills.
938    pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
939        self.bundled.extend(skills);
940        self
941    }
942
943    /// Merge in the framework's own bundled defaults (returned by
944    /// [`library_bundled_skills`]). Idempotent — calling twice is
945    /// harmless (later calls add duplicates which the finalise
946    /// deduper drops, downstream-first).
947    pub fn merge_framework_defaults(self) -> Self {
948        let defaults = library_bundled_skills();
949        self.add_bundled_many(defaults)
950    }
951
952    /// Layer in skill directories declared in the manifest's
953    /// `skills:` field, walked in declaration order. Each path
954    /// becomes a domain-pack-layer source; the bundled marker
955    /// `true` is acknowledged but its skills are already in the
956    /// bundled layer via `add_bundled`/`merge_framework_defaults`.
957    ///
958    /// Path resolution uses the same conventions as the rest of the
959    /// manifest (`./foo` relative to YAML dir, `~/foo` home-relative,
960    /// `/foo` absolute). Non-existent paths are reported as
961    /// [`SkillError::PathNotFound`] at this call site so operators
962    /// see typos immediately.
963    pub fn layer_dirs(
964        mut self,
965        source: &SkillsSource,
966        yaml_path: &Path,
967    ) -> Result<Self, SkillError> {
968        let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
969
970        match source {
971            SkillsSource::Disabled => {
972                // Skills disabled entirely — return the registry
973                // unchanged. Downstream may still have called
974                // add_bundled, but those won't be reachable without
975                // a layer telling us skills are enabled.
976                self.root_includes_bundled = false;
977            }
978            SkillsSource::Sources(sources) => {
979                for src in sources {
980                    match src {
981                        SkillSource::Bundled => {
982                            self.root_includes_bundled = true;
983                        }
984                        SkillSource::Path(raw) => {
985                            let resolved = resolve_skill_path(raw, manifest_dir);
986                            if !resolved.is_dir() {
987                                return Err(SkillError::PathNotFound {
988                                    raw: raw.clone(),
989                                    resolved,
990                                });
991                            }
992                            self.root_dirs.push((resolved, raw.clone()));
993                        }
994                    }
995                }
996            }
997        }
998
999        Ok(self)
1000    }
1001
1002    /// Auto-detect the project layer at `<basename>.skills/`
1003    /// adjacent to the manifest YAML. Always called; the directory
1004    /// is optional — if it doesn't exist, the project layer is
1005    /// simply empty.
1006    pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
1007        let candidate = project_skills_dir(yaml_path);
1008        if candidate.is_dir() {
1009            self.project_dir = Some(candidate);
1010        }
1011        self
1012    }
1013
1014    /// Resolve all three layers and return the final registry.
1015    ///
1016    /// Resolution order per skill name: project > root layer
1017    /// (in declaration order) > bundled. The first source that
1018    /// contributes a skill with the given name wins; later sources
1019    /// are ignored for that name (no merging, no inheritance —
1020    /// full-file replacement).
1021    ///
1022    /// At this point the framework:
1023    /// - Parses all skill files (frontmatter validation)
1024    /// - Logs collision-resolution info via `tracing::info!` per skill
1025    /// - Enforces per-skill hard size limits ([`HARD_SIZE_LIMIT_BYTES`])
1026    /// - Warns on per-skill soft size limit ([`SOFT_SIZE_LIMIT_BYTES`])
1027    /// - Warns on session total exceeding [`SESSION_TOTAL_LIMIT_BYTES`]
1028    pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
1029        let Self {
1030            bundled,
1031            root_dirs,
1032            root_includes_bundled,
1033            project_dir,
1034            evaluator,
1035        } = self;
1036
1037        // Parse bundled skills first. These are the lowest-priority
1038        // layer; they get overridden by anything declared above.
1039        let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
1040        if root_includes_bundled {
1041            for b in &bundled {
1042                let path = PathBuf::from(format!("<bundled:{}>", b.name));
1043                let (frontmatter, body) =
1044                    parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
1045                        name: b.name,
1046                        message: e.to_string(),
1047                    })?;
1048                if frontmatter.name != b.name {
1049                    return Err(SkillError::BundledSkillInvalid {
1050                        name: b.name,
1051                        message: format!(
1052                            "frontmatter name {:?} does not match the bundled key {:?}",
1053                            frontmatter.name, b.name
1054                        ),
1055                    });
1056                }
1057                bundled_skills.push(Skill {
1058                    frontmatter,
1059                    body,
1060                    provenance: SkillProvenance::Bundled,
1061                });
1062            }
1063        }
1064
1065        // Root layer: walk each declared path; first wins per name.
1066        // Accumulate parse warnings across all layers so the resolved
1067        // registry can surface them to downstream binaries.
1068        let mut parse_warnings: Vec<ParseWarning> = Vec::new();
1069        let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
1070        for (resolved, _raw) in &root_dirs {
1071            let provenance = SkillProvenance::DomainPack(resolved.clone());
1072            let (skills, warnings) = load_skills_from_dir(resolved, provenance)?;
1073            parse_warnings.extend(warnings);
1074            root_skills_per_dir.push(skills);
1075        }
1076
1077        // Project layer: auto-detected adjacent dir.
1078        let project_skills: Vec<Skill> = match &project_dir {
1079            Some(dir) => {
1080                let (skills, warnings) = load_skills_from_dir(dir, SkillProvenance::Project)?;
1081                parse_warnings.extend(warnings);
1082                skills
1083            }
1084            None => Vec::new(),
1085        };
1086
1087        // Resolve per skill name. Priority:
1088        //   1. Project layer
1089        //   2. Root layer entries in declaration order
1090        //   3. Bundled (downstream entries first, then framework)
1091        //
1092        // The bundled list is already in downstream-first order
1093        // because downstream binaries call `add_bundled` before
1094        // `merge_framework_defaults` by convention.
1095
1096        let mut resolved: HashMap<String, Skill> = HashMap::new();
1097        let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
1098
1099        // Lowest priority first: bundled, then root in reverse
1100        // declaration order, then project. Later inserts overwrite.
1101        // We track collisions for the boot log.
1102        for skill in &bundled_skills {
1103            let name = skill.name().to_string();
1104            collisions
1105                .entry(name.clone())
1106                .or_default()
1107                .push(skill.provenance.clone());
1108            resolved.insert(name, skill.clone());
1109        }
1110        for skills in root_skills_per_dir.iter().rev() {
1111            for skill in skills {
1112                let name = skill.name().to_string();
1113                collisions
1114                    .entry(name.clone())
1115                    .or_default()
1116                    .push(skill.provenance.clone());
1117                resolved.insert(name, skill.clone());
1118            }
1119        }
1120        for skill in &project_skills {
1121            let name = skill.name().to_string();
1122            collisions
1123                .entry(name.clone())
1124                .or_default()
1125                .push(skill.provenance.clone());
1126            resolved.insert(name, skill.clone());
1127        }
1128
1129        // Log collision resolution for skills with more than one
1130        // candidate. Single-candidate skills don't need a log line.
1131        for (name, candidates) in &collisions {
1132            if candidates.len() > 1 {
1133                let winner = resolved
1134                    .get(name)
1135                    .map(|s| format_provenance(&s.provenance))
1136                    .unwrap_or_else(|| "<none>".to_string());
1137                let all_candidates: Vec<String> =
1138                    candidates.iter().map(format_provenance).collect();
1139                tracing::info!(
1140                    skill = %name,
1141                    candidates = ?all_candidates,
1142                    winner = %winner,
1143                    "skill resolved across multiple layers"
1144                );
1145            }
1146        }
1147
1148        // Check session-total size limit.
1149        let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
1150        if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
1151            tracing::warn!(
1152                total_bytes,
1153                limit = SESSION_TOTAL_LIMIT_BYTES,
1154                skill_count = resolved.len(),
1155                "total resolved skill body size exceeds session limit; \
1156                 consider trimming or splitting skills"
1157            );
1158        }
1159
1160        Ok(ResolvedRegistry {
1161            skills: resolved,
1162            evaluator,
1163            parse_warnings,
1164        })
1165    }
1166}
1167
1168fn format_provenance(p: &SkillProvenance) -> String {
1169    match p {
1170        SkillProvenance::Project => "project".to_string(),
1171        SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
1172        SkillProvenance::Bundled => "bundled".to_string(),
1173    }
1174}
1175
1176// ─── ResolvedRegistry ─────────────────────────────────────────────
1177
1178/// The post-resolution skill set. Consumed by `serve_prompts`
1179/// (Phase 1c) to wire `prompts/list` and `prompts/get` on the
1180/// MCP server.
1181#[derive(Default)]
1182pub struct ResolvedRegistry {
1183    skills: HashMap<String, Skill>,
1184    /// Optional domain-predicate evaluator carried from
1185    /// [`Registry::with_predicate_evaluator`]. `serve_prompts`
1186    /// consults this when evaluating `applies_when:` blocks; absent
1187    /// means domain predicates resolve to `Unknown` → skill
1188    /// inactive.
1189    pub(crate) evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
1190    /// Non-fatal per-file load failures (silent skips). Empty in the
1191    /// happy path; populated when a SKILL.md fails to parse and the
1192    /// rest of the directory is loaded around it. Downstream binaries
1193    /// render these in their boot summary so operators see what was
1194    /// silently dropped without having to enable tracing.
1195    parse_warnings: Vec<ParseWarning>,
1196}
1197
1198impl std::fmt::Debug for ResolvedRegistry {
1199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1200        f.debug_struct("ResolvedRegistry")
1201            .field("skills", &self.skills)
1202            .field(
1203                "evaluator",
1204                &self
1205                    .evaluator
1206                    .as_ref()
1207                    .map(|_| "<dyn SkillPredicateEvaluator>"),
1208            )
1209            .finish()
1210    }
1211}
1212
1213impl ResolvedRegistry {
1214    /// All resolved skill names, sorted alphabetically for stable
1215    /// output in `prompts/list`.
1216    pub fn skill_names(&self) -> Vec<String> {
1217        let mut names: Vec<String> = self.skills.keys().cloned().collect();
1218        names.sort();
1219        names
1220    }
1221
1222    /// Look up a skill by name. Used by `prompts/get` to fetch the
1223    /// full body when the agent requests it.
1224    pub fn get(&self, name: &str) -> Option<&Skill> {
1225        self.skills.get(name)
1226    }
1227
1228    /// Iterate all resolved skills. Order is unspecified — use
1229    /// `skill_names()` first if a deterministic iteration is needed.
1230    pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
1231        self.skills.iter()
1232    }
1233
1234    /// Number of resolved skills.
1235    pub fn len(&self) -> usize {
1236        self.skills.len()
1237    }
1238
1239    /// Whether the registry contains any skills.
1240    pub fn is_empty(&self) -> bool {
1241        self.skills.is_empty()
1242    }
1243
1244    /// Per-file load failures that were silently skipped. Empty in
1245    /// the happy path. Each entry names the path and the error so
1246    /// downstream binaries can render them in their boot summary —
1247    /// the durable channel for visibility into "this file was
1248    /// silently dropped" failures that previously cost a 25-minute
1249    /// debug session per operator.
1250    pub fn parse_warnings(&self) -> &[ParseWarning] {
1251        &self.parse_warnings
1252    }
1253
1254    /// Evaluate the `applies_when:` block on `skill` against this
1255    /// registry's evaluator plus the supplied runtime state. Returns
1256    /// the per-clause outcomes plus whether the skill should be
1257    /// considered active.
1258    ///
1259    /// `registered_tools` and `extensions` carry the runtime state
1260    /// the framework-internal predicates check against.
1261    /// `serve_prompts` calls this for every skill at boot;
1262    /// `skills-list` calls it (with placeholder empty state) for
1263    /// the operator-facing summary.
1264    ///
1265    /// A skill without an `applies_when:` block is always active.
1266    pub fn activation_for(
1267        &self,
1268        skill: &Skill,
1269        registered_tools: &std::collections::HashSet<String>,
1270        extensions: &serde_json::Map<String, serde_json::Value>,
1271    ) -> SkillActivation {
1272        let Some(applies_when) = skill.frontmatter.applies_when.as_ref() else {
1273            return SkillActivation {
1274                active: true,
1275                clauses: Vec::new(),
1276            };
1277        };
1278        let mut clauses = Vec::new();
1279        let mut all_satisfied = true;
1280
1281        if let Some(types) = applies_when.graph_has_node_type.as_ref() {
1282            let clause = PredicateClause::GraphHasNodeType(types);
1283            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1284            if outcome != PredicateOutcome::Satisfied {
1285                all_satisfied = false;
1286            }
1287            clauses.push((format!("graph_has_node_type: {types:?}"), outcome));
1288        }
1289        if let Some(prop) = applies_when.graph_has_property.as_ref() {
1290            let clause = PredicateClause::GraphHasProperty {
1291                node_type: &prop.node_type,
1292                prop_name: &prop.prop_name,
1293            };
1294            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1295            if outcome != PredicateOutcome::Satisfied {
1296                all_satisfied = false;
1297            }
1298            clauses.push((
1299                format!("graph_has_property: {}.{}", prop.node_type, prop.prop_name),
1300                outcome,
1301            ));
1302        }
1303        if let Some(tool) = applies_when.tool_registered.as_ref() {
1304            let clause = PredicateClause::ToolRegistered(tool);
1305            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1306            if outcome != PredicateOutcome::Satisfied {
1307                all_satisfied = false;
1308            }
1309            clauses.push((format!("tool_registered: {tool}"), outcome));
1310        }
1311        if let Some(key) = applies_when.extension_enabled.as_ref() {
1312            let clause = PredicateClause::ExtensionEnabled(key);
1313            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1314            if outcome != PredicateOutcome::Satisfied {
1315                all_satisfied = false;
1316            }
1317            clauses.push((format!("extension_enabled: {key}"), outcome));
1318        }
1319
1320        SkillActivation {
1321            active: all_satisfied,
1322            clauses,
1323        }
1324    }
1325
1326    fn dispatch_clause(
1327        &self,
1328        clause: &PredicateClause<'_>,
1329        registered_tools: &std::collections::HashSet<String>,
1330        extensions: &serde_json::Map<String, serde_json::Value>,
1331    ) -> PredicateOutcome {
1332        // Framework-internal predicates are dispatched in-framework
1333        // regardless of the evaluator's preference. This keeps
1334        // tool_registered + extension_enabled working even when no
1335        // evaluator is registered.
1336        match clause {
1337            PredicateClause::ToolRegistered(name) => {
1338                return if registered_tools.contains(*name) {
1339                    PredicateOutcome::Satisfied
1340                } else {
1341                    PredicateOutcome::Unsatisfied
1342                };
1343            }
1344            PredicateClause::ExtensionEnabled(key) => {
1345                let truthy = extensions
1346                    .get(*key)
1347                    .map(|v| !v.is_null() && v != &serde_json::Value::Bool(false))
1348                    .unwrap_or(false);
1349                return if truthy {
1350                    PredicateOutcome::Satisfied
1351                } else {
1352                    PredicateOutcome::Unsatisfied
1353                };
1354            }
1355            _ => {}
1356        }
1357
1358        // Domain predicates: defer to the evaluator. Unknown when no
1359        // evaluator is registered or the evaluator returns None.
1360        match self.evaluator.as_ref().and_then(|e| e.evaluate(clause)) {
1361            Some(true) => PredicateOutcome::Satisfied,
1362            Some(false) => PredicateOutcome::Unsatisfied,
1363            None => PredicateOutcome::Unknown,
1364        }
1365    }
1366}
1367
1368// ─── Tests ────────────────────────────────────────────────────────
1369
1370#[cfg(test)]
1371mod tests {
1372    use super::*;
1373    use std::io::Write;
1374
1375    fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
1376        let path = dir.join(format!("{name}.md"));
1377        let mut f = fs::File::create(&path).unwrap();
1378        f.write_all(content.as_bytes()).unwrap();
1379        path
1380    }
1381
1382    fn minimal_skill(name: &str) -> String {
1383        format!(
1384            "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
1385        )
1386    }
1387
1388    // ─── Frontmatter parsing ──────────────────────────────────────
1389
1390    #[test]
1391    fn parse_frontmatter_basic() {
1392        let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
1393        let path = PathBuf::from("test.md");
1394        let (fm, body) = parse_skill(content, &path).unwrap();
1395        assert_eq!(fm.name, "foo");
1396        assert_eq!(fm.description, "A foo skill.");
1397        assert_eq!(body, "\nBody here.\n");
1398        assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
1399    }
1400
1401    #[test]
1402    fn parse_frontmatter_missing_delimiters_rejected() {
1403        let content = "name: foo\ndescription: bar\n";
1404        let path = PathBuf::from("test.md");
1405        let err = parse_skill(content, &path).unwrap_err();
1406        assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
1407    }
1408
1409    #[test]
1410    fn parse_frontmatter_invalid_yaml_rejected() {
1411        let content = "---\nname: foo\n  bad: yaml: nesting\n---\nbody\n";
1412        let path = PathBuf::from("test.md");
1413        let err = parse_skill(content, &path).unwrap_err();
1414        assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
1415    }
1416
1417    #[test]
1418    fn parse_frontmatter_missing_name_rejected() {
1419        let content = "---\ndescription: bar\n---\nbody\n";
1420        let path = PathBuf::from("test.md");
1421        let err = parse_skill(content, &path).unwrap_err();
1422        assert!(matches!(
1423            err,
1424            SkillError::MissingRequiredField { field: "name", .. }
1425        ));
1426    }
1427
1428    #[test]
1429    fn parse_frontmatter_missing_description_rejected() {
1430        let content = "---\nname: foo\n---\nbody\n";
1431        let path = PathBuf::from("test.md");
1432        let err = parse_skill(content, &path).unwrap_err();
1433        assert!(matches!(
1434            err,
1435            SkillError::MissingRequiredField {
1436                field: "description",
1437                ..
1438            }
1439        ));
1440    }
1441
1442    #[test]
1443    fn parse_frontmatter_all_optional_fields() {
1444        let content = "---\n\
1445name: foo\n\
1446description: Full surface.\n\
1447references_tools: [grep, list_source]\n\
1448references_arguments: [grep.pattern]\n\
1449references_properties: [Function.module]\n\
1450auto_inject_hint: false\n\
1451applies_to:\n  mcp_methods: \">=0.3.35\"\n\
1452---\n\
1453Body.\n";
1454        let path = PathBuf::from("test.md");
1455        let (fm, _) = parse_skill(content, &path).unwrap();
1456        assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1457        assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1458        assert_eq!(fm.references_properties, vec!["Function.module"]);
1459        assert!(!fm.auto_inject_hint);
1460        assert_eq!(
1461            fm.applies_to.unwrap().get("mcp_methods"),
1462            Some(&">=0.3.35".to_string())
1463        );
1464    }
1465
1466    // ─── Loading from files + dirs ────────────────────────────────
1467
1468    #[test]
1469    fn load_skill_from_file_basic() {
1470        let dir = tempfile::tempdir().unwrap();
1471        let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1472        let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1473        assert_eq!(skill.name(), "foo");
1474        assert_eq!(skill.provenance, SkillProvenance::Project);
1475    }
1476
1477    #[test]
1478    fn load_skill_too_large_rejected() {
1479        let dir = tempfile::tempdir().unwrap();
1480        // Build a body just over the hard limit.
1481        let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1482        let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1483        let path = write_skill(dir.path(), "big", &content);
1484        let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1485        assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1486    }
1487
1488    #[test]
1489    fn load_skills_from_dir_walks_markdown_only() {
1490        let dir = tempfile::tempdir().unwrap();
1491        write_skill(dir.path(), "a", &minimal_skill("a"));
1492        write_skill(dir.path(), "b", &minimal_skill("b"));
1493        // Non-markdown file — ignored.
1494        fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1495        // Subdirectory — ignored.
1496        let sub = dir.path().join("sub");
1497        fs::create_dir(&sub).unwrap();
1498        write_skill(&sub, "c", &minimal_skill("c"));
1499
1500        let (skills, warnings) =
1501            load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1502        assert_eq!(skills.len(), 2);
1503        assert!(warnings.is_empty());
1504        let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1505        names.sort();
1506        assert_eq!(names, vec!["a", "b"]);
1507    }
1508
1509    #[test]
1510    fn load_skills_from_dir_missing_returns_empty() {
1511        let dir = tempfile::tempdir().unwrap();
1512        let nonexistent = dir.path().join("does-not-exist");
1513        let (skills, warnings) =
1514            load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1515        assert!(skills.is_empty());
1516        assert!(warnings.is_empty());
1517    }
1518
1519    #[test]
1520    fn load_skills_from_dir_surfaces_yaml_parse_failure_as_warning() {
1521        // The exact scenario the operator hit: unquoted colon in
1522        // description value triggers PyYAML's "mapping values are
1523        // not allowed here" — except ours uses serde_yaml so the
1524        // failure mode is `InvalidFrontmatter`. Either way, the
1525        // file is skipped, the rest of the dir loads, and the
1526        // warning surfaces structurally rather than just via
1527        // tracing::warn!.
1528        let dir = tempfile::tempdir().unwrap();
1529        // Valid skill.
1530        write_skill(dir.path(), "good", &minimal_skill("good"));
1531        // Broken skill: unquoted colon inside description.
1532        write_skill(
1533            dir.path(),
1534            "broken",
1535            "---\nname: broken\ndescription: First clause: second clause\n---\n# body\n",
1536        );
1537
1538        let (skills, warnings) =
1539            load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1540        assert_eq!(skills.len(), 1, "the good skill should still load");
1541        assert_eq!(skills[0].name(), "good");
1542        assert_eq!(
1543            warnings.len(),
1544            1,
1545            "the broken file should surface as a warning"
1546        );
1547        assert!(warnings[0].path.ends_with("broken.md"));
1548        assert!(!warnings[0].error.is_empty());
1549    }
1550
1551    #[test]
1552    fn resolved_registry_parse_warnings_propagated_from_project_layer() {
1553        // End-to-end through `Registry::finalise`: a broken file in
1554        // the project layer shows up on `ResolvedRegistry::parse_warnings`.
1555        let dir = tempfile::tempdir().unwrap();
1556        let yaml = dir.path().join("test_mcp.yaml");
1557        fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1558        let skills_dir = dir.path().join("test_mcp.skills");
1559        fs::create_dir(&skills_dir).unwrap();
1560        // Valid skill.
1561        write_skill(&skills_dir, "good", &minimal_skill("good"));
1562        // Broken skill: missing closing `---`.
1563        write_skill(
1564            &skills_dir,
1565            "broken",
1566            "---\nname: broken\ndescription: bad\nstill in frontmatter\n",
1567        );
1568
1569        let registry = Registry::new()
1570            .auto_detect_project_layer(&yaml)
1571            .finalise()
1572            .unwrap();
1573
1574        assert_eq!(registry.len(), 1, "good skill resolved");
1575        assert!(registry.get("good").is_some());
1576        let warnings = registry.parse_warnings();
1577        assert_eq!(warnings.len(), 1);
1578        assert!(warnings[0].path.ends_with("broken.md"));
1579    }
1580
1581    // ─── Path resolution ──────────────────────────────────────────
1582
1583    #[test]
1584    fn resolve_skill_path_relative() {
1585        let manifest_dir = Path::new("/a/b");
1586        assert_eq!(
1587            resolve_skill_path("./skills", manifest_dir),
1588            PathBuf::from("/a/b/./skills")
1589        );
1590        assert_eq!(
1591            resolve_skill_path("skills", manifest_dir),
1592            PathBuf::from("/a/b/skills")
1593        );
1594    }
1595
1596    #[test]
1597    fn resolve_skill_path_absolute() {
1598        let manifest_dir = Path::new("/a/b");
1599        assert_eq!(
1600            resolve_skill_path("/abs/skills", manifest_dir),
1601            PathBuf::from("/abs/skills")
1602        );
1603    }
1604
1605    #[test]
1606    fn resolve_skill_path_home_relative() {
1607        let manifest_dir = Path::new("/a/b");
1608        // Set HOME explicitly for the test.
1609        // SAFETY: tests run single-threaded for env mutation; this is
1610        // a known stylistic exception in Rust's 1.83+ unsafe-env API.
1611        unsafe {
1612            std::env::set_var("HOME", "/home/test");
1613        }
1614        assert_eq!(
1615            resolve_skill_path("~/skills", manifest_dir),
1616            PathBuf::from("/home/test/skills")
1617        );
1618    }
1619
1620    #[test]
1621    fn project_skills_dir_naming() {
1622        assert_eq!(
1623            project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1624            PathBuf::from("/a/b/legal_mcp.skills")
1625        );
1626        assert_eq!(
1627            project_skills_dir(Path::new("workspace_mcp.yaml")),
1628            PathBuf::from("workspace_mcp.skills")
1629        );
1630    }
1631
1632    // ─── Registry builder ─────────────────────────────────────────
1633
1634    #[test]
1635    fn registry_disabled_resolves_empty() {
1636        let dir = tempfile::tempdir().unwrap();
1637        let yaml = dir.path().join("test_mcp.yaml");
1638        fs::write(&yaml, "name: x\n").unwrap();
1639
1640        let registry = Registry::new()
1641            .layer_dirs(&SkillsSource::Disabled, &yaml)
1642            .unwrap()
1643            .auto_detect_project_layer(&yaml)
1644            .finalise()
1645            .unwrap();
1646        assert!(registry.is_empty());
1647    }
1648
1649    #[test]
1650    fn registry_add_bundled_only_visible_when_opted_in() {
1651        let dir = tempfile::tempdir().unwrap();
1652        let yaml = dir.path().join("test_mcp.yaml");
1653        fs::write(&yaml, "name: x\n").unwrap();
1654
1655        let bundled = BundledSkill {
1656            name: "foo",
1657            // Static body for testing — needs to be 'static, which is
1658            // why BundledSkill uses &'static str. For the test we
1659            // leak. Production code uses include_str!.
1660            body: Box::leak(minimal_skill("foo").into_boxed_str()),
1661        };
1662
1663        // Disabled → bundled is NOT visible, even if added.
1664        let registry = Registry::new()
1665            .add_bundled(bundled.clone())
1666            .layer_dirs(&SkillsSource::Disabled, &yaml)
1667            .unwrap()
1668            .finalise()
1669            .unwrap();
1670        assert!(registry.is_empty(), "disabled must short-circuit bundled");
1671
1672        // skills: [true] → bundled IS visible.
1673        let registry = Registry::new()
1674            .add_bundled(bundled)
1675            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1676            .unwrap()
1677            .finalise()
1678            .unwrap();
1679        assert_eq!(registry.len(), 1);
1680        assert!(registry.get("foo").is_some());
1681        assert_eq!(
1682            registry.get("foo").unwrap().provenance,
1683            SkillProvenance::Bundled
1684        );
1685    }
1686
1687    #[test]
1688    fn registry_three_layer_resolution_project_wins_over_bundled() {
1689        let dir = tempfile::tempdir().unwrap();
1690        let yaml = dir.path().join("test_mcp.yaml");
1691        fs::write(&yaml, "name: x\n").unwrap();
1692
1693        // Bundled `foo`:
1694        let bundled = BundledSkill {
1695            name: "foo",
1696            body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1697        };
1698
1699        // Project layer `foo`:
1700        let project_dir = dir.path().join("test_mcp.skills");
1701        fs::create_dir(&project_dir).unwrap();
1702        fs::write(
1703            project_dir.join("foo.md"),
1704            "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1705        )
1706        .unwrap();
1707
1708        let registry = Registry::new()
1709            .add_bundled(bundled)
1710            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1711            .unwrap()
1712            .auto_detect_project_layer(&yaml)
1713            .finalise()
1714            .unwrap();
1715
1716        assert_eq!(registry.len(), 1);
1717        let skill = registry.get("foo").unwrap();
1718        assert_eq!(skill.description(), "from project.");
1719        assert_eq!(skill.provenance, SkillProvenance::Project);
1720    }
1721
1722    #[test]
1723    fn registry_root_layer_first_declaration_wins() {
1724        let dir = tempfile::tempdir().unwrap();
1725        let yaml = dir.path().join("test_mcp.yaml");
1726        fs::write(&yaml, "name: x\n").unwrap();
1727
1728        // First domain pack: foo (from "primary").
1729        let primary = dir.path().join("primary");
1730        fs::create_dir(&primary).unwrap();
1731        fs::write(
1732            primary.join("foo.md"),
1733            "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1734        )
1735        .unwrap();
1736
1737        // Second domain pack: foo (from "secondary") — should LOSE.
1738        let secondary = dir.path().join("secondary");
1739        fs::create_dir(&secondary).unwrap();
1740        fs::write(
1741            secondary.join("foo.md"),
1742            "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1743        )
1744        .unwrap();
1745
1746        let registry = Registry::new()
1747            .layer_dirs(
1748                &SkillsSource::Sources(vec![
1749                    SkillSource::Path("./primary".into()),
1750                    SkillSource::Path("./secondary".into()),
1751                ]),
1752                &yaml,
1753            )
1754            .unwrap()
1755            .finalise()
1756            .unwrap();
1757
1758        assert_eq!(registry.len(), 1);
1759        assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1760    }
1761
1762    #[test]
1763    fn registry_root_layer_nonexistent_path_rejected() {
1764        let dir = tempfile::tempdir().unwrap();
1765        let yaml = dir.path().join("test_mcp.yaml");
1766        fs::write(&yaml, "name: x\n").unwrap();
1767
1768        let err = Registry::new()
1769            .layer_dirs(
1770                &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1771                &yaml,
1772            )
1773            .unwrap_err();
1774        assert!(matches!(err, SkillError::PathNotFound { .. }));
1775    }
1776
1777    #[test]
1778    fn from_manifest_resolves_full_stack() {
1779        let dir = tempfile::tempdir().unwrap();
1780        let yaml = dir.path().join("test_mcp.yaml");
1781        fs::write(&yaml, "name: x\nskills:\n  - true\n  - ./domain-pack\n").unwrap();
1782
1783        let project_dir = dir.path().join("test_mcp.skills");
1784        fs::create_dir(&project_dir).unwrap();
1785        fs::write(project_dir.join("a.md"), minimal_skill("a")).unwrap();
1786
1787        let pack_dir = dir.path().join("domain-pack");
1788        fs::create_dir(&pack_dir).unwrap();
1789        fs::write(pack_dir.join("b.md"), minimal_skill("b")).unwrap();
1790
1791        let registry = Registry::from_manifest(&yaml, false).unwrap();
1792        let names = registry.skill_names();
1793        assert!(names.contains(&"a".to_string()));
1794        assert!(names.contains(&"b".to_string()));
1795        assert_eq!(
1796            registry.get("a").unwrap().provenance,
1797            SkillProvenance::Project
1798        );
1799    }
1800
1801    #[test]
1802    fn from_manifest_surfaces_manifest_load_error() {
1803        let dir = tempfile::tempdir().unwrap();
1804        let yaml = dir.path().join("broken_mcp.yaml");
1805        fs::write(&yaml, "this: is: not: valid yaml\n").unwrap();
1806
1807        let err = Registry::from_manifest(&yaml, false).unwrap_err();
1808        assert!(matches!(err, SkillError::Manifest { .. }));
1809    }
1810
1811    #[test]
1812    fn registry_empty_list_opts_in_without_root_sources() {
1813        let dir = tempfile::tempdir().unwrap();
1814        let yaml = dir.path().join("test_mcp.yaml");
1815        fs::write(&yaml, "name: x\n").unwrap();
1816
1817        // No bundled, no paths — but project layer DOES exist.
1818        let project_dir = dir.path().join("test_mcp.skills");
1819        fs::create_dir(&project_dir).unwrap();
1820        fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1821
1822        let registry = Registry::new()
1823            .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1824            .unwrap()
1825            .auto_detect_project_layer(&yaml)
1826            .finalise()
1827            .unwrap();
1828
1829        assert_eq!(registry.len(), 1);
1830        assert_eq!(
1831            registry.get("only").unwrap().provenance,
1832            SkillProvenance::Project
1833        );
1834    }
1835
1836    #[test]
1837    fn registry_bundled_name_mismatch_rejected_at_finalise() {
1838        let dir = tempfile::tempdir().unwrap();
1839        let yaml = dir.path().join("test_mcp.yaml");
1840        fs::write(&yaml, "name: x\n").unwrap();
1841
1842        // BundledSkill says name="foo" but the frontmatter says name="bar".
1843        let bundled = BundledSkill {
1844            name: "foo",
1845            body: Box::leak(
1846                "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1847                    .to_string()
1848                    .into_boxed_str(),
1849            ),
1850        };
1851
1852        let err = Registry::new()
1853            .add_bundled(bundled)
1854            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1855            .unwrap()
1856            .finalise()
1857            .unwrap_err();
1858        assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1859    }
1860
1861    #[test]
1862    fn registry_library_bundled_skills_returns_vec() {
1863        // Five framework defaults ship from Phase 1d onward. The
1864        // exhaustive shape + uniqueness checks live in
1865        // `bundled_skills_index::tests`; here we just confirm the
1866        // re-export points downstream callers at the populated Vec.
1867        let skills = library_bundled_skills();
1868        assert!(
1869            !skills.is_empty(),
1870            "library_bundled_skills should return framework defaults from Phase 1d onward"
1871        );
1872    }
1873
1874    #[test]
1875    fn registry_skill_names_sorted() {
1876        let dir = tempfile::tempdir().unwrap();
1877        let yaml = dir.path().join("test_mcp.yaml");
1878        fs::write(&yaml, "name: x\n").unwrap();
1879
1880        let pack = dir.path().join("pack");
1881        fs::create_dir(&pack).unwrap();
1882        fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1883        fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1884        fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1885
1886        let registry = Registry::new()
1887            .layer_dirs(
1888                &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1889                &yaml,
1890            )
1891            .unwrap()
1892            .finalise()
1893            .unwrap();
1894
1895        assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1896    }
1897
1898    // ─── Authoring template ───────────────────────────────────────
1899
1900    #[test]
1901    fn render_skill_template_is_parse_valid() {
1902        // Round-trip: a freshly-rendered template must parse cleanly
1903        // through `parse_skill` so the operator's starting point is
1904        // never broken.
1905        let body = render_skill_template("custom_method", "A test description for the skill.");
1906        let (fm, _body) =
1907            parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1908        assert_eq!(fm.name, "custom_method");
1909        assert_eq!(fm.description, "A test description for the skill.");
1910    }
1911
1912    #[test]
1913    fn render_skill_template_substitutes_name_into_body_headings() {
1914        let body = render_skill_template("my_skill", "desc");
1915        assert!(body.contains("# `my_skill` methodology"));
1916        assert!(body.contains("## When `my_skill` is the wrong tool"));
1917    }
1918
1919    #[test]
1920    fn write_skill_template_writes_into_directory() {
1921        let dir = tempfile::tempdir().unwrap();
1922        let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1923        assert_eq!(dest, dir.path().join("alpha.md"));
1924        let content = fs::read_to_string(&dest).unwrap();
1925        assert!(content.contains("name: alpha"));
1926    }
1927
1928    #[test]
1929    fn write_skill_template_writes_to_explicit_md_path() {
1930        let dir = tempfile::tempdir().unwrap();
1931        let explicit = dir.path().join("renamed.md");
1932        let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1933        assert_eq!(dest, explicit);
1934        assert!(explicit.is_file());
1935    }
1936
1937    #[test]
1938    fn write_skill_template_creates_missing_parents() {
1939        let dir = tempfile::tempdir().unwrap();
1940        let nested = dir.path().join("a/b/c");
1941        let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1942        assert_eq!(dest, nested.join("alpha.md"));
1943        assert!(dest.is_file());
1944    }
1945
1946    #[test]
1947    fn write_skill_template_refuses_to_overwrite() {
1948        let dir = tempfile::tempdir().unwrap();
1949        let path = dir.path().join("alpha.md");
1950        fs::write(&path, "existing").unwrap();
1951        let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1952        assert!(matches!(err, SkillError::Io { .. }));
1953        // Original content preserved.
1954        assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1955    }
1956
1957    #[test]
1958    fn write_skill_template_round_trips_through_registry() {
1959        // End-to-end: write a template, build a registry that
1960        // auto-detects it as a project skill, confirm it resolves.
1961        let dir = tempfile::tempdir().unwrap();
1962        let yaml = dir.path().join("test_mcp.yaml");
1963        fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1964        let skills_dir = dir.path().join("test_mcp.skills");
1965        write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1966
1967        let registry = Registry::new()
1968            .auto_detect_project_layer(&yaml)
1969            .finalise()
1970            .unwrap();
1971        let skill = registry
1972            .get("custom_method")
1973            .expect("template should resolve");
1974        assert_eq!(skill.description(), "Project-layer skill body.");
1975    }
1976
1977    // ─── applies_when predicates (Phase 3) ────────────────────────
1978
1979    fn skill_with_applies_when(applies_when_yaml: &str) -> Skill {
1980        let body = format!(
1981            "---\nname: gated\ndescription: A gated skill.\n\
1982             applies_when:\n{applies_when_yaml}\n---\n\nBody.\n"
1983        );
1984        let (frontmatter, body) = parse_skill(&body, &PathBuf::from("gated.md")).unwrap();
1985        Skill {
1986            frontmatter,
1987            body,
1988            provenance: SkillProvenance::Bundled,
1989        }
1990    }
1991
1992    #[test]
1993    fn applies_when_parses_map_shape() {
1994        let skill = skill_with_applies_when(
1995            "  graph_has_node_type: [Function, Class]\n\
1996             \x20 tool_registered: cypher_query\n\
1997             \x20 extension_enabled: csv_http_server\n\
1998             \x20 graph_has_property:\n\
1999             \x20   node_type: Function\n\
2000             \x20   prop_name: module",
2001        );
2002        let applies = skill.frontmatter.applies_when.unwrap();
2003        assert_eq!(
2004            applies.graph_has_node_type.as_deref(),
2005            Some(["Function".to_string(), "Class".to_string()].as_slice())
2006        );
2007        assert_eq!(applies.tool_registered.as_deref(), Some("cypher_query"));
2008        assert_eq!(
2009            applies.extension_enabled.as_deref(),
2010            Some("csv_http_server")
2011        );
2012        assert_eq!(
2013            applies.graph_has_property,
2014            Some(GraphPropertyCheck {
2015                node_type: "Function".to_string(),
2016                prop_name: "module".to_string(),
2017            })
2018        );
2019    }
2020
2021    #[test]
2022    fn applies_when_absent_means_always_active() {
2023        let body = "---\nname: ungated\ndescription: An ungated skill.\n---\n\nBody.\n";
2024        let (frontmatter, body) = parse_skill(body, &PathBuf::from("ungated.md")).unwrap();
2025        let skill = Skill {
2026            frontmatter,
2027            body,
2028            provenance: SkillProvenance::Bundled,
2029        };
2030        let registry = ResolvedRegistry::default();
2031        let activation = registry.activation_for(
2032            &skill,
2033            &std::collections::HashSet::new(),
2034            &serde_json::Map::new(),
2035        );
2036        assert!(activation.active);
2037        assert!(activation.clauses.is_empty());
2038    }
2039
2040    #[test]
2041    fn tool_registered_predicate_dispatches_in_framework() {
2042        let skill = skill_with_applies_when("  tool_registered: cypher_query");
2043        let registry = ResolvedRegistry::default();
2044        let mut tools = std::collections::HashSet::new();
2045
2046        // Tool absent → unsatisfied.
2047        let inactive = registry.activation_for(&skill, &tools, &serde_json::Map::new());
2048        assert!(!inactive.active);
2049        assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
2050
2051        // Tool present → satisfied.
2052        tools.insert("cypher_query".to_string());
2053        let active = registry.activation_for(&skill, &tools, &serde_json::Map::new());
2054        assert!(active.active);
2055        assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
2056    }
2057
2058    #[test]
2059    fn extension_enabled_predicate_dispatches_in_framework() {
2060        let skill = skill_with_applies_when("  extension_enabled: csv_http_server");
2061        let registry = ResolvedRegistry::default();
2062        let tools = std::collections::HashSet::new();
2063        let mut extensions = serde_json::Map::new();
2064
2065        // Key absent → unsatisfied.
2066        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2067
2068        // Key with `false` → unsatisfied.
2069        extensions.insert("csv_http_server".to_string(), serde_json::json!(false));
2070        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2071
2072        // Key with `null` → unsatisfied.
2073        extensions.insert("csv_http_server".to_string(), serde_json::Value::Null);
2074        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2075
2076        // Key with truthy value → satisfied.
2077        extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
2078        assert!(registry.activation_for(&skill, &tools, &extensions).active);
2079
2080        // Key with a map → satisfied (truthy).
2081        extensions.insert(
2082            "csv_http_server".to_string(),
2083            serde_json::json!({"enabled": true}),
2084        );
2085        assert!(registry.activation_for(&skill, &tools, &extensions).active);
2086    }
2087
2088    struct StubEvaluator {
2089        has_function: bool,
2090    }
2091    impl SkillPredicateEvaluator for StubEvaluator {
2092        fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
2093            match clause {
2094                PredicateClause::GraphHasNodeType(types) => {
2095                    Some(types.iter().any(|t| t == "Function") && self.has_function)
2096                }
2097                _ => None,
2098            }
2099        }
2100    }
2101
2102    #[test]
2103    fn graph_predicate_dispatches_via_evaluator() {
2104        let skill = skill_with_applies_when("  graph_has_node_type: [Function, Class]");
2105
2106        // With evaluator that says yes → active.
2107        let registry = Registry::new()
2108            .with_predicate_evaluator(StubEvaluator { has_function: true })
2109            .finalise()
2110            .unwrap();
2111        let active = registry.activation_for(
2112            &skill,
2113            &std::collections::HashSet::new(),
2114            &serde_json::Map::new(),
2115        );
2116        assert!(active.active);
2117        assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
2118
2119        // With evaluator that says no → inactive.
2120        let registry = Registry::new()
2121            .with_predicate_evaluator(StubEvaluator {
2122                has_function: false,
2123            })
2124            .finalise()
2125            .unwrap();
2126        let inactive = registry.activation_for(
2127            &skill,
2128            &std::collections::HashSet::new(),
2129            &serde_json::Map::new(),
2130        );
2131        assert!(!inactive.active);
2132        assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
2133    }
2134
2135    #[test]
2136    fn graph_predicate_unknown_without_evaluator_means_inactive() {
2137        let skill = skill_with_applies_when("  graph_has_node_type: [Function]");
2138        let registry = ResolvedRegistry::default();
2139        let activation = registry.activation_for(
2140            &skill,
2141            &std::collections::HashSet::new(),
2142            &serde_json::Map::new(),
2143        );
2144        assert!(!activation.active);
2145        assert_eq!(activation.clauses[0].1, PredicateOutcome::Unknown);
2146    }
2147
2148    #[test]
2149    fn multiple_predicates_all_must_be_satisfied() {
2150        let skill = skill_with_applies_when(
2151            "  graph_has_node_type: [Function]\n\
2152             \x20 tool_registered: cypher_query",
2153        );
2154        let registry = Registry::new()
2155            .with_predicate_evaluator(StubEvaluator { has_function: true })
2156            .finalise()
2157            .unwrap();
2158        let mut tools = std::collections::HashSet::new();
2159        let extensions = serde_json::Map::new();
2160
2161        // Graph satisfied but tool absent → inactive.
2162        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2163
2164        // Both satisfied → active.
2165        tools.insert("cypher_query".to_string());
2166        assert!(registry.activation_for(&skill, &tools, &extensions).active);
2167    }
2168}