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