Skip to main content

harn_vm/skills/
mod.rs

1//! Filesystem-and-host skill discovery for Harn.
2//!
3//! See `docs/src/skills.md` for the user-facing reference. At a glance:
4//!
5//! - [`frontmatter`] parses SKILL.md YAML frontmatter into
6//!   [`SkillManifest`](frontmatter::SkillManifest).
7//! - [`source`] defines the [`SkillSource`] trait and the concrete
8//!   filesystem / host implementations.
9//! - [`discovery`] stacks multiple sources in priority order, handles
10//!   name collisions, and reports shadowed skills for `harn doctor`.
11//! - [`substitute`] implements the `$ARGUMENTS` / `$N` / `${HARN_*}`
12//!   escapes that run over SKILL.md bodies at invocation time.
13//!
14//! The `default_sources` helper wires together the seven non-host
15//! filesystem layers. Hosts add a bridge-backed [`HostSkillSource`]
16//! on top.
17
18pub mod discovery;
19pub mod frontmatter;
20pub mod runtime;
21pub mod source;
22pub mod substitute;
23
24use std::path::{Path, PathBuf};
25
26pub use discovery::{DiscoveryOptions, DiscoveryReport, LayeredDiscovery, Shadowed};
27pub use frontmatter::{parse_frontmatter, split_frontmatter, ParsedFrontmatter, SkillManifest};
28pub use runtime::{
29    clear_current_skill_registry, current_skill_registry, install_current_skill_registry,
30    load_bound_skill_by_name, load_bound_skill_by_name_with_options, load_skill_from_registry,
31    resolve_skill_entry, skill_entry_id, tool_rejected_error, vm_error as skill_vm_error,
32    BoundSkillRegistry, LoadSkillOptions, LoadedSkill, SkillFetcher,
33};
34pub use source::{
35    skill_entry_to_vm, skill_manifest_ref_to_vm, FsSkillSource, HostSkillSource, Layer, Skill,
36    SkillManifestRef, SkillSource,
37};
38pub use substitute::{substitute_skill_body, SubstitutionContext};
39
40/// Inputs controlling the seven non-host filesystem layers.
41#[derive(Debug, Clone, Default)]
42pub struct FsLayerConfig {
43    /// `--skill-dir` paths. First has highest priority, but inside the
44    /// CLI layer there is no further ordering — unqualified names
45    /// collide and the first one loaded wins.
46    pub cli_dirs: Vec<PathBuf>,
47    /// `$HARN_SKILLS_PATH` entries in the order they appeared.
48    pub env_dirs: Vec<PathBuf>,
49    /// Project root (directory holding `.harn/skills/`), if one was
50    /// found by walking up from the executing script.
51    pub project_root: Option<PathBuf>,
52    /// `[skills] paths` entries from harn.toml, pre-resolved to
53    /// absolute directories.
54    pub manifest_paths: Vec<PathBuf>,
55    /// `[[skill.source]]` entries from harn.toml, pre-resolved.
56    pub manifest_sources: Vec<ManifestSource>,
57    /// `$HOME/.harn/skills` (or the platform equivalent).
58    pub user_dir: Option<PathBuf>,
59    /// Walk target for `.harn/packages/**/skills/*/SKILL.md`.
60    pub packages_dir: Option<PathBuf>,
61    /// `/etc/harn/skills` + `$XDG_CONFIG_HOME/harn/skills` combined.
62    pub system_dirs: Vec<PathBuf>,
63}
64
65/// A `[[skill.source]]` entry resolved to something the VM can load.
66/// `fs` and `git` are active today; `registry` is reserved and inert
67/// until a marketplace exists (per issue #73).
68#[derive(Debug, Clone)]
69pub enum ManifestSource {
70    Fs {
71        path: PathBuf,
72        namespace: Option<String>,
73    },
74    Git {
75        path: PathBuf,
76        namespace: Option<String>,
77    },
78}
79
80impl ManifestSource {
81    pub fn path(&self) -> &Path {
82        match self {
83            ManifestSource::Fs { path, .. } | ManifestSource::Git { path, .. } => path,
84        }
85    }
86    pub fn namespace(&self) -> Option<&str> {
87        match self {
88            ManifestSource::Fs { namespace, .. } | ManifestSource::Git { namespace, .. } => {
89                namespace.as_deref()
90            }
91        }
92    }
93}
94
95/// Build a [`LayeredDiscovery`] for the seven non-host layers from
96/// [`FsLayerConfig`]. Callers extend it with a [`HostSkillSource`] when
97/// they have a bridge handle.
98pub fn build_fs_discovery(cfg: &FsLayerConfig, options: DiscoveryOptions) -> LayeredDiscovery {
99    let mut discovery = LayeredDiscovery::new().with_options(options);
100
101    for path in &cfg.cli_dirs {
102        discovery = discovery.push(FsSkillSource::new(path.clone(), Layer::Cli));
103    }
104    for path in &cfg.env_dirs {
105        discovery = discovery.push(FsSkillSource::new(path.clone(), Layer::Env));
106    }
107    if let Some(root) = &cfg.project_root {
108        let proj_skills = root.join(".harn").join("skills");
109        if proj_skills.exists() {
110            discovery = discovery.push(FsSkillSource::new(proj_skills, Layer::Project));
111        }
112    }
113    for path in &cfg.manifest_paths {
114        discovery = discovery.push(FsSkillSource::new(path.clone(), Layer::Manifest));
115    }
116    for entry in &cfg.manifest_sources {
117        let source = FsSkillSource::new(entry.path().to_path_buf(), Layer::Manifest);
118        let source = if let Some(ns) = entry.namespace() {
119            source.with_namespace(ns)
120        } else {
121            source
122        };
123        discovery = discovery.push(source);
124    }
125    if let Some(path) = &cfg.user_dir {
126        if path.exists() {
127            discovery = discovery.push(FsSkillSource::new(path.clone(), Layer::User));
128        }
129    }
130    if let Some(root) = &cfg.packages_dir {
131        for skills_root in walk_packages_skills(root) {
132            discovery = discovery.push(FsSkillSource::new(skills_root, Layer::Package));
133        }
134    }
135    for path in &cfg.system_dirs {
136        if path.exists() {
137            discovery = discovery.push(FsSkillSource::new(path.clone(), Layer::System));
138        }
139    }
140
141    discovery
142}
143
144/// Walk `<packages>/*/skills` and return each concrete skills root.
145/// Does not recurse more than two levels — package authors are expected
146/// to place their bundled skills one level deep.
147fn walk_packages_skills(packages_dir: &Path) -> Vec<PathBuf> {
148    let mut out = Vec::new();
149    let Ok(entries) = std::fs::read_dir(packages_dir) else {
150        return out;
151    };
152    for entry in entries.flatten() {
153        let pkg_skills = entry.path().join("skills");
154        if pkg_skills.is_dir() {
155            out.push(pkg_skills);
156        }
157    }
158    out.sort();
159    out
160}
161
162/// Parse `$HARN_SKILLS_PATH` into absolute directory candidates.
163/// The separator is `:` on Unix and `;` on Windows (matches `PATH`).
164pub fn parse_env_skills_path(raw: &str) -> Vec<PathBuf> {
165    #[cfg(unix)]
166    let sep = ':';
167    #[cfg(not(unix))]
168    let sep = ';';
169    raw.split(sep)
170        .filter(|s| !s.is_empty())
171        .map(PathBuf::from)
172        .collect()
173}
174
175/// Canonical system-level search paths. We read `$XDG_CONFIG_HOME` with
176/// the usual `$HOME/.config` fallback and always include `/etc/harn/skills`
177/// on Unix.
178pub fn default_system_dirs() -> Vec<PathBuf> {
179    let mut out = Vec::new();
180    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
181        if !xdg.is_empty() {
182            out.push(PathBuf::from(xdg).join("harn").join("skills"));
183        }
184    } else if let Some(home) = dirs_home() {
185        out.push(home.join(".config").join("harn").join("skills"));
186    }
187    #[cfg(unix)]
188    {
189        out.push(PathBuf::from("/etc/harn/skills"));
190    }
191    out
192}
193
194/// The conventional user-level skill directory (`~/.harn/skills`).
195pub fn default_user_dir() -> Option<PathBuf> {
196    dirs_home().map(|h| h.join(".harn").join("skills"))
197}
198
199fn dirs_home() -> Option<PathBuf> {
200    std::env::var_os("HOME").map(PathBuf::from).or_else(|| {
201        // Windows fallback without pulling in the `dirs` crate.
202        std::env::var_os("USERPROFILE").map(PathBuf::from)
203    })
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::fs;
210
211    #[test]
212    fn env_skills_path_parses_and_skips_empties() {
213        let raw = if cfg!(unix) {
214            "/a/b::/c/d"
215        } else {
216            "C:\\a\\b;;C:\\c\\d"
217        };
218        let parsed = parse_env_skills_path(raw);
219        assert_eq!(parsed.len(), 2);
220    }
221
222    #[test]
223    fn default_system_dirs_respects_xdg() {
224        let tmp = tempfile::tempdir().unwrap();
225        let xdg = tmp.path().to_path_buf();
226        // SAFETY for test isolation: each test process has its own env.
227        std::env::set_var("XDG_CONFIG_HOME", &xdg);
228        let dirs = default_system_dirs();
229        assert!(dirs.iter().any(|p| p.starts_with(&xdg)));
230        std::env::remove_var("XDG_CONFIG_HOME");
231    }
232
233    #[test]
234    fn walks_packages_skills_one_level_deep() {
235        let tmp = tempfile::tempdir().unwrap();
236        fs::create_dir_all(tmp.path().join("pkg-a").join("skills")).unwrap();
237        fs::create_dir_all(tmp.path().join("pkg-b").join("skills")).unwrap();
238        fs::create_dir_all(tmp.path().join("pkg-c")).unwrap(); // no skills/
239        let skills = walk_packages_skills(tmp.path());
240        assert_eq!(skills.len(), 2);
241    }
242}