trusty-mpm 0.8.0

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Catalog synchronization from the claude-mpm GitHub repository.
//!
//! Why: the claude-mpm repository is the authoritative source for ~40 agents
//! and ~25 skills; syncing from it eliminates manual re-porting and keeps the
//! catalog automatically current.
//! What: CatalogSync fetches the repository into ~/.trusty-mpm/catalog/ via
//! a GitBackend seam (real git in production, FakeGitBackend in tests), checks
//! a TTL to skip redundant fetches, and exposes list_agents/list_skills for
//! the `tm catalog ls` command.
//! Test: catalog_sync_fetches_on_first_call, catalog_sync_skips_on_ttl_valid,
//! catalog_sync_force_bypasses_ttl.

use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use thiserror::Error;
use tracing::{debug, info, warn};

use crate::provisioner::GitBackend;

/// Default TTL for the catalog cache: 24 hours.
const DEFAULT_TTL_HOURS: u64 = 24;
/// Default claude-mpm repository URL.
const DEFAULT_CATALOG_REPO: &str = "https://github.com/bobmatnyc/claude-mpm";
/// Sentinel file written after a successful sync.
const SYNC_SENTINEL: &str = ".catalog_synced_at";
/// Default git ref for the catalog repo.
const DEFAULT_CATALOG_REF: &str = "main";

/// Errors produced by catalog sync operations.
///
/// Why: callers need structured errors to distinguish git failures from
/// I/O errors and to produce actionable CLI output.
/// What: one variant per failure class.
/// Test: exercised by CatalogSync unit tests.
#[derive(Debug, Error)]
pub enum CatalogError {
    /// The git clone/pull operation failed.
    #[error("git sync error: {0}")]
    Git(String),

    /// An I/O operation on the catalog directory failed.
    #[error("catalog I/O error: {0}")]
    Io(#[from] std::io::Error),
}

/// Result of a catalog sync operation.
///
/// Why: CLI output and tests need to know whether the catalog was freshly
/// fetched or served from cache.
/// What: bundles the number of agents and skills found, and a flag indicating
/// whether a real fetch was performed.
/// Test: asserted by catalog_sync_fetches_on_first_call.
#[derive(Debug)]
pub struct CatalogSyncResult {
    /// True if a git fetch was actually performed (TTL expired or force=true).
    pub fetched: bool,
    /// Number of agent files found in the catalog after sync.
    pub agent_count: usize,
    /// Number of skill files found in the catalog after sync.
    pub skill_count: usize,
}

/// Synchronizes the claude-mpm agent/skill catalog from a git remote.
///
/// Why: the session manager needs deployed agents and skills for prepare_session;
/// syncing from the claude-mpm repo is the single source of truth.
/// What: clones or updates the catalog repo under ~/.trusty-mpm/catalog/,
/// respects a TTL to avoid redundant fetches, and exposes list methods for
/// the CLI.
/// Test: unit tests use FakeGitBackend; integration test uses a real repo (#[ignore]).
pub struct CatalogSync<G: GitBackend> {
    git: G,
    /// Root directory for the cached catalog (~/.trusty-mpm/catalog/).
    catalog_dir: PathBuf,
    /// URL of the claude-mpm repository.
    repo_url: String,
    /// Git ref to sync.
    git_ref: String,
    /// Cache TTL in seconds.
    ttl_secs: u64,
}

impl<G: GitBackend> CatalogSync<G> {
    /// Construct a CatalogSync with the given git backend and catalog directory.
    ///
    /// Why: the catalog directory and repo URL are injectable so tests can use
    /// a tempdir and a fake remote without touching the real filesystem.
    /// What: stores all config; no I/O at construction time.
    /// Test: used in every CatalogSync unit test.
    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,
        }
    }

    /// Construct with explicit repo_url and git_ref (for testing).
    ///
    /// Why: unit tests need to pass a specific repo URL without relying on env vars.
    /// What: overrides the repo_url and git_ref fields.
    /// Test: used by catalog_sync unit tests.
    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,
        }
    }

    /// Sync the catalog from the remote, respecting the TTL.
    ///
    /// Why: the session manager calls this on `tm catalog sync` and optionally
    /// on daemon start to ensure agents/skills are available.
    /// What: checks the sentinel file's mtime against the TTL; if the TTL has
    /// not expired (and force=false), skips the fetch. Otherwise clones the
    /// catalog repo into catalog_dir and writes the sentinel.
    /// Test: catalog_sync_fetches_on_first_call, catalog_sync_skips_on_ttl_valid.
    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");

        // Clone into a temporary subdir then move, to avoid partial state.
        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()))?;

        // Write the sentinel to record when we last synced.
        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,
        })
    }

    /// Return true if the catalog was synced within the TTL window.
    ///
    /// Why: avoids redundant network fetches when the catalog is fresh.
    /// What: reads the sentinel file mtime and compares to now - ttl_secs.
    /// Test: catalog_sync_skips_on_ttl_valid.
    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,
        }
    }

    /// Resolve the directory that holds a given catalog resource type.
    ///
    /// Why: the claude-mpm repo stores agents and skills under `repo/.claude/agents`
    /// and `repo/.claude/skills/`; older forks or mirrors may use the flat
    /// `repo/agents` / `repo/skills` layout. This helper tries the preferred
    /// `.claude/<subdir>` location first and falls back to the legacy flat path.
    /// What: returns the first of (`repo/.claude/<subdir>`, `repo/<subdir>`) that
    /// exists as a directory; falls back to `repo/<subdir>` when neither exists.
    /// Test: catalog_ls_lists_agents uses the `.claude/agents` path after the real
    /// repo layout was confirmed via `find ~/.trusty-mpm/catalog/repo/.claude`.
    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;
        }
        // Fall back to the legacy flat layout.
        self.catalog_dir.join("repo").join(subdir)
    }

    /// Count files in a catalog subdirectory (agents or skills).
    ///
    /// Why: the CLI needs to show how many agents and skills are cached.
    /// What: resolves the correct subdir path via `catalog_subdir`, then counts
    /// non-directory entries. Skills may be stored as directories (one dir per
    /// skill containing SKILL.md), so for skills we count directories instead.
    /// Test: catalog_sync_fetches_on_first_call asserts agent_count >= 0.
    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();
                // Skills are stored as directories (one dir per skill).
                // Agents are stored as flat .md files. Count whichever is present.
                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();
                // Return whichever is non-zero; prefer files (agents) over dirs (skills).
                if file_count > 0 {
                    file_count
                } else {
                    dir_count
                }
            }
            Err(_) => 0,
        }
    }

    /// List agent file names from the cached catalog.
    ///
    /// Why: `tm catalog ls` needs a listing of available agents.
    /// What: returns file stems from `catalog_subdir("agents")` — preferring
    /// `.claude/agents` over the legacy `agents` flat path.
    /// Test: catalog_ls_lists_agents.
    pub fn list_agents(&self) -> Vec<String> {
        list_names(&self.catalog_subdir("agents"))
    }

    /// List skill directory names from the cached catalog.
    ///
    /// Why: `tm catalog ls` needs a listing of available skills.
    /// What: returns directory names from `catalog_subdir("skills")` — preferring
    /// `.claude/skills` over the legacy `skills` flat path. Skills are stored
    /// as one directory per skill containing a SKILL.md file.
    /// Test: catalog_ls_lists_skills.
    pub fn list_skills(&self) -> Vec<String> {
        list_skill_names(&self.catalog_subdir("skills"))
    }
}

/// Return the file stems in a directory (for flat-file layouts like agents).
///
/// Why: catalog listing needs clean names without extensions.
/// What: reads directory entries, skips directories, strips extensions, and
/// returns sorted names.
/// Test: used by list_agents which is tested in unit tests.
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(),
    }
}

/// Return the subdirectory names in a skills directory.
///
/// Why: in the claude-mpm repo, each skill is stored as a directory containing
/// a SKILL.md file (e.g. `.claude/skills/trusty-memory/SKILL.md`). The catalog
/// listing should show skill directory names, not file extensions.
/// What: reads directory entries, includes only directories, and returns sorted
/// names. Falls back to flat-file stems when no directories are found (legacy).
/// Test: used by list_skills which is tested in unit tests.
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();
            // Prefer directory-per-skill layout (`.claude/skills/<name>/SKILL.md`).
            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;
            }
            // Fall back: flat .md files (legacy layout).
            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);

        // First sync writes the sentinel.
        let r1 = sync.sync(false).unwrap();
        assert!(r1.fetched);

        // Second sync within TTL should skip fetch.
        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);

        // Establish a fresh sentinel.
        sync.sync(false).unwrap();

        // Force sync should fetch again despite fresh TTL.
        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();

        // Simulate the real claude-mpm repo layout: agents are at
        // repo/.claude/agents/ (not repo/agents/).
        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();

        // Simulate the real claude-mpm repo layout: skills are stored as
        // directories under repo/.claude/skills/ (one dir per skill).
        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() {
        // When .claude/agents doesn't exist, should fall back to repo/agents/.
        let root = TempDir::new().unwrap();
        let sync = make_sync(&root);
        sync.sync(false).unwrap();

        // Use legacy flat path (no .claude dir).
        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:?}"
        );
    }
}