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