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