skillcask 0.1.2

A lockfile-first package manager for AI agent skills (SKILL.md), backed by Composer repositories
//! `skills.lock`: written by hand-rolled formatting so the layout is stable and merge-friendly.

use std::fmt::Write as _;
use std::path::Path;

use jiff::Timestamp;
use toml_edit::{DocumentMut, Item, TableLike};

use crate::error::{Error, MANIFEST, Result};

pub const FILE: &str = "skills.lock";

#[derive(Clone, Debug, PartialEq)]
pub struct Dist {
    pub kind: String,
    pub url: String,
    pub reference: Option<String>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct LockedSkill {
    pub name: String,
    /// Directory of the skill inside the package, `/`-separated, `""` for the root.
    pub path: String,
    pub tree_sha256: String,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Package {
    pub name: String,
    pub constraint: String,
    pub version: String,
    pub repository: String,
    pub dist: Dist,
    pub dist_sha256: String,
    pub released: Option<Timestamp>,
    pub skills: Vec<LockedSkill>,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct Lock {
    pub packages: Vec<Package>,
}

fn bad(what: impl std::fmt::Display) -> Error {
    Error::new(MANIFEST, format!("{FILE}: {what}"))
}

impl Lock {
    /// A missing lock is an empty one.
    pub fn load(root: &Path) -> Result<Lock> {
        match std::fs::read_to_string(root.join(FILE)) {
            Ok(t) => parse(&t),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Lock::default()),
            Err(e) => Err(e.into()),
        }
    }

    pub fn save(&self, root: &Path) -> Result<()> {
        std::fs::write(root.join(FILE), self.render())?;
        Ok(())
    }

    pub fn get(&self, name: &str) -> Option<&Package> {
        self.packages.iter().find(|p| p.name == name)
    }

    pub fn upsert(&mut self, p: Package) {
        self.packages.retain(|x| x.name != p.name);
        self.packages.push(p);
        self.packages.sort_by(|a, b| a.name.cmp(&b.name));
    }

    pub fn skills(&self) -> impl Iterator<Item = (&Package, &LockedSkill)> {
        self.packages.iter().flat_map(|p| p.skills.iter().map(move |s| (p, s)))
    }

    pub fn render(&self) -> String {
        let q = |s: &str| toml_edit::Value::from(s).to_string().trim().to_string();
        let mut out = String::from("# Generated by skillcask. Do not edit.\nversion = 1\n");
        let mut packages = self.packages.clone();
        packages.sort_by(|a, b| a.name.cmp(&b.name));
        for p in &packages {
            let _ = write!(
                out,
                "\n[[package]]\nname = {}\nconstraint = {}\nversion = {}\nrepository = {}\n",
                q(&p.name),
                q(&p.constraint),
                q(&p.version),
                q(&p.repository)
            );
            let _ = write!(out, "dist = {{ type = {}, url = {}", q(&p.dist.kind), q(&p.dist.url));
            if let Some(r) = &p.dist.reference {
                let _ = write!(out, ", reference = {}", q(r));
            }
            let _ = writeln!(out, " }}\ndist-sha256 = {}", q(&p.dist_sha256));
            if let Some(t) = p.released {
                let _ = writeln!(out, "released = {}", t.round(jiff::Unit::Second).unwrap_or(t));
            }
            out.push_str("skills = [");
            for (i, s) in p.skills.iter().enumerate() {
                let sep = if i == 0 { "" } else { "," };
                let _ = write!(out, "{sep}\n  {{ name = {}, path = {}, tree-sha256 = {} }}", q(&s.name), q(&s.path), q(&s.tree_sha256));
            }
            out.push_str(if p.skills.is_empty() { "]\n" } else { ",\n]\n" });
        }
        out
    }
}

pub fn parse(text: &str) -> Result<Lock> {
    let doc: DocumentMut = text.parse().map_err(bad)?;
    match doc.get("version").and_then(Item::as_integer) {
        Some(1) => {}
        v => return Err(bad(format!("unsupported lock version {v:?}"))),
    }
    let mut packages = Vec::new();
    if let Some(arr) = doc.get("package").and_then(Item::as_array_of_tables) {
        for t in arr.iter() {
            packages.push(package(t)?);
        }
    }
    Ok(Lock { packages })
}

fn package(t: &dyn TableLike) -> Result<Package> {
    let s = |t: &dyn TableLike, k: &str| -> Result<String> {
        t.get(k).and_then(Item::as_str).map(String::from).ok_or_else(|| bad(format!("missing {k}")))
    };
    let name = s(t, "name")?;
    if !crate::manifest::valid_package_name(&name) {
        return Err(bad(format!("invalid package name {name:?}")));
    }
    let ctx = |e: Error| bad(format!("{name}: {}", e.msg.trim_start_matches("skills.lock: ")));
    let dist = t.get("dist").and_then(Item::as_table_like).ok_or_else(|| bad(format!("{name}: missing dist")))?;
    let released = match t.get("released") {
        None => None,
        Some(i) => {
            let raw = i.as_datetime().map(|d| d.to_string()).or_else(|| i.as_str().map(String::from));
            Some(raw.and_then(|r| r.parse().ok()).ok_or_else(|| bad(format!("{name}: invalid released")))?)
        }
    };
    let mut skills = Vec::new();
    for v in t.get("skills").and_then(Item::as_array).ok_or_else(|| bad(format!("{name}: missing skills")))?.iter() {
        let st = v.as_inline_table().ok_or_else(|| bad(format!("{name}: invalid skills entry")))?;
        let skill = LockedSkill {
            name: s(st, "name").map_err(ctx)?,
            path: s(st, "path").map_err(ctx)?,
            tree_sha256: s(st, "tree-sha256").map_err(ctx)?,
        };
        // The lock is committed and may come from an untrusted change; both end up in paths.
        let safe_path =
            skill.path.is_empty() || skill.path.split('/').all(|c| !c.is_empty() && c != "." && c != ".." && !c.contains(['\\', ':']));
        if !crate::skill::valid_name(&skill.name) || !safe_path {
            return Err(bad(format!("{name}: invalid skill entry {:?} at {:?}", skill.name, skill.path)));
        }
        skills.push(skill);
    }
    Ok(Package {
        constraint: s(t, "constraint").map_err(ctx)?,
        version: s(t, "version").map_err(ctx)?,
        repository: s(t, "repository").map_err(ctx)?,
        dist: Dist { kind: s(dist, "type").map_err(ctx)?, url: s(dist, "url").map_err(ctx)?, reference: s(dist, "reference").ok() },
        dist_sha256: s(t, "dist-sha256").map_err(ctx)?,
        released,
        skills,
        name,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip_is_byte_stable() {
        let mut lock = Lock::default();
        for name in ["z/last", "acme/agents-md"] {
            lock.upsert(Package {
                name: name.into(),
                constraint: "^1.0".into(),
                version: "1.0.2".into(),
                repository: "https://git.example.com/packages.json".into(),
                dist: Dist { kind: "zip".into(), url: "https://x/a \"q\".zip".into(), reference: Some("9f2c1e".into()) },
                dist_sha256: "ab".repeat(32),
                released: Some("2026-10-01T09:12:00Z".parse().unwrap()),
                skills: vec![
                    LockedSkill { name: "agents-md".into(), path: "".into(), tree_sha256: "cd".repeat(32) },
                    LockedSkill { name: "two".into(), path: "skills/two".into(), tree_sha256: "ef".repeat(32) },
                ],
            });
        }
        let text = lock.render();
        assert!(text.contains("released = 2026-10-01T09:12:00Z\n"));
        assert!(text.find("acme/agents-md").unwrap() < text.find("z/last").unwrap());
        let back = parse(&text).unwrap();
        assert_eq!(back, lock);
        assert_eq!(back.render(), text);
        assert_eq!(parse("version = 1\n").unwrap(), Lock::default());
        assert!(parse("version = 2\n").is_err());
        let evil = |n: &str, p: &str| text.replace("name = \"agents-md\", path = \"\"", &format!("name = \"{n}\", path = \"{p}\""));
        assert!(parse(&evil("../../x", "")).is_err());
        assert!(parse(&evil("ok", "../..")).is_err());
        assert!(parse(&evil("ok", "a//b")).is_err());
        assert!(parse(&evil("ok", "skills/ok")).is_ok());
    }
}