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