use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};
use trusty_common::claude_config::{
default_settings_max_depth, discover_claude_settings, mcp_server_entry, patch_mcp_server,
};
const MCP_SERVER_KEY: &str = "trusty-memory";
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()?;
let patched = patch_claude_settings_phase()?;
println!("\n{} Setup complete!", "✓".green());
if patched > 0 {
println!(
" Updated {} Claude settings file{}.",
patched,
if patched == 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 patch_claude_settings_phase() -> Result<usize> {
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 n = patch_one(&fallback, &entry)?;
return Ok(n);
}
println!(
"{} Found {} settings file(s). Patching each…",
"·".dimmed(),
files.len()
);
let mut changed = 0usize;
for path in &files {
match patch_one(path, &entry) {
Ok(1) => {
changed += 1;
println!(" {} {}", "✓".green(), path.display());
}
Ok(_) => {
println!(
" {} {} {}",
"↻".cyan(),
path.display().to_string().dimmed(),
"(already configured)".dimmed()
);
}
Err(e) => {
eprintln!(
" {} {} {}",
"✗".red(),
path.display(),
format!("({e})").red()
);
}
}
}
Ok(changed)
}
fn patch_one(path: &Path, entry: &serde_json::Value) -> Result<usize> {
let wrote = patch_mcp_server(path, MCP_SERVER_KEY, entry)?;
Ok(if wrote { 1 } else { 0 })
}
#[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 n = patch_one(&path, &entry).expect("patch ok");
assert_eq!(n, 1, "first patch must write the file");
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");
}
#[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"]);
assert_eq!(patch_one(&path, &entry).unwrap(), 1, "first patch writes");
let after_first = std::fs::read_to_string(&path).unwrap();
assert_eq!(
patch_one(&path, &entry).unwrap(),
0,
"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": [] }
}
});
std::fs::write(&path, serde_json::to_string_pretty(&seed).unwrap()).unwrap();
let entry = mcp_server_entry(MCP_SERVER_KEY, &["serve"]);
let n = patch_one(&path, &entry).expect("patch ok");
assert_eq!(n, 1);
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));
}
}