vtcode 0.137.0

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::enable_raw_mode;
use std::io::{self, IsTerminal, Write};
use std::path::Path;
use unicode_width::UnicodeWidthChar;
use vtcode_config::api_keys::{
    CredentialSource, DiscoveredProvider, has_oauth_or_managed_auth, provider_credential_detail,
};
use vtcode_config::auth::{AuthCredentialsStoreMode, CustomApiKeyStorage};
use vtcode_config::workspace_env::{
    MigrationOutcome, migrate_single_env_key, read_workspace_env_values, workspace_env_path,
};
use vtcode_core::cli::args::{MigrateArgs, SecretProvider, SecretSubcommand};
use vtcode_core::config::models::Provider;

pub async fn handle_secret_command(command: SecretSubcommand, workspace: &Path) -> Result<()> {
    match command {
        SecretSubcommand::List => render_secret_status_table(None),
        SecretSubcommand::Status { provider_name } => {
            render_secret_status_table(provider_name.map(secret_provider_to_provider))
        }
        SecretSubcommand::Add { provider_name } => handle_add(secret_provider_to_provider(provider_name)).await,
        SecretSubcommand::Delete { provider_name } => handle_delete(secret_provider_to_provider(provider_name)).await,
        SecretSubcommand::Migrate(args) => handle_migrate(args, workspace).await,
    }
}

fn render_secret_status_table(filter: Option<Provider>) -> Result<()> {
    println!("API Key Status");
    println!();

    let providers: Vec<Provider> = match filter {
        Some(p) => vec![p],
        None => Provider::all_providers(),
    };

    let details: Vec<DiscoveredProvider> = providers.iter().filter_map(|&p| provider_credential_detail(p)).collect();

    for detail in &details {
        let source = detail.source;
        let source_label = match source {
            CredentialSource::Env => "Environment variable",
            CredentialSource::SecureStorage => "OS keyring / encrypted file",
            CredentialSource::OAuth => "OAuth session",
            CredentialSource::ManagedAuth => "Managed auth (external CLI)",
            CredentialSource::Local => "Local — no key required",
        };
        let status = match source {
            CredentialSource::Local | CredentialSource::ManagedAuth => "Ready",
            CredentialSource::OAuth | CredentialSource::SecureStorage | CredentialSource::Env => "Ready",
        };

        println!("  {} ({})", detail.provider.label(), detail.provider.as_ref());
        println!("    Status: {}", status);
        println!("    Source: {}", source_label);

        if let Some(env_key) = detail.env_var {
            println!("    Env var: {}", env_key);
        }

        println!();
    }

    let has_oauth_or_managed = has_oauth_or_managed_auth(&details);

    println!("Use `vtcode secret add <provider>` to store a key.");
    if !has_oauth_or_managed {
        println!("Use `vtcode secret delete <provider>` to remove a stored key.");
    }
    if has_oauth_or_managed {
        println!("OAuth / managed-auth providers (copilot, openai, openrouter) use their own login flows.");
        println!("Run `vtcode login <provider>` or `/login <provider>` for those.");
    }
    Ok(())
}

async fn handle_add(provider: Provider) -> Result<()> {
    if provider.uses_managed_auth() {
        println!(
            "{} uses managed auth (GitHub Copilot CLI). Run `vtcode login {}` instead.",
            provider.label(),
            provider.as_ref()
        );
        return Ok(());
    }
    let label = provider.label();
    let env_key = provider.default_api_key_env();

    println!("Bring your own key (BYOK) for {label}.");
    println!("Expected env: {}", env_key);
    println!("Secure display hint: ****************");
    println!("Key will be stored in secure storage (OS keyring or encrypted file).");
    println!("Key will NOT be stored in vtcode.toml or workspace environment files.");
    println!();

    let key = if io::stdin().is_terminal() {
        prompt_hidden_input(&format!("{} API key: ", label))?
    } else {
        eprintln!("Warning: stdin is not a terminal — the pasted key will be visible.");
        print!("{} API key: ", label);
        io::stdout().flush()?;
        let mut input = String::new();
        io::stdin().read_line(&mut input)?;
        input.trim().to_string()
    };

    if key.is_empty() {
        anyhow::bail!("API key cannot be empty.");
    }

    let storage = CustomApiKeyStorage::new(provider.as_ref());
    storage.store(&key, AuthCredentialsStoreMode::default())?;
    println!();
    println!("API key for {label} stored in secure storage.");
    println!("The key will be used automatically.");
    Ok(())
}

async fn handle_delete(provider: Provider) -> Result<()> {
    if provider.uses_managed_auth() {
        println!(
            "{} uses managed auth (GitHub Copilot CLI). Run `vtcode login {}` instead.",
            provider.label(),
            provider.as_ref()
        );
        return Ok(());
    }
    let label = provider.label();

    let storage = CustomApiKeyStorage::new(provider.as_ref());
    match storage.load(AuthCredentialsStoreMode::default()) {
        Ok(None) => {
            println!("No stored API key found for {label}.");
            return Ok(());
        }
        Ok(Some(_)) => {}
        Err(err) => {
            eprintln!("Warning: Could not inspect stored key for {label}: {err}");
        }
    }

    print!("Type 'confirm' to delete the stored API key for {label}, or press Enter to cancel: ");
    io::stdout().flush()?;

    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let trimmed = input.trim();

    if trimmed.ne("confirm") {
        println!("Deletion cancelled.");
        return Ok(());
    }

    storage.clear(AuthCredentialsStoreMode::default())?;
    println!();
    println!("API key for {label} deleted from secure storage.");
    println!("The change takes effect immediately.");
    Ok(())
}

async fn handle_migrate(args: MigrateArgs, workspace: &Path) -> Result<()> {
    let env_path = workspace_env_path(workspace);
    if !env_path.exists() {
        println!("No .env file found at {}. Nothing to migrate.", env_path.display());
        return Ok(());
    }

    println!("Scanning {} for API keys to migrate...", env_path.display());
    println!();

    let providers: Vec<Provider> = if let Some(p) = args.provider_name {
        let provider = secret_provider_to_provider(p);
        if provider.uses_managed_auth() {
            println!(
                "{} uses managed auth (GitHub Copilot CLI). Run `vtcode login {}` instead.",
                provider.label(),
                provider.as_ref()
            );
            return Ok(());
        }
        vec![provider]
    } else {
        Provider::all_providers()
            .into_iter()
            .filter(|p| !p.is_local() && !p.uses_managed_auth())
            .collect()
    };

    if args.dry_run {
        println!("[dry-run] Would migrate the following keys from .env to secure storage:");
        println!();
        let env_keys: Vec<&str> = providers
            .iter()
            .filter_map(|p| {
                let key = p.default_api_key_env();
                if key.is_empty() { None } else { Some(key) }
            })
            .collect();
        let found = read_workspace_env_values(workspace, &env_keys)?;
        for provider in &providers {
            let env_key = provider.default_api_key_env();
            if env_key.is_empty() {
                continue;
            }
            if found.contains_key(env_key) {
                println!("  {} ({})", provider.label(), env_key);
            }
        }
        println!();
        println!("No changes were made.");
        return Ok(());
    }

    let mut migrated = 0u32;
    let mut skipped = 0u32;
    let mut failed = 0u32;

    let env_keys: Vec<&str> = providers
        .iter()
        .filter_map(|p| {
            let key = p.default_api_key_env();
            if key.is_empty() { None } else { Some(key) }
        })
        .collect();
    let env_values = read_workspace_env_values(workspace, &env_keys)?;

    for provider in providers {
        let env_key = provider.default_api_key_env();

        if !args.force && !args.all && io::stdin().is_terminal() {
            print!("Migrate {} from .env to secure storage? [Y/n] ", env_key);
            io::stdout().flush()?;
            let mut input = String::new();
            io::stdin().read_line(&mut input)?;
            let trimmed = input.trim().to_lowercase();
            if !trimmed.is_empty() && trimmed != "y" && trimmed != "yes" {
                println!("Skipped {}.", env_key);
                skipped += 1;
                continue;
            }
        }

        let value = env_values.get(env_key).map(|s| s.as_str());
        match migrate_single_env_key(workspace, provider, AuthCredentialsStoreMode::default(), value)? {
            MigrationOutcome::Migrated => {
                println!("Migrated {} to secure storage.", env_key);
                migrated += 1;
            }
            MigrationOutcome::Skipped => {
                skipped += 1;
            }
            MigrationOutcome::Failed => {
                eprintln!("Failed to migrate {}.", env_key);
                failed += 1;
            }
        }
    }

    println!();
    println!("Migration complete: {} migrated, {} skipped, {} failed", migrated, skipped, failed);
    if failed > 0 {
        anyhow::bail!("Some migrations failed. Review the errors above.");
    }
    Ok(())
}

#[allow(clippy::let_unit_value)]
fn prompt_hidden_input(prompt: &str) -> Result<String> {
    if !io::stdin().is_terminal() {
        anyhow::bail!("Cannot prompt for hidden input: stdin is not a terminal");
    }

    let _raw = enable_raw_mode().with_context(|| "Failed to enable raw mode for secret input")?;

    {
        let mut stdout = io::stdout();
        write!(stdout, "{}", prompt)?;
        stdout.flush()?;
    }

    let mut buffer = String::new();
    loop {
        let event = event::read().with_context(|| "Failed to read keypress while entering API key")?;
        match handle_key(event, &mut buffer)? {
            KeyAction::Continue => continue,
            KeyAction::Submit => {
                let mut stdout = io::stdout();
                writeln!(stdout)?;
                stdout.flush()?;
                let trimmed = buffer.trim().to_string();
                return Ok(trimmed);
            }
            KeyAction::Abort => {
                println!();
                anyhow::bail!("Secret entry cancelled.");
            }
        }
    }
}

enum KeyAction {
    Continue,
    Submit,
    Abort,
}

fn handle_key(event: Event, buffer: &mut String) -> Result<KeyAction> {
    let Event::Key(key) = event else {
        return Ok(KeyAction::Continue);
    };
    if key.kind != KeyEventKind::Press {
        return Ok(KeyAction::Continue);
    }
    let mut stdout = io::stdout();
    match key.code {
        KeyCode::Enter => Ok(KeyAction::Submit),
        KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => Ok(KeyAction::Abort),
        KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => Ok(KeyAction::Submit),
        KeyCode::Char('u') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
            let width: usize = buffer.chars().map(|c| UnicodeWidthChar::width(c).unwrap_or(0).max(1)).sum();
            for _ in 0..width {
                write!(stdout, "\u{8}")?;
            }
            for _ in 0..width {
                write!(stdout, " ")?;
            }
            for _ in 0..width {
                write!(stdout, "\u{8}")?;
            }
            stdout.flush()?;
            buffer.clear();
            Ok(KeyAction::Continue)
        }
        KeyCode::Backspace => {
            if let Some(c) = buffer.pop() {
                let width = UnicodeWidthChar::width(c).unwrap_or(0).max(1);
                for _ in 0..width {
                    write!(stdout, "\u{8}")?;
                }
                for _ in 0..width {
                    write!(stdout, " ")?;
                }
                for _ in 0..width {
                    write!(stdout, "\u{8}")?;
                }
                stdout.flush()?;
            }
            Ok(KeyAction::Continue)
        }
        KeyCode::Char(c) if !c.is_control() => {
            buffer.push(c);
            write!(stdout, "*")?;
            stdout.flush()?;
            Ok(KeyAction::Continue)
        }
        _ => Ok(KeyAction::Continue),
    }
}

fn secret_provider_to_provider(p: SecretProvider) -> Provider {
    match p {
        SecretProvider::OpenAI => Provider::OpenAI,
        SecretProvider::Anthropic => Provider::Anthropic,
        SecretProvider::Gemini => Provider::Gemini,
        SecretProvider::DeepSeek => Provider::DeepSeek,
        SecretProvider::OpenRouter => Provider::OpenRouter,
        SecretProvider::StepFun => Provider::StepFun,
        SecretProvider::Zai => Provider::ZAI,
        SecretProvider::Moonshot => Provider::Moonshot,
        SecretProvider::MiniMax => Provider::Minimax,
        SecretProvider::Mistral => Provider::Mistral,
        SecretProvider::HuggingFace => Provider::HuggingFace,
        SecretProvider::MiMo => Provider::MiMo,
        SecretProvider::OpenCodeZen => Provider::OpenCodeZen,
        SecretProvider::OpenCodeGo => Provider::OpenCodeGo,
        SecretProvider::Qwen => Provider::Qwen,
        SecretProvider::Evolink => Provider::Evolink,
        SecretProvider::Poolside => Provider::Poolside,
        SecretProvider::Ollama => Provider::Ollama,
        SecretProvider::LMStudio => Provider::LmStudio,
        SecretProvider::Copilot => Provider::Copilot,
    }
}