Skip to main content

codewhale_cli/
lib.rs

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