Skip to main content

deepseek_tui_cli/
lib.rs

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