trusty-memory 0.18.1

MCP server (stdio + HTTP/SSE) for trusty-memory
Documentation
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
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
//! Handler for `trusty-memory setup`.
//!
//! Why: first-time users want a single command that installs the launchd
//! service, creates the data directory, and registers `trusty-memory` as an
//! MCP server in every Claude settings file on the machine. Doing this
//! piecewise (manual plist install, hand-edit settings.json, restart Claude)
//! is brittle and error-prone — `setup` makes it a one-liner that leans on
//! the shared `trusty_common::{launchd, claude_config}` modules so the
//! behaviour stays in lockstep with `trusty-search setup` and any future
//! trusty-* tool.
//! What: orchestrates three phases:
//!   1. Creates `<data_dir>/trusty-memory/` (e.g. `~/Library/Application
//!      Support/trusty-memory` on macOS).
//!   2. On macOS, installs and bootstraps the launchd LaunchAgent via the
//!      shared `LaunchdConfig`. On other platforms, this phase is skipped
//!      with a friendly note.
//!   3. Patches every discovered Claude settings file with an MCP server
//!      entry pointing at `trusty-memory serve --stdio`. Falls back to
//!      creating `~/.claude/settings.json` when no settings files were found.
//!
//! Test: unit tests cover the patch phase against tempdir-rooted settings
//! files. The launchd phase is side-effecting (macOS only) and exercised
//! manually via `cargo run -p trusty-memory -- setup`.

use anyhow::{Context, Result};
use colored::Colorize;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use trusty_common::claude_config::{
    default_settings_max_depth, discover_claude_settings, mcp_server_entry, merge_hook_entries,
    patch_mcp_server, write_json_atomic,
};

/// Canonical MCP server key used in Claude settings files.
///
/// Why: the same key is used by the migrate command and the patch phase
/// here; defining it once prevents the two from drifting (e.g. one writing
/// `trusty-memory` and the other writing `trusty_memory`).
/// What: the literal string `"trusty-memory"`.
/// Test: covered by every test in this module that asserts the key is
/// present after a patch.
const MCP_SERVER_KEY: &str = "trusty-memory";

/// The Claude Code hook event the UserPromptSubmit hook is registered under.
///
/// Why: Claude Code routes hooks to one of a handful of well-known events;
/// `UserPromptSubmit` fires before every user-typed prompt and is the only
/// event whose stdout is injected into the model's next message as additional
/// context. That makes it the right place to surface the palace's prompt
/// facts on every message without paying the per-call MCP tool tax.
/// What: the literal `"UserPromptSubmit"` string Claude Code expects in the
/// settings JSON.
const HOOK_EVENT: &str = "UserPromptSubmit";

/// The Claude Code hook event the inbox-check hook is registered under
/// (issue #99).
///
/// Why: `SessionStart` fires exactly once at the beginning of a new Claude
/// Code session and Claude Code injects the hook's stdout as context for
/// that session. That makes it the right place to deliver inter-project
/// messages that arrived since the previous session — they appear in the
/// model's working context immediately, without polling.
/// What: the literal `"SessionStart"` string Claude Code expects in the
/// settings JSON.
const SESSION_START_HOOK_EVENT: &str = "SessionStart";

/// Bare fallback for the UserPromptSubmit hook when absolute-path resolution fails.
///
/// Why: if `current_exe()` is unavailable, we still need a working hook command.
/// The bare name degrades to PATH-resolution, which is the pre-hardening behaviour.
/// What: `"trusty-memory prompt-context"`.
const HOOK_COMMAND_BARE: &str = "trusty-memory prompt-context";

/// Bare fallback for the SessionStart inbox-check hook.
///
/// Why: parallel to `HOOK_COMMAND_BARE` — keeps behaviour working when the
/// absolute-path resolution is unavailable.
/// What: `"trusty-memory inbox-check"`.
const INBOX_CHECK_HOOK_COMMAND_BARE: &str = "trusty-memory inbox-check";

/// Build the `"trusty-memory prompt-context"` command with an optional absolute
/// binary path prefix.
///
/// Why: using the absolute path avoids a PATH lookup at hook fire-time, which
/// prevents the "hook fails to launch" class of bug in build shells or
/// stripped-PATH environments (the claude-mpm SAM-deploy pattern). Falls back
/// to the bare name when the path cannot be resolved.
/// What: `"<abs-path> prompt-context"` when `exe` is `Some` and absolute;
/// `HOOK_COMMAND_BARE` otherwise.
/// Test: `patch_one_installs_hook_with_absolute_path` in `setup_tests.rs`.
fn hook_command(exe: Option<&std::path::Path>) -> String {
    match exe {
        Some(p) if p.is_absolute() => format!("{} prompt-context", p.display()),
        _ => HOOK_COMMAND_BARE.to_string(),
    }
}

/// Build the `"trusty-memory inbox-check"` command with an optional absolute
/// binary path prefix.
///
/// Why: same reasoning as [`hook_command`] — absolute path avoids PATH lookup
/// at hook fire-time.
/// What: `"<abs-path> inbox-check"` when `exe` is `Some` and absolute;
/// `INBOX_CHECK_HOOK_COMMAND_BARE` otherwise.
/// Test: `patch_one_installs_hook_with_absolute_path` in `setup_tests.rs`.
fn inbox_check_command(exe: Option<&std::path::Path>) -> String {
    match exe {
        Some(p) if p.is_absolute() => format!("{} inbox-check", p.display()),
        _ => INBOX_CHECK_HOOK_COMMAND_BARE.to_string(),
    }
}

/// Resolve and canonicalize the current executable path for use at setup time.
///
/// Why: called once during `handle_setup` before any chdir so the path is
/// stable for the lifetime of the install. The result is threaded through to
/// [`prompt_context_hook_additions`] and [`merge_prompt_context_hook`].
/// What: returns `Some(canonicalized_path)` or `None` on failure.
/// Test: covered indirectly by `patch_one_installs_hook_with_absolute_path`.
fn resolve_setup_exe() -> Option<std::path::PathBuf> {
    std::env::current_exe()
        .ok()
        .and_then(|p| p.canonicalize().ok().or(Some(p)))
}

/// Hook command timeout in milliseconds.
///
/// Why: Claude Code blocks the user's prompt until the hook exits, so the
/// timeout must be larger than the daemon's worst-case response latency
/// (HTTP round-trip + prompt-fact rendering) but small enough that a
/// completely dead daemon still releases the prompt within a few seconds.
/// 3 000 ms is the value used across the rest of the trusty-* setup tooling.
const HOOK_TIMEOUT_MS: u64 = 3_000;

/// Entry point for `trusty-memory setup`.
///
/// Why: a first-time-install command that wires up everything a user needs
/// to run trusty-memory from Claude Code with one invocation.
/// What: runs the three phases (data dir → launchd → Claude settings) in
/// order. A failure in the launchd phase is fatal on macOS (we want to
/// fail loud so the user can fix it), but Claude settings phase failures
/// for individual files are non-fatal — we log and continue.
/// Test: integration via `cargo run -p trusty-memory -- setup`; unit tests
/// cover the patch phase against fixture settings files.
pub fn handle_setup() -> Result<()> {
    println!("{} Setting up trusty-memory…\n", "·".dimmed());

    // Phase 1: data directory.
    let data_dir = ensure_data_dir()?;
    println!("{} Data directory: {}", "".green(), data_dir.display());

    // Phase 2: launchd (macOS only).
    install_service_phase()?;

    // Phase 2b: pre-warm the embedder model cache. This downloads ~22 MB
    // of ONNX into `$HOME/.cache/fastembed` before launchd ever starts the
    // daemon, so the first `memory_recall` request does not have to wait
    // for (and the read-only `TMPDIR` does not silently break) the model
    // retrieval (GH #58).
    prewarm_embedder_phase();

    // Phase 3: Claude settings patching (MCP entry + UserPromptSubmit hook).
    let SettingsPatchSummary {
        mcp_changed,
        hooks_changed,
    } = patch_claude_settings_phase()?;

    println!("\n{} Setup complete!", "".green());
    if mcp_changed > 0 {
        println!(
            "  Updated {} Claude settings file{} with the MCP server entry.",
            mcp_changed,
            if mcp_changed == 1 { "" } else { "s" }
        );
    }
    if hooks_changed > 0 {
        println!(
            "  Installed UserPromptSubmit hook into {} settings file{}.",
            hooks_changed,
            if hooks_changed == 1 { "" } else { "s" }
        );
    }
    println!(
        "  Try: {} (or restart Claude Code to pick up the new MCP server)",
        "trusty-memory serve".cyan()
    );
    Ok(())
}

/// Create the user data directory for trusty-memory.
///
/// Why: `trusty-memory serve` reads/writes its palace files under this
/// directory; pre-creating it during setup avoids first-run race conditions
/// and lets us surface permission failures up-front.
/// What: resolves `<data_dir>/trusty-memory` via [`dirs::data_dir`] and
/// creates it (and any missing parents). Returns the resolved path.
/// Test: `setup_creates_data_dir_under_override` exercises the happy path
/// with a tempdir-based override of `dirs::data_dir`.
fn ensure_data_dir() -> Result<PathBuf> {
    let base =
        dirs::data_dir().ok_or_else(|| anyhow::anyhow!("could not resolve user data directory"))?;
    let dir = base.join("trusty-memory");
    std::fs::create_dir_all(&dir).with_context(|| format!("create data dir {}", dir.display()))?;
    Ok(dir)
}

/// Install the launchd service (macOS) or skip with a note (other platforms).
///
/// Why: keeps the platform-specific logic in one place so `handle_setup`
/// can read top-to-bottom without `#[cfg]` blocks. On macOS the service is
/// the canonical way to keep the daemon alive across logins; on Linux /
/// Windows we expect operators to use systemd / Task Scheduler directly
/// and don't try to forge a half-working wrapper.
/// What: on macOS, calls `LaunchdConfig::install()` + `.bootstrap()`. On
/// other platforms, prints a one-line skip notice and returns Ok.
/// Test: side-effecting on macOS; covered manually. Other platforms hit the
/// no-op path during `cargo test -p trusty-memory` on Linux CI.
fn install_service_phase() -> Result<()> {
    #[cfg(target_os = "macos")]
    {
        use crate::commands::service::{build_launchd_config, launchd_log_dir, LAUNCHD_LABEL};

        let exe = std::env::current_exe()
            .map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))?;
        let log_dir = launchd_log_dir()?;
        let cfg = build_launchd_config(exe, log_dir.clone());
        cfg.install().context("install LaunchAgent plist")?;
        println!(
            "{} Installed LaunchAgent: {}",
            "".green(),
            cfg.plist_path()?.display()
        );

        cfg.bootstrap()
            .context("bootstrap LaunchAgent into user gui domain")?;
        println!(
            "{} Loaded {} (daemon will auto-start; logs in {}).",
            "".green(),
            LAUNCHD_LABEL,
            log_dir.display().to_string().dimmed()
        );
    }
    #[cfg(not(target_os = "macos"))]
    {
        println!(
            "{} Skipping launchd install (not macOS) — use your distro's \
             service manager to run `trusty-memory serve` on demand.",
            "·".dimmed()
        );
    }
    Ok(())
}

/// Pre-warm the fastembed ONNX model cache before launchd ever starts the
/// daemon.
///
/// Why: GH #58 — under launchd, `TMPDIR` is mounted read-only for the
/// agent's UID, so fastembed's first `TextEmbedding::try_new` fails with
/// `EROFS (os error 30)` and the HTTP daemon never becomes ready. Even
/// with `FASTEMBED_CACHE_DIR` correctly set in the plist, downloading the
/// ~22 MB model on the daemon's first request introduces latency and
/// failure modes (network blips, slow ANE compile). Pre-warming during
/// `setup` — which runs in the user's normal shell with full network and
/// HOME access — moves both the download and the ONNX session warmup
/// off the daemon's critical path and surfaces failures up-front where the
/// user can act on them.
/// What: explicitly sets `FASTEMBED_CACHE_DIR` to `$HOME/.cache/fastembed`
/// (the same path the launchd plist will use), then spins up a single-
/// threaded tokio runtime to drive `FastEmbedder::new()`. Failures are
/// reported as warnings — they do not abort `setup` because the daemon
/// will retry on its own startup, but a successful pre-warm is the
/// difference between "instant first recall" and "users see EROFS errors".
/// Test: side-effecting (network + filesystem); covered manually via
/// `cargo run -p trusty-memory -- setup`.
fn prewarm_embedder_phase() {
    let cache_dir = trusty_common::embedder::resolve_fastembed_cache_dir();
    // SAFETY: setup runs single-threaded before any worker spawns.
    unsafe {
        std::env::set_var("FASTEMBED_CACHE_DIR", &cache_dir);
    }
    if let Err(e) = std::fs::create_dir_all(&cache_dir) {
        eprintln!(
            "  {} could not create {} ({e}) — daemon will retry on first request.",
            "·".dimmed(),
            cache_dir.display()
        );
        return;
    }

    println!(
        "\n{} Pre-warming embedder model cache at {}",
        "·".dimmed(),
        cache_dir.display()
    );

    // `prewarm_embedder_phase` is called from a `#[tokio::main]` context, so
    // we must not call `block_on` on the current thread directly (that panics
    // with "Cannot start a runtime from within a runtime"). `block_in_place`
    // moves the blocking work off the async thread pool so we can build a
    // dedicated single-thread runtime safely.
    let result = tokio::task::block_in_place(|| {
        let rt = match tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
        {
            Ok(rt) => rt,
            Err(e) => {
                eprintln!(
                    "  {} could not build tokio runtime for pre-warm ({e}); skipping.",
                    "·".dimmed()
                );
                return None;
            }
        };
        Some(rt.block_on(trusty_common::embedder::FastEmbedder::new()))
    });

    let result = match result {
        None => return,
        Some(r) => r,
    };

    match result {
        Ok(_e) => {
            println!(
                "{} Embedder model cached. First recall after daemon start will be instant.",
                "".green()
            );
        }
        Err(e) => {
            // Non-fatal: daemon will retry on its own. Surface the error
            // loudly so the operator can intervene (e.g. fix offline
            // proxy, free disk space) before launchd hits the same wall.
            eprintln!(
                "  {} pre-warm failed ({e}). The daemon will retry on first request — \
                 if this persists, inspect {} for partial downloads.",
                "".red(),
                cache_dir.display()
            );
        }
    }
}

/// Per-file outcome of [`patch_one`].
///
/// Why: the patch phase tracks MCP-server and hook changes separately so
/// the summary banner can report each independently. Returning the two
/// counts together (rather than a single mutated `bool`) keeps idempotency
/// reporting precise.
/// What: `mcp_wrote = true` when the MCP server entry changed on disk;
/// `hook_wrote = true` when the UserPromptSubmit hook block changed.
/// Test: `patch_one_creates_missing_file`, `patch_one_is_idempotent`,
/// `patch_one_installs_hook`.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct PatchOutcome {
    mcp_wrote: bool,
    hook_wrote: bool,
}

impl PatchOutcome {
    fn any(&self) -> bool {
        self.mcp_wrote || self.hook_wrote
    }
}

/// Aggregate result of the Claude-settings phase across every discovered file.
///
/// Why: `handle_setup` renders separate "MCP" and "hook" lines in the final
/// summary banner; tracking the two counts independently keeps the line
/// rendering honest about exactly what changed.
/// What: one count per kind of mutation.
/// Test: `setup_phase_counts_mcp_and_hooks_separately` (covered indirectly
/// by `patch_one_*` tests).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct SettingsPatchSummary {
    mcp_changed: usize,
    hooks_changed: usize,
}

/// Patch every discovered Claude settings file (or fall back to
/// `~/.claude/settings.json`) so it registers `trusty-memory` as an MCP
/// server **and** carries the trusty-memory UserPromptSubmit hook.
///
/// Why: Claude Code only loads MCP servers it knows about; without the MCP
/// step `setup` would install the daemon but Claude would never call it.
/// And without the UserPromptSubmit hook, the model would have to invoke a
/// per-message MCP tool to get the prompt-context block — a token-tax that
/// the hook avoids by injecting the block on every prompt. Walking every
/// settings file matters because users frequently have both a global
/// `~/.claude/settings.json` and per-project `<repo>/.claude/settings.local.json`
/// files.
/// What: discovers settings files via
/// [`trusty_common::claude_config::discover_claude_settings`], then calls
/// [`patch_one`] for each — which idempotently upserts the MCP server entry
/// and idempotently merges the UserPromptSubmit hook. Both helpers are
/// idempotent by design (deep equality dedup), so re-running setup is safe.
/// When no files are found, falls back to creating `~/.claude/settings.json`.
/// Test: `setup_patches_existing_settings_file`,
/// `setup_creates_fallback_settings_file`, and the per-file `patch_one_*`
/// tests.
fn patch_claude_settings_phase() -> Result<SettingsPatchSummary> {
    let home =
        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?;
    println!(
        "\n{} Scanning for Claude settings under {}",
        "·".dimmed(),
        home.display()
    );

    // Resolve the binary path once at the start of the phase so every
    // settings file gets the same absolute hook command.
    let exe = resolve_setup_exe();

    let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve", "--stdio"]);
    let files = discover_claude_settings(&home, default_settings_max_depth());

    if files.is_empty() {
        let fallback = home.join(".claude").join("settings.json");
        println!(
            "{} No Claude settings files found. Creating {}",
            "·".dimmed(),
            fallback.display()
        );
        let outcome = patch_one(&fallback, &entry, exe.as_deref())?;
        return Ok(SettingsPatchSummary {
            mcp_changed: outcome.mcp_wrote as usize,
            hooks_changed: outcome.hook_wrote as usize,
        });
    }

    println!(
        "{} Found {} settings file(s). Patching each…",
        "·".dimmed(),
        files.len()
    );
    let mut summary = SettingsPatchSummary::default();
    for path in &files {
        match patch_one(path, &entry, exe.as_deref()) {
            Ok(outcome) => {
                summary.mcp_changed += outcome.mcp_wrote as usize;
                summary.hooks_changed += outcome.hook_wrote as usize;
                if outcome.any() {
                    let label = match (outcome.mcp_wrote, outcome.hook_wrote) {
                        (true, true) => "(mcp + hook)",
                        (true, false) => "(mcp)",
                        (false, true) => "(hook)",
                        (false, false) => "",
                    };
                    println!("  {} {} {}", "".green(), path.display(), label.dimmed());
                } else {
                    println!(
                        "  {} {} {}",
                        "".cyan(),
                        path.display().to_string().dimmed(),
                        "(already configured)".dimmed()
                    );
                }
            }
            Err(e) => {
                // Non-fatal: log and continue so one bad file doesn't sink
                // the whole setup run.
                eprintln!(
                    "  {} {} {}",
                    "".red(),
                    path.display(),
                    format!("({e})").red()
                );
            }
        }
    }
    Ok(summary)
}

/// Patch a single Claude settings file: upsert the MCP server entry, then
/// merge the UserPromptSubmit hook.
///
/// Why: keeping both edits in one helper lets the surrounding loop report a
/// single `(mcp + hook)` / `(mcp)` / `(hook)` / `(already configured)` line
/// per file. Each edit is idempotent on its own, so running setup twice
/// reports `(already configured)` on the second pass.
/// What: calls [`patch_mcp_server`] to upsert the MCP entry, then loads the
/// resulting file, runs [`merge_hook_entries`] with the trusty-memory hook
/// additions (with absolute binary path when `exe` is provided), and writes
/// the merged JSON back atomically when it differs from what is already on disk.
/// Test: `patch_one_creates_missing_file`, `patch_one_is_idempotent`,
/// `patch_one_installs_hook`, `patch_one_preserves_unrelated_keys`,
/// `patch_one_installs_hook_with_absolute_path`.
fn patch_one(
    path: &Path,
    entry: &serde_json::Value,
    exe: Option<&std::path::Path>,
) -> Result<PatchOutcome> {
    let mcp_wrote = patch_mcp_server(path, MCP_SERVER_KEY, entry)?;
    let hook_wrote = merge_prompt_context_hook(path, exe)?;
    Ok(PatchOutcome {
        mcp_wrote,
        hook_wrote,
    })
}

/// Build the trusty-memory `UserPromptSubmit` + `SessionStart` hook block
/// as Claude Code expects it.
///
/// Why: the live `settings.json` shape is `{"hooks": {"<Event>": [{ "matcher":
/// "*", "hooks": [{ "type": "command", "command": "...", "timeout": ... }]
/// }]}}`. A centralised constructor keeps every call site producing the
/// exact same shape so [`merge_hook_entries`] can dedup by deep equality.
/// Issue #99 added the `SessionStart` block for inter-project inbox
/// delivery; it shares the same shape and timeout as the prompt-context
/// hook so existing operators don't have to reason about two policies.
/// When `exe` is `Some`, the hook commands are emitted as absolute paths so
/// they fire even in stripped-PATH environments (the claude-mpm SAM pattern).
/// What: returns a JSON object with both the `UserPromptSubmit` event
/// (running `prompt-context`) and the `SessionStart` event (running
/// `inbox-check`), optionally prefixed with the absolute binary path.
/// Test: `patch_one_installs_hook`, `patch_one_installs_session_start_hook`,
/// `patch_one_installs_hook_with_absolute_path` in `setup_tests.rs`.
fn prompt_context_hook_additions(exe: Option<&std::path::Path>) -> Value {
    let prompt_cmd = hook_command(exe);
    let inbox_cmd = inbox_check_command(exe);
    json!({
        "hooks": {
            HOOK_EVENT: [
                {
                    "matcher": "*",
                    "hooks": [
                        {
                            "type": "command",
                            "command": prompt_cmd,
                            "timeout": HOOK_TIMEOUT_MS,
                        }
                    ],
                }
            ],
            SESSION_START_HOOK_EVENT: [
                {
                    "matcher": "*",
                    "hooks": [
                        {
                            "type": "command",
                            "command": inbox_cmd,
                            "timeout": HOOK_TIMEOUT_MS,
                        }
                    ],
                }
            ]
        }
    })
}

/// Idempotently merge the trusty-memory `UserPromptSubmit` hook into a
/// Claude Code settings file.
///
/// Why: the MCP server entry by itself just registers the daemon; the hook
/// is what makes Claude Code call `trusty-memory prompt-context` before
/// every user prompt and inject its stdout. Without this merge, the daemon
/// would be reachable but no prompt-context block would ever appear in the
/// model's input. `exe` is forwarded to [`prompt_context_hook_additions`]
/// so the command is emitted as an absolute path when available.
/// What: reads the existing settings (missing file → `{}`), runs the shared
/// [`merge_hook_entries`] helper to fold in `prompt_context_hook_additions(exe)`,
/// and writes the result back atomically when it differs from the input.
/// Returns `true` when the file was rewritten and `false` when the hook was
/// already present (idempotent re-run).
/// Test: `patch_one_installs_hook`, `patch_one_is_idempotent`.
fn merge_prompt_context_hook(path: &Path, exe: Option<&std::path::Path>) -> Result<bool> {
    let original: Value = match std::fs::read_to_string(path) {
        Ok(s) if s.trim().is_empty() => Value::Object(serde_json::Map::new()),
        Ok(s) => serde_json::from_str(&s)
            .with_context(|| format!("parse settings file {}", path.display()))?,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Value::Object(serde_json::Map::new()),
        Err(e) => {
            return Err(anyhow::Error::new(e))
                .with_context(|| format!("read settings file {}", path.display()))
        }
    };
    let additions = prompt_context_hook_additions(exe);
    let merged = merge_hook_entries(&original, &additions);
    if merged == original {
        return Ok(false);
    }
    write_json_atomic(path, &merged)?;
    Ok(true)
}

/// Why: test module extracted to setup_tests.rs to keep this file under the
/// 500-line cap (line-cap gate, issue #610). All tests exercise the same
/// items as before via `use super::*`; child modules can access private items.
/// What: declares the sibling test file as the `tests` submodule.
/// Test: see setup_tests.rs.
#[cfg(test)]
#[path = "setup_tests.rs"]
mod tests;