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