trusty-mpm 1.8.2

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
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! CLI command handlers for the standalone managed driver.
//!
//! Why: the standalone managed driver adds a new alias-keyed registry and
//! lifecycle on top of the existing session-manager machinery (DOC-24). Keeping
//! these handlers in their own file preserves the existing handlers untouched
//! and stays under the 500-SLOC cap.
//! What: eight thin handlers — `register_cmd`, `ls_cmd`, `load_cmd`, `run_cmd`,
//! `path_cmd`, `login_cmd`, `rm_cmd`, and `update_cmd` — each accepting a
//! `&ManagedPaths` resolved by `resolve_managed_paths` in `managed_root.rs`
//! (closes #1566). `rm_cmd` deregisters an alias and removes its project dir.
//! `update_cmd` pulls the latest changes and idempotently re-deploys managed
//! config — reusing the same `load_alias` function that `load_cmd` calls.
//! Test: `register_cmd` in `register_args_tests.rs`; registry semantics in
//! `core::standalone::registry`'s `test_registry_*`. (#4912: the former pointer
//! here named `cli_parses_register`/`cli_parses_ls`, neither of which exists.)

use std::path::Path;

use anyhow::Context;
use trusty_mpm::core::standalone::registry::RegistryEntry;

use super::managed_root::ManagedPaths;

/// Handle `tm register <owner/repo> [alias] [--force]`.
///
/// Why: the register command is the first step of the standalone lifecycle —
/// it persists the alias→URL mapping without cloning.
/// What: resolves the positionals via
/// [`super::register_args::resolve_register_args`] (which accepts both the
/// `<url> [alias]` and legacy `<alias> <url>` orders and derives an `owner-repo`
/// alias when none was given), validates the alias, calls `ManagedRegistry::add`,
/// saves, and prints [`register_alias`]'s `registered <alias> → <url>` line to
/// stdout. A derived alias that is already bound to a different URL refuses
/// without touching the registry — `add` errors before mutating and `save` is
/// never reached.
/// Test: `register_args_tests.rs`; registry semantics in registry tests.
pub(crate) fn register_cmd(
    paths: &ManagedPaths,
    first: &str,
    second: Option<&str>,
    force: bool,
) -> anyhow::Result<()> {
    println!("{}", register_alias(paths, first, second, force)?);
    Ok(())
}

/// [`register_cmd`] without the print: registers the alias and returns the
/// confirmation line.
///
/// Why (#9124): `tm register https://user:<token>@host/o/r` printed the token
/// and wrote it to `registry.json`. Nothing downstream needs it there: `tm
/// load` runs a plain `git clone`, which asks for credentials when the URL
/// carries none, and a stored token would also land in the clone's
/// `.git/config`. So the secret is stripped before the URL is stored, with a
/// stderr notice, rather than the URL being refused. #9155: only the secret
/// goes — `git@` in `git@github.com:o/r.git` is the ssh login, and dropping it
/// made `tm load` log in as the local user.
/// What: resolves the positionals, takes the URL's [`storage_form`], adds and
/// saves, and returns `registered <alias> → <url>` with the stored URL, which
/// carries no secret: [`super::register_args::resolved_url`] already dropped
/// any query string.
/// Test: `register_never_prints_or_stores_an_embedded_token_9124`,
/// `register_keeps_the_ssh_login_and_drops_only_the_secret_9155`.
pub(crate) fn register_alias(
    paths: &ManagedPaths,
    first: &str,
    second: Option<&str>,
    force: bool,
) -> anyhow::Result<String> {
    // #4912: URL first, alias optional — and the legacy order still accepted.
    let (alias, raw_url) = super::register_args::resolve_register_args(first, second)?;
    let derived = second.is_none();
    // #9124: a credential embedded in the URL is never written to disk.
    let (url, notice) = storage_form(&raw_url);
    if let Some(notice) = notice {
        eprintln!("{notice}");
    }

    let root = &paths.root;
    let mut registry = trusty_mpm::core::standalone::registry::ManagedRegistry::load(root)
        .with_context(|| format!("failed to load registry from {}", root.display()))?;
    registry.add(&alias, &url, force).with_context(|| {
        if derived {
            format!(
                "failed to register alias '{alias}' (derived from '{url}') — \
                 pass an explicit alias to register it under a different name"
            )
        } else {
            format!("failed to register alias '{alias}'")
        }
    })?;
    registry.save().context("failed to save registry")?;
    Ok(format!("registered {alias} → {url}"))
}

/// The stderr notice [`storage_form`] returns when it removed a secret.
pub(crate) const SECRET_REMOVED_NOTICE: &str = "tm register: removed the password or token \
     embedded in the URL before storing it; git asks for credentials when it clones the repository";

/// The URL `tm register` stores for `raw_url`, and the notice to print when
/// that removed a secret (#9124, #9155).
///
/// What: [`trusty_common::url_userinfo::strip_url_secret`] — the `:password`
/// on any scheme, the whole userinfo on http(s) — and
/// [`SECRET_REMOVED_NOTICE`] only when it changed the URL. The notice names no
/// part of the URL, so it cannot carry the secret.
/// Test: `register_keeps_the_ssh_login_and_drops_only_the_secret_9155`.
pub(crate) fn storage_form(raw_url: &str) -> (String, Option<&'static str>) {
    // #9155: `strip_userinfo` also dropped the `git@` ssh login.
    let url = trusty_common::url_userinfo::strip_url_secret(raw_url);
    let notice = (url != raw_url).then_some(SECRET_REMOVED_NOTICE);
    (url.into_owned(), notice)
}

/// Handle `tm ls [--json]`.
///
/// Why: operators need a quick overview of (a) local git project paths that
/// `tm` has visited and auto-registered, and (b) the managed fleet aliases
/// registered via `tm register`.
/// What: prints two sections — "Local project aliases" (auto-populated from
/// git roots visited by `tm`) and "Managed fleet aliases" (DOC-24 GitHub-URL
/// entries). Empty sections are omitted. `--json` outputs a combined JSON
/// object with both arrays for scripted consumers.
/// Test: flag parsing in `cli_parses_ls_json`, `cli_parses_ls_current`,
/// `cli_parses_ls_projects` (`tests_behavior_d_tests.rs`); the printing itself
/// is stdout-only with nothing to assert on (#4912 — this replaced a pointer to
/// `cli_parses_ls`, which never existed).
pub(crate) fn ls_cmd(paths: &ManagedPaths, json: bool) -> anyhow::Result<()> {
    let root = &paths.root;

    // Load the local project-path alias store (non-fatal if absent).
    let local_entries = trusty_mpm::core::project_aliases::ProjectAliasStore::load(root)
        .unwrap_or_default()
        .list();

    // Load the standalone managed-fleet registry.
    let registry = trusty_mpm::core::standalone::registry::ManagedRegistry::load(root)
        .with_context(|| format!("failed to load registry from {}", root.display()))?;
    let fleet_entries = registry.list();

    if json {
        // JSON output: combined object with two top-level keys.
        let local_rows: Vec<serde_json::Value> = local_entries
            .iter()
            .map(|e| {
                serde_json::json!({
                    "alias": e.alias,
                    "path": e.path,
                })
            })
            .collect();
        let fleet_rows: Vec<serde_json::Value> = fleet_entries
            .iter()
            .map(|e| fleet_json_row(e, registry.is_loaded(&e.alias, root), root))
            .collect();
        println!(
            "{}",
            serde_json::to_string_pretty(&serde_json::json!({
                "local_projects": local_rows,
                "managed_fleet": fleet_rows,
            }))?
        );
        return Ok(());
    }

    // Human-readable output.
    if local_entries.is_empty() && fleet_entries.is_empty() {
        println!("No aliases registered.");
        println!("  Run `tm` from a git project to auto-register a local alias.");
        println!("  Run `tm register <owner/repo>` to add a managed fleet alias.");
        return Ok(());
    }

    // ── Section 1: Local project aliases ──────────────────────────────────
    if !local_entries.is_empty() {
        println!("Local project aliases ({}):", local_entries.len());
        // Compute column widths for alignment.
        let alias_w = local_entries
            .iter()
            .map(|e| e.alias.len())
            .max()
            .unwrap_or(5)
            .max(5);
        println!("  {:<alias_w$}  PATH", "ALIAS");
        println!("  {}  {}", "─".repeat(alias_w), "─".repeat(40));
        for e in &local_entries {
            println!("  {:<alias_w$}  {}", e.alias, e.path.display());
        }
    }

    // ── Section 2: Managed fleet aliases ──────────────────────────────────
    if !fleet_entries.is_empty() {
        if !local_entries.is_empty() {
            println!();
        }
        println!("Managed fleet aliases ({}):", fleet_entries.len());
        let alias_w = fleet_entries
            .iter()
            .map(|e| e.alias.len())
            .max()
            .unwrap_or(5)
            .max(5);
        println!("  {:<alias_w$}  {:<10}  URL", "ALIAS", "LOADED");
        println!(
            "  {}  {}  {}",
            "─".repeat(alias_w),
            "─".repeat(10),
            "─".repeat(30)
        );
        for e in &fleet_entries {
            let loaded = registry.is_loaded(&e.alias, root);
            println!("{}", fleet_table_row(e, loaded, alias_w));
        }
    }

    Ok(())
}

/// One `managed_fleet` object of `tm list --json`.
///
/// Why (#9124, #9227): an entry stored before #9124 may carry a token, and
/// its password may hold a quote or space that free-text redaction misses.
/// What: the entry's fields, with the URL through `redact_stored_url`.
/// Test: `fleet_rows_never_print_a_quoted_password`.
fn fleet_json_row(e: &RegistryEntry, loaded: bool, root: &Path) -> serde_json::Value {
    serde_json::json!({
        "alias": e.alias,
        "url": trusty_mpm::core::remote_url_redact::redact_stored_url(&e.url),
        "ref": e.git_ref,
        "loaded": loaded,
        "repo_path": root.join("projects").join(&e.alias).join("repo"),
    })
}

/// One managed-fleet line of the `tm list` table, URL redacted as in
/// [`fleet_json_row`].
/// Test: `fleet_rows_never_print_a_quoted_password`.
fn fleet_table_row(e: &RegistryEntry, loaded: bool, alias_w: usize) -> String {
    let loaded = if loaded { "yes" } else { "no" };
    let url = trusty_mpm::core::remote_url_redact::redact_stored_url(&e.url);
    format!("  {:<alias_w$}  {:<10}  {url}", e.alias, loaded)
}

/// Handle `tm load <alias>`.
///
/// Why: `load` is the idempotent step that clones (or refreshes) a registered
/// alias and generates the project-local managed configuration.
/// What: calls `load_alias`, then prints the repo path to stdout.
/// Test: `cli_parses_load` in tests.rs.
pub(crate) fn load_cmd(paths: &ManagedPaths, alias: &str) -> anyhow::Result<()> {
    let root = &paths.root;
    let cfg_dir = &paths.claude_config_dir;
    trusty_mpm::core::standalone::global_config::ensure_global_config_dir(root, cfg_dir)?;
    let repo_path = trusty_mpm::core::standalone::load::load_alias(alias, root, cfg_dir)
        .with_context(|| format!("failed to load alias '{alias}'"))?;
    println!("{}", repo_path.display());
    Ok(())
}

/// Handle `tm run <alias>`.
///
/// Why: `tm run` is the primary interactive entry point — it ensures the alias
/// is loaded and launches `claude` with full isolation via CLAUDE_CONFIG_DIR.
/// What: calls `run_alias` from the `run` module which loads if needed, checks
/// credentials, and spawns `claude` with inherited stdio.
/// Test: `cli_parses_run` in tests.rs; end-to-end requires `claude` binary.
pub(crate) fn run_cmd(paths: &ManagedPaths, alias: &str) -> anyhow::Result<()> {
    let root = &paths.root;
    let cfg_dir = &paths.claude_config_dir;
    trusty_mpm::core::standalone::global_config::ensure_global_config_dir(root, cfg_dir)?;
    trusty_mpm::core::standalone::run::run_alias(alias, root, cfg_dir)
        .with_context(|| format!("failed to run alias '{alias}'"))
}

/// Handle `tm path <alias>`.
///
/// Why: prints the stable `repo/` path so IDEs or scripts can open the project
/// directory directly (DOC-24 SPEC-STANDALONE-MPM-06).
/// What: resolves the alias's repo path via the marker file and prints it.
/// Test: `cli_parses_path` in tests.rs.
pub(crate) fn path_cmd(paths: &ManagedPaths, alias: &str) -> anyhow::Result<()> {
    let root = &paths.root;
    let repo = trusty_mpm::core::standalone::run::resolve_repo_path(alias, root)
        .with_context(|| format!("failed to resolve path for alias '{alias}'"))?;
    println!("{}", repo.display());
    Ok(())
}

/// Handle `tm rm <alias>` — deregister and remove the project directory.
///
/// Why: operators need a clean teardown verb that pairs with `register/load`.
/// The shared `<root>/claude-config/` CLAUDE_CONFIG_DIR is intentionally
/// untouched — it is shared across all aliases and must not be wiped by a
/// single-alias removal.
/// What: removes the registry entry via `ManagedRegistry::remove`, saves the
/// updated registry, then deletes `<root>/projects/<alias>/` with
/// `fs::remove_dir_all`. Errors clearly when the alias is unknown in the
/// registry. The project dir deletion is best-effort (succeeds even if the
/// dir was already absent — i.e. never loaded).
/// Test: `test_rm_cmd_removes_entry_and_dir`,
/// `test_rm_cmd_errors_on_unknown_alias`,
/// `test_rm_cmd_leaves_claude_config_intact` in tests_behavior_b_tests.rs.
pub(crate) fn rm_cmd(paths: &ManagedPaths, alias: &str) -> anyhow::Result<()> {
    let root = &paths.root;
    let mut registry = trusty_mpm::core::standalone::registry::ManagedRegistry::load(root)
        .with_context(|| format!("failed to load registry from {}", root.display()))?;
    registry
        .remove(alias)
        .with_context(|| format!("alias '{alias}' is not registered"))?;
    registry
        .save()
        .context("failed to save registry after rm")?;

    let project_dir = root.join("projects").join(alias);
    if project_dir.exists() {
        std::fs::remove_dir_all(&project_dir).with_context(|| {
            format!(
                "failed to remove project directory {}",
                project_dir.display()
            )
        })?;
    }
    println!(
        "removed {alias} (registry entry deleted; project dir: {})",
        project_dir.display()
    );
    Ok(())
}

/// Handle `tm update [alias]` — refresh loaded project(s).
///
/// Why: operators need an idempotent "bring this project up to date" verb that
/// mirrors what `load` does (pull + re-deploy) without requiring a full `rm`
/// and re-`load` cycle. With no alias, updates all currently-loaded aliases so
/// a single `tm update` keeps the whole fleet current.
/// What: for each target alias, calls `load_alias` (the same function `load_cmd`
/// uses) which performs `git pull --ff-only` on the existing checkout and
/// idempotently re-runs `prepare_session` + `write_marker`.
/// Single-alias path: if the alias is registered but the project dir does not
/// yet exist, errors IMMEDIATELY with a clear hint to run `tm load <alias>`
/// first (does not defer to a generic end-of-loop message).
/// No-alias path: iterates all registry entries whose project dir already
/// exists (i.e. loaded), updates each; when any fail the returned error names
/// all failed aliases by name.
/// Test: update_cmd_errors_if_alias_not_in_registry,
/// update_cmd_errors_if_not_loaded, and
/// update_cmd_all_skips_unloaded_returns_ok_when_none_loaded
/// in tests_behavior_b_tests.rs.
pub(crate) fn update_cmd(paths: &ManagedPaths, alias: Option<&str>) -> anyhow::Result<()> {
    update_cmd_with_exe(paths, alias, None)
}

/// [`update_cmd`] with the hook binary pinned by the caller.
///
/// Why (#7244): the config-dir scaffolding this runs first writes the managed
/// hook triad, and that write refuses a build-artifact binary. On a runner with
/// no installed `tm` the refusal surfaced as `update_cmd`'s error, so the three
/// tests asserting on the ALIAS message read a hook-resolution message instead.
/// What: the body of [`update_cmd`], forwarding `hook_exe` to
/// [`trusty_mpm::core::standalone::global_config::ensure_global_config_dir_with_exe`].
/// Test: `update_cmd_errors_if_alias_not_in_registry`,
/// `update_cmd_errors_if_not_loaded`,
/// `update_cmd_all_skips_unloaded_returns_ok_when_none_loaded`.
pub(crate) fn update_cmd_with_exe(
    paths: &ManagedPaths,
    alias: Option<&str>,
    hook_exe: Option<&std::path::Path>,
) -> anyhow::Result<()> {
    let root = &paths.root;
    let cfg_dir = &paths.claude_config_dir;
    trusty_mpm::core::standalone::global_config::ensure_global_config_dir_with_exe(
        root, cfg_dir, hook_exe,
    )?;

    let registry = trusty_mpm::core::standalone::registry::ManagedRegistry::load(root)
        .with_context(|| format!("failed to load registry from {}", root.display()))?;

    // Single-alias fast path: validate, check loaded state, then update.
    if let Some(a) = alias {
        registry
            .get(a)
            .with_context(|| format!("alias '{a}' is not registered"))?;
        let repo_dir = root.join("projects").join(a).join("repo");
        if !repo_dir.exists() {
            anyhow::bail!(
                "alias '{a}' is registered but has not been loaded yet; \
                 run `tm load {a}` to clone and configure it first"
            );
        }
        trusty_mpm::core::standalone::load::load_alias(a, root, cfg_dir)
            .with_context(|| format!("failed to update alias '{a}'"))?;
        println!("updated {a}");
        return Ok(());
    }

    // No-alias path: update all loaded aliases.
    let targets: Vec<String> = registry
        .list()
        .into_iter()
        .filter(|e| root.join("projects").join(&e.alias).join("repo").exists())
        .map(|e| e.alias)
        .collect();

    if targets.is_empty() {
        println!("no loaded aliases found — nothing to update");
        return Ok(());
    }

    let mut failed: Vec<String> = Vec::new();
    for target in &targets {
        match trusty_mpm::core::standalone::load::load_alias(target, root, cfg_dir) {
            Ok(_) => println!("updated {target}"),
            Err(e) => {
                eprintln!("error updating '{target}': {e}");
                failed.push(target.clone());
            }
        }
    }
    if !failed.is_empty() {
        anyhow::bail!("failed to update: {}", failed.join(", "));
    }
    Ok(())
}

/// Handle `tm login` (WI-10 — one-time keychain auth setup).
///
/// Why: `CLAUDE_CONFIG_DIR` (A9) relocates the entire `~/.claude/` tree including
/// the macOS Keychain entry used for Claude Max/Pro OAuth. A fresh
/// `~/.trusty-mpm/claude-config/` has no keychain entry, so `tm run` sessions
/// report "Not logged in". `tm login` runs `claude auth login` under the
/// tm-global `CLAUDE_CONFIG_DIR` so the OAuth flow creates a keychain entry
/// keyed to that path, enabling all future `tm run` sessions to authenticate
/// without repeating this step (keychain entry persists across sessions).
/// What: ensures the tm-global config dir exists via
/// `ensure_global_config_dir`, then spawns `claude auth login` with
/// `CLAUDE_CONFIG_DIR=<root>/claude-config` and inherited stdio so the
/// user can complete the browser/OAuth flow. Prints guidance before and after.
/// Returns an error (non-zero exit) when `claude auth login` fails or is
/// cancelled so that `tm login && tm run …` does not proceed after a failed
/// login.
/// Test: command construction is unit-tested via
/// `test_build_login_command_sets_env_and_invocation` in
/// `core::standalone::run`. Exit-code propagation is covered by reasoning: the
/// `bail!` path is reached whenever `status.success()` is false. The interactive
/// OAuth completion requires a human.
pub(crate) fn login_cmd(paths: &ManagedPaths) -> anyhow::Result<()> {
    let root = &paths.root;
    let cfg_dir = &paths.claude_config_dir;
    trusty_mpm::core::standalone::global_config::ensure_global_config_dir(root, cfg_dir)?;

    eprintln!(
        "tm login: one-time setup — authenticates managed `tm run` sessions\n\
         on your Claude Max/Pro plan via the macOS Keychain.\n\
         Config dir: {}\n\
         Launching `claude auth login` — complete the browser OAuth flow...",
        cfg_dir.display()
    );

    // Routed through the disclaim-aware spawn (issue #2997): on macOS this
    // disclaims TCC responsibility for the child so consent prompts
    // attribute to `claude` rather than the signed `trusty-mpm`/`tm` binary.
    let status = trusty_mpm::core::spawn_disclaim::disclaimed_status(
        &mut trusty_mpm::core::standalone::run::build_login_command(cfg_dir),
    )
    .context("failed to spawn 'claude auth login'; is `claude` installed and on PATH?")?;

    if status.success() {
        eprintln!(
            "tm login: authentication complete.\n\
             You can now run `tm run <alias>` — sessions will authenticate\n\
             on your Claude plan automatically (no ANTHROPIC_API_KEY needed)."
        );
        Ok(())
    } else {
        eprintln!(
            "tm login: `claude auth login` exited with {status}.\n\
             If the OAuth flow was cancelled, run `tm login` again to retry.\n\
             Alternatively, export ANTHROPIC_API_KEY to use the API-key path instead."
        );
        anyhow::bail!("claude auth login failed: {status}")
    }
}

#[cfg(test)]
#[path = "standalone_tests.rs"]
mod standalone_tests;