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