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    entry.put_str("body", skill.body.as_str());
494    if let Some(dir) = &skill.skill_dir {
495        entry.put_str("skill_dir", dir.display().to_string());
496    }
497    entry.put_str("source", skill.layer.label());
498    entry.put_opt_str("namespace", skill.namespace.as_deref());
499    VmValue::dict(entry)
500}
501
502pub fn skill_manifest_ref_to_vm(skill: &SkillManifestRef) -> crate::value::VmValue {
503    use crate::value::VmValue;
504
505    let mut entry: crate::value::DictMap = crate::value::DictMap::new();
506    entry.put_str("name", skill.manifest.name.as_str());
507    entry.put_str("short", skill.manifest.short.as_str());
508    entry.put_str(
509        "description",
510        if skill.manifest.description.is_empty() {
511            skill.manifest.short.as_str()
512        } else {
513            skill.manifest.description.as_str()
514        },
515    );
516    entry.put_opt_str("when_to_use", skill.manifest.when_to_use.as_deref());
517    if skill.manifest.disable_model_invocation {
518        entry.insert(
519            crate::value::intern_key("disable_model_invocation"),
520            VmValue::Bool(true),
521        );
522    }
523    if !skill.manifest.allowed_tools.is_empty() {
524        entry.insert(
525            crate::value::intern_key("allowed_tools"),
526            VmValue::List(std::sync::Arc::new(
527                skill
528                    .manifest
529                    .allowed_tools
530                    .iter()
531                    .map(|tool| VmValue::String(arcstr::ArcStr::from(tool.as_str())))
532                    .collect(),
533            )),
534        );
535    }
536    if skill.manifest.user_invocable {
537        entry.insert(
538            crate::value::intern_key("user_invocable"),
539            VmValue::Bool(true),
540        );
541    }
542    if !skill.manifest.paths.is_empty() {
543        entry.insert(
544            crate::value::intern_key("paths"),
545            VmValue::List(std::sync::Arc::new(
546                skill
547                    .manifest
548                    .paths
549                    .iter()
550                    .map(|path| VmValue::String(arcstr::ArcStr::from(path.as_str())))
551                    .collect(),
552            )),
553        );
554    }
555    entry.put_opt_str("context", skill.manifest.context.as_deref());
556    entry.put_opt_str("agent", skill.manifest.agent.as_deref());
557    if !skill.manifest.hooks.is_empty() {
558        let mut hooks: crate::value::DictMap = crate::value::DictMap::new();
559        for (key, value) in &skill.manifest.hooks {
560            hooks.insert(
561                crate::value::intern_key(key),
562                VmValue::String(arcstr::ArcStr::from(value.as_str())),
563            );
564        }
565        entry.insert(crate::value::intern_key("hooks"), VmValue::dict(hooks));
566    }
567    entry.put_opt_str("model", skill.manifest.model.as_deref());
568    entry.put_opt_str("effort", skill.manifest.effort.as_deref());
569    entry.put_opt_str("shell", skill.manifest.shell.as_deref());
570    entry.put_opt_str("argument_hint", skill.manifest.argument_hint.as_deref());
571    entry.put_opt_str("targets", skill.manifest.targets.as_deref());
572    entry.put_str("source", skill.layer.label());
573    entry.put_opt_str("namespace", skill.namespace.as_deref());
574    VmValue::dict(entry)
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use std::fs;
581
582    fn write(tmp: &Path, rel: &str, body: &str) {
583        let p = tmp.join(rel);
584        fs::create_dir_all(p.parent().unwrap()).unwrap();
585        fs::write(p, body).unwrap();
586    }
587
588    #[test]
589    fn fs_source_walks_one_level_deep() {
590        let tmp = tempfile::tempdir().unwrap();
591        write(
592            tmp.path(),
593            "deploy/SKILL.md",
594            "---\nname: deploy\nshort: deploy the service\ndescription: ship it\n---\nrun deploy",
595        );
596        write(
597            tmp.path(),
598            "review/SKILL.md",
599            "---\nname: review\nshort: review a pull request\n---\nbody",
600        );
601        write(tmp.path(), "not-a-skill.txt", "no");
602
603        let src = FsSkillSource::new(tmp.path(), Layer::Project);
604        let listed = src.list();
605        assert_eq!(listed.len(), 2);
606        let names: Vec<_> = listed.iter().map(|s| s.manifest.name.clone()).collect();
607        assert!(names.contains(&"deploy".to_string()));
608        assert!(names.contains(&"review".to_string()));
609
610        let skill = src.fetch("deploy").unwrap();
611        assert_eq!(skill.manifest.short, "deploy the service");
612        assert_eq!(skill.manifest.description, "ship it");
613        assert_eq!(skill.body, "run deploy");
614    }
615
616    #[test]
617    fn fs_source_accepts_root_as_single_skill() {
618        let tmp = tempfile::tempdir().unwrap();
619        write(
620            tmp.path(),
621            "SKILL.md",
622            "---\nname: solo\nshort: single skill bundle\n---\n(body)",
623        );
624        let src = FsSkillSource::new(tmp.path(), Layer::Cli);
625        let listed = src.list();
626        assert_eq!(listed.len(), 1);
627        assert_eq!(listed[0].manifest.name, "solo");
628    }
629
630    #[test]
631    fn fs_source_defaults_name_to_directory() {
632        let tmp = tempfile::tempdir().unwrap();
633        write(
634            tmp.path(),
635            "nameless/SKILL.md",
636            "---\nshort: fallback to the directory name\n---\nbody only",
637        );
638        let src = FsSkillSource::new(tmp.path(), Layer::User);
639        let skill = src.fetch("nameless").unwrap();
640        assert_eq!(skill.manifest.name, "nameless");
641    }
642
643    #[test]
644    fn fs_source_namespace_prefixes_id() {
645        let tmp = tempfile::tempdir().unwrap();
646        write(
647            tmp.path(),
648            "deploy/SKILL.md",
649            "---\nname: deploy\nshort: deploy the service\n---\nbody",
650        );
651        let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
652        let listed = src.list();
653        assert_eq!(listed[0].id, "acme/ops/deploy");
654        let skill = src.fetch("acme/ops/deploy").unwrap();
655        assert_eq!(skill.id(), "acme/ops/deploy");
656    }
657
658    #[test]
659    fn fs_source_namespaced_fetch_requires_qualified_id() {
660        let tmp = tempfile::tempdir().unwrap();
661        write(
662            tmp.path(),
663            "deploy/SKILL.md",
664            "---\nname: deploy\nshort: deploy the service\n---\nbody",
665        );
666        let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
667
668        assert!(src.fetch("deploy").is_err());
669        assert!(src.fetch("other/deploy").is_err());
670        assert_eq!(
671            src.fetch("acme/ops/deploy").unwrap().id(),
672            "acme/ops/deploy"
673        );
674    }
675
676    #[test]
677    fn fs_source_missing_root_is_empty_not_error() {
678        let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
679        assert!(src.list().is_empty());
680        assert!(src.fetch("nope").is_err());
681    }
682
683    #[test]
684    fn fs_source_requires_short_card() {
685        let tmp = tempfile::tempdir().unwrap();
686        write(
687            tmp.path(),
688            "broken/SKILL.md",
689            "---\nname: broken\n---\nbody",
690        );
691        let src = FsSkillSource::new(tmp.path(), Layer::Project);
692        assert!(src.list().is_empty());
693        let err = src.fetch("broken").unwrap_err();
694        assert!(err.contains("`short`"), "{err}");
695    }
696
697    #[test]
698    fn host_source_wraps_closures() {
699        let host = HostSkillSource::new(
700            || {
701                vec![SkillManifestRef {
702                    id: "h1".into(),
703                    manifest: SkillManifest {
704                        name: "h1".into(),
705                        short: "host-provided skill".into(),
706                        ..Default::default()
707                    },
708                    layer: Layer::Host,
709                    namespace: None,
710                    origin: "host".into(),
711                    unknown_fields: Vec::new(),
712                }]
713            },
714            |id| {
715                Ok(Skill {
716                    manifest: SkillManifest {
717                        name: id.to_string(),
718                        short: "host-provided skill".into(),
719                        ..Default::default()
720                    },
721                    body: "host body".into(),
722                    skill_dir: None,
723                    layer: Layer::Host,
724                    namespace: None,
725                    unknown_fields: Vec::new(),
726                })
727            },
728        );
729        assert_eq!(host.list().len(), 1);
730        let s = host.fetch("h1").unwrap();
731        assert_eq!(s.body, "host body");
732        assert_eq!(s.layer, Layer::Host);
733    }
734
735    #[test]
736    fn layer_label_roundtrips() {
737        for layer in Layer::all() {
738            assert_eq!(Layer::from_label(layer.label()), Some(*layer));
739        }
740    }
741}