Skip to main content

codewhale_cli/
lib.rs

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