Skip to main content

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