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,
};
const MCP_SERVER_KEY: &str = "trusty-memory";
const HOOK_EVENT: &str = "UserPromptSubmit";
const SESSION_START_HOOK_EVENT: &str = "SessionStart";
const HOOK_COMMAND: &str = "trusty-memory prompt-context";
const INBOX_CHECK_HOOK_COMMAND: &str = "trusty-memory inbox-check";
const HOOK_TIMEOUT_MS: u64 = 3_000;
pub fn handle_setup() -> Result<()> {
println!("{} Setting up trusty-memory…\n", "·".dimmed());
let data_dir = ensure_data_dir()?;
println!("{} Data directory: {}", "✓".green(), data_dir.display());
install_service_phase()?;
prewarm_embedder_phase();
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(())
}
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)
}
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(())
}
fn prewarm_embedder_phase() {
let cache_dir = trusty_common::embedder::resolve_fastembed_cache_dir();
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()
);
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) => {
eprintln!(
" {} pre-warm failed ({e}). The daemon will retry on first request — \
if this persists, inspect {} for partial downloads.",
"✗".red(),
cache_dir.display()
);
}
}
}
#[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
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct SettingsPatchSummary {
mcp_changed: usize,
hooks_changed: usize,
}
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()
);
let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve"]);
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)?;
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) {
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) => {
eprintln!(
" {} {} {}",
"✗".red(),
path.display(),
format!("({e})").red()
);
}
}
}
Ok(summary)
}
fn patch_one(path: &Path, entry: &serde_json::Value) -> Result<PatchOutcome> {
let mcp_wrote = patch_mcp_server(path, MCP_SERVER_KEY, entry)?;
let hook_wrote = merge_prompt_context_hook(path)?;
Ok(PatchOutcome {
mcp_wrote,
hook_wrote,
})
}
fn prompt_context_hook_additions() -> Value {
json!({
"hooks": {
HOOK_EVENT: [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": HOOK_COMMAND,
"timeout": HOOK_TIMEOUT_MS,
}
],
}
],
SESSION_START_HOOK_EVENT: [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": INBOX_CHECK_HOOK_COMMAND,
"timeout": HOOK_TIMEOUT_MS,
}
],
}
]
}
})
}
fn merge_prompt_context_hook(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();
let merged = merge_hook_entries(&original, &additions);
if merged == original {
return Ok(false);
}
write_json_atomic(path, &merged)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn patch_one_creates_missing_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve"]);
let outcome = patch_one(&path, &entry).expect("patch ok");
assert!(outcome.mcp_wrote, "first patch writes the MCP entry");
assert!(outcome.hook_wrote, "first patch installs the hook");
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let server = &value["mcpServers"][MCP_SERVER_KEY];
assert_eq!(server["command"], "trusty-memory");
assert_eq!(server["args"][0], "serve");
let hook_entries = value["hooks"][HOOK_EVENT].as_array().unwrap();
assert_eq!(hook_entries.len(), 1, "exactly one matcher block");
let inner = hook_entries[0]["hooks"].as_array().unwrap();
assert_eq!(inner[0]["command"], HOOK_COMMAND);
assert_eq!(inner[0]["type"], "command");
assert_eq!(inner[0]["timeout"], HOOK_TIMEOUT_MS);
let ss_entries = value["hooks"][SESSION_START_HOOK_EVENT]
.as_array()
.expect("SessionStart hooks installed");
assert_eq!(
ss_entries.len(),
1,
"exactly one SessionStart matcher block"
);
let ss_inner = ss_entries[0]["hooks"].as_array().unwrap();
assert_eq!(ss_inner[0]["command"], INBOX_CHECK_HOOK_COMMAND);
assert_eq!(ss_inner[0]["type"], "command");
assert_eq!(ss_inner[0]["timeout"], HOOK_TIMEOUT_MS);
}
#[test]
fn patch_one_installs_session_start_hook_when_upgrading() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve"]);
let seed = json!({
"mcpServers": {
MCP_SERVER_KEY: { "command": "trusty-memory", "args": ["serve"] }
},
"hooks": {
HOOK_EVENT: [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": HOOK_COMMAND,
"timeout": HOOK_TIMEOUT_MS,
}]
}]
}
});
std::fs::write(&path, serde_json::to_string_pretty(&seed).unwrap()).unwrap();
let outcome = patch_one(&path, &entry).expect("patch ok");
assert!(!outcome.mcp_wrote);
assert!(outcome.hook_wrote, "SessionStart hook must be added");
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let ups = value["hooks"][HOOK_EVENT].as_array().unwrap();
assert_eq!(ups.len(), 1);
assert_eq!(ups[0]["hooks"][0]["command"], HOOK_COMMAND);
let ss = value["hooks"][SESSION_START_HOOK_EVENT].as_array().unwrap();
assert_eq!(ss.len(), 1);
assert_eq!(ss[0]["hooks"][0]["command"], INBOX_CHECK_HOOK_COMMAND);
}
#[test]
fn patch_one_adds_session_start_when_legacy_user_prompt_submit_has_different_shape() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve"]);
let seed = json!({
"mcpServers": {
MCP_SERVER_KEY: { "command": "trusty-memory", "args": ["serve"] }
},
"hooks": {
HOOK_EVENT: [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": HOOK_COMMAND,
}]
}]
}
});
std::fs::write(&path, serde_json::to_string_pretty(&seed).unwrap()).unwrap();
let outcome = patch_one(&path, &entry).expect("patch ok");
assert!(!outcome.mcp_wrote, "MCP entry already canonical");
assert!(
outcome.hook_wrote,
"SessionStart (and a fresh UserPromptSubmit shape) must be added"
);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let ss = value["hooks"][SESSION_START_HOOK_EVENT]
.as_array()
.expect("SessionStart array exists");
assert_eq!(ss.len(), 1, "exactly one SessionStart matcher block");
assert_eq!(ss[0]["hooks"][0]["command"], INBOX_CHECK_HOOK_COMMAND);
assert_eq!(ss[0]["hooks"][0]["timeout"], HOOK_TIMEOUT_MS);
let ups = value["hooks"][HOOK_EVENT].as_array().unwrap();
assert!(
ups.iter().any(|e| e["hooks"][0].get("timeout").is_none()),
"legacy timeout-less entry must be preserved"
);
assert!(
ups.iter()
.any(|e| e["hooks"][0]["timeout"] == HOOK_TIMEOUT_MS),
"canonical timeout=3000 entry must be appended"
);
}
#[test]
fn patch_one_is_idempotent() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve"]);
let first = patch_one(&path, &entry).unwrap();
assert!(first.mcp_wrote && first.hook_wrote, "first patch writes");
let after_first = std::fs::read_to_string(&path).unwrap();
let second = patch_one(&path, &entry).unwrap();
assert!(
!second.mcp_wrote && !second.hook_wrote,
"second patch is no-op"
);
let after_second = std::fs::read_to_string(&path).unwrap();
assert_eq!(after_first, after_second, "file must not change on no-op");
}
#[test]
fn patch_one_preserves_unrelated_keys() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let seed = json!({
"theme": "dark",
"mcpServers": {
"some-other-server": { "command": "x", "args": [] }
},
"hooks": {
"Stop": [{ "matcher": "*", "hooks": [
{ "type": "command", "command": "echo bye" }
] }]
}
});
std::fs::write(&path, serde_json::to_string_pretty(&seed).unwrap()).unwrap();
let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve"]);
let outcome = patch_one(&path, &entry).expect("patch ok");
assert!(outcome.mcp_wrote);
assert!(outcome.hook_wrote);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(value["theme"], "dark", "unrelated top-level key dropped");
let servers = value["mcpServers"].as_object().unwrap();
assert!(servers.contains_key("some-other-server"));
assert!(servers.contains_key(MCP_SERVER_KEY));
let stop = value["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 1);
assert_eq!(stop[0]["hooks"][0]["command"], "echo bye");
let ups = value["hooks"][HOOK_EVENT].as_array().unwrap();
assert_eq!(ups[0]["hooks"][0]["command"], HOOK_COMMAND);
}
#[test]
fn patch_one_installs_hook_when_mcp_already_present() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve"]);
let seed = json!({
"mcpServers": {
MCP_SERVER_KEY: { "command": "trusty-memory", "args": ["serve"] }
}
});
std::fs::write(&path, serde_json::to_string_pretty(&seed).unwrap()).unwrap();
let outcome = patch_one(&path, &entry).expect("patch ok");
assert!(!outcome.mcp_wrote, "MCP entry already present");
assert!(outcome.hook_wrote, "hook freshly installed");
}
}