claux 20260730.0.1

Terminal AI coding assistant with tool execution
mod api;
mod bootstrap;
mod checkpoint;
mod cli;
mod command_sandbox;
mod commands;
mod compact;
mod config;
mod context;
mod cost;
mod db;
#[cfg(test)]
mod evals;
mod model;
mod onboarding;
mod permissions;
mod plugin;
mod query;
mod repl;
mod sandbox;
mod session;
#[cfg(test)]
mod test_support;
mod theme;
mod tools;
mod tui;
mod utils;

use anyhow::Result;
use clap::Parser;
use std::sync::Arc;

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

    if let Some(cli::CliCommand::SandboxExec { workspace, command }) = &args.command {
        return command_sandbox::run_helper(workspace, command);
    }
    if matches!(args.command, Some(cli::CliCommand::SandboxProbe)) {
        return command_sandbox::run_probe();
    }

    // Init logging
    let filter = if args.debug {
        "claux=debug"
    } else if args.verbose {
        "claux=info"
    } else {
        "claux=warn"
    };
    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_writer(std::io::stderr)
        .init();

    if let Some(command) = &args.command {
        match command {
            cli::CliCommand::Config {
                command:
                    cli::ConfigCommand::Init {
                        provider,
                        model,
                        force,
                    },
            } => {
                let path = onboarding::init_config(*provider, model.as_deref(), *force)?;
                println!("Created {}", path.display());
                println!("Run `claux doctor` to verify the setup.");
                return Ok(());
            }
            cli::CliCommand::Doctor { offline } => {
                let config = config::Config::load(args.trust_project)?;
                let report = onboarding::doctor(&config, *offline).await;
                print!("{}", report.text);
                if !report.healthy {
                    anyhow::bail!("doctor found configuration errors");
                }
                return Ok(());
            }
            cli::CliCommand::SandboxExec { .. } | cli::CliCommand::SandboxProbe => {
                unreachable!("handled before logging")
            }
        }
    }

    // Load config (global + project)
    let mut config = config::Config::load(args.trust_project)?;
    if let Some(ref mode) = args.permission_mode {
        config.permission_mode = serde_json::from_value(serde_json::Value::String(mode.clone()))
            .map_err(|_| {
                anyhow::anyhow!(
                    "Invalid permission mode {mode:?}; expected default, accept-edits, bypass, or plan"
                )
            })?;
    }

    // Build plugin registry
    let mut plugin_registry = plugin::PluginRegistry::new();
    for plugin_config in &config.plugins {
        plugin_registry.add(Box::new(plugin::CommandPlugin::new(
            &plugin_config.name,
            &plugin_config.command,
            &plugin_config.args,
            plugin_config.trigger.clone(),
        )));
    }
    if !plugin_registry.is_empty() {
        tracing::info!(
            "Loaded {} plugin(s): {} context, {} tool-start, {} tool-complete, {} session-start, {} turn-end, {} permission-request",
            plugin_registry.len(),
            plugin_registry.get_by_trigger(&config::HookTrigger::OnContextBuild),
            plugin_registry.get_by_trigger(&config::HookTrigger::OnToolStart),
            plugin_registry.get_by_trigger(&config::HookTrigger::OnToolComplete),
            plugin_registry.get_by_trigger(&config::HookTrigger::OnSessionStart),
            plugin_registry.get_by_trigger(&config::HookTrigger::OnTurnEnd),
            plugin_registry.get_by_trigger(&config::HookTrigger::OnPermissionRequest),
        );
    }
    let plugin_registry = Arc::new(plugin_registry);

    let requested_model = match args.model.as_deref() {
        Some(model) => config.resolve_model(model)?,
        None => config.default_resolved_model()?,
    };

    tracing::debug!(
        "Config loaded: openai_base_url={:?} openai_api_key_cmd={:?} model={}",
        config.openai_base_url,
        config.openai_api_key_cmd,
        config.model
    );

    // One-shot mode: --print / -p
    if let Some(ref prompt) = args.prompt {
        let mut engine = build_engine(&config, &requested_model, plugin_registry.clone()).await?;

        let system_prompt = context::build_system_prompt_for_model(
            &requested_model.binding.model,
            Some(&plugin_registry),
            &config::HookTrigger::OnContextBuild,
            requested_model.binding.provider_kind == config::ProviderKind::Anthropic,
        )
        .await?;
        engine.set_system_prompt(system_prompt);

        let response = engine
            .submit(prompt, tokio_util::sync::CancellationToken::new())
            .await?;
        print!("{response}");
        return Ok(());
    }

    // Run session-start hooks
    plugin::PluginRegistry::execute_side_effects(
        &plugin_registry,
        &config::HookTrigger::OnSessionStart,
        None,
    )
    .await?;

    if args.tui {
        let mut models = config.selectable_models()?;
        if let Some(cli_model) = args.model.as_deref() {
            models.retain(|configured| {
                configured.binding.profile != cli_model && configured.binding.model != cli_model
            });
            models.insert(0, requested_model.clone());
        }
        return tui::run(&config, plugin_registry, models).await;
    }

    // Resume a previous session if requested. The matched id is handed to
    // the REPL so it continues that session instead of forking a new one.
    let mut resumed_id: Option<String> = None;
    let mut resolved_model = requested_model;
    let mut resumed_messages = None;
    if let Some(ref session_id) = args.resume {
        match session::find_session(session_id)? {
            Some((sid, path)) => {
                let (meta, messages) = session::load_session(&path)?;
                resolved_model = match meta.model_binding.as_ref() {
                    Some(binding) => config.resolve_binding(binding)?,
                    None => config.resolve_model(&meta.model).map_err(|error| {
                        anyhow::anyhow!(
                            "Session {} uses legacy model '{}', which cannot be resolved: {error}. \
                             Add a matching model profile or start a new session.",
                            meta.id,
                            meta.model
                        )
                    })?,
                };
                eprintln!(
                    "Resumed session {} ({}, {} messages)",
                    meta.id,
                    meta.model,
                    messages.len()
                );
                resumed_messages = Some(messages);
                resumed_id = Some(sid);
            }
            None => {
                eprintln!("Session not found: {session_id}. Starting new session.");
            }
        }
    }

    let mut engine = build_engine(&config, &resolved_model, plugin_registry.clone()).await?;
    if let Some(messages) = resumed_messages {
        engine.set_messages(messages);
    }
    repl::run(engine, &config, plugin_registry, resumed_id, resolved_model).await
}

async fn build_engine(
    config: &config::Config,
    resolved: &config::ResolvedModel,
    plugins: Arc<plugin::PluginRegistry>,
) -> Result<query::Engine> {
    let model = &resolved.binding.model;
    let provider = build_provider(resolved)?;
    tracing::info!(
        "Provider: {} ({}, profile {})",
        provider.name(),
        model,
        resolved.binding.profile
    );

    let resolved_for_factory = resolved.clone();
    let agent_factory: tools::agent::ProviderFactory = Box::new(move || {
        build_provider(&resolved_for_factory).expect("failed to build agent provider")
    });
    let sandbox_policy = Arc::new(sandbox::SandboxPolicy::from_native_tool_policy(
        config.native_tool_filesystem_policy,
        std::env::current_dir()?,
    )?);
    let command_sandbox = Arc::new(command_sandbox::CommandSandbox::new(
        config.bash_filesystem_policy,
        std::env::current_dir()?,
    )?);
    let mut tool_registry = tools::ToolRegistry::new_with_agent_factory(
        agent_factory,
        model.clone(),
        config.permission_mode,
        sandbox_policy,
        command_sandbox,
    );
    tool_registry.add_tools(bootstrap::connect_mcp_tools(config).await);

    let permission_checker = permissions::PermissionChecker::new(config.permission_mode);
    let mut engine = query::Engine::new(provider, tool_registry, permission_checker, model);
    engine.set_model_binding(resolved.binding.clone());
    engine.set_plugins(plugins);
    engine.set_auto_compact_threshold(config.auto_compact_threshold);
    engine.set_max_tokens(config.max_tokens);
    engine.set_model_metadata(resolved.metadata);
    Ok(engine)
}

/// Build a provider from config.
fn build_provider(resolved: &config::ResolvedModel) -> Result<Box<dyn api::Provider>> {
    let binding = &resolved.binding;
    let api_key = resolved.resolve_api_key().unwrap_or_default();
    if api_key.is_empty() && resolved.requires_api_key() {
        anyhow::bail!(
            "No API key found for profile '{}' (provider '{}'). Set {} or update \
             ~/.config/claux/config.toml.",
            binding.profile,
            binding.provider_name,
            binding.api_key_env,
        );
    }
    match binding.provider_kind {
        config::ProviderKind::Openai => {
            let base_url = binding.base_url.as_deref().ok_or_else(|| {
                anyhow::anyhow!("saved provider '{}' has no base URL", binding.provider)
            })?;
            match binding.protocol {
                config::OpenAIProtocol::ChatCompletions => {
                    Ok(Box::new(api::OpenAICompatProvider::new(
                        base_url,
                        &api_key,
                        &binding.model,
                        &binding.provider_name,
                    )))
                }
                config::OpenAIProtocol::Responses => {
                    Ok(Box::new(api::OpenAIResponsesProvider::new(
                        base_url,
                        &api_key,
                        &binding.model,
                        &binding.provider_name,
                        binding.reasoning_effort.as_deref(),
                    )))
                }
            }
        }
        config::ProviderKind::Anthropic => {
            if api_key.is_empty() {
                anyhow::bail!(
                    "No authentication found for profile '{}'. Set {}.",
                    binding.profile,
                    binding.api_key_env
                );
            }
            Ok(Box::new(api::AnthropicProvider::new(
                config::AnthropicApiKey::new(api_key),
                &binding.model,
            )))
        }
    }
}