From 30d7650bae920d59687236502849511eb6387880 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 00:01:43 -0500 Subject: [PATCH] feat(cli): #134 add `deepseek auth` keyring subcommands and surface backend in doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class keyring management on the dispatcher CLI and wires the TUI to read its DeepSeek key through the same Secrets façade. Subcommands: * `auth set --provider ` writes to the OS keyring; prompts on stdin without echo, never prints the key, never touches `config.toml`. Supports `--api-key` and `--api-key-stdin`. * `auth get --provider ` reports `set` / `not set` plus the resolving layer (keyring / env / config-file). Never prints the value. * `auth clear --provider ` deletes from keyring and from any legacy plaintext slot in `config.toml` for parity. * `auth list` table of all known providers and whether each layer holds a key. Non-revealing. * `auth migrate [--dry-run]` reads `api_key` (root + per-provider blocks) from `config.toml`, writes them to the keyring, then strips the entries from disk. Idempotent. * `auth status` expanded to also report the active keyring backend and per-provider keyring state. `doctor` now prints `keyring backend: ...` plus per-provider `keyring=yes/no, env=yes/no` lines and points users at `deepseek auth set` when no key resolves. `Config::deepseek_api_key()` in the TUI is rewritten to consult `Secrets::auto_detect()` first (keyring -> env), then fall back to the existing TOML slots with a deprecation warning. Error messages now lead with `deepseek auth set --provider `. 5 new unit tests cover argument parsing for the new subcommands and end-to-end auth set/clear/migrate behaviour against an `InMemoryKeyringStore`, verifying that no plaintext key ever lands in `config.toml`. Verified manually on macOS: $ deepseek auth set --provider deepseek --api-key-stdin $ security find-generic-password -s deepseek -a deepseek # entry present $ deepseek auth migrate # api_key lines stripped from ~/.deepseek/config.toml Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/cli/Cargo.toml | 1 + crates/cli/src/main.rs | 536 +++++++++++++++++++++++++++++++++++---- crates/tui/Cargo.toml | 1 + crates/tui/src/config.rs | 79 +++--- crates/tui/src/main.rs | 52 +++- 5 files changed, 558 insertions(+), 111 deletions(-) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ba6e7f5a..5f4204c5 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,6 +19,7 @@ deepseek-app-server = { path = "../app-server", version = "0.6.0" } deepseek-config = { path = "../config", version = "0.6.0" } deepseek-execpolicy = { path = "../execpolicy", version = "0.6.0" } deepseek-mcp = { path = "../mcp", version = "0.6.0" } +deepseek-secrets = { path = "../secrets", version = "0.6.0" } deepseek-state = { path = "../state", version = "0.6.0" } chrono.workspace = true dirs.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index e80f1acb..37eab70e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -15,6 +15,7 @@ use deepseek_app_server::{ use deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions}; use deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine}; use deepseek_mcp::{McpServerDefinition, run_stdio_server}; +use deepseek_secrets::Secrets; use deepseek_state::{StateStore, ThreadListFilters}; #[derive(Debug, Clone, Copy, ValueEnum)] @@ -189,17 +190,43 @@ struct AuthArgs { #[derive(Debug, Subcommand)] enum AuthCommand { + /// Show current provider, env vars, and config-file presence. Status, + /// Save an API key to the OS keyring (never written to disk in + /// plaintext). Reads from `--api-key`, `--api-key-stdin`, or + /// prompts on stdin when neither is given. Does not echo the key. Set { #[arg(long, value_enum)] provider: ProviderArg, + /// Inline value (discouraged — appears in shell history). #[arg(long)] api_key: Option, + /// Read the key from stdin instead of prompting. + #[arg(long = "api-key-stdin", default_value_t = false)] + api_key_stdin: bool, }, + /// Report whether a provider has a key configured. Never prints + /// the value; just `set` / `not set` plus the source layer. + Get { + #[arg(long, value_enum)] + provider: ProviderArg, + }, + /// Delete a provider's key from the OS keyring (and from the + /// plaintext config slot, if present, for parity). Clear { #[arg(long, value_enum)] provider: ProviderArg, }, + /// List all known providers with their auth state, without + /// revealing keys. + List, + /// Migrate plaintext `api_key` values from `~/.deepseek/config.toml` + /// into the OS keyring, then strip them from the file. + Migrate { + /// Don't actually write anything; print what would change. + #[arg(long, default_value_t = false)] + dry_run: bool, + }, } #[derive(Debug, Args)] @@ -506,85 +533,254 @@ fn run_logout_command(store: &mut ConfigStore) -> Result<()> { Ok(()) } +/// Map [`ProviderKind`] to the canonical keyring slot name (`-a` arg +/// in `security find-generic-password`). +fn keyring_slot(provider: ProviderKind) -> &'static str { + match provider { + ProviderKind::Deepseek => "deepseek", + ProviderKind::NvidiaNim => "nvidia-nim", + ProviderKind::Openai => "openai", + ProviderKind::Openrouter => "openrouter", + ProviderKind::Novita => "novita", + } +} + +/// Provider order used by the `auth list` and `auth status` outputs. +const PROVIDER_LIST: [ProviderKind; 5] = [ + ProviderKind::Deepseek, + ProviderKind::NvidiaNim, + ProviderKind::Openrouter, + ProviderKind::Novita, + ProviderKind::Openai, +]; + +fn provider_env_set(provider: ProviderKind) -> bool { + deepseek_secrets::env_for(keyring_slot(provider)).is_some() +} + +fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool { + let slot = store + .config + .providers + .for_provider(provider) + .api_key + .as_ref(); + let root = (provider == ProviderKind::Deepseek) + .then_some(store.config.api_key.as_ref()) + .flatten(); + slot.or(root).is_some_and(|v| !v.trim().is_empty()) +} + fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> { + run_auth_command_with_secrets(store, command, &Secrets::auto_detect()) +} + +fn run_auth_command_with_secrets( + store: &mut ConfigStore, + command: AuthCommand, + secrets: &Secrets, +) -> Result<()> { match command { AuthCommand::Status => { - let deepseek_env = std::env::var("DEEPSEEK_API_KEY") - .ok() - .filter(|v| !v.trim().is_empty()) - .is_some(); - let openai_env = std::env::var("OPENAI_API_KEY") - .ok() - .filter(|v| !v.trim().is_empty()) - .is_some(); - let nvidia_env = std::env::var("NVIDIA_API_KEY") - .or_else(|_| std::env::var("NVIDIA_NIM_API_KEY")) - .ok() - .filter(|v| !v.trim().is_empty()) - .is_some(); - let deepseek_file = store - .config - .providers - .deepseek - .api_key - .as_ref() - .or(store.config.api_key.as_ref()) - .is_some_and(|v| !v.trim().is_empty()); - let openai_file = store - .config - .providers - .openai - .api_key - .as_ref() - .is_some_and(|v| !v.trim().is_empty()); - let nvidia_file = store - .config - .providers - .nvidia_nim - .api_key - .as_ref() - .is_some_and(|v| !v.trim().is_empty()); - println!("provider: {}", store.config.provider.as_str()); - println!( - "deepseek auth: env={}, config={}", - deepseek_env, deepseek_file - ); - println!( - "nvidia-nim auth: env={}, config={}", - nvidia_env, nvidia_file - ); - println!("openai auth: env={}, config={}", openai_env, openai_file); + println!("keyring backend: {}", secrets.backend_name()); + for provider in PROVIDER_LIST { + let slot = keyring_slot(provider); + let keyring_set = secrets + .get(slot) + .ok() + .flatten() + .is_some_and(|v| !v.trim().is_empty()); + let env_set = provider_env_set(provider); + let file_set = provider_config_set(store, provider); + println!( + "{slot} auth: keyring={}, env={}, config={}", + keyring_set, env_set, file_set + ); + } Ok(()) } - AuthCommand::Set { provider, api_key } => { + AuthCommand::Set { + provider, + api_key, + api_key_stdin, + } => { let provider: ProviderKind = provider.into(); - let api_key = match api_key { - Some(v) => v, - None => read_api_key_from_stdin()?, + let slot = keyring_slot(provider); + let api_key = match (api_key, api_key_stdin) { + (Some(v), _) => v, + (None, true) => read_api_key_from_stdin()?, + (None, false) => prompt_api_key(slot)?, }; - store.config.provider = provider; - store.config.providers.for_provider_mut(provider).api_key = Some(api_key); - if provider == ProviderKind::Deepseek { - store.config.api_key = store.config.providers.deepseek.api_key.clone(); + secrets + .set(slot, &api_key) + .with_context(|| format!("failed to write {slot} key to keyring"))?; + // Don't print the key. Don't echo length. + println!("saved API key for {slot} to {}", secrets.backend_name()); + Ok(()) + } + AuthCommand::Get { provider } => { + let provider: ProviderKind = provider.into(); + let slot = keyring_slot(provider); + let in_keyring = secrets + .get(slot) + .ok() + .flatten() + .is_some_and(|v| !v.trim().is_empty()); + let in_env = provider_env_set(provider); + let in_file = provider_config_set(store, provider); + // Report the highest-priority source that has it. + let resolved = secrets.resolve(slot).is_some() || in_file; + if resolved { + let source = if in_keyring { + "keyring" + } else if in_env { + "env" + } else { + "config-file" + }; + println!("{slot}: set (source: {source})"); + } else { + println!("{slot}: not set"); } - store.save()?; - println!("saved API key for {}", provider.as_str()); Ok(()) } AuthCommand::Clear { provider } => { let provider: ProviderKind = provider.into(); + let slot = keyring_slot(provider); + secrets + .delete(slot) + .with_context(|| format!("failed to delete {slot} key from keyring"))?; + // Also clear the plaintext slot in config.toml for parity. store.config.providers.for_provider_mut(provider).api_key = None; if provider == ProviderKind::Deepseek { store.config.api_key = None; } store.save()?; - println!("cleared API key for {}", provider.as_str()); + println!("cleared API key for {slot}"); Ok(()) } + AuthCommand::List => { + println!("keyring backend: {}", secrets.backend_name()); + println!("provider keyring env config"); + for provider in PROVIDER_LIST { + let slot = keyring_slot(provider); + let kr = secrets + .get(slot) + .ok() + .flatten() + .is_some_and(|v| !v.trim().is_empty()); + let env = provider_env_set(provider); + let file = provider_config_set(store, provider); + println!( + "{slot:<12} {} {} {}", + yes_no(kr), + yes_no(env), + yes_no(file) + ); + } + Ok(()) + } + AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run), } } +fn yes_no(b: bool) -> &'static str { + if b { "yes" } else { "no " } +} + +fn prompt_api_key(slot: &str) -> Result { + use std::io::{IsTerminal, Write}; + eprint!("Enter API key for {slot}: "); + io::stderr().flush().ok(); + if !io::stdin().is_terminal() { + // Non-interactive: read directly without prompting twice. + return read_api_key_from_stdin(); + } + let mut buf = String::new(); + io::stdin() + .read_line(&mut buf) + .context("failed to read API key from stdin")?; + let key = buf.trim().to_string(); + if key.is_empty() { + bail!("empty API key provided"); + } + Ok(key) +} + +/// Move plaintext keys from config.toml into the keyring. Stays +/// idempotent: rerunning is a no-op once the file is clean. +fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> { + let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new(); + let mut warnings: Vec = Vec::new(); + + for provider in PROVIDER_LIST { + let slot = keyring_slot(provider); + let from_provider_block = store + .config + .providers + .for_provider(provider) + .api_key + .clone() + .filter(|v| !v.trim().is_empty()); + let from_root = (provider == ProviderKind::Deepseek) + .then(|| store.config.api_key.clone()) + .flatten() + .filter(|v| !v.trim().is_empty()); + let value = from_provider_block.or(from_root); + let Some(value) = value else { continue }; + + if let Ok(Some(existing)) = secrets.get(slot) + && existing == value + { + // Already migrated; safe to strip the file slot. + } else if dry_run { + migrated.push((provider, slot)); + continue; + } else if let Err(err) = secrets.set(slot, &value) { + warnings.push(format!("skipped {slot}: failed to write to keyring: {err}")); + continue; + } + if !dry_run { + store.config.providers.for_provider_mut(provider).api_key = None; + if provider == ProviderKind::Deepseek { + store.config.api_key = None; + } + } + migrated.push((provider, slot)); + } + + if !dry_run && !migrated.is_empty() { + store + .save() + .context("failed to write updated config.toml")?; + } + + println!("keyring backend: {}", secrets.backend_name()); + if migrated.is_empty() { + println!("nothing to migrate (config.toml has no plaintext api_key entries)"); + } else { + println!( + "{} {} provider key(s):", + if dry_run { "would migrate" } else { "migrated" }, + migrated.len() + ); + for (_, slot) in &migrated { + println!(" - {slot}"); + } + if !dry_run { + println!( + "config.toml at {} no longer contains api_key entries for migrated providers.", + store.path().display() + ); + } + } + for w in warnings { + eprintln!("warning: {w}"); + } + Ok(()) +} + fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> { match command { ConfigCommand::Get { key } => { @@ -1199,6 +1395,234 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn parses_auth_subcommand_matrix() { + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Set { + provider: ProviderArg::Deepseek, + api_key: None, + api_key_stdin: false, + } + })) + )); + + let cli = parse_ok(&[ + "deepseek", + "auth", + "set", + "--provider", + "openrouter", + "--api-key-stdin", + ]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Set { + provider: ProviderArg::Openrouter, + api_key: None, + api_key_stdin: true, + } + })) + )); + + let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Get { + provider: ProviderArg::Novita + } + })) + )); + + let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Clear { + provider: ProviderArg::NvidiaNim + } + })) + )); + + let cli = parse_ok(&["deepseek", "auth", "list"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::List + })) + )); + + let cli = parse_ok(&["deepseek", "auth", "migrate"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Migrate { dry_run: false } + })) + )); + + let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Migrate { dry_run: true } + })) + )); + } + + #[test] + fn auth_set_writes_to_keyring_and_not_to_config_file() { + use deepseek_secrets::{InMemoryKeyringStore, KeyringStore}; + use std::sync::Arc; + + let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "deepseek-cli-auth-set-test-{}-{nanos}.toml", + std::process::id() + )); + let mut store = ConfigStore::load(Some(path.clone())).expect("store should load"); + let inner = Arc::new(InMemoryKeyringStore::new()); + let secrets = Secrets::new(inner.clone()); + + run_auth_command_with_secrets( + &mut store, + AuthCommand::Set { + provider: ProviderArg::Deepseek, + api_key: Some("sk-keyring".to_string()), + api_key_stdin: false, + }, + &secrets, + ) + .expect("set should succeed"); + + assert_eq!( + inner.get("deepseek").unwrap(), + Some("sk-keyring".to_string()) + ); + // Plaintext config slot must not be written. + assert!(store.config.api_key.is_none()); + assert!(store.config.providers.deepseek.api_key.is_none()); + let saved = std::fs::read_to_string(&path).unwrap_or_default(); + assert!( + !saved.contains("sk-keyring"), + "plaintext key leaked into config: {saved}" + ); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn auth_clear_removes_from_keyring_and_config() { + use deepseek_secrets::{InMemoryKeyringStore, KeyringStore}; + use std::sync::Arc; + + let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "deepseek-cli-auth-clear-test-{}-{nanos}.toml", + std::process::id() + )); + let mut store = ConfigStore::load(Some(path.clone())).expect("store should load"); + store.config.api_key = Some("sk-stale".to_string()); + store.config.providers.deepseek.api_key = Some("sk-stale".to_string()); + store.save().unwrap(); + + let inner = Arc::new(InMemoryKeyringStore::new()); + inner.set("deepseek", "sk-keyring").unwrap(); + let secrets = Secrets::new(inner.clone()); + + run_auth_command_with_secrets( + &mut store, + AuthCommand::Clear { + provider: ProviderArg::Deepseek, + }, + &secrets, + ) + .expect("clear should succeed"); + + assert_eq!(inner.get("deepseek").unwrap(), None); + assert!(store.config.api_key.is_none()); + assert!(store.config.providers.deepseek.api_key.is_none()); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() { + use deepseek_secrets::{InMemoryKeyringStore, KeyringStore}; + use std::sync::Arc; + + let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "deepseek-cli-auth-migrate-test-{}-{nanos}.toml", + std::process::id() + )); + let mut store = ConfigStore::load(Some(path.clone())).expect("store should load"); + store.config.api_key = Some("sk-deep".to_string()); + store.config.providers.deepseek.api_key = Some("sk-deep".to_string()); + store.config.providers.openrouter.api_key = Some("or-key".to_string()); + store.config.providers.novita.api_key = Some("nv-key".to_string()); + store.save().unwrap(); + + let inner = Arc::new(InMemoryKeyringStore::new()); + let secrets = Secrets::new(inner.clone()); + + run_auth_command_with_secrets( + &mut store, + AuthCommand::Migrate { dry_run: false }, + &secrets, + ) + .expect("migrate should succeed"); + + assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string())); + assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string())); + assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string())); + + // Config file must no longer contain the api keys. + assert!(store.config.api_key.is_none()); + assert!(store.config.providers.deepseek.api_key.is_none()); + assert!(store.config.providers.openrouter.api_key.is_none()); + assert!(store.config.providers.novita.api_key.is_none()); + + let saved = std::fs::read_to_string(&path).expect("config exists post-migrate"); + assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}"); + assert!(!saved.contains("or-key"), "plaintext leaked: {saved}"); + assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}"); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn auth_migrate_dry_run_does_not_modify_anything() { + use deepseek_secrets::{InMemoryKeyringStore, KeyringStore}; + use std::sync::Arc; + + let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml", + std::process::id() + )); + let mut store = ConfigStore::load(Some(path.clone())).expect("store should load"); + store.config.providers.openrouter.api_key = Some("or-stay".to_string()); + store.save().unwrap(); + + let inner = Arc::new(InMemoryKeyringStore::new()); + let secrets = Secrets::new(inner.clone()); + + run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets) + .expect("dry-run should succeed"); + + assert_eq!(inner.get("openrouter").unwrap(), None); + assert_eq!( + store.config.providers.openrouter.api_key.as_deref(), + Some("or-stay") + ); + + let _ = std::fs::remove_file(path); + } + #[test] fn parses_global_override_flags() { let cli = parse_ok(&[ diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 8ca40f05..d52ea218 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" +deepseek-secrets = { path = "../secrets", version = "0.6.0" } async-stream = "0.3.6" async-trait = "0.1" bytes = "1.11.0" diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 9c4f24ca..e30f329e 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -636,77 +636,72 @@ impl Config { normalize_base_url(&base) } - /// Read the API key from config/environment. + /// Read the API key. + /// + /// Precedence: **OS keyring → environment → config file**. The + /// keyring + env layers are collapsed by [`deepseek_secrets::Secrets::resolve`]; + /// the config-file fallback is preserved here for users who haven't + /// run `deepseek auth migrate` yet. pub fn deepseek_api_key(&self) -> Result { let provider = self.api_provider(); + let slot = match provider { + ApiProvider::Deepseek => "deepseek", + ApiProvider::NvidiaNim => "nvidia-nim", + ApiProvider::Openrouter => "openrouter", + ApiProvider::Novita => "novita", + }; - match provider { - ApiProvider::Deepseek => { - if let Ok(key) = std::env::var("DEEPSEEK_API_KEY") - && !key.trim().is_empty() - { - return Ok(key); - } - } - ApiProvider::NvidiaNim => { - for name in ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] { - if let Ok(key) = std::env::var(name) - && !key.trim().is_empty() - { - return Ok(key); - } - } - } - ApiProvider::Openrouter => { - if let Ok(key) = std::env::var("OPENROUTER_API_KEY") - && !key.trim().is_empty() - { - return Ok(key); - } - } - ApiProvider::Novita => { - if let Ok(key) = std::env::var("NOVITA_API_KEY") - && !key.trim().is_empty() - { - return Ok(key); - } - } + // 1. OS keyring + 2. environment variables (handled by Secrets). + let secrets = deepseek_secrets::Secrets::auto_detect(); + if let Some(value) = secrets.resolve(slot) + && !value.trim().is_empty() + { + return Ok(value); } - // Then check config file + // 3. config file (provider-scoped slot). if let Some(configured) = self .provider_config_for(provider) .and_then(|provider| provider.api_key.clone()) && !configured.trim().is_empty() { + tracing::warn!( + "[providers.{slot}] api_key in config.toml is deprecated; \ + run 'deepseek auth set --provider {slot}' to move it to the OS keyring" + ); return Ok(configured); } + // 4. legacy root `api_key` (deepseek only). if let Some(configured) = self.api_key.clone() && !configured.trim().is_empty() && configured != API_KEYRING_SENTINEL { + tracing::warn!( + "api_key in config.toml is deprecated; run 'deepseek auth migrate' to move it to the OS keyring" + ); return Ok(configured); } match provider { ApiProvider::Deepseek => anyhow::bail!( "DeepSeek API key not found. Set it using one of these methods:\n\ - 1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\ - 2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\ - 3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml" + 1. Run 'deepseek auth set --provider deepseek' to save it in the OS keyring (recommended)\n\ + 2. Set DEEPSEEK_API_KEY environment variable\n\ + 3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml (deprecated)" ), ApiProvider::NvidiaNim => anyhow::bail!( - "NVIDIA NIM API key not found. Set NVIDIA_API_KEY, NVIDIA_NIM_API_KEY, \ - or save api_key in ~/.deepseek/config.toml with provider = \"nvidia-nim\"." + "NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \ + set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \ + with provider = \"nvidia-nim\"." ), ApiProvider::Openrouter => anyhow::bail!( - "OpenRouter API key not found. Set OPENROUTER_API_KEY \ - or add [providers.openrouter] api_key in ~/.deepseek/config.toml." + "OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \ + set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml." ), ApiProvider::Novita => anyhow::bail!( - "Novita API key not found. Set NOVITA_API_KEY \ - or add [providers.novita] api_key in ~/.deepseek/config.toml." + "Novita API key not found. Run 'deepseek auth set --provider novita', \ + set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml." ), } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 08b8278f..fe27a220 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1342,28 +1342,54 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt // Check API keys println!(); println!("{}", "API Keys:".bold()); - let has_api_key = if std::env::var("DEEPSEEK_API_KEY") - .ok() - .filter(|k| !k.trim().is_empty()) - .is_some() - { - println!( - " {} DEEPSEEK_API_KEY is set", + + // Report the active keyring backend (system / file-based / unavailable). + let secrets = deepseek_secrets::Secrets::auto_detect(); + println!(" · keyring backend: {}", secrets.backend_name()); + + // Per-provider state: keyring, env, config file (no values printed). + for (slot, env_names) in [ + ("deepseek", &["DEEPSEEK_API_KEY"][..]), + ("nvidia-nim", &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"][..]), + ("openrouter", &["OPENROUTER_API_KEY"][..]), + ("novita", &["NOVITA_API_KEY"][..]), + ] { + let in_keyring = secrets + .get(slot) + .ok() + .flatten() + .is_some_and(|v| !v.trim().is_empty()); + let in_env = env_names.iter().any(|n| { + std::env::var(n) + .ok() + .filter(|v| !v.trim().is_empty()) + .is_some() + }); + let icon = if in_keyring || in_env { "✓".truecolor(aqua_r, aqua_g, aqua_b) - ); - true - } else if config.deepseek_api_key().is_ok() { + } else { + "·".dimmed() + }; println!( - " {} DeepSeek API key found in effective config", + " {} {slot}: keyring={}, env={}", + icon, + if in_keyring { "yes" } else { "no" }, + if in_env { "yes" } else { "no" } + ); + } + + let has_api_key = if config.deepseek_api_key().is_ok() { + println!( + " {} active provider key resolved", "✓".truecolor(aqua_r, aqua_g, aqua_b) ); true } else { println!( - " {} DeepSeek API key not configured", + " {} active provider key not configured", "✗".truecolor(red_r, red_g, red_b) ); - println!(" Run 'deepseek' to configure interactively, or set DEEPSEEK_API_KEY"); + println!(" Run 'deepseek auth set --provider ' to save a key to the OS keyring."); false };