Skip to main content

harn_vm/skills/
source.rs

1//! `SkillSource` trait + concrete filesystem / host implementations.
2//!
3//! A `SkillSource` is anything that can enumerate skills (metadata-only)
4//! and fetch a fully-populated [`Skill`] on demand. The layered
5//! discovery code ([`super::discovery::LayeredDiscovery`]) stacks
6//! multiple sources on top of each other — filesystem walks for
7//! `--skill-dir`, `$HARN_SKILLS_PATH`, `.harn/skills/`, `harn.toml`,
8//! `~/.harn/skills`, installed package-generation skills, `/etc/harn/skills`,
9//! `$XDG_CONFIG_HOME/harn/skills`, plus a host-backed source for
10//! bridge-mode runs. Each layer tags every manifest with the layer
11//! label so higher-priority layers can shadow lower ones cleanly and
12//! `harn doctor` can report where each skill came from.
13
14use crate::value::VmDictExt;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use super::frontmatter::{parse_frontmatter, split_frontmatter, SkillManifest};
20
21/// A single layer label. Top-level layer numbering matches the priority
22/// table in the spec: `Cli` (1) wins over `Env` (2) which wins over
23/// `Project` (3) and so on.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub enum Layer {
26    Cli,
27    Env,
28    Project,
29    Manifest,
30    User,
31    Package,
32    System,
33    Host,
34}
35
36impl Layer {
37    pub fn label(self) -> &'static str {
38        match self {
39            Layer::Cli => "cli",
40            Layer::Env => "env",
41            Layer::Project => "project",
42            Layer::Manifest => "manifest",
43            Layer::User => "user",
44            Layer::Package => "package",
45            Layer::System => "system",
46            Layer::Host => "host",
47        }
48    }
49
50    pub fn from_label(label: &str) -> Option<Layer> {
51        match label {
52            "cli" => Some(Layer::Cli),
53            "env" => Some(Layer::Env),
54            "project" => Some(Layer::Project),
55            "manifest" => Some(Layer::Manifest),
56            "user" => Some(Layer::User),
57            "package" => Some(Layer::Package),
58            "system" => Some(Layer::System),
59            "host" => Some(Layer::Host),
60            _ => None,
61        }
62    }
63
64    pub const fn all() -> &'static [Layer] {
65        &[
66            Layer::Cli,
67            Layer::Env,
68            Layer::Project,
69            Layer::Manifest,
70            Layer::User,
71            Layer::Package,
72            Layer::System,
73            Layer::Host,
74        ]
75    }
76}
77
78/// The fully loaded form of a skill: manifest + markdown body + context
79/// needed to substitute `${HARN_SKILL_DIR}` and surface diagnostics.
80#[derive(Debug, Clone)]
81pub struct Skill {
82    pub manifest: SkillManifest,
83    /// SKILL.md body after the closing frontmatter delimiter. Not yet
84    /// substituted — callers apply [`super::substitute::substitute_skill_body`]
85    /// at invocation time so per-run args / session ids can vary.
86    pub body: String,
87    /// Absolute directory the SKILL.md lives in. `None` for host-provided
88    /// skills where the host owns the underlying storage.
89    pub skill_dir: Option<PathBuf>,
90    /// Which layer produced this skill.
91    pub layer: Layer,
92    /// If set, points to the fully-qualified skill id (e.g. `acme/ops`).
93    pub namespace: Option<String>,
94    /// Field names found in the frontmatter but not recognized by the
95    /// current build. Displayed as warnings by `harn doctor`.
96    pub unknown_fields: Vec<String>,
97}
98
99impl Skill {
100    /// `"<namespace>/<name>"` when the skill has a namespace, otherwise
101    /// just `name`. This is the key layered discovery uses for collision
102    /// detection.
103    pub fn id(&self) -> String {
104        match &self.namespace {
105            Some(ns) if !ns.is_empty() => format!("{ns}/{}", self.manifest.name),
106            _ => self.manifest.name.clone(),
107        }
108    }
109}
110
111/// Abstract skill source. Implementations are [`Send`] so we can hand
112/// them to async code paths in the future; today everything is sync.
113pub trait SkillSource: Send + Sync {
114    /// Enumerate skills without loading bodies. Callers use this to
115    /// produce the shadowing table before paying to read every file.
116    fn list(&self) -> Vec<SkillManifestRef>;
117
118    /// Load a specific skill by id. Must be deterministic for the id
119    /// returned by `list()`.
120    fn fetch(&self, id: &str) -> Result<Skill, String>;
121
122    /// Layer this source represents. Used for shadowing + provenance.
123    fn layer(&self) -> Layer;
124
125    /// Human-readable label for diagnostics (e.g. the root directory).
126    fn describe(&self) -> String;
127}
128
129/// Light-weight handle returned by `list()` so callers can decide which
130/// layer wins before re-reading the SKILL.md.
131#[derive(Debug, Clone)]
132pub struct SkillManifestRef {
133    pub id: String,
134    pub manifest: SkillManifest,
135    pub layer: Layer,
136    pub namespace: Option<String>,
137    pub origin: String,
138    pub unknown_fields: Vec<String>,
139}
140
141const COMMAND_FRONTMATTER_FIELDS: &[&str] = &["hooks", "command", "run"];
142
143/// Remove frontmatter fields that downstream hosts may execute as
144/// commands when the registry entry carries failed provenance.
145pub fn strip_untrusted_command_frontmatter(entry: &mut crate::value::DictMap) -> bool {
146    if !has_failed_provenance(entry) {
147        return false;
148    }
149    let mut stripped = false;
150    for key in COMMAND_FRONTMATTER_FIELDS {
151        stripped |= entry.remove(*key).is_some();
152    }
153    stripped
154}
155
156fn has_failed_provenance(entry: &crate::value::DictMap) -> bool {
157    let Some(provenance) = entry
158        .get("provenance")
159        .and_then(crate::value::VmValue::as_dict)
160    else {
161        return false;
162    };
163    let signed = matches!(
164        provenance.get("signed"),
165        Some(crate::value::VmValue::Bool(true))
166    );
167    let trusted = matches!(
168        provenance.get("trusted"),
169        Some(crate::value::VmValue::Bool(true))
170    );
171    let verified_status = match provenance.get("status") {
172        Some(crate::value::VmValue::String(status)) => &**status == "verified",
173        Some(_) => false,
174        None => signed && trusted,
175    };
176    !(signed && trusted && verified_status)
177}
178
179/// Filesystem source — walks one root directory looking for
180/// `SKILL.md` files two levels deep (`<root>/<name>/SKILL.md`) or a
181/// single flat file (`<root>/SKILL.md` when `<root>` itself is the
182/// skill dir). The single-root shape keeps CLI `--skill-dir`
183/// behavior predictable; users who want multi-root share-pools layer
184/// them via the manifest `[skills] paths`.
185#[derive(Debug, Clone)]
186pub struct FsSkillSource {
187    pub root: PathBuf,
188    pub layer: Layer,
189    /// Optional namespace prefix. When set, every discovered skill is
190    /// registered as `<namespace>/<name>` and shadowing only happens on
191    /// the fully-qualified id. Powers the `[[skill.source]] name =
192    /// "acme/ops"` escape hatch for multi-tenant setups.
193    pub namespace: Option<String>,
194}
195
196impl FsSkillSource {
197    pub fn new(root: impl Into<PathBuf>, layer: Layer) -> Self {
198        Self {
199            root: root.into(),
200            layer,
201            namespace: None,
202        }
203    }
204
205    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
206        let ns = namespace.into();
207        self.namespace = if ns.is_empty() { None } else { Some(ns) };
208        self
209    }
210
211    fn iter_skill_dirs(&self) -> Vec<PathBuf> {
212        let mut results = Vec::new();
213        if !self.root.is_dir() {
214            return results;
215        }
216        // Accept `<root>/SKILL.md` as a single-skill bundle (unusual but
217        // convenient for `--skill-dir /path/to/one-skill`).
218        if self.root.join("SKILL.md").is_file() {
219            results.push(self.root.clone());
220            return results;
221        }
222        // Otherwise walk one level deep.
223        let Ok(entries) = fs::read_dir(&self.root) else {
224            return results;
225        };
226        for entry in entries.flatten() {
227            let path = entry.path();
228            if !path.is_dir() {
229                continue;
230            }
231            if path.join("SKILL.md").is_file() {
232                results.push(path);
233            }
234        }
235        results.sort();
236        results
237    }
238
239    fn finalize_manifest(
240        &self,
241        dir: &Path,
242        skill_file: &Path,
243        manifest: &mut SkillManifest,
244    ) -> Result<(), String> {
245        if manifest.name.is_empty() {
246            if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {
247                manifest.name = name.to_string();
248            }
249        }
250        if manifest.name.is_empty() {
251            return Err(format!(
252                "{}: SKILL.md has no `name` field and directory has no basename",
253                skill_file.display()
254            ));
255        }
256        if manifest.short.trim().is_empty() {
257            return Err(format!(
258                "{}: SKILL.md requires a non-empty `short` field",
259                skill_file.display()
260            ));
261        }
262        Ok(())
263    }
264
265    fn load_manifest_from_dir(&self, dir: &Path) -> Result<SkillManifestRef, String> {
266        let skill_file = dir.join("SKILL.md");
267        let source = fs::read_to_string(&skill_file)
268            .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
269        let (fm, _) = split_frontmatter(&source);
270        let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
271        let mut manifest = parsed.manifest;
272        self.finalize_manifest(dir, &skill_file, &mut manifest)?;
273        let id = match &self.namespace {
274            Some(ns) if !ns.is_empty() => format!("{ns}/{}", manifest.name),
275            _ => manifest.name.clone(),
276        };
277        Ok(SkillManifestRef {
278            id,
279            manifest,
280            layer: self.layer,
281            namespace: self.namespace.clone(),
282            origin: dir.display().to_string(),
283            unknown_fields: parsed.unknown_fields,
284        })
285    }
286
287    fn load_from_dir(&self, dir: &Path) -> Result<Skill, String> {
288        let skill_file = dir.join("SKILL.md");
289        let source = fs::read_to_string(&skill_file)
290            .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
291        let (fm, body) = split_frontmatter(&source);
292        let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
293        let mut manifest = parsed.manifest;
294        self.finalize_manifest(dir, &skill_file, &mut manifest)?;
295        let skill = Skill {
296            body: body.to_string(),
297            skill_dir: Some(dir.to_path_buf()),
298            layer: self.layer,
299            namespace: self.namespace.clone(),
300            unknown_fields: parsed.unknown_fields,
301            manifest,
302        };
303        Ok(skill)
304    }
305}
306
307impl SkillSource for FsSkillSource {
308    fn list(&self) -> Vec<SkillManifestRef> {
309        let mut out = Vec::new();
310        for dir in self.iter_skill_dirs() {
311            match self.load_manifest_from_dir(&dir) {
312                Ok(skill) => {
313                    out.push(skill);
314                }
315                Err(err) => {
316                    eprintln!("warning: skills: {err}");
317                }
318            }
319        }
320        out
321    }
322
323    fn fetch(&self, id: &str) -> Result<Skill, String> {
324        for dir in self.iter_skill_dirs() {
325            let skill = self.load_from_dir(&dir)?;
326            if skill.id() == id || (self.namespace.is_none() && skill.manifest.name == id) {
327                return Ok(skill);
328            }
329        }
330        Err(format!(
331            "skill '{id}' not found under {}",
332            self.root.display()
333        ))
334    }
335
336    fn layer(&self) -> Layer {
337        self.layer
338    }
339
340    fn describe(&self) -> String {
341        match &self.namespace {
342            Some(ns) => format!("{} [{}] ns={ns}", self.root.display(), self.layer.label()),
343            None => format!("{} [{}]", self.root.display(), self.layer.label()),
344        }
345    }
346}
347
348/// Parse and validate one skill bundle through the same boundary used by
349/// layered discovery. `path` may name the bundle directory or its SKILL.md.
350pub fn validate_skill_bundle(path: impl AsRef<Path>) -> Result<Skill, String> {
351    let candidate = path.as_ref();
352    let dir = if candidate.is_file() {
353        if candidate.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") {
354            return Err(format!(
355                "{} is a file; expected SKILL.md or a skill directory",
356                candidate.display(),
357            ));
358        }
359        candidate
360            .parent()
361            .ok_or_else(|| format!("{} has no containing skill directory", candidate.display()))?
362    } else {
363        candidate
364    };
365    if !dir.is_dir() {
366        return Err(format!("skill directory does not exist: {}", dir.display()));
367    }
368    FsSkillSource::new(dir, Layer::Cli).load_from_dir(dir)
369}
370
371/// Callable the bridge adapter hands to [`HostSkillSource`] to
372/// enumerate skills via `skills/list`.
373pub type HostSkillLister = Arc<dyn Fn() -> Vec<SkillManifestRef> + Send + Sync>;
374
375/// Callable the bridge adapter hands to [`HostSkillSource`] to fetch
376/// one skill via `skills/fetch`.
377pub type HostSkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
378
379/// Bridge-backed skill source. Calls the `skills/list` / `skills/fetch`
380/// RPCs defined in `crates/harn-vm/src/bridge.rs` so a host can expose
381/// its own managed skill store to the VM.
382pub struct HostSkillSource {
383    loader: HostSkillLister,
384    fetcher: HostSkillFetcher,
385}
386
387impl HostSkillSource {
388    pub fn new<L, F>(loader: L, fetcher: F) -> Self
389    where
390        L: Fn() -> Vec<SkillManifestRef> + Send + Sync + 'static,
391        F: Fn(&str) -> Result<Skill, String> + Send + Sync + 'static,
392    {
393        Self {
394            loader: Arc::new(loader),
395            fetcher: Arc::new(fetcher),
396        }
397    }
398}
399
400impl std::fmt::Debug for HostSkillSource {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        f.debug_struct("HostSkillSource").finish_non_exhaustive()
403    }
404}
405
406impl SkillSource for HostSkillSource {
407    fn list(&self) -> Vec<SkillManifestRef> {
408        (self.loader)()
409    }
410
411    fn fetch(&self, id: &str) -> Result<Skill, String> {
412        (self.fetcher)(id)
413    }
414
415    fn layer(&self) -> Layer {
416        Layer::Host
417    }
418
419    fn describe(&self) -> String {
420        "host-provided [host]".to_string()
421    }
422}
423
424/// Convert a [`Skill`] into the `{_type: "skill_registry", skills: [...]}`
425/// dict form used by the existing skill_* VM builtins. Returns the entry
426/// dict only — callers assemble the outer registry.
427pub fn skill_entry_to_vm(skill: &Skill) -> crate::value::VmValue {
428    use crate::value::VmValue;
429
430    let mut entry: crate::value::DictMap = crate::value::DictMap::new();
431    entry.put_str("name", skill.manifest.name.as_str());
432    entry.put_str("short", skill.manifest.short.as_str());
433    entry.put_str(
434        "description",
435        if skill.manifest.description.is_empty() {
436            skill.manifest.short.as_str()
437        } else {
438            skill.manifest.description.as_str()
439        },
440    );
441    entry.put_opt_str("when_to_use", skill.manifest.when_to_use.as_deref());
442    if skill.manifest.disable_model_invocation {
443        entry.insert(
444            crate::value::intern_key("disable_model_invocation"),
445            VmValue::Bool(true),
446        );
447    }
448    if !skill.manifest.allowed_tools.is_empty() {
449        entry.insert(
450            crate::value::intern_key("allowed_tools"),
451            VmValue::List(std::sync::Arc::new(
452                skill
453                    .manifest
454                    .allowed_tools
455                    .iter()
456                    .map(|t| VmValue::String(arcstr::ArcStr::from(t.as_str())))
457                    .collect(),
458            )),
459        );
460    }
461    if skill.manifest.user_invocable {
462        entry.insert(
463            crate::value::intern_key("user_invocable"),
464            VmValue::Bool(true),
465        );
466    }
467    if !skill.manifest.paths.is_empty() {
468        entry.insert(
469            crate::value::intern_key("paths"),
470            VmValue::List(std::sync::Arc::new(
471                skill
472                    .manifest
473                    .paths
474                    .iter()
475                    .map(|p| VmValue::String(arcstr::ArcStr::from(p.as_str())))
476                    .collect(),
477            )),
478        );
479    }
480    entry.put_opt_str("context", skill.manifest.context.as_deref());
481    entry.put_opt_str("agent", skill.manifest.agent.as_deref());
482    if !skill.manifest.hooks.is_empty() {
483        let mut hooks: crate::value::DictMap = crate::value::DictMap::new();
484        for (k, v) in &skill.manifest.hooks {
485            hooks.insert(
486                crate::value::intern_key(k),
487                VmValue::String(arcstr::ArcStr::from(v.as_str())),
488            );
489        }
490        entry.insert(crate::value::intern_key("hooks"), VmValue::dict(hooks));
491    }
492    entry.put_opt_str("model", skill.manifest.model.as_deref());
493    entry.put_opt_str("effort", skill.manifest.effort.as_deref());
494    if skill.manifest.require_signature {
495        entry.insert(
496            crate::value::intern_key("require_signature"),
497            VmValue::Bool(true),
498        );
499    }
500    if !skill.manifest.trusted_signers.is_empty() {
501        entry.insert(
502            crate::value::intern_key("trusted_signers"),
503            VmValue::List(std::sync::Arc::new(
504                skill
505                    .manifest
506                    .trusted_signers
507                    .iter()
508                    .map(|fingerprint| VmValue::String(arcstr::ArcStr::from(fingerprint.as_str())))
509                    .collect(),
510            )),
511        );
512    }
513    entry.put_opt_str("shell", skill.manifest.shell.as_deref());
514    entry.put_opt_str("argument_hint", skill.manifest.argument_hint.as_deref());
515    entry.put_opt_str("targets", skill.manifest.targets.as_deref());
516    if !skill.manifest.mcp.is_empty() {
517        entry.insert(
518            crate::value::intern_key("mcp"),
519            VmValue::List(std::sync::Arc::new(
520                skill
521                    .manifest
522                    .mcp
523                    .iter()
524                    .map(crate::json_to_vm_value)
525                    .collect(),
526            )),
527        );
528    }
529    entry.put_str("body", skill.body.as_str());
530    if let Some(dir) = &skill.skill_dir {
531        entry.put_str("skill_dir", dir.display().to_string());
532    }
533    entry.put_str("source", skill.layer.label());
534    entry.put_opt_str("namespace", skill.namespace.as_deref());
535    VmValue::dict(entry)
536}
537
538pub fn skill_manifest_ref_to_vm(skill: &SkillManifestRef) -> crate::value::VmValue {
539    use crate::value::VmValue;
540
541    let mut entry: crate::value::DictMap = crate::value::DictMap::new();
542    entry.put_str("name", skill.manifest.name.as_str());
543    entry.put_str("short", skill.manifest.short.as_str());
544    entry.put_str(
545        "description",
546        if skill.manifest.description.is_empty() {
547            skill.manifest.short.as_str()
548        } else {
549            skill.manifest.description.as_str()
550        },
551    );
552    entry.put_opt_str("when_to_use", skill.manifest.when_to_use.as_deref());
553    if skill.manifest.disable_model_invocation {
554        entry.insert(
555            crate::value::intern_key("disable_model_invocation"),
556            VmValue::Bool(true),
557        );
558    }
559    if !skill.manifest.allowed_tools.is_empty() {
560        entry.insert(
561            crate::value::intern_key("allowed_tools"),
562            VmValue::List(std::sync::Arc::new(
563                skill
564                    .manifest
565                    .allowed_tools
566                    .iter()
567                    .map(|tool| VmValue::String(arcstr::ArcStr::from(tool.as_str())))
568                    .collect(),
569            )),
570        );
571    }
572    if skill.manifest.user_invocable {
573        entry.insert(
574            crate::value::intern_key("user_invocable"),
575            VmValue::Bool(true),
576        );
577    }
578    if !skill.manifest.paths.is_empty() {
579        entry.insert(
580            crate::value::intern_key("paths"),
581            VmValue::List(std::sync::Arc::new(
582                skill
583                    .manifest
584                    .paths
585                    .iter()
586                    .map(|path| VmValue::String(arcstr::ArcStr::from(path.as_str())))
587                    .collect(),
588            )),
589        );
590    }
591    entry.put_opt_str("context", skill.manifest.context.as_deref());
592    entry.put_opt_str("agent", skill.manifest.agent.as_deref());
593    if !skill.manifest.hooks.is_empty() {
594        let mut hooks: crate::value::DictMap = crate::value::DictMap::new();
595        for (key, value) in &skill.manifest.hooks {
596            hooks.insert(
597                crate::value::intern_key(key),
598                VmValue::String(arcstr::ArcStr::from(value.as_str())),
599            );
600        }
601        entry.insert(crate::value::intern_key("hooks"), VmValue::dict(hooks));
602    }
603    entry.put_opt_str("model", skill.manifest.model.as_deref());
604    entry.put_opt_str("effort", skill.manifest.effort.as_deref());
605    entry.put_opt_str("shell", skill.manifest.shell.as_deref());
606    entry.put_opt_str("argument_hint", skill.manifest.argument_hint.as_deref());
607    entry.put_opt_str("targets", skill.manifest.targets.as_deref());
608    if !skill.manifest.mcp.is_empty() {
609        entry.insert(
610            crate::value::intern_key("mcp"),
611            VmValue::List(std::sync::Arc::new(
612                skill
613                    .manifest
614                    .mcp
615                    .iter()
616                    .map(crate::json_to_vm_value)
617                    .collect(),
618            )),
619        );
620    }
621    entry.put_str("source", skill.layer.label());
622    entry.put_opt_str("namespace", skill.namespace.as_deref());
623    VmValue::dict(entry)
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use std::fs;
630
631    fn write(tmp: &Path, rel: &str, body: &str) {
632        let p = tmp.join(rel);
633        fs::create_dir_all(p.parent().unwrap()).unwrap();
634        fs::write(p, body).unwrap();
635    }
636
637    #[test]
638    fn fs_source_walks_one_level_deep() {
639        let tmp = tempfile::tempdir().unwrap();
640        write(
641            tmp.path(),
642            "deploy/SKILL.md",
643            "---\nname: deploy\nshort: deploy the service\ndescription: ship it\n---\nrun deploy",
644        );
645        write(
646            tmp.path(),
647            "review/SKILL.md",
648            "---\nname: review\nshort: review a pull request\n---\nbody",
649        );
650        write(tmp.path(), "not-a-skill.txt", "no");
651
652        let src = FsSkillSource::new(tmp.path(), Layer::Project);
653        let listed = src.list();
654        assert_eq!(listed.len(), 2);
655        let names: Vec<_> = listed.iter().map(|s| s.manifest.name.clone()).collect();
656        assert!(names.contains(&"deploy".to_string()));
657        assert!(names.contains(&"review".to_string()));
658
659        let skill = src.fetch("deploy").unwrap();
660        assert_eq!(skill.manifest.short, "deploy the service");
661        assert_eq!(skill.manifest.description, "ship it");
662        assert_eq!(skill.body, "run deploy");
663    }
664
665    #[test]
666    fn mcp_servers_surface_on_the_vm_entry() {
667        // A skill declaring MCP servers must expose them on its registry entry
668        // as `mcp` so the agent loop can mount them mid-conversation.
669        let tmp = tempfile::tempdir().unwrap();
670        write(
671            tmp.path(),
672            "weather/SKILL.md",
673            "---\nname: weather\nshort: Weather lookups\nmcp-servers:\n  - name: weather-mcp\n    command: node\n---\nbody",
674        );
675        let src = FsSkillSource::new(tmp.path(), Layer::Project);
676        let skill = src.fetch("weather").unwrap();
677        assert_eq!(skill.manifest.mcp.len(), 1);
678
679        let entry = skill_entry_to_vm(&skill);
680        let dict = entry.as_dict().expect("entry is a dict");
681        let crate::value::VmValue::List(servers) = dict.get("mcp").expect("entry carries mcp")
682        else {
683            panic!("mcp must be a list");
684        };
685        assert_eq!(servers.len(), 1);
686        let name = servers[0]
687            .as_dict()
688            .and_then(|d| d.get("name"))
689            .map(crate::value::VmValue::display);
690        assert_eq!(name.as_deref(), Some("weather-mcp"));
691    }
692
693    #[test]
694    fn fs_source_accepts_root_as_single_skill() {
695        let tmp = tempfile::tempdir().unwrap();
696        write(
697            tmp.path(),
698            "SKILL.md",
699            "---\nname: solo\nshort: single skill bundle\n---\n(body)",
700        );
701        let src = FsSkillSource::new(tmp.path(), Layer::Cli);
702        let listed = src.list();
703        assert_eq!(listed.len(), 1);
704        assert_eq!(listed[0].manifest.name, "solo");
705    }
706
707    #[test]
708    fn fs_source_defaults_name_to_directory() {
709        let tmp = tempfile::tempdir().unwrap();
710        write(
711            tmp.path(),
712            "nameless/SKILL.md",
713            "---\nshort: fallback to the directory name\n---\nbody only",
714        );
715        let src = FsSkillSource::new(tmp.path(), Layer::User);
716        let skill = src.fetch("nameless").unwrap();
717        assert_eq!(skill.manifest.name, "nameless");
718    }
719
720    #[test]
721    fn fs_source_namespace_prefixes_id() {
722        let tmp = tempfile::tempdir().unwrap();
723        write(
724            tmp.path(),
725            "deploy/SKILL.md",
726            "---\nname: deploy\nshort: deploy the service\n---\nbody",
727        );
728        let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
729        let listed = src.list();
730        assert_eq!(listed[0].id, "acme/ops/deploy");
731        let skill = src.fetch("acme/ops/deploy").unwrap();
732        assert_eq!(skill.id(), "acme/ops/deploy");
733    }
734
735    #[test]
736    fn fs_source_namespaced_fetch_requires_qualified_id() {
737        let tmp = tempfile::tempdir().unwrap();
738        write(
739            tmp.path(),
740            "deploy/SKILL.md",
741            "---\nname: deploy\nshort: deploy the service\n---\nbody",
742        );
743        let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
744
745        assert!(src.fetch("deploy").is_err());
746        assert!(src.fetch("other/deploy").is_err());
747        assert_eq!(
748            src.fetch("acme/ops/deploy").unwrap().id(),
749            "acme/ops/deploy"
750        );
751    }
752
753    #[test]
754    fn fs_source_missing_root_is_empty_not_error() {
755        let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
756        assert!(src.list().is_empty());
757        assert!(src.fetch("nope").is_err());
758    }
759
760    #[test]
761    fn fs_source_requires_short_card() {
762        let tmp = tempfile::tempdir().unwrap();
763        write(
764            tmp.path(),
765            "broken/SKILL.md",
766            "---\nname: broken\n---\nbody",
767        );
768        let src = FsSkillSource::new(tmp.path(), Layer::Project);
769        assert!(src.list().is_empty());
770        let err = src.fetch("broken").unwrap_err();
771        assert!(err.contains("`short`"), "{err}");
772    }
773
774    #[test]
775    fn validate_skill_bundle_accepts_directory_or_manifest_path() {
776        let tmp = tempfile::tempdir().unwrap();
777        write(
778            tmp.path(),
779            "review/SKILL.md",
780            "---\nshort: Review developer docs\nfuture-field: retained\n---\nbody",
781        );
782        let dir = tmp.path().join("review");
783        let from_dir = validate_skill_bundle(&dir).expect("directory validates");
784        let from_file =
785            validate_skill_bundle(dir.join("SKILL.md")).expect("manifest path validates");
786        assert_eq!(from_dir.id(), "review");
787        assert_eq!(from_file.id(), "review");
788        assert_eq!(from_dir.unknown_fields, vec!["future-field"]);
789    }
790
791    #[test]
792    fn validate_skill_bundle_rejects_non_manifest_files() {
793        let tmp = tempfile::tempdir().unwrap();
794        write(tmp.path(), "notes.md", "not a skill");
795        let error = validate_skill_bundle(tmp.path().join("notes.md")).unwrap_err();
796        assert!(error.contains("expected SKILL.md"), "{error}");
797    }
798
799    #[test]
800    fn host_source_wraps_closures() {
801        let host = HostSkillSource::new(
802            || {
803                vec![SkillManifestRef {
804                    id: "h1".into(),
805                    manifest: SkillManifest {
806                        name: "h1".into(),
807                        short: "host-provided skill".into(),
808                        ..Default::default()
809                    },
810                    layer: Layer::Host,
811                    namespace: None,
812                    origin: "host".into(),
813                    unknown_fields: Vec::new(),
814                }]
815            },
816            |id| {
817                Ok(Skill {
818                    manifest: SkillManifest {
819                        name: id.to_string(),
820                        short: "host-provided skill".into(),
821                        ..Default::default()
822                    },
823                    body: "host body".into(),
824                    skill_dir: None,
825                    layer: Layer::Host,
826                    namespace: None,
827                    unknown_fields: Vec::new(),
828                })
829            },
830        );
831        assert_eq!(host.list().len(), 1);
832        let s = host.fetch("h1").unwrap();
833        assert_eq!(s.body, "host body");
834        assert_eq!(s.layer, Layer::Host);
835    }
836
837    #[test]
838    fn layer_label_roundtrips() {
839        for layer in Layer::all() {
840            assert_eq!(Layer::from_label(layer.label()), Some(*layer));
841        }
842    }
843}