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