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