use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use harn_vm::skills::{
build_fs_discovery, default_system_dirs, default_user_dir, parse_env_skills_path,
skill_entry_to_vm, DiscoveryOptions, DiscoveryReport, FsLayerConfig, Layer, LayeredDiscovery,
ManifestSource,
};
use harn_vm::value::VmValue;
use crate::package::{
load_skills_config, resolve_skills_paths, ResolvedSkillsConfig, SkillSourceEntry,
};
#[derive(Debug, Default, Clone)]
pub struct SkillLoaderInputs {
pub cli_dirs: Vec<PathBuf>,
pub source_path: Option<PathBuf>,
}
pub struct LoadedSkills {
pub registry: VmValue,
pub report: DiscoveryReport,
pub loader_warnings: Vec<String>,
#[allow(dead_code)]
pub discovery: LayeredDiscovery,
}
pub fn load_skills(inputs: &SkillLoaderInputs) -> LoadedSkills {
let mut cfg = FsLayerConfig {
cli_dirs: inputs.cli_dirs.clone(),
..FsLayerConfig::default()
};
if let Ok(raw) = std::env::var("HARN_SKILLS_PATH") {
if !raw.is_empty() {
cfg.env_dirs = parse_env_skills_path(&raw);
}
}
if let Some(project_root) = inputs
.source_path
.as_deref()
.and_then(harn_vm::stdlib::process::find_project_root)
{
cfg.project_root = Some(project_root.clone());
cfg.packages_dir = Some(project_root.join(".harn").join("packages"));
}
let resolved = load_skills_config(inputs.source_path.as_deref());
let mut options = DiscoveryOptions::default();
if let Some(resolved) = resolved.as_ref() {
cfg.manifest_paths.extend(resolve_skills_paths(resolved));
cfg.manifest_sources
.extend(resolved.sources.iter().filter_map(manifest_source_to_vm));
apply_option_overrides(&mut options, resolved);
}
cfg.user_dir = default_user_dir();
cfg.system_dirs = default_system_dirs();
let discovery = build_fs_discovery(&cfg, options);
let report = discovery.build_report();
let mut loader_warnings = Vec::new();
let mut entries: Vec<VmValue> = Vec::new();
for winner in &report.winners {
match discovery.fetch(&winner.id) {
Ok(skill) => {
if !skill.unknown_fields.is_empty() {
loader_warnings.push(format!(
"skills: {} has unknown frontmatter fields: {}",
skill.id(),
skill.unknown_fields.join(", "),
));
}
entries.push(skill_entry_to_vm(&skill));
}
Err(err) => {
loader_warnings.push(format!("skills: could not load '{}': {err}", winner.id));
}
}
}
let mut registry: BTreeMap<String, VmValue> = BTreeMap::new();
registry.insert(
"_type".to_string(),
VmValue::String(Rc::from("skill_registry")),
);
registry.insert("skills".to_string(), VmValue::List(Rc::new(entries)));
let registry_value = VmValue::Dict(Rc::new(registry));
LoadedSkills {
registry: registry_value,
report,
loader_warnings,
discovery,
}
}
fn manifest_source_to_vm(entry: &SkillSourceEntry) -> Option<ManifestSource> {
match entry {
SkillSourceEntry::Fs { path, namespace } => Some(ManifestSource::Fs {
path: PathBuf::from(path),
namespace: namespace.clone(),
}),
SkillSourceEntry::Git {
url,
tag,
namespace,
} => {
let _ = (url, tag);
namespace.as_ref().map(|ns| ManifestSource::Git {
path: PathBuf::new(),
namespace: Some(ns.clone()),
})
}
SkillSourceEntry::Registry { .. } => None,
}
}
fn apply_option_overrides(options: &mut DiscoveryOptions, resolved: &ResolvedSkillsConfig) {
for label in &resolved.config.disable {
if let Some(layer) = Layer::from_label(label) {
options.disabled_layers.push(layer);
}
}
if !resolved.config.lookup_order.is_empty() {
let ordered: Vec<Layer> = resolved
.config
.lookup_order
.iter()
.filter_map(|s| Layer::from_label(s))
.collect();
if !ordered.is_empty() {
options.lookup_order = Some(ordered);
}
}
}
pub fn install_skills_global(vm: &mut harn_vm::Vm, loaded: &LoadedSkills) {
vm.set_global("skills", loaded.registry.clone());
}
pub fn emit_loader_warnings(warnings: &[String]) {
for w in warnings {
eprintln!("warning: {w}");
}
}
pub fn canonicalize_cli_dirs(raw: &[String], cwd: Option<&Path>) -> Vec<PathBuf> {
let base = cwd
.map(Path::to_path_buf)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
raw.iter()
.map(|p| {
let candidate = PathBuf::from(p);
if candidate.is_absolute() {
candidate
} else {
base.join(candidate)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_skill(root: &Path, sub: &str, name: &str, body: &str) {
let dir = root.join(sub);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\n---\n{body}"),
)
.unwrap();
}
#[test]
fn cli_dirs_produce_registry_entries() {
let tmp = tempfile::tempdir().unwrap();
write_skill(tmp.path(), "deploy", "deploy", "body A");
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
assert_eq!(loaded.report.winners.len(), 1);
assert!(loaded.loader_warnings.is_empty());
let VmValue::Dict(registry) = &loaded.registry else {
panic!("registry should be a dict");
};
let VmValue::List(entries) = registry.get("skills").unwrap() else {
panic!("skills should be a list");
};
assert_eq!(entries.len(), 1);
}
#[test]
fn unknown_frontmatter_fields_surface_as_warnings() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("thing");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
"---\nname: thing\nfuture_mystery_field: 42\n---\nbody",
)
.unwrap();
let loaded = load_skills(&SkillLoaderInputs {
cli_dirs: vec![tmp.path().to_path_buf()],
source_path: None,
});
assert_eq!(loaded.report.winners.len(), 1);
assert!(
loaded
.loader_warnings
.iter()
.any(|w| w.contains("future_mystery_field")),
"{:?}",
loaded.loader_warnings
);
}
}