Skip to main content

harn_cli/
skill_loader.rs

1//! CLI-side glue that assembles `harn-vm`'s layered skill discovery
2//! from the inputs `harn run` / `harn test` / `harn check` see at
3//! startup: repeatable `--skill-dir`, `$HARN_SKILLS_PATH`, the nearest
4//! `harn.toml`, and the user's home / system directories.
5//!
6//! The output is a pre-populated `skills` VM global — a registry dict
7//! in the shape the existing `skill_*` builtins already understand, so
8//! scripts can call `skill_count(skills)` / `skill_find(skills, name)`
9//! without any new language surface.
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::rc::Rc;
14use std::sync::Arc;
15
16use harn_vm::skills::{
17    build_fs_discovery, default_system_dirs, default_user_dir, install_current_skill_registry,
18    parse_env_skills_path, skill_manifest_ref_to_vm, BoundSkillRegistry, DiscoveryOptions,
19    DiscoveryReport, FsLayerConfig, Layer, LayeredDiscovery, ManifestSource, SkillManifestRef,
20};
21use harn_vm::value::VmValue;
22
23use crate::package::{
24    load_skills_config, resolve_skills_paths, ResolvedSkillsConfig, SkillSourceEntry,
25};
26use crate::skill_provenance::{self, VerificationReport, VerificationStatus, VerifyOptions};
27
28/// Inputs threaded in from the CLI layer. Anything we can compute from
29/// the environment or from the source path we compute internally; this
30/// struct captures only the stuff the user passed via flags.
31#[derive(Debug, Default, Clone)]
32pub struct SkillLoaderInputs {
33    pub cli_dirs: Vec<PathBuf>,
34    pub source_path: Option<PathBuf>,
35}
36
37/// Bundle of everything the run path needs: the registry VmValue to set
38/// as a global, plus the raw discovery report (for `harn doctor` and
39/// post-run diagnostics). The `loader_warnings` vec carries per-skill
40/// messages — unknown frontmatter fields, unreadable SKILL.md files —
41/// that the caller prints to stderr before the VM starts.
42pub struct LoadedSkills {
43    pub registry: VmValue,
44    pub report: DiscoveryReport,
45    pub loader_warnings: Vec<String>,
46    /// Lives on so callers can re-resolve a skill by id without
47    /// rebuilding the layered discovery — hot-reload uses this to
48    /// re-fetch a single SKILL.md after `skills/update` fires.
49    #[allow(dead_code)]
50    pub discovery: Arc<LayeredDiscovery>,
51}
52
53/// Build a [`LoadedSkills`] from CLI inputs. Does no I/O unless one of
54/// the input layers has a directory to walk.
55pub fn load_skills(inputs: &SkillLoaderInputs) -> LoadedSkills {
56    let mut cfg = FsLayerConfig {
57        cli_dirs: inputs.cli_dirs.clone(),
58        ..FsLayerConfig::default()
59    };
60
61    if let Ok(raw) = std::env::var("HARN_SKILLS_PATH") {
62        if !raw.is_empty() {
63            cfg.env_dirs = parse_env_skills_path(&raw);
64        }
65    }
66
67    if let Some(project_root) = inputs
68        .source_path
69        .as_deref()
70        .and_then(harn_vm::stdlib::process::find_project_root)
71    {
72        cfg.project_root = Some(project_root.clone());
73        cfg.packages_dir = Some(project_root.join(".harn").join("packages"));
74    }
75
76    let resolved = load_skills_config(inputs.source_path.as_deref());
77    let registry_url = resolved
78        .as_ref()
79        .and_then(|resolved| resolved.config.signer_registry_url.clone());
80    let mut options = DiscoveryOptions::default();
81    if let Some(resolved) = resolved.as_ref() {
82        cfg.manifest_paths.extend(resolve_skills_paths(resolved));
83        cfg.manifest_sources
84            .extend(resolved.sources.iter().filter_map(manifest_source_to_vm));
85        apply_option_overrides(&mut options, resolved);
86    }
87
88    cfg.user_dir = default_user_dir();
89    cfg.system_dirs = default_system_dirs();
90
91    let discovery = Arc::new(build_fs_discovery(&cfg, options));
92    let report = discovery.build_report();
93
94    let mut loader_warnings = Vec::new();
95    let mut entries: Vec<VmValue> = Vec::new();
96    for winner in &report.winners {
97        if !winner.unknown_fields.is_empty() {
98            loader_warnings.push(format!(
99                "skills: {} has unknown frontmatter fields: {}",
100                winner.id,
101                winner.unknown_fields.join(", "),
102            ));
103        }
104        // Verify provenance up front against the manifest ref (origin is
105        // the skill directory). This keeps the #238 two-tier lazy-load
106        // model — the full SKILL.md body is only fetched on actual
107        // invocation — while still gating on Ed25519 signature trust at
108        // enumeration time.
109        let provenance = build_provenance_report_for_ref(winner, registry_url.clone());
110        if let Some(report) = provenance.as_ref() {
111            if matches!(
112                report.status,
113                VerificationStatus::InvalidSignature
114                    | VerificationStatus::MissingSigner
115                    | VerificationStatus::UntrustedSigner
116            ) {
117                loader_warnings.push(format!(
118                    "skills: {} provenance check: {}",
119                    winner.id,
120                    report.human_summary()
121                ));
122            }
123        }
124        let mut entry = match skill_manifest_ref_to_vm(winner) {
125            VmValue::Dict(map) => (*map).clone(),
126            _ => BTreeMap::new(),
127        };
128        if let Some(report) = provenance {
129            entry.insert("provenance".to_string(), provenance_to_vm(&report));
130        }
131        entries.push(VmValue::Dict(Rc::new(entry)));
132    }
133
134    let mut registry: BTreeMap<String, VmValue> = BTreeMap::new();
135    registry.insert(
136        "_type".to_string(),
137        VmValue::String(Rc::from("skill_registry")),
138    );
139    registry.insert("skills".to_string(), VmValue::List(Rc::new(entries)));
140    let registry_value = VmValue::Dict(Rc::new(registry));
141
142    LoadedSkills {
143        registry: registry_value,
144        report,
145        loader_warnings,
146        discovery,
147    }
148}
149
150fn build_provenance_report_for_ref(
151    winner: &SkillManifestRef,
152    registry_url: Option<String>,
153) -> Option<VerificationReport> {
154    if winner.origin.is_empty() {
155        return None;
156    }
157    let skill_path = PathBuf::from(&winner.origin).join("SKILL.md");
158    let options = VerifyOptions {
159        registry_url,
160        allowed_signers: winner.manifest.trusted_signers.clone(),
161        allowed_endorsers: winner.manifest.trusted_endorsers.clone(),
162    };
163    match skill_provenance::verify_skill(&skill_path, &options) {
164        Ok(report) => Some(report),
165        Err(error) => Some(VerificationReport {
166            skill_path: skill_path.clone(),
167            signature_path: skill_provenance::signature_path_for(&skill_path),
168            skill_sha256: String::new(),
169            signer_fingerprint: None,
170            signed_at: None,
171            endorsements: Vec::new(),
172            signed: false,
173            trusted: false,
174            status: VerificationStatus::InvalidSignature,
175            error: Some(error),
176        }),
177    }
178}
179
180fn provenance_to_vm(report: &VerificationReport) -> VmValue {
181    let mut dict = BTreeMap::new();
182    dict.insert(
183        "skill_sha256".to_string(),
184        VmValue::String(Rc::from(report.skill_sha256.as_str())),
185    );
186    dict.insert("signed".to_string(), VmValue::Bool(report.signed));
187    dict.insert("trusted".to_string(), VmValue::Bool(report.trusted));
188    dict.insert(
189        "status".to_string(),
190        VmValue::String(Rc::from(status_label(report.status))),
191    );
192    dict.insert(
193        "signature_path".to_string(),
194        VmValue::String(Rc::from(report.signature_path.display().to_string())),
195    );
196    if let Some(fingerprint) = report.signer_fingerprint.as_deref() {
197        dict.insert(
198            "signer_fingerprint".to_string(),
199            VmValue::String(Rc::from(fingerprint)),
200        );
201        dict.insert(
202            "author".to_string(),
203            signer_policy_input(fingerprint, report.signed_at.as_deref()),
204        );
205    }
206    let endorsements = report
207        .endorsements
208        .iter()
209        .map(|endorsement| {
210            let mut item = match signer_policy_input(
211                &endorsement.endorser_fingerprint,
212                Some(&endorsement.signed_at),
213            ) {
214                VmValue::Dict(map) => (*map).clone(),
215                _ => BTreeMap::new(),
216            };
217            item.insert("trusted".to_string(), VmValue::Bool(endorsement.trusted));
218            item.insert(
219                "status".to_string(),
220                VmValue::String(Rc::from(status_label(endorsement.status))),
221            );
222            if let Some(error) = endorsement.error.as_deref() {
223                item.insert("error".to_string(), VmValue::String(Rc::from(error)));
224            }
225            VmValue::Dict(Rc::new(item))
226        })
227        .collect();
228    dict.insert(
229        "endorsements".to_string(),
230        VmValue::List(Rc::new(endorsements)),
231    );
232    let mut policy_input = BTreeMap::new();
233    policy_input.insert(
234        "action".to_string(),
235        VmValue::String(Rc::from("skill.provenance")),
236    );
237    if let Some(fingerprint) = report.signer_fingerprint.as_deref() {
238        policy_input.insert(
239            "author_actor_id".to_string(),
240            VmValue::String(Rc::from(fingerprint)),
241        );
242    }
243    policy_input.insert(
244        "endorser_actor_ids".to_string(),
245        VmValue::List(Rc::new(
246            report
247                .endorsements
248                .iter()
249                .map(|endorsement| {
250                    VmValue::String(Rc::from(endorsement.endorser_fingerprint.as_str()))
251                })
252                .collect(),
253        )),
254    );
255    dict.insert(
256        "trust_policy_input".to_string(),
257        VmValue::Dict(Rc::new(policy_input)),
258    );
259    if let Some(error) = report.error.as_deref() {
260        dict.insert("error".to_string(), VmValue::String(Rc::from(error)));
261    }
262    VmValue::Dict(Rc::new(dict))
263}
264
265fn signer_policy_input(fingerprint: &str, signed_at: Option<&str>) -> VmValue {
266    let mut dict = BTreeMap::new();
267    dict.insert(
268        "fingerprint".to_string(),
269        VmValue::String(Rc::from(fingerprint)),
270    );
271    dict.insert(
272        "trust_actor_id".to_string(),
273        VmValue::String(Rc::from(fingerprint)),
274    );
275    dict.insert(
276        "trust_action".to_string(),
277        VmValue::String(Rc::from("skill.provenance")),
278    );
279    if let Some(signed_at) = signed_at {
280        dict.insert(
281            "signed_at".to_string(),
282            VmValue::String(Rc::from(signed_at)),
283        );
284    }
285    VmValue::Dict(Rc::new(dict))
286}
287
288fn status_label(status: VerificationStatus) -> &'static str {
289    status.as_str()
290}
291
292fn manifest_source_to_vm(entry: &SkillSourceEntry) -> Option<ManifestSource> {
293    match entry {
294        SkillSourceEntry::Fs { path, namespace } => Some(ManifestSource::Fs {
295            path: PathBuf::from(path),
296            namespace: namespace.clone(),
297        }),
298        SkillSourceEntry::Git {
299            url,
300            tag,
301            namespace,
302        } => {
303            // Git deps are materialized by `harn install` under
304            // `.harn/packages/<name>`. We can't know the name from just
305            // the URL without parsing, and we don't want to re-clone on
306            // every `harn run` — so the fs source that covers the
307            // installed copy is already layered in via the Package layer
308            // (see `cfg.packages_dir`). Here we just surface the raw
309            // config so `harn doctor` can warn if the manifest declares
310            // a git source but `harn install` hasn't been run.
311            let _ = (url, tag);
312            namespace.as_ref().map(|ns| ManifestSource::Git {
313                path: PathBuf::new(),
314                namespace: Some(ns.clone()),
315            })
316        }
317        SkillSourceEntry::Registry { .. } => None,
318    }
319}
320
321fn apply_option_overrides(options: &mut DiscoveryOptions, resolved: &ResolvedSkillsConfig) {
322    for label in &resolved.config.disable {
323        if let Some(layer) = Layer::from_label(label) {
324            options.disabled_layers.push(layer);
325        }
326    }
327    if !resolved.config.lookup_order.is_empty() {
328        let ordered: Vec<Layer> = resolved
329            .config
330            .lookup_order
331            .iter()
332            .filter_map(|s| Layer::from_label(s))
333            .collect();
334        if !ordered.is_empty() {
335            options.lookup_order = Some(ordered);
336        }
337    }
338}
339
340/// Set the resolved skill registry as the VM global `skills`. Safe to
341/// call even when no skills were discovered — the value is an empty
342/// `skill_registry` so `skill_count(skills)` still returns `0`.
343pub fn install_skills_global(vm: &mut harn_vm::Vm, loaded: &LoadedSkills) {
344    vm.set_global("skills", loaded.registry.clone());
345    let discovery = loaded.discovery.clone();
346    install_current_skill_registry(Some(BoundSkillRegistry {
347        registry: loaded.registry.clone(),
348        fetcher: Arc::new(move |id| discovery.fetch(id)),
349    }));
350}
351
352/// Print loader warnings to stderr. Non-fatal — a malformed SKILL.md
353/// simply doesn't participate in the registry.
354pub fn emit_loader_warnings(warnings: &[String]) {
355    for w in warnings {
356        eprintln!("warning: {w}");
357    }
358}
359
360/// Convenience: canonicalize CLI-provided `--skill-dir` paths against
361/// the provided cwd (or the process cwd when `None`). Non-existent paths
362/// are kept as-is so `harn doctor` can flag the typo.
363pub fn canonicalize_cli_dirs(raw: &[String], cwd: Option<&Path>) -> Vec<PathBuf> {
364    let base = cwd
365        .map(Path::to_path_buf)
366        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
367    raw.iter()
368        .map(|p| {
369            let candidate = PathBuf::from(p);
370            if candidate.is_absolute() {
371                candidate
372            } else {
373                base.join(candidate)
374            }
375        })
376        .collect()
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::fs;
383
384    use crate::env_guard::ScopedEnvVar;
385    use crate::skill_provenance;
386    use crate::tests::common::{cwd_lock::lock_cwd, env_lock::lock_env};
387
388    fn write_skill(root: &Path, sub: &str, name: &str, body: &str) {
389        let dir = root.join(sub);
390        fs::create_dir_all(&dir).unwrap();
391        fs::write(
392            dir.join("SKILL.md"),
393            format!("---\nname: {name}\nshort: {name} short card\n---\n{body}"),
394        )
395        .unwrap();
396    }
397
398    fn set_home(path: &Path) -> ScopedEnvVar {
399        ScopedEnvVar::set("HOME", path.to_str().unwrap())
400    }
401
402    #[test]
403    fn cli_dirs_produce_registry_entries() {
404        let tmp = tempfile::tempdir().unwrap();
405        write_skill(tmp.path(), "deploy", "deploy", "body A");
406        let loaded = load_skills(&SkillLoaderInputs {
407            cli_dirs: vec![tmp.path().to_path_buf()],
408            source_path: None,
409        });
410        assert_eq!(loaded.report.winners.len(), 1);
411        assert!(loaded.loader_warnings.is_empty());
412        let VmValue::Dict(registry) = &loaded.registry else {
413            panic!("registry should be a dict");
414        };
415        let VmValue::List(entries) = registry.get("skills").unwrap() else {
416            panic!("skills should be a list");
417        };
418        assert_eq!(entries.len(), 1);
419        let entry = entries[0].as_dict().expect("skill entry should be a dict");
420        assert_eq!(
421            entry.get("short").map(|value| value.display()).as_deref(),
422            Some("deploy short card")
423        );
424        assert!(
425            !entry.contains_key("body"),
426            "startup registry should not eagerly include the full body"
427        );
428    }
429
430    #[test]
431    fn unknown_frontmatter_fields_surface_as_warnings() {
432        let tmp = tempfile::tempdir().unwrap();
433        let dir = tmp.path().join("thing");
434        fs::create_dir_all(&dir).unwrap();
435        fs::write(
436            dir.join("SKILL.md"),
437            "---\nname: thing\nshort: thing short card\nfuture_mystery_field: 42\n---\nbody",
438        )
439        .unwrap();
440        let loaded = load_skills(&SkillLoaderInputs {
441            cli_dirs: vec![tmp.path().to_path_buf()],
442            source_path: None,
443        });
444        assert_eq!(loaded.report.winners.len(), 1);
445        assert!(
446            loaded
447                .loader_warnings
448                .iter()
449                .any(|w| w.contains("future_mystery_field")),
450            "{:?}",
451            loaded.loader_warnings
452        );
453    }
454
455    #[test]
456    fn loader_attaches_verified_provenance_metadata() {
457        let _cwd = lock_cwd();
458        let _env = lock_env().blocking_lock();
459        let tmp = tempfile::tempdir().unwrap();
460        let _home = set_home(tmp.path());
461
462        let skill_dir = tmp.path().join("deploy");
463        fs::create_dir_all(&skill_dir).unwrap();
464        fs::write(
465            skill_dir.join("SKILL.md"),
466            "---\nname: deploy\nshort: deploy short card\nrequire_signature: true\n---\nbody",
467        )
468        .unwrap();
469
470        let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
471        skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
472        skill_provenance::trust_add(keys.public_key_path.to_str().unwrap()).unwrap();
473        let endorser_keys =
474            skill_provenance::generate_keypair(tmp.path().join("endorser.pem")).unwrap();
475        skill_provenance::endorse_skill(
476            skill_dir.join("SKILL.md"),
477            &endorser_keys.private_key_path,
478        )
479        .unwrap();
480        skill_provenance::trust_add(endorser_keys.public_key_path.to_str().unwrap()).unwrap();
481
482        let loaded = load_skills(&SkillLoaderInputs {
483            cli_dirs: vec![tmp.path().to_path_buf()],
484            source_path: None,
485        });
486        let VmValue::Dict(registry) = &loaded.registry else {
487            panic!("registry should be a dict");
488        };
489        let VmValue::List(entries) = registry.get("skills").unwrap() else {
490            panic!("skills should be a list");
491        };
492        let Some(provenance) = entries[0]
493            .as_dict()
494            .and_then(|entry| entry.get("provenance"))
495            .and_then(VmValue::as_dict)
496        else {
497            panic!("provenance should be present");
498        };
499        assert_eq!(
500            provenance.get("signed").map(VmValue::display).as_deref(),
501            Some("true")
502        );
503        assert_eq!(
504            provenance.get("trusted").map(VmValue::display).as_deref(),
505            Some("true")
506        );
507        assert!(
508            loaded.loader_warnings.is_empty(),
509            "{:?}",
510            loaded.loader_warnings
511        );
512    }
513
514    #[test]
515    fn loader_warns_when_signature_is_invalid() {
516        let _cwd = lock_cwd();
517        let _env = lock_env().blocking_lock();
518        let tmp = tempfile::tempdir().unwrap();
519        let _home = set_home(tmp.path());
520
521        let skill_dir = tmp.path().join("deploy");
522        fs::create_dir_all(&skill_dir).unwrap();
523        fs::write(
524            skill_dir.join("SKILL.md"),
525            "---\nname: deploy\nshort: deploy short card\n---\nbody",
526        )
527        .unwrap();
528
529        let keys = skill_provenance::generate_keypair(tmp.path().join("signer.pem")).unwrap();
530        skill_provenance::sign_skill(skill_dir.join("SKILL.md"), &keys.private_key_path).unwrap();
531        fs::write(
532            skill_dir.join("SKILL.md"),
533            "---\nname: deploy\nshort: deploy short card\n---\nbody changed",
534        )
535        .unwrap();
536
537        let loaded = load_skills(&SkillLoaderInputs {
538            cli_dirs: vec![tmp.path().to_path_buf()],
539            source_path: None,
540        });
541        assert!(
542            loaded
543                .loader_warnings
544                .iter()
545                .any(|warning| warning.contains("does not match the current contents")),
546            "{:?}",
547            loaded.loader_warnings
548        );
549    }
550}