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}
139
140/// Filesystem source — walks one root directory looking for
141/// `SKILL.md` files two levels deep (`<root>/<name>/SKILL.md`) or a
142/// single flat file (`<root>/SKILL.md` when `<root>` itself is the
143/// skill dir). The single-root shape keeps CLI `--skill-dir`
144/// behavior predictable; users who want multi-root share-pools layer
145/// them via the manifest `[skills] paths`.
146#[derive(Debug, Clone)]
147pub struct FsSkillSource {
148    pub root: PathBuf,
149    pub layer: Layer,
150    /// Optional namespace prefix. When set, every discovered skill is
151    /// registered as `<namespace>/<name>` and shadowing only happens on
152    /// the fully-qualified id. Powers the `[[skill.source]] name =
153    /// "acme/ops"` escape hatch for multi-tenant setups.
154    pub namespace: Option<String>,
155}
156
157impl FsSkillSource {
158    pub fn new(root: impl Into<PathBuf>, layer: Layer) -> Self {
159        Self {
160            root: root.into(),
161            layer,
162            namespace: None,
163        }
164    }
165
166    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
167        let ns = namespace.into();
168        self.namespace = if ns.is_empty() { None } else { Some(ns) };
169        self
170    }
171
172    fn iter_skill_dirs(&self) -> Vec<PathBuf> {
173        let mut results = Vec::new();
174        if !self.root.is_dir() {
175            return results;
176        }
177        // Accept `<root>/SKILL.md` as a single-skill bundle (unusual but
178        // convenient for `--skill-dir /path/to/one-skill`).
179        if self.root.join("SKILL.md").is_file() {
180            results.push(self.root.clone());
181            return results;
182        }
183        // Otherwise walk one level deep.
184        let Ok(entries) = fs::read_dir(&self.root) else {
185            return results;
186        };
187        for entry in entries.flatten() {
188            let path = entry.path();
189            if !path.is_dir() {
190                continue;
191            }
192            if path.join("SKILL.md").is_file() {
193                results.push(path);
194            }
195        }
196        results.sort();
197        results
198    }
199
200    fn load_from_dir(&self, dir: &Path) -> Result<Skill, String> {
201        let skill_file = dir.join("SKILL.md");
202        let source = fs::read_to_string(&skill_file)
203            .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
204        let (fm, body) = split_frontmatter(&source);
205        let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
206        let mut manifest = parsed.manifest;
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        let skill = Skill {
213            body: body.to_string(),
214            skill_dir: Some(dir.to_path_buf()),
215            layer: self.layer,
216            namespace: self.namespace.clone(),
217            unknown_fields: parsed.unknown_fields,
218            manifest,
219        };
220        if skill.manifest.name.is_empty() {
221            return Err(format!(
222                "{}: SKILL.md has no `name` field and directory has no basename",
223                skill_file.display()
224            ));
225        }
226        Ok(skill)
227    }
228}
229
230impl SkillSource for FsSkillSource {
231    fn list(&self) -> Vec<SkillManifestRef> {
232        let mut out = Vec::new();
233        for dir in self.iter_skill_dirs() {
234            match self.load_from_dir(&dir) {
235                Ok(skill) => {
236                    let id = skill.id();
237                    out.push(SkillManifestRef {
238                        id,
239                        manifest: skill.manifest,
240                        layer: skill.layer,
241                        namespace: skill.namespace,
242                        origin: dir.display().to_string(),
243                    });
244                }
245                Err(err) => {
246                    eprintln!("warning: skills: {err}");
247                }
248            }
249        }
250        out
251    }
252
253    fn fetch(&self, id: &str) -> Result<Skill, String> {
254        let target_name = match id.rsplit_once('/') {
255            Some((_, n)) => n,
256            None => id,
257        };
258        for dir in self.iter_skill_dirs() {
259            let skill = self.load_from_dir(&dir)?;
260            if skill.id() == id || skill.manifest.name == target_name {
261                return Ok(skill);
262            }
263        }
264        Err(format!(
265            "skill '{id}' not found under {}",
266            self.root.display()
267        ))
268    }
269
270    fn layer(&self) -> Layer {
271        self.layer
272    }
273
274    fn describe(&self) -> String {
275        match &self.namespace {
276            Some(ns) => format!("{} [{}] ns={ns}", self.root.display(), self.layer.label()),
277            None => format!("{} [{}]", self.root.display(), self.layer.label()),
278        }
279    }
280}
281
282/// Callable the bridge adapter hands to [`HostSkillSource`] to
283/// enumerate skills via `skills/list`.
284pub type HostSkillLister = Arc<dyn Fn() -> Vec<SkillManifestRef> + Send + Sync>;
285
286/// Callable the bridge adapter hands to [`HostSkillSource`] to fetch
287/// one skill via `skills/fetch`.
288pub type HostSkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
289
290/// Bridge-backed skill source. Calls the `skills/list` / `skills/fetch`
291/// RPCs defined in `crates/harn-vm/src/bridge.rs` so a host can expose
292/// its own managed skill store to the VM.
293pub struct HostSkillSource {
294    loader: HostSkillLister,
295    fetcher: HostSkillFetcher,
296}
297
298impl HostSkillSource {
299    pub fn new<L, F>(loader: L, fetcher: F) -> Self
300    where
301        L: Fn() -> Vec<SkillManifestRef> + Send + Sync + 'static,
302        F: Fn(&str) -> Result<Skill, String> + Send + Sync + 'static,
303    {
304        Self {
305            loader: Arc::new(loader),
306            fetcher: Arc::new(fetcher),
307        }
308    }
309}
310
311impl std::fmt::Debug for HostSkillSource {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        f.debug_struct("HostSkillSource").finish_non_exhaustive()
314    }
315}
316
317impl SkillSource for HostSkillSource {
318    fn list(&self) -> Vec<SkillManifestRef> {
319        (self.loader)()
320    }
321
322    fn fetch(&self, id: &str) -> Result<Skill, String> {
323        (self.fetcher)(id)
324    }
325
326    fn layer(&self) -> Layer {
327        Layer::Host
328    }
329
330    fn describe(&self) -> String {
331        "host-provided [host]".to_string()
332    }
333}
334
335/// Convert a [`Skill`] into the `{_type: "skill_registry", skills: [...]}`
336/// dict form used by the existing skill_* VM builtins. Returns the entry
337/// dict only — callers assemble the outer registry.
338pub fn skill_entry_to_vm(skill: &Skill) -> crate::value::VmValue {
339    use crate::value::VmValue;
340    use std::rc::Rc;
341
342    let mut entry: BTreeMap<String, VmValue> = BTreeMap::new();
343    entry.insert(
344        "name".to_string(),
345        VmValue::String(Rc::from(skill.manifest.name.as_str())),
346    );
347    entry.insert(
348        "description".to_string(),
349        VmValue::String(Rc::from(skill.manifest.description.as_str())),
350    );
351    if let Some(when) = &skill.manifest.when_to_use {
352        entry.insert(
353            "when_to_use".to_string(),
354            VmValue::String(Rc::from(when.as_str())),
355        );
356    }
357    if skill.manifest.disable_model_invocation {
358        entry.insert("disable_model_invocation".to_string(), VmValue::Bool(true));
359    }
360    if !skill.manifest.allowed_tools.is_empty() {
361        entry.insert(
362            "allowed_tools".to_string(),
363            VmValue::List(Rc::new(
364                skill
365                    .manifest
366                    .allowed_tools
367                    .iter()
368                    .map(|t| VmValue::String(Rc::from(t.as_str())))
369                    .collect(),
370            )),
371        );
372    }
373    if skill.manifest.user_invocable {
374        entry.insert("user_invocable".to_string(), VmValue::Bool(true));
375    }
376    if !skill.manifest.paths.is_empty() {
377        entry.insert(
378            "paths".to_string(),
379            VmValue::List(Rc::new(
380                skill
381                    .manifest
382                    .paths
383                    .iter()
384                    .map(|p| VmValue::String(Rc::from(p.as_str())))
385                    .collect(),
386            )),
387        );
388    }
389    if let Some(context) = &skill.manifest.context {
390        entry.insert(
391            "context".to_string(),
392            VmValue::String(Rc::from(context.as_str())),
393        );
394    }
395    if let Some(agent) = &skill.manifest.agent {
396        entry.insert(
397            "agent".to_string(),
398            VmValue::String(Rc::from(agent.as_str())),
399        );
400    }
401    if !skill.manifest.hooks.is_empty() {
402        let mut hooks: BTreeMap<String, VmValue> = BTreeMap::new();
403        for (k, v) in &skill.manifest.hooks {
404            hooks.insert(k.clone(), VmValue::String(Rc::from(v.as_str())));
405        }
406        entry.insert("hooks".to_string(), VmValue::Dict(Rc::new(hooks)));
407    }
408    if let Some(model) = &skill.manifest.model {
409        entry.insert(
410            "model".to_string(),
411            VmValue::String(Rc::from(model.as_str())),
412        );
413    }
414    if let Some(effort) = &skill.manifest.effort {
415        entry.insert(
416            "effort".to_string(),
417            VmValue::String(Rc::from(effort.as_str())),
418        );
419    }
420    if let Some(shell) = &skill.manifest.shell {
421        entry.insert(
422            "shell".to_string(),
423            VmValue::String(Rc::from(shell.as_str())),
424        );
425    }
426    if let Some(hint) = &skill.manifest.argument_hint {
427        entry.insert(
428            "argument_hint".to_string(),
429            VmValue::String(Rc::from(hint.as_str())),
430        );
431    }
432    entry.insert(
433        "body".to_string(),
434        VmValue::String(Rc::from(skill.body.as_str())),
435    );
436    if let Some(dir) = &skill.skill_dir {
437        entry.insert(
438            "skill_dir".to_string(),
439            VmValue::String(Rc::from(dir.display().to_string().as_str())),
440        );
441    }
442    entry.insert(
443        "source".to_string(),
444        VmValue::String(Rc::from(skill.layer.label())),
445    );
446    if let Some(ns) = &skill.namespace {
447        entry.insert(
448            "namespace".to_string(),
449            VmValue::String(Rc::from(ns.as_str())),
450        );
451    }
452    VmValue::Dict(Rc::new(entry))
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use std::fs;
459
460    fn write(tmp: &Path, rel: &str, body: &str) {
461        let p = tmp.join(rel);
462        fs::create_dir_all(p.parent().unwrap()).unwrap();
463        fs::write(p, body).unwrap();
464    }
465
466    #[test]
467    fn fs_source_walks_one_level_deep() {
468        let tmp = tempfile::tempdir().unwrap();
469        write(
470            tmp.path(),
471            "deploy/SKILL.md",
472            "---\nname: deploy\ndescription: ship it\n---\nrun deploy",
473        );
474        write(
475            tmp.path(),
476            "review/SKILL.md",
477            "---\nname: review\n---\nbody",
478        );
479        write(tmp.path(), "not-a-skill.txt", "no");
480
481        let src = FsSkillSource::new(tmp.path(), Layer::Project);
482        let listed = src.list();
483        assert_eq!(listed.len(), 2);
484        let names: Vec<_> = listed.iter().map(|s| s.manifest.name.clone()).collect();
485        assert!(names.contains(&"deploy".to_string()));
486        assert!(names.contains(&"review".to_string()));
487
488        let skill = src.fetch("deploy").unwrap();
489        assert_eq!(skill.manifest.description, "ship it");
490        assert_eq!(skill.body, "run deploy");
491    }
492
493    #[test]
494    fn fs_source_accepts_root_as_single_skill() {
495        let tmp = tempfile::tempdir().unwrap();
496        write(tmp.path(), "SKILL.md", "---\nname: solo\n---\n(body)");
497        let src = FsSkillSource::new(tmp.path(), Layer::Cli);
498        let listed = src.list();
499        assert_eq!(listed.len(), 1);
500        assert_eq!(listed[0].manifest.name, "solo");
501    }
502
503    #[test]
504    fn fs_source_defaults_name_to_directory() {
505        let tmp = tempfile::tempdir().unwrap();
506        write(tmp.path(), "nameless/SKILL.md", "---\n---\nbody only");
507        let src = FsSkillSource::new(tmp.path(), Layer::User);
508        let skill = src.fetch("nameless").unwrap();
509        assert_eq!(skill.manifest.name, "nameless");
510    }
511
512    #[test]
513    fn fs_source_namespace_prefixes_id() {
514        let tmp = tempfile::tempdir().unwrap();
515        write(
516            tmp.path(),
517            "deploy/SKILL.md",
518            "---\nname: deploy\n---\nbody",
519        );
520        let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
521        let listed = src.list();
522        assert_eq!(listed[0].id, "acme/ops/deploy");
523        let skill = src.fetch("acme/ops/deploy").unwrap();
524        assert_eq!(skill.id(), "acme/ops/deploy");
525    }
526
527    #[test]
528    fn fs_source_missing_root_is_empty_not_error() {
529        let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
530        assert!(src.list().is_empty());
531        assert!(src.fetch("nope").is_err());
532    }
533
534    #[test]
535    fn host_source_wraps_closures() {
536        let host = HostSkillSource::new(
537            || {
538                vec![SkillManifestRef {
539                    id: "h1".into(),
540                    manifest: SkillManifest {
541                        name: "h1".into(),
542                        ..Default::default()
543                    },
544                    layer: Layer::Host,
545                    namespace: None,
546                    origin: "host".into(),
547                }]
548            },
549            |id| {
550                Ok(Skill {
551                    manifest: SkillManifest {
552                        name: id.to_string(),
553                        ..Default::default()
554                    },
555                    body: "host body".into(),
556                    skill_dir: None,
557                    layer: Layer::Host,
558                    namespace: None,
559                    unknown_fields: Vec::new(),
560                })
561            },
562        );
563        assert_eq!(host.list().len(), 1);
564        let s = host.fetch("h1").unwrap();
565        assert_eq!(s.body, "host body");
566        assert_eq!(s.layer, Layer::Host);
567    }
568
569    #[test]
570    fn layer_label_roundtrips() {
571        for layer in Layer::all() {
572            assert_eq!(Layer::from_label(layer.label()), Some(*layer));
573        }
574    }
575}