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