AkurAI Build
Menu

Public repository / 1733e7c7f9cb6fd1bd4760c59b7727822aab6036

AkurAI-Notes / crates/notes-cli/src/main.rs

Latest change 705e1ce035ea4b9fb4c413dd38f550650185b571 - release: v0.0.5 by Claude Code

#![forbid(unsafe_code)]

mod cmd_mcp;
mod cmd_seed;
mod cmd_serve;

use std::path::PathBuf;
use std::process::ExitCode;

const VERSION: &str = env!("CARGO_PKG_VERSION");

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    match run(&args) {
        Ok(()) => ExitCode::SUCCESS,
        Err(msg) => {
            eprintln!("error: {msg}");
            ExitCode::FAILURE
        }
    }
}

fn run(args: &[String]) -> Result<(), String> {
    match args.first().map(String::as_str) {
        Some("serve") => {
            let cfg = parse_serve(&args[1..])?;
            cmd_serve::run(cfg).map_err(|e| e.to_string())
        }
        Some("mcp") => {
            let cfg = parse_mcp(&args[1..])?;
            cmd_mcp::run(cfg).map_err(|e| e.to_string())
        }
        Some("seed") => {
            let db_path = args.get(1).map(|s| s.as_str()).unwrap_or("notes.db");
            cmd_seed::run(db_path)
        }
        Some("version") | Some("--version") | Some("-V") => {
            println!("akurai-notes {VERSION}");
            Ok(())
        }
        Some("help") | Some("--help") | Some("-h") | None => {
            print_help();
            Ok(())
        }
        Some(other) => Err(format!(
            "unknown command '{other}' (try: akurai-notes help)"
        )),
    }
}

fn parse_serve(args: &[String]) -> Result<cmd_serve::Config, String> {
    let mut host = "127.0.0.1".to_string();
    let mut port: u16 = 8099;
    let mut dir = PathBuf::from("site/frontend");
    let mut db = "notes.db".to_string();

    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "--host" => {
                host = next(args, &mut i, "--host")?;
            }
            "--port" | "-p" => {
                port = next(args, &mut i, "--port")?
                    .parse()
                    .map_err(|_| "invalid --port".to_string())?;
            }
            "--dir" | "--frontend-dir" => {
                dir = PathBuf::from(next(args, &mut i, "--dir")?);
            }
            "--db" => {
                db = next(args, &mut i, "--db")?;
            }
            other => return Err(format!("unknown flag '{other}'")),
        }
        i += 1;
    }
    Ok(cmd_serve::Config {
        host,
        port,
        dir,
        db,
    })
}

fn parse_mcp(args: &[String]) -> Result<cmd_mcp::McpRunConfig, String> {
    let mut host = "127.0.0.1".to_string();
    let mut port: u16 = 8101;
    let mut db = "/var/lib/akurai-notes/notes.db".to_string();
    let mut token = String::new();
    let mut embed_url = String::new();
    let mut embed_model = String::new();
    let mut embed_api_key = String::new();
    let mut owner_sub = String::new();

    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "--host" => host = next(args, &mut i, "--host")?,
            "--port" | "-p" => {
                port = next(args, &mut i, "--port")?
                    .parse()
                    .map_err(|_| "invalid --port".to_string())?;
            }
            "--db" => db = next(args, &mut i, "--db")?,
            "--token" => token = next(args, &mut i, "--token")?,
            "--embed-url" => embed_url = next(args, &mut i, "--embed-url")?,
            "--embed-model" => embed_model = next(args, &mut i, "--embed-model")?,
            "--embed-api-key" => embed_api_key = next(args, &mut i, "--embed-api-key")?,
            "--owner-sub" => owner_sub = next(args, &mut i, "--owner-sub")?,
            other => return Err(format!("unknown flag '{other}'")),
        }
        i += 1;
    }

    if token.is_empty() {
        token = std::env::var("AKURAI_NOTES_MCP_TOKEN").unwrap_or_default();
    }
    if owner_sub.is_empty() {
        owner_sub = std::env::var("AKURAI_NOTES_MCP_OWNER_SUB").unwrap_or_default();
    }
    if embed_url.is_empty() {
        embed_url = std::env::var("AKURAI_NOTES_EMBED_URL").unwrap_or_default();
    }
    if embed_model.is_empty() {
        embed_model = std::env::var("AKURAI_NOTES_EMBED_MODEL").unwrap_or_default();
    }
    if embed_api_key.is_empty() {
        embed_api_key = std::env::var("AKURAI_NOTES_EMBED_API_KEY").unwrap_or_default();
    }

    Ok(cmd_mcp::McpRunConfig {
        host,
        port,
        db,
        token,
        embed_url,
        embed_model,
        embed_api_key,
        owner_sub,
    })
}

fn next(args: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
    *i += 1;
    args.get(*i)
        .cloned()
        .ok_or_else(|| format!("{flag} needs a value"))
}

fn print_help() {
    println!("akurai-notes {VERSION} — Private notes with semantic search\n");
    println!("USAGE:");
    println!("  akurai-notes serve [opts]    Start the Notes web server");
    println!("  akurai-notes mcp   [opts]    Start the MCP server");
    println!("  akurai-notes seed  [db]      Seed the database with demo data");
    println!("  akurai-notes version         Print version\n");
    println!("SERVE OPTIONS:");
    println!("  --host <addr>   Bind host (default: 127.0.0.1)");
    println!("  --port, -p <n>  Bind port (default: 8099)");
    println!("  --dir <path>    Frontend directory (default: site/frontend)");
    println!("  --db <path>     Database file (default: notes.db)\n");
    println!("MCP OPTIONS:");
    println!("  --host <addr>      Bind host (default: 127.0.0.1)");
    println!("  --port, -p <n>     Bind port (default: 8101)");
    println!("  --db <path>        Database file (default: /var/lib/akurai-notes/notes.db)");
    println!("  --token <tok>      Bearer token (env: AKURAI_NOTES_MCP_TOKEN)");
    println!("  --embed-url <url>  Embedding API URL");
    println!("  --embed-model <m>  Embedding model name");
    println!("  --embed-api-key <k> Embedding bearer token");
    println!("  --owner-sub <sub>  Owner subject (env: AKURAI_NOTES_MCP_OWNER_SUB)");
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Mutex, OnceLock};

    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
    }

    struct EnvGuard(Vec<(&'static str, Option<String>)>);

    impl EnvGuard {
        fn capture(keys: &[&'static str]) -> Self {
            Self(
                keys.iter()
                    .map(|key| (*key, std::env::var(key).ok()))
                    .collect(),
            )
        }
    }

    impl Drop for EnvGuard {
        fn drop(&mut self) {
            for (key, value) in &self.0 {
                match value {
                    Some(value) => std::env::set_var(key, value),
                    None => std::env::remove_var(key),
                }
            }
        }
    }

    #[test]
    fn parse_mcp_reads_embed_env_when_flags_are_absent() {
        let _lock = env_lock();
        let _env = EnvGuard::capture(&[
            "AKURAI_NOTES_EMBED_URL",
            "AKURAI_NOTES_EMBED_MODEL",
            "AKURAI_NOTES_EMBED_API_KEY",
        ]);
        std::env::set_var(
            "AKURAI_NOTES_EMBED_URL",
            "http://127.0.0.1:4219/v1/embeddings",
        );
        std::env::set_var("AKURAI_NOTES_EMBED_MODEL", "intfloat/multilingual-e5-small");
        std::env::set_var("AKURAI_NOTES_EMBED_API_KEY", "router-secret");

        let args: Vec<String> = Vec::new();
        let cfg = parse_mcp(&args).unwrap();

        assert_eq!(cfg.embed_url, "http://127.0.0.1:4219/v1/embeddings");
        assert_eq!(cfg.embed_model, "intfloat/multilingual-e5-small");
        assert_eq!(cfg.embed_api_key, "router-secret");
    }

    #[test]
    fn parse_mcp_prefers_embed_flags_over_env() {
        let _lock = env_lock();
        let _env = EnvGuard::capture(&[
            "AKURAI_NOTES_EMBED_URL",
            "AKURAI_NOTES_EMBED_MODEL",
            "AKURAI_NOTES_EMBED_API_KEY",
        ]);
        std::env::set_var(
            "AKURAI_NOTES_EMBED_URL",
            "http://127.0.0.1:4219/v1/embeddings",
        );
        std::env::set_var("AKURAI_NOTES_EMBED_MODEL", "env-model");
        std::env::set_var("AKURAI_NOTES_EMBED_API_KEY", "env-secret");

        let args = vec![
            "--embed-url".to_string(),
            "http://127.0.0.1:7777/v1/embeddings".to_string(),
            "--embed-model".to_string(),
            "flag-model".to_string(),
            "--embed-api-key".to_string(),
            "flag-secret".to_string(),
        ];
        let cfg = parse_mcp(&args).unwrap();

        assert_eq!(cfg.embed_url, "http://127.0.0.1:7777/v1/embeddings");
        assert_eq!(cfg.embed_model, "flag-model");
        assert_eq!(cfg.embed_api_key, "flag-secret");
    }
}