mod cli;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use erinra::config;
#[derive(Parser)]
#[command(
name = "erinra",
version,
about = "Memory MCP server for LLM coding assistants"
)]
struct Cli {
#[arg(long, env = "ERINRA_DATA_DIR")]
data_dir: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Serve {
#[arg(long)]
log_level: Option<String>,
#[arg(long)]
log_file: Option<PathBuf>,
#[arg(long)]
busy_timeout: Option<u32>,
#[arg(long)]
embedding_model: Option<String>,
#[arg(long)]
reranker_model: Option<String>,
#[arg(long)]
web: bool,
#[arg(long, requires = "web")]
port: Option<u16>,
#[arg(long, requires = "web")]
bind: Option<String>,
},
Export {
output: PathBuf,
#[arg(long)]
gzip: bool,
#[arg(long)]
since: Option<String>,
},
Import {
input: PathBuf,
},
Sync {
#[arg(long)]
force: bool,
},
Reembed {
#[arg(long)]
model: Option<String>,
},
Status,
Models,
Licenses,
#[command(name = "_daemon", hide = true)]
InternalDaemon {
#[arg(long)]
port: u16,
#[arg(long)]
bind: String,
},
Dash {
#[arg(long)]
port: Option<u16>,
#[arg(long)]
bind: Option<String>,
#[arg(long)]
no_open: bool,
#[arg(long)]
open_only: bool,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let data_dir = cli.data_dir.map(Ok).unwrap_or_else(|| {
dirs::home_dir()
.map(|mut d| {
d.push(".erinra");
d
})
.ok_or_else(|| {
anyhow::anyhow!("could not determine home directory; set ERINRA_DATA_DIR")
})
})?;
match cli.command {
Command::Serve {
log_level,
log_file,
busy_timeout,
embedding_model,
reranker_model,
web,
port,
bind,
} => {
cli::serve(
&data_dir,
log_level,
log_file,
busy_timeout,
embedding_model,
reranker_model,
web,
port,
bind,
)
.await
}
cmd => {
let config = if data_dir.exists() {
config::Config::load(&data_dir, None).context("failed to load configuration")?
} else {
config::Config::default()
};
cli::init_tracing(&config.logging)?;
match cmd {
Command::Export {
output,
gzip,
since,
} => cli::export(&data_dir, &config, &output, gzip, since),
Command::Import { input } => cli::import(&data_dir, &config, &input).await,
Command::Sync { force } => cli::run_sync(&data_dir, &config, force).await,
Command::Reembed { model } => cli::reembed(&data_dir, &config, model).await,
Command::Status => cli::status(&data_dir, &config),
Command::Models => cli::models(),
Command::Licenses => cli::licenses(),
Command::Dash {
port,
bind,
no_open,
open_only,
} => cli::dash(&data_dir, &config, port, bind, no_open, open_only).await,
Command::InternalDaemon { port, bind } => {
cli::run_daemon(&data_dir, &config, port, &bind).await
}
Command::Serve { .. } => unreachable!(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn serve_web_with_port_and_bind() {
let cli = Cli::try_parse_from([
"erinra",
"serve",
"--web",
"--port",
"9090",
"--bind",
"127.0.0.1",
])
.expect("serve --web --port --bind should parse");
match cli.command {
Command::Serve {
web, port, bind, ..
} => {
assert!(web);
assert_eq!(port, Some(9090));
assert_eq!(bind, Some("127.0.0.1".to_string()));
}
_ => panic!("expected Serve variant"),
}
}
#[test]
fn serve_port_requires_web_flag() {
let result = Cli::try_parse_from(["erinra", "serve", "--port", "9090"]);
assert!(result.is_err(), "--port without --web should fail");
}
#[test]
fn serve_defaults_web_false() {
let cli = Cli::try_parse_from(["erinra", "serve"]).expect("bare serve should parse");
match cli.command {
Command::Serve {
web, port, bind, ..
} => {
assert!(!web);
assert_eq!(port, None);
assert_eq!(bind, None);
}
_ => panic!("expected Serve variant"),
}
}
#[test]
fn serve_bind_requires_web_flag() {
let result = Cli::try_parse_from(["erinra", "serve", "--bind", "0.0.0.0"]);
assert!(result.is_err(), "--bind without --web should fail");
}
#[test]
fn dash_with_port_bind_no_open() {
let cli = Cli::try_parse_from([
"erinra",
"dash",
"--port",
"9090",
"--bind",
"127.0.0.1",
"--no-open",
])
.expect("dash --port --bind --no-open should parse");
match cli.command {
Command::Dash {
port,
bind,
no_open,
open_only: _,
} => {
assert_eq!(port, Some(9090));
assert_eq!(bind, Some("127.0.0.1".to_string()));
assert!(no_open);
}
_ => panic!("expected Dash variant"),
}
}
#[test]
fn hidden_daemon_subcommand_parses() {
let cli =
Cli::try_parse_from(["erinra", "_daemon", "--port", "9090", "--bind", "127.0.0.1"])
.expect("_daemon subcommand should parse");
match cli.command {
Command::InternalDaemon { port, bind } => {
assert_eq!(port, 9090);
assert_eq!(bind, "127.0.0.1");
}
_ => panic!("expected InternalDaemon variant"),
}
}
#[test]
fn serve_with_reranker_model() {
let cli = Cli::try_parse_from(["erinra", "serve", "--reranker-model", "BGERerankerBase"])
.expect("serve --reranker-model should parse");
match cli.command {
Command::Serve { reranker_model, .. } => {
assert_eq!(reranker_model, Some("BGERerankerBase".to_string()));
}
_ => panic!("expected Serve variant"),
}
}
#[test]
fn serve_defaults_no_reranker_model() {
let cli = Cli::try_parse_from(["erinra", "serve"]).expect("bare serve should parse");
match cli.command {
Command::Serve { reranker_model, .. } => {
assert_eq!(reranker_model, None);
}
_ => panic!("expected Serve variant"),
}
}
}