feat(cli): #134 add deepseek auth keyring subcommands and surface backend in doctor
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 <name>` 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 <name>` reports `set` / `not set` plus the resolving layer (keyring / env / config-file). Never prints the value. * `auth clear --provider <name>` 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 <name>`. 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+480
-56
@@ -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<String>,
|
||||
/// 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<String> {
|
||||
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<String> = 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(&[
|
||||
|
||||
@@ -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"
|
||||
|
||||
+37
-42
@@ -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<String> {
|
||||
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."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
+39
-13
@@ -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 <name>' to save a key to the OS keyring.");
|
||||
false
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user