1pub 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#[derive(Debug, Clone, Default)]
34pub struct FsLayerConfig {
35 pub cli_dirs: Vec<PathBuf>,
39 pub env_dirs: Vec<PathBuf>,
41 pub project_root: Option<PathBuf>,
44 pub manifest_paths: Vec<PathBuf>,
47 pub manifest_sources: Vec<ManifestSource>,
49 pub user_dir: Option<PathBuf>,
51 pub packages_dir: Option<PathBuf>,
53 pub system_dirs: Vec<PathBuf>,
55}
56
57#[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
87pub 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
136fn 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
154pub 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
167pub 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
186pub 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 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 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(); let skills = walk_packages_skills(tmp.path());
232 assert_eq!(skills.len(), 2);
233 }
234}