use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use crate::bail;
use crate::error::{Context, Error, MANIFEST, Result};
use crate::manifest::valid_package_name;
use crate::skill::{SKILL_MD, frontmatter, valid_name};
use crate::version::{Stability, Version};
pub struct PublishArgs {
pub vendor: String,
pub base_url: String,
pub out: PathBuf,
pub paths: Vec<PathBuf>,
}
struct Found {
version: String,
normalized: Version,
description: Option<String>,
zip: Vec<u8>,
}
pub fn publish(args: &PublishArgs) -> Result<()> {
if !args.base_url.starts_with("https://") {
bail!(MANIFEST, "--base-url must be an https:// URL");
}
let base = args.base_url.trim_end_matches('/');
std::fs::create_dir_all(&args.out)?;
let out = args.out.canonicalize()?;
let roots = if args.paths.is_empty() { vec![PathBuf::from(".")] } else { args.paths.clone() };
let mut dirs = Vec::new();
for r in &roots {
find(r, &out, &mut dirs)?;
}
let mut found: BTreeMap<String, Vec<Found>> = BTreeMap::new();
for dir in dirs {
let fm = frontmatter(&std::fs::read_to_string(dir.join(SKILL_MD))?);
let (Some(name), Some(version)) = (fm.get("name"), fm.get("version")) else { continue };
let at = dir.display();
if !valid_name(name) {
bail!(MANIFEST, "{at}: invalid skill name {name:?}");
}
let normalized = Version::parse(version)
.filter(|v| v.stability >= Stability::Stable)
.filter(|_| version.chars().all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c)))
.ok_or_else(|| Error::new(MANIFEST, format!("{at}: version {version:?} is not a stable release version")))?;
let package = fm.get("package").cloned().unwrap_or_else(|| format!("{}/{name}", args.vendor));
if !valid_package_name(&package) {
bail!(MANIFEST, "{at}: invalid package name {package:?}");
}
let versions = found.entry(package.clone()).or_default();
if versions.iter().any(|f| f.normalized == normalized) {
bail!(MANIFEST, "{package} {version} is defined twice");
}
let description = fm.get("description").filter(|d| ![">", "|", ">-", "|-"].contains(&d.as_str())).cloned();
versions.push(Found { zip: crate::archive::zip_dir(&dir).ctx(&at)?, version: version.clone(), normalized, description });
}
let index_path = out.join("packages.json");
let mut available: BTreeSet<String> = match std::fs::read(&index_path) {
Ok(b) => serde_json::from_slice::<Value>(&b)
.map_err(|e| Error::new(MANIFEST, format!("{}: {e}", index_path.display())))?
.get("available-packages")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default(),
Err(_) => BTreeSet::new(),
};
let mut writes: Vec<(PathBuf, Vec<u8>)> = Vec::new();
let mut report = Vec::new();
for (package, versions) in found {
let p2_path = out.join("p2").join(format!("{package}.json"));
let mut meta: Vec<Value> = match std::fs::read(&p2_path) {
Ok(b) => serde_json::from_slice::<Value>(&b)
.ok()
.and_then(|v| v.get("packages")?.get(&package)?.as_array().cloned())
.ok_or_else(|| Error::new(MANIFEST, format!("{}: not a Composer metadata file", p2_path.display())))?,
Err(_) => Vec::new(),
};
for f in versions {
let shasum = hex::encode(Sha1::digest(&f.zip));
let normalized = f.normalized.to_string();
let existing = meta.iter().find(|m| m.get("version_normalized").and_then(Value::as_str) == Some(normalized.as_str()));
let state = match existing {
Some(m) if m["dist"]["shasum"].as_str() != Some(shasum.as_str()) => {
bail!(MANIFEST, "{package} {} is already published with different content; bump the version", f.version)
}
Some(_) => "unchanged",
None => {
let mut m = json!({
"name": package,
"version": f.version,
"version_normalized": normalized,
"type": "ai-agent-skill",
"extra": { "ai-agent-skill": SKILL_MD },
"dist": {
"type": "zip",
"url": format!("{base}/dist/{package}/{}.zip", f.version),
"shasum": shasum,
"reference": hex::encode(Sha256::digest(&f.zip)),
},
"time": jiff::Timestamp::now().round(jiff::Unit::Second).map_err(|e| Error::new(MANIFEST, e.to_string()))?.to_string(),
});
if let Some(d) = f.description {
m["description"] = d.into();
}
meta.push(m);
"published"
}
};
writes.push((out.join("dist").join(&package).join(format!("{}.zip", f.version)), f.zip));
report.push(format!("{state} {package} {}", f.version));
}
let key = |m: &Value| m.get("version_normalized").and_then(Value::as_str).and_then(Version::parse);
meta.sort_by_key(|m| std::cmp::Reverse(key(m)));
writes.push((p2_path, pretty(&json!({ "packages": { package.clone(): meta } }))));
available.insert(package);
}
let index = json!({
"packages": {},
"metadata-url": format!("{base}/p2/%package%.json"),
"available-packages": available,
});
writes.push((index_path, pretty(&index)));
for (path, bytes) in &writes {
write_if_changed(path, bytes)?;
}
for line in report {
println!("{line}");
}
Ok(())
}
fn find(dir: &Path, out: &Path, found: &mut Vec<PathBuf>) -> Result<()> {
if dir.canonicalize().is_ok_and(|c| c == out) {
return Ok(());
}
if dir.join(SKILL_MD).is_file() {
found.push(dir.to_path_buf());
}
let mut children: Vec<_> = std::fs::read_dir(dir)?.collect::<std::io::Result<_>>()?;
children.sort_by_key(|e| e.file_name());
for e in children {
let name = e.file_name();
let name = name.to_string_lossy();
if e.file_type()?.is_dir() && !name.starts_with('.') && name != "target" && name != "node_modules" {
find(&e.path(), out, found)?;
}
}
Ok(())
}
fn pretty(v: &Value) -> Vec<u8> {
let mut s = serde_json::to_string_pretty(v).expect("json values serialise");
s.push('\n');
s.into_bytes()
}
fn write_if_changed(path: &Path, bytes: &[u8]) -> Result<()> {
if std::fs::read(path).is_ok_and(|b| b == bytes) {
return Ok(());
}
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
std::fs::write(path, bytes)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn skill(root: &Path, dir: &str, name: &str, version: &str, body: &str) {
let d = root.join(dir);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join(SKILL_MD), format!("---\nname: {name}\nversion: {version}\ndescription: d\n---\n{body}")).unwrap();
}
fn snapshot(dir: &Path) -> BTreeMap<PathBuf, (Vec<u8>, std::time::SystemTime)> {
let mut out = BTreeMap::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(d).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
stack.push(p);
} else {
out.insert(p.clone(), (std::fs::read(&p).unwrap(), p.metadata().unwrap().modified().unwrap()));
}
}
}
out
}
#[test]
fn publishes_immutably() {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
skill(src.path(), "skills/a", "alpha", "1.0.0", "a");
skill(src.path(), "skills/b", "beta", "2.1.0", "b");
skill(src.path(), "nested/deep/c", "gamma", "0.3.0", "c");
skill(src.path(), "skills/noversion", "nov", "", "x");
std::fs::write(src.path().join("skills/noversion/SKILL.md"), "---\nname: nov\n---\n").unwrap();
let args = PublishArgs {
vendor: "acme".into(),
base_url: "https://example.com/repo/".into(),
out: out.path().into(),
paths: vec![src.path().into()],
};
publish(&args).unwrap();
let index: Value = serde_json::from_slice(&std::fs::read(out.path().join("packages.json")).unwrap()).unwrap();
assert_eq!(index["metadata-url"], "https://example.com/repo/p2/%package%.json");
assert_eq!(index["available-packages"], json!(["acme/alpha", "acme/beta", "acme/gamma"]));
let p2: Value = serde_json::from_slice(&std::fs::read(out.path().join("p2/acme/beta.json")).unwrap()).unwrap();
let v = &p2["packages"]["acme/beta"][0];
assert_eq!(v["version_normalized"], "2.1.0.0");
assert_eq!(v["dist"]["url"], "https://example.com/repo/dist/acme/beta/2.1.0.zip");
let zip = std::fs::read(out.path().join("dist/acme/beta/2.1.0.zip")).unwrap();
assert_eq!(v["dist"]["shasum"], hex::encode(Sha1::digest(&zip)));
let before = snapshot(out.path());
std::thread::sleep(std::time::Duration::from_millis(1100));
publish(&args).unwrap();
assert_eq!(snapshot(out.path()), before);
skill(src.path(), "skills/a", "alpha", "1.0.0", "changed");
assert!(publish(&args).unwrap_err().msg.contains("acme/alpha 1.0.0"));
skill(src.path(), "skills/a", "alpha", "1.1.0", "changed");
publish(&args).unwrap();
let p2: Value = serde_json::from_slice(&std::fs::read(out.path().join("p2/acme/alpha.json")).unwrap()).unwrap();
let versions: Vec<_> =
p2["packages"]["acme/alpha"].as_array().unwrap().iter().map(|v| v["version"].as_str().unwrap().to_string()).collect();
assert_eq!(versions, ["1.1.0", "1.0.0"]);
assert!(out.path().join("dist/acme/alpha/1.0.0.zip").exists());
}
#[test]
fn rejects_bad_input() {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
let args = |base: &str| PublishArgs {
vendor: "acme".into(),
base_url: base.into(),
out: out.path().into(),
paths: vec![src.path().into()],
};
assert!(publish(&args("http://insecure")).is_err());
skill(src.path(), "a", "Bad_Name", "1.0.0", "");
assert!(publish(&args("https://x")).is_err());
skill(src.path(), "a", "ok", "1.0.0-beta1", "");
assert!(publish(&args("https://x")).is_err());
skill(src.path(), "a", "ok", "1.0.0", "");
skill(src.path(), "b", "ok", "1.0.0", "other");
assert!(publish(&args("https://x")).unwrap_err().msg.contains("defined twice"));
}
}