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 std::collections::BTreeMap;
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
141/// Filesystem source — walks one root directory looking for
142/// `SKILL.md` files two levels deep (`<root>/<name>/SKILL.md`) or a
143/// single flat file (`<root>/SKILL.md` when `<root>` itself is the
144/// skill dir). The single-root shape keeps CLI `--skill-dir`
145/// behavior predictable; users who want multi-root share-pools layer
146/// them via the manifest `[skills] paths`.
147#[derive(Debug, Clone)]
148pub struct FsSkillSource {
149    pub root: PathBuf,
150    pub layer: Layer,
151    /// Optional namespace prefix. When set, every discovered skill is
152    /// registered as `<namespace>/<name>` and shadowing only happens on
153    /// the fully-qualified id. Powers the `[[skill.source]] name =
154    /// "acme/ops"` escape hatch for multi-tenant setups.
155    pub namespace: Option<String>,
156}
157
158impl FsSkillSource {
159    pub fn new(root: impl Into<PathBuf>, layer: Layer) -> Self {
160        Self {
161            root: root.into(),
162            layer,
163            namespace: None,
164        }
165    }
166
167    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
168        let ns = namespace.into();
169        self.namespace = if ns.is_empty() { None } else { Some(ns) };
170        self
171    }
172
173    fn iter_skill_dirs(&self) -> Vec<PathBuf> {
174        let mut results = Vec::new();
175        if !self.root.is_dir() {
176            return results;
177        }
178        // Accept `<root>/SKILL.md` as a single-skill bundle (unusual but
179        // convenient for `--skill-dir /path/to/one-skill`).
180        if self.root.join("SKILL.md").is_file() {
181            results.push(self.root.clone());
182            return results;
183        }
184        // Otherwise walk one level deep.
185        let Ok(entries) = fs::read_dir(&self.root) else {
186            return results;
187        };
188        for entry in entries.flatten() {
189            let path = entry.path();
190            if !path.is_dir() {
191                continue;
192            }
193            if path.join("SKILL.md").is_file() {
194                results.push(path);
195            }
196        }
197        results.sort();
198        results
199    }
200
201    fn finalize_manifest(
202        &self,
203        dir: &Path,
204        skill_file: &Path,
205        manifest: &mut SkillManifest,
206    ) -> Result<(), String> {
207        if manifest.name.is_empty() {
208            if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {
209                manifest.name = name.to_string();
210            }
211        }
212        if manifest.name.is_empty() {
213            return Err(format!(
214                "{}: SKILL.md has no `name` field and directory has no basename",
215                skill_file.display()
216            ));
217        }
218        if manifest.short.trim().is_empty() {
219            return Err(format!(
220                "{}: SKILL.md requires a non-empty `short` field",
221                skill_file.display()
222            ));
223        }
224        Ok(())
225    }
226
227    fn load_manifest_from_dir(&self, dir: &Path) -> Result<SkillManifestRef, String> {
228        let skill_file = dir.join("SKILL.md");
229        let source = fs::read_to_string(&skill_file)
230            .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
231        let (fm, _) = split_frontmatter(&source);
232        let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
233        let mut manifest = parsed.manifest;
234        self.finalize_manifest(dir, &skill_file, &mut manifest)?;
235        let id = match &self.namespace {
236            Some(ns) if !ns.is_empty() => format!("{ns}/{}", manifest.name),
237            _ => manifest.name.clone(),
238        };
239        Ok(SkillManifestRef {
240            id,
241            manifest,
242            layer: self.layer,
243            namespace: self.namespace.clone(),
244            origin: dir.display().to_string(),
245            unknown_fields: parsed.unknown_fields,
246        })
247    }
248
249    fn load_from_dir(&self, dir: &Path) -> Result<Skill, String> {
250        let skill_file = dir.join("SKILL.md");
251        let source = fs::read_to_string(&skill_file)
252            .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
253        let (fm, body) = split_frontmatter(&source);
254        let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
255        let mut manifest = parsed.manifest;
256        self.finalize_manifest(dir, &skill_file, &mut manifest)?;
257        let skill = Skill {
258            body: body.to_string(),
259            skill_dir: Some(dir.to_path_buf()),
260            layer: self.layer,
261            namespace: self.namespace.clone(),
262            unknown_fields: parsed.unknown_fields,
263            manifest,
264        };
265        Ok(skill)
266    }
267}
268
269impl SkillSource for FsSkillSource {
270    fn list(&self) -> Vec<SkillManifestRef> {
271        let mut out = Vec::new();
272        for dir in self.iter_skill_dirs() {
273            match self.load_manifest_from_dir(&dir) {
274                Ok(skill) => {
275                    out.push(skill);
276                }
277                Err(err) => {
278                    eprintln!("warning: skills: {err}");
279                }
280            }
281        }
282        out
283    }
284
285    fn fetch(&self, id: &str) -> Result<Skill, String> {
286        let target_name = match id.rsplit_once('/') {
287            Some((_, n)) => n,
288            None => id,
289        };
290        for dir in self.iter_skill_dirs() {
291            let skill = self.load_from_dir(&dir)?;
292            if skill.id() == id || skill.manifest.name == target_name {
293                return Ok(skill);
294            }
295        }
296        Err(format!(
297            "skill '{id}' not found under {}",
298            self.root.display()
299        ))
300    }
301
302    fn layer(&self) -> Layer {
303        self.layer
304    }
305
306    fn describe(&self) -> String {
307        match &self.namespace {
308            Some(ns) => format!("{} [{}] ns={ns}", self.root.display(), self.layer.label()),
309            None => format!("{} [{}]", self.root.display(), self.layer.label()),
310        }
311    }
312}
313
314/// Callable the bridge adapter hands to [`HostSkillSource`] to
315/// enumerate skills via `skills/list`.
316pub type HostSkillLister = Arc<dyn Fn() -> Vec<SkillManifestRef> + Send + Sync>;
317
318/// Callable the bridge adapter hands to [`HostSkillSource`] to fetch
319/// one skill via `skills/fetch`.
320pub type HostSkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
321
322/// Bridge-backed skill source. Calls the `skills/list` / `skills/fetch`
323/// RPCs defined in `crates/harn-vm/src/bridge.rs` so a host can expose
324/// its own managed skill store to the VM.
325pub struct HostSkillSource {
326    loader: HostSkillLister,
327    fetcher: HostSkillFetcher,
328}
329
330impl HostSkillSource {
331    pub fn new<L, F>(loader: L, fetcher: F) -> Self
332    where
333        L: Fn() -> Vec<SkillManifestRef> + Send + Sync + 'static,
334        F: Fn(&str) -> Result<Skill, String> + Send + Sync + 'static,
335    {
336        Self {
337            loader: Arc::new(loader),
338            fetcher: Arc::new(fetcher),
339        }
340    }
341}
342
343impl std::fmt::Debug for HostSkillSource {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        f.debug_struct("HostSkillSource").finish_non_exhaustive()
346    }
347}
348
349impl SkillSource for HostSkillSource {
350    fn list(&self) -> Vec<SkillManifestRef> {
351        (self.loader)()
352    }
353
354    fn fetch(&self, id: &str) -> Result<Skill, String> {
355        (self.fetcher)(id)
356    }
357
358    fn layer(&self) -> Layer {
359        Layer::Host
360    }
361
362    fn describe(&self) -> String {
363        "host-provided [host]".to_string()
364    }
365}
366
367/// Convert a [`Skill`] into the `{_type: "skill_registry", skills: [...]}`
368/// dict form used by the existing skill_* VM builtins. Returns the entry
369/// dict only — callers assemble the outer registry.
370pub fn skill_entry_to_vm(skill: &Skill) -> crate::value::VmValue {
371    use crate::value::VmValue;
372    use std::rc::Rc;
373
374    let mut entry: BTreeMap<String, VmValue> = BTreeMap::new();
375    entry.insert(
376        "name".to_string(),
377        VmValue::String(Rc::from(skill.manifest.name.as_str())),
378    );
379    entry.insert(
380        "short".to_string(),
381        VmValue::String(Rc::from(skill.manifest.short.as_str())),
382    );
383    entry.insert(
384        "description".to_string(),
385        VmValue::String(Rc::from(if skill.manifest.description.is_empty() {
386            skill.manifest.short.as_str()
387        } else {
388            skill.manifest.description.as_str()
389        })),
390    );
391    if let Some(when) = &skill.manifest.when_to_use {
392        entry.insert(
393            "when_to_use".to_string(),
394            VmValue::String(Rc::from(when.as_str())),
395        );
396    }
397    if skill.manifest.disable_model_invocation {
398        entry.insert("disable_model_invocation".to_string(), VmValue::Bool(true));
399    }
400    if !skill.manifest.allowed_tools.is_empty() {
401        entry.insert(
402            "allowed_tools".to_string(),
403            VmValue::List(Rc::new(
404                skill
405                    .manifest
406                    .allowed_tools
407                    .iter()
408                    .map(|t| VmValue::String(Rc::from(t.as_str())))
409                    .collect(),
410            )),
411        );
412    }
413    if skill.manifest.user_invocable {
414        entry.insert("user_invocable".to_string(), VmValue::Bool(true));
415    }
416    if !skill.manifest.paths.is_empty() {
417        entry.insert(
418            "paths".to_string(),
419            VmValue::List(Rc::new(
420                skill
421                    .manifest
422                    .paths
423                    .iter()
424                    .map(|p| VmValue::String(Rc::from(p.as_str())))
425                    .collect(),
426            )),
427        );
428    }
429    if let Some(context) = &skill.manifest.context {
430        entry.insert(
431            "context".to_string(),
432            VmValue::String(Rc::from(context.as_str())),
433        );
434    }
435    if let Some(agent) = &skill.manifest.agent {
436        entry.insert(
437            "agent".to_string(),
438            VmValue::String(Rc::from(agent.as_str())),
439        );
440    }
441    if !skill.manifest.hooks.is_empty() {
442        let mut hooks: BTreeMap<String, VmValue> = BTreeMap::new();
443        for (k, v) in &skill.manifest.hooks {
444            hooks.insert(k.clone(), VmValue::String(Rc::from(v.as_str())));
445        }
446        entry.insert("hooks".to_string(), VmValue::Dict(Rc::new(hooks)));
447    }
448    if let Some(model) = &skill.manifest.model {
449        entry.insert(
450            "model".to_string(),
451            VmValue::String(Rc::from(model.as_str())),
452        );
453    }
454    if let Some(effort) = &skill.manifest.effort {
455        entry.insert(
456            "effort".to_string(),
457            VmValue::String(Rc::from(effort.as_str())),
458        );
459    }
460    if skill.manifest.require_signature {
461        entry.insert("require_signature".to_string(), VmValue::Bool(true));
462    }
463    if !skill.manifest.trusted_signers.is_empty() {
464        entry.insert(
465            "trusted_signers".to_string(),
466            VmValue::List(Rc::new(
467                skill
468                    .manifest
469                    .trusted_signers
470                    .iter()
471                    .map(|fingerprint| VmValue::String(Rc::from(fingerprint.as_str())))
472                    .collect(),
473            )),
474        );
475    }
476    if let Some(shell) = &skill.manifest.shell {
477        entry.insert(
478            "shell".to_string(),
479            VmValue::String(Rc::from(shell.as_str())),
480        );
481    }
482    if let Some(hint) = &skill.manifest.argument_hint {
483        entry.insert(
484            "argument_hint".to_string(),
485            VmValue::String(Rc::from(hint.as_str())),
486        );
487    }
488    entry.insert(
489        "body".to_string(),
490        VmValue::String(Rc::from(skill.body.as_str())),
491    );
492    if let Some(dir) = &skill.skill_dir {
493        entry.insert(
494            "skill_dir".to_string(),
495            VmValue::String(Rc::from(dir.display().to_string().as_str())),
496        );
497    }
498    entry.insert(
499        "source".to_string(),
500        VmValue::String(Rc::from(skill.layer.label())),
501    );
502    if let Some(ns) = &skill.namespace {
503        entry.insert(
504            "namespace".to_string(),
505            VmValue::String(Rc::from(ns.as_str())),
506        );
507    }
508    VmValue::Dict(Rc::new(entry))
509}
510
511pub fn skill_manifest_ref_to_vm(skill: &SkillManifestRef) -> crate::value::VmValue {
512    use crate::value::VmValue;
513    use std::rc::Rc;
514
515    let mut entry: BTreeMap<String, VmValue> = BTreeMap::new();
516    entry.insert(
517        "name".to_string(),
518        VmValue::String(Rc::from(skill.manifest.name.as_str())),
519    );
520    entry.insert(
521        "short".to_string(),
522        VmValue::String(Rc::from(skill.manifest.short.as_str())),
523    );
524    entry.insert(
525        "description".to_string(),
526        VmValue::String(Rc::from(if skill.manifest.description.is_empty() {
527            skill.manifest.short.as_str()
528        } else {
529            skill.manifest.description.as_str()
530        })),
531    );
532    if let Some(when) = &skill.manifest.when_to_use {
533        entry.insert(
534            "when_to_use".to_string(),
535            VmValue::String(Rc::from(when.as_str())),
536        );
537    }
538    if skill.manifest.disable_model_invocation {
539        entry.insert("disable_model_invocation".to_string(), VmValue::Bool(true));
540    }
541    if !skill.manifest.allowed_tools.is_empty() {
542        entry.insert(
543            "allowed_tools".to_string(),
544            VmValue::List(Rc::new(
545                skill
546                    .manifest
547                    .allowed_tools
548                    .iter()
549                    .map(|tool| VmValue::String(Rc::from(tool.as_str())))
550                    .collect(),
551            )),
552        );
553    }
554    if skill.manifest.user_invocable {
555        entry.insert("user_invocable".to_string(), VmValue::Bool(true));
556    }
557    if !skill.manifest.paths.is_empty() {
558        entry.insert(
559            "paths".to_string(),
560            VmValue::List(Rc::new(
561                skill
562                    .manifest
563                    .paths
564                    .iter()
565                    .map(|path| VmValue::String(Rc::from(path.as_str())))
566                    .collect(),
567            )),
568        );
569    }
570    if let Some(context) = &skill.manifest.context {
571        entry.insert(
572            "context".to_string(),
573            VmValue::String(Rc::from(context.as_str())),
574        );
575    }
576    if let Some(agent) = &skill.manifest.agent {
577        entry.insert(
578            "agent".to_string(),
579            VmValue::String(Rc::from(agent.as_str())),
580        );
581    }
582    if !skill.manifest.hooks.is_empty() {
583        let mut hooks: BTreeMap<String, VmValue> = BTreeMap::new();
584        for (key, value) in &skill.manifest.hooks {
585            hooks.insert(key.clone(), VmValue::String(Rc::from(value.as_str())));
586        }
587        entry.insert("hooks".to_string(), VmValue::Dict(Rc::new(hooks)));
588    }
589    if let Some(model) = &skill.manifest.model {
590        entry.insert(
591            "model".to_string(),
592            VmValue::String(Rc::from(model.as_str())),
593        );
594    }
595    if let Some(effort) = &skill.manifest.effort {
596        entry.insert(
597            "effort".to_string(),
598            VmValue::String(Rc::from(effort.as_str())),
599        );
600    }
601    if let Some(shell) = &skill.manifest.shell {
602        entry.insert(
603            "shell".to_string(),
604            VmValue::String(Rc::from(shell.as_str())),
605        );
606    }
607    if let Some(hint) = &skill.manifest.argument_hint {
608        entry.insert(
609            "argument_hint".to_string(),
610            VmValue::String(Rc::from(hint.as_str())),
611        );
612    }
613    entry.insert(
614        "source".to_string(),
615        VmValue::String(Rc::from(skill.layer.label())),
616    );
617    if let Some(ns) = &skill.namespace {
618        entry.insert(
619            "namespace".to_string(),
620            VmValue::String(Rc::from(ns.as_str())),
621        );
622    }
623    VmValue::Dict(Rc::new(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 fs_source_accepts_root_as_single_skill() {
667        let tmp = tempfile::tempdir().unwrap();
668        write(
669            tmp.path(),
670            "SKILL.md",
671            "---\nname: solo\nshort: single skill bundle\n---\n(body)",
672        );
673        let src = FsSkillSource::new(tmp.path(), Layer::Cli);
674        let listed = src.list();
675        assert_eq!(listed.len(), 1);
676        assert_eq!(listed[0].manifest.name, "solo");
677    }
678
679    #[test]
680    fn fs_source_defaults_name_to_directory() {
681        let tmp = tempfile::tempdir().unwrap();
682        write(
683            tmp.path(),
684            "nameless/SKILL.md",
685            "---\nshort: fallback to the directory name\n---\nbody only",
686        );
687        let src = FsSkillSource::new(tmp.path(), Layer::User);
688        let skill = src.fetch("nameless").unwrap();
689        assert_eq!(skill.manifest.name, "nameless");
690    }
691
692    #[test]
693    fn fs_source_namespace_prefixes_id() {
694        let tmp = tempfile::tempdir().unwrap();
695        write(
696            tmp.path(),
697            "deploy/SKILL.md",
698            "---\nname: deploy\nshort: deploy the service\n---\nbody",
699        );
700        let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
701        let listed = src.list();
702        assert_eq!(listed[0].id, "acme/ops/deploy");
703        let skill = src.fetch("acme/ops/deploy").unwrap();
704        assert_eq!(skill.id(), "acme/ops/deploy");
705    }
706
707    #[test]
708    fn fs_source_missing_root_is_empty_not_error() {
709        let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
710        assert!(src.list().is_empty());
711        assert!(src.fetch("nope").is_err());
712    }
713
714    #[test]
715    fn fs_source_requires_short_card() {
716        let tmp = tempfile::tempdir().unwrap();
717        write(
718            tmp.path(),
719            "broken/SKILL.md",
720            "---\nname: broken\n---\nbody",
721        );
722        let src = FsSkillSource::new(tmp.path(), Layer::Project);
723        assert!(src.list().is_empty());
724        let err = src.fetch("broken").unwrap_err();
725        assert!(err.contains("`short`"), "{err}");
726    }
727
728    #[test]
729    fn host_source_wraps_closures() {
730        let host = HostSkillSource::new(
731            || {
732                vec![SkillManifestRef {
733                    id: "h1".into(),
734                    manifest: SkillManifest {
735                        name: "h1".into(),
736                        short: "host-provided skill".into(),
737                        ..Default::default()
738                    },
739                    layer: Layer::Host,
740                    namespace: None,
741                    origin: "host".into(),
742                    unknown_fields: Vec::new(),
743                }]
744            },
745            |id| {
746                Ok(Skill {
747                    manifest: SkillManifest {
748                        name: id.to_string(),
749                        short: "host-provided skill".into(),
750                        ..Default::default()
751                    },
752                    body: "host body".into(),
753                    skill_dir: None,
754                    layer: Layer::Host,
755                    namespace: None,
756                    unknown_fields: Vec::new(),
757                })
758            },
759        );
760        assert_eq!(host.list().len(), 1);
761        let s = host.fetch("h1").unwrap();
762        assert_eq!(s.body, "host body");
763        assert_eq!(s.layer, Layer::Host);
764    }
765
766    #[test]
767    fn layer_label_roundtrips() {
768        for layer in Layer::all() {
769            assert_eq!(Layer::from_label(layer.label()), Some(*layer));
770        }
771    }
772}