Skip to main content

harn_cli/package/
skills.rs

1use super::*;
2use std::path::Component;
3
4/// Resolved `[skills]` section plus the directory the manifest came
5/// from. Paths in `skills.paths` are joined against `manifest_dir`;
6/// `[[skill.source]]` fs entries get absolutized here too.
7pub struct ResolvedSkillsConfig {
8    pub config: SkillsConfig,
9    pub sources: Vec<SkillSourceEntry>,
10    pub manifest_dir: PathBuf,
11}
12
13/// Load the `[skills]` + `[[skill.source]]` tables from the nearest
14/// harn.toml, walking up from `anchor` like [`load_check_config`].
15/// Returns `None` when there is no manifest on the walk path.
16pub 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    // Absolutize `[[skill.source]]` fs paths relative to manifest_dir.
23    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
57/// Expand `skills.paths` (which may include simple `*` globs) into
58/// concrete directories relative to `manifest_dir`. We implement just
59/// enough globbing for the documented `packages/*/skills` pattern so
60/// we don't force a `glob`-crate dep on harn-cli.
61pub 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                    // Filter branches whose literal suffix does not exist on
104                    // disk so downstream FS sources don't iterate over phantom
105                    // directories (one Rust round-trip cheaper than discovering
106                    // them at load time). Roots and prefixes are kept even
107                    // though existence checks on incomplete Windows paths such
108                    // as `C:` are not meaningful.
109                    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}