use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
#[cfg(feature = "backend-sqlite")]
use ijima_core::NamespaceId;
use ijima_core::capabilities::ALL_CAPABILITIES;
use ijima_server::{IjimaAuth, key_store};
#[derive(Parser)]
#[command(
name = "ijima",
version,
about = "Ijima — centralized agentic memory backend (admin CLI)"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Token {
#[command(subcommand)]
action: TokenAction,
},
Doctrine {
#[command(subcommand)]
action: DoctrineAction,
},
Serve(ServeArgs),
Export(ExportArgs),
#[cfg(feature = "backend-sqlite")]
Migrate(MigrateArgs),
}
#[derive(Args, Debug)]
struct ExportArgs {
#[arg(long, short)]
out: std::path::PathBuf,
}
#[derive(Args, Debug)]
struct MigrateArgs {
#[arg(long, value_name = "PATH")]
palace: Option<std::path::PathBuf>,
#[arg(long, value_name = "PATH")]
brain: Option<std::path::PathBuf>,
#[arg(long)]
embed: bool,
#[arg(long, value_name = "NS", default_value = "global")]
namespace: String,
}
#[derive(Subcommand)]
enum DoctrineAction {
Ingest(IngestArgs),
}
#[derive(Args)]
struct IngestArgs {
#[arg(long, value_name = "DIR")]
dir: PathBuf,
#[arg(long, value_name = "URL")]
url: String,
#[arg(long, value_name = "TOKEN")]
token: String,
}
#[derive(Args)]
struct ServeArgs {
#[arg(long)]
host: Option<String>,
#[arg(long)]
port: Option<u16>,
}
#[derive(Subcommand)]
enum TokenAction {
Issue(IssueArgs),
}
#[derive(Args)]
struct IssueArgs {
#[arg(long)]
principal: String,
#[arg(long)]
capability: String,
#[arg(long, value_name = "PATH")]
key_file: Option<PathBuf>,
#[arg(long)]
json: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
ijima_server::server::init_tracing();
match cli.command {
Command::Token { action } => match action {
TokenAction::Issue(args) => match run_issue(args) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(error = %e, "token issue failed");
ExitCode::FAILURE
}
},
},
Command::Doctrine { action } => match action {
DoctrineAction::Ingest(args) => {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
match rt {
Ok(rt) => match rt.block_on(run_doctrine_ingest(args)) {
Ok(n) => {
tracing::info!(entries = n, "doctrine ingested");
ExitCode::SUCCESS
}
Err(e) => {
tracing::error!(error = %e, "doctrine ingest failed");
ExitCode::FAILURE
}
},
Err(e) => {
tracing::error!(error = %e, "runtime build failed");
ExitCode::FAILURE
}
}
}
},
Command::Serve(args) => {
let mut config = ijima_server::server::DaemonConfig::default();
if let Some(h) = args.host {
config.host = h;
}
if let Some(p) = args.port {
config.port = p;
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build();
match rt {
Ok(rt) => match rt.block_on(ijima_server::server::serve(&config)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(error = %e, "serve failed");
ExitCode::FAILURE
}
},
Err(e) => {
tracing::error!(error = %e, "runtime build failed");
ExitCode::FAILURE
}
}
}
Command::Export(args) => {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
match rt {
Ok(rt) => match rt.block_on(run_export(args)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("ijima: {e}");
ExitCode::FAILURE
}
},
Err(e) => {
eprintln!("ijima: runtime: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(feature = "backend-sqlite")]
Command::Migrate(args) => {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
match rt {
Ok(rt) => match rt.block_on(run_migrate(args)) {
Ok(report) => {
tracing::info!(
attempted = report.attempted,
imported = report.imported,
skipped = report.skipped,
"migration complete"
);
eprintln!(
"ijima: migrated {} imported, {} skipped (of {} attempted)",
report.imported, report.skipped, report.attempted
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("ijima: {e}");
ExitCode::FAILURE
}
},
Err(e) => {
eprintln!("ijima: runtime: {e}");
ExitCode::FAILURE
}
}
}
}
}
fn run_issue(args: IssueArgs) -> ijima_core::Result<()> {
validate_capability(&args.capability)?;
let key_path = match args.key_file {
Some(p) => p,
None => key_store::default_key_path()?,
};
let seed = key_store::load_or_create(&key_path)?;
let auth = IjimaAuth::from_embedded_policy_with_seed(seed)?;
let token = auth.issue_bearer(args.principal.as_str(), &args.capability)?;
let public_key = auth.issuer_public_key_hex();
if args.json {
println!(
"{{\"token\":\"{token}\",\"principal\":\"{}\",\"capability\":\"{}\",\"public_key\":\"{public_key}\"}}",
args.principal, args.capability
);
} else {
println!("{token}");
}
Ok(())
}
fn validate_capability(cap: &str) -> ijima_core::Result<()> {
if ALL_CAPABILITIES.contains(&cap) {
Ok(())
} else {
Err(ijima_core::IjimaError::invalid_input(format!(
"unknown capability '{cap}'. Valid: {}",
ALL_CAPABILITIES.join(", ")
)))
}
}
async fn run_doctrine_ingest(args: IngestArgs) -> ijima_core::Result<usize> {
let entries = ijima_server::doctrine::read_doctrine_dir(&args.dir)?;
if entries.is_empty() {
tracing::warn!(dir = %args.dir.display(), "no *.md doctrine files found");
return Ok(0);
}
tracing::info!(entries = entries.len(), "ingesting doctrine");
let parsed: Vec<_> = entries.iter().map(|(_, e)| e.clone()).collect();
ijima_server::doctrine::ingest_to_daemon(&args.url, &args.token, &parsed).await
}
async fn run_export(args: ExportArgs) -> ijima_core::Result<()> {
let data_dir = std::env::var("IJIMA_DIR")
.map(std::path::PathBuf::from)
.or_else(|_| {
std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(".ijima"))
.ok_or_else(|| {
ijima_core::IjimaError::invalid_input(
"cannot resolve data dir: set IJIMA_DIR or HOME",
)
})
})?;
let db_path = data_dir.join("ijima.db");
let store = ijima_server::SurrealStore::open_persistent(&db_path).await?;
store.export_to(&args.out).await?;
eprintln!("ijima: exported to {}", args.out.display());
Ok(())
}
#[cfg(feature = "backend-sqlite")]
async fn run_migrate(
args: MigrateArgs,
) -> ijima_core::Result<ijima_server::migration::ImportReport> {
use ijima_server::migration::{
import_memories, map_pipalace_memory, map_zeroclaw_memory, read_pipalace_memories,
read_zeroclaw_memories,
};
if args.palace.is_none() && args.brain.is_none() {
return Err(ijima_core::IjimaError::invalid_input(
"migrate needs at least one of --palace <memories.db> or --brain <brain.db>",
));
}
let data_dir = std::env::var("IJIMA_DIR")
.map(std::path::PathBuf::from)
.or_else(|_| {
std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(".ijima"))
.ok_or_else(|| {
ijima_core::IjimaError::invalid_input(
"cannot resolve data dir: set IJIMA_DIR or HOME",
)
})
})?;
let db_path = data_dir.join("ijima.db");
#[cfg(feature = "embeddings-candle")]
let store = if args.embed {
let embedder: std::sync::Arc<dyn ijima_core::Embedder> =
std::sync::Arc::new(ijima_server::embeddings_candle::CandleEmbedder::from_env()?);
ijima_server::SurrealStore::open_persistent_with(&db_path, embedder).await?
} else {
ijima_server::SurrealStore::open_persistent(&db_path).await?
};
#[cfg(not(feature = "embeddings-candle"))]
let store = ijima_server::SurrealStore::open_persistent(&db_path).await?;
let ns = NamespaceId::new(&args.namespace);
let mut all: Vec<ijima_core::Memory> = Vec::new();
if let Some(palace) = &args.palace {
let rows = read_pipalace_memories(&palace.to_string_lossy())?;
eprintln!("ijima: read {} rows from {}", rows.len(), palace.display());
all.extend(rows.iter().map(map_pipalace_memory));
}
if let Some(brain) = &args.brain {
let rows = read_zeroclaw_memories(&brain.to_string_lossy())?;
eprintln!("ijima: read {} rows from {}", rows.len(), brain.display());
all.extend(rows.iter().map(map_zeroclaw_memory));
}
eprintln!(
"ijima: importing {} memories into namespace `{}`…",
all.len(),
ns.as_str()
);
let report = import_memories(&store, &ns, all).await?;
Ok(report)
}