use anyhow::Result;
use clap::{Parser, Subcommand};
use std::net::SocketAddr;
use trusty_memory::commands::inbox_check::handle_inbox_check;
use trusty_memory::commands::link::handle_link;
use trusty_memory::commands::migrate::{handle_migrate, MigrateTarget};
use trusty_memory::commands::note::handle_note;
use trusty_memory::commands::prompt_context::handle_prompt_context;
use trusty_memory::commands::send_message::handle_send_message;
use trusty_memory::commands::service::{handle_service, ServiceAction};
use trusty_memory::commands::setup::handle_setup;
use trusty_memory::commands::start::handle_start;
use trusty_memory::commands::stop::handle_stop;
use trusty_memory::commands::upgrade::handle_upgrade;
use trusty_memory::{resolve_palace_registry_dir, run_http, run_http_dynamic, AppState};
#[derive(Debug, Parser)]
#[command(
name = "trusty-memory",
version,
about = "Memory palace MCP server + migration utility",
long_about = "MCP server (stdio + HTTP/SSE) for trusty-memory, plus a \
`migrate kuzu-memory` subcommand that rewrites Claude \
settings files referencing the legacy kuzu-memory server."
)]
struct Cli {
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Start,
Stop,
Serve {
#[arg(long, value_name = "ADDR")]
http: Option<SocketAddr>,
#[arg(long)]
foreground: bool,
#[arg(long, value_name = "NAME")]
palace: Option<String>,
},
Migrate {
#[arg(value_enum)]
target: MigrateTarget,
#[arg(long)]
dry_run: bool,
#[arg(long)]
config_only: bool,
#[arg(long, value_name = "PATH")]
from: Option<std::path::PathBuf>,
#[arg(long, value_name = "NAME")]
palace: Option<String>,
#[arg(long, value_name = "N")]
limit: Option<usize>,
},
Setup,
#[command(name = "prompt-context")]
PromptContext,
Doctor {
#[arg(long)]
fix_palaces: bool,
#[arg(long, requires = "fix_palaces")]
fix: bool,
},
Service {
#[command(subcommand)]
action: ServiceAction,
},
#[command(subcommand_required = true)]
Monitor {
#[command(subcommand)]
target: MonitorTarget,
},
#[command(name = "send-message")]
SendMessage {
#[arg(long, value_name = "PALACE")]
to: String,
#[arg(long, value_name = "PURPOSE")]
purpose: String,
#[arg(long, value_name = "TEXT")]
content: String,
#[arg(long, value_name = "PALACE")]
from: Option<String>,
},
#[command(name = "inbox-check")]
InboxCheck {
#[arg(long, value_name = "PALACE")]
palace: Option<String>,
},
Note {
#[arg(value_name = "CONTENT")]
content: String,
#[arg(long, value_name = "NAME")]
palace: Option<String>,
#[arg(long = "tag", value_name = "TAG")]
tags: Vec<String>,
},
#[command(name = "kg-rebuild")]
KgRebuild {
#[arg(long, value_name = "ID")]
palace: Option<String>,
},
Link {
#[arg(long, value_name = "DIR")]
path: Option<std::path::PathBuf>,
#[arg(long, value_name = "SLUG")]
slug: Option<String>,
#[arg(long, value_name = "TEXT")]
note: Option<String>,
#[arg(long)]
force: bool,
},
Port {
#[arg(long, conflicts_with = "json")]
addr: bool,
#[arg(long, conflicts_with = "addr")]
json: bool,
},
Upgrade {
#[arg(long)]
check: bool,
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Debug, Subcommand)]
enum MonitorTarget {
Web,
Tui,
Status {
#[arg(long)]
json: bool,
},
Palaces {
id: Option<String>,
#[arg(long)]
json: bool,
},
}
static HELP: std::sync::LazyLock<trusty_common::help::HelpConfig> =
std::sync::LazyLock::new(|| {
trusty_common::help::load_help(include_str!("../help.yaml"))
.expect("trusty-memory help.yaml is bundled and valid")
});
#[tokio::main]
async fn main() -> Result<()> {
let argv: Vec<String> = std::env::args().collect();
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
e.print().ok();
if matches!(
e.kind(),
clap::error::ErrorKind::InvalidSubcommand | clap::error::ErrorKind::UnknownArgument
) {
trusty_common::help::print_suggestion_hint(&argv, &HELP);
}
std::process::exit(e.exit_code());
}
};
let (log_buffer, error_store) = trusty_common::init_tracing_with_buffer_and_capture(
cli.verbose,
trusty_common::log_buffer::DEFAULT_LOG_CAPACITY,
"trusty-memory",
env!("CARGO_PKG_VERSION"),
);
let is_daemon_path = matches!(cli.command, Command::Serve { .. } | Command::Start);
let is_upgrade = matches!(cli.command, Command::Upgrade { .. });
if !is_daemon_path && !is_upgrade {
if let Some(info) = trusty_common::update::check_throttled(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
)
.await
{
eprintln!("{}", trusty_common::update::notice(&info));
}
}
match cli.command {
Command::Start => handle_start().await,
Command::Stop => handle_stop().await,
Command::Serve {
http,
foreground,
palace,
} => run_serve(http, foreground, palace, log_buffer, error_store).await,
Command::Migrate {
target,
dry_run,
config_only,
from,
palace,
limit,
} => handle_migrate(target, dry_run, config_only, from, palace, limit),
Command::Setup => handle_setup(),
Command::PromptContext => handle_prompt_context().await,
Command::Service { action } => handle_service(&action),
Command::Doctor { fix_palaces, fix } => {
if fix_palaces {
trusty_memory::commands::doctor::handle_doctor_fix_palaces(fix).await?;
}
trusty_memory::commands::doctor::handle_doctor().await
}
Command::Monitor { target } => run_monitor(target).await,
Command::SendMessage {
to,
purpose,
content,
from,
} => handle_send_message(to, purpose, content, from).await,
Command::InboxCheck { palace } => handle_inbox_check(palace).await,
Command::Note {
content,
palace,
tags,
} => handle_note(content, palace, tags).await,
Command::KgRebuild { palace } => {
trusty_memory::commands::kg_rebuild::handle_kg_rebuild(palace).await
}
Command::Link {
path,
slug,
note,
force,
} => handle_link(path, slug, note, force),
Command::Port { addr, json } => {
let format = if json {
trusty_memory::commands::port::PortFormat::Json
} else if addr {
trusty_memory::commands::port::PortFormat::Addr
} else {
trusty_memory::commands::port::PortFormat::Port
};
trusty_memory::commands::port::handle_port(format)
}
Command::Upgrade { check, yes } => handle_upgrade(check, yes).await,
}
}
async fn run_monitor(target: MonitorTarget) -> Result<()> {
use trusty_memory::commands::monitor;
match target {
MonitorTarget::Web => trusty_memory::commands::daemon_guard::open_web_dashboard().await,
MonitorTarget::Tui => trusty_common::monitor::memory_tui::run().await,
MonitorTarget::Status { json } => monitor::handle_status(json).await,
MonitorTarget::Palaces { id, json } => monitor::handle_palaces(id, json).await,
}
}
async fn run_serve(
http: Option<SocketAddr>,
foreground: bool,
palace: Option<String>,
log_buffer: trusty_common::log_buffer::LogBuffer,
error_store: trusty_common::error_capture::ErrorStore,
) -> Result<()> {
if !foreground && http.is_none() {
return trusty_memory::commands::start::handle_start().await;
}
{
use trusty_memory::commands::single_instance::{single_instance_check, StartupAction};
let addr_file = trusty_memory::http_addr_path();
let action = single_instance_check(addr_file.as_deref()).await;
match action {
StartupAction::Proceed => {
}
StartupAction::ExitAlreadyRunning => {
tracing::info!(
"single-instance guard: another trusty-memory instance is \
already running; exiting 0 to stop launchd respawn storm"
);
eprintln!(
"trusty-memory: another instance is already running; \
exiting cleanly (exit 0 stops launchd KeepAlive respawn)"
);
std::process::exit(0);
}
StartupAction::Fail(msg) => {
anyhow::bail!("single-instance check failed unexpectedly: {msg}");
}
}
}
let data_dir = trusty_common::resolve_data_dir("trusty-memory")?;
if !data_dir.is_absolute() {
anyhow::bail!(
"resolved trusty-memory data directory {:?} is not absolute; \
refusing to start to prevent palace directories from being created \
under the daemon working directory",
data_dir
);
}
if data_dir == std::path::Path::new("/") {
anyhow::bail!(
"resolved trusty-memory data directory is the filesystem root (/); \
refusing to start to prevent palace directories from being created \
directly under /",
);
}
let data_root = resolve_palace_registry_dir(data_dir);
if let Err(e) = trusty_memory::commands::migrations::migrate_default_palace_name(&data_root) {
tracing::warn!("default-palace name migration skipped: {e:#}");
}
if let Some(addr) = http {
let state = AppState::new(data_root)
.with_default_palace(palace)
.with_log_buffer(log_buffer)
.with_error_store(error_store)
.with_bm25_client_from_env();
spawn_startup_tasks(&state);
run_http(state, addr).await
} else {
let state = AppState::new(data_root)
.with_default_palace(palace)
.with_log_buffer(log_buffer)
.with_error_store(error_store)
.with_bm25_client_from_env();
spawn_startup_tasks(&state);
run_http_dynamic(state).await
}
}
fn spawn_startup_tasks(state: &AppState) {
let bg_state = state.clone();
tokio::spawn(async move {
let started = std::time::Instant::now();
tracing::info!("starting background palace hydration");
match bg_state.load_palaces_from_disk().await {
Ok(count) => tracing::info!(
elapsed_ms = started.elapsed().as_millis() as u64,
"background palace hydration complete: {count} palaces loaded"
),
Err(e) => tracing::error!(
elapsed_ms = started.elapsed().as_millis() as u64,
"background palace hydration failed: {e:#}"
),
}
if let Some(palace) = bg_state.default_palace.clone() {
if let Ok(cwd) = std::env::current_dir() {
bg_state.spawn_alias_discovery(palace, cwd);
}
}
{
let update_available = bg_state.update_available.clone();
tokio::spawn(async move {
let crate_name = env!("CARGO_PKG_NAME");
let current = env!("CARGO_PKG_VERSION");
if let Some(info) =
trusty_common::update::check_throttled(crate_name, current).await
{
tracing::info!(
latest = %info.latest,
"update available: {}",
trusty_common::update::notice(&info)
);
eprintln!("{}", trusty_common::update::notice(&info));
if let Ok(mut guard) = update_available.lock() {
*guard = Some(info.latest);
}
}
});
}
let pin_scan_started = std::time::Instant::now();
let pin_map_ref = bg_state.pin_project_map.clone();
let scan_result = tokio::task::spawn_blocking(move || {
let search_dirs = trusty_memory::startup_scan::default_search_dirs();
trusty_memory::startup_scan::scan_pin_map(&search_dirs)
})
.await;
match scan_result {
Ok(map) => {
let count = map.len();
let elapsed_ms = pin_scan_started.elapsed().as_millis() as u64;
for (palace_id, project_path) in map {
pin_map_ref.insert(palace_id, project_path);
}
tracing::info!(
pins_found = count,
elapsed_ms,
"startup pin scan complete: {count} pin(s) discovered in {elapsed_ms}ms"
);
eprintln!("startup pin scan complete: {count} pin(s) discovered in {elapsed_ms}ms");
}
Err(e) => {
tracing::warn!("startup pin scan task panicked or was cancelled: {e}");
eprintln!("startup pin scan task panicked or was cancelled: {e}");
}
}
});
}
#[cfg(test)]
mod startup_task_tests {
use super::*;
use std::fs;
use trusty_memory::project_root::{write_project_pin, ProjectPin, PIN_SCHEMA_VERSION};
#[tokio::test]
async fn spawn_startup_tasks_populates_pin_map() {
let tmp = tempfile::tempdir().expect("tempdir");
let search_root = tmp.path().join("Projects");
let project_dir = search_root.join("my-project");
fs::create_dir_all(&project_dir).expect("create project dir");
write_project_pin(
&project_dir,
&ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "my-palace".to_string(),
note: None,
},
)
.expect("write pin");
let prev_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", tmp.path());
}
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let state_root = tmp.path().join("data");
fs::create_dir_all(&state_root).expect("create data dir");
let state = AppState::new(state_root);
spawn_startup_tasks(&state);
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
loop {
if state.pin_project_map.contains_key("my-palace") {
break;
}
if std::time::Instant::now() >= deadline {
panic!(
"pin_project_map was not populated within 500 ms; \
spawn_startup_tasks may not be running the pin scan"
);
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
match prev_home {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
let found = state.pin_project_map.get("my-palace").map(|e| e.clone());
assert!(
found.is_some(),
"pin_project_map must contain 'my-palace' after spawn_startup_tasks"
);
let actual = fs::canonicalize(found.unwrap()).expect("canonicalize actual");
let expected = fs::canonicalize(&project_dir).expect("canonicalize expected");
assert_eq!(
actual, expected,
"pin_project_map entry must point to the project directory"
);
}
}