1mod metrics;
2mod update;
3
4use std::io::{self, Read, Write};
5use std::net::SocketAddr;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result, anyhow, bail};
10use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::{Shell, generate};
12use deepseek_agent::ModelRegistry;
13use deepseek_app_server::{
14 AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
15};
16use deepseek_config::{
17 CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,
18};
19use deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
20use deepseek_mcp::{McpServerDefinition, run_stdio_server};
21use deepseek_secrets::Secrets;
22use deepseek_state::{StateStore, ThreadListFilters};
23
24#[derive(Debug, Clone, Copy, ValueEnum)]
25enum ProviderArg {
26 Deepseek,
27 NvidiaNim,
28 Openai,
29 Openrouter,
30 Novita,
31 Fireworks,
32 Sglang,
33 Vllm,
34 Ollama,
35}
36
37impl From<ProviderArg> for ProviderKind {
38 fn from(value: ProviderArg) -> Self {
39 match value {
40 ProviderArg::Deepseek => ProviderKind::Deepseek,
41 ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
42 ProviderArg::Openai => ProviderKind::Openai,
43 ProviderArg::Openrouter => ProviderKind::Openrouter,
44 ProviderArg::Novita => ProviderKind::Novita,
45 ProviderArg::Fireworks => ProviderKind::Fireworks,
46 ProviderArg::Sglang => ProviderKind::Sglang,
47 ProviderArg::Vllm => ProviderKind::Vllm,
48 ProviderArg::Ollama => ProviderKind::Ollama,
49 }
50 }
51}
52
53#[derive(Debug, Parser)]
54#[command(
55 name = "deepseek",
56 version = env!("DEEPSEEK_BUILD_VERSION"),
57 bin_name = "deepseek",
58 override_usage = "deepseek [OPTIONS] [PROMPT]\n deepseek [OPTIONS] <COMMAND> [ARGS]"
59)]
60struct Cli {
61 #[arg(long)]
62 config: Option<PathBuf>,
63 #[arg(long)]
64 profile: Option<String>,
65 #[arg(
66 long,
67 value_enum,
68 help = "Advanced provider selector for non-TUI registry/config commands"
69 )]
70 provider: Option<ProviderArg>,
71 #[arg(long)]
72 model: Option<String>,
73 #[arg(long = "output-mode")]
74 output_mode: Option<String>,
75 #[arg(long = "log-level")]
76 log_level: Option<String>,
77 #[arg(long)]
78 telemetry: Option<bool>,
79 #[arg(long)]
80 approval_policy: Option<String>,
81 #[arg(long)]
82 sandbox_mode: Option<String>,
83 #[arg(long)]
84 api_key: Option<String>,
85 #[arg(long)]
86 base_url: Option<String>,
87 #[arg(long = "no-alt-screen", hide = true)]
88 no_alt_screen: bool,
89 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
90 mouse_capture: bool,
91 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
92 no_mouse_capture: bool,
93 #[arg(long = "skip-onboarding")]
94 skip_onboarding: bool,
95 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
96 prompt_flag: Option<String>,
97 #[arg(
98 value_name = "PROMPT",
99 trailing_var_arg = true,
100 allow_hyphen_values = true
101 )]
102 prompt: Vec<String>,
103 #[command(subcommand)]
104 command: Option<Commands>,
105}
106
107#[derive(Debug, Subcommand)]
108enum Commands {
109 Run(RunArgs),
111 Doctor(TuiPassthroughArgs),
113 Models(TuiPassthroughArgs),
115 Sessions(TuiPassthroughArgs),
117 Resume(TuiPassthroughArgs),
119 Fork(TuiPassthroughArgs),
121 Init(TuiPassthroughArgs),
123 Setup(TuiPassthroughArgs),
125 Exec(TuiPassthroughArgs),
127 Review(TuiPassthroughArgs),
129 Apply(TuiPassthroughArgs),
131 Eval(TuiPassthroughArgs),
133 Mcp(TuiPassthroughArgs),
135 Features(TuiPassthroughArgs),
137 Serve(TuiPassthroughArgs),
139 Completions(TuiPassthroughArgs),
141 Login(LoginArgs),
143 Logout,
145 Auth(AuthArgs),
147 McpServer,
149 Config(ConfigArgs),
151 Model(ModelArgs),
153 Thread(ThreadArgs),
155 Sandbox(SandboxArgs),
157 AppServer(AppServerArgs),
159 #[command(after_help = r#"Examples:
161 Bash (current shell only):
162 source <(deepseek completion bash)
163
164 Bash (persistent, Linux/bash-completion):
165 mkdir -p ~/.local/share/bash-completion/completions
166 deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek
167 # Requires bash-completion to be installed and loaded by your shell.
168
169 Zsh:
170 mkdir -p ~/.zfunc
171 deepseek completion zsh > ~/.zfunc/_deepseek
172 # Add to ~/.zshrc if needed:
173 # fpath=(~/.zfunc $fpath)
174 # autoload -Uz compinit && compinit
175
176 Fish:
177 mkdir -p ~/.config/fish/completions
178 deepseek completion fish > ~/.config/fish/completions/deepseek.fish
179
180 PowerShell (current shell only):
181 deepseek completion powershell | Out-String | Invoke-Expression
182
183The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
184 Completion {
185 #[arg(value_enum)]
186 shell: Shell,
187 },
188 Metrics(MetricsArgs),
190 Update,
192}
193
194#[derive(Debug, Args)]
195struct MetricsArgs {
196 #[arg(long)]
198 json: bool,
199 #[arg(long, value_name = "DURATION")]
201 since: Option<String>,
202}
203
204#[derive(Debug, Args)]
205struct RunArgs {
206 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
207 args: Vec<String>,
208}
209
210#[derive(Debug, Args, Clone)]
211struct TuiPassthroughArgs {
212 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
213 args: Vec<String>,
214}
215
216#[derive(Debug, Args)]
217struct LoginArgs {
218 #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]
219 provider: ProviderArg,
220 #[arg(long)]
221 api_key: Option<String>,
222 #[arg(long, default_value_t = false, hide = true)]
223 chatgpt: bool,
224 #[arg(long, default_value_t = false, hide = true)]
225 device_code: bool,
226 #[arg(long, hide = true)]
227 token: Option<String>,
228}
229
230#[derive(Debug, Args)]
231struct AuthArgs {
232 #[command(subcommand)]
233 command: AuthCommand,
234}
235
236#[derive(Debug, Subcommand)]
237enum AuthCommand {
238 Status,
240 Set {
244 #[arg(long, value_enum)]
245 provider: ProviderArg,
246 #[arg(long)]
248 api_key: Option<String>,
249 #[arg(long = "api-key-stdin", default_value_t = false)]
251 api_key_stdin: bool,
252 },
253 Get {
256 #[arg(long, value_enum)]
257 provider: ProviderArg,
258 },
259 Clear {
261 #[arg(long, value_enum)]
262 provider: ProviderArg,
263 },
264 List,
267 #[command(hide = true)]
269 Migrate {
270 #[arg(long, default_value_t = false)]
272 dry_run: bool,
273 },
274}
275
276#[derive(Debug, Args)]
277struct ConfigArgs {
278 #[command(subcommand)]
279 command: ConfigCommand,
280}
281
282#[derive(Debug, Subcommand)]
283enum ConfigCommand {
284 Get { key: String },
285 Set { key: String, value: String },
286 Unset { key: String },
287 List,
288 Path,
289}
290
291#[derive(Debug, Args)]
292struct ModelArgs {
293 #[command(subcommand)]
294 command: ModelCommand,
295}
296
297#[derive(Debug, Subcommand)]
298enum ModelCommand {
299 List {
300 #[arg(long, value_enum)]
301 provider: Option<ProviderArg>,
302 },
303 Resolve {
304 model: Option<String>,
305 #[arg(long, value_enum)]
306 provider: Option<ProviderArg>,
307 },
308}
309
310#[derive(Debug, Args)]
311struct ThreadArgs {
312 #[command(subcommand)]
313 command: ThreadCommand,
314}
315
316#[derive(Debug, Subcommand)]
317enum ThreadCommand {
318 List {
319 #[arg(long, default_value_t = false)]
320 all: bool,
321 #[arg(long)]
322 limit: Option<usize>,
323 },
324 Read {
325 thread_id: String,
326 },
327 Resume {
328 thread_id: String,
329 },
330 Fork {
331 thread_id: String,
332 },
333 Archive {
334 thread_id: String,
335 },
336 Unarchive {
337 thread_id: String,
338 },
339 SetName {
340 thread_id: String,
341 name: String,
342 },
343}
344
345#[derive(Debug, Args)]
346struct SandboxArgs {
347 #[command(subcommand)]
348 command: SandboxCommand,
349}
350
351#[derive(Debug, Subcommand)]
352enum SandboxCommand {
353 Check {
354 command: String,
355 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
356 ask: ApprovalModeArg,
357 },
358}
359
360#[derive(Debug, Clone, Copy, ValueEnum)]
361enum ApprovalModeArg {
362 UnlessTrusted,
363 OnFailure,
364 OnRequest,
365 Never,
366}
367
368impl From<ApprovalModeArg> for AskForApproval {
369 fn from(value: ApprovalModeArg) -> Self {
370 match value {
371 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
372 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
373 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
374 ApprovalModeArg::Never => AskForApproval::Never,
375 }
376 }
377}
378
379#[derive(Debug, Args)]
380struct AppServerArgs {
381 #[arg(long, default_value = "127.0.0.1")]
382 host: String,
383 #[arg(long, default_value_t = 8787)]
384 port: u16,
385 #[arg(long)]
386 config: Option<PathBuf>,
387 #[arg(long, default_value_t = false)]
388 stdio: bool,
389}
390
391const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
392
393pub fn run_cli() -> std::process::ExitCode {
394 match run() {
395 Ok(()) => std::process::ExitCode::SUCCESS,
396 Err(err) => {
397 eprintln!("error: {err}");
405 for cause in err.chain().skip(1) {
406 eprintln!(" caused by: {cause}");
407 }
408 std::process::ExitCode::FAILURE
409 }
410 }
411}
412
413fn run() -> Result<()> {
414 let mut cli = Cli::parse();
415
416 let mut store = ConfigStore::load(cli.config.clone())?;
417 let runtime_overrides = CliRuntimeOverrides {
418 provider: cli.provider.map(Into::into),
419 model: cli.model.clone(),
420 api_key: cli.api_key.clone(),
421 base_url: cli.base_url.clone(),
422 auth_mode: None,
423 output_mode: cli.output_mode.clone(),
424 log_level: cli.log_level.clone(),
425 telemetry: cli.telemetry,
426 approval_policy: cli.approval_policy.clone(),
427 sandbox_mode: cli.sandbox_mode.clone(),
428 };
429 let command = cli.command.take();
430
431 match command {
432 Some(Commands::Run(args)) => {
433 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
434 delegate_to_tui(&cli, &resolved_runtime, args.args)
435 }
436 Some(Commands::Doctor(args)) => {
437 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
438 delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
439 }
440 Some(Commands::Models(args)) => {
441 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
442 delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
443 }
444 Some(Commands::Sessions(args)) => {
445 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
446 delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
447 }
448 Some(Commands::Resume(args)) => {
449 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
450 run_resume_command(&cli, &resolved_runtime, args)
451 }
452 Some(Commands::Fork(args)) => {
453 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
454 delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
455 }
456 Some(Commands::Init(args)) => {
457 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
458 delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
459 }
460 Some(Commands::Setup(args)) => {
461 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
462 delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
463 }
464 Some(Commands::Exec(args)) => {
465 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
466 delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
467 }
468 Some(Commands::Review(args)) => {
469 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
470 delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
471 }
472 Some(Commands::Apply(args)) => {
473 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
474 delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
475 }
476 Some(Commands::Eval(args)) => {
477 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
478 delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
479 }
480 Some(Commands::Mcp(args)) => {
481 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
482 delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
483 }
484 Some(Commands::Features(args)) => {
485 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
486 delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
487 }
488 Some(Commands::Serve(args)) => {
489 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
490 delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
491 }
492 Some(Commands::Completions(args)) => {
493 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
494 delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
495 }
496 Some(Commands::Login(args)) => run_login_command(&mut store, args),
497 Some(Commands::Logout) => run_logout_command(&mut store),
498 Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
499 Some(Commands::McpServer) => run_mcp_server_command(&mut store),
500 Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
501 Some(Commands::Model(args)) => run_model_command(args.command),
502 Some(Commands::Thread(args)) => run_thread_command(args.command),
503 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
504 Some(Commands::AppServer(args)) => run_app_server_command(args),
505 Some(Commands::Completion { shell }) => {
506 let mut cmd = Cli::command();
507 generate(shell, &mut cmd, "deepseek", &mut io::stdout());
508 Ok(())
509 }
510 Some(Commands::Metrics(args)) => run_metrics_command(args),
511 Some(Commands::Update) => update::run_update(),
512 None => {
513 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
514 let mut forwarded = Vec::new();
515 let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(
516 String::new(),
517 |mut acc, part| {
518 if !acc.is_empty() {
519 acc.push(' ');
520 }
521 acc.push_str(part);
522 acc
523 },
524 );
525 if !prompt.is_empty() {
526 forwarded.push("--prompt".to_string());
527 forwarded.push(prompt);
528 }
529 delegate_to_tui(&cli, &resolved_runtime, forwarded)
530 }
531 }
532}
533
534fn resolve_runtime_for_dispatch(
535 store: &mut ConfigStore,
536 runtime_overrides: &CliRuntimeOverrides,
537) -> ResolvedRuntimeOptions {
538 let runtime_secrets = Secrets::auto_detect();
539 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
540}
541
542fn resolve_runtime_for_dispatch_with_secrets(
543 store: &mut ConfigStore,
544 runtime_overrides: &CliRuntimeOverrides,
545 secrets: &Secrets,
546) -> ResolvedRuntimeOptions {
547 let mut resolved = store
548 .config
549 .resolve_runtime_options_with_secrets(runtime_overrides, secrets);
550
551 if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)
552 && !provider_config_set(store, resolved.provider)
553 && let Some(api_key) = resolved.api_key.clone()
554 {
555 write_provider_api_key_to_config(store, resolved.provider, &api_key);
556 match store.save() {
557 Ok(()) => {
558 eprintln!(
559 "info: recovered API key from OS keyring and saved it to {}",
560 store.path().display()
561 );
562 resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);
563 }
564 Err(err) => {
565 eprintln!(
566 "warning: recovered API key from OS keyring but failed to save {}: {err}",
567 store.path().display()
568 );
569 }
570 }
571 }
572
573 resolved
574}
575
576fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
577 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
578 forwarded.push(command.to_string());
579 forwarded.extend(args.args);
580 forwarded
581}
582
583fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
584 run_login_command_with_secrets(store, args, &Secrets::auto_detect())
585}
586
587fn run_login_command_with_secrets(
588 store: &mut ConfigStore,
589 args: LoginArgs,
590 secrets: &Secrets,
591) -> Result<()> {
592 let provider: ProviderKind = args.provider.into();
593 store.config.provider = provider;
594
595 if args.chatgpt {
596 let token = match args.token {
597 Some(token) => token,
598 None => read_api_key_from_stdin()?,
599 };
600 store.config.auth_mode = Some("chatgpt".to_string());
601 store.config.chatgpt_access_token = Some(token);
602 store.config.device_code_session = None;
603 store.save()?;
604 println!("logged in using chatgpt token mode ({})", provider.as_str());
605 return Ok(());
606 }
607
608 if args.device_code {
609 let token = match args.token {
610 Some(token) => token,
611 None => read_api_key_from_stdin()?,
612 };
613 store.config.auth_mode = Some("device_code".to_string());
614 store.config.device_code_session = Some(token);
615 store.config.chatgpt_access_token = None;
616 store.save()?;
617 println!(
618 "logged in using device code session mode ({})",
619 provider.as_str()
620 );
621 return Ok(());
622 }
623
624 let api_key = match args.api_key {
625 Some(v) => v,
626 None => read_api_key_from_stdin()?,
627 };
628 write_provider_api_key_to_config(store, provider, &api_key);
629 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
630 store.save()?;
631 let destination = if keyring_saved {
632 format!("{} and {}", store.path().display(), secrets.backend_name())
633 } else {
634 store.path().display().to_string()
635 };
636 if provider == ProviderKind::Deepseek {
637 println!("logged in using API key mode (deepseek); saved key to {destination}");
638 } else {
639 println!(
640 "logged in using API key mode ({}); saved key to {destination}",
641 provider.as_str(),
642 );
643 }
644 Ok(())
645}
646
647fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
648 run_logout_command_with_secrets(store, &Secrets::auto_detect())
649}
650
651fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
652 let active_provider = store.config.provider;
653 store.config.api_key = None;
654 for provider in PROVIDER_LIST {
655 clear_provider_api_key_from_config(store, provider);
656 }
657 clear_provider_api_key_from_keyring(secrets, active_provider);
658 store.config.auth_mode = None;
659 store.config.chatgpt_access_token = None;
660 store.config.device_code_session = None;
661 store.save()?;
662 println!("logged out");
663 Ok(())
664}
665
666fn provider_slot(provider: ProviderKind) -> &'static str {
668 match provider {
669 ProviderKind::Deepseek => "deepseek",
670 ProviderKind::NvidiaNim => "nvidia-nim",
671 ProviderKind::Openai => "openai",
672 ProviderKind::Openrouter => "openrouter",
673 ProviderKind::Novita => "novita",
674 ProviderKind::Fireworks => "fireworks",
675 ProviderKind::Sglang => "sglang",
676 ProviderKind::Vllm => "vllm",
677 ProviderKind::Ollama => "ollama",
678 }
679}
680
681const PROVIDER_LIST: [ProviderKind; 9] = [
683 ProviderKind::Deepseek,
684 ProviderKind::NvidiaNim,
685 ProviderKind::Openrouter,
686 ProviderKind::Novita,
687 ProviderKind::Fireworks,
688 ProviderKind::Sglang,
689 ProviderKind::Vllm,
690 ProviderKind::Ollama,
691 ProviderKind::Openai,
692];
693
694#[cfg(test)]
695fn no_keyring_secrets() -> Secrets {
696 Secrets::new(std::sync::Arc::new(
697 deepseek_secrets::InMemoryKeyringStore::new(),
698 ))
699}
700
701fn write_provider_api_key_to_config(
702 store: &mut ConfigStore,
703 provider: ProviderKind,
704 api_key: &str,
705) {
706 store.config.provider = provider;
707 store.config.auth_mode = Some("api_key".to_string());
708 store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
709 if provider == ProviderKind::Deepseek {
710 store.config.api_key = Some(api_key.to_string());
711 if store.config.default_text_model.is_none() {
712 store.config.default_text_model = Some(
713 store
714 .config
715 .providers
716 .deepseek
717 .model
718 .clone()
719 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
720 );
721 }
722 }
723}
724
725fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
726 store.config.providers.for_provider_mut(provider).api_key = None;
727 if provider == ProviderKind::Deepseek {
728 store.config.api_key = None;
729 }
730}
731
732fn provider_env_set(provider: ProviderKind) -> bool {
733 provider_env_value(provider).is_some()
734}
735
736fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
737 match provider {
738 ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"],
739 ProviderKind::Openrouter => &["OPENROUTER_API_KEY"],
740 ProviderKind::Novita => &["NOVITA_API_KEY"],
741 ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
742 ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
743 ProviderKind::Sglang => &["SGLANG_API_KEY"],
744 ProviderKind::Vllm => &["VLLM_API_KEY"],
745 ProviderKind::Ollama => &["OLLAMA_API_KEY"],
746 ProviderKind::Openai => &["OPENAI_API_KEY"],
747 }
748}
749
750fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
751 provider_env_vars(provider).iter().find_map(|var| {
752 std::env::var(var)
753 .ok()
754 .filter(|value| !value.trim().is_empty())
755 .map(|value| (*var, value))
756 })
757}
758
759fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
760 let slot = store
761 .config
762 .providers
763 .for_provider(provider)
764 .api_key
765 .as_deref();
766 let root = (provider == ProviderKind::Deepseek)
767 .then_some(store.config.api_key.as_deref())
768 .flatten();
769 slot.or(root).filter(|v| !v.trim().is_empty())
770}
771
772fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
773 provider_config_api_key(store, provider).is_some()
774}
775
776fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
777 secrets
778 .get(provider_slot(provider))
779 .ok()
780 .flatten()
781 .filter(|v| !v.trim().is_empty())
782}
783
784fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
785 provider_keyring_api_key(secrets, provider).is_some()
786}
787
788fn write_provider_api_key_to_keyring(
789 secrets: &Secrets,
790 provider: ProviderKind,
791 api_key: &str,
792) -> bool {
793 secrets.set(provider_slot(provider), api_key).is_ok()
794}
795
796fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
797 let _ = secrets.delete(provider_slot(provider));
798}
799
800fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
801 let provider = store.config.provider;
802 let config_key = provider_config_api_key(store, provider);
803 let keyring_key = provider_keyring_api_key(secrets, provider);
804 let env_key = provider_env_value(provider);
805
806 let active_source = if config_key.is_some() {
807 "config"
808 } else if keyring_key.is_some() {
809 "keyring"
810 } else if env_key.is_some() {
811 "env"
812 } else {
813 "missing"
814 };
815 let active_last4 = config_key
816 .map(last4_label)
817 .or_else(|| keyring_key.as_deref().map(last4_label))
818 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));
819 let active_label = active_last4
820 .map(|last4| format!("{active_source} (last4: {last4})"))
821 .unwrap_or_else(|| active_source.to_string());
822
823 let env_var_label = env_key
824 .as_ref()
825 .map(|(name, _)| (*name).to_string())
826 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
827 let env_status = env_key
828 .as_ref()
829 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
830 .unwrap_or_else(|| "unset".to_string());
831
832 vec![
833 format!("provider: {}", provider.as_str()),
834 format!("active source: {active_label}"),
835 "lookup order: config -> keyring -> env".to_string(),
836 format!(
837 "config file: {} ({})",
838 store.path().display(),
839 source_status(config_key, "missing")
840 ),
841 format!(
842 "keyring: {} ({})",
843 secrets.backend_name(),
844 source_status(keyring_key.as_deref(), "missing")
845 ),
846 format!("env var: {env_var_label} ({env_status})"),
847 ]
848}
849
850fn source_status(value: Option<&str>, missing_label: &str) -> String {
851 value
852 .map(|v| format!("set, last4: {}", last4_label(v)))
853 .unwrap_or_else(|| missing_label.to_string())
854}
855
856fn last4_label(value: &str) -> String {
857 let trimmed = value.trim();
858 let chars: Vec<char> = trimmed.chars().collect();
859 if chars.len() <= 4 {
860 return "<redacted>".to_string();
861 }
862 let last4: String = chars[chars.len() - 4..].iter().collect();
863 format!("...{last4}")
864}
865
866fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
867 run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
868}
869
870fn run_auth_command_with_secrets(
871 store: &mut ConfigStore,
872 command: AuthCommand,
873 secrets: &Secrets,
874) -> Result<()> {
875 match command {
876 AuthCommand::Status => {
877 for line in auth_status_lines(store, secrets) {
878 println!("{line}");
879 }
880 Ok(())
881 }
882 AuthCommand::Set {
883 provider,
884 api_key,
885 api_key_stdin,
886 } => {
887 let provider: ProviderKind = provider.into();
888 let slot = provider_slot(provider);
889 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
890 store.config.provider = provider;
891 let provider_cfg = store.config.providers.for_provider_mut(provider);
892 if provider_cfg.base_url.is_none() {
893 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
894 }
895 store.save()?;
896 println!(
897 "configured {slot} provider in {} (API key optional)",
898 store.path().display()
899 );
900 return Ok(());
901 }
902 let api_key = match (api_key, api_key_stdin) {
903 (Some(v), _) => v,
904 (None, true) => read_api_key_from_stdin()?,
905 (None, false) => prompt_api_key(slot)?,
906 };
907 write_provider_api_key_to_config(store, provider, &api_key);
908 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
909 store.save()?;
910 if keyring_saved {
912 println!(
913 "saved API key for {slot} to {} and {}",
914 store.path().display(),
915 secrets.backend_name()
916 );
917 } else {
918 println!("saved API key for {slot} to {}", store.path().display());
919 }
920 Ok(())
921 }
922 AuthCommand::Get { provider } => {
923 let provider: ProviderKind = provider.into();
924 let slot = provider_slot(provider);
925 let in_file = provider_config_set(store, provider);
926 let in_keyring = !in_file && provider_keyring_set(secrets, provider);
927 let in_env = provider_env_set(provider);
928 let source = if in_file {
930 Some("config-file")
931 } else if in_keyring {
932 Some("keyring")
933 } else if in_env {
934 Some("env")
935 } else {
936 None
937 };
938 match source {
939 Some(source) => println!("{slot}: set (source: {source})"),
940 None => println!("{slot}: not set"),
941 }
942 Ok(())
943 }
944 AuthCommand::Clear { provider } => {
945 let provider: ProviderKind = provider.into();
946 let slot = provider_slot(provider);
947 clear_provider_api_key_from_config(store, provider);
948 clear_provider_api_key_from_keyring(secrets, provider);
949 store.save()?;
950 println!("cleared API key for {slot} from config and keyring");
951 Ok(())
952 }
953 AuthCommand::List => {
954 println!("provider config keyring env active");
955 let active_provider = store.config.provider;
956 for provider in PROVIDER_LIST {
957 let slot = provider_slot(provider);
958 let file = provider_config_set(store, provider);
959 let keyring = (provider == active_provider && !file)
960 .then(|| provider_keyring_set(secrets, provider));
961 let env = provider_env_set(provider);
962 let active = if file {
963 "config"
964 } else if keyring == Some(true) {
965 "keyring"
966 } else if env {
967 "env"
968 } else {
969 "missing"
970 };
971 println!(
972 "{slot:<12} {} {} {} {active}",
973 yes_no(file),
974 keyring_status_short(keyring),
975 yes_no(env)
976 );
977 }
978 Ok(())
979 }
980 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
981 }
982}
983
984fn yes_no(b: bool) -> &'static str {
985 if b { "yes" } else { "no " }
986}
987
988fn keyring_status_short(state: Option<bool>) -> &'static str {
989 match state {
990 Some(true) => "yes",
991 Some(false) => "no ",
992 None => "n/a",
993 }
994}
995
996fn prompt_api_key(slot: &str) -> Result<String> {
997 use std::io::{IsTerminal, Write};
998 eprint!("Enter API key for {slot}: ");
999 io::stderr().flush().ok();
1000 if !io::stdin().is_terminal() {
1001 return read_api_key_from_stdin();
1003 }
1004 let mut buf = String::new();
1005 io::stdin()
1006 .read_line(&mut buf)
1007 .context("failed to read API key from stdin")?;
1008 let key = buf.trim().to_string();
1009 if key.is_empty() {
1010 bail!("empty API key provided");
1011 }
1012 Ok(key)
1013}
1014
1015fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
1018 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
1019 let mut warnings: Vec<String> = Vec::new();
1020
1021 for provider in PROVIDER_LIST {
1022 let slot = provider_slot(provider);
1023 let from_provider_block = store
1024 .config
1025 .providers
1026 .for_provider(provider)
1027 .api_key
1028 .clone()
1029 .filter(|v| !v.trim().is_empty());
1030 let from_root = (provider == ProviderKind::Deepseek)
1031 .then(|| store.config.api_key.clone())
1032 .flatten()
1033 .filter(|v| !v.trim().is_empty());
1034 let value = from_provider_block.or(from_root);
1035 let Some(value) = value else { continue };
1036
1037 if let Ok(Some(existing)) = secrets.get(slot)
1038 && existing == value
1039 {
1040 } else if dry_run {
1042 migrated.push((provider, slot));
1043 continue;
1044 } else if let Err(err) = secrets.set(slot, &value) {
1045 warnings.push(format!("skipped {slot}: failed to write to keyring: {err}"));
1046 continue;
1047 }
1048 if !dry_run {
1049 store.config.providers.for_provider_mut(provider).api_key = None;
1050 if provider == ProviderKind::Deepseek {
1051 store.config.api_key = None;
1052 }
1053 }
1054 migrated.push((provider, slot));
1055 }
1056
1057 if !dry_run && !migrated.is_empty() {
1058 store
1059 .save()
1060 .context("failed to write updated config.toml")?;
1061 }
1062
1063 println!("keyring backend: {}", secrets.backend_name());
1064 if migrated.is_empty() {
1065 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
1066 } else {
1067 println!(
1068 "{} {} provider key(s):",
1069 if dry_run { "would migrate" } else { "migrated" },
1070 migrated.len()
1071 );
1072 for (_, slot) in &migrated {
1073 println!(" - {slot}");
1074 }
1075 if !dry_run {
1076 println!(
1077 "config.toml at {} no longer contains api_key entries for migrated providers.",
1078 store.path().display()
1079 );
1080 }
1081 }
1082 for w in warnings {
1083 eprintln!("warning: {w}");
1084 }
1085 Ok(())
1086}
1087
1088fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1089 match command {
1090 ConfigCommand::Get { key } => {
1091 if let Some(value) = store.config.get_value(&key) {
1092 println!("{value}");
1093 return Ok(());
1094 }
1095 bail!("key not found: {key}");
1096 }
1097 ConfigCommand::Set { key, value } => {
1098 store.config.set_value(&key, &value)?;
1099 store.save()?;
1100 println!("set {key}");
1101 Ok(())
1102 }
1103 ConfigCommand::Unset { key } => {
1104 store.config.unset_value(&key)?;
1105 store.save()?;
1106 println!("unset {key}");
1107 Ok(())
1108 }
1109 ConfigCommand::List => {
1110 for (key, value) in store.config.list_values() {
1111 println!("{key} = {value}");
1112 }
1113 Ok(())
1114 }
1115 ConfigCommand::Path => {
1116 println!("{}", store.path().display());
1117 Ok(())
1118 }
1119 }
1120}
1121
1122fn run_model_command(command: ModelCommand) -> Result<()> {
1123 let registry = ModelRegistry::default();
1124 match command {
1125 ModelCommand::List { provider } => {
1126 let filter = provider.map(ProviderKind::from);
1127 for model in registry.list().into_iter().filter(|m| match filter {
1128 Some(p) => m.provider == p,
1129 None => true,
1130 }) {
1131 println!("{} ({})", model.id, model.provider.as_str());
1132 }
1133 Ok(())
1134 }
1135 ModelCommand::Resolve { model, provider } => {
1136 let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
1137 println!("requested: {}", resolved.requested.unwrap_or_default());
1138 println!("resolved: {}", resolved.resolved.id);
1139 println!("provider: {}", resolved.resolved.provider.as_str());
1140 println!("used_fallback: {}", resolved.used_fallback);
1141 Ok(())
1142 }
1143 }
1144}
1145
1146fn run_thread_command(command: ThreadCommand) -> Result<()> {
1147 let state = StateStore::open(None)?;
1148 match command {
1149 ThreadCommand::List { all, limit } => {
1150 let threads = state.list_threads(ThreadListFilters {
1151 include_archived: all,
1152 limit,
1153 })?;
1154 for thread in threads {
1155 println!(
1156 "{} | {} | {} | {}",
1157 thread.id,
1158 thread
1159 .name
1160 .clone()
1161 .unwrap_or_else(|| "(unnamed)".to_string()),
1162 thread.model_provider,
1163 thread.cwd.display()
1164 );
1165 }
1166 Ok(())
1167 }
1168 ThreadCommand::Read { thread_id } => {
1169 let thread = state.get_thread(&thread_id)?;
1170 println!("{}", serde_json::to_string_pretty(&thread)?);
1171 Ok(())
1172 }
1173 ThreadCommand::Resume { thread_id } => {
1174 let args = vec!["resume".to_string(), thread_id];
1175 delegate_simple_tui(args)
1176 }
1177 ThreadCommand::Fork { thread_id } => {
1178 let args = vec!["fork".to_string(), thread_id];
1179 delegate_simple_tui(args)
1180 }
1181 ThreadCommand::Archive { thread_id } => {
1182 state.mark_archived(&thread_id)?;
1183 println!("archived {thread_id}");
1184 Ok(())
1185 }
1186 ThreadCommand::Unarchive { thread_id } => {
1187 state.mark_unarchived(&thread_id)?;
1188 println!("unarchived {thread_id}");
1189 Ok(())
1190 }
1191 ThreadCommand::SetName { thread_id, name } => {
1192 let mut thread = state
1193 .get_thread(&thread_id)?
1194 .with_context(|| format!("thread not found: {thread_id}"))?;
1195 thread.name = Some(name);
1196 thread.updated_at = chrono::Utc::now().timestamp();
1197 state.upsert_thread(&thread)?;
1198 println!("renamed {thread_id}");
1199 Ok(())
1200 }
1201 }
1202}
1203
1204fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1205 match command {
1206 SandboxCommand::Check { command, ask } => {
1207 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1208 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1209 let decision = engine.check(ExecPolicyContext {
1210 command: &command,
1211 cwd: &cwd.display().to_string(),
1212 ask_for_approval: ask.into(),
1213 sandbox_mode: Some("workspace-write"),
1214 })?;
1215 println!("{}", serde_json::to_string_pretty(&decision)?);
1216 Ok(())
1217 }
1218 }
1219}
1220
1221fn run_app_server_command(args: AppServerArgs) -> Result<()> {
1222 let runtime = tokio::runtime::Builder::new_multi_thread()
1223 .enable_all()
1224 .build()
1225 .context("failed to create tokio runtime")?;
1226 if args.stdio {
1227 return runtime.block_on(run_app_server_stdio(args.config));
1228 }
1229 let listen: SocketAddr = format!("{}:{}", args.host, args.port)
1230 .parse()
1231 .with_context(|| {
1232 format!(
1233 "invalid app-server listen address {}:{}",
1234 args.host, args.port
1235 )
1236 })?;
1237 runtime.block_on(run_app_server(AppServerOptions {
1238 listen,
1239 config_path: args.config,
1240 }))
1241}
1242
1243fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1244 let persisted = load_mcp_server_definitions(store);
1245 let updated = run_stdio_server(persisted)?;
1246 persist_mcp_server_definitions(store, &updated)
1247}
1248
1249fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1250 let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1251 return Vec::new();
1252 };
1253
1254 match parse_mcp_server_definitions(&raw) {
1255 Ok(definitions) => definitions,
1256 Err(err) => {
1257 eprintln!(
1258 "warning: failed to parse persisted MCP server definitions ({}): {}",
1259 MCP_SERVER_DEFINITIONS_KEY, err
1260 );
1261 Vec::new()
1262 }
1263 }
1264}
1265
1266fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1267 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1268 return Ok(parsed);
1269 }
1270
1271 let unwrapped: String = serde_json::from_str(raw)
1272 .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1273 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1274 format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1275 })
1276}
1277
1278fn persist_mcp_server_definitions(
1279 store: &mut ConfigStore,
1280 definitions: &[McpServerDefinition],
1281) -> Result<()> {
1282 let encoded =
1283 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1284 store
1285 .config
1286 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1287 store.save()
1288}
1289
1290fn delegate_to_tui(
1291 cli: &Cli,
1292 resolved_runtime: &ResolvedRuntimeOptions,
1293 passthrough: Vec<String>,
1294) -> Result<()> {
1295 let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1296 let tui = PathBuf::from(cmd.get_program());
1297 let status = cmd
1298 .status()
1299 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1300 exit_with_tui_status(status)
1301}
1302
1303fn run_resume_command(
1304 cli: &Cli,
1305 resolved_runtime: &ResolvedRuntimeOptions,
1306 args: TuiPassthroughArgs,
1307) -> Result<()> {
1308 let passthrough = tui_args("resume", args);
1309 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
1310 return run_dispatcher_resume_picker(cli, resolved_runtime);
1311 }
1312 delegate_to_tui(cli, resolved_runtime, passthrough)
1313}
1314
1315fn run_dispatcher_resume_picker(
1316 cli: &Cli,
1317 resolved_runtime: &ResolvedRuntimeOptions,
1318) -> Result<()> {
1319 let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
1320 let tui = PathBuf::from(sessions_cmd.get_program());
1321 let status = sessions_cmd
1322 .status()
1323 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1324 if !status.success() {
1325 return exit_with_tui_status(status);
1326 }
1327
1328 println!();
1329 println!("Windows note: enter a session id or prefix from the list above.");
1330 println!("You can also run `deepseek resume --last` to skip this prompt.");
1331 print!("Session id/prefix (Enter to cancel): ");
1332 io::stdout().flush()?;
1333
1334 let mut input = String::new();
1335 io::stdin()
1336 .read_line(&mut input)
1337 .context("failed to read session selection")?;
1338 let session_id = input.trim();
1339 if session_id.is_empty() {
1340 bail!("No session selected.");
1341 }
1342
1343 delegate_to_tui(
1344 cli,
1345 resolved_runtime,
1346 vec!["resume".to_string(), session_id.to_string()],
1347 )
1348}
1349
1350fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
1351 is_windows && passthrough == ["resume"]
1352}
1353
1354fn build_tui_command(
1355 cli: &Cli,
1356 resolved_runtime: &ResolvedRuntimeOptions,
1357 passthrough: Vec<String>,
1358) -> Result<Command> {
1359 let tui = locate_sibling_tui_binary()?;
1360
1361 let mut cmd = Command::new(&tui);
1362 if let Some(config) = cli.config.as_ref() {
1363 cmd.arg("--config").arg(config);
1364 }
1365 if let Some(profile) = cli.profile.as_ref() {
1366 cmd.arg("--profile").arg(profile);
1367 }
1368 let _ = cli.no_alt_screen;
1371 if cli.mouse_capture {
1372 cmd.arg("--mouse-capture");
1373 }
1374 if cli.no_mouse_capture {
1375 cmd.arg("--no-mouse-capture");
1376 }
1377 if cli.skip_onboarding {
1378 cmd.arg("--skip-onboarding");
1379 }
1380 cmd.args(passthrough);
1381
1382 if !matches!(
1383 resolved_runtime.provider,
1384 ProviderKind::Deepseek
1385 | ProviderKind::NvidiaNim
1386 | ProviderKind::Openai
1387 | ProviderKind::Openrouter
1388 | ProviderKind::Novita
1389 | ProviderKind::Fireworks
1390 | ProviderKind::Sglang
1391 | ProviderKind::Vllm
1392 | ProviderKind::Ollama
1393 ) {
1394 bail!(
1395 "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.",
1396 resolved_runtime.provider.as_str()
1397 );
1398 }
1399
1400 cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
1401 cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
1402 cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
1403 if !resolved_runtime.http_headers.is_empty() {
1404 let encoded = resolved_runtime
1405 .http_headers
1406 .iter()
1407 .map(|(name, value)| format!("{}={}", name.trim(), value.trim()))
1408 .collect::<Vec<_>>()
1409 .join(",");
1410 cmd.env("DEEPSEEK_HTTP_HEADERS", encoded);
1411 }
1412 if let Some(api_key) = resolved_runtime.api_key.as_ref() {
1413 cmd.env("DEEPSEEK_API_KEY", api_key);
1414 if resolved_runtime.provider == ProviderKind::Openai {
1415 cmd.env("OPENAI_API_KEY", api_key);
1416 }
1417 let source = resolved_runtime
1418 .api_key_source
1419 .unwrap_or(RuntimeApiKeySource::Env)
1420 .as_env_value();
1421 cmd.env("DEEPSEEK_API_KEY_SOURCE", source);
1422 }
1423
1424 if let Some(model) = cli.model.as_ref() {
1425 cmd.env("DEEPSEEK_MODEL", model);
1426 }
1427 if let Some(output_mode) = cli.output_mode.as_ref() {
1428 cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
1429 }
1430 if let Some(log_level) = cli.log_level.as_ref() {
1431 cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
1432 }
1433 if let Some(telemetry) = cli.telemetry {
1434 cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
1435 }
1436 if let Some(policy) = cli.approval_policy.as_ref() {
1437 cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
1438 }
1439 if let Some(mode) = cli.sandbox_mode.as_ref() {
1440 cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
1441 }
1442 if let Some(api_key) = cli.api_key.as_ref() {
1443 cmd.env("DEEPSEEK_API_KEY", api_key);
1444 if resolved_runtime.provider == ProviderKind::Openai {
1445 cmd.env("OPENAI_API_KEY", api_key);
1446 }
1447 cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
1448 }
1449 if let Some(base_url) = cli.base_url.as_ref() {
1450 cmd.env("DEEPSEEK_BASE_URL", base_url);
1451 }
1452
1453 Ok(cmd)
1454}
1455
1456fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
1457 match status.code() {
1458 Some(code) => std::process::exit(code),
1459 None => bail!("deepseek-tui terminated by signal"),
1460 }
1461}
1462
1463fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
1464 let tui = locate_sibling_tui_binary()?;
1465 let status = Command::new(&tui)
1466 .args(args)
1467 .status()
1468 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1469 match status.code() {
1470 Some(code) => std::process::exit(code),
1471 None => bail!("deepseek-tui terminated by signal"),
1472 }
1473}
1474
1475fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
1476 format!(
1477 "failed to spawn companion TUI binary at {}: {err}\n\
1478\n\
1479The `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \
1480to execute it. Common fixes:\n\
1481 - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\n\
1482 - On Windows, run `where deepseek` and `where deepseek-tui`; both should \
1483come from the same install directory.\n\
1484 - If you downloaded release assets manually, keep both `deepseek` and \
1485`deepseek-tui` binaries together and make sure the TUI binary is executable.\n\
1486 - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \
1487binary.",
1488 tui.display()
1489 )
1490}
1491
1492fn locate_sibling_tui_binary() -> Result<PathBuf> {
1502 if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
1503 let candidate = PathBuf::from(override_path);
1504 if candidate.is_file() {
1505 return Ok(candidate);
1506 }
1507 bail!(
1508 "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
1509 candidate.display()
1510 );
1511 }
1512
1513 let current = std::env::current_exe().context("failed to locate current executable path")?;
1514 if let Some(found) = sibling_tui_candidate(¤t) {
1515 return Ok(found);
1516 }
1517
1518 let expected = current.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1521 bail!(
1522 "Companion `deepseek-tui` binary not found at {}.\n\
1523\n\
1524The `deepseek` dispatcher delegates interactive sessions to a sibling \
1525`deepseek-tui` binary. To fix this, install one of:\n\
1526 • npm: npm install -g deepseek-tui (downloads both binaries)\n\
1527 • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\n\
1528 • GitHub Releases: download BOTH `deepseek-<platform>` AND \
1529`deepseek-tui-<platform>` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \
1530and place them in the same directory.\n\
1531\n\
1532Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.",
1533 expected.display()
1534 );
1535}
1536
1537fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1541 let primary =
1544 dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1545 if primary.is_file() {
1546 return Some(primary);
1547 }
1548 if cfg!(windows) {
1551 let suffixless = dispatcher.with_file_name("deepseek-tui");
1552 if suffixless.is_file() {
1553 return Some(suffixless);
1554 }
1555 }
1556 None
1557}
1558
1559fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1560 let since = match args.since.as_deref() {
1561 Some(s) => {
1562 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1563 }
1564 None => None,
1565 };
1566 metrics::run(metrics::MetricsArgs {
1567 json: args.json,
1568 since,
1569 })
1570}
1571
1572fn read_api_key_from_stdin() -> Result<String> {
1573 let mut input = String::new();
1574 io::stdin()
1575 .read_to_string(&mut input)
1576 .context("failed to read api key from stdin")?;
1577 let key = input.trim().to_string();
1578 if key.is_empty() {
1579 bail!("empty API key provided");
1580 }
1581 Ok(key)
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586 use super::*;
1587 use clap::error::ErrorKind;
1588 use std::ffi::OsString;
1589 use std::sync::{Mutex, OnceLock};
1590
1591 fn parse_ok(argv: &[&str]) -> Cli {
1592 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1593 }
1594
1595 fn help_for(argv: &[&str]) -> String {
1596 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1597 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1598 err.to_string()
1599 }
1600
1601 fn command_env(cmd: &Command, name: &str) -> Option<String> {
1602 let name = std::ffi::OsStr::new(name);
1603 cmd.get_envs().find_map(|(key, value)| {
1604 if key == name {
1605 value.map(|v| v.to_string_lossy().into_owned())
1606 } else {
1607 None
1608 }
1609 })
1610 }
1611
1612 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1613 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1614 LOCK.get_or_init(|| Mutex::new(()))
1615 .lock()
1616 .unwrap_or_else(|p| p.into_inner())
1617 }
1618
1619 struct ScopedEnvVar {
1620 name: &'static str,
1621 previous: Option<OsString>,
1622 }
1623
1624 impl ScopedEnvVar {
1625 fn set(name: &'static str, value: &str) -> Self {
1626 let previous = std::env::var_os(name);
1627 unsafe { std::env::set_var(name, value) };
1630 Self { name, previous }
1631 }
1632 }
1633
1634 impl Drop for ScopedEnvVar {
1635 fn drop(&mut self) {
1636 unsafe {
1638 if let Some(previous) = self.previous.take() {
1639 std::env::set_var(self.name, previous);
1640 } else {
1641 std::env::remove_var(self.name);
1642 }
1643 }
1644 }
1645 }
1646
1647 #[test]
1648 fn clap_command_definition_is_consistent() {
1649 Cli::command().debug_assert();
1650 }
1651
1652 #[test]
1659 fn anyhow_chain_surfaces_toml_parse_cause() {
1660 use anyhow::Context;
1661 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
1662 let err = Err::<(), _>(inner)
1663 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
1664 .unwrap_err();
1665
1666 assert_eq!(
1668 err.to_string(),
1669 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
1670 );
1671
1672 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
1674 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
1675 }
1676
1677 #[test]
1678 fn parses_config_command_matrix() {
1679 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
1680 assert!(matches!(
1681 cli.command,
1682 Some(Commands::Config(ConfigArgs {
1683 command: ConfigCommand::Get { ref key }
1684 })) if key == "provider"
1685 ));
1686
1687 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
1688 assert!(matches!(
1689 cli.command,
1690 Some(Commands::Config(ConfigArgs {
1691 command: ConfigCommand::Set { ref key, ref value }
1692 })) if key == "model" && value == "deepseek-v4-flash"
1693 ));
1694
1695 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
1696 assert!(matches!(
1697 cli.command,
1698 Some(Commands::Config(ConfigArgs {
1699 command: ConfigCommand::Unset { ref key }
1700 })) if key == "model"
1701 ));
1702
1703 assert!(matches!(
1704 parse_ok(&["deepseek", "config", "list"]).command,
1705 Some(Commands::Config(ConfigArgs {
1706 command: ConfigCommand::List
1707 }))
1708 ));
1709 assert!(matches!(
1710 parse_ok(&["deepseek", "config", "path"]).command,
1711 Some(Commands::Config(ConfigArgs {
1712 command: ConfigCommand::Path
1713 }))
1714 ));
1715 }
1716
1717 #[test]
1718 fn parses_model_command_matrix() {
1719 let cli = parse_ok(&["deepseek", "model", "list"]);
1720 assert!(matches!(
1721 cli.command,
1722 Some(Commands::Model(ModelArgs {
1723 command: ModelCommand::List { provider: None }
1724 }))
1725 ));
1726
1727 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
1728 assert!(matches!(
1729 cli.command,
1730 Some(Commands::Model(ModelArgs {
1731 command: ModelCommand::List {
1732 provider: Some(ProviderArg::Openai)
1733 }
1734 }))
1735 ));
1736
1737 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
1738 assert!(matches!(
1739 cli.command,
1740 Some(Commands::Model(ModelArgs {
1741 command: ModelCommand::Resolve {
1742 model: Some(ref model),
1743 provider: None
1744 }
1745 })) if model == "deepseek-v4-flash"
1746 ));
1747
1748 let cli = parse_ok(&[
1749 "deepseek",
1750 "model",
1751 "resolve",
1752 "--provider",
1753 "deepseek",
1754 "deepseek-v4-pro",
1755 ]);
1756 assert!(matches!(
1757 cli.command,
1758 Some(Commands::Model(ModelArgs {
1759 command: ModelCommand::Resolve {
1760 model: Some(ref model),
1761 provider: Some(ProviderArg::Deepseek)
1762 }
1763 })) if model == "deepseek-v4-pro"
1764 ));
1765 }
1766
1767 #[test]
1768 fn parses_thread_command_matrix() {
1769 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
1770 assert!(matches!(
1771 cli.command,
1772 Some(Commands::Thread(ThreadArgs {
1773 command: ThreadCommand::List {
1774 all: true,
1775 limit: Some(50)
1776 }
1777 }))
1778 ));
1779
1780 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
1781 assert!(matches!(
1782 cli.command,
1783 Some(Commands::Thread(ThreadArgs {
1784 command: ThreadCommand::Read { ref thread_id }
1785 })) if thread_id == "thread-1"
1786 ));
1787
1788 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
1789 assert!(matches!(
1790 cli.command,
1791 Some(Commands::Thread(ThreadArgs {
1792 command: ThreadCommand::Resume { ref thread_id }
1793 })) if thread_id == "thread-2"
1794 ));
1795
1796 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
1797 assert!(matches!(
1798 cli.command,
1799 Some(Commands::Thread(ThreadArgs {
1800 command: ThreadCommand::Fork { ref thread_id }
1801 })) if thread_id == "thread-3"
1802 ));
1803
1804 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
1805 assert!(matches!(
1806 cli.command,
1807 Some(Commands::Thread(ThreadArgs {
1808 command: ThreadCommand::Archive { ref thread_id }
1809 })) if thread_id == "thread-4"
1810 ));
1811
1812 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
1813 assert!(matches!(
1814 cli.command,
1815 Some(Commands::Thread(ThreadArgs {
1816 command: ThreadCommand::Unarchive { ref thread_id }
1817 })) if thread_id == "thread-5"
1818 ));
1819
1820 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
1821 assert!(matches!(
1822 cli.command,
1823 Some(Commands::Thread(ThreadArgs {
1824 command: ThreadCommand::SetName {
1825 ref thread_id,
1826 ref name
1827 }
1828 })) if thread_id == "thread-6" && name == "My Thread"
1829 ));
1830 }
1831
1832 #[test]
1833 fn parses_sandbox_app_server_and_completion_matrix() {
1834 let cli = parse_ok(&[
1835 "deepseek",
1836 "sandbox",
1837 "check",
1838 "echo hello",
1839 "--ask",
1840 "on-failure",
1841 ]);
1842 assert!(matches!(
1843 cli.command,
1844 Some(Commands::Sandbox(SandboxArgs {
1845 command: SandboxCommand::Check {
1846 ref command,
1847 ask: ApprovalModeArg::OnFailure
1848 }
1849 })) if command == "echo hello"
1850 ));
1851
1852 let cli = parse_ok(&[
1853 "deepseek",
1854 "app-server",
1855 "--host",
1856 "0.0.0.0",
1857 "--port",
1858 "9999",
1859 ]);
1860 assert!(matches!(
1861 cli.command,
1862 Some(Commands::AppServer(AppServerArgs {
1863 ref host,
1864 port: 9999,
1865 stdio: false,
1866 ..
1867 })) if host == "0.0.0.0"
1868 ));
1869
1870 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
1871 assert!(matches!(
1872 cli.command,
1873 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
1874 ));
1875
1876 let cli = parse_ok(&["deepseek", "completion", "bash"]);
1877 assert!(matches!(
1878 cli.command,
1879 Some(Commands::Completion { shell: Shell::Bash })
1880 ));
1881 }
1882
1883 #[test]
1884 fn parses_direct_tui_command_aliases() {
1885 let cli = parse_ok(&["deepseek", "doctor"]);
1886 assert!(matches!(
1887 cli.command,
1888 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
1889 ));
1890
1891 let cli = parse_ok(&["deepseek", "models", "--json"]);
1892 assert!(matches!(
1893 cli.command,
1894 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
1895 ));
1896
1897 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
1898 assert!(matches!(
1899 cli.command,
1900 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
1901 ));
1902
1903 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
1904 assert!(matches!(
1905 cli.command,
1906 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
1907 if args == &["--skills", "--local"]
1908 ));
1909 }
1910
1911 #[test]
1912 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
1913 assert!(should_pick_resume_in_dispatcher(
1914 &["resume".to_string()],
1915 true
1916 ));
1917 assert!(!should_pick_resume_in_dispatcher(
1918 &["resume".to_string(), "--last".to_string()],
1919 true
1920 ));
1921 assert!(!should_pick_resume_in_dispatcher(
1922 &["resume".to_string(), "abc123".to_string()],
1923 true
1924 ));
1925 assert!(!should_pick_resume_in_dispatcher(
1926 &["resume".to_string()],
1927 false
1928 ));
1929 }
1930
1931 #[test]
1932 fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
1933 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1934 let path = std::env::temp_dir().join(format!(
1935 "deepseek-cli-login-test-{}-{nanos}.toml",
1936 std::process::id()
1937 ));
1938 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1939 let secrets = no_keyring_secrets();
1940
1941 run_login_command_with_secrets(
1942 &mut store,
1943 LoginArgs {
1944 provider: ProviderArg::Deepseek,
1945 api_key: Some("sk-test".to_string()),
1946 chatgpt: false,
1947 device_code: false,
1948 token: None,
1949 },
1950 &secrets,
1951 )
1952 .expect("login should write config");
1953
1954 assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
1955 assert_eq!(
1956 store.config.providers.deepseek.api_key.as_deref(),
1957 Some("sk-test")
1958 );
1959 assert_eq!(
1960 store.config.default_text_model.as_deref(),
1961 Some("deepseek-v4-pro")
1962 );
1963 let saved = std::fs::read_to_string(&path).expect("config should be written");
1964 assert!(saved.contains("api_key = \"sk-test\""));
1965 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
1966
1967 let _ = std::fs::remove_file(path);
1968 }
1969
1970 #[test]
1971 fn parses_auth_subcommand_matrix() {
1972 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
1973 assert!(matches!(
1974 cli.command,
1975 Some(Commands::Auth(AuthArgs {
1976 command: AuthCommand::Set {
1977 provider: ProviderArg::Deepseek,
1978 api_key: None,
1979 api_key_stdin: false,
1980 }
1981 }))
1982 ));
1983
1984 let cli = parse_ok(&[
1985 "deepseek",
1986 "auth",
1987 "set",
1988 "--provider",
1989 "openrouter",
1990 "--api-key-stdin",
1991 ]);
1992 assert!(matches!(
1993 cli.command,
1994 Some(Commands::Auth(AuthArgs {
1995 command: AuthCommand::Set {
1996 provider: ProviderArg::Openrouter,
1997 api_key: None,
1998 api_key_stdin: true,
1999 }
2000 }))
2001 ));
2002
2003 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
2004 assert!(matches!(
2005 cli.command,
2006 Some(Commands::Auth(AuthArgs {
2007 command: AuthCommand::Get {
2008 provider: ProviderArg::Novita
2009 }
2010 }))
2011 ));
2012
2013 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
2014 assert!(matches!(
2015 cli.command,
2016 Some(Commands::Auth(AuthArgs {
2017 command: AuthCommand::Clear {
2018 provider: ProviderArg::NvidiaNim
2019 }
2020 }))
2021 ));
2022
2023 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
2024 assert!(matches!(
2025 cli.command,
2026 Some(Commands::Auth(AuthArgs {
2027 command: AuthCommand::Set {
2028 provider: ProviderArg::Fireworks,
2029 api_key: None,
2030 api_key_stdin: false,
2031 }
2032 }))
2033 ));
2034
2035 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
2036 assert!(matches!(
2037 cli.command,
2038 Some(Commands::Auth(AuthArgs {
2039 command: AuthCommand::Get {
2040 provider: ProviderArg::Sglang
2041 }
2042 }))
2043 ));
2044
2045 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
2046 assert!(matches!(
2047 cli.command,
2048 Some(Commands::Auth(AuthArgs {
2049 command: AuthCommand::Get {
2050 provider: ProviderArg::Vllm
2051 }
2052 }))
2053 ));
2054
2055 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
2056 assert!(matches!(
2057 cli.command,
2058 Some(Commands::Auth(AuthArgs {
2059 command: AuthCommand::Set {
2060 provider: ProviderArg::Ollama,
2061 api_key: None,
2062 api_key_stdin: false,
2063 }
2064 }))
2065 ));
2066
2067 let cli = parse_ok(&["deepseek", "auth", "list"]);
2068 assert!(matches!(
2069 cli.command,
2070 Some(Commands::Auth(AuthArgs {
2071 command: AuthCommand::List
2072 }))
2073 ));
2074
2075 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
2076 assert!(matches!(
2077 cli.command,
2078 Some(Commands::Auth(AuthArgs {
2079 command: AuthCommand::Migrate { dry_run: false }
2080 }))
2081 ));
2082
2083 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
2084 assert!(matches!(
2085 cli.command,
2086 Some(Commands::Auth(AuthArgs {
2087 command: AuthCommand::Migrate { dry_run: true }
2088 }))
2089 ));
2090 }
2091
2092 #[test]
2093 fn auth_set_writes_to_shared_config_file() {
2094 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2095 use std::sync::Arc;
2096
2097 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2098 let path = std::env::temp_dir().join(format!(
2099 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
2100 std::process::id()
2101 ));
2102 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2103 let inner = Arc::new(InMemoryKeyringStore::new());
2104 let secrets = Secrets::new(inner.clone());
2105
2106 run_auth_command_with_secrets(
2107 &mut store,
2108 AuthCommand::Set {
2109 provider: ProviderArg::Deepseek,
2110 api_key: Some("sk-keyring".to_string()),
2111 api_key_stdin: false,
2112 },
2113 &secrets,
2114 )
2115 .expect("set should succeed");
2116
2117 assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
2118 assert_eq!(
2119 store.config.providers.deepseek.api_key.as_deref(),
2120 Some("sk-keyring")
2121 );
2122 let saved = std::fs::read_to_string(&path).unwrap_or_default();
2123 assert!(saved.contains("api_key = \"sk-keyring\""));
2124 assert_eq!(
2125 inner.get("deepseek").unwrap().as_deref(),
2126 Some("sk-keyring")
2127 );
2128
2129 let _ = std::fs::remove_file(path);
2130 }
2131
2132 #[test]
2133 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
2134 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2135 let path = std::env::temp_dir().join(format!(
2136 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
2137 std::process::id()
2138 ));
2139 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2140 let secrets = no_keyring_secrets();
2141
2142 run_auth_command_with_secrets(
2143 &mut store,
2144 AuthCommand::Set {
2145 provider: ProviderArg::Ollama,
2146 api_key: None,
2147 api_key_stdin: false,
2148 },
2149 &secrets,
2150 )
2151 .expect("ollama auth set should not require a key");
2152
2153 assert_eq!(store.config.provider, ProviderKind::Ollama);
2154 assert_eq!(
2155 store.config.providers.ollama.base_url.as_deref(),
2156 Some("http://localhost:11434/v1")
2157 );
2158 assert_eq!(store.config.providers.ollama.api_key, None);
2159
2160 let _ = std::fs::remove_file(path);
2161 }
2162
2163 #[test]
2164 fn auth_clear_removes_from_config() {
2165 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2166 use std::sync::Arc;
2167
2168 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2169 let path = std::env::temp_dir().join(format!(
2170 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
2171 std::process::id()
2172 ));
2173 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2174 store.config.api_key = Some("sk-stale".to_string());
2175 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2176 store.save().unwrap();
2177
2178 let inner = Arc::new(InMemoryKeyringStore::new());
2179 inner.set("deepseek", "sk-stale").unwrap();
2180 let secrets = Secrets::new(inner.clone());
2181
2182 run_auth_command_with_secrets(
2183 &mut store,
2184 AuthCommand::Clear {
2185 provider: ProviderArg::Deepseek,
2186 },
2187 &secrets,
2188 )
2189 .expect("clear should succeed");
2190
2191 assert!(store.config.api_key.is_none());
2192 assert!(store.config.providers.deepseek.api_key.is_none());
2193 assert_eq!(inner.get("deepseek").unwrap(), None);
2194
2195 let _ = std::fs::remove_file(path);
2196 }
2197
2198 #[test]
2199 fn auth_status_and_list_only_probe_active_provider_keyring() {
2200 use deepseek_secrets::{KeyringStore, SecretsError};
2201 use std::sync::{Arc, Mutex};
2202
2203 #[derive(Default)]
2204 struct RecordingStore {
2205 gets: Mutex<Vec<String>>,
2206 }
2207
2208 impl KeyringStore for RecordingStore {
2209 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
2210 self.gets.lock().unwrap().push(key.to_string());
2211 Ok(None)
2212 }
2213
2214 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
2215 Ok(())
2216 }
2217
2218 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
2219 Ok(())
2220 }
2221
2222 fn backend_name(&self) -> &'static str {
2223 "recording"
2224 }
2225 }
2226
2227 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2228 let path = std::env::temp_dir().join(format!(
2229 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
2230 std::process::id()
2231 ));
2232 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2233 store.config.provider = ProviderKind::Deepseek;
2234 let inner = Arc::new(RecordingStore::default());
2235 let secrets = Secrets::new(inner.clone());
2236
2237 run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)
2238 .expect("status should succeed");
2239 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
2240 .expect("list should succeed");
2241
2242 assert_eq!(
2243 inner.gets.lock().unwrap().as_slice(),
2244 ["deepseek", "deepseek"]
2245 );
2246
2247 let _ = std::fs::remove_file(path);
2248 }
2249
2250 #[test]
2251 fn auth_status_reports_all_active_provider_sources_with_last4() {
2252 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2253 use std::sync::Arc;
2254
2255 let _lock = env_lock();
2256 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
2257
2258 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2259 let path = std::env::temp_dir().join(format!(
2260 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
2261 std::process::id()
2262 ));
2263 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2264 store.config.provider = ProviderKind::Deepseek;
2265 store.config.api_key = Some("sk-config-3333".to_string());
2266 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
2267
2268 let inner = Arc::new(InMemoryKeyringStore::new());
2269 inner.set("deepseek", "sk-keyring-2222").unwrap();
2270 let secrets = Secrets::new(inner);
2271
2272 let output = auth_status_lines(&store, &secrets).join("\n");
2273
2274 assert!(output.contains("provider: deepseek"));
2275 assert!(output.contains("active source: config (last4: ...3333)"));
2276 assert!(output.contains("lookup order: config -> keyring -> env"));
2277 assert!(output.contains("config file: "));
2278 assert!(output.contains("set, last4: ...3333"));
2279 assert!(output.contains("keyring: in-memory (test) (set, last4: ...2222)"));
2280 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
2281 assert!(!output.contains("sk-config-3333"));
2282 assert!(!output.contains("sk-keyring-2222"));
2283 assert!(!output.contains("sk-env-1111"));
2284
2285 let _ = std::fs::remove_file(path);
2286 }
2287
2288 #[test]
2289 fn dispatch_keyring_recovery_self_heals_into_config_file() {
2290 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2291 use std::sync::Arc;
2292
2293 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2294 let path = std::env::temp_dir().join(format!(
2295 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
2296 std::process::id()
2297 ));
2298 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2299 let inner = Arc::new(InMemoryKeyringStore::new());
2300 inner.set("deepseek", "ring-key").unwrap();
2301 let secrets = Secrets::new(inner);
2302
2303 let resolved = resolve_runtime_for_dispatch_with_secrets(
2304 &mut store,
2305 &CliRuntimeOverrides::default(),
2306 &secrets,
2307 );
2308
2309 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
2310 assert_eq!(
2311 resolved.api_key_source,
2312 Some(RuntimeApiKeySource::ConfigFile)
2313 );
2314 assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
2315 assert_eq!(
2316 store.config.providers.deepseek.api_key.as_deref(),
2317 Some("ring-key")
2318 );
2319
2320 let saved = std::fs::read_to_string(&path).expect("config should be written");
2321 assert!(saved.contains("api_key = \"ring-key\""));
2322
2323 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
2324 &mut store,
2325 &CliRuntimeOverrides::default(),
2326 &no_keyring_secrets(),
2327 );
2328 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
2329 assert_eq!(
2330 resolved_again.api_key_source,
2331 Some(RuntimeApiKeySource::ConfigFile)
2332 );
2333
2334 let _ = std::fs::remove_file(path);
2335 }
2336
2337 #[test]
2338 fn logout_removes_plaintext_provider_keys() {
2339 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2340 let path = std::env::temp_dir().join(format!(
2341 "deepseek-cli-logout-test-{}-{nanos}.toml",
2342 std::process::id()
2343 ));
2344 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2345 store.config.api_key = Some("sk-stale".to_string());
2346 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2347 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
2348 store.save().unwrap();
2349
2350 let secrets = no_keyring_secrets();
2351
2352 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
2353
2354 assert!(store.config.api_key.is_none());
2355 assert!(store.config.providers.deepseek.api_key.is_none());
2356 assert!(store.config.providers.fireworks.api_key.is_none());
2357
2358 let _ = std::fs::remove_file(path);
2359 }
2360
2361 #[test]
2362 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
2363 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2364 use std::sync::Arc;
2365
2366 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2367 let path = std::env::temp_dir().join(format!(
2368 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
2369 std::process::id()
2370 ));
2371 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2372 store.config.api_key = Some("sk-deep".to_string());
2373 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
2374 store.config.providers.openrouter.api_key = Some("or-key".to_string());
2375 store.config.providers.novita.api_key = Some("nv-key".to_string());
2376 store.save().unwrap();
2377
2378 let inner = Arc::new(InMemoryKeyringStore::new());
2379 let secrets = Secrets::new(inner.clone());
2380
2381 run_auth_command_with_secrets(
2382 &mut store,
2383 AuthCommand::Migrate { dry_run: false },
2384 &secrets,
2385 )
2386 .expect("migrate should succeed");
2387
2388 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
2389 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
2390 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
2391
2392 assert!(store.config.api_key.is_none());
2394 assert!(store.config.providers.deepseek.api_key.is_none());
2395 assert!(store.config.providers.openrouter.api_key.is_none());
2396 assert!(store.config.providers.novita.api_key.is_none());
2397
2398 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
2399 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
2400 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
2401 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
2402
2403 let _ = std::fs::remove_file(path);
2404 }
2405
2406 #[test]
2407 fn auth_migrate_dry_run_does_not_modify_anything() {
2408 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2409 use std::sync::Arc;
2410
2411 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2412 let path = std::env::temp_dir().join(format!(
2413 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
2414 std::process::id()
2415 ));
2416 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2417 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
2418 store.save().unwrap();
2419
2420 let inner = Arc::new(InMemoryKeyringStore::new());
2421 let secrets = Secrets::new(inner.clone());
2422
2423 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
2424 .expect("dry-run should succeed");
2425
2426 assert_eq!(inner.get("openrouter").unwrap(), None);
2427 assert_eq!(
2428 store.config.providers.openrouter.api_key.as_deref(),
2429 Some("or-stay")
2430 );
2431
2432 let _ = std::fs::remove_file(path);
2433 }
2434
2435 #[test]
2436 fn parses_global_override_flags() {
2437 let cli = parse_ok(&[
2438 "deepseek",
2439 "--provider",
2440 "openai",
2441 "--config",
2442 "/tmp/deepseek.toml",
2443 "--profile",
2444 "work",
2445 "--model",
2446 "gpt-4.1",
2447 "--output-mode",
2448 "json",
2449 "--log-level",
2450 "debug",
2451 "--telemetry",
2452 "true",
2453 "--approval-policy",
2454 "on-request",
2455 "--sandbox-mode",
2456 "workspace-write",
2457 "--base-url",
2458 "https://api.openai.com/v1",
2459 "--api-key",
2460 "sk-test",
2461 "--no-alt-screen",
2462 "--no-mouse-capture",
2463 "--skip-onboarding",
2464 "model",
2465 "resolve",
2466 "gpt-4.1",
2467 ]);
2468
2469 assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
2470 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
2471 assert_eq!(cli.profile.as_deref(), Some("work"));
2472 assert_eq!(cli.model.as_deref(), Some("gpt-4.1"));
2473 assert_eq!(cli.output_mode.as_deref(), Some("json"));
2474 assert_eq!(cli.log_level.as_deref(), Some("debug"));
2475 assert_eq!(cli.telemetry, Some(true));
2476 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
2477 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
2478 assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
2479 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
2480 assert!(cli.no_alt_screen);
2481 assert!(cli.no_mouse_capture);
2482 assert!(!cli.mouse_capture);
2483 assert!(cli.skip_onboarding);
2484 }
2485
2486 #[test]
2487 fn build_tui_command_allows_openai_and_forwards_provider_key() {
2488 let _lock = env_lock();
2489 let dir = tempfile::TempDir::new().expect("tempdir");
2490 let custom = dir
2491 .path()
2492 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2493 std::fs::write(&custom, b"").unwrap();
2494 let custom_str = custom.to_string_lossy().into_owned();
2495 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2496
2497 let cli = parse_ok(&["deepseek", "--provider", "openai"]);
2498 let resolved = ResolvedRuntimeOptions {
2499 provider: ProviderKind::Openai,
2500 model: "glm-5".to_string(),
2501 api_key: Some("resolved-openai-key".to_string()),
2502 api_key_source: Some(RuntimeApiKeySource::Keyring),
2503 base_url: "https://openai-compatible.example/v4".to_string(),
2504 auth_mode: Some("api_key".to_string()),
2505 output_mode: None,
2506 log_level: None,
2507 telemetry: false,
2508 approval_policy: None,
2509 sandbox_mode: None,
2510 http_headers: std::collections::BTreeMap::new(),
2511 };
2512
2513 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
2514 assert_eq!(
2515 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
2516 Some("openai")
2517 );
2518 assert_eq!(
2519 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
2520 Some("glm-5")
2521 );
2522 assert_eq!(
2523 command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
2524 Some("https://openai-compatible.example/v4")
2525 );
2526 assert_eq!(
2527 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
2528 Some("resolved-openai-key")
2529 );
2530 assert_eq!(
2531 command_env(&cmd, "OPENAI_API_KEY").as_deref(),
2532 Some("resolved-openai-key")
2533 );
2534 assert_eq!(
2535 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
2536 Some("keyring")
2537 );
2538 }
2539
2540 #[test]
2541 fn parses_top_level_prompt_flag_for_canonical_one_shot() {
2542 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
2543
2544 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
2545 assert!(cli.prompt.is_empty());
2546 }
2547
2548 #[test]
2549 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
2550 let cli = parse_ok(&["deepseek", "hello", "world"]);
2551
2552 assert_eq!(cli.prompt, vec!["hello", "world"]);
2553 assert!(cli.command.is_none());
2554 }
2555
2556 #[test]
2557 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
2558 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
2559
2560 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
2561 assert_eq!(cli.prompt, vec!["world"]);
2562 }
2563
2564 #[test]
2565 fn known_subcommands_still_parse_before_prompt_tail() {
2566 let cli = parse_ok(&["deepseek", "doctor"]);
2567
2568 assert!(cli.prompt.is_empty());
2569 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
2570 }
2571
2572 #[test]
2573 fn root_help_surface_contains_expected_subcommands_and_globals() {
2574 let rendered = help_for(&["deepseek", "--help"]);
2575
2576 for token in [
2577 "run",
2578 "doctor",
2579 "models",
2580 "sessions",
2581 "resume",
2582 "setup",
2583 "login",
2584 "logout",
2585 "auth",
2586 "mcp-server",
2587 "config",
2588 "model",
2589 "thread",
2590 "sandbox",
2591 "app-server",
2592 "completion",
2593 "metrics",
2594 "--provider",
2595 "--model",
2596 "--config",
2597 "--profile",
2598 "--output-mode",
2599 "--log-level",
2600 "--telemetry",
2601 "--base-url",
2602 "--api-key",
2603 "--approval-policy",
2604 "--sandbox-mode",
2605 "--mouse-capture",
2606 "--no-mouse-capture",
2607 "--skip-onboarding",
2608 "--prompt",
2609 ] {
2610 assert!(
2611 rendered.contains(token),
2612 "expected help to contain token: {token}"
2613 );
2614 }
2615 }
2616
2617 #[test]
2618 fn subcommand_help_surfaces_are_stable() {
2619 let cases = [
2620 ("config", vec!["get", "set", "unset", "list", "path"]),
2621 ("model", vec!["list", "resolve"]),
2622 (
2623 "thread",
2624 vec![
2625 "list",
2626 "read",
2627 "resume",
2628 "fork",
2629 "archive",
2630 "unarchive",
2631 "set-name",
2632 ],
2633 ),
2634 ("sandbox", vec!["check"]),
2635 (
2636 "app-server",
2637 vec!["--host", "--port", "--config", "--stdio"],
2638 ),
2639 (
2640 "completion",
2641 vec![
2642 "<SHELL>",
2643 "bash",
2644 "source <(deepseek completion bash)",
2645 "~/.local/share/bash-completion/completions/deepseek",
2646 "fpath=(~/.zfunc $fpath)",
2647 "deepseek completion fish > ~/.config/fish/completions/deepseek.fish",
2648 "deepseek completion powershell | Out-String | Invoke-Expression",
2649 ],
2650 ),
2651 ("metrics", vec!["--json", "--since"]),
2652 ];
2653
2654 for (subcommand, expected_tokens) in cases {
2655 let argv = ["deepseek", subcommand, "--help"];
2656 let rendered = help_for(&argv);
2657 for token in expected_tokens {
2658 assert!(
2659 rendered.contains(token),
2660 "expected help for `{subcommand}` to include `{token}`"
2661 );
2662 }
2663 }
2664 }
2665
2666 #[test]
2672 fn sibling_tui_candidate_picks_platform_correct_name() {
2673 let dir = tempfile::TempDir::new().expect("tempdir");
2674 let dispatcher = dir
2675 .path()
2676 .join("deepseek")
2677 .with_extension(std::env::consts::EXE_EXTENSION);
2678 std::fs::write(&dispatcher, b"").unwrap();
2680
2681 assert!(sibling_tui_candidate(&dispatcher).is_none());
2683
2684 let target =
2685 dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
2686 std::fs::write(&target, b"").unwrap();
2687
2688 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
2689 assert_eq!(found, target, "primary platform-correct name wins");
2690 }
2691
2692 #[test]
2693 fn dispatcher_spawn_error_names_path_and_recovery_checks() {
2694 let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
2695 let message = tui_spawn_error(Path::new("C:/tools/deepseek-tui.exe"), &err);
2696
2697 assert!(message.contains("C:/tools/deepseek-tui.exe"));
2698 assert!(message.contains("access is denied"));
2699 assert!(message.contains("where deepseek"));
2700 assert!(message.contains("DEEPSEEK_TUI_BIN"));
2701 }
2702
2703 #[cfg(windows)]
2708 #[test]
2709 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
2710 let dir = tempfile::TempDir::new().expect("tempdir");
2711 let dispatcher = dir.path().join("deepseek.exe");
2712 std::fs::write(&dispatcher, b"").unwrap();
2713
2714 let suffixless = dispatcher.with_file_name("deepseek-tui");
2716 std::fs::write(&suffixless, b"").unwrap();
2717
2718 let found = sibling_tui_candidate(&dispatcher)
2719 .expect("Windows fallback must locate suffixless deepseek-tui");
2720 assert_eq!(found, suffixless);
2721 }
2722
2723 #[test]
2726 fn locate_sibling_tui_binary_honours_env_override() {
2727 let _lock = env_lock();
2728 let dir = tempfile::TempDir::new().expect("tempdir");
2729 let custom = dir
2730 .path()
2731 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2732 std::fs::write(&custom, b"").unwrap();
2733 let custom_str = custom.to_string_lossy().into_owned();
2734 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2735
2736 let resolved = locate_sibling_tui_binary().expect("override must resolve");
2737 assert_eq!(resolved, custom);
2738 }
2739}