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