use std::path::PathBuf;
use std::sync::Arc;
use clap::{Parser, Subcommand};
use uuid::Uuid;
use crate::cli::output;
use crate::cli::output::{BOLD, GREEN, RESET};
use crate::config::settings::Settings;
use crate::store::db::Database;
use crate::store::memory::{CreateMemoryInput, Scope, SearchQuery};
use std::str::FromStr;
#[derive(Parser)]
#[command(
name = "mneme",
about = "mneme — la memoria persistente para tu agente IA",
version,
author
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[allow(clippy::large_enum_variant)]
#[derive(Subcommand)]
pub enum Commands {
Save {
title: String,
content: String,
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, short = 't', default_value = "note")]
r#type: String,
#[arg(long, short = 'i', default_value = "medium")]
importance: String,
#[arg(long, value_delimiter = ',')]
tags: Vec<String>,
#[arg(long)]
what: Option<String>,
#[arg(long)]
why: Option<String>,
#[arg(long)]
context: Option<String>,
#[arg(long)]
learned: Option<String>,
#[arg(long)]
scope: Option<String>,
#[arg(long)]
topic_key: Option<String>,
},
Search {
query: String,
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, short = 't')]
r#type: Option<String>,
#[arg(long, short = 'l', default_value = "10")]
limit: u32,
#[arg(long)]
json: bool,
},
Get {
id: String,
#[arg(long)]
json: bool,
},
List {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, short = 'a')]
all: bool,
#[arg(long, short = 't')]
r#type: Option<String>,
#[arg(long, short = 'l', default_value = "20")]
limit: u32,
#[arg(long)]
json: bool,
},
Delete {
id: String,
#[arg(long)]
hard: bool,
},
Restore {
id: String,
},
Relate {
from_id: String,
to_id: String,
relation_type: String,
#[arg(long)]
confidence: Option<f32>,
},
Context {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, short = 'l', default_value = "10")]
limit: u32,
},
Stats {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
},
Projects,
Serve {
#[arg(long, short = 'p', default_value = "7438")]
port: u16,
#[arg(long, default_value = "127.0.0.1")]
host: String,
},
Mcp,
Tui,
Setup {
#[command(subcommand)]
agent: AgentSetup,
},
Export {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, short = 'o')]
output: Option<PathBuf>,
#[arg(long, short = 'f', default_value = "json")]
format: String,
},
Import {
file: PathBuf,
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
},
Doctor {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
json: bool,
},
Reindex {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
json: bool,
},
Bench {
#[arg(long, short = 's')]
scenario: Option<String>,
#[arg(long)]
json: bool,
},
Learn {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
json: bool,
},
Consolidate {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, default_value = "auto")]
strategy: String,
#[arg(long, default_value_t = 30)]
days: u64,
#[arg(long)]
dry_run: bool,
#[arg(long)]
json: bool,
},
Block {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
slot: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
content: Option<String>,
},
Completion {
shell: String,
},
DbExport {
#[arg(long, short = 'o')]
output: Option<String>,
},
DbImport {
file: String,
},
SaveBatch {
file: PathBuf,
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
},
DeleteRelation {
relation_id: String,
},
Audit {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, default_value = "30")]
days_threshold: u32,
#[arg(long)]
json: bool,
},
Deduplicate {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, default_value = "0.85")]
threshold: f64,
},
Feedback {
memory_id: String,
is_useful: bool,
#[arg(long)]
reason: Option<String>,
},
Deprecate {
memory_id: String,
reason: String,
#[arg(long)]
supersedes_id: Option<String>,
},
Graph {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
json: bool,
},
Summarize {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
session_id: Option<String>,
},
InjectContext {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
file: Option<String>,
#[arg(long, short = 'l', default_value = "10")]
limit: u32,
},
ForgetProject {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
confirm: bool,
},
Health {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
json: bool,
},
Remind {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, short = 'i', default_value = "high")]
importance: String,
},
TagSuggest {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
title: String,
#[arg(long)]
content: Option<String>,
},
KnowledgeGaps {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
json: bool,
},
Sync {
#[command(subcommand)]
command: SyncCommands,
},
Watch {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, short = 'd')]
dir: Option<PathBuf>,
#[arg(long, default_value = "2")]
interval: u64,
#[arg(long, default_value = ".mneme")]
ext: String,
},
Keys {
#[command(subcommand)]
cmd: KeysCommands,
},
Version,
}
#[derive(Subcommand, Debug)]
pub enum KeysCommands {
List,
Add {
alias: String,
key: String,
#[arg(long)]
default: bool,
},
Remove { alias_or_id: String },
SetDefault { alias_or_id: String },
Detect,
Test,
}
#[derive(Subcommand)]
pub enum SyncCommands {
Peers {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
},
AddPeer {
name: String,
address: String,
#[arg(long, default_value = "http")]
transport: String,
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long)]
auto_sync: bool,
},
RemovePeer {
id: String,
},
Now {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
},
Export {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
#[arg(long, short = 'o')]
output: Option<PathBuf>,
},
Import {
file: PathBuf,
},
Log {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
},
Status {
#[arg(long, short = 'p', env = "MNEME_PROJECT")]
project: Option<String>,
},
}
#[derive(Subcommand)]
pub enum AgentSetup {
Opencode,
ClaudeCode,
Continue,
Cursor,
Windsurf,
VscodeCopilot,
GeminiCli,
Codex,
Zed,
}
pub fn run_command(
command: Commands,
db: &Database,
embeddings: Option<&std::sync::Arc<crate::embeddings::engine::EmbeddingEngine>>,
) -> crate::error::Result<()> {
match command {
Commands::Save {
title,
content,
project,
r#type,
importance,
tags,
what,
why,
context,
learned,
scope,
topic_key,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let memory_type = r#type.parse()?;
let importance = importance.parse()?;
let scope = scope
.as_deref()
.map(Scope::from_str)
.transpose()?
.unwrap_or(Scope::Project);
let input = CreateMemoryInput {
project,
scope: Some(scope),
title,
content,
what,
why,
context,
learned,
memory_type,
importance,
tags,
topic_key,
capture_prompt: None,
encrypt: false,
valid_from: None,
valid_until: None,
provenance: None,
};
let engine = embeddings.map(std::sync::Arc::clone);
let embedding_store = db.embeddings();
let memory = db.memories().save(input, engine, Some(embedding_store))?;
output::print_success(&format!("Memory saved: {}", memory.id));
output::print_memory(&memory);
}
Commands::Search {
query,
project,
r#type,
limit,
json,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let memory_type = r#type.as_deref().map(str::parse).transpose()?;
let search_query = SearchQuery {
text: query,
project: Some(project),
scope: None,
memory_type,
importance: None,
tags: Vec::new(),
limit,
include_snippet: true,
all_projects: false,
};
let weights = crate::store::search::SearchWeights::default();
let results = db.memories().search(&search_query, &weights, None)?;
if json {
println!("{}", serde_json::to_string_pretty(&results)?);
} else {
output::print_search_results(&results);
}
}
Commands::Get { id, json } => {
let id = Uuid::parse_str(&id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
match db.memories().get(id)? {
Some(memory) => {
if json {
println!("{}", serde_json::to_string_pretty(&memory)?);
} else {
output::print_memory(&memory);
}
}
None => {
output::print_error(&format!("Memory not found: {}", id));
std::process::exit(1);
}
}
}
Commands::List {
project,
all,
r#type,
limit,
json,
} => {
let project_names: Vec<String> = if all {
db.memories().list_projects()?
.into_iter().map(|p| p.name).collect()
} else {
vec![project.unwrap_or_else(Settings::infer_project)]
};
for (i, pname) in project_names.iter().enumerate() {
if i > 0 { println!(); }
if all { println!("─── {} ───", pname); }
let memory_type = r#type.as_deref().map(str::parse).transpose()?;
let memories = db.memories()
.list(pname, memory_type.as_ref(), None, None, limit, 0)?;
if json {
println!("{}", serde_json::to_string_pretty(&memories)?);
} else {
output::print_memory_list(&memories);
}
}
}
Commands::Delete { id, hard } => {
let id = Uuid::parse_str(&id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
db.memories().delete(id, hard)?;
if hard {
output::print_success(&format!("Memory hard-deleted: {}", id));
} else {
output::print_success(&format!("Memory soft-deleted: {}", id));
}
}
Commands::Restore { id } => {
let id = Uuid::parse_str(&id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
let memory = db.memories().restore(id)?;
output::print_success(&format!("Memory restored: {}", memory.id));
output::print_memory(&memory);
}
Commands::Relate {
from_id,
to_id,
relation_type,
confidence,
} => {
let from_id = Uuid::parse_str(&from_id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
let to_id = Uuid::parse_str(&to_id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
let relation_type = relation_type.parse()?;
let input = crate::store::memory::CreateRelationInput {
source_id: from_id,
target_id: to_id,
relation_type,
confidence: Some(confidence.unwrap_or(1.0)),
reason: None,
};
let store = db.memories();
store.create_relation(input)?;
output::print_success(&format!("Relation created: {} -> {}", from_id, to_id));
}
Commands::Context { project, limit } => {
let project = project.unwrap_or_else(Settings::infer_project);
let memories = db.memories().context(&project, None, limit)?;
output::print_memory_list(&memories);
}
Commands::Stats { project } => {
let project = project.unwrap_or_else(Settings::infer_project);
let stats = db.memories().stats(&project)?;
output::print_stats(&stats);
}
Commands::Projects => {
let projects = db.memories().list_projects()?;
output::print_projects(&projects);
}
Commands::Serve { port, host } => {
output::print_warning("Use 'mneme serve' via the async runtime, not sync.");
tracing::info!("Starting HTTP server on {}:{}", host, port);
}
Commands::Mcp => {
output::print_warning("Use 'mneme mcp' via the async runtime, not sync.");
}
Commands::Tui => {
println!("TUI coming in v0.3");
}
Commands::Setup { agent } => match agent {
AgentSetup::Opencode => setup_opencode()?,
AgentSetup::ClaudeCode => setup_claude_code()?,
AgentSetup::Continue => setup_continue()?,
AgentSetup::Cursor => setup_cursor()?,
AgentSetup::Windsurf => setup_windsurf()?,
AgentSetup::VscodeCopilot => setup_vscode_copilot()?,
AgentSetup::GeminiCli => setup_gemini_cli()?,
AgentSetup::Codex => setup_codex()?,
AgentSetup::Zed => setup_zed()?,
},
Commands::Export {
project,
output,
format,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let memories = db.memories().list(&project, None, None, None, 10000, 0)?;
let (content, default_ext) = match format.as_str() {
"md" | "markdown" => (crate::export::export_to_markdown(&memories, &project), "md"),
_ => (serde_json::to_string_pretty(&memories)?, "json"),
};
let output_path = output.unwrap_or_else(|| {
let mut path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let timestamp = chrono::Utc::now().format("%Y%m%d");
path.push(format!("{}_{}_export.{}", project, timestamp, default_ext));
path
});
std::fs::write(&output_path, &content)?;
output::print_success(&format!(
"Exported {} memories to {}",
memories.len(),
output_path.display()
));
}
Commands::Import { file, project } => {
let project = project.unwrap_or_else(Settings::infer_project);
let content = std::fs::read_to_string(&file)?;
let store = db.memories();
let mut count = 0u32;
let engine = embeddings.map(std::sync::Arc::clone);
let embedding_store = db.embeddings();
let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("json");
if ext == "md" {
let imported = crate::export::import_from_markdown(&content)?;
for mem in imported {
let input = CreateMemoryInput {
project: project.clone(),
scope: Some(mem.scope),
title: mem.title,
content: mem.content,
what: mem.what,
why: mem.why,
context: mem.context,
learned: mem.learned,
memory_type: mem.memory_type,
importance: mem.importance,
tags: mem.tags,
topic_key: None,
capture_prompt: None,
encrypt: false,
valid_from: None,
valid_until: None,
provenance: None,
};
store.save(input, engine.clone(), Some(embedding_store.clone()))?;
count += 1;
}
} else {
let memories: Vec<crate::store::memory::Memory> = serde_json::from_str(&content)?;
for mem in memories {
let input = CreateMemoryInput {
project: project.clone(),
scope: Some(mem.scope),
title: mem.title,
content: mem.content,
what: mem.what,
why: mem.why,
context: mem.context,
learned: mem.learned,
memory_type: mem.memory_type,
importance: mem.importance,
tags: mem.tags,
topic_key: mem.topic_key,
capture_prompt: None,
encrypt: false,
valid_from: None,
valid_until: None,
provenance: None,
};
store.save(input, engine.clone(), Some(embedding_store.clone()))?;
count += 1;
}
}
output::print_success(&format!(
"Imported {} memories from {}",
count,
file.display()
));
}
Commands::Doctor { project, json } => {
let project = project.unwrap_or_else(Settings::infer_project);
let report = run_doctor(db, &project)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_doctor_report(&report);
}
}
Commands::Reindex {
project,
force: _force,
json,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
match embeddings {
Some(engine) => {
let embedding_store = db.embeddings();
let rt = tokio::runtime::Runtime::new()?;
let stats = rt.block_on(async {
db.memories()
.reindex_embeddings(&project, engine, &embedding_store)
.await
})?;
if json {
println!("{}", serde_json::to_string_pretty(&stats)?);
} else {
output::print_success(&format!(
"Reindexed {}/{} embeddings in {}ms",
stats.indexed, stats.total, stats.duration_ms
));
}
}
None => {
output::print_error("Embeddings engine not available");
std::process::exit(1);
}
}
}
Commands::Bench { scenario, json } => {
let bench_db = std::sync::Arc::new(db.clone());
let runner = crate::bench::BenchmarkRunner::new(bench_db);
let scenario_obj = match scenario {
Some(path) => runner.load_scenario(std::path::Path::new(&path))?,
None => crate::bench::example_rust_scenario(),
};
let result = runner.run(&scenario_obj)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", crate::bench::format_report(&result));
}
}
Commands::Learn { project, json } => {
let learn_db = std::sync::Arc::new(db.clone());
let miner = crate::learn::FailureMiner::new(learn_db);
let project = project.unwrap_or_else(Settings::infer_project);
let report = miner.mine(&project)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("{}", crate::learn::format_failure_report(&report));
}
}
Commands::Consolidate { project, strategy, days, dry_run, json } => {
let eng_db = std::sync::Arc::new(db.clone());
let eng = crate::consolidate::ConsolidationEngine::new(eng_db);
let project = project.unwrap_or_else(Settings::infer_project);
let strat = match strategy.as_str() {
"age" => crate::consolidate::ConsolidationStrategy::Age,
"deprecated" => crate::consolidate::ConsolidationStrategy::Deprecated,
"unused" => crate::consolidate::ConsolidationStrategy::Unused,
_ => crate::consolidate::ConsolidationStrategy::Auto,
};
let result = eng.consolidate(&project, strat, days, dry_run)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", crate::consolidate::format_consolidation_result(&result));
}
}
Commands::Block { project, slot, title, content } => {
let eng_db = std::sync::Arc::new(db.clone());
let eng = crate::consolidate::ConsolidationEngine::new(eng_db);
let project = project.unwrap_or_else(Settings::infer_project);
match (slot, content) {
(Some(s), Some(c)) => {
let t = title.as_deref().unwrap_or(&s);
let block = eng.set_block(&project, &s, t, &c)?;
println!("✅ Block '{}' saved: {}", s, block.title);
}
(Some(s), None) => {
match eng.get_block(&project, &s)? {
Some(block) => {
println!("# Block: {} ({})\n\n", block.slot, block.title);
println!("{}", block.content);
}
None => println!("No block '{}' for project '{}'.", s, project),
}
}
(None, _) => {
let blocks = eng.list_blocks(&project)?;
if blocks.is_empty() {
println!("No blocks for project '{}'.", project);
} else {
println!("Blocks for '{}':", project);
for b in &blocks {
println!(" [{}] {} — {} chars", b.slot, b.title, b.content.len());
}
}
}
}
}
Commands::Completion { shell } => {
use clap::CommandFactory;
let cmd = Cli::command();
let shell_enum = match shell.as_str() {
"bash" => clap_complete::Shell::Bash,
"zsh" => clap_complete::Shell::Zsh,
"fish" => clap_complete::Shell::Fish,
"powershell" => clap_complete::Shell::PowerShell,
_ => {
eprintln!("Unsupported shell: {}. Use: bash, zsh, fish, powershell", shell);
std::process::exit(1);
}
};
clap_complete::generate(shell_enum, &mut cmd.clone(), "mneme", &mut std::io::stdout());
}
Commands::DbExport { output } => {
let output_path = output.unwrap_or_else(|| {
let date = chrono::Utc::now().format("%Y%m%d_%H%M%S");
format!("mneme-backup-{}.db", date)
});
let db_path = &crate::config::settings::Settings::load()?.database.path;
std::fs::copy(db_path, &output_path)
.map_err(|e| crate::error::MnemeError::Io(e))?;
println!("✅ Exported database to {}", output_path);
}
Commands::DbImport { file } => {
let db_path = &crate::config::settings::Settings::load()?.database.path;
let backup_path = format!("{}.pre-import", db_path.display());
std::fs::copy(db_path, &backup_path)
.map_err(|e| crate::error::MnemeError::Io(e))?;
std::fs::copy(&file, db_path)
.map_err(|e| crate::error::MnemeError::Io(e))?;
println!("✅ Imported database from {}", file);
println!(" Backup of old DB at {}", backup_path);
println!(" Restart mneme to use the new database.");
}
Commands::SaveBatch { file, project } => {
let project = project.unwrap_or_else(Settings::infer_project);
let content = std::fs::read_to_string(&file)?;
let memories: Vec<crate::store::memory::Memory> = serde_json::from_str(&content)?;
let store = db.memories();
let mut inputs = Vec::new();
for mem in memories {
inputs.push(CreateMemoryInput {
project: project.clone(),
scope: Some(mem.scope),
title: mem.title,
content: mem.content,
what: mem.what,
why: mem.why,
context: mem.context,
learned: mem.learned,
memory_type: mem.memory_type,
importance: mem.importance,
tags: mem.tags,
topic_key: mem.topic_key,
capture_prompt: None,
encrypt: false,
valid_from: None,
valid_until: None,
provenance: None,
});
}
let engine = embeddings.map(std::sync::Arc::clone);
let embedding_store = db.embeddings();
let (saved, duplicates) = store.save_batch(inputs, engine, Some(embedding_store))?;
output::print_success(&format!(
"Batch saved: {} new, {} duplicates",
saved.len(),
duplicates.len()
));
}
Commands::DeleteRelation { relation_id } => {
let id = Uuid::parse_str(&relation_id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
db.memories().delete_relation(id)?;
output::print_success(&format!("Relation deleted: {}", relation_id));
}
Commands::Audit {
project,
days_threshold,
json,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let report = db.memories().audit(&project, days_threshold)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
output::print_audit(&report);
}
}
Commands::Deduplicate { project, threshold } => {
let project = project.unwrap_or_else(Settings::infer_project);
let embedding_store = db.embeddings();
let groups =
db.memories()
.find_duplicates_semantic(&project, threshold, &embedding_store)?;
output::print_duplicate_groups(&groups);
}
Commands::Feedback {
memory_id,
is_useful,
reason,
} => {
let id = Uuid::parse_str(&memory_id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
let feedback_id = db
.memories()
.add_feedback(id, is_useful, reason.as_deref())?;
output::print_success(&format!("Feedback recorded: {}", feedback_id));
}
Commands::Deprecate {
memory_id,
reason,
supersedes_id,
} => {
let id = Uuid::parse_str(&memory_id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
let supersedes = supersedes_id
.as_deref()
.map(Uuid::parse_str)
.transpose()
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
let memory = db.memories().deprecate(id, &reason, supersedes)?;
output::print_success(&format!("Memory deprecated: {}", memory.id));
}
Commands::Graph { project, json } => {
let project = project.unwrap_or_else(Settings::infer_project);
let graph = db.memories().get_graph(&project)?;
if json {
println!("{}", serde_json::to_string_pretty(&graph)?);
} else {
output::print_graph(&graph);
}
}
Commands::Summarize {
project,
session_id,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let sid = session_id
.as_deref()
.map(Uuid::parse_str)
.transpose()
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
let summary = db.memories().summarize(&project, sid)?;
println!("{}", summary.summary);
}
Commands::InjectContext {
project,
file,
limit,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let context = db
.memories()
.inject_context(&project, file.as_deref(), limit)?;
println!("{}", context);
}
Commands::ForgetProject { project, confirm } => {
if !confirm {
output::print_error("Use --confirm to permanently delete all project memories");
std::process::exit(1);
}
let project = project.unwrap_or_else(Settings::infer_project);
let deleted = db.memories().forget_project(&project)?;
output::print_success(&format!(
"Deleted {} memories for project '{}'",
deleted, project
));
}
Commands::Health { project, json } => {
let report = db.memories().health(project.as_deref())?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
output::print_health(&report);
}
}
Commands::Remind {
project,
importance,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let imp = importance.parse()?;
let memories = db.memories().remind(&project, &imp)?;
output::print_remind(&memories);
}
Commands::TagSuggest {
project,
title,
content,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let tags = db
.memories()
.suggest_tags(&project, &title, content.as_deref())?;
println!("Suggested tags: {}", tags.join(", "));
}
Commands::KnowledgeGaps { project, json } => {
let project = project.unwrap_or_else(Settings::infer_project);
let report = db.memories().knowledge_gaps(&project)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
output::print_knowledge_gaps(&report);
}
}
Commands::Sync { command } => {
use crate::sync::peer::{Peer, TransportType};
use std::str::FromStr;
match command {
SyncCommands::Peers { project } => {
let project = project.unwrap_or_else(Settings::infer_project);
let peers = db.peers().list(&project)?;
output::print_peer_list(&peers);
}
SyncCommands::AddPeer {
name,
address,
transport,
project,
auto_sync,
} => {
let project = project.unwrap_or_else(Settings::infer_project);
let transport = TransportType::from_str(&transport)?;
let peer = Peer {
id: Uuid::new_v4(),
name,
transport,
address,
project,
last_sync: None,
last_status: None,
auto_sync,
created_at: chrono::Utc::now(),
};
db.peers().add(&peer)?;
output::print_success(&format!("Peer added: {} ({})", peer.id, peer.name));
}
SyncCommands::RemovePeer { id } => {
let id = Uuid::parse_str(&id)
.map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
db.peers().remove(id)?;
output::print_success(&format!("Peer removed: {}", id));
}
SyncCommands::Now { project } => {
let project = project.unwrap_or_else(Settings::infer_project);
let settings = Settings::load()?;
let engine =
crate::sync::engine::SyncEngine::new(Arc::new(db.clone()), settings.sync)?;
let rt = tokio::runtime::Runtime::new()?;
let results = rt.block_on(async { engine.sync_auto(&project).await })?;
output::print_sync_result(&results);
}
SyncCommands::Export { project, output } => {
let project = project.unwrap_or_else(Settings::infer_project);
let settings = Settings::load()?;
let engine =
crate::sync::engine::SyncEngine::new(Arc::new(db.clone()), settings.sync)?;
let stats = engine.export_project(&project, output)?;
output::print_sync_status(&stats);
}
SyncCommands::Import { file: _file } => {
output::print_warning("Sync import not yet implemented in CLI");
}
SyncCommands::Log { project } => {
let _project = project.unwrap_or_else(Settings::infer_project);
output::print_warning("Sync log not yet implemented in CLI");
}
SyncCommands::Status { project } => {
let project = project.unwrap_or_else(Settings::infer_project);
let peers = db.peers().list(&project)?;
output::print_peer_list(&peers);
}
}
}
Commands::Keys { cmd } => {
let conn = db.get_conn();
let key_store = crate::crypto::KeyStore::new(conn);
match cmd {
KeysCommands::List => {
let keys = key_store.list()?;
if keys.is_empty() {
println!("No hay claves registradas.");
} else {
println!("{:<20} {:<15} {:<8} AGREGADA", "ALIAS", "TIPO", "DEFAULT");
println!("{}", "─".repeat(60));
for k in &keys {
println!(
"{:<20} {:<15} {:<8} {}",
k.alias,
k.key_type,
if k.is_default { "✓" } else { "✗" },
k.added_at.format("%Y-%m-%d")
);
}
}
}
KeysCommands::Add {
alias,
key,
default,
} => {
let recipient = crate::crypto::RecipientKey::from_string(&key)?;
let registered = key_store.add(&alias, &recipient)?;
if default {
key_store.set_default(registered.id)?;
}
println!("✓ Clave '{}' registrada ({})", alias, registered.key_type);
}
KeysCommands::Remove { alias_or_id } => {
let keys = key_store.list()?;
let target = keys
.iter()
.find(|k| k.alias == *alias_or_id || k.id.to_string() == *alias_or_id)
.ok_or_else(|| crate::MnemeError::KeyNotFound(alias_or_id.clone()))?;
key_store.remove(target.id)?;
println!("✓ Clave '{}' eliminada", alias_or_id);
}
KeysCommands::SetDefault { alias_or_id } => {
let keys = key_store.list()?;
let target = keys
.iter()
.find(|k| k.alias == *alias_or_id || k.id.to_string() == *alias_or_id)
.ok_or_else(|| crate::MnemeError::KeyNotFound(alias_or_id.clone()))?;
key_store.set_default(target.id)?;
println!("✓ '{}' marcada como default", alias_or_id);
}
KeysCommands::Detect => {
println!("Claves SSH detectadas en el sistema:");
if let Some(mut home) = dirs::home_dir() {
home.push(".ssh");
for name in &["id_ed25519", "id_rsa", "id_ecdsa"] {
let pub_key = home.join(format!("{}.pub", name));
let priv_key = home.join(name);
if priv_key.exists() {
let available = if pub_key.exists() {
"✓ disponible"
} else {
"pub key no encontrada"
};
println!(" ~/.ssh/{} {}", name, available);
}
}
}
}
KeysCommands::Test => match crate::crypto::IdentityKey::detect() {
Ok(id) => println!("✓ Identidad disponible: {}", id.path().display()),
Err(e) => println!("✗ Identidad no disponible: {}", e),
},
}
}
Commands::Watch { .. } => {
output::print_warning("Use 'mneme watch' via the async runtime, not sync.");
}
Commands::Version => {
println!("mneme v{}", env!("CARGO_PKG_VERSION"));
crate::update::print_update_status("mneme-brain", env!("CARGO_PKG_VERSION"));
}
}
Ok(())
}
fn setup_opencode() -> crate::error::Result<()> {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
let opencode_dir = config_dir.join("opencode");
std::fs::create_dir_all(&opencode_dir)?;
let config_path = opencode_dir.join("opencode.json");
let mut config: serde_json::Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
} else {
let legacy_path = opencode_dir.join("config.json");
if legacy_path.exists() {
let content = std::fs::read_to_string(&legacy_path)?;
let cfg = serde_json::from_str(&content).unwrap_or(serde_json::json!({}));
std::fs::remove_file(&legacy_path)?;
cfg
} else {
serde_json::json!({})
}
};
if !config.is_object() {
config = serde_json::json!({});
}
if config.get("mcp").map_or(true, |v| !v.is_object()) {
config["mcp"] = serde_json::json!({});
}
config["mcp"]["mneme"] = serde_json::json!({
"command": ["mneme", "mcp"],
"type": "local",
"enabled": true
});
let content = serde_json::to_string_pretty(&config)?;
std::fs::write(&config_path, content)?;
output::print_success(&format!(
"opencode config written to {}",
config_path.display()
));
Ok(())
}
fn setup_claude_code() -> crate::error::Result<()> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let claude_dir = home.join(".claude");
std::fs::create_dir_all(&claude_dir)?;
let config_path = claude_dir.join("settings.json");
let mut config: serde_json::Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
config["mcpServers"] = serde_json::json!({
"mneme": {
"command": "mneme",
"args": ["mcp"]
}
});
let content = serde_json::to_string_pretty(&config)?;
std::fs::write(&config_path, content)?;
output::print_success(&format!(
"Claude Code config written to {}",
config_path.display()
));
Ok(())
}
fn setup_continue() -> crate::error::Result<()> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let continue_dir = home.join(".continue");
std::fs::create_dir_all(&continue_dir)?;
let config_path = continue_dir.join("config.json");
let mut config: serde_json::Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
let servers = config
.get_mut("models")
.and_then(|m| m.as_array_mut())
.cloned()
.unwrap_or_default();
let mut new_servers = servers;
new_servers.push(serde_json::json!({
"title": "Mneme MCP",
"provider": "mcp",
"model": "mneme",
"apiBase": "",
"server": {
"command": "mneme",
"args": ["mcp"]
}
}));
config["models"] = serde_json::json!(new_servers);
let content = serde_json::to_string_pretty(&config)?;
std::fs::write(&config_path, content)?;
output::print_success(&format!(
"Continue config written to {}",
config_path.display()
));
Ok(())
}
fn home_dir() -> std::path::PathBuf {
dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."))
}
fn xdg_config_home() -> std::path::PathBuf {
std::env::var("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".config"))
}
fn write_standard_mcp_config(config_path: std::path::PathBuf, label: &str) -> crate::error::Result<()> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut config: serde_json::Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
};
if !config.is_object() {
config = serde_json::json!({});
}
if config.get("mcpServers").map_or(true, |v| !v.is_object()) {
config["mcpServers"] = serde_json::json!({});
}
config["mcpServers"]["mneme"] = serde_json::json!({
"command": "mneme",
"args": ["mcp"]
});
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
output::print_success(&format!("{label} config written to {}", config_path.display()));
Ok(())
}
fn setup_cursor() -> crate::error::Result<()> {
let config_dir = home_dir().join(".cursor");
let config_path = config_dir.join("mcp.json");
write_standard_mcp_config(config_path, "Cursor")
}
fn setup_windsurf() -> crate::error::Result<()> {
let config_dir = home_dir().join(".codeium").join("windsurf");
let config_path = config_dir.join("mcp_config.json");
write_standard_mcp_config(config_path, "Windsurf")
}
fn setup_gemini_cli() -> crate::error::Result<()> {
let config_dir = xdg_config_home().join("gemini-cli");
let config_path = config_dir.join("mcp.json");
write_standard_mcp_config(config_path, "Gemini CLI")
}
fn setup_codex() -> crate::error::Result<()> {
let config_dir = home_dir().join(".codex");
let config_path = config_dir.join("settings.json");
write_standard_mcp_config(config_path, "Codex CLI")
}
fn setup_vscode_copilot() -> crate::error::Result<()> {
#[cfg(target_os = "linux")]
let base = xdg_config_home().join("Code").join("User");
#[cfg(target_os = "macos")]
let base = home_dir().join("Library").join("Application Support").join("Code").join("User");
#[cfg(target_os = "windows")]
let base = {
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string());
std::path::PathBuf::from(appdata).join("Code").join("User")
};
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
let base = xdg_config_home().join("Code").join("User");
let config_dir = base.join("globalStorage").join("github.copilot-chat");
std::fs::create_dir_all(&config_dir)?;
let config_path = config_dir.join("mcp_config.json");
let mut config: serde_json::Value = if config_path.exists() {
serde_json::from_str(&std::fs::read_to_string(&config_path)?)
.unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
};
if !config.is_object() {
config = serde_json::json!({});
}
if config.get("servers").map_or(true, |v| !v.is_object()) {
config["servers"] = serde_json::json!({});
}
config["servers"]["mneme"] = serde_json::json!({
"type": "stdio",
"command": "mneme",
"args": ["mcp"]
});
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
output::print_success(&format!(
"VS Code Copilot Chat config written to {}",
config_path.display()
));
Ok(())
}
fn setup_zed() -> crate::error::Result<()> {
let config_dir = xdg_config_home().join("zed");
std::fs::create_dir_all(&config_dir)?;
let config_path = config_dir.join("settings.json");
let mut config: serde_json::Value = if config_path.exists() {
serde_json::from_str(&std::fs::read_to_string(&config_path)?)
.unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
};
if !config.is_object() {
config = serde_json::json!({});
}
if config.get("mcp_servers").map_or(true, |v| !v.is_object()) {
config["mcp_servers"] = serde_json::json!({});
}
config["mcp_servers"]["mneme"] = serde_json::json!({
"command": "mneme",
"args": ["mcp"]
});
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
output::print_success(&format!(
"Zed config merged into {}",
config_path.display()
));
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
struct DoctorReport {
healthy: bool,
project: String,
project_source: String,
project_path: String,
db_path: String,
db_reachable: bool,
memory_count: u32,
session_count: u32,
orphaned_relations: u32,
issues: Vec<String>,
checks: Vec<String>,
}
fn run_doctor(db: &Database, project: &str) -> crate::error::Result<DoctorReport> {
let issues = Vec::new();
let mut checks = Vec::new();
let db_reachable = true;
checks.push("Database connection: OK".to_string());
let memory_count = db
.memories()
.stats(project)
.map(|s| s.total_memories)
.unwrap_or(0);
let session_count = db
.sessions()
.list(project, 1000)
.map(|s| s.len() as u32)
.unwrap_or(0);
checks.push(format!(
"Memories in project '{}': {}",
project, memory_count
));
checks.push(format!(
"Sessions in project '{}': {}",
project, session_count
));
let orphaned_relations = 0u32;
checks.push("Orphaned relations: 0".to_string());
let project_source = if Settings::git_toplevel().is_some() {
"git"
} else {
"directory"
};
let project_path = Settings::git_toplevel()
.or_else(|| std::env::current_dir().ok())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let healthy = issues.is_empty();
Ok(DoctorReport {
healthy,
project: project.to_string(),
project_source: project_source.to_string(),
project_path,
db_path: Settings::default()
.database
.path
.to_string_lossy()
.to_string(),
db_reachable,
memory_count,
session_count,
orphaned_relations,
issues,
checks,
})
}
fn print_doctor_report(report: &DoctorReport) {
if report.healthy {
output::print_success("All checks passed!");
} else {
output::print_error("Issues detected:");
for issue in &report.issues {
println!(" - {}", issue);
}
}
println!();
println!(
"{BOLD}Project:{RESET} {} ({})",
report.project, report.project_source
);
println!("{BOLD}Path:{RESET} {}", report.project_path);
println!("{BOLD}DB path:{RESET} {}", report.db_path);
println!();
for check in &report.checks {
println!(" {GREEN}✓{RESET} {}", check);
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn test_db() -> Database {
let path = std::path::PathBuf::from(format!("/tmp/mneme_cli_test_{}.db", Uuid::new_v4()));
Database::open(&path).unwrap()
}
#[test]
fn test_doctor_empty_project() {
let db = test_db();
let report = run_doctor(&db, "test-project").unwrap();
assert!(report.healthy);
assert_eq!(report.project, "test-project");
assert_eq!(report.memory_count, 0);
assert_eq!(report.session_count, 0);
assert_eq!(report.orphaned_relations, 0);
assert!(report.db_reachable);
assert!(report.issues.is_empty());
assert!(!report.checks.is_empty());
}
#[test]
fn test_doctor_with_known_project() {
let db = test_db();
let store = db.memories();
store
.save(
CreateMemoryInput {
encrypt: false,
project: "doctor-test".to_string(),
scope: Some(Scope::Project),
title: "Doctor Test".to_string(),
content: "test".to_string(),
what: None,
why: None,
context: None,
learned: None,
memory_type: crate::store::memory::MemoryType::Note,
importance: crate::store::memory::Importance::Medium,
tags: vec![],
topic_key: None,
capture_prompt: None,
valid_from: None,
valid_until: None,
provenance: None,
},
None,
None,
)
.unwrap();
let report = run_doctor(&db, "doctor-test").unwrap();
assert!(report.healthy);
assert_eq!(report.memory_count, 1);
assert_eq!(report.project, "doctor-test");
}
#[test]
fn test_doctor_report_serializes_to_json() {
let db = test_db();
let report = run_doctor(&db, "json-test").unwrap();
let json = serde_json::to_string(&report).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["project"], "json-test");
assert_eq!(parsed["healthy"], true);
assert!(parsed["db_path"].is_string());
assert!(parsed["checks"].is_array());
}
#[test]
fn test_doctor_db_path_is_non_empty() {
let db = test_db();
let report = run_doctor(&db, "path-test").unwrap();
assert!(!report.db_path.is_empty());
assert!(report.db_path.contains("mneme"));
}
#[test]
fn test_print_doctor_report_does_not_panic_healthy() {
let db = test_db();
let report = run_doctor(&db, "print-healthy").unwrap();
print_doctor_report(&report);
}
#[test]
fn test_print_doctor_report_does_not_panic_with_issues() {
let report = DoctorReport {
healthy: false,
project: "test".to_string(),
project_source: "git".to_string(),
project_path: "/tmp/test".to_string(),
db_path: "/tmp/test.db".to_string(),
db_reachable: true,
memory_count: 0,
session_count: 0,
orphaned_relations: 0,
issues: vec!["Missing config file".to_string()],
checks: vec!["Memory count: 0".to_string()],
};
print_doctor_report(&report);
}
#[test]
fn test_doctor_with_session_activity() {
let db = test_db();
let sessions = db.sessions();
let session = sessions.start("session-test", None).unwrap();
sessions
.end(session.id, Some("Test session"))
.unwrap();
let report = run_doctor(&db, "session-test").unwrap();
assert_eq!(report.project, "session-test");
}
#[test]
fn test_report_struct_fields() {
let db = test_db();
let report = run_doctor(&db, "fields-test").unwrap();
assert!(report.healthy); assert!(report.memory_count <= 1_000_000); assert!(report.session_count <= 1_000_000); assert!(report.orphaned_relations <= 1_000_000); assert!(!report.project_path.is_empty()); }
}