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`, `.harn/packages/**/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/// Callable the bridge adapter hands to [`HostSkillSource`] to
349/// enumerate skills via `skills/list`.
350pub type HostSkillLister = Arc<dyn Fn() -> Vec<SkillManifestRef> + Send + Sync>;
351
352/// Callable the bridge adapter hands to [`HostSkillSource`] to fetch
353/// one skill via `skills/fetch`.
354pub type HostSkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
355
356/// Bridge-backed skill source. Calls the `skills/list` / `skills/fetch`
357/// RPCs defined in `crates/harn-vm/src/bridge.rs` so a host can expose
358/// its own managed skill store to the VM.
359pub struct HostSkillSource {
360    loader: HostSkillLister,
361    fetcher: HostSkillFetcher,
362}
363
364impl HostSkillSource {
365    pub fn new<L, F>(loader: L, fetcher: F) -> Self
366    where
367        L: Fn() -> Vec<SkillManifestRef> + Send + Sync + 'static,
368        F: Fn(&str) -> Result<Skill, String> + Send + Sync + 'static,
369    {
370        Self {
371            loader: Arc::new(loader),
372            fetcher: Arc::new(fetcher),
373        }
374    }
375}
376
377impl std::fmt::Debug for HostSkillSource {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        f.debug_struct("HostSkillSource").finish_non_exhaustive()
380    }
381}
382
383impl SkillSource for HostSkillSource {
384    fn list(&self) -> Vec<SkillManifestRef> {
385        (self.loader)()
386    }
387
388    fn fetch(&self, id: &str) -> Result<Skill, String> {
389        (self.fetcher)(id)
390    }
391
392    fn layer(&self) -> Layer {
393        Layer::Host
394    }
395
396    fn describe(&self) -> String {
397        "host-provided [host]".to_string()
398    }
399}
400
401/// Convert a [`Skill`] into the `{_type: "skill_registry", skills: [...]}`
402/// dict form used by the existing skill_* VM builtins. Returns the entry
403/// dict only — callers assemble the outer registry.
404pub fn skill_entry_to_vm(skill: &Skill) -> crate::value::VmValue {
405    use crate::value::VmValue;
406
407    let mut entry: crate::value::DictMap = crate::value::DictMap::new();
408    entry.put_str("name", skill.manifest.name.as_str());
409    entry.put_str("short", skill.manifest.short.as_str());
410    entry.put_str(
411        "description",
412        if skill.manifest.description.is_empty() {
413            skill.manifest.short.as_str()
414        } else {
415            skill.manifest.description.as_str()
416        },
417    );
418    entry.put_opt_str("when_to_use", skill.manifest.when_to_use.as_deref());
419    if skill.manifest.disable_model_invocation {
420        entry.insert(
421            crate::value::intern_key("disable_model_invocation"),
422            VmValue::Bool(true),
423        );
424    }
425    if !skill.manifest.allowed_tools.is_empty() {
426        entry.insert(
427            crate::value::intern_key("allowed_tools"),
428            VmValue::List(std::sync::Arc::new(
429                skill
430                    .manifest
431                    .allowed_tools
432                    .iter()
433                    .map(|t| VmValue::String(arcstr::ArcStr::from(t.as_str())))
434                    .collect(),
435            )),
436        );
437    }
438    if skill.manifest.user_invocable {
439        entry.insert(
440            crate::value::intern_key("user_invocable"),
441            VmValue::Bool(true),
442        );
443    }
444    if !skill.manifest.paths.is_empty() {
445        entry.insert(
446            crate::value::intern_key("paths"),
447            VmValue::List(std::sync::Arc::new(
448                skill
449                    .manifest
450                    .paths
451                    .iter()
452                    .map(|p| VmValue::String(arcstr::ArcStr::from(p.as_str())))
453                    .collect(),
454            )),
455        );
456    }
457    entry.put_opt_str("context", skill.manifest.context.as_deref());
458    entry.put_opt_str("agent", skill.manifest.agent.as_deref());
459    if !skill.manifest.hooks.is_empty() {
460        let mut hooks: crate::value::DictMap = crate::value::DictMap::new();
461        for (k, v) in &skill.manifest.hooks {
462            hooks.insert(
463                crate::value::intern_key(k),
464                VmValue::String(arcstr::ArcStr::from(v.as_str())),
465            );
466        }
467        entry.insert(crate::value::intern_key("hooks"), VmValue::dict(hooks));
468    }
469    entry.put_opt_str("model", skill.manifest.model.as_deref());
470    entry.put_opt_str("effort", skill.manifest.effort.as_deref());
471    if skill.manifest.require_signature {
472        entry.insert(
473            crate::value::intern_key("require_signature"),
474            VmValue::Bool(true),
475        );
476    }
477    if !skill.manifest.trusted_signers.is_empty() {
478        entry.insert(
479            crate::value::intern_key("trusted_signers"),
480            VmValue::List(std::sync::Arc::new(
481                skill
482                    .manifest
483                    .trusted_signers
484                    .iter()
485                    .map(|fingerprint| VmValue::String(arcstr::ArcStr::from(fingerprint.as_str())))
486                    .collect(),
487            )),
488        );
489    }
490    entry.put_opt_str("shell", skill.manifest.shell.as_deref());
491    entry.put_opt_str("argument_hint", skill.manifest.argument_hint.as_deref());
492    entry.put_opt_str("targets", skill.manifest.targets.as_deref());
493    if !skill.manifest.mcp.is_empty() {
494        entry.insert(
495            crate::value::intern_key("mcp"),
496            VmValue::List(std::sync::Arc::new(
497                skill
498                    .manifest
499                    .mcp
500                    .iter()
501                    .map(crate::json_to_vm_value)
502                    .collect(),
503            )),
504        );
505    }
506    entry.put_str("body", skill.body.as_str());
507    if let Some(dir) = &skill.skill_dir {
508        entry.put_str("skill_dir", dir.display().to_string());
509    }
510    entry.put_str("source", skill.layer.label());
511    entry.put_opt_str("namespace", skill.namespace.as_deref());
512    VmValue::dict(entry)
513}
514
515pub fn skill_manifest_ref_to_vm(skill: &SkillManifestRef) -> crate::value::VmValue {
516    use crate::value::VmValue;
517
518    let mut entry: crate::value::DictMap = crate::value::DictMap::new();
519    entry.put_str("name", skill.manifest.name.as_str());
520    entry.put_str("short", skill.manifest.short.as_str());
521    entry.put_str(
522        "description",
523        if skill.manifest.description.is_empty() {
524            skill.manifest.short.as_str()
525        } else {
526            skill.manifest.description.as_str()
527        },
528    );
529    entry.put_opt_str("when_to_use", skill.manifest.when_to_use.as_deref());
530    if skill.manifest.disable_model_invocation {
531        entry.insert(
532            crate::value::intern_key("disable_model_invocation"),
533            VmValue::Bool(true),
534        );
535    }
536    if !skill.manifest.allowed_tools.is_empty() {
537        entry.insert(
538            crate::value::intern_key("allowed_tools"),
539            VmValue::List(std::sync::Arc::new(
540                skill
541                    .manifest
542                    .allowed_tools
543                    .iter()
544                    .map(|tool| VmValue::String(arcstr::ArcStr::from(tool.as_str())))
545                    .collect(),
546            )),
547        );
548    }
549    if skill.manifest.user_invocable {
550        entry.insert(
551            crate::value::intern_key("user_invocable"),
552            VmValue::Bool(true),
553        );
554    }
555    if !skill.manifest.paths.is_empty() {
556        entry.insert(
557            crate::value::intern_key("paths"),
558            VmValue::List(std::sync::Arc::new(
559                skill
560                    .manifest
561                    .paths
562                    .iter()
563                    .map(|path| VmValue::String(arcstr::ArcStr::from(path.as_str())))
564                    .collect(),
565            )),
566        );
567    }
568    entry.put_opt_str("context", skill.manifest.context.as_deref());
569    entry.put_opt_str("agent", skill.manifest.agent.as_deref());
570    if !skill.manifest.hooks.is_empty() {
571        let mut hooks: crate::value::DictMap = crate::value::DictMap::new();
572        for (key, value) in &skill.manifest.hooks {
573            hooks.insert(
574                crate::value::intern_key(key),
575                VmValue::String(arcstr::ArcStr::from(value.as_str())),
576            );
577        }
578        entry.insert(crate::value::intern_key("hooks"), VmValue::dict(hooks));
579    }
580    entry.put_opt_str("model", skill.manifest.model.as_deref());
581    entry.put_opt_str("effort", skill.manifest.effort.as_deref());
582    entry.put_opt_str("shell", skill.manifest.shell.as_deref());
583    entry.put_opt_str("argument_hint", skill.manifest.argument_hint.as_deref());
584    entry.put_opt_str("targets", skill.manifest.targets.as_deref());
585    if !skill.manifest.mcp.is_empty() {
586        entry.insert(
587            crate::value::intern_key("mcp"),
588            VmValue::List(std::sync::Arc::new(
589                skill
590                    .manifest
591                    .mcp
592                    .iter()
593                    .map(crate::json_to_vm_value)
594                    .collect(),
595            )),
596        );
597    }
598    entry.put_str("source", skill.layer.label());
599    entry.put_opt_str("namespace", skill.namespace.as_deref());
600    VmValue::dict(entry)
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use std::fs;
607
608    fn write(tmp: &Path, rel: &str, body: &str) {
609        let p = tmp.join(rel);
610        fs::create_dir_all(p.parent().unwrap()).unwrap();
611        fs::write(p, body).unwrap();
612    }
613
614    #[test]
615    fn fs_source_walks_one_level_deep() {
616        let tmp = tempfile::tempdir().unwrap();
617        write(
618            tmp.path(),
619            "deploy/SKILL.md",
620            "---\nname: deploy\nshort: deploy the service\ndescription: ship it\n---\nrun deploy",
621        );
622        write(
623            tmp.path(),
624            "review/SKILL.md",
625            "---\nname: review\nshort: review a pull request\n---\nbody",
626        );
627        write(tmp.path(), "not-a-skill.txt", "no");
628
629        let src = FsSkillSource::new(tmp.path(), Layer::Project);
630        let listed = src.list();
631        assert_eq!(listed.len(), 2);
632        let names: Vec<_> = listed.iter().map(|s| s.manifest.name.clone()).collect();
633        assert!(names.contains(&"deploy".to_string()));
634        assert!(names.contains(&"review".to_string()));
635
636        let skill = src.fetch("deploy").unwrap();
637        assert_eq!(skill.manifest.short, "deploy the service");
638        assert_eq!(skill.manifest.description, "ship it");
639        assert_eq!(skill.body, "run deploy");
640    }
641
642    #[test]
643    fn mcp_servers_surface_on_the_vm_entry() {
644        // A skill declaring MCP servers must expose them on its registry entry
645        // as `mcp` so the agent loop can mount them mid-conversation.
646        let tmp = tempfile::tempdir().unwrap();
647        write(
648            tmp.path(),
649            "weather/SKILL.md",
650            "---\nname: weather\nshort: Weather lookups\nmcp-servers:\n  - name: weather-mcp\n    command: node\n---\nbody",
651        );
652        let src = FsSkillSource::new(tmp.path(), Layer::Project);
653        let skill = src.fetch("weather").unwrap();
654        assert_eq!(skill.manifest.mcp.len(), 1);
655
656        let entry = skill_entry_to_vm(&skill);
657        let dict = entry.as_dict().expect("entry is a dict");
658        let crate::value::VmValue::List(servers) = dict.get("mcp").expect("entry carries mcp")
659        else {
660            panic!("mcp must be a list");
661        };
662        assert_eq!(servers.len(), 1);
663        let name = servers[0]
664            .as_dict()
665            .and_then(|d| d.get("name"))
666            .map(crate::value::VmValue::display);
667        assert_eq!(name.as_deref(), Some("weather-mcp"));
668    }
669
670    #[test]
671    fn fs_source_accepts_root_as_single_skill() {
672        let tmp = tempfile::tempdir().unwrap();
673        write(
674            tmp.path(),
675            "SKILL.md",
676            "---\nname: solo\nshort: single skill bundle\n---\n(body)",
677        );
678        let src = FsSkillSource::new(tmp.path(), Layer::Cli);
679        let listed = src.list();
680        assert_eq!(listed.len(), 1);
681        assert_eq!(listed[0].manifest.name, "solo");
682    }
683
684    #[test]
685    fn fs_source_defaults_name_to_directory() {
686        let tmp = tempfile::tempdir().unwrap();
687        write(
688            tmp.path(),
689            "nameless/SKILL.md",
690            "---\nshort: fallback to the directory name\n---\nbody only",
691        );
692        let src = FsSkillSource::new(tmp.path(), Layer::User);
693        let skill = src.fetch("nameless").unwrap();
694        assert_eq!(skill.manifest.name, "nameless");
695    }
696
697    #[test]
698    fn fs_source_namespace_prefixes_id() {
699        let tmp = tempfile::tempdir().unwrap();
700        write(
701            tmp.path(),
702            "deploy/SKILL.md",
703            "---\nname: deploy\nshort: deploy the service\n---\nbody",
704        );
705        let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
706        let listed = src.list();
707        assert_eq!(listed[0].id, "acme/ops/deploy");
708        let skill = src.fetch("acme/ops/deploy").unwrap();
709        assert_eq!(skill.id(), "acme/ops/deploy");
710    }
711
712    #[test]
713    fn fs_source_namespaced_fetch_requires_qualified_id() {
714        let tmp = tempfile::tempdir().unwrap();
715        write(
716            tmp.path(),
717            "deploy/SKILL.md",
718            "---\nname: deploy\nshort: deploy the service\n---\nbody",
719        );
720        let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
721
722        assert!(src.fetch("deploy").is_err());
723        assert!(src.fetch("other/deploy").is_err());
724        assert_eq!(
725            src.fetch("acme/ops/deploy").unwrap().id(),
726            "acme/ops/deploy"
727        );
728    }
729
730    #[test]
731    fn fs_source_missing_root_is_empty_not_error() {
732        let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
733        assert!(src.list().is_empty());
734        assert!(src.fetch("nope").is_err());
735    }
736
737    #[test]
738    fn fs_source_requires_short_card() {
739        let tmp = tempfile::tempdir().unwrap();
740        write(
741            tmp.path(),
742            "broken/SKILL.md",
743            "---\nname: broken\n---\nbody",
744        );
745        let src = FsSkillSource::new(tmp.path(), Layer::Project);
746        assert!(src.list().is_empty());
747        let err = src.fetch("broken").unwrap_err();
748        assert!(err.contains("`short`"), "{err}");
749    }
750
751    #[test]
752    fn host_source_wraps_closures() {
753        let host = HostSkillSource::new(
754            || {
755                vec![SkillManifestRef {
756                    id: "h1".into(),
757                    manifest: SkillManifest {
758                        name: "h1".into(),
759                        short: "host-provided skill".into(),
760                        ..Default::default()
761                    },
762                    layer: Layer::Host,
763                    namespace: None,
764                    origin: "host".into(),
765                    unknown_fields: Vec::new(),
766                }]
767            },
768            |id| {
769                Ok(Skill {
770                    manifest: SkillManifest {
771                        name: id.to_string(),
772                        short: "host-provided skill".into(),
773                        ..Default::default()
774                    },
775                    body: "host body".into(),
776                    skill_dir: None,
777                    layer: Layer::Host,
778                    namespace: None,
779                    unknown_fields: Vec::new(),
780                })
781            },
782        );
783        assert_eq!(host.list().len(), 1);
784        let s = host.fetch("h1").unwrap();
785        assert_eq!(s.body, "host body");
786        assert_eq!(s.layer, Layer::Host);
787    }
788
789    #[test]
790    fn layer_label_roundtrips() {
791        for layer in Layer::all() {
792            assert_eq!(Layer::from_label(layer.label()), Some(*layer));
793        }
794    }
795}