use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use thiserror::Error;
use tracing::{debug, info, warn};
use crate::provisioner::GitBackend;
const DEFAULT_TTL_HOURS: u64 = 24;
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,
}
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 {
let ttl_hours = std::env::var("TRUSTY_MPM_CATALOG_TTL_HOURS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_TTL_HOURS);
let repo_url = std::env::var("TRUSTY_MPM_CATALOG_REPO")
.unwrap_or_else(|_| DEFAULT_CATALOG_REPO.to_owned());
let git_ref = std::env::var("TRUSTY_MPM_CATALOG_REF")
.unwrap_or_else(|_| DEFAULT_CATALOG_REF.to_owned());
Self {
git,
catalog_dir,
repo_url,
git_ref,
ttl_secs: ttl_hours * 3600,
}
}
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");
let clone_target = self.catalog_dir.join("repo");
std::fs::create_dir_all(&clone_target)?;
self.git
.clone_repo(&self.repo_url, &self.git_ref, &clone_target)
.map_err(|e| CatalogError::Git(e.to_string()))?;
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"
);
Ok(CatalogSyncResult {
fetched: true,
agent_count,
skill_count,
})
}
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"))
}
}
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 = TempDir::new().unwrap();
let sync = make_sync(&root);
let result = sync.sync(false).unwrap();
assert!(result.fetched, "first sync must fetch");
}
#[test]
fn catalog_sync_skips_on_ttl_valid() {
let root = TempDir::new().unwrap();
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 = TempDir::new().unwrap();
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 = TempDir::new().unwrap();
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 = TempDir::new().unwrap();
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_ls_falls_back_to_legacy_agent_path() {
let root = TempDir::new().unwrap();
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:?}"
);
}
}