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