harn_cli/package/
skills.rs1use super::*;
2use std::path::Component;
3
4pub struct ResolvedSkillsConfig {
8 pub config: SkillsConfig,
9 pub sources: Vec<SkillSourceEntry>,
10 pub manifest_dir: PathBuf,
11}
12
13pub fn load_skills_config(anchor: Option<&Path>) -> Option<ResolvedSkillsConfig> {
17 let anchor = anchor
18 .map(Path::to_path_buf)
19 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
20 let (manifest, dir) = find_nearest_manifest(&anchor)?;
21
22 let sources = manifest
24 .skill
25 .sources
26 .into_iter()
27 .map(|s| match s {
28 SkillSourceEntry::Fs { path, namespace } => {
29 let abs = if PathBuf::from(&path).is_absolute() {
30 path
31 } else {
32 dir.join(&path).display().to_string()
33 };
34 SkillSourceEntry::Fs {
35 path: abs,
36 namespace,
37 }
38 }
39 other => other,
40 })
41 .collect();
42
43 let mut config = manifest.skills;
44 if let Some(raw) = config.signer_registry_url.as_deref() {
45 if !raw.is_empty() && Url::parse(raw).is_err() && !PathBuf::from(raw).is_absolute() {
46 config.signer_registry_url = Some(dir.join(raw).display().to_string());
47 }
48 }
49
50 Some(ResolvedSkillsConfig {
51 config,
52 sources,
53 manifest_dir: dir,
54 })
55}
56
57pub fn resolve_skills_paths(cfg: &ResolvedSkillsConfig) -> Vec<PathBuf> {
62 let mut out = Vec::new();
63 for entry in &cfg.config.paths {
64 let raw = PathBuf::from(entry);
65 let absolute = if raw.is_absolute() {
66 raw
67 } else {
68 cfg.manifest_dir.join(raw)
69 };
70 out.extend(expand_single_star_glob(&absolute));
71 }
72 out
73}
74
75pub(crate) fn expand_single_star_glob(path: &Path) -> Vec<PathBuf> {
76 if !path
77 .components()
78 .any(|component| matches!(component, Component::Normal(name) if name == OsStr::new("*")))
79 {
80 return vec![path.to_path_buf()];
81 }
82
83 let mut results: Vec<PathBuf> = vec![PathBuf::new()];
84 for component in path.components() {
85 let mut next: Vec<PathBuf> = Vec::new();
86 match component {
87 Component::Normal(name) if name == OsStr::new("*") => {
88 for parent in &results {
89 if let Ok(entries) = fs::read_dir(parent) {
90 for entry in entries.flatten() {
91 let path = entry.path();
92 if path.is_dir() {
93 next.push(path);
94 }
95 }
96 }
97 }
98 }
99 _ => {
100 for parent in &results {
101 let mut joined = parent.clone();
102 joined.push(component.as_os_str());
103 if joined.exists()
110 || parent.as_os_str().is_empty()
111 || matches!(
112 component,
113 Component::Prefix(_) | Component::RootDir | Component::CurDir
114 )
115 {
116 next.push(joined);
117 }
118 }
119 }
120 }
121 next.sort();
122 results = next;
123 }
124 results
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn load_skills_config_parses_tables_and_sources() {
133 let tmp = tempfile::tempdir().unwrap();
134 let root = tmp.path();
135 std::fs::create_dir_all(root.join(".git")).unwrap();
136 fs::write(
137 root.join(MANIFEST),
138 r#"
139 [skills]
140 paths = ["packages/*/skills", "../shared-skills"]
141 lookup_order = ["cli", "project", "host"]
142 disable = ["system"]
143 signer_registry_url = "https://skills.harnlang.com/signers/"
144
145 [skills.defaults]
146 tool_search = "bm25"
147 always_loaded = ["look", "edit"]
148
149 [[skill.source]]
150 type = "fs"
151 path = "../shared"
152
153 [[skill.source]]
154 type = "git"
155 url = "https://github.com/acme/harn-skills"
156 tag = "v1.2.0"
157
158 [[skill.source]]
159 type = "registry"
160 url = "https://skills.harnlang.com"
161 name = "acme/ops"
162 "#,
163 )
164 .unwrap();
165 let harn_file = root.join("main.harn");
166 fs::write(&harn_file, "pipeline main() {}\n").unwrap();
167
168 let resolved = load_skills_config(Some(&harn_file)).expect("skills config should load");
169 assert_eq!(resolved.config.paths.len(), 2);
170 assert_eq!(resolved.config.lookup_order, vec!["cli", "project", "host"]);
171 assert_eq!(resolved.config.disable, vec!["system"]);
172 assert_eq!(
173 resolved.config.signer_registry_url.as_deref(),
174 Some("https://skills.harnlang.com/signers/")
175 );
176 assert_eq!(
177 resolved.config.defaults.tool_search.as_deref(),
178 Some("bm25")
179 );
180 assert_eq!(resolved.config.defaults.always_loaded, vec!["look", "edit"]);
181
182 assert_eq!(resolved.sources.len(), 3);
183 match &resolved.sources[0] {
184 SkillSourceEntry::Fs { path, .. } => {
185 assert!(path.ends_with("shared"), "fs path absolutized: {path}");
186 }
187 other => panic!("expected fs source, got {other:?}"),
188 }
189 match &resolved.sources[1] {
190 SkillSourceEntry::Git { url, tag, .. } => {
191 assert!(url.contains("harn-skills"));
192 assert_eq!(tag.as_deref(), Some("v1.2.0"));
193 }
194 other => panic!("expected git source, got {other:?}"),
195 }
196 assert!(matches!(
197 &resolved.sources[2],
198 SkillSourceEntry::Registry { .. }
199 ));
200 }
201
202 #[test]
203 fn expand_single_star_glob_handles_packages_pattern() {
204 let tmp = tempfile::tempdir().unwrap();
205 let root = tmp.path();
206 fs::create_dir_all(root.join("packages/pkg-a/skills")).unwrap();
207 fs::create_dir_all(root.join("packages/pkg-b/skills")).unwrap();
208 fs::create_dir_all(root.join("packages/pkg-c")).unwrap();
209
210 let raw = root.join("packages").join("*").join("skills");
211 let expanded = expand_single_star_glob(&raw);
212 let expanded: Vec<_> = expanded
213 .iter()
214 .map(|path| {
215 path.strip_prefix(root)
216 .unwrap()
217 .components()
218 .map(|component| component.as_os_str().to_string_lossy())
219 .collect::<Vec<_>>()
220 .join("/")
221 })
222 .collect();
223
224 assert_eq!(
225 expanded,
226 vec!["packages/pkg-a/skills", "packages/pkg-b/skills"]
227 );
228 }
229
230 #[test]
231 fn expand_single_star_glob_leaves_non_glob_paths_unchanged() {
232 let tmp = tempfile::tempdir().unwrap();
233 let path = tmp.path().join("packages").join("pkg-a").join("skills");
234
235 assert_eq!(expand_single_star_glob(&path), vec![path]);
236 }
237}