use std::path::Path;
use serde::{Serialize, Deserialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillEntry {
pub name: String,
pub description: String,
pub source: String,
pub path: String,
pub sha256: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Registry {
#[serde(default)]
pub skills: Vec<SkillEntry>,
}
pub fn load_registry(root: &Path) -> Registry {
let p = root.join("registry.json");
match std::fs::read_to_string(&p) {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
eprintln!("kibble caps: ignoring unreadable registry.json ({e})");
Registry::default()
}),
Err(_) => Registry::default(),
}
}
pub fn save_registry(root: &Path, reg: &Registry) -> std::io::Result<()> {
std::fs::create_dir_all(root)?;
let body = serde_json::to_string_pretty(reg)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = root.join("registry.json.tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, root.join("registry.json"))
}
use std::path::PathBuf;
pub fn sanitize_name(raw: &str) -> Option<String> {
let mut out = String::new();
let mut prev_dash = false;
for ch in raw.to_lowercase().chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
out.push(ch);
prev_dash = false;
} else {
if !prev_dash {
out.push('-');
prev_dash = true;
}
}
}
let trimmed = out.trim_matches(|c| c == '-' || c == '.').to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
}
pub fn parse_frontmatter(skill_md: &str) -> (Option<String>, Option<String>) {
let trimmed = skill_md.trim_start();
if !trimmed.starts_with("---") {
return (None, None);
}
let mut lines = trimmed.lines();
lines.next(); let (mut name, mut desc) = (None, None);
for line in lines {
let l = line.trim();
if l == "---" { break; }
if let Some(v) = l.strip_prefix("name:") {
name = Some(v.trim().trim_matches('"').to_string());
} else if let Some(v) = l.strip_prefix("description:") {
desc = Some(v.trim().trim_matches('"').to_string());
}
}
let clean = |o: Option<String>| o.filter(|s| !s.is_empty());
(clean(name), clean(desc))
}
#[derive(Debug)]
pub struct DetectedSkill {
pub name: String,
pub description: String,
pub dir: PathBuf,
}
fn walk_skill_md(dir: &Path, out: &mut Vec<PathBuf>) {
let entries: Vec<PathBuf> = match std::fs::read_dir(dir) {
Ok(rd) => rd.flatten().map(|e| e.path()).collect(),
Err(_) => return,
};
if entries.iter().any(|p| p.file_name().and_then(|n| n.to_str()) == Some("SKILL.md")) {
out.push(dir.join("SKILL.md"));
return;
}
for p in entries {
if p.is_dir() { walk_skill_md(&p, out); }
}
}
pub fn detect_skills(src: &Path) -> Vec<DetectedSkill> {
let mut md_files = Vec::new();
walk_skill_md(src, &mut md_files);
let mut skills = Vec::new();
for md in md_files {
let dir = match md.parent() { Some(d) => d.to_path_buf(), None => continue };
let body = std::fs::read_to_string(&md).unwrap_or_default();
let (fm_name, fm_desc) = parse_frontmatter(&body);
let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("skill");
let raw_name = fm_name.unwrap_or_else(|| dir_name.to_string());
let name = match sanitize_name(&raw_name) { Some(n) => n, None => continue };
skills.push(DetectedSkill { name, description: fm_desc.unwrap_or_default(), dir });
}
skills
}
fn expand_home(p: &str) -> PathBuf {
if let Some(rest) = p.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(p)
}
pub fn caps_root(cfg: &crate::config::CapsConfig, project_root: Option<&Path>) -> PathBuf {
match project_root {
Some(r) => r.join(".kibble").join("caps"),
None => expand_home(&cfg.root),
}
}
fn copy_tree(from: &Path, to: &Path, budget: &mut u64) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let path = entry.path();
let meta = std::fs::symlink_metadata(&path)?;
if meta.file_type().is_symlink() {
eprintln!("kibble caps: skipping symlink {}", path.display());
continue;
}
let dest = to.join(entry.file_name());
if meta.file_type().is_dir() {
copy_tree(&path, &dest, budget)?;
} else {
let len = meta.len();
if len > *budget {
return Err(std::io::Error::other("skill exceeds caps max_bytes"));
}
*budget -= len;
std::fs::copy(&path, &dest)?;
}
}
Ok(())
}
fn sha256_dir(dir: &Path) -> String {
let mut files = Vec::new();
fn collect(d: &Path, base: &Path, out: &mut Vec<(String, PathBuf)>) {
if let Ok(rd) = std::fs::read_dir(d) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() { collect(&p, base, out); }
else if let Ok(rel) = p.strip_prefix(base) {
out.push((rel.to_string_lossy().replace('\\', "/"), p));
}
}
}
}
collect(dir, dir, &mut files);
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut h = Sha256::new();
for (rel, p) in files {
h.update(rel.as_bytes());
h.update([0u8]);
if let Ok(bytes) = std::fs::read(&p) { h.update(&bytes); }
}
format!("{:x}", h.finalize())
}
pub fn install(
root: &Path,
src: &Path,
source_label: &str,
force: bool,
max_bytes: u64,
) -> std::io::Result<Vec<String>> {
let skills_dir = root.join("skills");
std::fs::create_dir_all(&skills_dir)?;
let skills_real = std::fs::canonicalize(&skills_dir)?;
let mut reg = load_registry(root);
let mut installed = Vec::new();
for det in detect_skills(src) {
let dest = skills_dir.join(&det.name);
let dest_parent_real = std::fs::canonicalize(dest.parent().unwrap_or(&skills_dir))?;
if !dest_parent_real.starts_with(&skills_real) {
eprintln!("kibble caps: rejecting out-of-root skill {}", det.name);
continue;
}
if dest.exists() {
if !force {
eprintln!("kibble caps: {} already installed (use --force)", det.name);
continue;
}
std::fs::remove_dir_all(&dest)?;
}
let mut budget = max_bytes;
copy_tree(&det.dir, &dest, &mut budget)?;
let sha = sha256_dir(&dest);
let rel = format!("skills/{}", det.name);
reg.skills.retain(|s| s.name != det.name);
reg.skills.push(SkillEntry {
name: det.name.clone(),
description: det.description,
source: source_label.to_string(),
path: rel,
sha256: sha,
});
installed.push(det.name);
}
save_registry(root, ®)?;
Ok(installed)
}
pub fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://") || s.starts_with("git@")
}
pub fn list(root: &Path) -> Vec<SkillEntry> {
load_registry(root).skills
}
pub fn remove(root: &Path, name: &str) -> std::io::Result<bool> {
let safe = match sanitize_name(name) { Some(n) => n, None => return Ok(false) };
let mut reg = load_registry(root);
let before = reg.skills.len();
reg.skills.retain(|s| s.name != safe);
let removed = reg.skills.len() != before;
let dir = root.join("skills").join(&safe);
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
save_registry(root, ®)?;
Ok(removed)
}
pub async fn install_source(
repo_root: &Path,
cfg: &crate::config::KibbleConfig,
src: &str,
force: bool,
project: bool,
scan_only: bool,
) -> std::io::Result<Vec<String>> {
let root = caps_root(&cfg.caps, if project { Some(repo_root) } else { None });
let max_bytes = cfg.caps.max_bytes;
let mut tmp_to_clean: Option<PathBuf> = None;
let src_dir: PathBuf = if is_url(src) {
let tmp = std::env::temp_dir().join(format!("kibble_caps_src_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp)?;
let proxy = crate::net::resolve_proxy(cfg.network.proxy.as_deref(), |k| std::env::var(k).ok());
let client = crate::net::build_client(proxy.as_deref())
.map_err(|e| std::io::Error::other(format!("http client: {e}")))?;
crate::fetch::resolve(&client, src, &tmp, max_bytes, proxy.as_deref()).await?;
tmp_to_clean = Some(tmp.clone());
tmp
} else {
PathBuf::from(src)
};
let result = if scan_only {
Ok(detect_skills(&src_dir).into_iter().map(|s| s.name).collect())
} else {
install(&root, &src_dir, src, force, max_bytes)
};
if let Some(tmp) = tmp_to_clean { let _ = std::fs::remove_dir_all(&tmp); }
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_copies_and_registers() {
let base = std::env::temp_dir().join(format!("kibble_inst_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let src = base.join("src"); let root = base.join("root");
std::fs::create_dir_all(src.join("scripts")).unwrap();
std::fs::write(src.join("SKILL.md"), "---\nname: pdf-tools\ndescription: D\n---\nbody").unwrap();
std::fs::write(src.join("scripts/run.sh"), "echo hi").unwrap();
let names = install(&root, &src, "https://x/repo", false, 1_000_000).unwrap();
assert_eq!(names, vec!["pdf-tools"]);
assert!(root.join("skills/pdf-tools/SKILL.md").is_file());
assert!(root.join("skills/pdf-tools/scripts/run.sh").is_file()); let reg = load_registry(&root);
assert_eq!(reg.skills.len(), 1);
assert_eq!(reg.skills[0].name, "pdf-tools");
assert_eq!(reg.skills[0].source, "https://x/repo");
assert!(!reg.skills[0].sha256.is_empty());
}
#[test]
fn install_collision_skips_unless_force() {
let base = std::env::temp_dir().join(format!("kibble_coll_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let src = base.join("src"); let root = base.join("root");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("SKILL.md"), "---\nname: s\ndescription: v1\n---\n").unwrap();
install(&root, &src, "src", false, 1_000_000).unwrap();
std::fs::write(src.join("SKILL.md"), "---\nname: s\ndescription: v2\n---\n").unwrap();
let again = install(&root, &src, "src", false, 1_000_000).unwrap();
assert!(again.is_empty(), "collision should skip without --force");
assert_eq!(load_registry(&root).skills[0].description, "v1");
let forced = install(&root, &src, "src", true, 1_000_000).unwrap();
assert_eq!(forced, vec!["s"]);
assert_eq!(load_registry(&root).skills[0].description, "v2");
}
#[test]
fn sanitize_name_rules() {
assert_eq!(sanitize_name("pdf-tools").as_deref(), Some("pdf-tools"));
assert_eq!(sanitize_name("My Skill!").as_deref(), Some("my-skill"));
assert_eq!(sanitize_name("../evil").as_deref(), Some("evil"));
assert_eq!(sanitize_name("a/../b").as_deref(), Some("a-b"));
assert_eq!(sanitize_name("..."), None);
assert_eq!(sanitize_name(""), None);
}
#[test]
fn parse_frontmatter_extracts_name_desc() {
let md = "---\nname: pdf-tools\ndescription: Work with PDFs\n---\n# body\n";
let (n, d) = parse_frontmatter(md);
assert_eq!(n.as_deref(), Some("pdf-tools"));
assert_eq!(d.as_deref(), Some("Work with PDFs"));
let (n2, _) = parse_frontmatter("no frontmatter here");
assert!(n2.is_none());
}
#[test]
fn detect_skills_collection_no_root() {
let src = std::env::temp_dir().join(format!("kibble_detc_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&src);
std::fs::create_dir_all(src.join("skills/alpha")).unwrap();
std::fs::write(src.join("skills/alpha/SKILL.md"), "---\nname: alpha\ndescription: A\n---\n").unwrap();
std::fs::create_dir_all(src.join("skills/beta")).unwrap();
std::fs::write(src.join("skills/beta/SKILL.md"), "---\ndescription: no name\n---\n").unwrap(); let mut got: Vec<String> = detect_skills(&src).into_iter().map(|s| s.name).collect();
got.sort();
assert_eq!(got, vec!["alpha", "beta"]);
}
#[test]
fn detect_skills_prunes_nested_under_root_skill() {
let src = std::env::temp_dir().join(format!("kibble_detp_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&src);
std::fs::create_dir_all(src.join("skills/inner")).unwrap();
std::fs::write(src.join("SKILL.md"), "---\nname: top\ndescription: T\n---\n").unwrap();
std::fs::write(src.join("skills/inner/SKILL.md"), "---\nname: inner\ndescription: I\n---\n").unwrap();
let got: Vec<String> = detect_skills(&src).into_iter().map(|s| s.name).collect();
assert_eq!(got, vec!["top"]); }
#[test]
fn is_url_detects() {
assert!(is_url("https://github.com/a/b"));
assert!(is_url("http://x"));
assert!(is_url("git@github.com:a/b.git"));
assert!(!is_url("./local/path"));
assert!(!is_url("/abs/skill"));
}
#[test]
fn list_and_remove() {
let base = std::env::temp_dir().join(format!("kibble_lr_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let src = base.join("src"); let root = base.join("root");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("SKILL.md"), "---\nname: gone\ndescription: D\n---\n").unwrap();
install(&root, &src, "src", false, 1_000_000).unwrap();
assert_eq!(list(&root).len(), 1);
assert!(root.join("skills/gone").is_dir());
assert!(remove(&root, "gone").unwrap());
assert!(!root.join("skills/gone").exists());
assert!(list(&root).is_empty());
assert!(!remove(&root, "missing").unwrap()); }
#[test]
fn scan_writes_nothing() {
let base = std::env::temp_dir().join(format!("kibble_scan_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let src = base.join("src"); let root = base.join("root");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("SKILL.md"), "---\nname: s\ndescription: D\n---\n").unwrap();
let found: Vec<String> = detect_skills(&src).into_iter().map(|s| s.name).collect();
assert_eq!(found, vec!["s"]);
assert!(!root.exists());
}
#[test]
fn registry_round_trip() {
let root = std::env::temp_dir().join(format!("kibble_reg_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let reg = Registry { skills: vec![SkillEntry {
name: "pdf-tools".into(), description: "d".into(), source: "s".into(),
path: "skills/pdf-tools".into(), sha256: "abc".into(),
}]};
save_registry(&root, ®).unwrap();
let back = load_registry(&root);
assert_eq!(back.skills.len(), 1);
assert_eq!(back.skills[0].name, "pdf-tools");
let empty = load_registry(&root.join("nope"));
assert!(empty.skills.is_empty());
}
#[test]
fn install_skips_symlinks_no_recursion() {
use std::os::unix::fs::symlink;
let base = std::env::temp_dir().join(format!("kibble_sym_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let src = base.join("src"); let root = base.join("root");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("SKILL.md"), "---\nname: s\ndescription: D\n---\n").unwrap();
symlink(&src, src.join("loop")).unwrap(); symlink("/etc/passwd", src.join("escape")).unwrap();
let names = install(&root, &src, "src", false, 1_000_000).unwrap(); assert_eq!(names, vec!["s"]);
assert!(root.join("skills/s/SKILL.md").is_file());
assert!(!root.join("skills/s/loop").exists()); assert!(!root.join("skills/s/escape").exists());
}
}