use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::durable_path::EncodedPath;
use crate::hashing::sha256;
pub(crate) const SKILL_MANIFEST_SCHEMA_VERSION: u32 = 1;
const SKILL_MANIFEST_FILE: &str = "skill-install-manifest.json";
const REFRESHED_VERSION_FILE: &str = "integrations-refreshed-version";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct InstalledSkillFile {
pub(crate) relative_path: String,
pub(crate) bytes: usize,
pub(crate) sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SkillInstallation {
pub(crate) root: EncodedPath,
pub(crate) installed_by_version: String,
pub(crate) files: Vec<InstalledSkillFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SkillInstallManifest {
pub(crate) schema_version: u32,
pub(crate) installations: Vec<SkillInstallation>,
}
impl Default for SkillInstallManifest {
fn default() -> Self {
Self {
schema_version: SKILL_MANIFEST_SCHEMA_VERSION,
installations: Vec::new(),
}
}
}
pub(crate) fn manifest_path(config_path: &Path) -> PathBuf {
config_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.join(SKILL_MANIFEST_FILE)
}
pub(crate) fn refreshed_version_path(config_path: &Path) -> PathBuf {
manifest_path(config_path).with_file_name(REFRESHED_VERSION_FILE)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ManifestState {
Absent,
Loaded(SkillInstallManifest),
Unreadable(String),
}
impl ManifestState {
pub(crate) fn writable_manifest(&self, path: &Path) -> Result<SkillInstallManifest> {
match self {
Self::Loaded(manifest) => Ok(manifest.clone()),
Self::Absent => Ok(SkillInstallManifest::default()),
Self::Unreadable(problem) => bail!(
"cannot change managed skills because ownership manifest {} is unreadable: \
{problem}; repair or move that manifest, then retry",
path.display()
),
}
}
pub(crate) fn installation(&self, root: &Path) -> Option<&SkillInstallation> {
match self {
Self::Loaded(manifest) => manifest.installation(root),
Self::Absent | Self::Unreadable(_) => None,
}
}
}
pub(crate) fn load_manifest(path: &Path) -> Result<ManifestState> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(ManifestState::Absent)
}
Err(error) => {
return Err(error).with_context(|| {
format!("failed to read skill install manifest {}", path.display())
})
}
};
match serde_json::from_str::<SkillInstallManifest>(&text) {
Ok(manifest) if manifest.schema_version == SKILL_MANIFEST_SCHEMA_VERSION => {
Ok(ManifestState::Loaded(manifest))
}
Ok(manifest) => Ok(ManifestState::Unreadable(format!(
"manifest schema_version {} is not the {} this build understands; it was probably \
written by a newer aise",
manifest.schema_version, SKILL_MANIFEST_SCHEMA_VERSION
))),
Err(error) => Ok(ManifestState::Unreadable(format!("{error}"))),
}
}
impl SkillInstallManifest {
pub(crate) fn installation(&self, root: &Path) -> Option<&SkillInstallation> {
self.installations.iter().find(|installation| {
installation
.root
.to_path_buf()
.is_ok_and(|recorded| recorded == root)
})
}
pub(crate) fn record(&mut self, root: &Path, files: &[(String, &str)]) {
let installation = SkillInstallation {
root: EncodedPath::from_path(root),
installed_by_version: env!("CARGO_PKG_VERSION").to_string(),
files: files
.iter()
.map(|(relative_path, content)| InstalledSkillFile {
relative_path: relative_path.clone(),
bytes: content.len(),
sha256: sha256(content.as_bytes()),
})
.collect(),
};
self.forget(root);
self.installations.push(installation);
self.sort();
}
pub(crate) fn forget(&mut self, root: &Path) {
self.installations.retain(|installation| {
installation
.root
.to_path_buf()
.is_ok_and(|recorded| recorded != root)
});
}
fn sort(&mut self) {
let mut keyed: BTreeMap<Vec<u8>, SkillInstallation> = BTreeMap::new();
for installation in self.installations.drain(..) {
let key = installation
.root
.to_path_buf()
.map(|path| path.as_os_str().as_encoded_bytes().to_vec())
.unwrap_or_default();
keyed.insert(key, installation);
}
self.installations = keyed.into_values().collect();
}
pub(crate) fn to_json(&self) -> Result<String> {
Ok(serde_json::to_string_pretty(self)? + "\n")
}
}
impl SkillInstallation {
pub(crate) fn file(&self, relative_path: &str) -> Option<&InstalledSkillFile> {
self.files
.iter()
.find(|file| file.relative_path == relative_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn files() -> Vec<(String, &'static str)> {
vec![
("SKILL.md".to_string(), "skill body"),
("corrections/policy.toml".to_string(), "policy body"),
]
}
#[test]
fn a_recorded_install_round_trips_through_json() {
let mut manifest = SkillInstallManifest::default();
let root = Path::new("/home/test/.claude/skills/ai-session-search");
manifest.record(root, &files());
let json = manifest.to_json().unwrap();
assert!(json.ends_with('\n'));
let decoded: SkillInstallManifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, manifest);
let installation = decoded.installation(root).expect("root is recorded");
assert_eq!(installation.files.len(), 2);
let policy = installation.file("corrections/policy.toml").unwrap();
assert_eq!(policy.bytes, "policy body".len());
assert_eq!(policy.sha256, sha256(b"policy body"));
assert!(decoded.installation(Path::new("/elsewhere")).is_none());
}
#[test]
fn recording_one_root_leaves_every_other_root_untouched() {
let mut manifest = SkillInstallManifest::default();
let claude = Path::new("/home/test/.claude/skills/ai-session-search");
let codex = Path::new("/home/test/.agents/skills/ai-session-search");
manifest.record(claude, &files());
manifest.record(codex, &files());
assert_eq!(manifest.installations.len(), 2);
manifest.record(claude, &[("SKILL.md".to_string(), "new body")]);
assert_eq!(
manifest.installations.len(),
2,
"re-recording replaces one entry, it does not append a second"
);
assert_eq!(manifest.installation(claude).unwrap().files.len(), 1);
assert_eq!(
manifest.installation(codex).unwrap().files.len(),
2,
"the other root's record must survive"
);
manifest.forget(claude);
assert!(manifest.installation(claude).is_none());
assert!(manifest.installation(codex).is_some());
}
#[test]
fn installations_serialize_in_a_stable_order_regardless_of_install_order() {
let roots = [Path::new("/b/skills/x"), Path::new("/a/skills/x")];
let mut forward = SkillInstallManifest::default();
for root in roots {
forward.record(root, &files());
}
let mut backward = SkillInstallManifest::default();
for root in roots.iter().rev() {
backward.record(root, &files());
}
assert_eq!(forward.to_json().unwrap(), backward.to_json().unwrap());
}
#[test]
fn a_missing_manifest_is_absent_and_a_damaged_one_says_why() {
let dir = tempfile::tempdir().unwrap();
let path = manifest_path(&dir.path().join("config.toml"));
assert_eq!(path.file_name().unwrap(), "skill-install-manifest.json");
assert_eq!(load_manifest(&path).unwrap(), ManifestState::Absent);
std::fs::write(&path, "{ truncated").unwrap();
let state = load_manifest(&path).unwrap();
assert!(
matches!(state, ManifestState::Unreadable(_)),
"a damaged manifest must not read as absent: {state:?}"
);
assert!(
state.installation(Path::new("/anything")).is_none(),
"an unreadable manifest proves nothing about any root"
);
std::fs::write(&path, r#"{"schema_version": 99, "installations": []}"#).unwrap();
match load_manifest(&path).unwrap() {
ManifestState::Unreadable(reason) => assert!(reason.contains("99"), "{reason}"),
other => panic!("expected an unreadable future schema, got {other:?}"),
}
}
#[test]
fn writable_manifest_rejects_damaged_ownership_evidence() {
let state = ManifestState::Unreadable("truncated".to_string());
let path = Path::new("/state/skill-install-manifest.json");
let error = state
.writable_manifest(path)
.expect_err("a write must preserve damaged ownership evidence");
let message = format!("{error:#}");
assert!(
message.contains(&path.display().to_string())
&& message.contains("truncated")
&& message.contains("repair or move"),
"{message}"
);
assert_eq!(
ManifestState::Absent
.writable_manifest(path)
.unwrap()
.installations,
Vec::new()
);
}
}