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