agentmem 0.1.1

Local-first memory engine for AI coding agents
Documentation
//! CLI command dispatcher.
//!
//! This module keeps binary entrypoints thin by handling:
//!
//! - argument parsing
//! - config resolution
//! - store opening
//! - command execution
//! - human-readable output
//!
//! Binaries should usually call `run()` and exit with the returned status code.

use std::path::PathBuf;

use clap::{Parser, Subcommand};

use crate::cli::onboarding::run_onboarding;
use crate::cli::output::{
    print_blank_line, print_error, print_field, print_heading, print_info, print_line,
    print_success,
};
use crate::config::{resolve_local_config_path, Config};
use crate::error::Result;
use crate::index::{build_index, query_index, read_index_stats, QueryResult};
use crate::store::Store;
use crate::types::{Key, KeyPrefix, Namespace, Value};

/// Runs the CLI and returns a process exit code.
///
/// - `0` success
/// - `1` handled user-facing error
pub fn run() -> i32 {
    match run_inner() {
        Ok(()) => 0,
        Err(error) => {
            print_error(&error);
            1
        }
    }
}

fn run_inner() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Command::Init => {
            let _ = run_onboarding()?;
        }

        Command::Info => {
            let store = open_local_store()?;
            render_info(&store);
        }

        Command::Get { key } => {
            let store = open_local_store()?;
            let key = Key::new(key)?;

            if let Some(value) = store.get(&key) {
                print_line(value.as_str());
            } else {
                print_info("key not found");
            }
        }

        Command::Set { key, value } => {
            let mut store = open_local_store_locked()?;
            let key = Key::new(key)?;
            let value = Value::new(value)?;

            let _ = store.set(key, value)?;
            store.flush()?;

            print_success("value stored");
        }

        Command::Delete { key } => {
            let mut store = open_local_store_locked()?;
            let key = Key::new(key)?;

            match store.delete(&key) {
                Some(_) => {
                    store.flush()?;
                    print_success("key removed");
                }
                None => print_info("key not found"),
            }
        }

        Command::List { prefix } => {
            let store = open_local_store()?;

            match prefix {
                Some(prefix) => {
                    let prefix = KeyPrefix::new(prefix)?;
                    for entry in store.list_prefix(&prefix) {
                        print_line(format!("{} = {}", entry.key, entry.value));
                    }
                }
                None => {
                    for entry in store.entries() {
                        print_line(format!("{} = {}", entry.key, entry.value));
                    }
                }
            }
        }

        Command::Namespace { namespace } => {
            let store = open_local_store()?;
            let namespace = Namespace::new(namespace)?;

            for entry in store.list_namespace(&namespace) {
                print_line(format!("{} = {}", entry.key, entry.value));
            }
        }

        Command::Clear => {
            let confirmed =
                crate::cli::prompts::prompt_confirm("Delete all entries?", false)?;

            if confirmed {
                let mut store = open_local_store_locked()?;
                store.clear();
                store.flush()?;
                print_success("store cleared");
            } else {
                print_info("cancelled");
            }
        }

        Command::Index { command } => {
            let command =
                command.unwrap_or(IndexCommand::Build { root: None });

            match command {
                IndexCommand::Build { root } => {
                    let mut store = open_local_store_locked()?;
                    let root = root.unwrap_or(std::env::current_dir()?);
                    let report = build_index(&mut store, &root)?;

                    print_success("index built");
                    print_field("Root", report.root);
                    print_field("Files", report.file_count.to_string());
                    print_field("Skipped", report.skipped_files.to_string());
                    print_field("Chunks", report.chunk_count.to_string());
                    print_field("Tokens", report.token_count.to_string());
                }

                IndexCommand::Query {
                    query,
                    top_k,
                    token_budget,
                } => {
                    let store = open_local_store()?;
                    let result =
                        query_index(&store, &query, top_k, token_budget)?;

                    render_index_query(&result);
                }

                IndexCommand::Stats => {
                    let store = open_local_store()?;
                    let stats = read_index_stats(&store);

                    if !stats.built {
                        print_info("index not built");
                    } else {
                        print_heading("Index Stats");
                        print_field(
                            "Root",
                            stats.root.unwrap_or_default(),
                        );
                        print_field(
                            "Files",
                            stats.file_count.to_string(),
                        );
                        print_field(
                            "Chunks",
                            stats.chunk_count.to_string(),
                        );
                        print_field(
                            "Tokens",
                            stats.token_count.to_string(),
                        );
                        print_field(
                            "Built (unix)",
                            stats
                                .built_unix_seconds
                                .map_or_else(
                                    || "unknown".to_owned(),
                                    |value| value.to_string(),
                                ),
                        );
                        print_blank_line();
                    }
                }
            }
        }
    }

    Ok(())
}

/// CLI root parser.
#[derive(Debug, Parser)]
#[command(
    name = "agentmem",
    version,
    about = "Secure local memory for AI agents"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

/// Supported commands.
#[derive(Debug, Subcommand)]
enum Command {
    /// First-run setup.
    Init,

    /// Show store information.
    Info,

    /// Get a value by key.
    Get {
        /// Validated key.
        key: String,
    },

    /// Set a value.
    Set {
        /// Validated key.
        key: String,

        /// Text value.
        value: String,
    },

    /// Delete a key.
    Delete {
        /// Validated key.
        key: String,
    },

    /// List entries, optionally filtered by prefix.
    List {
        /// Optional prefix filter.
        #[arg(long)]
        prefix: Option<String>,
    },

    /// List entries inside a namespace.
    Namespace {
        /// Namespace path.
        namespace: String,
    },

    /// Remove all entries.
    Clear,

    /// Build and query local code index.
    Index {
        #[command(subcommand)]
        command: Option<IndexCommand>,
    },
}

/// Index subcommands.
#[derive(Debug, Subcommand)]
enum IndexCommand {
    /// Build or rebuild index for a workspace root.
    Build {
        /// Root directory to scan. Defaults to current directory.
        #[arg(long)]
        root: Option<PathBuf>,
    },

    /// Query ranked chunks from local index.
    Query {
        /// Natural language or keyword query.
        query: String,

        /// Maximum number of chunks to return.
        #[arg(long, default_value_t = 8)]
        top_k: usize,

        /// Maximum estimated tokens in returned chunks.
        #[arg(long, default_value_t = 4000)]
        token_budget: usize,
    },

    /// Show persisted index summary.
    Stats,
}

/// Opens the project-local store.
fn open_local_store() -> Result<Store> {
    let config_path = resolve_local_config_path()?;
    let config = Config::load(config_path)?;
    Store::open(config)
}

/// Opens the project-local store and acquires a lock.
fn open_local_store_locked() -> Result<Store> {
    let config_path = resolve_local_config_path()?;
    let config = Config::load(config_path)?;
    Store::open_locked(config)
}

/// Renders store information.
fn render_info(store: &Store) {
    let info = store.info();
    let stats = store.stats();

    print_heading("Store Info");
    print_field("Project", info.project_name.as_str());
    print_field("Path", &info.path.to_string());
    print_field("Entries", &stats.entry_count.to_string());
    print_field("Locked", if stats.locked { "yes" } else { "no" });
    print_field("Format", &info.format_version.to_string());

    print_blank_line();
}

fn render_index_query(result: &QueryResult) {
    print_heading("Index Query");
    print_field("Query", &result.query);
    print_field("Top K", result.top_k.to_string());
    print_field("Token budget", result.token_budget.to_string());
    print_field("Used tokens", result.used_tokens.to_string());
    print_field("Confidence", format!("{:.2}", result.confidence));
    print_field(
        "Fallback required",
        if result.fallback_required {
            "yes"
        } else {
            "no"
        },
    );
    print_field(
        "Matched tokens",
        if result.matched_tokens.is_empty() {
            "(none)".to_owned()
        } else {
            result.matched_tokens.join(", ")
        },
    );
    print_field(
        "Missing tokens",
        if result.missing_tokens.is_empty() {
            "(none)".to_owned()
        } else {
            result.missing_tokens.join(", ")
        },
    );
    print_blank_line();

    if result.chunks.is_empty() {
        print_info("no matching chunks");
        return;
    }

    for chunk in &result.chunks {
        print_line(format!(
            "{}:{}-{} score={} est_tokens={}",
            chunk.path,
            chunk.line_start,
            chunk.line_end,
            chunk.score,
            chunk.estimated_tokens
        ));

        print_line(truncate_for_terminal(&chunk.content, 700));
        print_blank_line();
    }
}

fn truncate_for_terminal(input: &str, max_len: usize) -> String {
    if input.len() <= max_len {
        return input.to_owned();
    }

    let mut clipped =
        input.chars().take(max_len).collect::<String>();

    clipped.push_str("...");
    clipped
}