use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use thiserror::Error;
use tracing::{debug, info, warn};
use super::catalog_url::urls_match;
use crate::provisioner::GitBackend;
const DEFAULT_TTL_HOURS: u64 = 24;
const CATALOG_SUBDIR: &str = "catalog";
pub fn catalog_root_for(framework_root: &Path) -> PathBuf {
framework_root.join(CATALOG_SUBDIR)
}
const DEFAULT_CATALOG_REPO: &str = "https://github.com/bobmatnyc/claude-mpm";
const SYNC_SENTINEL: &str = ".catalog_synced_at";
const DEFAULT_CATALOG_REF: &str = "main";
#[derive(Debug, Error)]
pub enum CatalogError {
#[error("git sync error: {0}")]
Git(String),
#[error("catalog I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug)]
pub struct CatalogSyncResult {
pub fetched: bool,
pub agent_count: usize,
pub skill_count: usize,
}
impl CatalogSyncResult {
pub fn agents_empty(&self) -> bool {
self.agent_count == 0
}
}
pub struct CatalogSync<G: GitBackend> {
git: G,
catalog_dir: PathBuf,
repo_url: String,
git_ref: String,
ttl_secs: u64,
}
impl<G: GitBackend> CatalogSync<G> {
pub fn new(git: G, catalog_dir: PathBuf) -> Self {
Self::with_config(git, catalog_dir, None)
}
pub fn with_config(
git: G,
catalog_dir: PathBuf,
config: Option<&crate::core::config::ManifestConfig>,
) -> Self {
let env_ttl = std::env::var("TRUSTY_MPM_CATALOG_TTL_HOURS")
.ok()
.and_then(|v| v.parse::<u64>().ok());
let ttl_hours = config
.and_then(|c| c.ttl_hours)
.or(env_ttl)
.unwrap_or(DEFAULT_TTL_HOURS);
let env_repo = std::env::var("TRUSTY_MPM_CATALOG_REPO").ok();
let repo_url = config
.and_then(|c| c.repo.clone())
.or(env_repo)
.unwrap_or_else(|| DEFAULT_CATALOG_REPO.to_owned());
let env_ref = std::env::var("TRUSTY_MPM_CATALOG_REF").ok();
let git_ref = config
.and_then(|c| c.git_ref.clone())
.or(env_ref)
.unwrap_or_else(|| DEFAULT_CATALOG_REF.to_owned());
Self {
git,
catalog_dir,
repo_url,
git_ref,
ttl_secs: ttl_hours * 3600,
}
}
pub fn for_framework(
git: G,
framework_root: &Path,
config: Option<&crate::core::config::ManifestConfig>,
) -> Self {
Self::with_config(git, catalog_root_for(framework_root), config)
}
pub fn with_repo(git: G, catalog_dir: PathBuf, repo_url: &str, git_ref: &str) -> Self {
Self {
git,
catalog_dir,
repo_url: repo_url.to_owned(),
git_ref: git_ref.to_owned(),
ttl_secs: DEFAULT_TTL_HOURS * 3600,
}
}
pub fn sync(&self, force: bool) -> Result<CatalogSyncResult, CatalogError> {
if !force && self.ttl_valid() {
debug!(dir = %self.catalog_dir.display(), "catalog TTL valid; skipping fetch");
return Ok(CatalogSyncResult {
fetched: false,
agent_count: self.count_files("agents"),
skill_count: self.count_files("skills"),
});
}
info!(repo = %self.repo_url, git_ref = %self.git_ref, dir = %self.catalog_dir.display(), "syncing catalog");
std::fs::create_dir_all(&self.catalog_dir)?;
let clone_target = self.catalog_dir.join("repo");
self.ensure_repo(&clone_target)?;
let sentinel = self.catalog_dir.join(SYNC_SENTINEL);
std::fs::write(&sentinel, chrono::Utc::now().to_rfc3339())?;
let agent_count = self.count_files("agents");
let skill_count = self.count_files("skills");
info!(
agents = agent_count,
skills = skill_count,
"catalog sync complete"
);
if agent_count == 0 {
warn!(
repo = %self.repo_url,
git_ref = %self.git_ref,
dir = %self.catalog_dir.display(),
"catalog sync found 0 agents — the synced repo has no composable \
agents at repo/.claude/agents (upstream layout may have moved); \
provisioning falls back to the bundled agent assets"
);
}
Ok(CatalogSyncResult {
fetched: true,
agent_count,
skill_count,
})
}
fn ensure_repo(&self, target: &Path) -> Result<(), CatalogError> {
if !target.exists() {
return self.do_clone(target);
}
if !self.git.is_git_repo(target) {
warn!(path = %target.display(), "catalog dir exists but is not a git repo; re-cloning");
guard_remove(target, &self.catalog_dir)?;
return self.do_clone(target);
}
match self.git.remote_url(target) {
Ok(ref url) if urls_match(url, &self.repo_url) => {
info!(dir = %target.display(), "catalog repo up to date remote; fetching updates");
self.do_update(target)
}
Ok(actual) => {
warn!(
actual = %actual,
expected = %self.repo_url,
"catalog repo has wrong remote; re-cloning"
);
guard_remove(target, &self.catalog_dir)?;
self.do_clone(target)
}
Err(e) => {
warn!(err = %e, "catalog repo appears corrupt; re-cloning");
guard_remove(target, &self.catalog_dir)?;
self.do_clone(target)
}
}
}
fn do_clone(&self, target: &Path) -> Result<(), CatalogError> {
self.git
.clone_repo(&self.repo_url, &self.git_ref, target)
.map_err(|e| CatalogError::Git(e.to_string()))
}
fn do_update(&self, target: &Path) -> Result<(), CatalogError> {
match self.git.fetch_and_reset(target, &self.git_ref) {
Ok(()) => Ok(()),
Err(e) => {
warn!(err = %e, "catalog fetch/reset failed; re-cloning from scratch");
guard_remove(target, &self.catalog_dir)?;
self.do_clone(target)
}
}
}
fn ttl_valid(&self) -> bool {
let sentinel = self.catalog_dir.join(SYNC_SENTINEL);
match std::fs::metadata(&sentinel) {
Ok(meta) => match meta.modified() {
Ok(mtime) => {
let age = SystemTime::now()
.duration_since(mtime)
.unwrap_or(Duration::from_secs(u64::MAX));
age < Duration::from_secs(self.ttl_secs)
}
Err(e) => {
warn!("catalog sentinel mtime error: {e}");
false
}
},
Err(_) => false,
}
}
fn catalog_subdir(&self, subdir: &str) -> std::path::PathBuf {
let preferred = self.catalog_dir.join("repo").join(".claude").join(subdir);
if preferred.is_dir() {
return preferred;
}
self.catalog_dir.join("repo").join(subdir)
}
fn count_files(&self, subdir: &str) -> usize {
let dir = self.catalog_subdir(subdir);
match std::fs::read_dir(&dir) {
Ok(entries) => {
let entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
let file_count = entries
.iter()
.filter(|e| e.file_type().map(|t| !t.is_dir()).unwrap_or(false))
.count();
let dir_count = entries
.iter()
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.count();
if file_count > 0 {
file_count
} else {
dir_count
}
}
Err(_) => 0,
}
}
pub fn list_agents(&self) -> Vec<String> {
list_names(&self.catalog_subdir("agents"))
}
pub fn list_skills(&self) -> Vec<String> {
list_skill_names(&self.catalog_subdir("skills"))
}
pub fn manifest_path(&self) -> PathBuf {
self.catalog_dir
.join("repo")
.join(".claude")
.join("manifest.toml")
}
pub fn catalog_root(&self) -> &Path {
&self.catalog_dir
}
}
fn guard_remove(target: &Path, catalog_dir: &Path) -> Result<(), CatalogError> {
let canon_target = target.canonicalize().map_err(CatalogError::Io)?;
let canon_catalog = catalog_dir.canonicalize().map_err(CatalogError::Io)?;
if canon_target == canon_catalog {
return Err(CatalogError::Git(format!(
"safety: refusing to remove catalog root '{}'",
target.display()
)));
}
if !canon_target.starts_with(&canon_catalog) {
return Err(CatalogError::Git(format!(
"safety: refusing to remove '{}' — not inside catalog dir '{}'",
target.display(),
catalog_dir.display()
)));
}
std::fs::remove_dir_all(target).map_err(CatalogError::Io)
}
fn list_names(dir: &Path) -> Vec<String> {
match std::fs::read_dir(dir) {
Ok(entries) => {
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| !t.is_dir()).unwrap_or(false))
.filter_map(|e| {
e.path()
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_owned())
})
.collect();
names.sort();
names
}
Err(_) => Vec::new(),
}
}
fn list_skill_names(dir: &Path) -> Vec<String> {
match std::fs::read_dir(dir) {
Ok(entries) => {
let all: Vec<_> = entries.filter_map(|e| e.ok()).collect();
let dir_names: Vec<String> = all
.iter()
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| {
e.path()
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_owned())
})
.collect();
if !dir_names.is_empty() {
let mut sorted = dir_names;
sorted.sort();
return sorted;
}
let mut names: Vec<String> = all
.iter()
.filter(|e| e.file_type().map(|t| !t.is_dir()).unwrap_or(false))
.filter_map(|e| {
e.path()
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_owned())
})
.collect();
names.sort();
names
}
Err(_) => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provisioner::FakeGitBackend;
use tempfile::TempDir;
fn make_sync(root: &TempDir) -> CatalogSync<FakeGitBackend> {
CatalogSync::with_repo(
FakeGitBackend::new(),
root.path().to_owned(),
"https://github.com/bobmatnyc/claude-mpm",
"main",
)
}
#[test]
fn catalog_sync_fetches_on_first_call() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
let result = sync.sync(false).unwrap();
assert!(result.fetched, "first sync must fetch");
}
#[test]
fn catalog_sync_reports_empty_agents() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
let result = sync.sync(false).unwrap();
assert!(result.fetched, "first sync must fetch");
assert_eq!(result.agent_count, 0, "no agents present in the fake clone");
assert!(
result.agents_empty(),
"an agent-less catalog must be flagged empty"
);
}
#[test]
fn catalog_sync_not_empty_when_agents_present() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
sync.sync(false).unwrap();
let agents_dir = root.path().join("repo").join(".claude").join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("rust-engineer.md"), "# rust").unwrap();
let result = sync.sync(true).unwrap();
assert_eq!(result.agent_count, 1);
assert!(!result.agents_empty());
}
#[test]
fn catalog_sync_skips_on_ttl_valid() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
let r1 = sync.sync(false).unwrap();
assert!(r1.fetched);
let r2 = sync.sync(false).unwrap();
assert!(!r2.fetched, "second sync within TTL must skip fetch");
}
#[test]
fn catalog_sync_force_bypasses_ttl() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
sync.sync(false).unwrap();
let result = sync.sync(true).unwrap();
assert!(result.fetched, "force sync must fetch regardless of TTL");
}
#[test]
fn catalog_ls_lists_agents() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
sync.sync(false).unwrap();
let agents_dir = root.path().join("repo").join(".claude").join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("engineer.md"), "# engineer").unwrap();
std::fs::write(agents_dir.join("qa.md"), "# qa").unwrap();
let agents = sync.list_agents();
assert!(
agents.contains(&"engineer".to_owned()),
"agents: {agents:?}"
);
assert!(agents.contains(&"qa".to_owned()), "agents: {agents:?}");
}
#[test]
fn catalog_ls_lists_skills() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
sync.sync(false).unwrap();
let skills_dir = root.path().join("repo").join(".claude").join("skills");
std::fs::create_dir_all(skills_dir.join("trusty-memory")).unwrap();
std::fs::create_dir_all(skills_dir.join("auto-bug-reporter")).unwrap();
std::fs::write(
skills_dir.join("trusty-memory").join("SKILL.md"),
"# trusty-memory",
)
.unwrap();
let skills = sync.list_skills();
assert!(
skills.contains(&"trusty-memory".to_owned()),
"skills: {skills:?}"
);
assert!(
skills.contains(&"auto-bug-reporter".to_owned()),
"skills: {skills:?}"
);
}
#[test]
fn catalog_config_overrides_env_and_default() {
let root = crate::test_support::hermetic_temp_dir();
let cfg = crate::core::config::ManifestConfig {
repo: Some("https://github.com/me/fork".to_owned()),
git_ref: Some("dev".to_owned()),
ttl_hours: Some(2),
};
let sync =
CatalogSync::with_config(FakeGitBackend::new(), root.path().to_owned(), Some(&cfg));
assert_eq!(sync.repo_url, "https://github.com/me/fork");
assert_eq!(sync.git_ref, "dev");
assert_eq!(sync.ttl_secs, 2 * 3600);
}
#[test]
fn catalog_manifest_path_points_at_repo_dotclaude() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
assert_eq!(
sync.manifest_path(),
root.path()
.join("repo")
.join(".claude")
.join("manifest.toml")
);
}
#[test]
fn catalog_root_returns_catalog_dir() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
assert_eq!(sync.catalog_root(), root.path());
}
#[test]
fn catalog_root_for_joins_subdir() {
let fw_root = std::path::Path::new("/home/.trusty-mpm");
assert_eq!(
super::catalog_root_for(fw_root),
std::path::PathBuf::from("/home/.trusty-mpm/catalog")
);
}
#[test]
fn for_framework_uses_catalog_root() {
let fw_root = crate::test_support::hermetic_temp_dir();
let sync = CatalogSync::for_framework(FakeGitBackend::new(), fw_root.path(), None);
assert_eq!(sync.catalog_root(), fw_root.path().join("catalog"));
}
#[test]
fn catalog_ls_falls_back_to_legacy_agent_path() {
let root = crate::test_support::hermetic_temp_dir();
let sync = make_sync(&root);
sync.sync(false).unwrap();
let agents_dir = root.path().join("repo").join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("legacy-agent.md"), "# legacy").unwrap();
let agents = sync.list_agents();
assert!(
agents.contains(&"legacy-agent".to_owned()),
"agents from legacy path: {agents:?}"
);
}
#[test]
fn guard_remove_rejects_path_outside_catalog() {
let root = crate::test_support::hermetic_temp_dir();
let catalog_dir = root.path().join("catalog");
std::fs::create_dir_all(&catalog_dir).unwrap();
let outside = root.path().join("not-catalog");
std::fs::create_dir_all(&outside).unwrap();
assert!(
guard_remove(&outside, &catalog_dir).is_err(),
"path outside catalog must be rejected"
);
assert!(
guard_remove(&catalog_dir, &catalog_dir).is_err(),
"catalog root itself must be rejected"
);
}
}