gigi-cli 1.0.0

Gigi — A Claude Code-like AI coding assistant CLI in Rust
pub mod agent;
pub mod commands;
pub mod config;
pub mod config_store;
pub mod prompt;
pub mod query;
pub mod session;
pub mod tools;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use colored::*;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;

use crate::config::AppConfig;
use crate::session::Session;
use crate::agent::Agent;
use crate::query::{QueryEngine, ModelProvider, ProviderType, create_provider};
use crate::tools::ToolRegistry;

#[derive(Parser, Debug)]
#[command(
    name = "Gigi",
    about = "Gigi — A Claude Code-like AI coding assistant CLI in Rust",
    version = "1.0.0"
)]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,

    /// Override the model provider (anthropic, groq, google, ollama, lm_studio, llama_cpp, custom)
    #[arg(long, short = 'p')]
    provider: Option<String>,

    /// Override the model ID
    #[arg(long, short = 'm')]
    model: Option<String>,

    /// Resume a specific session by ID in interactive mode
    #[arg(long, short = 's')]
    session: Option<String>,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Start a new interactive session (default behavior)
    New,

    /// Resume a saved session
    Resume {
        /// The UUID of the session to resume (full or 8-character prefix)
        session_id: String,
    },

    /// List all saved sessions
    Sessions,

    /// Run a single prompt non-interactively and exit
    Run {
        /// The prompt to execute
        prompt: String,

        /// Optional session ID to run the prompt within (resuming conversation history)
        #[arg(long, short = 's')]
        session: Option<String>,
    },

    /// Display the current configuration
    Config,
}

fn create_default_registry(tech_query_url: &str) -> ToolRegistry {
    let mut registry = ToolRegistry::new();
    registry.register(Box::new(crate::tools::bash::BashTool::new()));
    registry.register(Box::new(crate::tools::glob_search::GlobSearchTool::new()));
    registry.register(Box::new(crate::tools::grep_search::GrepSearchTool::new()));
    registry.register(Box::new(crate::tools::read_file::ReadFileTool::new()));
    registry.register(Box::new(crate::tools::write_file::WriteFileTool::new()));
    registry.register(Box::new(crate::tools::edit_file::EditFileTool::new()));
    registry.register(Box::new(crate::tools::tech_query::TechQueryTool::new(Some(tech_query_url.to_string()))));
    registry.register(Box::new(crate::tools::web_search::WebSearchTool::new()));
    registry.register(Box::new(crate::tools::web_fetch::WebFetchTool::new()));
    registry
}


fn get_os_info() -> (String, String) {
    let os_name = std::env::consts::OS.to_string();
    let os_version = if cfg!(target_os = "windows") {
        if let Ok(output) = std::process::Command::new("cmd").args(["/c", "ver"]).output() {
            String::from_utf8_lossy(&output.stdout).trim().to_string()
        } else {
            "Windows".to_string()
        }
    } else if cfg!(target_os = "macos") {
        if let Ok(output) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
            String::from_utf8_lossy(&output.stdout).trim().to_string()
        } else {
            "macOS".to_string()
        }
    } else {
        if let Ok(output) = std::process::Command::new("uname").arg("-r").output() {
            String::from_utf8_lossy(&output.stdout).trim().to_string()
        } else {
            "Linux/Unix".to_string()
        }
    };
    (os_name, os_version)
}

async fn build_system_prompt_with_memory(session_dir: &std::path::Path, model_info: &str) -> String {
    let mut memory_notes = Vec::new();
    let memory_path = session_dir.join("memory.json");
    if memory_path.exists() {
        if let Ok(json) = std::fs::read_to_string(&memory_path) {
            if let Ok(store) = serde_json::from_str::<serde_json::Value>(&json) {
                if let Some(notes) = store["notes"].as_array() {
                    for note in notes {
                        if let Some(text) = note["text"].as_str() {
                            memory_notes.push(text.to_string());
                        }
                    }
                }
            }
        }
    }

    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
    let (os_name, os_version) = get_os_info();
    let project_context = crate::prompt::ProjectContext::discover(cwd);

    crate::prompt::SystemPromptBuilder::new()
        .with_os(os_name, os_version)
        .with_model_info(model_info.to_string())
        .with_project_context(project_context)
        .with_memory_notes(memory_notes)
        .build()
}

async fn resolve_session_id(session_dir: &std::path::Path, prefix: &str) -> Result<String> {
    if prefix.len() == 36 {
        return Ok(prefix.to_string());
    }

    let summaries = Session::list_all(session_dir).await?;
    let matching: Vec<_> = summaries.into_iter().filter(|s| s.id.starts_with(prefix)).collect();

    if matching.is_empty() {
        anyhow::bail!("No session found matching prefix '{}'", prefix);
    } else if matching.len() > 1 {
        anyhow::bail!(
            "Multiple sessions match prefix '{}':\n{}",
            prefix,
            matching.iter().map(|s| format!("  - [{}] {}", &s.id[..8], s.title)).collect::<Vec<_>>().join("\n")
        );
    }

    Ok(matching[0].id.clone())
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();
    let mut config = AppConfig::from_env();

    // Determine active provider name and model ID
    let provider_name = cli.provider.unwrap_or_else(|| config.default_provider.clone());
    let model_name = cli.model.clone();

    // Setup key if missing
    config.prompt_for_key_if_missing(&provider_name)?;

    match cli.command {
        Some(Commands::Sessions) => {
            let summaries = Session::list_all(&config.session_dir).await?;
            if summaries.is_empty() {
                println!("{}", "No saved sessions found.".dimmed());
            } else {
                println!("{}", "\n━━━ Saved Sessions ━━━".bold());
                for summary in &summaries {
                    println!("  {}", summary);
                }
            }
            return Ok(());
        }

        Some(Commands::Config) => {
            println!("{}", config.display_summary());
            return Ok(());
        }

        Some(Commands::Run { prompt, session }) => {
            let provider = create_provider(&config, &provider_name, model_name)?;
            let model_info = format!("{} ({})", provider.name(), provider.model_id());
            let system_prompt = build_system_prompt_with_memory(&config.session_dir, &model_info).await;
            let tools = create_default_registry(&config.tech_query_url);
            let tool_defs = tools.definitions();

            let session = match session {
                Some(sid) => {
                    let full_id = resolve_session_id(&config.session_dir, &sid).await?;
                    Session::load(&config.session_dir, &full_id).await?
                }
                None => Session::new(provider.name(), provider.model_id()),
            };

            let engine = QueryEngine::new(provider, system_prompt, tool_defs).with_max_tokens(config.max_tokens);
            let mut agent = Agent::new(engine, tools, session, config);

            agent.run_turn(&prompt).await?;
            return Ok(());
        }

        Some(Commands::Resume { session_id }) => {
            let full_id = resolve_session_id(&config.session_dir, &session_id).await?;
            run_repl(config, &provider_name, model_name, Some(full_id)).await?;
        }

        Some(Commands::New) | None => {
            let mut session_id = cli.session;
            if session_id.is_none() {
                if let Ok(summaries) = Session::list_all(&config.session_dir).await {
                    if let Some(latest) = summaries.first() {
                        println!("{}", format!("Found recent session: {}", latest).cyan());
                        print!("Would you like to resume this session? (y/N): ");
                        use std::io::Write;
                        let _ = std::io::stdout().flush();
                        let mut input = String::new();
                        if std::io::stdin().read_line(&mut input).is_ok() {
                            let trimmed = input.trim().to_lowercase();
                            if trimmed == "y" || trimmed == "yes" {
                                session_id = Some(latest.id.clone());
                            }
                        }
                    }
                }
            }
            run_repl(config, &provider_name, model_name, session_id).await?;
        }
    }


    Ok(())
}

async fn run_repl(
    config: AppConfig,
    provider_name: &str,
    model_name: Option<String>,
    session_id: Option<String>,
) -> Result<()> {
    let provider = create_provider(&config, provider_name, model_name)?;
    let model_info = format!("{} ({})", provider.name(), provider.model_id());
    let system_prompt = build_system_prompt_with_memory(&config.session_dir, &model_info).await;
    let tools = create_default_registry(&config.tech_query_url);
    let tool_defs = tools.definitions();

    let session = match session_id {
        Some(sid) => {
            let full_id = resolve_session_id(&config.session_dir, &sid).await?;
            println!("{}", format!("Resuming session: {}", &full_id[..8]).green());
            Session::load(&config.session_dir, &full_id).await?
        }
        None => {
            let s = Session::new(provider.name(), provider.model_id());
            println!("{}", format!("Starting new session: {}", &s.id[..8]).green());
            s
        }
    };

    let engine = QueryEngine::new(provider, system_prompt, tool_defs).with_max_tokens(config.max_tokens);
    let mut agent = Agent::new(engine, tools, session, config.clone());

    println!("{}", "\n━━━ Gigi AI Coding Assistant CLI ━━━".bold().cyan());
    println!("  Model: {}", agent.model_info().bold());
    println!("  Type {} for a list of commands, or ask any question.", "/help".cyan());
    println!("  Press Ctrl+C or Ctrl+D to exit.\n");

    let mut rl = DefaultEditor::new()?;
    let history_path = config.session_dir.join("history.txt");
    let _ = rl.load_history(&history_path);

    loop {
        let prompt = format!("{}", "Gigi".cyan().bold());
        let readline = rl.readline(&prompt);

        match readline {
            Ok(line) => {
                let line = line.trim();
                if line.is_empty() {
                    continue;
                }

                let _ = rl.add_history_entry(line);
                let _ = rl.save_history(&history_path);

                if line == "/exit" || line == "/quit" {
                    println!("Goodbye!");
                    break;
                }

                // Dispatch slash command or run agent turn
                let handled = crate::commands::dispatch(&mut agent, line).await;
                match handled {
                    Ok(true) => {}
                    Ok(false) => {
                        if let Err(e) = agent.run_turn(line).await {
                            println!("{}", format!("Error: {}", e).red());
                        }
                    }
                    Err(e) => {
                        println!("{}", format!("Command error: {}", e).red());
                    }
                }
            }
            Err(ReadlineError::Interrupted) => {
                println!("Interrupted.");
                break;
            }
            Err(ReadlineError::Eof) => {
                println!("EOF.");
                break;
            }
            Err(err) => {
                println!("Error: {:?}", err);
                break;
            }
        }
    }

    Ok(())
}