mod api; mod commands; mod config; mod errors; mod output; mod ui; mod utils; use anyhow::Result; use api::ApiClient; use clap::{Parser, Subcommand, ValueEnum}; use config::Config; use output::OutputFormat; use std::path::PathBuf; #[derive(Debug, Clone, Copy, ValueEnum)] enum Format { Pretty, Json, Yaml, } impl From for OutputFormat { fn from(f: Format) -> Self { match f { Format::Pretty => OutputFormat::Pretty, Format::Json => OutputFormat::Json, Format::Yaml => OutputFormat::Yaml, } } } #[derive(Parser)] #[command(name = "hindsight")] #[command(about = "Hindsight CLI - Semantic memory system", long_about = None)] #[command(version)] #[command(before_help = get_before_help())] #[command(after_help = get_after_help())] struct Cli { /// Output format (pretty, json, yaml) #[arg(short = 'o', long, global = true, default_value = "pretty")] output: Format, /// Show verbose output including full requests and responses #[arg(short = 'v', long, global = true)] verbose: bool, #[command(subcommand)] command: Commands, } fn get_after_help() -> String { let config = config::Config::load().ok(); let (api_url, source) = match &config { Some(c) => (c.api_url.as_str(), c.source.to_string()), None => ("http://localhost:8888", "default".to_string()), }; format!( "Current API URL: {} (from {})\n\nRun 'hindsight configure' to change the API URL.", api_url, source ) } fn get_before_help() -> &'static str { ui::get_logo() } #[derive(Subcommand)] enum Commands { /// Manage banks (list, profile, stats) #[command(subcommand)] Bank(BankCommands), /// Manage memories (recall, reflect, retain, delete) #[command(subcommand)] Memory(MemoryCommands), /// Manage documents (list, get, delete) #[command(subcommand)] Document(DocumentCommands), /// Manage entities (list, get, regenerate) #[command(subcommand)] Entity(EntityCommands), /// Manage async operations (list, cancel) #[command(subcommand)] Operation(OperationCommands), /// Interactive TUI explorer (k9s-style) for navigating banks, memories, entities, and performing recall/reflect #[command(alias = "tui")] Explore, /// Configure the CLI (API URL, etc.) #[command(after_help = "Configuration priority:\n 1. Environment variable (HINDSIGHT_API_URL) - highest priority\n 2. Config file (~/.hindsight/config)\n 3. Default (http://localhost:8888)")] Configure { /// API URL to connect to (interactive prompt if not provided) #[arg(long)] api_url: Option, }, } #[derive(Subcommand)] enum BankCommands { /// List all banks List, /// Get bank disposition and background Disposition { /// Bank ID bank_id: String, }, /// Get memory statistics for a bank Stats { /// Bank ID bank_id: String, }, /// Set bank name Name { /// Bank ID bank_id: String, /// Bank name name: String, }, /// Set or merge bank background Background { /// Bank ID bank_id: String, /// Background content content: String, /// Skip automatic disposition inference #[arg(long)] no_update_disposition: bool, }, /// Delete a bank and all its data Delete { /// Bank ID bank_id: String, /// Skip confirmation prompt #[arg(short = 'y', long)] yes: bool, }, } #[derive(Subcommand)] enum MemoryCommands { /// Recall memories using semantic search Recall { /// Bank ID bank_id: String, /// Search query query: String, /// Fact types to search (world, experience, opinion) #[arg(short = 't', long, value_delimiter = ',', default_values = &["world", "experience", "opinion"])] fact_type: Vec, /// Thinking budget (low, mid, high) #[arg(short = 'b', long, default_value = "mid")] budget: String, /// Maximum tokens for results #[arg(long, default_value = "4096")] max_tokens: i64, /// Show trace information #[arg(long)] trace: bool, /// Include chunks in results #[arg(long)] include_chunks: bool, /// Maximum tokens for chunks (only used with --include-chunks) #[arg(long, default_value = "8192")] chunk_max_tokens: i64, }, /// Generate answers using bank identity (reflect/reasoning) Reflect { /// Bank ID bank_id: String, /// Query to reflect on query: String, /// Thinking budget (low, mid, high) #[arg(short = 'b', long, default_value = "mid")] budget: String, /// Additional context #[arg(short = 'c', long)] context: Option, }, /// Store (retain) a single memory Retain { /// Bank ID bank_id: String, /// Memory content content: String, /// Document ID (auto-generated if not provided) #[arg(short = 'd', long)] doc_id: Option, /// Context for the memory #[arg(short = 'c', long)] context: Option, /// Queue for background processing #[arg(long)] r#async: bool, }, /// Bulk import memories from files (retain) RetainFiles { /// Bank ID bank_id: String, /// Path to file or directory path: PathBuf, /// Search directories recursively #[arg(short = 'r', long, default_value = "true")] recursive: bool, /// Context for all memories #[arg(short = 'c', long)] context: Option, /// Queue for background processing #[arg(long)] r#async: bool, }, /// Delete a memory unit Delete { /// Bank ID bank_id: String, /// Memory unit ID unit_id: String, }, /// Clear all memories for a bank Clear { /// Bank ID bank_id: String, /// Fact type to clear (world, agent, opinion). If not specified, clears all types. #[arg(short = 't', long, value_parser = ["world", "agent", "opinion"])] fact_type: Option, /// Skip confirmation prompt #[arg(short = 'y', long)] yes: bool, }, } #[derive(Subcommand)] enum DocumentCommands { /// List documents for a bank List { /// Bank ID bank_id: String, /// Search query to filter documents #[arg(short = 'q', long)] query: Option, /// Maximum number of results #[arg(short = 'l', long, default_value = "100")] limit: i32, /// Offset for pagination #[arg(short = 's', long, default_value = "0")] offset: i32, }, /// Get a specific document by ID Get { /// Bank ID bank_id: String, /// Document ID document_id: String, }, /// Delete a document and all its memory units Delete { /// Bank ID bank_id: String, /// Document ID document_id: String, }, } #[derive(Subcommand)] enum EntityCommands { /// List entities for a bank List { /// Bank ID bank_id: String, /// Maximum number of results #[arg(short = 'l', long, default_value = "100")] limit: i64, }, /// Get detailed information about an entity Get { /// Bank ID bank_id: String, /// Entity ID entity_id: String, }, /// Regenerate observations for an entity Regenerate { /// Bank ID bank_id: String, /// Entity ID entity_id: String, }, } #[derive(Subcommand)] enum OperationCommands { /// List async operations for a bank List { /// Bank ID bank_id: String, }, /// Cancel a pending async operation Cancel { /// Bank ID bank_id: String, /// Operation ID operation_id: String, }, } fn main() { if let Err(_) = run() { std::process::exit(1); } } fn run() -> Result<()> { let cli = Cli::parse(); let output_format: OutputFormat = cli.output.into(); let verbose = cli.verbose; // Handle configure command before loading full config (it doesn't need API client) if let Commands::Configure { api_url } = cli.command { return handle_configure(api_url, output_format); } // Load configuration let config = Config::from_env().unwrap_or_else(|e| { ui::print_error(&format!("Configuration error: {}", e)); errors::print_config_help(); std::process::exit(1); }); let api_url = config.api_url().to_string(); // Create API client let client = ApiClient::new(api_url.clone()).unwrap_or_else(|e| { errors::handle_api_error(e, &api_url); }); // Execute command and handle errors let result: Result<()> = match cli.command { Commands::Configure { .. } => unreachable!(), // Handled above Commands::Explore => commands::explore::run(&client), Commands::Bank(bank_cmd) => match bank_cmd { BankCommands::List => commands::bank::list(&client, verbose, output_format), BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format), BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format), BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format), BankCommands::Background { bank_id, content, no_update_disposition } => { commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format) } BankCommands::Delete { bank_id, yes } => { commands::bank::delete(&client, &bank_id, yes, verbose, output_format) } }, Commands::Memory(memory_cmd) => match memory_cmd { MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => { commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format) } MemoryCommands::Reflect { bank_id, query, budget, context } => { commands::memory::reflect(&client, &bank_id, query, budget, context, verbose, output_format) } MemoryCommands::Retain { bank_id, content, doc_id, context, r#async } => { commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format) } MemoryCommands::RetainFiles { bank_id, path, recursive, context, r#async } => { commands::memory::retain_files(&client, &bank_id, path, recursive, context, r#async, verbose, output_format) } MemoryCommands::Delete { bank_id, unit_id } => { commands::memory::delete(&client, &bank_id, &unit_id, verbose, output_format) } MemoryCommands::Clear { bank_id, fact_type, yes } => { commands::memory::clear(&client, &bank_id, fact_type, yes, verbose, output_format) } }, Commands::Document(doc_cmd) => match doc_cmd { DocumentCommands::List { bank_id, query, limit, offset } => { commands::document::list(&client, &bank_id, query, limit, offset, verbose, output_format) } DocumentCommands::Get { bank_id, document_id } => { commands::document::get(&client, &bank_id, &document_id, verbose, output_format) } DocumentCommands::Delete { bank_id, document_id } => { commands::document::delete(&client, &bank_id, &document_id, verbose, output_format) } }, Commands::Entity(entity_cmd) => match entity_cmd { EntityCommands::List { bank_id, limit } => { commands::entity::list(&client, &bank_id, limit, verbose, output_format) } EntityCommands::Get { bank_id, entity_id } => { commands::entity::get(&client, &bank_id, &entity_id, verbose, output_format) } EntityCommands::Regenerate { bank_id, entity_id } => { commands::entity::regenerate(&client, &bank_id, &entity_id, verbose, output_format) } }, Commands::Operation(op_cmd) => match op_cmd { OperationCommands::List { bank_id } => { commands::operation::list(&client, &bank_id, verbose, output_format) } OperationCommands::Cancel { bank_id, operation_id } => { commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format) } }, }; // Handle API errors with nice messages if let Err(e) = result { errors::handle_api_error(e, &api_url); } Ok(()) } fn handle_configure(api_url: Option, output_format: OutputFormat) -> Result<()> { // Load current config to show current state let current_config = Config::load().ok(); if output_format == OutputFormat::Pretty { ui::print_info("Hindsight CLI Configuration"); println!(); // Show current configuration if let Some(ref config) = current_config { println!(" Current API URL: {}", config.api_url); println!(" Source: {}", config.source); println!(); } } // Get the new API URL (from argument or prompt) let new_api_url = match api_url { Some(url) => url, None => { // Interactive prompt let current = current_config.as_ref().map(|c| c.api_url.as_str()); config::prompt_api_url(current)? } }; // Validate the URL if !new_api_url.starts_with("http://") && !new_api_url.starts_with("https://") { ui::print_error(&format!( "Invalid API URL: {}. Must start with http:// or https://", new_api_url )); return Ok(()); } // Save to config file let config_path = Config::save_api_url(&new_api_url)?; if output_format == OutputFormat::Pretty { ui::print_success(&format!("Configuration saved to {}", config_path.display())); println!(); println!(" API URL: {}", new_api_url); println!(); println!("Note: Environment variable HINDSIGHT_API_URL will override this setting."); } else { let result = serde_json::json!({ "api_url": new_api_url, "config_path": config_path.display().to_string(), }); output::print_output(&result, output_format)?; } Ok(()) }