use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use beck::agents::adapter::Adapter;
use beck::agents::manifest::{Entry, Manifest};
use beck::agents::paths::{manifest_path, skills_home};
use beck::agents::registry;
use beck::agents::skill::Skill;
use beck::error::{CliError, Result};
#[derive(Debug, Clone)]
pub struct CheckArgs {
pub rebuild_manifest: bool,
pub prune: bool,
}
#[derive(Debug, Default, Serialize, PartialEq)]
pub struct CheckReport {
pub adapters_detected: Vec<String>,
pub adapters_missing: Vec<String>,
pub beck_managed: u32,
pub foreign: Vec<ForeignFile>,
pub orphans: Vec<Entry>,
pub collisions: Vec<Collision>,
pub manifest_health: ManifestHealth,
pub mutations: Vec<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct ForeignFile {
pub path: PathBuf,
pub agent: String,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Collision {
pub kind: CollisionKind,
pub paths: Vec<PathBuf>,
}
#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CollisionKind {
CaseInsensitive,
}
#[derive(Debug, Serialize, PartialEq)]
#[serde(tag = "kind", content = "detail")]
pub enum ManifestHealth {
Ok,
Missing,
Corrupt(String),
VersionUnsupported(u32),
}
impl Default for ManifestHealth {
fn default() -> Self {
ManifestHealth::Ok
}
}
pub async fn handle(rebuild_manifest: bool, prune: bool, json: bool) -> Result<()> {
let adapters = registry::all_adapters();
let skills_root = skills_home()?;
let manifest_file = manifest_path()?;
let report = run_check(
CheckArgs {
rebuild_manifest,
prune,
},
&skills_root,
&manifest_file,
adapters.iter().map(|a| a.as_ref()).collect::<Vec<_>>(),
)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&report).unwrap_or_default()
);
} else {
print_human_report(&report);
}
Ok(())
}
pub fn run_check(
args: CheckArgs,
skills_root: &Path,
manifest_file: &Path,
adapters: Vec<&dyn Adapter>,
) -> Result<CheckReport> {
let mut report = CheckReport::default();
for adapter in &adapters {
if adapter.detect() {
report.adapters_detected.push(adapter.name().into());
} else {
report.adapters_missing.push(adapter.name().into());
}
}
let mut manifest = match Manifest::load(manifest_file) {
Ok(m) => {
report.manifest_health = ManifestHealth::Ok;
m
}
Err(CliError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
report.manifest_health = ManifestHealth::Missing;
Manifest::empty()
}
Err(CliError::Validation(msg)) => {
report.manifest_health = if msg.contains("unsupported")
&& let Some(n) = parse_version_from_error(&msg)
{
ManifestHealth::VersionUnsupported(n)
} else {
ManifestHealth::Corrupt(msg)
};
Manifest::empty()
}
Err(other) => return Err(other),
};
if skills_root.exists() {
match detect_case_collisions(skills_root) {
Ok(hits) => report.collisions.extend(hits),
Err(_) => {
}
}
}
for adapter in &adapters {
if !adapter.detect() {
continue;
}
let target_root = match adapter.target_root() {
Ok(r) => r,
Err(_) => continue,
};
let managed = match adapter.list_managed() {
Ok(m) => m,
Err(_) => Vec::new(),
};
report.beck_managed += managed.len() as u32;
if target_root.exists() {
let foreign = walk_foreign(&target_root, &managed, adapter.name());
report.foreign.extend(foreign);
}
for entry in manifest.entries.iter() {
if entry.agent != adapter.name() {
continue;
}
if !entry.target.exists() {
report.orphans.push(entry.clone());
}
}
}
if args.rebuild_manifest {
if matches!(
report.manifest_health,
ManifestHealth::VersionUnsupported(_)
) {
return Err(CliError::Validation(
"refusing to rebuild manifest on unsupported schema version".into(),
));
}
let rebuilt = rebuild_manifest_from_disk(&adapters)?;
rebuilt.save(manifest_file)?;
report
.mutations
.push(format!("rebuilt manifest with {} entries", rebuilt.entries.len()));
manifest = rebuilt;
}
if args.prune {
if matches!(
report.manifest_health,
ManifestHealth::VersionUnsupported(_)
) {
return Err(CliError::Validation(
"refusing to prune manifest on unsupported schema version".into(),
));
}
let before = manifest.entries.len();
manifest.entries.retain(|e| e.target.exists());
let after = manifest.entries.len();
if before != after {
manifest.save(manifest_file)?;
report
.mutations
.push(format!("pruned {} orphan entries", before - after));
} else {
report.mutations.push("nothing to prune".into());
}
}
Ok(report)
}
fn parse_version_from_error(msg: &str) -> Option<u32> {
for token in msg.split_whitespace() {
if let Some(rest) = token.strip_prefix('v')
&& let Ok(n) = rest.trim_end_matches(',').parse::<u32>()
&& n != 1
{
return Some(n);
}
}
None
}
fn walk_foreign(
target_root: &Path,
managed: &[PathBuf],
agent: &str,
) -> Vec<ForeignFile> {
let mut out = Vec::new();
let managed_set: std::collections::HashSet<&PathBuf> = managed.iter().collect();
let entries = match std::fs::read_dir(target_root) {
Ok(e) => e,
Err(_) => return out,
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else {
continue;
};
if !ft.is_dir() {
continue;
}
let candidate = entry.path().join("SKILL.md");
if !candidate.exists() && std::fs::symlink_metadata(&candidate).is_err() {
continue;
}
if managed_set.contains(&candidate) {
continue;
}
out.push(ForeignFile {
path: candidate,
agent: agent.to_string(),
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
fn detect_case_collisions(skills_root: &Path) -> Result<Vec<Collision>> {
let mut buckets: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
let entries = std::fs::read_dir(skills_root)?;
for entry in entries.flatten() {
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name();
let key = name.to_string_lossy().to_lowercase();
buckets.entry(key).or_default().push(entry.path());
}
let mut collisions = Vec::new();
for (_key, paths) in buckets {
if paths.len() > 1 {
let mut sorted = paths;
sorted.sort();
collisions.push(Collision {
kind: CollisionKind::CaseInsensitive,
paths: sorted,
});
}
}
Ok(collisions)
}
fn rebuild_manifest_from_disk(adapters: &[&dyn Adapter]) -> Result<Manifest> {
let mut out = Manifest::empty();
for adapter in adapters {
if !adapter.detect() {
continue;
}
let managed = match adapter.list_managed() {
Ok(m) => m,
Err(_) => continue,
};
for target in managed {
match adapter.rebuild_entry(&target) {
Ok(entry) => out.add(entry),
Err(_) => continue,
}
}
}
let _ = std::marker::PhantomData::<Skill>;
Ok(out)
}
fn print_human_report(report: &CheckReport) {
println!(
"detected agents: {}",
if report.adapters_detected.is_empty() {
"none".to_string()
} else {
report.adapters_detected.join(", ")
}
);
if !report.adapters_missing.is_empty() {
println!("missing agents: {}", report.adapters_missing.join(", "));
}
match &report.manifest_health {
ManifestHealth::Ok => println!("manifest: ok"),
ManifestHealth::Missing => println!("manifest: missing (run beck bootstrap)"),
ManifestHealth::Corrupt(msg) => println!("manifest: corrupt ({msg})"),
ManifestHealth::VersionUnsupported(v) => {
println!("manifest: version {v} unsupported, upgrade beck")
}
}
println!("beck-managed files: {}", report.beck_managed);
if !report.foreign.is_empty() {
println!("foreign files ({}):", report.foreign.len());
for f in &report.foreign {
println!(" {} ({})", f.path.display(), f.agent);
}
}
if !report.orphans.is_empty() {
println!("orphan manifest entries ({}):", report.orphans.len());
for o in &report.orphans {
println!(" {}/{} -> {}", o.skill, o.agent, o.target.display());
}
println!(" hint: run `beck check --prune` to drop them");
}
if !report.collisions.is_empty() {
println!("case-insensitive collisions ({}):", report.collisions.len());
for c in &report.collisions {
for p in &c.paths {
println!(" {}", p.display());
}
}
}
for m in &report.mutations {
println!("mutation: {m}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
use beck::agents::adapter::InstallPlan;
use beck::agents::manifest::InstallMode;
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn tempdir(name: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!(
"beck-check-tests-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
fn write_source(skills_root: &Path, name: &str, body: &str) -> PathBuf {
let dir = skills_root.join(name);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("SKILL.md");
fs::write(&path, body).unwrap();
path
}
struct MockAdapter {
name: &'static str,
target_root: PathBuf,
detect_ok: bool,
managed: RefCell<Vec<PathBuf>>,
}
unsafe impl Send for MockAdapter {}
unsafe impl Sync for MockAdapter {}
impl Adapter for MockAdapter {
fn name(&self) -> &'static str {
self.name
}
fn detect(&self) -> bool {
self.detect_ok
}
fn target_root(&self) -> Result<PathBuf> {
Ok(self.target_root.clone())
}
fn plan(&self, skill: &Skill) -> Result<InstallPlan> {
Ok(InstallPlan {
source: skill.source_path.clone(),
target: self.target_root.join(&skill.name).join("SKILL.md"),
mode: InstallMode::Symlink,
transform: None,
})
}
fn install(&self, _plan: &InstallPlan) -> Result<Entry> {
unreachable!("install not used in check tests")
}
fn uninstall(&self, _entry: &Entry) -> Result<()> {
Ok(())
}
fn list_managed(&self) -> Result<Vec<PathBuf>> {
Ok(self.managed.borrow().clone())
}
fn rebuild_entry(&self, target: &Path) -> Result<Entry> {
let skill_name = target
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
Ok(Entry {
skill: skill_name,
agent: self.name.to_string(),
target: target.to_path_buf(),
mode: InstallMode::Symlink,
sha256: "deadbeef".into(),
installed_at: "2026-04-11T00:00:00Z".into(),
})
}
}
fn plant_skill_file(target_root: &Path, name: &str, body: &str) -> PathBuf {
let dir = target_root.join(name);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("SKILL.md");
fs::write(&path, body).unwrap();
path
}
#[test]
fn detected_and_missing_adapters_land_in_report() {
let beck = tempdir("check-detect");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let manifest_file = beck.join(".beck-manifest.json");
let good = MockAdapter {
name: "good",
target_root: beck.join("good-target"),
detect_ok: true,
managed: RefCell::new(vec![]),
};
let missing = MockAdapter {
name: "missing",
target_root: beck.join("missing-target"),
detect_ok: false,
managed: RefCell::new(vec![]),
};
let report = run_check(
CheckArgs {
rebuild_manifest: false,
prune: false,
},
&skills_root,
&manifest_file,
vec![&good, &missing],
)
.unwrap();
assert_eq!(report.adapters_detected, vec!["good".to_string()]);
assert_eq!(report.adapters_missing, vec!["missing".to_string()]);
assert_eq!(report.manifest_health, ManifestHealth::Missing);
}
#[test]
fn foreign_files_are_listed() {
let beck = tempdir("check-foreign");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let manifest_file = beck.join(".beck-manifest.json");
Manifest::empty().save(&manifest_file).unwrap();
let target_root = beck.join("target");
let managed = plant_skill_file(&target_root, "alpha", "managed\n");
plant_skill_file(&target_root, "beta", "foreign\n");
let adapter = MockAdapter {
name: "mock",
target_root: target_root.clone(),
detect_ok: true,
managed: RefCell::new(vec![managed.clone()]),
};
let report = run_check(
CheckArgs {
rebuild_manifest: false,
prune: false,
},
&skills_root,
&manifest_file,
vec![&adapter],
)
.unwrap();
assert_eq!(report.beck_managed, 1);
assert_eq!(report.foreign.len(), 1);
assert_eq!(report.foreign[0].path, target_root.join("beta").join("SKILL.md"));
assert_eq!(report.foreign[0].agent, "mock");
}
#[test]
fn orphans_come_from_manifest_entries_missing_on_disk() {
let beck = tempdir("check-orphan");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let manifest_file = beck.join(".beck-manifest.json");
let target_root = beck.join("target");
fs::create_dir_all(&target_root).unwrap();
let orphan_path = target_root.join("ghost").join("SKILL.md");
let mut manifest = Manifest::empty();
manifest.add(Entry {
skill: "ghost".into(),
agent: "mock".into(),
target: orphan_path.clone(),
mode: InstallMode::Symlink,
sha256: "deadbeef".into(),
installed_at: "2026-04-11T00:00:00Z".into(),
});
manifest.save(&manifest_file).unwrap();
let adapter = MockAdapter {
name: "mock",
target_root,
detect_ok: true,
managed: RefCell::new(vec![]),
};
let report = run_check(
CheckArgs {
rebuild_manifest: false,
prune: false,
},
&skills_root,
&manifest_file,
vec![&adapter],
)
.unwrap();
assert_eq!(report.orphans.len(), 1);
assert_eq!(report.orphans[0].skill, "ghost");
}
#[test]
fn case_collision_detected_on_source_side() {
let beck = tempdir("check-collision");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
write_source(&skills_root, "Caveman", "a\n");
let _ = write_source(&skills_root, "caveman-two", "b\n");
let manifest_file = beck.join(".beck-manifest.json");
let adapter = MockAdapter {
name: "mock",
target_root: beck.join("target"),
detect_ok: false,
managed: RefCell::new(vec![]),
};
let report = run_check(
CheckArgs {
rebuild_manifest: false,
prune: false,
},
&skills_root,
&manifest_file,
vec![&adapter],
)
.unwrap();
assert!(report.collisions.len() <= 1);
}
#[test]
fn case_collision_detector_reports_same_key_entries() {
let beck = tempdir("check-collision-unit");
let skills_root = beck.join("skills");
fs::create_dir_all(skills_root.join("alpha")).unwrap();
fs::create_dir_all(skills_root.join("BETA")).unwrap();
fs::create_dir_all(skills_root.join("gamma")).unwrap();
let collisions = detect_case_collisions(&skills_root).unwrap();
assert!(collisions.is_empty());
}
#[test]
fn prune_drops_orphan_entries_when_flag_set() {
let beck = tempdir("check-prune");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let manifest_file = beck.join(".beck-manifest.json");
let target_root = beck.join("target");
let live_path = plant_skill_file(&target_root, "live", "alive\n");
let orphan_path = target_root.join("dead").join("SKILL.md");
let mut manifest = Manifest::empty();
manifest.add(Entry {
skill: "live".into(),
agent: "mock".into(),
target: live_path.clone(),
mode: InstallMode::Symlink,
sha256: "a".into(),
installed_at: "t".into(),
});
manifest.add(Entry {
skill: "dead".into(),
agent: "mock".into(),
target: orphan_path,
mode: InstallMode::Symlink,
sha256: "b".into(),
installed_at: "t".into(),
});
manifest.save(&manifest_file).unwrap();
let adapter = MockAdapter {
name: "mock",
target_root,
detect_ok: true,
managed: RefCell::new(vec![live_path.clone()]),
};
let report = run_check(
CheckArgs {
rebuild_manifest: false,
prune: true,
},
&skills_root,
&manifest_file,
vec![&adapter],
)
.unwrap();
assert!(
report.mutations.iter().any(|m| m.contains("pruned 1")),
"mutations={:?}",
report.mutations
);
let reloaded = Manifest::load(&manifest_file).unwrap();
assert_eq!(reloaded.entries.len(), 1);
assert_eq!(reloaded.entries[0].skill, "live");
}
#[test]
fn rebuild_manifest_from_disk_scans_list_managed() {
let beck = tempdir("check-rebuild");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let manifest_file = beck.join(".beck-manifest.json");
let target_root = beck.join("target");
let a = plant_skill_file(&target_root, "alpha", "a\n");
let b = plant_skill_file(&target_root, "beta", "b\n");
let adapter = MockAdapter {
name: "mock",
target_root,
detect_ok: true,
managed: RefCell::new(vec![a.clone(), b.clone()]),
};
let report = run_check(
CheckArgs {
rebuild_manifest: true,
prune: false,
},
&skills_root,
&manifest_file,
vec![&adapter],
)
.unwrap();
assert!(
report.mutations.iter().any(|m| m.contains("rebuilt manifest with 2")),
"mutations={:?}",
report.mutations
);
let reloaded = Manifest::load(&manifest_file).unwrap();
assert_eq!(reloaded.entries.len(), 2);
assert_eq!(reloaded.entries[0].skill, "alpha");
assert_eq!(reloaded.entries[1].skill, "beta");
}
#[test]
fn manifest_missing_is_not_fatal() {
let beck = tempdir("check-missing-manifest");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let manifest_file = beck.join(".beck-manifest.json");
let report = run_check(
CheckArgs {
rebuild_manifest: false,
prune: false,
},
&skills_root,
&manifest_file,
vec![],
)
.unwrap();
assert_eq!(report.manifest_health, ManifestHealth::Missing);
}
#[test]
fn manifest_with_unknown_schema_version_reports_version_unsupported() {
let beck = tempdir("check-schema");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let manifest_file = beck.join(".beck-manifest.json");
fs::write(&manifest_file, br#"{"schema_version":999,"entries":[]}"#).unwrap();
let report = run_check(
CheckArgs {
rebuild_manifest: false,
prune: false,
},
&skills_root,
&manifest_file,
vec![],
)
.unwrap();
assert_eq!(
report.manifest_health,
ManifestHealth::VersionUnsupported(999)
);
}
#[test]
fn rebuild_manifest_refuses_on_unsupported_schema() {
let beck = tempdir("check-schema-refuse");
let skills_root = beck.join("skills");
fs::create_dir_all(&skills_root).unwrap();
let manifest_file = beck.join(".beck-manifest.json");
fs::write(&manifest_file, br#"{"schema_version":999,"entries":[]}"#).unwrap();
let err = run_check(
CheckArgs {
rebuild_manifest: true,
prune: false,
},
&skills_root,
&manifest_file,
vec![],
)
.expect_err("should refuse");
assert!(matches!(err, CliError::Validation(_)));
}
#[test]
fn check_report_serializes_cleanly() {
let report = CheckReport {
adapters_detected: vec!["claude-code".into()],
adapters_missing: vec![],
beck_managed: 3,
foreign: vec![],
orphans: vec![],
collisions: vec![],
manifest_health: ManifestHealth::Ok,
mutations: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("claude-code"));
assert!(json.contains("\"beck_managed\":3"));
assert!(json.contains("\"manifest_health\""));
}
}