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