Skip to main content

codewhale_cli/
lib.rs

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