Skip to main content

codewhale_cli/
lib.rs

1#![allow(clippy::uninlined_format_args)]
2
3mod cloud;
4mod credential_handoff;
5mod metrics;
6#[cfg(not(target_env = "ohos"))]
7mod update;
8
9use std::io::{self, IsTerminal, Read, Write};
10use std::net::SocketAddr;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14use anyhow::{Context, Result, anyhow, bail};
15use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
16use clap_complete::{Shell, generate};
17use codewhale_agent::ModelRegistry;
18use codewhale_app_server::{
19    AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
20};
21use codewhale_config::{
22    CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
23    ProviderSource, ResolvedRuntimeOptions, RuntimeApiKeySource, SetupState,
24    classify_config_api_key_value, provider_base_url_is_official,
25};
26use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
27use codewhale_mcp::{McpServerDefinition, run_stdio_server};
28use codewhale_secrets::Secrets;
29use codewhale_state::{StateStore, ThreadListFilters};
30use codewhale_telemetry::{
31    self as telemetry, Counters, DurationBucket, Errors, Event, ExitClass, SessionSource, Surface,
32    TelemetryDecision, TurnWall,
33};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
36enum ProviderArg {
37    Deepseek,
38    NvidiaNim,
39    Openai,
40    Atlascloud,
41    WanjieArk,
42    Volcengine,
43    Openrouter,
44    XiaomiMimo,
45    Novita,
46    Fireworks,
47    Siliconflow,
48    #[value(
49        alias = "silicon-flow-cn",
50        alias = "siliconflow-CN",
51        alias = "silicon_flow_cn",
52        alias = "siliconflow_cn",
53        alias = "siliconflow-china",
54        alias = "siliconflow_china"
55    )]
56    SiliconflowCn,
57    Arcee,
58    Moonshot,
59    Sglang,
60    Vllm,
61    Ollama,
62    Huggingface,
63    Together,
64    OpenaiCodex,
65    Anthropic,
66    #[value(alias = "open-model", alias = "open_model")]
67    Openmodel,
68    Zai,
69    Stepfun,
70    Minimax,
71    #[value(
72        alias = "minimax_anthropic",
73        alias = "mini-max-anthropic",
74        alias = "mini_max_anthropic"
75    )]
76    MinimaxAnthropic,
77    #[value(alias = "deep-infra", alias = "deep_infra")]
78    Deepinfra,
79    #[value(alias = "fugu", alias = "sakana-ai", alias = "sakana_ai")]
80    Sakana,
81    #[value(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")]
82    LongCat,
83    #[value(alias = "opencode_go", alias = "opencodego")]
84    OpencodeGo,
85    #[value(
86        alias = "opencode_zen",
87        alias = "opencodezen",
88        alias = "zen",
89        alias = "opencode"
90    )]
91    OpencodeZen,
92    #[value(
93        alias = "meta-ai",
94        alias = "meta_ai",
95        alias = "meta-model-api",
96        alias = "muse",
97        alias = "muse-spark"
98    )]
99    Meta,
100    #[value(alias = "x-ai", alias = "x_ai", alias = "grok")]
101    Xai,
102}
103
104impl From<ProviderArg> for ProviderKind {
105    fn from(value: ProviderArg) -> Self {
106        match value {
107            ProviderArg::Deepseek => ProviderKind::Deepseek,
108            ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
109            ProviderArg::Openai => ProviderKind::Openai,
110            ProviderArg::Atlascloud => ProviderKind::Atlascloud,
111            ProviderArg::WanjieArk => ProviderKind::WanjieArk,
112            ProviderArg::Volcengine => ProviderKind::Volcengine,
113            ProviderArg::Openrouter => ProviderKind::Openrouter,
114            ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
115            ProviderArg::Novita => ProviderKind::Novita,
116            ProviderArg::Fireworks => ProviderKind::Fireworks,
117            ProviderArg::Siliconflow => ProviderKind::Siliconflow,
118            ProviderArg::SiliconflowCn => ProviderKind::SiliconflowCN,
119            ProviderArg::Arcee => ProviderKind::Arcee,
120            ProviderArg::Moonshot => ProviderKind::Moonshot,
121            ProviderArg::Sglang => ProviderKind::Sglang,
122            ProviderArg::Vllm => ProviderKind::Vllm,
123            ProviderArg::Ollama => ProviderKind::Ollama,
124            ProviderArg::Huggingface => ProviderKind::Huggingface,
125            ProviderArg::Together => ProviderKind::Together,
126            ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
127            ProviderArg::Anthropic => ProviderKind::Anthropic,
128            ProviderArg::Openmodel => ProviderKind::Openmodel,
129            ProviderArg::Zai => ProviderKind::Zai,
130            ProviderArg::Stepfun => ProviderKind::Stepfun,
131            ProviderArg::Minimax => ProviderKind::Minimax,
132            ProviderArg::MinimaxAnthropic => ProviderKind::MinimaxAnthropic,
133            ProviderArg::Deepinfra => ProviderKind::Deepinfra,
134            ProviderArg::Sakana => ProviderKind::Sakana,
135            ProviderArg::LongCat => ProviderKind::LongCat,
136            ProviderArg::OpencodeGo => ProviderKind::OpencodeGo,
137            ProviderArg::OpencodeZen => ProviderKind::OpencodeZen,
138            ProviderArg::Meta => ProviderKind::Meta,
139            ProviderArg::Xai => ProviderKind::Xai,
140        }
141    }
142}
143
144fn builtin_provider_arg(value: &str) -> Option<ProviderArg> {
145    ProviderArg::from_str(value, false).ok()
146}
147
148fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
149    if value.is_empty()
150        || value == "__custom__"
151        || !value
152            .chars()
153            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
154    {
155        return Err(
156            "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
157                .to_string(),
158        );
159    }
160    Ok(value.to_string())
161}
162
163#[derive(Debug, Parser)]
164#[command(
165    name = "codewhale",
166    version = env!("DEEPSEEK_BUILD_VERSION"),
167    bin_name = "codewhale",
168    override_usage = "codewhale [OPTIONS] [PROMPT]\n       codewhale [OPTIONS] <COMMAND> [ARGS]"
169)]
170struct Cli {
171    #[arg(long)]
172    config: Option<PathBuf>,
173    #[arg(long)]
174    profile: Option<String>,
175    #[arg(
176        long,
177        value_name = "PROVIDER",
178        value_parser = parse_provider_identifier,
179        help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
180    )]
181    provider: Option<String>,
182    #[arg(long)]
183    model: Option<String>,
184    #[arg(long = "output-mode")]
185    output_mode: Option<String>,
186    #[arg(
187        long = "verbosity",
188        value_name = "LEVEL",
189        help = "Controls transcript and output verbosity (normal, concise)"
190    )]
191    verbosity: Option<String>,
192    #[arg(long = "log-level")]
193    log_level: Option<String>,
194    #[arg(
195        long,
196        value_name = "BOOL",
197        help = "Opt in to anonymous product telemetry for this run (default off; \
198                CODEWHALE_TELEMETRY=0 always wins)"
199    )]
200    telemetry: Option<bool>,
201    #[arg(long)]
202    approval_policy: Option<String>,
203    #[arg(long)]
204    sandbox_mode: Option<String>,
205    #[arg(long)]
206    api_key: Option<String>,
207    #[arg(long)]
208    base_url: Option<String>,
209    /// Workspace directory for TUI file tools
210    #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
211    workspace: Option<PathBuf>,
212    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
213    mouse_capture: bool,
214    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
215    no_mouse_capture: bool,
216    #[arg(long = "skip-onboarding")]
217    skip_onboarding: bool,
218    /// Skip loading project-level config, including the workspace-specific
219    /// `[workspace]`/`[projects]` overlay from user config. Must appear before
220    /// the subcommand; it is forwarded to the TUI ahead of the subcommand.
221    #[arg(long = "no-project-config")]
222    no_project_config: bool,
223    /// Legacy compatibility alias for Act + Full Access.
224    #[arg(long, hide = true)]
225    yolo: bool,
226    /// Continue the most recent interactive session for this workspace.
227    #[arg(short = 'c', long = "continue")]
228    continue_session: bool,
229    #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
230    prompt_flag: Option<String>,
231    #[arg(
232        value_name = "PROMPT",
233        trailing_var_arg = true,
234        allow_hyphen_values = true
235    )]
236    prompt: Vec<String>,
237    #[command(subcommand)]
238    command: Option<Commands>,
239}
240
241#[derive(Debug, Subcommand)]
242enum Commands {
243    /// Run interactive/non-interactive flows via the TUI binary.
244    Run(RunArgs),
245    /// Run Codewhale diagnostics.
246    Doctor(TuiPassthroughArgs),
247    /// List live provider API models via the TUI binary.
248    Models(TuiPassthroughArgs),
249    /// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary.
250    #[command(visible_alias = "tts")]
251    Speech(TuiPassthroughArgs),
252    /// List saved TUI sessions.
253    Sessions(TuiPassthroughArgs),
254    /// Resume a saved TUI session.
255    Resume(TuiPassthroughArgs),
256    /// Launch an interactive session and hand it to the Codewhale web app.
257    Rc(TuiPassthroughArgs),
258    /// Fork a saved TUI session.
259    Fork(TuiPassthroughArgs),
260    /// Create a default AGENTS.md in the current directory.
261    Init(TuiPassthroughArgs),
262    /// Bootstrap MCP config and/or skills directories.
263    Setup(TuiPassthroughArgs),
264    /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge).
265    RemoteSetup(RemoteSetupArgs),
266    /// Run a non-interactive prompt through the TUI runtime.
267    #[command(after_help = "\
268Examples:
269  codewhale exec \"explain this function\"
270  codewhale exec --auto \"list crates/ with ls\"
271  codewhale exec --auto --output-format stream-json \"fix the failing test\"
272
273Common forwarded flags:
274  --auto                           Enable tool-backed agent mode with auto-approvals
275  --json                           Emit summary JSON
276  --resume <SESSION_ID>            Resume a previous session by ID or prefix
277  --session-id <SESSION_ID>        Resume a previous session by ID or prefix
278  --continue                       Continue the most recent session for this workspace
279  --output-format <FORMAT>         Output format: text or stream-json
280
281Plain `codewhale exec` is a one-shot model response. Use `--auto` for
282non-interactive filesystem/shell tool use, matching the supported automation
283path used by stream-json wrappers.
284")]
285    Exec(TuiPassthroughArgs),
286    /// Manage durable Agent Fleet runs via the TUI runtime.
287    Fleet(TuiPassthroughArgs),
288    /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
289    #[command(name = "workflow-tool", hide = true)]
290    WorkflowTool(TuiPassthroughArgs),
291    /// Internal detached-runtime output/receipt supervisor.
292    #[command(name = "lane-log-proxy", hide = true)]
293    LaneLogProxy(LaneLogProxyArgs),
294    /// Run checked-in Workflows through a Lane Runtime backend.
295    #[command(after_help = "\
296Examples:
297  codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
298  codewhale workflow run stopship --fleet stopship --runtime inline --verify
299
300`workflow run` validates the checked-in Workflow source and named Fleet roster,
301creates a Lane record, then dispatches the Workflow tool directly through the
302selected Runtime backend without an operator model turn.
303")]
304    Workflow(WorkflowArgs),
305    /// Manage running workflow instances (Lanes) and Runtime backends (#4176).
306    #[command(after_help = "\
307Examples:
308  codewhale lane list
309  codewhale lane status <lane-id>
310  codewhale lane attach <lane-id>
311  codewhale lane logs <lane-id>
312  codewhale lane interrupt <lane-id>
313  codewhale lane interrupt <lane-id>@<lifecycle-seq>
314  codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
315
316Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
317Runtime, not Fleet.
318
319list/status/interrupt/restart/resume share one control-plane contract with the
320`/lane` slash command and its hotbar action: same verb ids, same availability,
321same read-vs-write authority, same exact-identity target selection, and the
322same receipt (`--json`). `lane stop` is a compatibility spelling of
323`lane interrupt`. Appending `@<lifecycle-seq>` fences a write to the exact
324lifecycle generation you observed.
325")]
326    Lane(LaneArgs),
327    /// Run a Codewhale-powered code review over a git diff.
328    Review(TuiPassthroughArgs),
329    /// Apply a patch file or stdin to the working tree.
330    Apply(TuiPassthroughArgs),
331    /// Run the offline TUI evaluation harness.
332    Eval(TuiPassthroughArgs),
333    /// Manage TUI MCP servers.
334    Mcp(TuiPassthroughArgs),
335    /// Inspect TUI feature flags.
336    Features(TuiPassthroughArgs),
337    /// Run a local TUI server.
338    #[command(after_help = "\
339Forwarded serve options:
340      --mcp                 Start MCP server over stdio
341      --http                Start runtime HTTP/SSE API server
342      --mobile              Start runtime HTTP/SSE API server with the mobile control page
343      --web                 Start the embedded loopback-only browser client
344      --qr                  Show a QR code for the mobile URL (requires --mobile)
345      --acp                 Start ACP server over stdio for editor clients
346      --host <HOST>         Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
347      --port <PORT>         Bind port [default: 7878]
348      --workers <WORKERS>   Background task worker count (1-8)
349      --cors-origin <URL>   Additional CORS origin to allow (repeatable)
350      --auth-token <TOKEN>  Require this bearer token for /v1/* runtime API routes
351      --insecure            Disable runtime API auth when no token is configured
352
353`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
354aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
355New integrations should prefer `codewhale app-server`.")]
356    Serve(TuiPassthroughArgs),
357    /// Open the first-class local browser client over the canonical Runtime API.
358    #[command(
359        after_help = "The browser receives a one-time loopback bootstrap capability, never the Runtime token.\nThe capability is exchanged for a bounded, process-local HttpOnly, SameSite=Strict web session and then invalidated."
360    )]
361    Web(WebArgs),
362    /// Generate shell completions for the TUI binary.
363    Completions(TuiPassthroughArgs),
364    /// Configure provider credentials.
365    Login(LoginArgs),
366    /// Remove saved authentication state.
367    Logout,
368    /// Manage authentication credentials and provider mode.
369    Auth(AuthArgs),
370    /// Sign in to your Codewhale account and manage account-scoped provider keys.
371    #[command(visible_alias = "cloud")]
372    Account(cloud::CloudArgs),
373    /// Run MCP server mode over stdio.
374    McpServer,
375    /// Read/write/list config values.
376    Config(ConfigArgs),
377    /// Resolve or list available models across providers.
378    Model(ModelArgs),
379    /// Manage thread/session metadata and resume/fork flows.
380    Thread(ThreadArgs),
381    /// Evaluate sandbox/approval policy decisions.
382    Sandbox(SandboxArgs),
383    /// Run the canonical runtime API / control plane (HTTP/SSE, mobile, stdio).
384    #[command(after_help = "\
385Transports:
386  codewhale app-server --http              Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
387  codewhale app-server --mobile            Runtime API + phone control page (binds 0.0.0.0)
388  codewhale app-server --stdio             JSON-RPC control transport over stdio (no listener)
389  codewhale app-server                     Legacy in-process app-server HTTP on 127.0.0.1:8787
390
391`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
392--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
393is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
394
395See docs/RUNTIME_API.md.")]
396    AppServer(AppServerArgs),
397    /// Generate shell completions.
398    #[command(after_help = r#"Examples:
399  Bash (current shell only):
400    source <(codewhale completion bash)
401
402  Bash (persistent, Linux/bash-completion):
403    mkdir -p ~/.local/share/bash-completion/completions
404    codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
405    # Requires bash-completion to be installed and loaded by your shell.
406
407  Zsh:
408    mkdir -p ~/.zfunc
409    codewhale completion zsh > ~/.zfunc/_codewhale
410    # Add to ~/.zshrc if needed:
411    #   fpath=(~/.zfunc $fpath)
412    #   autoload -Uz compinit && compinit
413
414  Fish:
415    mkdir -p ~/.config/fish/completions
416    codewhale completion fish > ~/.config/fish/completions/codewhale.fish
417
418  PowerShell (current shell only):
419    codewhale completion powershell | Out-String | Invoke-Expression
420
421The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
422    Completion {
423        #[arg(value_enum)]
424        shell: Shell,
425    },
426    /// Print a usage rollup from the audit log and session store.
427    Metrics(MetricsArgs),
428    /// Check for and apply updates to the `codewhale` binary.
429    Update(UpdateArgs),
430}
431
432fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
433    matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
434}
435
436fn top_level_provider_override(
437    provider: Option<&str>,
438    command: Option<&Commands>,
439) -> Result<Option<ProviderKind>> {
440    let Some(provider) = provider else {
441        return Ok(None);
442    };
443    if let Some(provider) = builtin_provider_arg(provider) {
444        return Ok(Some(provider.into()));
445    }
446    if command_accepts_raw_provider(command) {
447        return Ok(None);
448    }
449
450    let expected = ProviderArg::value_variants()
451        .iter()
452        .filter_map(ValueEnum::to_possible_value)
453        .map(|value| value.get_name().to_string())
454        .collect::<Vec<_>>()
455        .join(", ");
456    bail!(
457        "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
458    )
459}
460
461fn prepare_raw_provider_tui_dispatch(
462    cli: &Cli,
463    command: Option<&Commands>,
464    runtime_overrides: &CliRuntimeOverrides,
465) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
466    let Some(provider) = cli.provider.as_deref() else {
467        return Ok(None);
468    };
469    if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
470        return Ok(None);
471    }
472
473    let passthrough = match command {
474        Some(Commands::Exec(args)) => {
475            reject_exec_global_flags(&args.args)?;
476            tui_args("exec", args.clone())
477        }
478        Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
479        _ => unreachable!("raw provider validation only permits Exec and Fleet"),
480    };
481
482    // Dynamic provider config belongs to the TUI schema. Do not parse it
483    // through the dispatcher's enum-backed ConfigStore or recover credentials
484    // for an unrelated fallback provider before the TUI sees the raw id.
485    let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
486    Ok(Some((resolved_runtime, passthrough)))
487}
488
489#[derive(Debug, Args)]
490struct UpdateArgs {
491    /// Update to the latest beta release instead of the latest stable release.
492    #[arg(long)]
493    beta: bool,
494    /// Only check the latest release; do not download or replace binaries.
495    #[arg(long)]
496    check: bool,
497    /// Proxy URL to use for update HTTP requests.
498    #[arg(long, value_name = "URL")]
499    proxy: Option<String>,
500}
501
502#[derive(Debug, Args)]
503struct MetricsArgs {
504    /// Emit machine-readable JSON.
505    #[arg(long)]
506    json: bool,
507    /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
508    #[arg(long, value_name = "DURATION")]
509    since: Option<String>,
510}
511
512#[derive(Debug, Args)]
513struct RunArgs {
514    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
515    args: Vec<String>,
516}
517
518#[derive(Debug, Args, Clone)]
519struct TuiPassthroughArgs {
520    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
521    args: Vec<String>,
522}
523
524#[derive(Debug, Args)]
525struct WebArgs {
526    /// Loopback port for the local Runtime API and embedded client.
527    #[arg(long, default_value_t = 7878)]
528    port: u16,
529}
530
531#[derive(Debug, Args)]
532struct LaneLogProxyArgs {
533    #[arg(long, value_name = "PATH")]
534    log_path: PathBuf,
535    #[arg(long, value_name = "PATH")]
536    receipt_path: PathBuf,
537    #[arg(long, value_name = "PATH")]
538    receipt_tmp_path: PathBuf,
539    #[arg(long, value_name = "PATH")]
540    environment_path: Option<PathBuf>,
541    #[arg(long)]
542    lane_id: String,
543    #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
544    command: Vec<String>,
545}
546
547/// `codewhale lane …` — running workflow instances (#4176).
548#[derive(Debug, Args)]
549struct LaneArgs {
550    #[command(subcommand)]
551    command: LaneCommand,
552}
553
554#[derive(Debug, Subcommand)]
555// Clap constructs this command enum once at process startup. Keeping the
556// fields inline makes the generated CLI shape explicit; boxing them only to
557// reduce this transient value would add indirection without runtime benefit.
558#[allow(clippy::large_enum_variant)]
559enum LaneCommand {
560    /// List known lanes (newest first).
561    List {
562        /// Emit JSON.
563        #[arg(long, default_value_t = false)]
564        json: bool,
565    },
566    /// Show one lane's status and attach metadata.
567    Status {
568        /// Lane id (e.g. `lane-a1b2c3d4`).
569        lane_id: String,
570        #[arg(long, default_value_t = false)]
571        json: bool,
572    },
573    /// Attach to a tmux-backed lane (prints attach command; execs when possible).
574    Attach {
575        lane_id: String,
576        /// Only print the attach command; do not exec.
577        #[arg(long, default_value_t = false)]
578        print: bool,
579    },
580    /// Tail the lane stream-json / NDJSON journal.
581    Logs {
582        lane_id: String,
583        /// Follow the log file (like `tail -f`).
584        #[arg(long, short = 'f', default_value_t = false)]
585        follow: bool,
586        /// Number of trailing lines when not following (default 50).
587        #[arg(long, default_value_t = 50)]
588        tail: usize,
589    },
590    /// Stop a running lane and run worktree TTL cleanup.
591    ///
592    /// Compatibility spelling for `lane interrupt`; both resolve to the
593    /// `lane.interrupt` control-plane verb (#1888).
594    Stop { lane_id: String },
595    /// Interrupt a running lane (durable `lane.interrupt`).
596    ///
597    /// Accepts an exact lane id, optionally fenced as `<lane-id>@<seq>` so the
598    /// stop only applies to the lifecycle generation you observed.
599    Interrupt {
600        lane_id: String,
601        #[arg(long, default_value_t = false)]
602        json: bool,
603    },
604    /// Restart a lane in place (declared, no backend — reports why).
605    Restart {
606        lane_id: String,
607        #[arg(long, default_value_t = false)]
608        json: bool,
609    },
610    /// Resume a stopped lane (declared, no backend — reports why).
611    Resume {
612        lane_id: String,
613        #[arg(long, default_value_t = false)]
614        json: bool,
615    },
616    /// Start a lane under a Runtime backend (tmux|inline|vm|ci).
617    Start {
618        /// Workflow name (e.g. `stopship`).
619        #[arg(long)]
620        workflow: Option<String>,
621        /// Fleet roster name (e.g. `stopship`).
622        #[arg(long)]
623        fleet: Option<String>,
624        /// Issue id binding.
625        #[arg(long)]
626        issue: Option<String>,
627        /// Free-form goal text.
628        #[arg(long)]
629        goal: Option<String>,
630        /// Runtime backend: tmux, inline, vm, or ci.
631        #[arg(long, default_value = "tmux")]
632        runtime: String,
633        /// Create an isolated worktree under this repo root.
634        #[arg(long, value_name = "DIR")]
635        worktree_repo: Option<PathBuf>,
636        /// Branch name for the worktree (requires `--worktree-repo`).
637        #[arg(long)]
638        branch: Option<String>,
639        /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
640        #[arg(long, value_name = "DIR")]
641        worktree_path: Option<PathBuf>,
642        /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
643        #[arg(long)]
644        worktree_ttl_secs: Option<u64>,
645        /// Command to run in the runtime (after `--`).
646        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
647        command: Vec<String>,
648    },
649}
650
651/// `codewhale workflow …` — Workflow entrypoints backed by Lanes (#4177/#4178).
652#[derive(Debug, Args)]
653struct WorkflowArgs {
654    #[command(subcommand)]
655    command: WorkflowCommand,
656}
657
658#[derive(Debug, Subcommand)]
659enum WorkflowCommand {
660    /// Run a checked-in Workflow through a Runtime-backed Lane.
661    Run {
662        /// Workflow name or path. `stopship` maps to workflows/stopship.workflow.js.
663        workflow: String,
664        /// Named Fleet roster (e.g. stopship). Optional: without one, roles
665        /// resolve against the built-in roster and the session route.
666        #[arg(long)]
667        fleet: Option<String>,
668        /// Issue id binding recorded on the Lane and passed into workflow args.
669        #[arg(long)]
670        issue: Option<String>,
671        /// Free-form goal text recorded on the Lane and passed into workflow args.
672        #[arg(long)]
673        goal: Option<String>,
674        /// Runtime backend: tmux, inline, vm, or ci.
675        #[arg(long, default_value = "tmux")]
676        runtime: String,
677        /// Explicit Workflow source path, overriding name-based resolution.
678        #[arg(long, value_name = "PATH")]
679        source_path: Option<PathBuf>,
680        /// Optional shared Workflow token budget.
681        #[arg(long)]
682        token_budget: Option<u64>,
683        /// Run verifier gates after a successful Workflow completion.
684        #[arg(long, default_value_t = false)]
685        verify: bool,
686        /// Create an isolated worktree under this repo root.
687        #[arg(long, value_name = "DIR")]
688        worktree_repo: Option<PathBuf>,
689        /// Branch name for the worktree (requires `--worktree-repo`).
690        #[arg(long)]
691        branch: Option<String>,
692        /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
693        #[arg(long, value_name = "DIR")]
694        worktree_path: Option<PathBuf>,
695        /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
696        #[arg(long)]
697        worktree_ttl_secs: Option<u64>,
698    },
699}
700
701struct LaneStartRequest {
702    workflow: Option<String>,
703    fleet: Option<String>,
704    issue: Option<String>,
705    goal: Option<String>,
706    runtime: String,
707    worktree_repo: Option<PathBuf>,
708    branch: Option<String>,
709    worktree_path: Option<PathBuf>,
710    worktree_ttl_secs: Option<u64>,
711    command: Vec<String>,
712    environment: Vec<(String, String)>,
713    cwd: Option<PathBuf>,
714}
715
716fn start_lane(request: LaneStartRequest) -> Result<()> {
717    use codewhale_lane::{
718        LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
719    };
720
721    let LaneStartRequest {
722        workflow,
723        fleet,
724        issue,
725        goal,
726        runtime,
727        worktree_repo,
728        branch,
729        worktree_path,
730        worktree_ttl_secs,
731        command,
732        environment,
733        cwd,
734    } = request;
735    let kind = RuntimeBackendKind::parse(&runtime)?;
736    let reg = LaneRegistry::open_default()?;
737    let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
738    let worktree = match (worktree_repo, branch) {
739        (Some(repo_root), Some(branch_name)) => {
740            let path = worktree_path
741                .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
742            Some(WorktreeProvision {
743                repo_root,
744                branch: branch_name,
745                path,
746                base_ref: None,
747            })
748        }
749        (None, None) => None,
750        _ => bail!("--worktree-repo and --branch must be provided together"),
751    };
752    let cmd = if command.is_empty() {
753        vec![
754            "sh".into(),
755            "-c".into(),
756            format!("echo lane {} started", record.id),
757        ]
758    } else {
759        command
760    };
761    let spec = LaneStartSpec {
762        command: cmd,
763        cwd,
764        environment,
765        log_proxy: (kind == RuntimeBackendKind::Tmux)
766            .then(std::env::current_exe)
767            .transpose()
768            .context("resolve current Codewhale executable for tmux log proxy")?,
769        worktree,
770    };
771    let backend = resolve_backend(kind);
772    backend.start(&reg, &mut record, &spec)?;
773    println!("started {}", record.id);
774    println!("status:  {}", record.status.as_str());
775    println!("runtime: {}", record.runtime.as_str());
776    println!("log:     {}", record.log_path.display());
777    if let Some(attach) = backend.attach_command(&record) {
778        println!("attach:  {attach}");
779    }
780    Ok(())
781}
782
783/// Print one shared control receipt on the CLI surface.
784///
785/// The CLI does not format Lane control results itself: it renders the same
786/// [`codewhale_lane::ControlReceipt`] the slash command and hotbar render, so
787/// the three surfaces cannot drift in what they report (#1888).
788fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
789    if json {
790        // v0.9.2 compatibility: `lane list --json` has always emitted an array
791        // of `LaneRecord`, and `lane status --json` a single one. Scripts
792        // select `.[].id`, `.worktree_path`, `.log_path` off that shape, so the
793        // receipt does not replace it. The receipt is what every other verb
794        // emits, and what the human renderer shows for these two.
795        match receipt.operation {
796            codewhale_lane::ControlOperation::LaneList => {
797                println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
798            }
799            codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
800                Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
801                // Legacy behaviour for an unknown id: `reg.load()` failed, so
802                // the command errored on stderr and printed *nothing* on
803                // stdout. Emitting a receipt (or a bare `null`) here would make
804                // `lane status --json <bad-id> | jq` succeed where it used to
805                // fail. Stay silent and let the bail! below set the exit code.
806                None if receipt.is_error() => {}
807                None => println!("{}", serde_json::to_string_pretty(receipt)?),
808            },
809            _ => println!("{}", serde_json::to_string_pretty(receipt)?),
810        }
811    } else if receipt.is_error() {
812        eprintln!("{}", receipt.render());
813    } else {
814        println!("{}", receipt.render());
815    }
816    if receipt.is_error() {
817        let detail = receipt
818            .failure
819            .as_ref()
820            .map(ToString::to_string)
821            .unwrap_or_else(|| receipt.outcome.as_str().to_string());
822        bail!("{}: {detail}", receipt.operation_id);
823    }
824    Ok(())
825}
826
827fn run_lane_control(
828    operation: codewhale_lane::ControlOperation,
829    lane_id: Option<&str>,
830    json: bool,
831) -> Result<()> {
832    let receipt = codewhale_lane::control::execute_lane_control(
833        codewhale_lane::ControlSurface::Cli,
834        operation,
835        lane_id,
836    );
837    emit_control_receipt(&receipt, json)
838}
839
840fn run_lane_command(args: LaneArgs) -> Result<()> {
841    use codewhale_lane::{ControlOperation, LaneRegistry, backend_for};
842    use std::io::{BufRead, Seek, Write};
843    use std::process::Command;
844    use std::thread;
845    use std::time::Duration;
846
847    match args.command {
848        LaneCommand::List { json } => run_lane_control(ControlOperation::LaneList, None, json),
849        LaneCommand::Status { lane_id, json } => {
850            run_lane_control(ControlOperation::LaneStatus, Some(&lane_id), json)
851        }
852        LaneCommand::Interrupt { lane_id, json } => {
853            run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
854        }
855        LaneCommand::Restart { lane_id, json } => {
856            run_lane_control(ControlOperation::LaneRestart, Some(&lane_id), json)
857        }
858        LaneCommand::Resume { lane_id, json } => {
859            run_lane_control(ControlOperation::LaneResume, Some(&lane_id), json)
860        }
861        LaneCommand::Attach { lane_id, print } => {
862            let reg = LaneRegistry::open_default()?;
863            let mut lane = reg.load(&lane_id)?;
864            let backend = backend_for(&lane);
865            backend.reconcile(&reg, &mut lane)?;
866            let Some(attach) = backend.attach_command(&lane) else {
867                if !lane.status.is_active() {
868                    bail!(
869                        "lane `{lane_id}` is {} and has no active attach target",
870                        lane.status.as_str()
871                    );
872                }
873                bail!(
874                    "lane `{lane_id}` runtime `{}` has no attach target",
875                    lane.runtime.as_str()
876                );
877            };
878            if print {
879                println!("{attach}");
880                return Ok(());
881            }
882            if let Some(session) = lane.tmux_session.as_deref() {
883                let socket = lane
884                    .tmux_socket
885                    .as_deref()
886                    .context("tmux lane is missing its pinned server socket")?;
887                let status = Command::new("tmux")
888                    .arg("-S")
889                    .arg(socket)
890                    .args(["attach", "-t", session])
891                    .status();
892                match status {
893                    Ok(s) if s.success() => Ok(()),
894                    Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
895                    Err(err) => {
896                        eprintln!("could not exec tmux: {err}");
897                        println!("{attach}");
898                        bail!("tmux attach unavailable");
899                    }
900                }
901            } else {
902                println!("{attach}");
903                Ok(())
904            }
905        }
906        LaneCommand::Logs {
907            lane_id,
908            follow,
909            tail,
910        } => {
911            let reg = LaneRegistry::open_default()?;
912            let lane = reg.load(&lane_id)?;
913            let path = lane.log_path;
914            if !path.exists() {
915                bail!("log file missing: {}", path.display());
916            }
917            let content = std::fs::read(&path)?;
918            let lines: Vec<&[u8]> = content
919                .split(|byte| *byte == b'\n')
920                .filter(|line| !line.is_empty())
921                .collect();
922            let start = lines.len().saturating_sub(tail);
923            let mut stdout = std::io::stdout().lock();
924            for line in &lines[start..] {
925                stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
926                stdout.write_all(b"\n")?;
927            }
928            stdout.flush()?;
929            if !follow {
930                return Ok(());
931            }
932            let mut file = std::fs::File::open(&path)?;
933            file.seek(std::io::SeekFrom::End(0))?;
934            let mut reader = std::io::BufReader::new(file);
935            loop {
936                let mut line = Vec::new();
937                match reader.read_until(b'\n', &mut line) {
938                    Ok(0) => {
939                        thread::sleep(Duration::from_millis(200));
940                        continue;
941                    }
942                    Ok(_) => {
943                        let mut stdout = std::io::stdout().lock();
944                        stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
945                        stdout.flush()?;
946                    }
947                    Err(err) => return Err(err.into()),
948                }
949            }
950        }
951        // `stop` is the historical spelling of `interrupt`. Both go through
952        // the same verb so the durable transition, the lifecycle fence, and
953        // the receipt are identical.
954        LaneCommand::Stop { lane_id } => {
955            run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), false)
956        }
957        LaneCommand::Start {
958            workflow,
959            fleet,
960            issue,
961            goal,
962            runtime,
963            worktree_repo,
964            branch,
965            worktree_path,
966            worktree_ttl_secs,
967            command,
968        } => start_lane(LaneStartRequest {
969            workflow,
970            fleet,
971            issue,
972            goal,
973            runtime,
974            worktree_repo,
975            branch,
976            worktree_path,
977            worktree_ttl_secs,
978            command,
979            environment: Vec::new(),
980            cwd: None,
981        }),
982    }
983}
984
985fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
986    let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
987        command: args.command,
988        log_path: args.log_path,
989        receipt_path: args.receipt_path,
990        receipt_tmp_path: args.receipt_tmp_path,
991        environment_path: args.environment_path,
992        lane_id: args.lane_id,
993    })?;
994    std::process::exit(exit_code);
995}
996
997fn run_workflow_command(
998    cli: &Cli,
999    resolved_runtime: &ResolvedRuntimeOptions,
1000    config_path: &Path,
1001    args: WorkflowArgs,
1002) -> Result<()> {
1003    match args.command {
1004        WorkflowCommand::Run {
1005            workflow,
1006            fleet,
1007            issue,
1008            goal,
1009            runtime,
1010            source_path,
1011            token_budget,
1012            verify,
1013            worktree_repo,
1014            branch,
1015            worktree_path,
1016            worktree_ttl_secs,
1017        } => {
1018            let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
1019            let source_path =
1020                resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
1021            validate_workflow_source_file(&source_path)?;
1022
1023            let source_root = if let Some(repo) = worktree_repo.as_deref() {
1024                repo.canonicalize()
1025                    .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
1026            } else {
1027                workspace.clone()
1028            };
1029
1030            // A fleet is an optional pin layer, not a requirement: role-only
1031            // tasks resolve against the built-in roster and the session route
1032            // (matching the TUI tool path). When a fleet IS given, it is
1033            // loaded and validated before the run starts.
1034            if let Some(name) = fleet.as_deref() {
1035                let roots = named_fleet_search_roots(&workspace);
1036                let loaded =
1037                    codewhale_workflow::load_named_fleet(name, &roots).with_context(|| {
1038                        format!("load fleet `{name}` from {}", display_roots(&roots))
1039                    })?;
1040                if workflow == "stopship" || name == "stopship" {
1041                    loaded
1042                        .validate_stopship_roles()
1043                        .with_context(|| format!("validate stopship roles in fleet `{name}`"))?;
1044                }
1045            }
1046
1047            let process = workflow_exec_command(WorkflowExecSpec {
1048                cli,
1049                resolved_runtime,
1050                config_path,
1051                source_root: &source_root,
1052                source_path: &source_path,
1053                workflow: &workflow,
1054                fleet: fleet.as_deref(),
1055                issue: issue.as_deref(),
1056                goal: goal.as_deref(),
1057                token_budget,
1058                verify,
1059            })?;
1060            start_lane(LaneStartRequest {
1061                workflow: Some(workflow),
1062                fleet,
1063                issue,
1064                goal,
1065                runtime,
1066                worktree_repo,
1067                branch,
1068                worktree_path,
1069                worktree_ttl_secs,
1070                command: process.command,
1071                environment: process.environment,
1072                cwd: Some(workspace),
1073            })
1074        }
1075    }
1076}
1077
1078fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
1079    if let Some(path) = explicit {
1080        return path
1081            .canonicalize()
1082            .with_context(|| format!("resolve workflow workspace {}", path.display()));
1083    }
1084    let cwd = std::env::current_dir().context("resolve current directory")?;
1085    let output = Command::new("git")
1086        .args(["rev-parse", "--show-toplevel"])
1087        .current_dir(&cwd)
1088        .output();
1089    if let Ok(output) = output
1090        && output.status.success()
1091    {
1092        let text = String::from_utf8_lossy(&output.stdout);
1093        let root = text.trim();
1094        if !root.is_empty() {
1095            let root = PathBuf::from(root);
1096            return Ok(root.canonicalize().unwrap_or(root));
1097        }
1098    }
1099    Ok(cwd)
1100}
1101
1102fn resolve_workflow_source_path(
1103    workflow: &str,
1104    source_path: Option<&PathBuf>,
1105    workspace: &Path,
1106) -> Result<PathBuf> {
1107    let candidates = workflow_source_candidates(workflow, source_path, workspace);
1108    for candidate in &candidates {
1109        if candidate.is_file() {
1110            return Ok(candidate.clone());
1111        }
1112    }
1113    bail!(
1114        "workflow source for `{workflow}` not found; tried {}",
1115        candidates
1116            .iter()
1117            .map(|p| p.display().to_string())
1118            .collect::<Vec<_>>()
1119            .join(", ")
1120    )
1121}
1122
1123fn workflow_source_candidates(
1124    workflow: &str,
1125    source_path: Option<&PathBuf>,
1126    workspace: &Path,
1127) -> Vec<PathBuf> {
1128    let mut candidates = Vec::new();
1129    if let Some(path) = source_path {
1130        candidates.push(resolve_against_workspace(path, workspace));
1131        return candidates;
1132    }
1133
1134    let raw = workflow.trim();
1135    let workflow_path = PathBuf::from(raw);
1136    if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1137        candidates.push(resolve_against_workspace(&workflow_path, workspace));
1138        return candidates;
1139    }
1140
1141    let normalized = raw.replace('-', "_");
1142    for rel in [
1143        format!("workflows/{raw}.workflow.js"),
1144        format!("workflows/{normalized}.workflow.js"),
1145    ] {
1146        let path = workspace.join(rel);
1147        if !candidates.iter().any(|existing| existing == &path) {
1148            candidates.push(path);
1149        }
1150    }
1151    candidates
1152}
1153
1154fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1155    if path.is_absolute() {
1156        path.to_path_buf()
1157    } else {
1158        workspace.join(path)
1159    }
1160}
1161
1162fn validate_workflow_source_file(path: &Path) -> Result<()> {
1163    let source =
1164        std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1165    if source.trim_start().starts_with("export default workflow(")
1166        || source.trim_start().starts_with("workflow(")
1167        || source.contains("\nworkflow(")
1168    {
1169        let identifier = path.display().to_string();
1170        if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1171            codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1172                .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1173        } else {
1174            codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1175                .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1176        }
1177    }
1178    Ok(())
1179}
1180
1181fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1182    let mut roots = Vec::new();
1183    if let Ok(home) = codewhale_config::codewhale_home() {
1184        roots.push(home);
1185    }
1186    roots.push(workspace.to_path_buf());
1187    roots
1188}
1189
1190fn display_roots(roots: &[PathBuf]) -> String {
1191    roots
1192        .iter()
1193        .map(|root| root.display().to_string())
1194        .collect::<Vec<_>>()
1195        .join(", ")
1196}
1197
1198struct WorkflowExecSpec<'a> {
1199    cli: &'a Cli,
1200    resolved_runtime: &'a ResolvedRuntimeOptions,
1201    config_path: &'a Path,
1202    source_root: &'a Path,
1203    source_path: &'a Path,
1204    workflow: &'a str,
1205    fleet: Option<&'a str>,
1206    issue: Option<&'a str>,
1207    goal: Option<&'a str>,
1208    token_budget: Option<u64>,
1209    verify: bool,
1210}
1211
1212struct WorkflowProcessSpec {
1213    command: Vec<String>,
1214    environment: Vec<(String, String)>,
1215}
1216
1217fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1218    let WorkflowExecSpec {
1219        cli,
1220        resolved_runtime,
1221        config_path,
1222        source_root,
1223        source_path,
1224        workflow,
1225        fleet,
1226        issue,
1227        goal,
1228        token_budget,
1229        verify,
1230    } = spec;
1231    let source_arg = source_path
1232        .strip_prefix(source_root)
1233        .with_context(|| {
1234            format!(
1235                "workflow source {} must be inside execution root {}",
1236                source_path.display(),
1237                source_root.display()
1238            )
1239        })?
1240        .display()
1241        .to_string();
1242    let mut payload = serde_json::json!({
1243        "action": "run",
1244        "source_path": source_arg,
1245        "fleet": fleet,
1246        "args": {
1247            "workflow": workflow,
1248            "fleet": fleet,
1249            "issue": issue,
1250            "goal": goal,
1251        },
1252        "verify": verify,
1253    });
1254    if let Some(token_budget) = token_budget {
1255        payload["token_budget"] = serde_json::json!(token_budget);
1256    }
1257    let input_json = serde_json::to_string(&payload)?;
1258    let passthrough = vec![
1259        "workflow-tool".to_string(),
1260        "--approval-source".to_string(),
1261        "explicit-workflow-command".to_string(),
1262        "--input-json".to_string(),
1263        input_json,
1264    ];
1265    let command =
1266        build_tui_command_with_paths(cli, resolved_runtime, passthrough, Some(config_path), None)?;
1267    lane_process_spec_from_command(&command)
1268}
1269
1270fn valid_lane_environment_key(key: &str) -> bool {
1271    let mut chars = key.chars();
1272    chars
1273        .next()
1274        .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1275        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1276}
1277
1278fn shell_owned_lane_environment(key: &str) -> bool {
1279    matches!(
1280        key,
1281        "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1282    )
1283}
1284
1285fn lane_process_spec_from_command(command: &Command) -> Result<WorkflowProcessSpec> {
1286    let mut argv = Vec::new();
1287    argv.push(command.get_program().to_string_lossy().into_owned());
1288    argv.extend(
1289        command
1290            .get_args()
1291            .map(|arg| arg.to_string_lossy().into_owned()),
1292    );
1293    let mut environment = std::collections::BTreeMap::new();
1294    for (key, value) in std::env::vars_os() {
1295        let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1296            continue;
1297        };
1298        if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1299            environment.insert(key.to_string(), value.to_string());
1300        }
1301    }
1302    for (key, value) in command.get_envs() {
1303        let key = key
1304            .to_str()
1305            .context("workflow runtime environment key is not UTF-8")?
1306            .to_string();
1307        if let Some(value) = value {
1308            environment.insert(
1309                key,
1310                value
1311                    .to_str()
1312                    .context("workflow runtime environment value is not UTF-8")?
1313                    .to_string(),
1314            );
1315        } else {
1316            environment.remove(&key);
1317        }
1318    }
1319    Ok(WorkflowProcessSpec {
1320        command: argv,
1321        environment: environment.into_iter().collect(),
1322    })
1323}
1324
1325/// Flags for `codewhale remote-setup`. Forwarded to the TUI binary, which owns
1326/// the interactive wizard and bundle generation.
1327#[derive(Debug, Args, Clone, Default)]
1328struct RemoteSetupArgs {
1329    /// Cloud target slug (lighthouse, azure, digitalocean). Skips the prompt.
1330    #[arg(long)]
1331    cloud: Option<String>,
1332    /// Chat bridge slug (feishu, telegram). Skips the prompt.
1333    #[arg(long)]
1334    bridge: Option<String>,
1335    /// Provider slug; validated against the provider registry. Skips the prompt.
1336    #[arg(long)]
1337    provider: Option<String>,
1338    /// Bundle output directory (default `./codewhale-deploy/<cloud>-<bridge>`).
1339    #[arg(long, value_name = "DIR")]
1340    out: Option<PathBuf>,
1341    /// Emit the bundle, do not provision (default).
1342    #[arg(long, default_value_t = false)]
1343    generate_only: bool,
1344    /// Run the cloud CLI to auto-provision (not yet implemented).
1345    #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
1346    apply: bool,
1347    /// Skip the final confirmation gate (CI / non-interactive).
1348    #[arg(long, default_value_t = false)]
1349    yes: bool,
1350    /// Fail instead of prompting if any required value is missing.
1351    #[arg(long, default_value_t = false)]
1352    non_interactive: bool,
1353}
1354
1355/// Build the forwarded argv for the TUI `remote-setup` subcommand from the
1356/// structured CLI flags. Mirrors the named flags exactly so the TUI clap parser
1357/// re-derives the same `RemoteSetupArgs`.
1358fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1359    let mut forwarded = vec!["remote-setup".to_string()];
1360    if let Some(cloud) = args.cloud {
1361        forwarded.push("--cloud".to_string());
1362        forwarded.push(cloud);
1363    }
1364    if let Some(bridge) = args.bridge {
1365        forwarded.push("--bridge".to_string());
1366        forwarded.push(bridge);
1367    }
1368    if let Some(provider) = args.provider {
1369        forwarded.push("--provider".to_string());
1370        forwarded.push(provider);
1371    }
1372    if let Some(out) = args.out {
1373        forwarded.push("--out".to_string());
1374        forwarded.push(out.to_string_lossy().into_owned());
1375    }
1376    if args.generate_only {
1377        forwarded.push("--generate-only".to_string());
1378    }
1379    if args.apply {
1380        forwarded.push("--apply".to_string());
1381    }
1382    if args.yes {
1383        forwarded.push("--yes".to_string());
1384    }
1385    if args.non_interactive {
1386        forwarded.push("--non-interactive".to_string());
1387    }
1388    forwarded
1389}
1390
1391#[derive(Debug, Args)]
1392struct LoginArgs {
1393    #[arg(long, value_enum, hide = true)]
1394    provider: Option<ProviderArg>,
1395    #[arg(long)]
1396    api_key: Option<String>,
1397}
1398
1399#[derive(Debug, Args)]
1400struct AuthArgs {
1401    #[command(subcommand)]
1402    command: AuthCommand,
1403}
1404
1405#[derive(Debug, Subcommand)]
1406enum AuthCommand {
1407    /// Sign in to xAI/Grok with an SSH-friendly device code.
1408    #[command(name = "xai-device")]
1409    XaiDevice,
1410    /// Explicitly allow read-only access to one credential file owned by
1411    /// another CLI. Managed mutation is currently unsupported and fails closed.
1412    #[command(name = "external-consent")]
1413    ExternalConsent {
1414        #[arg(long, value_enum)]
1415        provider: ProviderArg,
1416        #[arg(long, value_enum)]
1417        mode: ExternalCredentialModeArg,
1418        /// Exact credential file path. Defaults to the selected CLI's resolved
1419        /// path without probing whether the file exists.
1420        #[arg(long, value_name = "PATH")]
1421        path: Option<PathBuf>,
1422        /// Confirm the disclosed exact read-only grant without an interactive
1423        /// prompt. Required when stdin is not a terminal.
1424        #[arg(long, default_value_t = false)]
1425        yes: bool,
1426    },
1427    /// Revoke access to another CLI's credential file for one provider.
1428    #[command(name = "external-revoke")]
1429    ExternalRevoke {
1430        #[arg(long, value_enum)]
1431        provider: ProviderArg,
1432    },
1433    /// Show current provider and runtime-effective credential route state.
1434    /// Without `--provider`, shows all known providers.
1435    /// With `--provider`, shows detailed status for that provider.
1436    Status {
1437        /// Show status for a specific provider only.
1438        #[arg(long, value_enum)]
1439        provider: Option<ProviderArg>,
1440    },
1441    /// Save an API key to the shared user config file. Reads from
1442    /// `--api-key`, `--api-key-stdin`, or prompts on stdin when
1443    /// neither is given. Does not echo the key.
1444    Set {
1445        #[arg(long, value_enum)]
1446        provider: ProviderArg,
1447        /// Inline value (discouraged — appears in shell history).
1448        #[arg(long)]
1449        api_key: Option<String>,
1450        /// Read the key from stdin instead of prompting.
1451        #[arg(long = "api-key-stdin", default_value_t = false)]
1452        api_key_stdin: bool,
1453    },
1454    /// Report the effective credential route for a provider. Never prints a
1455    /// credential; reports the source layer or structural OAuth/repair state.
1456    Get {
1457        #[arg(long, value_enum)]
1458        provider: ProviderArg,
1459    },
1460    /// Pipe the runtime-effective API key to a local client; refuses terminals.
1461    PrintApiKey {
1462        #[arg(long, value_enum)]
1463        provider: ProviderArg,
1464    },
1465    /// Delete a provider's key from config and secret-store storage.
1466    Clear {
1467        #[arg(long, value_enum)]
1468        provider: ProviderArg,
1469    },
1470    /// List all known providers with their runtime-effective auth state,
1471    /// without revealing credentials.
1472    List,
1473    /// Advanced: migrate config-file keys into a platform credential store.
1474    #[command(hide = true)]
1475    Migrate {
1476        /// Don't actually write anything; print what would change.
1477        #[arg(long, default_value_t = false)]
1478        dry_run: bool,
1479    },
1480}
1481
1482#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1483enum ExternalCredentialModeArg {
1484    ReadOnly,
1485    Managed,
1486}
1487
1488#[derive(Debug, Args)]
1489struct ConfigArgs {
1490    #[command(subcommand)]
1491    command: ConfigCommand,
1492}
1493
1494#[derive(Debug, Subcommand)]
1495enum ConfigCommand {
1496    Get { key: String },
1497    Set { key: String, value: String },
1498    Unset { key: String },
1499    List,
1500    Path,
1501}
1502
1503#[derive(Debug, Args)]
1504struct ModelArgs {
1505    #[command(subcommand)]
1506    command: ModelCommand,
1507}
1508
1509#[derive(Debug, Subcommand)]
1510enum ModelCommand {
1511    List {
1512        #[arg(long, value_enum)]
1513        provider: Option<ProviderArg>,
1514    },
1515    Resolve {
1516        model: Option<String>,
1517        #[arg(long, value_enum)]
1518        provider: Option<ProviderArg>,
1519    },
1520    /// Set the default model (e.g. "pro", "flash", "deepseek-v4-pro").
1521    Set { model: String },
1522}
1523
1524#[derive(Debug, Args)]
1525struct ThreadArgs {
1526    #[command(subcommand)]
1527    command: ThreadCommand,
1528}
1529
1530#[derive(Debug, Subcommand)]
1531enum ThreadCommand {
1532    List {
1533        #[arg(long, default_value_t = false)]
1534        all: bool,
1535        #[arg(long)]
1536        limit: Option<usize>,
1537    },
1538    Read {
1539        thread_id: String,
1540    },
1541    Resume {
1542        thread_id: String,
1543    },
1544    Fork {
1545        thread_id: String,
1546    },
1547    Archive {
1548        thread_id: String,
1549    },
1550    Unarchive {
1551        thread_id: String,
1552    },
1553    SetName {
1554        thread_id: String,
1555        name: String,
1556    },
1557    /// Remove the custom name from a thread, restoring the default
1558    /// `(unnamed)` rendering in `thread list`.
1559    ClearName {
1560        thread_id: String,
1561    },
1562}
1563
1564#[derive(Debug, Args)]
1565struct SandboxArgs {
1566    #[command(subcommand)]
1567    command: SandboxCommand,
1568}
1569
1570#[derive(Debug, Subcommand)]
1571enum SandboxCommand {
1572    Check {
1573        command: String,
1574        #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1575        ask: ApprovalModeArg,
1576    },
1577}
1578
1579#[derive(Debug, Clone, Copy, ValueEnum)]
1580enum ApprovalModeArg {
1581    UnlessTrusted,
1582    OnFailure,
1583    OnRequest,
1584    Never,
1585}
1586
1587impl From<ApprovalModeArg> for AskForApproval {
1588    fn from(value: ApprovalModeArg) -> Self {
1589        match value {
1590            ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1591            ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1592            ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1593            ApprovalModeArg::Never => AskForApproval::Never,
1594        }
1595    }
1596}
1597
1598#[derive(Debug, Args)]
1599struct AppServerArgs {
1600    /// Serve the full HTTP/SSE runtime API (`/v1/*`: sessions, threads, turns,
1601    /// approvals, events, usage, fleet, tasks). This is the canonical runtime
1602    /// API surface; it delegates to the same server as `codewhale serve --http`.
1603    #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1604    http: bool,
1605    /// Serve the runtime API plus the phone-friendly mobile control page.
1606    /// Equivalent to the legacy `codewhale serve --mobile`.
1607    #[arg(long, conflicts_with = "stdio")]
1608    mobile: bool,
1609    /// Run the app-server JSON-RPC control transport over stdio (no listener).
1610    /// Used by local SDKs and JSON-RPC integrations.
1611    #[arg(long, default_value_t = false)]
1612    stdio: bool,
1613    /// Show a QR code for the mobile URL in the terminal (requires --mobile).
1614    #[arg(long, requires = "mobile")]
1615    qr: bool,
1616    /// Bind host. Defaults to 127.0.0.1; with --mobile and no host, binds
1617    /// 0.0.0.0 so LAN devices can reach the mobile page.
1618    #[arg(long)]
1619    host: Option<String>,
1620    /// Bind port. Defaults to 7878 for --http/--mobile (the runtime API) and
1621    /// 8787 for the legacy in-process app-server HTTP transport.
1622    #[arg(long)]
1623    port: Option<u16>,
1624    /// Background task worker count (1-8). Only used with --http/--mobile.
1625    #[arg(long)]
1626    workers: Option<usize>,
1627    #[arg(long)]
1628    config: Option<PathBuf>,
1629    #[arg(long = "auth-token")]
1630    auth_token: Option<String>,
1631    #[arg(long, default_value_t = false)]
1632    insecure_no_auth: bool,
1633    #[arg(long = "cors-origin")]
1634    cors_origin: Vec<String>,
1635}
1636
1637const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
1638
1639fn install_rustls_crypto_provider() {
1640    let _ = rustls::crypto::ring::default_provider().install_default();
1641}
1642
1643pub fn run_cli() -> std::process::ExitCode {
1644    install_rustls_crypto_provider();
1645
1646    match run() {
1647        Ok(()) => std::process::ExitCode::SUCCESS,
1648        Err(err) => {
1649            // Use the full anyhow chain so callers see the underlying
1650            // cause (e.g. the actual TOML parse error with line/column)
1651            // instead of just the top-level context message. The bare
1652            // `{err}` Display impl drops the chain — see #767, where
1653            // users hit "failed to parse config at <path>" with no
1654            // hint that the real error was a stray BOM or unbalanced
1655            // quote a few lines down.
1656            eprintln!("error: {err}");
1657            for cause in err.chain().skip(1) {
1658                eprintln!("  caused by: {cause}");
1659            }
1660            std::process::ExitCode::FAILURE
1661        }
1662    }
1663}
1664
1665fn split_lane_log_proxy_command(
1666    command: Option<Commands>,
1667) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
1668    match command {
1669        Some(Commands::LaneLogProxy(args)) => (Some(args), None),
1670        command => (None, command),
1671    }
1672}
1673
1674fn run() -> Result<()> {
1675    let mut cli = Cli::parse();
1676
1677    // The detached log proxy must not depend on user config parsing: its job
1678    // is to frame child output and publish a terminal receipt even when the
1679    // delegated command's own config is malformed.
1680    let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
1681    if let Some(args) = proxy {
1682        return run_lane_log_proxy_command(args);
1683    }
1684
1685    let pipe_api_key_handoff = matches!(
1686        &command,
1687        Some(Commands::Auth(AuthArgs {
1688            command: AuthCommand::PrintApiKey { .. }
1689        }))
1690    );
1691    if pipe_api_key_handoff {
1692        credential_handoff::prepare_stdout(io::stdout().is_terminal())?;
1693    }
1694    let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
1695    let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
1696    let runtime_overrides = CliRuntimeOverrides {
1697        provider: runtime_provider,
1698        model: cli.model.clone(),
1699        api_key: cli.api_key.clone(),
1700        base_url: cli.base_url.clone(),
1701        auth_mode: None,
1702        output_mode: cli.output_mode.clone(),
1703        log_level: cli.log_level.clone(),
1704        telemetry: cli.telemetry,
1705        approval_policy: cli.approval_policy.clone(),
1706        sandbox_mode: cli.sandbox_mode.clone(),
1707        yolo: Some(cli.yolo),
1708        verbosity: cli.verbosity.clone(),
1709    };
1710    if uses_raw_tui_provider
1711        && let Some((resolved_runtime, passthrough)) =
1712            prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
1713    {
1714        return delegate_to_tui(&cli, &resolved_runtime, passthrough);
1715    }
1716
1717    let mut store = ConfigStore::load(cli.config.clone()).map_err(|error| {
1718        if pipe_api_key_handoff {
1719            anyhow!("unavailable credential")
1720        } else {
1721            error
1722        }
1723    })?;
1724    match command {
1725        Some(Commands::Run(args)) => {
1726            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1727            delegate_to_tui(&cli, &resolved_runtime, args.args)
1728        }
1729        Some(Commands::Doctor(args)) => {
1730            let resolved_runtime =
1731                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1732            delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
1733        }
1734        Some(Commands::Models(args)) => {
1735            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1736            delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
1737        }
1738        Some(Commands::Speech(args)) => {
1739            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1740            delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
1741        }
1742        Some(Commands::Sessions(args)) => {
1743            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1744            delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
1745        }
1746        Some(Commands::Resume(args)) => {
1747            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1748            run_resume_command(&cli, &resolved_runtime, args)
1749        }
1750        Some(Commands::Rc(args)) => {
1751            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1752            let mut passthrough = vec!["--remote-control".to_string()];
1753            passthrough.extend(args.args);
1754            delegate_to_tui(&cli, &resolved_runtime, passthrough)
1755        }
1756        Some(Commands::Fork(args)) => {
1757            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1758            delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
1759        }
1760        Some(Commands::Init(args)) => {
1761            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1762            delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
1763        }
1764        Some(Commands::Setup(args)) => {
1765            let resolved_runtime = if setup_is_status_report(&args) {
1766                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
1767            } else {
1768                resolve_runtime_for_dispatch(&mut store, &runtime_overrides)
1769            };
1770            delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
1771        }
1772        Some(Commands::RemoteSetup(args)) => {
1773            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1774            delegate_to_tui(&cli, &resolved_runtime, remote_setup_tui_args(args))
1775        }
1776        Some(Commands::Exec(args)) => {
1777            reject_exec_global_flags(&args.args)?;
1778            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1779            delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
1780        }
1781        Some(Commands::Fleet(args)) => {
1782            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1783            delegate_to_tui(&cli, &resolved_runtime, tui_args("fleet", args))
1784        }
1785        Some(Commands::WorkflowTool(args)) => {
1786            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1787            delegate_to_tui(&cli, &resolved_runtime, tui_args("workflow-tool", args))
1788        }
1789        Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
1790        Some(Commands::Workflow(args)) => {
1791            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1792            let config_path = store.path().to_path_buf();
1793            run_workflow_command(&cli, &resolved_runtime, &config_path, args)
1794        }
1795        Some(Commands::Lane(args)) => run_lane_command(args),
1796        Some(Commands::Review(args)) => {
1797            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1798            delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
1799        }
1800        Some(Commands::Apply(args)) => {
1801            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1802            delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
1803        }
1804        Some(Commands::Eval(args)) => {
1805            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1806            delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
1807        }
1808        Some(Commands::Mcp(args)) => {
1809            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1810            delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
1811        }
1812        Some(Commands::Features(args)) => {
1813            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1814            delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
1815        }
1816        Some(Commands::Serve(args)) => {
1817            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1818            // `serve` starts a long-running runtime API listener; supervise the
1819            // delegated child so it is torn down with the dispatcher (#3259).
1820            delegate_server_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
1821        }
1822        Some(Commands::Web(args)) => {
1823            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1824            delegate_server_to_tui(&cli, &resolved_runtime, web_serve_passthrough(&args))
1825        }
1826        Some(Commands::Completions(args)) => {
1827            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1828            delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
1829        }
1830        Some(Commands::Login(args)) => run_login_command(&mut store, args),
1831        Some(Commands::Logout) => run_logout_command(&mut store),
1832        Some(Commands::Auth(args)) => match args.command {
1833            AuthCommand::XaiDevice => {
1834                let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1835                delegate_to_tui(
1836                    &cli,
1837                    &resolved_runtime,
1838                    vec!["auth".to_string(), "xai-device".to_string()],
1839                )
1840            }
1841            command => {
1842                let resolved_runtime =
1843                    resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1844                let session = start_cli_telemetry(
1845                    &resolved_runtime,
1846                    Some(store.path().to_path_buf()),
1847                    Surface::Cli,
1848                );
1849                let outcome =
1850                    run_auth_command_with_runtime(&mut store, command, &runtime_overrides);
1851                finish_cli_telemetry(session, &outcome);
1852                outcome
1853            }
1854        },
1855        Some(Commands::Account(args)) => {
1856            cloud::reject_inline_api_key(cli.api_key.as_deref())?;
1857            cloud::run(args, cli.profile.as_deref(), &store)
1858        }
1859        Some(Commands::McpServer) => {
1860            // `codewhale serve --mcp` delegates to the TUI and arms there, so
1861            // without this the same user action reported differently depending
1862            // on which spelling they typed — and `mcp-server`, a surface the
1863            // schema documents as emitting, could only ever read zero. A
1864            // structural zero a maintainer mistakes for an adoption zero is
1865            // the thing the "which surfaces emit" section exists to prevent.
1866            let resolved_runtime =
1867                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1868            let session = start_cli_telemetry(
1869                &resolved_runtime,
1870                Some(store.path().to_path_buf()),
1871                Surface::McpServer,
1872            );
1873            let outcome = run_mcp_server_command(&mut store);
1874            finish_cli_telemetry(session, &outcome);
1875            outcome
1876        }
1877        Some(Commands::Config(args)) => {
1878            let resolved_runtime =
1879                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1880            let session = start_cli_telemetry(
1881                &resolved_runtime,
1882                Some(store.path().to_path_buf()),
1883                Surface::Cli,
1884            );
1885            let outcome = run_config_command(&mut store, args.command);
1886            finish_cli_telemetry(session, &outcome);
1887            outcome
1888        }
1889        Some(Commands::Model(args)) => {
1890            // `model resolve` is a diagnostic: it must report the same route
1891            // the runtime would take, so it resolves through the same
1892            // read-only path `doctor` uses rather than looking only at flags.
1893            let resolved_runtime =
1894                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1895            run_model_command(
1896                &mut store,
1897                args.command,
1898                runtime_overrides.provider,
1899                &resolved_runtime,
1900            )
1901        }
1902        Some(Commands::Thread(args)) => {
1903            run_thread_command(&cli, &mut store, &runtime_overrides, args.command)
1904        }
1905        Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
1906        Some(Commands::AppServer(args)) => {
1907            // The HTTP/mobile runtime API is delegated to the mature `serve` path
1908            // in the TUI binary, which reads the *global* --config. app-server has
1909            // historically taken a subcommand-level --config, so bridge it before
1910            // resolving runtime options (provider/keyring) for the delegated run.
1911            if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
1912                cli.config = args.config.clone();
1913                store = ConfigStore::load(cli.config.clone())?;
1914            }
1915            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1916            run_app_server_command(&cli, &resolved_runtime, args)
1917        }
1918        Some(Commands::Completion { shell }) => {
1919            let mut cmd = Cli::command();
1920            generate(shell, &mut cmd, "codewhale", &mut io::stdout());
1921            Ok(())
1922        }
1923        Some(Commands::Metrics(args)) => run_metrics_command(args),
1924        Some(Commands::Update(args)) => {
1925            let resolved_runtime =
1926                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1927            let session = start_cli_telemetry(
1928                &resolved_runtime,
1929                Some(store.path().to_path_buf()),
1930                Surface::Cli,
1931            );
1932            #[cfg(not(target_env = "ohos"))]
1933            let outcome = update::run_update(args.beta, args.check, args.proxy);
1934            #[cfg(target_env = "ohos")]
1935            let outcome = {
1936                let _ = args;
1937                Err(anyhow!(
1938                    "self-update is not supported on HarmonyOS/OpenHarmony yet"
1939                ))
1940            };
1941            finish_cli_telemetry(session, &outcome);
1942            outcome
1943        }
1944        None => {
1945            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1946            let forwarded = root_tui_passthrough(&cli)?;
1947            delegate_to_tui(&cli, &resolved_runtime, forwarded)
1948        }
1949    }
1950}
1951
1952fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
1953    let mut forwarded = Vec::new();
1954    if cli.continue_session {
1955        forwarded.push("--continue".to_string());
1956    }
1957
1958    let prompt =
1959        cli.prompt_flag
1960            .iter()
1961            .chain(cli.prompt.iter())
1962            .fold(String::new(), |mut acc, part| {
1963                if !acc.is_empty() {
1964                    acc.push(' ');
1965                }
1966                acc.push_str(part);
1967                acc
1968            });
1969    if !prompt.is_empty() {
1970        if cli.continue_session {
1971            bail!(
1972                "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
1973            );
1974        }
1975        forwarded.push("--prompt".to_string());
1976        forwarded.push(prompt);
1977    }
1978
1979    Ok(forwarded)
1980}
1981
1982fn resolve_runtime_for_dispatch(
1983    store: &mut ConfigStore,
1984    runtime_overrides: &CliRuntimeOverrides,
1985) -> ResolvedRuntimeOptions {
1986    let runtime_secrets = Secrets::auto_detect();
1987    resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
1988}
1989
1990/// Resolve enough routing state to delegate a static diagnostic without
1991/// reading or migrating the durable secret store.
1992///
1993/// The TUI's doctor/setup-status path performs its own read-only source check,
1994/// so this dispatcher must not recover and export a credential merely to start
1995/// that report. Regular runtime and authentication commands keep using
1996/// [`resolve_runtime_for_dispatch`].
1997fn resolve_runtime_for_diagnostic_dispatch(
1998    store: &ConfigStore,
1999    runtime_overrides: &CliRuntimeOverrides,
2000) -> ResolvedRuntimeOptions {
2001    store.config.resolve_runtime_options(runtime_overrides)
2002}
2003
2004/// An armed telemetry session belonging to a subcommand that runs *in this
2005/// process*.
2006///
2007/// Existing at all is the permission: it is only ever constructed behind
2008/// [`TelemetryDecision::Enabled`], and the default state of every installation —
2009/// no notice answered — yields `None`.
2010struct CliTelemetrySession {
2011    started: std::time::Instant,
2012}
2013
2014/// Arm telemetry for a subcommand the dispatcher executes itself.
2015///
2016/// Only the terminal branches take this path. Everything that delegates to the
2017/// TUI binary is armed over there, under its own surface, from the environment
2018/// this dispatcher forwards — naming a surface here for a delegated command
2019/// would report one run twice under two identities.
2020///
2021/// The setup-state decision is an independent AND condition applied inside
2022/// [`telemetry::decide`]; a pre-existing `telemetry = true` is not consent,
2023/// because the key has been settable and inert for a long time.
2024fn start_cli_telemetry(
2025    resolved: &ResolvedRuntimeOptions,
2026    config_path: Option<PathBuf>,
2027    surface: Surface,
2028) -> Option<CliTelemetrySession> {
2029    let setup = SetupState::load().ok().flatten().unwrap_or_default();
2030    let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
2031        return None;
2032    };
2033    telemetry::init(consent.with_config_path(config_path));
2034    telemetry::record(Event::SessionStart {
2035        source: SessionSource::Unknown,
2036    });
2037    Some(CliTelemetrySession {
2038        started: std::time::Instant::now(),
2039    })
2040}
2041
2042/// Close the session opened by [`start_cli_telemetry`] and flush, bounded.
2043///
2044/// The exit class comes from what actually happened, never from an exit code:
2045/// a cancelled run and a SIGINT both exit 130, so a code-derived class would
2046/// mislabel every cancel as a signal.
2047///
2048/// The flush re-resolves telemetry from disk before it sends anything, which is
2049/// what makes `codewhale config set telemetry false` take effect on the very run
2050/// that wrote it rather than on the next one.
2051fn finish_cli_telemetry(session: Option<CliTelemetrySession>, outcome: &Result<()>) {
2052    let Some(session) = session else {
2053        return;
2054    };
2055    telemetry::set_exit_class(if outcome.is_ok() {
2056        ExitClass::Clean
2057    } else {
2058        ExitClass::Error
2059    });
2060    telemetry::record(Event::SessionEnd {
2061        duration_bucket: DurationBucket::from_secs(session.started.elapsed().as_secs()),
2062        exit_class: telemetry::exit_class(),
2063        // Cold start is measured by the TUI's startup trace. This surface has
2064        // no equivalent, and inventing one from process start would be a
2065        // different measurement wearing the same name.
2066        cold_start_bucket: None,
2067        providers: Vec::new(),
2068        counters: Counters::default(),
2069        errors: Errors::default(),
2070        turn_wall: TurnWall::default(),
2071    });
2072    let _ = telemetry::shutdown_blocking(telemetry::SHUTDOWN_FLUSH_TIMEOUT);
2073}
2074
2075fn resolve_runtime_for_dispatch_with_secrets(
2076    store: &mut ConfigStore,
2077    runtime_overrides: &CliRuntimeOverrides,
2078    secrets: &Secrets,
2079) -> ResolvedRuntimeOptions {
2080    store
2081        .config
2082        .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
2083}
2084
2085fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
2086    let mut forwarded = Vec::with_capacity(args.args.len() + 1);
2087    forwarded.push(command.to_string());
2088    forwarded.extend(args.args);
2089    forwarded
2090}
2091
2092fn setup_is_status_report(args: &TuiPassthroughArgs) -> bool {
2093    args.args.iter().any(|arg| arg == "--status")
2094}
2095
2096fn reject_exec_global_flags(args: &[String]) -> Result<()> {
2097    const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
2098
2099    for arg in args {
2100        if arg == "--" {
2101            break;
2102        }
2103        let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
2104        if GLOBAL_ONLY_FLAGS.contains(&flag) {
2105            bail!(
2106                "{flag} must be placed before `exec`.\n\nUse:\n  codewhale {flag} <value> exec \"<prompt>\""
2107            );
2108        }
2109    }
2110
2111    Ok(())
2112}
2113
2114fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
2115    run_login_command_with_secrets(store, args, &Secrets::auto_detect())
2116}
2117
2118fn run_login_command_with_secrets(
2119    store: &mut ConfigStore,
2120    args: LoginArgs,
2121    secrets: &Secrets,
2122) -> Result<()> {
2123    let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
2124    let api_key = match args.api_key {
2125        Some(v) => v,
2126        None => read_api_key_from_stdin()?,
2127    };
2128    let mut credential_store = credential_metadata_store(store)?;
2129    let store = credential_store.as_mut().unwrap_or(store);
2130    store.config.provider = provider;
2131
2132    let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
2133    let destination = if secret_store_saved {
2134        secrets.backend_name().to_string()
2135    } else {
2136        codewhale_config::quote_os_path(store.path())
2137    };
2138    if provider == ProviderKind::Deepseek {
2139        println!("logged in using API key mode (deepseek); saved key to {destination}");
2140    } else {
2141        println!(
2142            "logged in using API key mode ({}); saved key to {destination}",
2143            provider.as_str(),
2144        );
2145    }
2146    Ok(())
2147}
2148
2149fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
2150    run_logout_command_with_secrets(store, &Secrets::auto_detect())
2151}
2152
2153fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
2154    codewhale_config::with_xai_oauth_revocation_transaction(|| {
2155        run_logout_command_with_secrets_unlocked(store, secrets)
2156    })
2157}
2158
2159fn run_logout_command_with_secrets_unlocked(
2160    store: &mut ConfigStore,
2161    secrets: &Secrets,
2162) -> Result<()> {
2163    let original_config = store.config.clone();
2164    store.config.api_key = None;
2165    for provider in ProviderKind::ALL {
2166        clear_provider_api_key_from_config(store, provider);
2167        store
2168            .config
2169            .providers
2170            .for_provider_mut(provider)
2171            .external_credentials = None;
2172    }
2173    let xai = store.config.providers.for_provider_mut(ProviderKind::Xai);
2174    xai.oauth_credential_generation = None;
2175    xai.auth_mode = None;
2176    store.config.auth_mode = None;
2177    if let Err(error) = store.save() {
2178        store.config = original_config;
2179        return Err(error);
2180    }
2181    let keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
2182    if keyring_failures.is_empty() {
2183        println!("logged out");
2184    } else {
2185        eprintln!(
2186            "failed to delete stored credentials for: {}",
2187            keyring_failures.join(", ")
2188        );
2189        println!("logged out (some stored credentials could not be deleted)");
2190    }
2191    Ok(())
2192}
2193
2194/// Map [`ProviderKind`] to the canonical provider credential slot.
2195fn provider_slot(provider: ProviderKind) -> &'static str {
2196    // Shared-account families (SiliconFlow China, the four Model Studio
2197    // variants) collapse onto one slot; see ProviderKind::secret_store_slot.
2198    provider.secret_store_slot()
2199}
2200
2201/// Resolve the store for credential-adjacent writes: provider selection,
2202/// `auth_mode` markers, and the plaintext-free metadata that accompanies a
2203/// saved key.
2204///
2205/// Credentials and their metadata are user-global — a key saved while
2206/// working in one repo must be visible from every other repo, and the secret
2207/// store already is (#5045). When the ambient config path is a
2208/// workspace-scoped document (`<repo>/.codewhale/config.toml`), login and
2209/// `auth set` must not bind the provider or write auth markers there: the
2210/// binding would be invisible from every other repo and would invite
2211/// plaintext keys into a committable repo file (#5198). Returns a store
2212/// loaded on the user-global document in that case, or `None` when the
2213/// ambient store is already correctly scoped, so key + provider binding +
2214/// auth markers share one user-global scope by default.
2215fn credential_metadata_store(store: &ConfigStore) -> Result<Option<ConfigStore>> {
2216    if !codewhale_config::config_path_is_workspace_scoped(store.path()) {
2217        return Ok(None);
2218    }
2219    let global = codewhale_config::default_config_path()?;
2220    eprintln!(
2221        "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
2222        codewhale_config::quote_os_path(store.path()),
2223        codewhale_config::quote_os_path(&global),
2224    );
2225    ConfigStore::load(Some(global)).map(Some)
2226}
2227
2228#[cfg(test)]
2229fn no_keyring_secrets() -> Secrets {
2230    Secrets::new(std::sync::Arc::new(
2231        codewhale_secrets::InMemoryKeyringStore::new(),
2232    ))
2233}
2234
2235fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
2236    store.config.auth_mode = Some("api_key".to_string());
2237    let provider_config = store.config.providers.for_provider_mut(provider);
2238    provider_config.auth_mode = Some("api_key".to_string());
2239    provider_config.external_credentials = None;
2240    if provider == ProviderKind::Xai {
2241        provider_config.oauth_credential_generation = None;
2242    }
2243    if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() {
2244        store.config.default_text_model = Some(
2245            store
2246                .config
2247                .providers
2248                .deepseek
2249                .model
2250                .clone()
2251                .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
2252        );
2253    }
2254}
2255
2256/// Persist a provider credential to the durable secret store without silently
2257/// downgrading a backend failure to plaintext config storage.
2258fn persist_provider_api_key(
2259    store: &mut ConfigStore,
2260    secrets: &Secrets,
2261    provider: ProviderKind,
2262    api_key: &str,
2263) -> Result<bool> {
2264    if provider == ProviderKind::Xai {
2265        return codewhale_config::with_xai_oauth_revocation_transaction(|| {
2266            persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2267        });
2268    }
2269    persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2270}
2271
2272fn persist_provider_api_key_unlocked(
2273    store: &mut ConfigStore,
2274    secrets: &Secrets,
2275    provider: ProviderKind,
2276    api_key: &str,
2277) -> Result<bool> {
2278    let original_config = store.config.clone();
2279    prepare_provider_api_key_metadata(store, provider);
2280    let slot = provider_slot(provider);
2281    // A readable prior value is required before a secret-store write so a
2282    // later config failure can restore the exact prior state. If the backend
2283    // cannot provide that snapshot, fail before changing the config file.
2284    let prior_secret = secrets.get(slot);
2285    let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
2286        Ok(_) => match secrets.set(slot, api_key) {
2287            Ok(()) => {
2288                clear_provider_api_key_from_config(store, provider);
2289                true
2290            }
2291            Err(err) => {
2292                store.config = original_config;
2293                return Err(anyhow::anyhow!(
2294                    "Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
2295                    codewhale_config::quote_os_path(store.path())
2296                ));
2297            }
2298        },
2299        Err(error) => {
2300            store.config = original_config;
2301            return Err(anyhow::anyhow!(
2302                "Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
2303                codewhale_config::quote_os_path(store.path())
2304            ));
2305        }
2306    };
2307    if let Err(error) = store.save() {
2308        store.config = original_config;
2309        if secret_store_saved {
2310            let current = secrets
2311                .get(slot)
2312                .map_err(|rollback| anyhow::anyhow!(
2313                    "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
2314                ))?;
2315            if current.as_deref() == Some(api_key) {
2316                match prior_secret.expect("snapshot succeeded before secret write") {
2317                    Some(previous) => secrets.set(slot, &previous),
2318                    None => secrets.delete(slot),
2319                }
2320                .map_err(|rollback| anyhow::anyhow!(
2321                    "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
2322                ))?;
2323            }
2324        }
2325        return Err(error);
2326    }
2327    codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
2328    Ok(secret_store_saved)
2329}
2330
2331fn clear_auth_provider(
2332    store: &mut ConfigStore,
2333    secrets: &Secrets,
2334    provider: ProviderKind,
2335) -> Result<()> {
2336    let slot = provider_slot(provider);
2337    let original_config = store.config.clone();
2338    clear_provider_api_key_from_config(store, provider);
2339    if provider == ProviderKind::Xai {
2340        let xai = store.config.providers.for_provider_mut(provider);
2341        xai.oauth_credential_generation = None;
2342        xai.auth_mode = None;
2343        xai.external_credentials = None;
2344    }
2345    if let Err(error) = store.save() {
2346        store.config = original_config;
2347        return Err(error);
2348    }
2349    clear_provider_api_key_from_keyring(secrets, provider);
2350    if provider == ProviderKind::Xai {
2351        println!("cleared xAI credentials from config, secret store, and owned OAuth storage");
2352    } else {
2353        println!("cleared API key for {slot} from config and secret store");
2354    }
2355    Ok(())
2356}
2357
2358fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
2359    store.config.providers.for_provider_mut(provider).api_key = None;
2360    if provider == ProviderKind::Deepseek {
2361        store.config.api_key = None;
2362    }
2363}
2364
2365fn provider_env_set(provider: ProviderKind) -> bool {
2366    provider_env_value(provider).is_some()
2367}
2368
2369fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
2370    provider.provider().env_vars()
2371}
2372
2373fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
2374    provider_env_vars(provider).iter().find_map(|var| {
2375        std::env::var(var)
2376            .ok()
2377            .filter(|value| !value.trim().is_empty())
2378            .map(|value| (*var, value))
2379    })
2380}
2381
2382fn openai_codex_auth_file_path() -> PathBuf {
2383    if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
2384        let path = PathBuf::from(path);
2385        if !path.as_os_str().is_empty() {
2386            return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2387        }
2388    }
2389
2390    let codex_home = std::env::var("CODEX_HOME")
2391        .map(PathBuf::from)
2392        .unwrap_or_else(|_| {
2393            dirs::home_dir()
2394                .unwrap_or_else(|| PathBuf::from("."))
2395                .join(".codex")
2396        });
2397    let path = codex_home.join("auth.json");
2398    codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2399}
2400
2401fn grok_auth_file_path() -> PathBuf {
2402    for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
2403        if let Ok(path) = std::env::var(key) {
2404            let path = PathBuf::from(path.trim());
2405            if !path.as_os_str().is_empty() {
2406                return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2407            }
2408        }
2409    }
2410    if let Ok(home) = std::env::var("GROK_HOME") {
2411        let home = PathBuf::from(home.trim());
2412        if !home.as_os_str().is_empty() {
2413            let path = home.join("auth.json");
2414            return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2415        }
2416    }
2417    let path = dirs::home_dir()
2418        .unwrap_or_else(|| PathBuf::from("."))
2419        .join(".grok")
2420        .join("auth.json");
2421    codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2422}
2423
2424fn external_credential_target(
2425    provider: ProviderKind,
2426    path_override: Option<PathBuf>,
2427) -> Result<(codewhale_config::ExternalCredentialSource, PathBuf)> {
2428    let (source, default_path) = match provider {
2429        ProviderKind::OpenaiCodex => (
2430            codewhale_config::ExternalCredentialSource::CodexCli,
2431            openai_codex_auth_file_path(),
2432        ),
2433        ProviderKind::Xai => (
2434            codewhale_config::ExternalCredentialSource::GrokCli,
2435            grok_auth_file_path(),
2436        ),
2437        ProviderKind::Moonshot => bail!(
2438            "Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported."
2439        ),
2440        _ => bail!(
2441            "{} has no supported external CLI credential source",
2442            provider.as_str()
2443        ),
2444    };
2445    let path =
2446        codewhale_config::resolve_external_credential_path(path_override.unwrap_or(default_path))?;
2447    Ok((source, path))
2448}
2449
2450fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
2451    let slot = store
2452        .config
2453        .providers
2454        .for_provider(provider)
2455        .api_key
2456        .as_deref();
2457    let root = (provider == ProviderKind::Deepseek)
2458        .then_some(store.config.api_key.as_deref())
2459        .flatten();
2460    slot.or(root)
2461        .filter(|value| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal)
2462}
2463
2464fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
2465    provider_config_api_key(store, provider).is_some()
2466}
2467
2468fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
2469    secrets
2470        .get(provider_slot(provider))
2471        .ok()
2472        .flatten()
2473        .filter(|v| !v.trim().is_empty())
2474}
2475
2476fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
2477    provider_keyring_api_key(secrets, provider).is_some()
2478}
2479
2480fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
2481    let _ = secrets.delete(provider_slot(provider));
2482}
2483
2484/// Delete the keyring credential of every provider that has one stored.
2485///
2486/// Returns a human-readable entry per slot whose deletion failed, so the
2487/// caller can report the failure instead of claiming a clean logout while
2488/// credentials linger in the keyring. Slots shared by several providers
2489/// (e.g. the historical `siliconflow` slot) are deleted once.
2490fn clear_all_provider_api_keys_from_keyring(secrets: &Secrets) -> Vec<String> {
2491    let mut failures = Vec::new();
2492    let mut cleared_slots = std::collections::HashSet::new();
2493    for provider in ProviderKind::ALL {
2494        let slot = provider_slot(provider);
2495        if !cleared_slots.insert(slot) {
2496            continue;
2497        }
2498        if !provider_keyring_set(secrets, provider) {
2499            continue;
2500        }
2501        if let Err(error) = secrets.delete(slot) {
2502            failures.push(format!("{slot}: {error}"));
2503        }
2504    }
2505    failures
2506}
2507
2508fn external_consent(
2509    store: &ConfigStore,
2510    provider: ProviderKind,
2511) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
2512    store
2513        .config
2514        .providers
2515        .for_provider(provider)
2516        .external_credentials
2517        .as_ref()
2518}
2519
2520fn external_read_consent(
2521    store: &ConfigStore,
2522    provider: ProviderKind,
2523) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
2524    let (source, expected_path) = external_credential_target(provider, None).ok()?;
2525    external_consent(store, provider)
2526        .filter(|consent| consent.read_grant(provider, source, &expected_path).is_ok())
2527}
2528
2529fn external_oauth_selected(store: &ConfigStore, provider: ProviderKind) -> bool {
2530    if external_read_consent(store, provider).is_none() {
2531        return false;
2532    }
2533    if provider == ProviderKind::OpenaiCodex {
2534        return true;
2535    }
2536    provider == ProviderKind::Xai
2537        && xai_oauth_mode_selected(store.config.providers.xai.auth_mode.as_deref())
2538}
2539
2540#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2541enum XaiOAuthGenerationPointer {
2542    Absent,
2543    Valid,
2544    Invalid,
2545}
2546
2547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2548enum XaiAuthDiagnosticRoute {
2549    /// Normal API-key diagnostics apply. This includes custom endpoints, where
2550    /// xAI OAuth is intentionally inactive.
2551    ApiKey,
2552    /// A syntactically valid Codewhale-owned generation pointer selects the
2553    /// owned OAuth route. Diagnostics deliberately do not inspect the file.
2554    OwnedOAuth,
2555    /// A configured but unsafe/malformed generation pointer blocks external
2556    /// Grok CLI access. The runtime can still fall back to API-key sources.
2557    NeedsRepair,
2558    /// With no configured generation, an exact read-only Grok CLI consent can
2559    /// be selected structurally. The external file is never probed here.
2560    ExternalConsent,
2561}
2562
2563#[derive(Debug, Clone)]
2564struct XaiAuthDiagnostics {
2565    base_url: String,
2566    official_endpoint: bool,
2567    auth_mode: Option<String>,
2568    oauth_selected: bool,
2569    generation: XaiOAuthGenerationPointer,
2570    route: XaiAuthDiagnosticRoute,
2571}
2572
2573impl XaiAuthDiagnostics {
2574    /// API-key routes are reported from the same endpoint-bound resolver that
2575    /// dispatch uses. Owned OAuth and consent-only routes remain structural so
2576    /// diagnostics cannot turn into a credential-store probe.
2577    fn evaluates_runtime_api_key(&self) -> bool {
2578        matches!(
2579            self.route,
2580            XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::NeedsRepair
2581        )
2582    }
2583
2584    fn is_custom_endpoint(&self) -> bool {
2585        !self.official_endpoint
2586    }
2587}
2588
2589/// Source and redacted tail from the shared runtime resolver. Keeping only a
2590/// redacted tail prevents the presentation layer from accidentally retaining a
2591/// plaintext credential after it has derived the effective route.
2592#[derive(Debug, Clone, Default)]
2593struct XaiRuntimeApiKey {
2594    source: Option<RuntimeApiKeySource>,
2595    last4: Option<String>,
2596}
2597
2598impl XaiRuntimeApiKey {
2599    fn source_name(&self) -> Option<&'static str> {
2600        match self.source {
2601            Some(RuntimeApiKeySource::Cli) => Some("cli"),
2602            Some(RuntimeApiKeySource::ConfigFile) => Some("config"),
2603            Some(RuntimeApiKeySource::Keyring) => Some("secret store"),
2604            Some(RuntimeApiKeySource::Env) => Some("env"),
2605            None => None,
2606        }
2607    }
2608
2609    fn source_with_last4(&self) -> Option<String> {
2610        self.source_name()
2611            .map(|source| match self.last4.as_deref() {
2612                Some(last4) => format!("{source} (last4: {last4})"),
2613                None => source.to_string(),
2614            })
2615    }
2616
2617    fn uses(&self, source: RuntimeApiKeySource) -> bool {
2618        self.source == Some(source)
2619    }
2620}
2621
2622fn runtime_overrides_for_provider(
2623    runtime_overrides: &CliRuntimeOverrides,
2624    provider: ProviderKind,
2625) -> CliRuntimeOverrides {
2626    let mut overrides = runtime_overrides.clone();
2627    overrides.provider = Some(provider);
2628    overrides
2629}
2630
2631fn xai_oauth_mode_selected(auth_mode: Option<&str>) -> bool {
2632    auth_mode.is_some_and(|mode| {
2633        matches!(
2634            mode.trim()
2635                .to_ascii_lowercase()
2636                .replace(['-', ' '], "_")
2637                .as_str(),
2638            "oauth"
2639                | "xai_oauth"
2640                | "xai"
2641                | "grok"
2642                | "grok_oauth"
2643                | "grok_cli"
2644                | "device"
2645                | "device_code"
2646                | "device_auth"
2647        )
2648    })
2649}
2650
2651fn xai_oauth_generation_pointer(store: &ConfigStore) -> XaiOAuthGenerationPointer {
2652    match store
2653        .config
2654        .providers
2655        .xai
2656        .oauth_credential_generation
2657        .as_deref()
2658    {
2659        None => XaiOAuthGenerationPointer::Absent,
2660        Some(generation) if codewhale_config::is_valid_xai_oauth_generation(generation) => {
2661            XaiOAuthGenerationPointer::Valid
2662        }
2663        Some(_) => XaiOAuthGenerationPointer::Invalid,
2664    }
2665}
2666
2667/// Resolve the same xAI route facts the runtime uses, without asking the
2668/// durable credential store for a secret. `ConfigToml::resolve_runtime_options`
2669/// deliberately uses an in-memory store, so this is safe for diagnostic output
2670/// that must remain structural/non-probing.
2671fn xai_auth_diagnostics(
2672    store: &ConfigStore,
2673    runtime_overrides: &CliRuntimeOverrides,
2674) -> XaiAuthDiagnostics {
2675    // We only need the effective endpoint here. Suppressing API-key
2676    // resolution keeps valid-owned and consent-only diagnostics structural:
2677    // they must not read ambient credential state merely to describe a route.
2678    let mut route_overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai);
2679    route_overrides.api_key = None;
2680    route_overrides.auth_mode = Some("none".to_string());
2681    let resolved = store.config.resolve_runtime_options(&route_overrides);
2682    let official_endpoint =
2683        provider_base_url_is_official(ProviderKind::Xai, resolved.base_url.as_str());
2684    // The TUI activates xAI OAuth only from `[providers.xai] auth_mode`; a
2685    // root-level auth mode may influence generic API-key policy but must never
2686    // turn an inert xAI generation pointer into an OAuth route.
2687    let auth_mode = store.config.providers.xai.auth_mode.clone();
2688    let generation = xai_oauth_generation_pointer(store);
2689    let oauth_selected = xai_oauth_mode_selected(auth_mode.as_deref());
2690    let route = if !official_endpoint || !oauth_selected {
2691        XaiAuthDiagnosticRoute::ApiKey
2692    } else {
2693        match generation {
2694            XaiOAuthGenerationPointer::Valid => XaiAuthDiagnosticRoute::OwnedOAuth,
2695            XaiOAuthGenerationPointer::Invalid => XaiAuthDiagnosticRoute::NeedsRepair,
2696            XaiOAuthGenerationPointer::Absent
2697                if external_read_consent(store, ProviderKind::Xai).is_some() =>
2698            {
2699                XaiAuthDiagnosticRoute::ExternalConsent
2700            }
2701            XaiOAuthGenerationPointer::Absent => XaiAuthDiagnosticRoute::ApiKey,
2702        }
2703    };
2704
2705    XaiAuthDiagnostics {
2706        base_url: resolved.base_url,
2707        official_endpoint,
2708        auth_mode,
2709        oauth_selected,
2710        generation,
2711        route,
2712    }
2713}
2714
2715/// Return the API-key route exactly as the dispatcher would resolve it. This
2716/// is the critical distinction for a global `--base-url` or `XAI_BASE_URL`:
2717/// official-provider config, keyring, and ambient keys must not cross onto an
2718/// unrelated custom endpoint.
2719fn xai_runtime_api_key(
2720    store: &ConfigStore,
2721    secrets: &Secrets,
2722    runtime_overrides: &CliRuntimeOverrides,
2723) -> XaiRuntimeApiKey {
2724    let resolved = store.config.resolve_runtime_options_with_secrets(
2725        &runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
2726        secrets,
2727    );
2728    debug_assert_eq!(resolved.provider, ProviderKind::Xai);
2729    XaiRuntimeApiKey {
2730        source: resolved.api_key_source,
2731        last4: resolved.api_key.as_deref().map(last4_label),
2732    }
2733}
2734
2735fn api_key_source_name(
2736    config_key: Option<&str>,
2737    keyring_key: Option<&str>,
2738    env_key: Option<&(&'static str, String)>,
2739) -> Option<&'static str> {
2740    if config_key.is_some() {
2741        Some("config")
2742    } else if keyring_key.is_some() {
2743        Some("secret store")
2744    } else if env_key.is_some() {
2745        Some("env")
2746    } else {
2747        None
2748    }
2749}
2750
2751fn xai_status_summary_source(
2752    diagnostics: &XaiAuthDiagnostics,
2753    api_key: Option<&XaiRuntimeApiKey>,
2754) -> String {
2755    match diagnostics.route {
2756        XaiAuthDiagnosticRoute::OwnedOAuth => {
2757            "Codewhale-owned OAuth configured/unprobed (valid generation pointer)".to_string()
2758        }
2759        XaiAuthDiagnosticRoute::NeedsRepair => {
2760            let api_key = api_key
2761                .and_then(XaiRuntimeApiKey::source_name)
2762                .unwrap_or("no runtime-effective API key");
2763            format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
2764        }
2765        XaiAuthDiagnosticRoute::ExternalConsent => {
2766            "external consent configured/unprobed".to_string()
2767        }
2768        XaiAuthDiagnosticRoute::ApiKey => api_key
2769            .and_then(XaiRuntimeApiKey::source_name)
2770            .unwrap_or("unset")
2771            .to_string(),
2772    }
2773}
2774
2775fn xai_credential_route_label(
2776    diagnostics: &XaiAuthDiagnostics,
2777    api_key: Option<&XaiRuntimeApiKey>,
2778) -> String {
2779    match diagnostics.route {
2780        XaiAuthDiagnosticRoute::OwnedOAuth => {
2781            "Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
2782                .to_string()
2783        }
2784        XaiAuthDiagnosticRoute::NeedsRepair => {
2785            let api_key = api_key
2786                .and_then(XaiRuntimeApiKey::source_with_last4)
2787                .unwrap_or_else(|| "no runtime-effective API key".to_string());
2788            format!(
2789                "xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
2790            )
2791        }
2792        XaiAuthDiagnosticRoute::ExternalConsent => {
2793            "external read-only consent configured/unprobed".to_string()
2794        }
2795        XaiAuthDiagnosticRoute::ApiKey => api_key
2796            .and_then(XaiRuntimeApiKey::source_with_last4)
2797            .unwrap_or_else(|| "missing".to_string()),
2798    }
2799}
2800
2801fn xai_table_storage_status(
2802    api_key: Option<&XaiRuntimeApiKey>,
2803    source: RuntimeApiKeySource,
2804) -> &'static str {
2805    match api_key {
2806        Some(api_key) if api_key.uses(source) => "set",
2807        Some(_) => "-",
2808        // The selected structural OAuth/consent route intentionally does not
2809        // establish whether any API-key storage is populated.
2810        None => "unprobed",
2811    }
2812}
2813
2814fn xai_list_storage_status(
2815    api_key: Option<&XaiRuntimeApiKey>,
2816    source: RuntimeApiKeySource,
2817) -> &'static str {
2818    match api_key {
2819        Some(api_key) if api_key.uses(source) => "yes",
2820        Some(_) => "no",
2821        None => "?",
2822    }
2823}
2824
2825fn xai_list_route(
2826    diagnostics: &XaiAuthDiagnostics,
2827    api_key: Option<&XaiRuntimeApiKey>,
2828) -> &'static str {
2829    match diagnostics.route {
2830        XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
2831        XaiAuthDiagnosticRoute::NeedsRepair => "needs-repair",
2832        XaiAuthDiagnosticRoute::ExternalConsent => "external-consent-configured",
2833        XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(|api_key| api_key.source) {
2834            Some(RuntimeApiKeySource::Cli) => "cli",
2835            Some(RuntimeApiKeySource::ConfigFile) => "config",
2836            Some(RuntimeApiKeySource::Keyring) => "store",
2837            Some(RuntimeApiKeySource::Env) => "env",
2838            None => "missing",
2839        },
2840    }
2841}
2842
2843fn xai_storage_detail(
2844    diagnostics: &XaiAuthDiagnostics,
2845    api_key: Option<&XaiRuntimeApiKey>,
2846    source: RuntimeApiKeySource,
2847) -> String {
2848    match api_key {
2849        Some(api_key) if api_key.uses(source) => api_key
2850            .last4
2851            .as_deref()
2852            .map(|last4| format!("runtime-effective, last4: {last4}"))
2853            .unwrap_or_else(|| "runtime-effective".to_string()),
2854        Some(_) if diagnostics.is_custom_endpoint() => {
2855            "not eligible for this custom xAI endpoint".to_string()
2856        }
2857        Some(_) => "not selected by the runtime resolver".to_string(),
2858        None if diagnostics.evaluates_runtime_api_key() && diagnostics.is_custom_endpoint() => {
2859            "not eligible for this custom xAI endpoint".to_string()
2860        }
2861        None if diagnostics.evaluates_runtime_api_key() => {
2862            "not set for this runtime route".to_string()
2863        }
2864        None => "unprobed (structural OAuth/consent route)".to_string(),
2865    }
2866}
2867
2868fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
2869    match diagnostics.route {
2870        XaiAuthDiagnosticRoute::OwnedOAuth => {
2871            "lookup order: configured Codewhale-owned OAuth generation (storage unprobed); Grok CLI consent blocked".to_string()
2872        }
2873        XaiAuthDiagnosticRoute::NeedsRepair => {
2874            "lookup order: invalid Codewhale-owned OAuth generation blocks Grok CLI consent; runtime-effective API-key fallback: CLI -> config -> secret store -> env".to_string()
2875        }
2876        XaiAuthDiagnosticRoute::ExternalConsent => {
2877            "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)".to_string()
2878        }
2879        XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
2880            "lookup order: endpoint-bound API key only for this custom xAI endpoint (explicit CLI key or route-bound config key)".to_string()
2881        }
2882        XaiAuthDiagnosticRoute::ApiKey => {
2883            "lookup order: CLI -> config -> secret store -> env".to_string()
2884        }
2885    }
2886}
2887
2888fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
2889    match diagnostics.route {
2890        XaiAuthDiagnosticRoute::OwnedOAuth => {
2891            "xai: configured (source: Codewhale-owned OAuth generation; valid pointer; storage unprobed)".to_string()
2892        }
2893        XaiAuthDiagnosticRoute::NeedsRepair => {
2894            let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
2895                Some("config") => "config-file".to_string(),
2896                Some("secret store") => "secret-store".to_string(),
2897                Some("env") => "env".to_string(),
2898                Some("cli") => "cli".to_string(),
2899                Some(other) => other.to_string(),
2900                None => "no runtime-effective API key".to_string(),
2901            };
2902            format!(
2903                "xai: needs repair (invalid Codewhale-owned OAuth generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
2904            )
2905        }
2906        XaiAuthDiagnosticRoute::ExternalConsent => {
2907            "xai: configured (source: external read-only consent; availability unprobed)".to_string()
2908        }
2909        XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
2910                Some("config") => "xai: set (source: config-file)".to_string(),
2911                Some("secret store") => "xai: set (source: secret-store)".to_string(),
2912                Some("env") => "xai: set (source: env)".to_string(),
2913                Some("cli") => "xai: set (source: cli)".to_string(),
2914                Some(other) => format!("xai: set (source: {other})"),
2915                None => "xai: not set".to_string(),
2916            },
2917    }
2918}
2919
2920fn auth_get_line_with_runtime(
2921    store: &ConfigStore,
2922    secrets: &Secrets,
2923    provider: ProviderKind,
2924    runtime_overrides: &CliRuntimeOverrides,
2925) -> String {
2926    let slot = provider_slot(provider);
2927    if provider == ProviderKind::Xai {
2928        let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
2929        let api_key = diagnostics
2930            .evaluates_runtime_api_key()
2931            .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
2932        return xai_get_line(&diagnostics, api_key.as_ref());
2933    }
2934
2935    let config_key = provider_config_api_key(store, provider);
2936    let keyring_key = config_key
2937        .is_none()
2938        .then(|| provider_keyring_api_key(secrets, provider))
2939        .flatten();
2940    let env_key = provider_env_value(provider);
2941
2942    match api_key_source_name(config_key, keyring_key.as_deref(), env_key.as_ref()) {
2943        Some("config") => format!("{slot}: set (source: config-file)"),
2944        Some("secret store") => format!("{slot}: set (source: secret-store)"),
2945        Some("env") => format!("{slot}: set (source: env)"),
2946        Some(other) => format!("{slot}: set (source: {other})"),
2947        None => format!("{slot}: not set"),
2948    }
2949}
2950
2951#[cfg(test)]
2952fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
2953    auth_status_all_providers_with_runtime(store, secrets, &CliRuntimeOverrides::default())
2954}
2955
2956fn auth_status_all_providers_with_runtime(
2957    store: &ConfigStore,
2958    secrets: &Secrets,
2959    runtime_overrides: &CliRuntimeOverrides,
2960) -> Vec<String> {
2961    let active_provider = store.config.provider;
2962    let mut lines = Vec::new();
2963    lines.push(format!(
2964        "active provider: {} (set via config or CODEWHALE_PROVIDER)",
2965        active_provider.as_str()
2966    ));
2967    lines.push(String::new());
2968    lines.push(format!(
2969        "{:<14} {:<8} {:<10} {:<8} {}",
2970        "provider", "config", "keyring", "env", "status"
2971    ));
2972    lines.push("-".repeat(70));
2973
2974    for provider in ProviderKind::ALL {
2975        if provider == ProviderKind::Xai {
2976            let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
2977            let api_key = diagnostics
2978                .evaluates_runtime_api_key()
2979                .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
2980            let active_marker = if provider == active_provider {
2981                " *"
2982            } else {
2983                ""
2984            };
2985            lines.push(format!(
2986                "{:<14} {:<8} {:<10} {:<8} {}{}",
2987                provider.as_str(),
2988                xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
2989                xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
2990                xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
2991                xai_status_summary_source(&diagnostics, api_key.as_ref()),
2992                active_marker
2993            ));
2994            continue;
2995        }
2996
2997        let config_key = provider_config_api_key(store, provider);
2998        let keyring_key = provider_keyring_api_key(secrets, provider);
2999        let env_key = provider_env_value(provider);
3000        let external_selected = external_oauth_selected(store, provider);
3001
3002        let config_status = config_key.map(|_| "set").unwrap_or("-");
3003        let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
3004        let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
3005
3006        let source = if provider == ProviderKind::OpenaiCodex {
3007            // Keep the summary consistent with `auth status`: Codex auth is
3008            // OAuth-file (or env token) based — config/keyring keys are not
3009            // consulted for it.
3010            if env_key.is_some() {
3011                "env".to_string()
3012            } else if external_selected {
3013                "external consent (not probed)".to_string()
3014            } else {
3015                "unset".to_string()
3016            }
3017        } else if external_selected {
3018            "external consent (not probed)".to_string()
3019        } else if config_key.is_some() {
3020            "config".to_string()
3021        } else if keyring_key.is_some() {
3022            "keyring".to_string()
3023        } else if env_key.is_some() {
3024            "env".to_string()
3025        } else {
3026            "unset".to_string()
3027        };
3028
3029        let active_marker = if provider == active_provider {
3030            " *"
3031        } else {
3032            ""
3033        };
3034
3035        lines.push(format!(
3036            "{:<14} {:<8} {:<10} {:<8} {}{}",
3037            provider.as_str(),
3038            config_status,
3039            keyring_status,
3040            env_status,
3041            source,
3042            active_marker
3043        ));
3044    }
3045
3046    lines.push(String::new());
3047    lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
3048    lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
3049    lines
3050}
3051
3052#[cfg(test)]
3053fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3054    auth_list_lines_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3055}
3056
3057fn auth_list_lines_with_runtime(
3058    store: &ConfigStore,
3059    secrets: &Secrets,
3060    runtime_overrides: &CliRuntimeOverrides,
3061) -> Vec<String> {
3062    let mut lines = Vec::new();
3063    lines.push("provider     config store env  route".to_string());
3064    for provider in ProviderKind::ALL {
3065        let slot = provider_slot(provider);
3066        if provider == ProviderKind::Xai {
3067            let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3068            let api_key = diagnostics
3069                .evaluates_runtime_api_key()
3070                .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3071            lines.push(format!(
3072                "{slot:<12}  {}     {}      {}   {}",
3073                xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3074                xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3075                xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3076                xai_list_route(&diagnostics, api_key.as_ref())
3077            ));
3078            continue;
3079        }
3080
3081        let file = provider_config_set(store, provider);
3082        let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
3083        let env = provider_env_set(provider);
3084        let external_selected = external_oauth_selected(store, provider);
3085        let active = if provider == ProviderKind::OpenaiCodex {
3086            if env {
3087                "env"
3088            } else if external_selected {
3089                "external-consent"
3090            } else {
3091                "missing"
3092            }
3093        } else if external_selected {
3094            "external-consent"
3095        } else if file {
3096            "config"
3097        } else if keyring == Some(true) {
3098            "store"
3099        } else if env {
3100            "env"
3101        } else {
3102            "missing"
3103        };
3104        lines.push(format!(
3105            "{slot:<12}  {}     {}      {}   {active}",
3106            yes_no(file),
3107            keyring_status_short(keyring),
3108            yes_no(env)
3109        ));
3110    }
3111    lines
3112}
3113
3114#[cfg(test)]
3115fn auth_status_lines_for_provider(
3116    store: &ConfigStore,
3117    secrets: &Secrets,
3118    provider: ProviderKind,
3119) -> Vec<String> {
3120    auth_status_lines_for_provider_with_runtime(
3121        store,
3122        secrets,
3123        provider,
3124        &CliRuntimeOverrides::default(),
3125    )
3126}
3127
3128fn auth_status_lines_for_provider_with_runtime(
3129    store: &ConfigStore,
3130    secrets: &Secrets,
3131    provider: ProviderKind,
3132    runtime_overrides: &CliRuntimeOverrides,
3133) -> Vec<String> {
3134    if provider == ProviderKind::Xai {
3135        return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
3136    }
3137
3138    let config_key = provider_config_api_key(store, provider);
3139    let keyring_key = provider_keyring_api_key(secrets, provider);
3140    let env_key = provider_env_value(provider);
3141    let external = external_consent(store, provider);
3142    let external_selected = external_oauth_selected(store, provider);
3143
3144    let active_label = {
3145        let active_source = if provider == ProviderKind::OpenaiCodex {
3146            if env_key.is_some() {
3147                "env"
3148            } else if external_selected {
3149                "external read-only consent (availability not probed)"
3150            } else {
3151                "missing"
3152            }
3153        } else if external_selected {
3154            "external read-only consent (availability not probed)"
3155        } else if config_key.is_some() {
3156            "config"
3157        } else if keyring_key.is_some() {
3158            "secret store"
3159        } else if env_key.is_some() {
3160            "env"
3161        } else {
3162            "missing"
3163        };
3164        let active_last4 = if provider == ProviderKind::OpenaiCodex {
3165            env_key.as_ref().map(|(_, value)| last4_label(value))
3166        } else {
3167            config_key
3168                .map(last4_label)
3169                .or_else(|| keyring_key.as_deref().map(last4_label))
3170                .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
3171        };
3172        active_last4
3173            .map(|last4| format!("{active_source} (last4: {last4})"))
3174            .unwrap_or_else(|| active_source.to_string())
3175    };
3176
3177    let env_var_label = env_key
3178        .as_ref()
3179        .map(|(name, _)| (*name).to_string())
3180        .unwrap_or_else(|| provider_env_vars(provider).join("/"));
3181    let env_status = env_key
3182        .as_ref()
3183        .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
3184        .unwrap_or_else(|| "unset".to_string());
3185
3186    let is_active = provider == store.config.provider;
3187    let active_marker = if is_active { " (active provider)" } else { "" };
3188
3189    let provider_cfg = store.config.providers.for_provider(provider);
3190    let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
3191    let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3192
3193    let lookup_order = if provider == ProviderKind::OpenaiCodex {
3194        "lookup order: env -> consent-gated exact Codex CLI file".to_string()
3195    } else {
3196        "lookup order: config -> secret store -> env".to_string()
3197    };
3198    let auth_mode = if provider == ProviderKind::OpenaiCodex {
3199        "codex_oauth".to_string()
3200    } else {
3201        provider_cfg
3202            .auth_mode
3203            .as_deref()
3204            .or(store.config.auth_mode.as_deref())
3205            .unwrap_or("api_key")
3206            .to_string()
3207    };
3208
3209    let mut lines = vec![
3210        format!("provider: {}{}", provider.as_str(), active_marker),
3211        format!("route: {}", base_url),
3212        format!("model: {}", model),
3213        format!("auth mode: {auth_mode}"),
3214        format!("active source: {active_label}"),
3215        lookup_order,
3216        format!(
3217            "config file: {} ({})",
3218            codewhale_config::quote_os_path(store.path()),
3219            source_status(config_key, "missing")
3220        ),
3221        format!(
3222            "secret store: {} ({})",
3223            secrets.backend_name(),
3224            source_status(keyring_key.as_deref(), "missing")
3225        ),
3226        format!("env var: {env_var_label} ({env_status})"),
3227    ];
3228
3229    if let Ok((source, expected_path)) = external_credential_target(provider, None) {
3230        let status = codewhale_config::external_credential_consent_status(
3231            external,
3232            provider,
3233            source,
3234            &expected_path,
3235            store.config.provider,
3236        );
3237        lines.push(format!(
3238            "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3239            status.access.as_str(),
3240            status.provider,
3241            status.source.as_str(),
3242            status.owner,
3243            codewhale_config::quote_os_path(&status.path),
3244            status.consent_version,
3245            status.route_state,
3246            status.scope_valid,
3247            status.ambient_path_changed,
3248        ));
3249        lines.push(format!("semantics: {}", status.semantics));
3250        lines.push(format!("revoke: {}", status.revoke_command));
3251        if let Some(warning) = status.ambient_path_warning() {
3252            lines.push(warning);
3253        }
3254    } else {
3255        lines.push("external credentials: disabled (no file was probed)".to_string());
3256    }
3257    lines
3258}
3259
3260fn xai_auth_status_lines_for_provider(
3261    store: &ConfigStore,
3262    secrets: &Secrets,
3263    runtime_overrides: &CliRuntimeOverrides,
3264) -> Vec<String> {
3265    let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3266    let api_key = diagnostics
3267        .evaluates_runtime_api_key()
3268        .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3269    let external = external_consent(store, ProviderKind::Xai);
3270    let selected_marker = if store.config.provider == ProviderKind::Xai {
3271        " (selected provider)"
3272    } else {
3273        ""
3274    };
3275    let provider_cfg = &store.config.providers.xai;
3276    let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3277    let auth_mode = diagnostics.auth_mode.as_deref().unwrap_or("api_key");
3278
3279    let mut lines = vec![
3280        format!("provider: xai{selected_marker}"),
3281        format!("route: {}", diagnostics.base_url),
3282        format!("model: {model}"),
3283        format!("auth mode: {auth_mode}"),
3284        format!(
3285            "credential route: {}",
3286            xai_credential_route_label(&diagnostics, api_key.as_ref())
3287        ),
3288        xai_lookup_order(&diagnostics),
3289        format!(
3290            "config file: {} ({})",
3291            codewhale_config::quote_os_path(store.path()),
3292            xai_storage_detail(
3293                &diagnostics,
3294                api_key.as_ref(),
3295                RuntimeApiKeySource::ConfigFile
3296            )
3297        ),
3298        format!(
3299            "secret store: {} ({})",
3300            secrets.backend_name(),
3301            xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Keyring)
3302        ),
3303        format!(
3304            "env var: {} ({})",
3305            provider_env_vars(ProviderKind::Xai).join("/"),
3306            xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Env)
3307        ),
3308        format!(
3309            "endpoint policy: {}",
3310            if diagnostics.official_endpoint {
3311                "official xAI endpoint"
3312            } else {
3313                "custom xAI endpoint; API-key-only (owned and external OAuth are inactive)"
3314            }
3315        ),
3316    ];
3317
3318    lines.push(match diagnostics.generation {
3319        XaiOAuthGenerationPointer::Absent => "xAI OAuth generation: absent".to_string(),
3320        XaiOAuthGenerationPointer::Valid
3321            if diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth =>
3322        {
3323            "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
3324                .to_string()
3325        }
3326        XaiOAuthGenerationPointer::Valid => {
3327            "xAI OAuth generation: valid but inactive for this route".to_string()
3328        }
3329        XaiOAuthGenerationPointer::Invalid => {
3330            "xAI OAuth generation: invalid Codewhale-owned pointer".to_string()
3331        }
3332    });
3333
3334    match diagnostics.route {
3335        XaiAuthDiagnosticRoute::OwnedOAuth => {
3336            lines.push(
3337                "external credentials: blocked by the configured Codewhale-owned xAI OAuth generation (file not probed)"
3338                    .to_string(),
3339            );
3340            return lines;
3341        }
3342        XaiAuthDiagnosticRoute::NeedsRepair => {
3343            lines.push(
3344                "external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer (file not probed)"
3345                    .to_string(),
3346            );
3347            lines.push(
3348                "repair: run `codewhale auth xai-device` to replace the owned generation, or switch [providers.xai] auth_mode to \"api_key\" and remove oauth_credential_generation. Grok CLI consent remains blocked until the pointer is absent."
3349                    .to_string(),
3350            );
3351            return lines;
3352        }
3353        XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
3354            lines.push(
3355                "external credentials: unavailable on a custom xAI endpoint (API-key-only; file not probed)"
3356                    .to_string(),
3357            );
3358            return lines;
3359        }
3360        XaiAuthDiagnosticRoute::ApiKey if !diagnostics.oauth_selected && external.is_some() => {
3361            lines.push(
3362                "external credentials: configured but inactive because xAI OAuth mode is not selected (file not probed)"
3363                    .to_string(),
3364            );
3365            return lines;
3366        }
3367        XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::ExternalConsent => {}
3368    }
3369
3370    if let Ok((source, expected_path)) = external_credential_target(ProviderKind::Xai, None) {
3371        let status = codewhale_config::external_credential_consent_status(
3372            external,
3373            ProviderKind::Xai,
3374            source,
3375            &expected_path,
3376            store.config.provider,
3377        );
3378        lines.push(format!(
3379            "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3380            status.access.as_str(),
3381            status.provider,
3382            status.source.as_str(),
3383            status.owner,
3384            codewhale_config::quote_os_path(&status.path),
3385            status.consent_version,
3386            status.route_state,
3387            status.scope_valid,
3388            status.ambient_path_changed,
3389        ));
3390        lines.push(format!("semantics: {}", status.semantics));
3391        lines.push(format!("revoke: {}", status.revoke_command));
3392        if let Some(warning) = status.ambient_path_warning() {
3393            lines.push(warning);
3394        }
3395    } else {
3396        lines.push("external credentials: disabled (no file was probed)".to_string());
3397    }
3398    lines
3399}
3400
3401fn source_status(value: Option<&str>, missing_label: &str) -> String {
3402    value
3403        .map(|v| format!("set, last4: {}", last4_label(v)))
3404        .unwrap_or_else(|| missing_label.to_string())
3405}
3406
3407fn last4_label(value: &str) -> String {
3408    let trimmed = value.trim();
3409    let chars: Vec<char> = trimmed.chars().collect();
3410    if chars.len() <= 4 {
3411        return "<redacted>".to_string();
3412    }
3413    let last4: String = chars[chars.len() - 4..].iter().collect();
3414    format!("...{last4}")
3415}
3416
3417fn run_auth_command_with_runtime(
3418    store: &mut ConfigStore,
3419    command: AuthCommand,
3420    runtime_overrides: &CliRuntimeOverrides,
3421) -> Result<()> {
3422    run_auth_command_with_secrets_and_runtime(
3423        store,
3424        command,
3425        &Secrets::auto_detect(),
3426        runtime_overrides,
3427    )
3428}
3429
3430#[cfg(test)]
3431fn run_auth_command_with_secrets(
3432    store: &mut ConfigStore,
3433    command: AuthCommand,
3434    secrets: &Secrets,
3435) -> Result<()> {
3436    run_auth_command_with_secrets_and_runtime(
3437        store,
3438        command,
3439        secrets,
3440        &CliRuntimeOverrides::default(),
3441    )
3442}
3443
3444fn run_auth_command_with_secrets_and_runtime(
3445    store: &mut ConfigStore,
3446    command: AuthCommand,
3447    secrets: &Secrets,
3448    runtime_overrides: &CliRuntimeOverrides,
3449) -> Result<()> {
3450    match command {
3451        AuthCommand::XaiDevice => {
3452            bail!("xAI device authentication must be delegated to codewhale-tui")
3453        }
3454        AuthCommand::ExternalConsent {
3455            provider,
3456            mode,
3457            path,
3458            yes,
3459        } => {
3460            let provider: ProviderKind = provider.into();
3461            let (source, path) = external_credential_target(provider, path)?;
3462            let preview = external_consent_preview_lines(provider, source, &path);
3463            for line in &preview {
3464                println!("{line}");
3465            }
3466            if mode == ExternalCredentialModeArg::Managed {
3467                bail!(
3468                    "managed external credential access is unsupported in v0.9.1: no provider has a reviewed schema-safe preservation adapter. Use --mode read-only, or use Codewhale-owned login/API-key storage."
3469                );
3470            }
3471            confirm_external_consent(yes)?;
3472            let path_value = path.to_str().context(
3473                "external credential path cannot be persisted losslessly because it is not valid UTF-8",
3474            )?;
3475            let provider_key = provider.provider().provider_config_key();
3476            codewhale_config::mutate_config_document(store.path(), |document| {
3477                if matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai) {
3478                    codewhale_config::set_config_document_value(
3479                        document,
3480                        &["providers", provider_key, "auth_mode"],
3481                        "oauth",
3482                    )?;
3483                }
3484                let prefix = &["providers", provider_key, "external_credentials"];
3485                codewhale_config::set_config_document_value(
3486                    document,
3487                    &[prefix[0], prefix[1], prefix[2], "access"],
3488                    "read_only",
3489                )?;
3490                codewhale_config::set_config_document_value(
3491                    document,
3492                    &[prefix[0], prefix[1], prefix[2], "provider"],
3493                    provider.as_str(),
3494                )?;
3495                codewhale_config::set_config_document_value(
3496                    document,
3497                    &[prefix[0], prefix[1], prefix[2], "source"],
3498                    source.as_str(),
3499                )?;
3500                codewhale_config::set_config_document_value(
3501                    document,
3502                    &[prefix[0], prefix[1], prefix[2], "path"],
3503                    path_value,
3504                )?;
3505                codewhale_config::set_config_document_value(
3506                    document,
3507                    &[prefix[0], prefix[1], prefix[2], "consent_version"],
3508                    i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
3509                )
3510            })?;
3511            store
3512                .reload()
3513                .context("external consent was saved, but config reload failed")?;
3514            println!(
3515                "saved read-only external credential consent: provider={}, owner={}, path={}, consent_version={} ({})",
3516                provider.as_str(),
3517                source.as_str(),
3518                codewhale_config::quote_os_path(&path),
3519                codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION,
3520                codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
3521            );
3522            println!(
3523                "revoke with: codewhale auth external-revoke --provider {}",
3524                provider.as_str()
3525            );
3526            Ok(())
3527        }
3528        AuthCommand::ExternalRevoke { provider } => {
3529            let provider: ProviderKind = provider.into();
3530            let provider_key = provider.provider().provider_config_key();
3531            codewhale_config::mutate_config_document(store.path(), |document| {
3532                codewhale_config::unset_config_document_value(
3533                    document,
3534                    &["providers", provider_key, "external_credentials"],
3535                )?;
3536                Ok(())
3537            })?;
3538            store
3539                .reload()
3540                .context("external consent was revoked, but config reload failed")?;
3541            println!(
3542                "external credential access disabled for {}",
3543                provider.as_str()
3544            );
3545            Ok(())
3546        }
3547        AuthCommand::Status { provider } => {
3548            match provider {
3549                Some(p) => {
3550                    let provider: ProviderKind = p.into();
3551                    for line in auth_status_lines_for_provider_with_runtime(
3552                        store,
3553                        secrets,
3554                        provider,
3555                        runtime_overrides,
3556                    ) {
3557                        println!("{line}");
3558                    }
3559                }
3560                None => {
3561                    for line in
3562                        auth_status_all_providers_with_runtime(store, secrets, runtime_overrides)
3563                    {
3564                        println!("{line}");
3565                    }
3566                }
3567            }
3568            Ok(())
3569        }
3570        AuthCommand::Set {
3571            provider,
3572            api_key,
3573            api_key_stdin,
3574        } => {
3575            let provider: ProviderKind = provider.into();
3576            let slot = provider_slot(provider);
3577            if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
3578                let provider_cfg = store.config.providers.for_provider_mut(provider);
3579                if provider_cfg.base_url.is_none() {
3580                    provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
3581                }
3582                store.save()?;
3583                println!(
3584                    "configured {slot} provider in {} (API key optional)",
3585                    store.path().display()
3586                );
3587                return Ok(());
3588            }
3589            let api_key = match (api_key, api_key_stdin) {
3590                (Some(v), _) => v,
3591                (None, true) => read_api_key_from_stdin()?,
3592                (None, false) => prompt_api_key(slot)?,
3593            };
3594            let mut credential_store = credential_metadata_store(store)?;
3595            let store = credential_store.as_mut().unwrap_or(store);
3596            let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
3597            // Don't print the key. Don't echo length.
3598            if secret_store_saved {
3599                println!(
3600                    "saved API key for {slot} to {} (config contains metadata only)",
3601                    secrets.backend_name(),
3602                );
3603            } else {
3604                println!("saved API key for {slot} to {}", store.path().display());
3605            }
3606            Ok(())
3607        }
3608        AuthCommand::Get { provider } => {
3609            let provider: ProviderKind = provider.into();
3610            println!(
3611                "{}",
3612                auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
3613            );
3614            Ok(())
3615        }
3616        AuthCommand::PrintApiKey { provider } => {
3617            let provider: ProviderKind = provider.into();
3618            let mut stdout = io::stdout().lock();
3619            credential_handoff::handoff_secret_line(&mut stdout, io::stdout().is_terminal(), || {
3620                credential_handoff::resolve_api_key(store, secrets, provider, runtime_overrides)
3621            })
3622        }
3623        AuthCommand::Clear { provider } => {
3624            let provider: ProviderKind = provider.into();
3625            if provider == ProviderKind::Xai {
3626                codewhale_config::with_xai_oauth_revocation_transaction(|| {
3627                    clear_auth_provider(store, secrets, provider)
3628                })
3629            } else {
3630                clear_auth_provider(store, secrets, provider)
3631            }
3632        }
3633        AuthCommand::List => {
3634            for line in auth_list_lines_with_runtime(store, secrets, runtime_overrides) {
3635                println!("{line}");
3636            }
3637            Ok(())
3638        }
3639        AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
3640    }
3641}
3642
3643fn external_consent_preview_lines(
3644    provider: ProviderKind,
3645    source: codewhale_config::ExternalCredentialSource,
3646    path: &Path,
3647) -> Vec<String> {
3648    vec![
3649        "External credential consent preview (nothing has been saved):".to_string(),
3650        format!("  provider: {}", provider.as_str()),
3651        format!(
3652            "  owning CLI: {} ({})",
3653            source.owner_label(),
3654            source.as_str()
3655        ),
3656        format!(
3657            "  exact resolved path: {}",
3658            codewhale_config::quote_os_path(path)
3659        ),
3660        format!(
3661            "  access: read_only ({})",
3662            codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS
3663        ),
3664        "  managed: unavailable (no reviewed schema-safe preservation adapter)".to_string(),
3665        format!(
3666            "  revoke: codewhale auth external-revoke --provider {}",
3667            provider.as_str()
3668        ),
3669    ]
3670}
3671
3672fn confirm_external_consent(yes: bool) -> Result<()> {
3673    use std::io::IsTerminal;
3674
3675    if yes {
3676        return Ok(());
3677    }
3678    if !std::io::stdin().is_terminal() {
3679        bail!(
3680            "external credential consent was not saved: non-interactive use requires explicit --yes after reviewing the preview"
3681        );
3682    }
3683    confirm_external_consent_answer(&mut std::io::stdin().lock(), &mut std::io::stdout().lock())
3684}
3685
3686fn confirm_external_consent_answer(
3687    reader: &mut impl std::io::BufRead,
3688    writer: &mut impl std::io::Write,
3689) -> Result<()> {
3690    write!(writer, "Type 'yes' to grant this exact read-only access: ")?;
3691    writer.flush()?;
3692    let mut answer = String::new();
3693    reader
3694        .read_line(&mut answer)
3695        .context("reading external credential consent confirmation")?;
3696    if answer.trim() != "yes" {
3697        bail!("external credential consent cancelled; no configuration was changed");
3698    }
3699    Ok(())
3700}
3701
3702fn yes_no(b: bool) -> &'static str {
3703    if b { "yes" } else { "no " }
3704}
3705
3706fn keyring_status_short(state: Option<bool>) -> &'static str {
3707    match state {
3708        Some(true) => "yes",
3709        Some(false) => "no ",
3710        None => "n/a",
3711    }
3712}
3713
3714fn prompt_api_key(slot: &str) -> Result<String> {
3715    use std::io::{IsTerminal, Write};
3716    eprint!("Enter API key for {slot}: ");
3717    io::stderr().flush().ok();
3718    if !io::stdin().is_terminal() {
3719        // Non-interactive: read directly without prompting twice.
3720        return read_api_key_from_stdin();
3721    }
3722    let mut buf = String::new();
3723    io::stdin()
3724        .read_line(&mut buf)
3725        .context("failed to read API key from stdin")?;
3726    let key = buf.trim().to_string();
3727    if key.is_empty() {
3728        bail!("empty API key provided");
3729    }
3730    Ok(key)
3731}
3732
3733/// Move plaintext keys from config.toml into the configured secret store.
3734/// Hidden in v0.8.8 because the normal setup path is config/env only.
3735fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
3736    let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
3737    let mut warnings: Vec<String> = Vec::new();
3738    let literal =
3739        |value: &String| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal;
3740
3741    for provider in ProviderKind::ALL {
3742        let slot = provider_slot(provider);
3743        let from_provider_block = store
3744            .config
3745            .providers
3746            .for_provider(provider)
3747            .api_key
3748            .clone()
3749            .filter(literal);
3750        let from_root = (provider == ProviderKind::Deepseek)
3751            .then(|| store.config.api_key.clone())
3752            .flatten()
3753            .filter(literal);
3754        let value = from_provider_block.or(from_root);
3755        let Some(value) = value else { continue };
3756
3757        if let Ok(Some(existing)) = secrets.get(slot)
3758            && existing == value
3759        {
3760            // Already migrated; safe to strip the file slot.
3761        } else if dry_run {
3762            migrated.push((provider, slot));
3763            continue;
3764        } else if let Err(err) = secrets.set(slot, &value) {
3765            warnings.push(format!(
3766                "skipped {slot}: failed to write to secret store: {err}"
3767            ));
3768            continue;
3769        }
3770        if !dry_run {
3771            store.config.providers.for_provider_mut(provider).api_key = None;
3772            if provider == ProviderKind::Deepseek {
3773                store.config.api_key = None;
3774            }
3775        }
3776        migrated.push((provider, slot));
3777    }
3778
3779    if !dry_run && !migrated.is_empty() {
3780        store
3781            .save()
3782            .context("failed to write updated config.toml")?;
3783    }
3784    if !dry_run {
3785        codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
3786            .context("failed to remove plaintext API keys from config backup")?;
3787    }
3788
3789    println!("secret store backend: {}", secrets.backend_name());
3790    if migrated.is_empty() {
3791        println!("nothing to migrate (config.toml has no plaintext api_key entries)");
3792    } else {
3793        println!(
3794            "{} {} provider key(s):",
3795            if dry_run { "would migrate" } else { "migrated" },
3796            migrated.len()
3797        );
3798        for (_, slot) in &migrated {
3799            println!("  - {slot}");
3800        }
3801        if !dry_run {
3802            println!(
3803                "config.toml at {} no longer contains api_key entries for migrated providers.",
3804                store.path().display()
3805            );
3806        }
3807    }
3808    for w in warnings {
3809        eprintln!("warning: {w}");
3810    }
3811    Ok(())
3812}
3813
3814fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
3815    match command {
3816        ConfigCommand::Get { key } => {
3817            if let Some(value) = store.config.get_display_value(&key) {
3818                println!("{value}");
3819                return Ok(());
3820            }
3821            bail!("key not found: {key}");
3822        }
3823        ConfigCommand::Set { key, value } => {
3824            // Turning telemetry on from the command line is still a consent
3825            // moment, and it is the only one this surface can offer. The
3826            // notice goes to stderr so `config set` stays pipeable, and it is
3827            // shown *before* the write commits: declining writes nothing at
3828            // all, not a value that a later notice would have to undo.
3829            if !confirm_telemetry_opt_in_if_needed(&key, &value)? {
3830                return Ok(());
3831            }
3832            store.config.set_value(&key, &value)?;
3833            store.save()?;
3834            println!("set {key}");
3835            Ok(())
3836        }
3837        ConfigCommand::Unset { key } => {
3838            store.config.unset_value(&key)?;
3839            store.save()?;
3840            println!("unset {key}");
3841            Ok(())
3842        }
3843        ConfigCommand::List => {
3844            // Configured truth, not live-session truth (DGF-01): a running
3845            // session keeps the route it resolved at launch, so these values
3846            // must not be read as "what the current session is serving".
3847            // `#` keeps the header safe for `key = value` line parsers.
3848            println!("# configured values ({})", store.path().display());
3849            println!(
3850                "# a running session keeps the route it resolved at launch; `codewhale model resolve` reports the route a new session would take"
3851            );
3852            for (key, value) in store.config.list_values() {
3853                println!("{key} = {value}");
3854            }
3855            Ok(())
3856        }
3857        ConfigCommand::Path => {
3858            println!("{}", store.path().display());
3859            Ok(())
3860        }
3861    }
3862}
3863
3864/// Show the telemetry notice and record the answer when `config set` is about
3865/// to turn telemetry on.
3866///
3867/// Returns whether the caller should proceed with the write. `Ok(false)` means
3868/// the user declined; the decline is recorded, so they are not asked again
3869/// until the notice content itself changes.
3870///
3871/// Off a terminal this is a no-op and the value is written unchanged: the
3872/// setup-state decision stays unrecorded, which leaves the switch inert. That
3873/// is the shape of the whole feature — both halves are required, neither alone
3874/// suffices, and a script that flips the key on a build machine has not
3875/// consented on anyone's behalf.
3876fn confirm_telemetry_opt_in_if_needed(key: &str, value: &str) -> Result<bool> {
3877    use codewhale_telemetry::notice;
3878
3879    // The same spellings `ConfigToml::set_value` accepts for a boolean. An
3880    // unrecognised value is left to the setter to reject.
3881    let turning_on = matches!(
3882        value.trim().to_ascii_lowercase().as_str(),
3883        "1" | "true" | "yes" | "on" | "enabled"
3884    );
3885    if key != "telemetry" || !turning_on {
3886        return Ok(true);
3887    }
3888    if !(io::stdin().is_terminal() && io::stderr().is_terminal()) {
3889        return Ok(true);
3890    }
3891    let Ok(Some(mut state)) = SetupState::load().map(|state| Some(state.unwrap_or_default()))
3892    else {
3893        return Ok(true);
3894    };
3895    if !state.needs_telemetry_notice(codewhale_config::TELEMETRY_NOTICE_VERSION) {
3896        return Ok(true);
3897    }
3898
3899    eprintln!("\n  {}\n", notice::NOTICE_HEADLINE);
3900    for line in notice::NOTICE_BODY.lines() {
3901        if line.is_empty() {
3902            eprintln!();
3903        } else {
3904            eprintln!("  {line}");
3905        }
3906    }
3907    eprint!("\n  {} ", notice::NOTICE_PROMPT);
3908    io::stderr().flush().ok();
3909
3910    let mut answer = String::new();
3911    // A failed read is not an answer. Enter is not an answer either — it is
3912    // the pre-selected decline.
3913    let opt_in = io::stdin().read_line(&mut answer).is_ok() && notice::answer_is_yes(&answer);
3914
3915    state.record_telemetry_notice(codewhale_config::TELEMETRY_NOTICE_VERSION, opt_in);
3916    if let Err(error) = state.save() {
3917        eprintln!("  Could not record that decision ({error}); telemetry stays off.\n");
3918        return Ok(false);
3919    }
3920    eprintln!("  {}\n", notice::decision_receipt(opt_in));
3921    Ok(opt_in)
3922}
3923
3924fn model_command_provider_hint(
3925    command_provider: Option<ProviderArg>,
3926    top_level_provider: Option<ProviderKind>,
3927) -> Option<ProviderKind> {
3928    command_provider
3929        .map(ProviderKind::from)
3930        .or(top_level_provider)
3931}
3932
3933fn provider_source_label(source: ProviderSource) -> String {
3934    match source {
3935        ProviderSource::Cli => "--provider".to_string(),
3936        ProviderSource::Env(name) => format!("environment ({name})"),
3937        ProviderSource::Config => "config".to_string(),
3938    }
3939}
3940
3941fn run_model_command(
3942    store: &mut ConfigStore,
3943    command: ModelCommand,
3944    top_level_provider: Option<ProviderKind>,
3945    resolved_runtime: &ResolvedRuntimeOptions,
3946) -> Result<()> {
3947    let registry = ModelRegistry::default();
3948    match command {
3949        ModelCommand::List { provider } => {
3950            let filter = model_command_provider_hint(provider, top_level_provider);
3951            for model in registry.list().into_iter().filter(|m| match filter {
3952                Some(p) => m.provider == p,
3953                None => true,
3954            }) {
3955                println!("{} ({})", model.id, model.provider.as_str());
3956            }
3957            Ok(())
3958        }
3959        ModelCommand::Resolve { model, provider } => {
3960            // Only `model resolve --provider X` is a hypothetical. The
3961            // top-level `--provider` is the route this process is actually on,
3962            // and it is already folded into `resolved_runtime` — treating it as
3963            // a hypothetical made `codewhale --provider moonshot --model
3964            // kimi-k3 model resolve` re-derive a registry default and report
3965            // `kimi-k2.7-code` while the runtime used `kimi-k3` (v0.9.1 kimi-k3 dogfood report). The
3966            // top-level `--model` was not consulted at all on that path.
3967            let subcommand_provider = provider.map(ProviderKind::from);
3968            let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
3969
3970            // With no explicit query, this reports the route the runtime would
3971            // actually take — the same answer `doctor` gives — rather than
3972            // re-deriving one from an empty flag set. Re-deriving is what made
3973            // a Z.ai config report `provider: deepseek` (#4832).
3974            if queried.is_none() && subcommand_provider.is_none() {
3975                let source = resolved_runtime.model_source;
3976                println!(
3977                    "requested: {}",
3978                    if source.is_explicit() {
3979                        resolved_runtime.model.as_str()
3980                    } else {
3981                        ""
3982                    }
3983                );
3984                println!("resolved: {}", resolved_runtime.model);
3985                println!("provider: {}", resolved_runtime.provider.as_str());
3986                println!("used_fallback: {}", !source.is_explicit());
3987                println!(
3988                    "provider_source: {}",
3989                    provider_source_label(resolved_runtime.provider_source)
3990                );
3991                println!("model_source: {}", source.as_str());
3992                return Ok(());
3993            }
3994
3995            // An explicit model or provider makes this a hypothetical query
3996            // ("what would this name resolve to"), so answer it against the
3997            // registry — but default the provider to the configured one rather
3998            // than to any single vendor.
3999            let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
4000            let mut resolved = registry.resolve(queried, provider_hint);
4001            // The registry refuses to answer a provider-scoped question with
4002            // another vendor's model. That is right when the *user* named the
4003            // provider, but the hint above is often ours: when only a model was
4004            // named, "what does this id mean" is still a global question, so
4005            // retry unhinted rather than substituting the configured provider's
4006            // default for the id the user typed.
4007            let provider_named_by_user =
4008                subcommand_provider.is_some() || top_level_provider.is_some();
4009            if !provider_named_by_user && queried.is_some() && resolved.used_fallback {
4010                resolved = registry.resolve(queried, None);
4011            }
4012            println!("requested: {}", resolved.requested.unwrap_or_default());
4013            println!("resolved: {}", resolved.resolved.id);
4014            println!("provider: {}", resolved.resolved.provider.as_str());
4015            println!("used_fallback: {}", resolved.used_fallback);
4016            println!(
4017                "provider_source: {}",
4018                if subcommand_provider.is_some() {
4019                    "--provider".to_string()
4020                } else {
4021                    provider_source_label(resolved_runtime.provider_source)
4022                }
4023            );
4024            println!(
4025                "model_source: {}",
4026                if queried.is_some() {
4027                    "argument"
4028                } else {
4029                    resolved_runtime.model_source.as_str()
4030                }
4031            );
4032            Ok(())
4033        }
4034        ModelCommand::Set { model } => {
4035            let trimmed = model.trim();
4036            if trimmed.is_empty() {
4037                bail!("Model name cannot be empty");
4038            }
4039            let canonical = match trimmed.to_ascii_lowercase().as_str() {
4040                "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
4041                "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
4042                _ => trimmed,
4043            };
4044            store.config.default_text_model = Some(canonical.to_string());
4045            store.save()?;
4046            println!("Default model set to '{canonical}'");
4047            Ok(())
4048        }
4049    }
4050}
4051
4052/// The TUI passthrough a thread subcommand delegates as, if it delegates.
4053///
4054/// Exhaustive on purpose: a future `ThreadCommand` variant that starts a
4055/// session has to state its passthrough here, where the caller below routes it
4056/// through the one command builder that applies the telemetry floor.
4057fn thread_delegation(command: &ThreadCommand) -> Option<Vec<String>> {
4058    match command {
4059        ThreadCommand::Resume { thread_id } => Some(vec!["resume".to_string(), thread_id.clone()]),
4060        ThreadCommand::Fork { thread_id } => Some(vec!["fork".to_string(), thread_id.clone()]),
4061        ThreadCommand::List { .. }
4062        | ThreadCommand::Read { .. }
4063        | ThreadCommand::Archive { .. }
4064        | ThreadCommand::Unarchive { .. }
4065        | ThreadCommand::SetName { .. }
4066        | ThreadCommand::ClearName { .. } => None,
4067    }
4068}
4069
4070fn run_thread_command(
4071    cli: &Cli,
4072    store: &mut ConfigStore,
4073    runtime_overrides: &CliRuntimeOverrides,
4074    command: ThreadCommand,
4075) -> Result<()> {
4076    // `thread resume`/`thread fork` start a full interactive session in the TUI
4077    // binary, so they delegate exactly like the top-level `resume` does —
4078    // through `build_tui_command`, which forwards `--config` and states the
4079    // resolved telemetry value in the child's environment. They used to take a
4080    // bare `Command::new(tui).args(args)` that forwarded neither, so a session
4081    // launched this way re-resolved from `$CODEWHALE_HOME/config.toml` with no
4082    // overrides and armed telemetry even when the user had passed
4083    // `--telemetry false` or pointed `--config` at a file that said
4084    // `telemetry = false`.
4085    if let Some(passthrough) = thread_delegation(&command) {
4086        let resolved_runtime = resolve_runtime_for_dispatch(store, runtime_overrides);
4087        return delegate_to_tui(cli, &resolved_runtime, passthrough);
4088    }
4089    let state = StateStore::open(None)?;
4090    match command {
4091        ThreadCommand::List { all, limit } => {
4092            let threads = state.list_threads(ThreadListFilters {
4093                include_archived: all,
4094                limit,
4095            })?;
4096            for thread in threads {
4097                println!(
4098                    "{} | {} | {} | {}",
4099                    thread.id,
4100                    thread
4101                        .name
4102                        .clone()
4103                        .unwrap_or_else(|| "(unnamed)".to_string()),
4104                    thread.model_provider,
4105                    thread.cwd.display()
4106                );
4107            }
4108            Ok(())
4109        }
4110        ThreadCommand::Read { thread_id } => {
4111            let thread = state.get_thread(&thread_id)?;
4112            println!("{}", serde_json::to_string_pretty(&thread)?);
4113            Ok(())
4114        }
4115        ThreadCommand::Resume { .. } | ThreadCommand::Fork { .. } => {
4116            unreachable!("thread_delegation routes resume and fork before this match")
4117        }
4118        ThreadCommand::Archive { thread_id } => {
4119            state.mark_archived(&thread_id)?;
4120            println!("archived {thread_id}");
4121            Ok(())
4122        }
4123        ThreadCommand::Unarchive { thread_id } => {
4124            state.mark_unarchived(&thread_id)?;
4125            println!("unarchived {thread_id}");
4126            Ok(())
4127        }
4128        ThreadCommand::SetName { thread_id, name } => {
4129            let mut thread = state
4130                .get_thread(&thread_id)?
4131                .with_context(|| format!("thread not found: {thread_id}"))?;
4132            thread.name = Some(name);
4133            thread.updated_at = chrono::Utc::now().timestamp();
4134            state.upsert_thread(&thread)?;
4135            println!("renamed {thread_id}");
4136            Ok(())
4137        }
4138        ThreadCommand::ClearName { thread_id } => {
4139            let mut thread = state
4140                .get_thread(&thread_id)?
4141                .with_context(|| format!("thread not found: {thread_id}"))?;
4142            thread.name = None;
4143            thread.updated_at = chrono::Utc::now().timestamp();
4144            state.upsert_thread(&thread)?;
4145            println!("cleared name for {thread_id}");
4146            Ok(())
4147        }
4148    }
4149}
4150
4151fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
4152    match command {
4153        SandboxCommand::Check { command, ask } => {
4154            let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
4155            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
4156            let decision = engine.check(ExecPolicyContext {
4157                command: &command,
4158                cwd: &cwd.display().to_string(),
4159                tool: Some("exec_shell"),
4160                path: None,
4161                ask_for_approval: ask.into(),
4162                sandbox_mode: Some("workspace-write"),
4163            })?;
4164            println!("{}", serde_json::to_string_pretty(&decision)?);
4165            Ok(())
4166        }
4167    }
4168}
4169
4170fn run_app_server_command(
4171    cli: &Cli,
4172    resolved_runtime: &ResolvedRuntimeOptions,
4173    args: AppServerArgs,
4174) -> Result<()> {
4175    // The full runtime API lives in the TUI crate behind `serve --http`/`--mobile`.
4176    // Rather than duplicate ~6.5k lines or add a CLI→TUI crate dependency, the
4177    // canonical `app-server --http`/`--mobile` entrypoint reuses that mature server
4178    // by delegating to the sibling TUI binary (the same mechanism `serve` uses).
4179    if args.http || args.mobile {
4180        // Delegated runtime API listener — supervise it so the child does not
4181        // outlive the dispatcher (#3259).
4182        return delegate_server_to_tui(cli, resolved_runtime, app_server_serve_passthrough(&args));
4183    }
4184
4185    // Everything below runs the app-server *in this process*, which is why the
4186    // surface cannot be derived from the executable: `current_exe()` would
4187    // report every one of these sessions as `cli`.
4188    let session = start_cli_telemetry(
4189        resolved_runtime,
4190        args.config.clone().or_else(|| cli.config.clone()),
4191        Surface::AppServer,
4192    );
4193
4194    let runtime = match tokio::runtime::Builder::new_multi_thread()
4195        .enable_all()
4196        .build()
4197        .context("failed to create tokio runtime")
4198    {
4199        Ok(runtime) => runtime,
4200        Err(error) => {
4201            let outcome = Err(error);
4202            finish_cli_telemetry(session, &outcome);
4203            return outcome;
4204        }
4205    };
4206    if args.stdio {
4207        let outcome = runtime.block_on(run_app_server_stdio(args.config));
4208        finish_cli_telemetry(session, &outcome);
4209        return outcome;
4210    }
4211    // Legacy in-process app-server HTTP transport (`/healthz`, `/thread`, `/app`,
4212    // `/prompt`, `/tool`, `/jobs`). Kept for backward compatibility; defaults to
4213    // 127.0.0.1:8787 to avoid colliding with the runtime API default of :7878.
4214    let host = args.host.as_deref().unwrap_or("127.0.0.1");
4215    let port = args.port.unwrap_or(8787);
4216    let outcome = format!("{host}:{port}")
4217        .parse::<SocketAddr>()
4218        .with_context(|| format!("invalid app-server listen address {host}:{port}"))
4219        .and_then(|listen| {
4220            runtime.block_on(run_app_server(AppServerOptions {
4221                listen,
4222                config_path: args.config,
4223                auth_token: args.auth_token.or_else(app_server_token_from_env),
4224                insecure_no_auth: args.insecure_no_auth,
4225                cors_origins: args.cors_origin,
4226            }))
4227        });
4228    finish_cli_telemetry(session, &outcome);
4229    outcome
4230}
4231
4232/// Build the `serve` argv forwarded to the TUI binary for
4233/// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the
4234/// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The
4235/// subcommand-level `--config` is bridged through the global `--config` in the
4236/// dispatcher, so it is intentionally not part of this passthrough. An auth
4237/// token from the environment is deliberately *not* forwarded into child argv;
4238/// the runtime API reads CODEWHALE_RUNTIME_TOKEN/DEEPSEEK_RUNTIME_TOKEN itself.
4239fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
4240    let mut forwarded = vec!["serve".to_string()];
4241    forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
4242    if let Some(host) = args.host.as_ref() {
4243        forwarded.push("--host".to_string());
4244        forwarded.push(host.clone());
4245    }
4246    if let Some(port) = args.port {
4247        forwarded.push("--port".to_string());
4248        forwarded.push(port.to_string());
4249    }
4250    if let Some(workers) = args.workers {
4251        forwarded.push("--workers".to_string());
4252        forwarded.push(workers.to_string());
4253    }
4254    for origin in &args.cors_origin {
4255        forwarded.push("--cors-origin".to_string());
4256        forwarded.push(origin.clone());
4257    }
4258    if let Some(token) = args.auth_token.as_ref() {
4259        forwarded.push("--auth-token".to_string());
4260        forwarded.push(token.clone());
4261    }
4262    if args.insecure_no_auth {
4263        forwarded.push("--insecure".to_string());
4264    }
4265    if args.qr {
4266        forwarded.push("--qr".to_string());
4267    }
4268    forwarded
4269}
4270
4271fn web_serve_passthrough(args: &WebArgs) -> Vec<String> {
4272    vec![
4273        "serve".to_string(),
4274        "--web".to_string(),
4275        "--port".to_string(),
4276        args.port.to_string(),
4277    ]
4278}
4279
4280fn app_server_token_from_env() -> Option<String> {
4281    std::env::var("CODEWHALE_APP_SERVER_TOKEN")
4282        .ok()
4283        .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
4284}
4285
4286fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
4287    let persisted = load_mcp_server_definitions(store);
4288    let updated = run_stdio_server(persisted)?;
4289    persist_mcp_server_definitions(store, &updated)
4290}
4291
4292fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
4293    // `get_raw_string` first: `get_value` re-renders the extras entry as TOML,
4294    // which quotes a JSON payload into `'[{"config":…}]'` and makes it
4295    // unparseable — so every persisted definition was silently dropped and
4296    // `mcp-server` started with an empty server list (#4727). `get_value`
4297    // remains as the fallback for keys that are not plain extras strings.
4298    let raw = store
4299        .config
4300        .get_raw_string(MCP_SERVER_DEFINITIONS_KEY)
4301        .map(ToOwned::to_owned)
4302        .or_else(|| store.config.get_value(MCP_SERVER_DEFINITIONS_KEY));
4303    let Some(raw) = raw else {
4304        return Vec::new();
4305    };
4306
4307    match parse_mcp_server_definitions(&raw) {
4308        Ok(definitions) => definitions,
4309        Err(err) => {
4310            eprintln!(
4311                "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
4312            );
4313            Vec::new()
4314        }
4315    }
4316}
4317
4318fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
4319    if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
4320        return Ok(parsed);
4321    }
4322
4323    let unwrapped: String = serde_json::from_str(raw).map_err(|_| {
4324        anyhow!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted")
4325    })?;
4326    serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).map_err(|_| {
4327        anyhow!(
4328            "invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted"
4329        )
4330    })
4331}
4332
4333fn persist_mcp_server_definitions(
4334    store: &mut ConfigStore,
4335    definitions: &[McpServerDefinition],
4336) -> Result<()> {
4337    let encoded =
4338        serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
4339    store
4340        .config
4341        .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
4342    store.save()
4343}
4344
4345fn delegate_to_tui(
4346    cli: &Cli,
4347    resolved_runtime: &ResolvedRuntimeOptions,
4348    passthrough: Vec<String>,
4349) -> Result<()> {
4350    let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
4351    let tui = PathBuf::from(cmd.get_program());
4352    let status = cmd
4353        .status()
4354        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4355    exit_with_tui_status(status)
4356}
4357
4358/// Delegate a long-running server command (`serve --http`/`--mobile`,
4359/// `app-server --http`/`--mobile`) to the sibling TUI binary, supervising the
4360/// child so its listener does not outlive the dispatcher (#3259).
4361///
4362/// Plain [`delegate_to_tui`] blocks on `Command::status()`, which reaps the
4363/// child only on the child's own exit. If the dispatcher is terminated while
4364/// the delegated server is still running, the child can be reparented and keep
4365/// its listener bound. Here the child runs under a Tokio supervisor that
4366/// forwards termination (Ctrl+C / SIGTERM / SIGHUP) by killing and reaping the
4367/// child before the dispatcher exits, and `kill_on_drop` tears the child down
4368/// if the dispatcher unwinds.
4369///
4370/// For an *uncatchable* dispatcher death (SIGKILL, a hard crash) the Tokio
4371/// supervisor above can't run, so two OS-level safety nets are installed as
4372/// well (#3259): on Linux the child sets `PR_SET_PDEATHSIG` so the kernel
4373/// signals it when the dispatcher dies; on Windows the child is placed in a
4374/// kill-on-job-close Job Object so closing the dispatcher's handle (which the
4375/// OS does on process death) terminates it. macOS has no equivalent primitive,
4376/// so an uncatchable dispatcher death there can still orphan the child.
4377fn delegate_server_to_tui(
4378    cli: &Cli,
4379    resolved_runtime: &ResolvedRuntimeOptions,
4380    passthrough: Vec<String>,
4381) -> Result<()> {
4382    let mut std_cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
4383    install_server_parent_death_signal(&mut std_cmd);
4384    let tui = PathBuf::from(std_cmd.get_program());
4385    let runtime = tokio::runtime::Builder::new_current_thread()
4386        .enable_all()
4387        .build()
4388        .context("failed to create server-teardown runtime")?;
4389    runtime.block_on(async move {
4390        let mut cmd = tokio::process::Command::from(std_cmd);
4391        cmd.kill_on_drop(true);
4392        let mut child = cmd
4393            .spawn()
4394            .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4395        // Windows: hold a kill-on-job-close Job Object for the dispatcher's
4396        // lifetime so an uncatchable dispatcher death tears the child down.
4397        // Bound for the whole `block_on` scope; never dropped early because the
4398        // match arms below `std::process::exit`.
4399        #[cfg(windows)]
4400        let _child_job = attach_server_child_job(&child);
4401        match supervise_server_child(&mut child, server_shutdown_signal()).await? {
4402            ServerTeardown::Exited(status) => exit_with_tui_status(status),
4403            // The child has been killed and reaped; exit with the conventional
4404            // 128 + signal code for the signal that initiated the shutdown.
4405            ServerTeardown::Signaled(code) => std::process::exit(code),
4406        }
4407    })
4408}
4409
4410/// On Linux, ask the kernel to terminate the delegated server if the dispatcher
4411/// dies before it can run the graceful shutdown supervisor. This covers the
4412/// hard parent-death edge of #3259 for `SIGKILL`, OOM, or abrupt process exit.
4413#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
4414fn install_server_parent_death_signal(cmd: &mut Command) {
4415    use std::os::unix::process::CommandExt;
4416    // SAFETY: `pre_exec` runs in the child between fork and exec. The closure
4417    // only calls `libc::prctl` with constant arguments and does not touch heap
4418    // memory or parent-held locks.
4419    unsafe {
4420        cmd.pre_exec(|| {
4421            let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
4422            if result == -1 {
4423                // Best effort: the child only loses this OS-level safety net.
4424                let _ = std::io::Error::last_os_error();
4425            }
4426            Ok(())
4427        });
4428    }
4429}
4430
4431#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
4432fn install_server_parent_death_signal(_cmd: &mut Command) {}
4433
4434/// Outcome of supervising a delegated server child.
4435#[derive(Debug)]
4436enum ServerTeardown {
4437    /// The child exited on its own; its status is carried for propagation.
4438    Exited(std::process::ExitStatus),
4439    /// A shutdown signal fired; the child was killed and reaped. Carries the
4440    /// conventional `128 + signal` exit code to propagate.
4441    Signaled(i32),
4442}
4443
4444/// Wait for the server `child` to exit, or for `shutdown` to fire first. On
4445/// shutdown, kill the child and reap it so no listener is left reparented.
4446async fn supervise_server_child<F>(
4447    child: &mut tokio::process::Child,
4448    shutdown: F,
4449) -> io::Result<ServerTeardown>
4450where
4451    F: std::future::Future<Output = i32>,
4452{
4453    tokio::select! {
4454        status = child.wait() => Ok(ServerTeardown::Exited(status?)),
4455        code = shutdown => {
4456            // Send the kill, then wait so the PID is reaped before the
4457            // dispatcher returns and exits.
4458            let _ = child.start_kill();
4459            let _ = child.wait().await;
4460            Ok(ServerTeardown::Signaled(code))
4461        }
4462    }
4463}
4464
4465/// Resolve when the dispatcher should tear down a delegated server child, and
4466/// the conventional `128 + signal` exit code to propagate: Ctrl+C on every
4467/// platform (130), plus SIGTERM (143) and SIGHUP (129) on Unix.
4468#[cfg(unix)]
4469async fn server_shutdown_signal() -> i32 {
4470    use tokio::signal::unix::{SignalKind, signal};
4471    let mut terminate = signal(SignalKind::terminate()).ok();
4472    let mut hangup = signal(SignalKind::hangup()).ok();
4473    let term = async {
4474        match terminate.as_mut() {
4475            Some(s) => {
4476                s.recv().await;
4477            }
4478            None => std::future::pending::<()>().await,
4479        }
4480    };
4481    let hup = async {
4482        match hangup.as_mut() {
4483            Some(s) => {
4484                s.recv().await;
4485            }
4486            None => std::future::pending::<()>().await,
4487        }
4488    };
4489    tokio::select! {
4490        _ = tokio::signal::ctrl_c() => 130,
4491        _ = term => 143,
4492        _ = hup => 129,
4493    }
4494}
4495
4496#[cfg(not(unix))]
4497async fn server_shutdown_signal() -> i32 {
4498    let _ = tokio::signal::ctrl_c().await;
4499    130
4500}
4501
4502/// Assign the delegated server `child` to a kill-on-job-close Job Object so the
4503/// OS terminates it when the dispatcher's handle to the job closes — which it
4504/// does on any dispatcher exit, including an uncatchable kill (#3259). The
4505/// returned guard must be held for the dispatcher's lifetime. Best-effort:
4506/// returns `None` if the job cannot be created or assigned. Mirrors the Job
4507/// Object idiom in `crates/tui/src/tools/shell.rs`.
4508#[cfg(windows)]
4509fn attach_server_child_job(child: &tokio::process::Child) -> Option<ServerChildJob> {
4510    let Some(child_handle) = child.raw_handle() else {
4511        tracing::warn!("delegated server child exited before a job object could be attached");
4512        return None;
4513    };
4514
4515    match ServerChildJob::attach(child_handle) {
4516        Ok(job) => Some(job),
4517        Err(err) => {
4518            tracing::warn!("failed to place delegated server child in a job object: {err}");
4519            None
4520        }
4521    }
4522}
4523
4524#[cfg(windows)]
4525struct ServerChildJob {
4526    handle: windows::Win32::Foundation::HANDLE,
4527}
4528
4529// SAFETY: the wrapped value is a process-wide kernel handle; moving it across
4530// threads does not invalidate it, and it is only ever closed once, on drop.
4531#[cfg(windows)]
4532unsafe impl Send for ServerChildJob {}
4533
4534#[cfg(windows)]
4535impl ServerChildJob {
4536    fn attach(child_handle: std::os::windows::io::RawHandle) -> std::io::Result<Self> {
4537        use windows::Win32::Foundation::HANDLE;
4538        use windows::Win32::System::JobObjects::{
4539            AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
4540            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
4541            SetInformationJobObject,
4542        };
4543        use windows::core::PCWSTR;
4544
4545        // SAFETY: FFI calls with valid arguments; results are checked via the
4546        // `windows` Result wrappers and the handle is stored for close-on-drop.
4547        let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(win_io_error)?;
4548        let job = Self { handle };
4549
4550        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
4551        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
4552        unsafe {
4553            SetInformationJobObject(
4554                job.handle,
4555                JobObjectExtendedLimitInformation,
4556                &limits as *const _ as *const core::ffi::c_void,
4557                std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
4558            )
4559            .map_err(win_io_error)?;
4560            AssignProcessToJobObject(job.handle, HANDLE(child_handle)).map_err(win_io_error)?;
4561        }
4562        Ok(job)
4563    }
4564}
4565
4566#[cfg(windows)]
4567impl Drop for ServerChildJob {
4568    fn drop(&mut self) {
4569        // Closing the last handle triggers KILL_ON_JOB_CLOSE. On a normal return
4570        // the child has already been reaped, so this is a no-op cleanup; an
4571        // uncatchable dispatcher death closes the handle via the OS instead.
4572        unsafe {
4573            let _ = windows::Win32::Foundation::CloseHandle(self.handle);
4574        }
4575    }
4576}
4577
4578#[cfg(windows)]
4579fn win_io_error(err: windows::core::Error) -> std::io::Error {
4580    std::io::Error::other(err)
4581}
4582
4583#[cfg(all(test, unix))]
4584mod server_teardown_tests {
4585    use super::*;
4586
4587    #[tokio::test]
4588    async fn supervisor_propagates_child_exit_when_no_shutdown() {
4589        // `true` exits immediately with success; a never-firing shutdown must
4590        // let the child's own exit win.
4591        let mut child = tokio::process::Command::new("true")
4592            .kill_on_drop(true)
4593            .spawn()
4594            .expect("spawn true");
4595        let outcome = supervise_server_child(&mut child, std::future::pending::<i32>())
4596            .await
4597            .expect("supervise");
4598        match outcome {
4599            ServerTeardown::Exited(status) => assert!(status.success()),
4600            other => panic!("expected Exited, got {other:?}"),
4601        }
4602    }
4603
4604    #[tokio::test]
4605    async fn shutdown_signal_kills_and_reaps_long_running_child() {
4606        // A long-lived child stands in for the delegated server listener; the
4607        // regression is that it outlives dispatcher teardown (#3259).
4608        let mut child = tokio::process::Command::new("sleep")
4609            .arg("30")
4610            .kill_on_drop(true)
4611            .spawn()
4612            .expect("spawn sleep");
4613        assert!(
4614            child.id().is_some(),
4615            "child should be running before shutdown"
4616        );
4617        // A ready future models an immediate shutdown signal carrying the
4618        // SIGTERM exit code (143).
4619        let outcome = supervise_server_child(&mut child, async { 143 })
4620            .await
4621            .expect("supervise");
4622        assert!(matches!(outcome, ServerTeardown::Signaled(143)));
4623        // Once supervise returns the child has been killed AND reaped, so tokio
4624        // drops the recorded pid — no listener is left reparented.
4625        assert!(
4626            child.id().is_none(),
4627            "delegated child must be reaped after dispatcher teardown"
4628        );
4629    }
4630
4631    #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
4632    #[test]
4633    fn parent_death_signal_hook_does_not_break_spawn() {
4634        let mut cmd = Command::new("true");
4635        install_server_parent_death_signal(&mut cmd);
4636        let status = cmd.status().expect("spawn true with parent-death hook");
4637        assert!(status.success());
4638    }
4639}
4640
4641fn run_resume_command(
4642    cli: &Cli,
4643    resolved_runtime: &ResolvedRuntimeOptions,
4644    args: TuiPassthroughArgs,
4645) -> Result<()> {
4646    let passthrough = tui_args("resume", args);
4647    if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
4648        return run_dispatcher_resume_picker(cli, resolved_runtime);
4649    }
4650    delegate_to_tui(cli, resolved_runtime, passthrough)
4651}
4652
4653fn run_dispatcher_resume_picker(
4654    cli: &Cli,
4655    resolved_runtime: &ResolvedRuntimeOptions,
4656) -> Result<()> {
4657    let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
4658    let tui = PathBuf::from(sessions_cmd.get_program());
4659    let status = sessions_cmd
4660        .status()
4661        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4662    if !status.success() {
4663        return exit_with_tui_status(status);
4664    }
4665
4666    println!();
4667    println!("Windows note: enter a session id or prefix from the list above.");
4668    println!("You can also run `codewhale resume --last` to skip this prompt.");
4669    print!("Session id/prefix (Enter to cancel): ");
4670    io::stdout().flush()?;
4671
4672    let mut input = String::new();
4673    io::stdin()
4674        .read_line(&mut input)
4675        .context("failed to read session selection")?;
4676    let session_id = input.trim();
4677    if session_id.is_empty() {
4678        bail!("No session selected.");
4679    }
4680
4681    delegate_to_tui(
4682        cli,
4683        resolved_runtime,
4684        vec!["resume".to_string(), session_id.to_string()],
4685    )
4686}
4687
4688fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
4689    is_windows && passthrough == ["resume"]
4690}
4691
4692fn build_tui_command(
4693    cli: &Cli,
4694    resolved_runtime: &ResolvedRuntimeOptions,
4695    passthrough: Vec<String>,
4696) -> Result<Command> {
4697    build_tui_command_with_paths(
4698        cli,
4699        resolved_runtime,
4700        passthrough,
4701        cli.config.as_deref(),
4702        cli.workspace.as_deref(),
4703    )
4704}
4705
4706fn build_tui_command_with_paths(
4707    cli: &Cli,
4708    resolved_runtime: &ResolvedRuntimeOptions,
4709    passthrough: Vec<String>,
4710    config_path: Option<&Path>,
4711    workspace_path: Option<&Path>,
4712) -> Result<Command> {
4713    let tui = locate_sibling_tui_binary()?;
4714    let mut verbosity = if cli.profile.is_some() {
4715        cli.verbosity.clone()
4716    } else {
4717        resolved_runtime.verbosity.clone()
4718    };
4719    if verbosity.is_none()
4720        && passthrough
4721            .iter()
4722            .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
4723    {
4724        verbosity = Some("concise".to_string());
4725    }
4726
4727    let mut cmd = Command::new(&tui);
4728    if let Some(config) = config_path {
4729        cmd.arg("--config").arg(config);
4730    }
4731    if let Some(profile) = cli.profile.as_ref() {
4732        cmd.arg("--profile").arg(profile);
4733    }
4734    if let Some(workspace) = workspace_path {
4735        cmd.arg("--workspace").arg(workspace);
4736    }
4737    if cli.mouse_capture {
4738        cmd.arg("--mouse-capture");
4739    }
4740    if cli.no_mouse_capture {
4741        cmd.arg("--no-mouse-capture");
4742    }
4743    if cli.skip_onboarding {
4744        cmd.arg("--skip-onboarding");
4745    }
4746    if cli.no_project_config {
4747        cmd.arg("--no-project-config");
4748    }
4749    cmd.args(passthrough);
4750
4751    let uses_raw_tui_provider = cli
4752        .provider
4753        .as_deref()
4754        .is_some_and(|provider| builtin_provider_arg(provider).is_none());
4755    let keyring_bridge_provider = resolved_runtime.provider;
4756    let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
4757    let keyring_bridge_source = resolved_runtime.api_key_source;
4758
4759    if let Some(provider) = cli.provider.as_deref() {
4760        let provider = builtin_provider_arg(provider)
4761            .map(ProviderKind::from)
4762            .map_or_else(
4763                || provider.to_string(),
4764                |provider| provider.as_str().to_string(),
4765            );
4766        // Set both names so an inherited CODEWHALE_PROVIDER cannot outrank the
4767        // explicit CLI pin when the TUI applies its environment overrides.
4768        cmd.env("CODEWHALE_PROVIDER", &provider);
4769        cmd.env("DEEPSEEK_PROVIDER", provider);
4770    }
4771    if !(uses_raw_tui_provider
4772        || (cli.profile.is_some()
4773            && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
4774        && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
4775        && let Some(api_key) = keyring_bridge_api_key
4776    {
4777        // TUI reloads auth_mode from config/profile, but it does not re-query the
4778        // platform keyring on normal startup. Bridge only the recovered secret;
4779        // replaying auth_mode here would turn it back into a profile override.
4780        cmd.env("DEEPSEEK_API_KEY", api_key);
4781        for var in provider_env_vars(keyring_bridge_provider) {
4782            if *var != "DEEPSEEK_API_KEY" {
4783                cmd.env(var, api_key);
4784            }
4785        }
4786        cmd.env(
4787            "DEEPSEEK_API_KEY_SOURCE",
4788            RuntimeApiKeySource::Keyring.as_env_value(),
4789        );
4790    }
4791
4792    // For every forwarded flag below, set both the canonical CODEWHALE_* name
4793    // and the legacy DEEPSEEK_* alias so an inherited CODEWHALE_* shell export
4794    // cannot outrank the explicit CLI flag when the TUI applies its
4795    // CODEWHALE-first environment overrides.
4796    if let Some(model) = cli.model.as_ref() {
4797        cmd.env("CODEWHALE_MODEL", model);
4798        cmd.env("DEEPSEEK_MODEL", model);
4799    }
4800    if let Some(output_mode) = cli.output_mode.as_ref() {
4801        cmd.env("CODEWHALE_OUTPUT_MODE", output_mode);
4802        cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
4803    }
4804    if let Some(v) = verbosity.as_ref() {
4805        cmd.env("CODEWHALE_VERBOSITY", v);
4806        cmd.env("DEEPSEEK_VERBOSITY", v);
4807    }
4808    if let Some(log_level) = cli.log_level.as_ref() {
4809        cmd.env("CODEWHALE_LOG_LEVEL", log_level);
4810        cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
4811    }
4812    // Forward the *resolved* value, never the raw flag, and forward it
4813    // unconditionally including `false`. Forwarding only `Some(flag)`
4814    // overwrote an inherited `CODEWHALE_TELEMETRY=0` in the child's
4815    // environment, so `CODEWHALE_TELEMETRY=0 codewhale --telemetry true`
4816    // handed the TUI — which is the process that would emit — an environment
4817    // that resolves on. The child re-resolves from its own environment and
4818    // config file, and must not be able to fall back past the floor the
4819    // dispatcher has already applied.
4820    let telemetry = resolved_runtime.telemetry.to_string();
4821    cmd.env("CODEWHALE_TELEMETRY", &telemetry);
4822    cmd.env("DEEPSEEK_TELEMETRY", &telemetry);
4823    // …and state *why*, because the value alone cannot say. Off is the shipped
4824    // default, so the child receives `false` on every ordinary run and cannot
4825    // tell that from an operator who declared a kill switch. The child needs
4826    // the difference exactly once: the first-run notice must not ask a question
4827    // whose answer this environment overrides, and answering it must not
4828    // reverse a decision somebody already made. Stated on every run, so an
4829    // inherited marker can never leak in either direction.
4830    let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force();
4831    cmd.env(
4832        codewhale_config::TELEMETRY_FLOOR_ENV,
4833        if floor { "1" } else { "0" },
4834    );
4835    // The endpoint travels with the switch. The child re-validates it — plain
4836    // `http://` to anything that is not loopback is refused there, and there is
4837    // no environment variable that overrides that refusal — so forwarding is a
4838    // convenience, never an authorization.
4839    if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
4840        cmd.env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
4841        cmd.env("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint);
4842    }
4843    if let Some(policy) = cli.approval_policy.as_ref() {
4844        cmd.env("CODEWHALE_APPROVAL_POLICY", policy);
4845        cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
4846    }
4847    if let Some(mode) = cli.sandbox_mode.as_ref() {
4848        cmd.env("CODEWHALE_SANDBOX_MODE", mode);
4849        cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
4850    }
4851    if cli.yolo {
4852        cmd.env("CODEWHALE_YOLO", "true");
4853        cmd.env("DEEPSEEK_YOLO", "true");
4854    }
4855    if let Some(api_key) = cli.api_key.as_ref() {
4856        // `--profile` is resolved by the TUI after this facade starts it, so
4857        // the base ConfigStore provider may not be the effective provider.
4858        // Carry the explicit secret through a provider-neutral, source-marked
4859        // slot; the TUI applies it after profile/OAuth resolution and before
4860        // saved API-key slots. Preserve legacy provider envs only when their
4861        // identity is already unambiguous here.
4862        cmd.env("CODEWHALE_CLI_API_KEY", api_key);
4863        if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
4864            cmd.env("DEEPSEEK_API_KEY", api_key);
4865            for var in provider_env_vars(resolved_runtime.provider) {
4866                if *var != "DEEPSEEK_API_KEY" {
4867                    cmd.env(var, api_key);
4868                }
4869            }
4870        }
4871        cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
4872    }
4873    if let Some(base_url) = cli.base_url.as_ref() {
4874        cmd.env("CODEWHALE_BASE_URL", base_url);
4875        cmd.env("DEEPSEEK_BASE_URL", base_url);
4876    }
4877
4878    Ok(cmd)
4879}
4880
4881fn tui_child_exit_code(status: std::process::ExitStatus) -> Option<i32> {
4882    if let Some(code) = status.code() {
4883        return Some(code);
4884    }
4885
4886    #[cfg(unix)]
4887    {
4888        use std::os::unix::process::ExitStatusExt;
4889
4890        status.signal().map(|signal| 128 + signal)
4891    }
4892
4893    #[cfg(not(unix))]
4894    {
4895        None
4896    }
4897}
4898
4899fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
4900    if let Some(code) = tui_child_exit_code(status) {
4901        std::process::exit(code);
4902    }
4903    bail!("codewhale-tui terminated without an exit code")
4904}
4905
4906// There is deliberately no "just run the TUI with these args" helper here. One
4907// existed, `thread resume`/`thread fork` used it, and it forwarded neither
4908// `--config` nor the resolved telemetry value — so the kill switch the
4909// dispatcher had already applied never reached the process that emits. Every
4910// delegation goes through `build_tui_command_with_paths`, and
4911// `only_one_function_may_locate_and_spawn_the_tui` pins that.
4912
4913fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
4914    format!(
4915        "failed to spawn companion TUI binary at {}: {err}\n\
4916\n\
4917The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
4918to execute it. Common fixes:\n\
4919  - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
4920  - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
4921come from the same install directory.\n\
4922  - If you downloaded release assets manually, keep both `codewhale` and \
4923`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
4924  - Set CODEWHALE_TUI_BIN (legacy alias: DEEPSEEK_TUI_BIN) to the absolute \
4925path of a working `codewhale-tui` binary.",
4926        tui.display()
4927    )
4928}
4929
4930/// Resolve the sibling `codewhale-tui` executable next to the running
4931/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
4932/// the npm-distributed Windows package — which ships
4933/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).
4934///
4935/// `CODEWHALE_TUI_BIN` (legacy alias: `DEEPSEEK_TUI_BIN`) is consulted first
4936/// as an explicit override for custom installs and CI test layouts. On
4937/// Windows we additionally try the suffix-less name as a fallback for users
4938/// who already manually renamed the file before this fix landed.
4939fn locate_sibling_tui_binary() -> Result<PathBuf> {
4940    for var in ["CODEWHALE_TUI_BIN", "DEEPSEEK_TUI_BIN"] {
4941        if let Ok(override_path) = std::env::var(var) {
4942            let candidate = PathBuf::from(override_path);
4943            if candidate.is_file() {
4944                return Ok(candidate);
4945            }
4946            bail!(
4947                "{var} points at {}, which is not a regular file.",
4948                candidate.display()
4949            );
4950        }
4951    }
4952
4953    let current = std::env::current_exe().context("failed to locate current executable path")?;
4954    if let Some(found) = sibling_tui_candidate(&current) {
4955        return Ok(found);
4956    }
4957
4958    // Build a stable error path so the user sees the platform-correct
4959    // expected name, not "codewhale-tui" on Windows.
4960    let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
4961    bail!(
4962        "Companion `codewhale-tui` binary not found at {}.\n\
4963\n\
4964The `codewhale` dispatcher delegates interactive sessions to a sibling \
4965`codewhale-tui` binary. To fix this, install one of:\n\
4966  • npm:    npm install -g codewhale                (downloads both binaries)\n\
4967  • cargo:  cargo install codewhale-cli codewhale-tui --locked\n\
4968  • GitHub Releases: download BOTH `codewhale-<platform>` AND \
4969`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
4970and place them in the same directory.\n\
4971\n\
4972Or set CODEWHALE_TUI_BIN (legacy alias: DEEPSEEK_TUI_BIN) to the absolute path \
4973of an existing `codewhale-tui` binary.",
4974        expected.display()
4975    );
4976}
4977
4978/// Return the first existing sibling-binary path under any of the names
4979/// `codewhale-tui` might use on this platform. Pure function to keep
4980/// `locate_sibling_tui_binary` testable.
4981fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
4982    // Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
4983    // on Windows.
4984    let primary =
4985        dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
4986    if primary.is_file() {
4987        return Some(primary);
4988    }
4989    // Windows fallback: a user who manually renamed `.exe` away (per the
4990    // workaround in #247) still launches successfully under the new code.
4991    if cfg!(windows) {
4992        let suffixless = dispatcher.with_file_name("codewhale-tui");
4993        if suffixless.is_file() {
4994            return Some(suffixless);
4995        }
4996    }
4997    None
4998}
4999
5000fn run_metrics_command(args: MetricsArgs) -> Result<()> {
5001    let since = match args.since.as_deref() {
5002        Some(s) => {
5003            Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
5004        }
5005        None => None,
5006    };
5007    metrics::run(metrics::MetricsArgs {
5008        json: args.json,
5009        since,
5010    })
5011}
5012
5013fn read_api_key_from_stdin() -> Result<String> {
5014    let mut input = String::new();
5015    io::stdin()
5016        .read_to_string(&mut input)
5017        .context("failed to read api key from stdin")?;
5018    let key = input.trim().to_string();
5019    if key.is_empty() {
5020        bail!("empty API key provided");
5021    }
5022    Ok(key)
5023}
5024
5025#[cfg(test)]
5026mod tests {
5027    use super::*;
5028    use clap::error::ErrorKind;
5029    use codewhale_config::{ModelSource, ProviderSource};
5030    use std::ffi::OsString;
5031    use std::sync::{Mutex, OnceLock};
5032
5033    fn parse_ok(argv: &[&str]) -> Cli {
5034        Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
5035    }
5036
5037    fn help_for(argv: &[&str]) -> String {
5038        let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
5039        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
5040        err.to_string()
5041    }
5042
5043    fn command_env(cmd: &Command, name: &str) -> Option<String> {
5044        let name = std::ffi::OsStr::new(name);
5045        cmd.get_envs().find_map(|(key, value)| {
5046            if key == name {
5047                value.map(|v| v.to_string_lossy().into_owned())
5048            } else {
5049                None
5050            }
5051        })
5052    }
5053
5054    pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
5055        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5056        LOCK.get_or_init(|| Mutex::new(()))
5057            .lock()
5058            .unwrap_or_else(|p| p.into_inner())
5059    }
5060
5061    pub(crate) struct ScopedEnvVar {
5062        name: &'static str,
5063        previous: Option<OsString>,
5064    }
5065
5066    impl ScopedEnvVar {
5067        pub(crate) fn set(name: &'static str, value: &str) -> Self {
5068            let previous = std::env::var_os(name);
5069            // Safety: tests using this helper serialize with env_lock() and
5070            // restore the original value in Drop.
5071            unsafe { std::env::set_var(name, value) };
5072            Self { name, previous }
5073        }
5074
5075        pub(crate) fn remove(name: &'static str) -> Self {
5076            let previous = std::env::var_os(name);
5077            // Safety: tests using this helper serialize with env_lock() and
5078            // restore the original value in Drop.
5079            unsafe { std::env::remove_var(name) };
5080            Self { name, previous }
5081        }
5082    }
5083
5084    impl Drop for ScopedEnvVar {
5085        fn drop(&mut self) {
5086            // Safety: tests using this helper serialize with env_lock().
5087            unsafe {
5088                if let Some(previous) = self.previous.take() {
5089                    std::env::set_var(self.name, previous);
5090                } else {
5091                    std::env::remove_var(self.name);
5092                }
5093            }
5094        }
5095    }
5096
5097    #[derive(Default)]
5098    struct RecordingKeyringStore {
5099        gets: Mutex<Vec<String>>,
5100        values: Mutex<std::collections::BTreeMap<String, String>>,
5101    }
5102
5103    impl RecordingKeyringStore {
5104        fn set_value(&self, key: &str, value: &str) {
5105            self.values
5106                .lock()
5107                .expect("recording values lock")
5108                .insert(key.to_string(), value.to_string());
5109        }
5110
5111        fn queried(&self) -> Vec<String> {
5112            self.gets.lock().expect("recording gets lock").clone()
5113        }
5114    }
5115
5116    impl codewhale_secrets::KeyringStore for RecordingKeyringStore {
5117        fn get(
5118            &self,
5119            key: &str,
5120        ) -> std::result::Result<Option<String>, codewhale_secrets::SecretsError> {
5121            self.gets
5122                .lock()
5123                .expect("recording gets lock")
5124                .push(key.to_string());
5125            Ok(self
5126                .values
5127                .lock()
5128                .expect("recording values lock")
5129                .get(key)
5130                .cloned())
5131        }
5132
5133        fn set(
5134            &self,
5135            key: &str,
5136            value: &str,
5137        ) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5138            self.set_value(key, value);
5139            Ok(())
5140        }
5141
5142        fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5143            self.values
5144                .lock()
5145                .expect("recording values lock")
5146                .remove(key);
5147            Ok(())
5148        }
5149
5150        fn backend_name(&self) -> &'static str {
5151            "recording"
5152        }
5153    }
5154
5155    fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
5156        let dir = tempfile::TempDir::new().expect("tempdir");
5157        let custom = dir
5158            .path()
5159            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5160        std::fs::write(&custom, b"").unwrap();
5161        let custom_str = custom.to_string_lossy();
5162        let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5163        (dir, bin)
5164    }
5165
5166    fn resolved_runtime_for_test(
5167        provider: ProviderKind,
5168        provider_source: ProviderSource,
5169    ) -> ResolvedRuntimeOptions {
5170        ResolvedRuntimeOptions {
5171            provider,
5172            provider_source,
5173            model: "test-model".to_string(),
5174            model_source: ModelSource::ProviderDefault,
5175            api_key: None,
5176            api_key_source: None,
5177            base_url: "http://localhost:8000/v1".to_string(),
5178            auth_mode: None,
5179            insecure_skip_tls_verify: false,
5180            output_mode: None,
5181            log_level: None,
5182            telemetry: false,
5183            telemetry_explicit_off: false,
5184            telemetry_endpoint: None,
5185            approval_policy: None,
5186            sandbox_mode: None,
5187            yolo: None,
5188            verbosity: None,
5189            http_headers: std::collections::BTreeMap::new(),
5190        }
5191    }
5192
5193    #[test]
5194    fn clap_command_definition_is_consistent() {
5195        Cli::command().debug_assert();
5196    }
5197
5198    // Regression for #767: `run_cli` prints the full anyhow chain so users
5199    // see the underlying TOML parser error (line/column, expected token)
5200    // instead of just the top-level "failed to parse config at <path>"
5201    // wrapper. anyhow's bare `Display` impl drops the chain — pin both
5202    // pieces here so a future refactor of the printing path doesn't
5203    // silently regress.
5204    #[test]
5205    fn anyhow_chain_surfaces_toml_parse_cause() {
5206        use anyhow::Context;
5207        let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
5208        let err = Err::<(), _>(inner)
5209            .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
5210            .unwrap_err();
5211
5212        // What `eprintln!("error: {err}")` prints (top context only).
5213        assert_eq!(
5214            err.to_string(),
5215            "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
5216        );
5217
5218        // What the `for cause in err.chain().skip(1)` loop iterates over.
5219        let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
5220        assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
5221    }
5222
5223    #[test]
5224    fn malformed_persisted_mcp_json_omits_secret_contents_and_keys() {
5225        let secret = "sentinel";
5226        let raw =
5227            format!(r#"[{{"name":"private","env":{{"PRIVATE_TOKEN":"{secret}"}} trailing-junk}}]"#);
5228        let error = parse_mcp_server_definitions(&raw).expect_err("malformed JSON must fail");
5229        let diagnostic = format!("{error:#}");
5230        assert!(!diagnostic.contains(secret), "{diagnostic}");
5231        assert!(!diagnostic.contains("PRIVATE_TOKEN"), "{diagnostic}");
5232        assert!(diagnostic.contains("contents were omitted"), "{diagnostic}");
5233    }
5234
5235    #[test]
5236    fn parses_config_command_matrix() {
5237        let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
5238        assert!(matches!(
5239            cli.command,
5240            Some(Commands::Config(ConfigArgs {
5241                command: ConfigCommand::Get { ref key }
5242            })) if key == "provider"
5243        ));
5244
5245        let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
5246        assert!(matches!(
5247            cli.command,
5248            Some(Commands::Config(ConfigArgs {
5249                command: ConfigCommand::Set { ref key, ref value }
5250            })) if key == "model" && value == "deepseek-v4-flash"
5251        ));
5252
5253        let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
5254        assert!(matches!(
5255            cli.command,
5256            Some(Commands::Config(ConfigArgs {
5257                command: ConfigCommand::Unset { ref key }
5258            })) if key == "model"
5259        ));
5260
5261        assert!(matches!(
5262            parse_ok(&["deepseek", "config", "list"]).command,
5263            Some(Commands::Config(ConfigArgs {
5264                command: ConfigCommand::List
5265            }))
5266        ));
5267        assert!(matches!(
5268            parse_ok(&["deepseek", "config", "path"]).command,
5269            Some(Commands::Config(ConfigArgs {
5270                command: ConfigCommand::Path
5271            }))
5272        ));
5273    }
5274
5275    #[test]
5276    fn parses_update_beta_flag() {
5277        let cli = parse_ok(&["codewhale", "update"]);
5278        assert!(matches!(
5279            cli.command,
5280            Some(Commands::Update(UpdateArgs {
5281                beta: false,
5282                check: false,
5283                proxy: None
5284            }))
5285        ));
5286
5287        let cli = parse_ok(&["codewhale", "update", "--beta"]);
5288        assert!(matches!(
5289            cli.command,
5290            Some(Commands::Update(UpdateArgs {
5291                beta: true,
5292                check: false,
5293                proxy: None
5294            }))
5295        ));
5296
5297        let cli = parse_ok(&["codewhale", "update", "--check"]);
5298        assert!(matches!(
5299            cli.command,
5300            Some(Commands::Update(UpdateArgs {
5301                beta: false,
5302                check: true,
5303                proxy: None
5304            }))
5305        ));
5306
5307        let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
5308        let Some(Commands::Update(args)) = cli.command else {
5309            panic!("expected update command");
5310        };
5311        assert!(!args.beta);
5312        assert!(!args.check);
5313        assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
5314    }
5315
5316    #[test]
5317    fn parses_model_command_matrix() {
5318        let cli = parse_ok(&["deepseek", "model", "list"]);
5319        assert!(matches!(
5320            cli.command,
5321            Some(Commands::Model(ModelArgs {
5322                command: ModelCommand::List { provider: None }
5323            }))
5324        ));
5325
5326        let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
5327        assert!(matches!(
5328            cli.command,
5329            Some(Commands::Model(ModelArgs {
5330                command: ModelCommand::List {
5331                    provider: Some(ProviderArg::Openai)
5332                }
5333            }))
5334        ));
5335
5336        let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
5337        assert!(matches!(
5338            cli.command,
5339            Some(Commands::Model(ModelArgs {
5340                command: ModelCommand::Resolve {
5341                    model: Some(ref model),
5342                    provider: None
5343                }
5344            })) if model == "deepseek-v4-flash"
5345        ));
5346
5347        let cli = parse_ok(&[
5348            "deepseek",
5349            "model",
5350            "resolve",
5351            "--provider",
5352            "deepseek",
5353            "deepseek-v4-pro",
5354        ]);
5355        assert!(matches!(
5356            cli.command,
5357            Some(Commands::Model(ModelArgs {
5358                command: ModelCommand::Resolve {
5359                    model: Some(ref model),
5360                    provider: Some(ProviderArg::Deepseek)
5361                }
5362            })) if model == "deepseek-v4-pro"
5363        ));
5364
5365        let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
5366        assert!(matches!(
5367            cli.command,
5368            Some(Commands::Model(ModelArgs {
5369                command: ModelCommand::Set { ref model }
5370            })) if model == "pro"
5371        ));
5372    }
5373
5374    #[test]
5375    fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
5376        assert_eq!(
5377            model_command_provider_hint(None, Some(ProviderKind::Zai)),
5378            Some(ProviderKind::Zai)
5379        );
5380        assert_eq!(
5381            model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
5382            Some(ProviderKind::Minimax)
5383        );
5384        assert_eq!(model_command_provider_hint(None, None), None);
5385
5386        let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
5387        assert_eq!(cli.provider.as_deref(), Some("zai"));
5388        assert!(matches!(
5389            cli.command,
5390            Some(Commands::Model(ModelArgs {
5391                command: ModelCommand::List { provider: None }
5392            }))
5393        ));
5394    }
5395
5396    #[test]
5397    fn parses_thread_command_matrix() {
5398        let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
5399        assert!(matches!(
5400            cli.command,
5401            Some(Commands::Thread(ThreadArgs {
5402                command: ThreadCommand::List {
5403                    all: true,
5404                    limit: Some(50)
5405                }
5406            }))
5407        ));
5408
5409        let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
5410        assert!(matches!(
5411            cli.command,
5412            Some(Commands::Thread(ThreadArgs {
5413                command: ThreadCommand::Read { ref thread_id }
5414            })) if thread_id == "thread-1"
5415        ));
5416
5417        let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
5418        assert!(matches!(
5419            cli.command,
5420            Some(Commands::Thread(ThreadArgs {
5421                command: ThreadCommand::Resume { ref thread_id }
5422            })) if thread_id == "thread-2"
5423        ));
5424
5425        let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
5426        assert!(matches!(
5427            cli.command,
5428            Some(Commands::Thread(ThreadArgs {
5429                command: ThreadCommand::Fork { ref thread_id }
5430            })) if thread_id == "thread-3"
5431        ));
5432
5433        let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
5434        assert!(matches!(
5435            cli.command,
5436            Some(Commands::Thread(ThreadArgs {
5437                command: ThreadCommand::Archive { ref thread_id }
5438            })) if thread_id == "thread-4"
5439        ));
5440
5441        let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
5442        assert!(matches!(
5443            cli.command,
5444            Some(Commands::Thread(ThreadArgs {
5445                command: ThreadCommand::Unarchive { ref thread_id }
5446            })) if thread_id == "thread-5"
5447        ));
5448
5449        let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
5450        assert!(matches!(
5451            cli.command,
5452            Some(Commands::Thread(ThreadArgs {
5453                command: ThreadCommand::SetName {
5454                    ref thread_id,
5455                    ref name
5456                }
5457            })) if thread_id == "thread-6" && name == "My Thread"
5458        ));
5459
5460        let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
5461        assert!(matches!(
5462            cli.command,
5463            Some(Commands::Thread(ThreadArgs {
5464                command: ThreadCommand::ClearName { ref thread_id }
5465            })) if thread_id == "thread-7"
5466        ));
5467    }
5468
5469    #[test]
5470    fn parses_sandbox_app_server_and_completion_matrix() {
5471        let cli = parse_ok(&[
5472            "deepseek",
5473            "sandbox",
5474            "check",
5475            "echo hello",
5476            "--ask",
5477            "on-failure",
5478        ]);
5479        assert!(matches!(
5480            cli.command,
5481            Some(Commands::Sandbox(SandboxArgs {
5482                command: SandboxCommand::Check {
5483                    ref command,
5484                    ask: ApprovalModeArg::OnFailure
5485                }
5486            })) if command == "echo hello"
5487        ));
5488
5489        let cli = parse_ok(&[
5490            "deepseek",
5491            "app-server",
5492            "--host",
5493            "0.0.0.0",
5494            "--port",
5495            "9999",
5496        ]);
5497        assert!(matches!(
5498            cli.command,
5499            Some(Commands::AppServer(AppServerArgs {
5500                host: Some(ref host),
5501                port: Some(9999),
5502                stdio: false,
5503                http: false,
5504                mobile: false,
5505                ..
5506            })) if host == "0.0.0.0"
5507        ));
5508
5509        let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
5510        assert!(matches!(
5511            cli.command,
5512            Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
5513        ));
5514
5515        let cli = parse_ok(&["deepseek", "completion", "bash"]);
5516        assert!(matches!(
5517            cli.command,
5518            Some(Commands::Completion { shell: Shell::Bash })
5519        ));
5520    }
5521
5522    #[test]
5523    fn app_server_transports_are_mutually_exclusive() {
5524        assert!(matches!(
5525            parse_ok(&["deepseek", "app-server", "--http"]).command,
5526            Some(Commands::AppServer(AppServerArgs {
5527                http: true,
5528                mobile: false,
5529                stdio: false,
5530                ..
5531            }))
5532        ));
5533        assert!(matches!(
5534            parse_ok(&["deepseek", "app-server", "--mobile"]).command,
5535            Some(Commands::AppServer(AppServerArgs {
5536                mobile: true,
5537                http: false,
5538                stdio: false,
5539                ..
5540            }))
5541        ));
5542
5543        for argv in [
5544            ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
5545            ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
5546            ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
5547        ] {
5548            let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
5549            assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
5550        }
5551    }
5552
5553    #[test]
5554    fn app_server_qr_requires_mobile() {
5555        let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
5556            .expect_err("--qr without --mobile must fail");
5557        assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
5558        assert!(matches!(
5559            parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
5560            Some(Commands::AppServer(AppServerArgs {
5561                mobile: true,
5562                qr: true,
5563                ..
5564            }))
5565        ));
5566    }
5567
5568    #[test]
5569    fn app_server_serve_passthrough_maps_flags_to_serve() {
5570        let args = AppServerArgs {
5571            http: true,
5572            mobile: false,
5573            stdio: false,
5574            qr: false,
5575            host: Some("127.0.0.1".to_string()),
5576            port: Some(9000),
5577            workers: Some(4),
5578            config: None,
5579            auth_token: Some("tok".to_string()),
5580            insecure_no_auth: true,
5581            cors_origin: vec!["http://localhost:5173".to_string()],
5582        };
5583        let argv = app_server_serve_passthrough(&args);
5584        let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
5585        // app-server's --insecure-no-auth maps onto serve's --insecure.
5586        assert_eq!(
5587            as_str,
5588            vec![
5589                "serve",
5590                "--http",
5591                "--host",
5592                "127.0.0.1",
5593                "--port",
5594                "9000",
5595                "--workers",
5596                "4",
5597                "--cors-origin",
5598                "http://localhost:5173",
5599                "--auth-token",
5600                "tok",
5601                "--insecure",
5602            ]
5603        );
5604    }
5605
5606    #[test]
5607    fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
5608        let args = AppServerArgs {
5609            http: false,
5610            mobile: true,
5611            stdio: false,
5612            qr: true,
5613            host: None,
5614            port: None,
5615            workers: None,
5616            config: None,
5617            auth_token: None,
5618            insecure_no_auth: false,
5619            cors_origin: vec![],
5620        };
5621        let argv = app_server_serve_passthrough(&args);
5622        let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
5623        // No host/port forwarded → serve applies its own --mobile 0.0.0.0 default.
5624        // No auth token is injected from the environment into child argv.
5625        assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
5626    }
5627
5628    #[test]
5629    fn web_command_is_typed_and_delegates_without_auth_material() {
5630        let cli = parse_ok(&["codewhale", "web", "--port", "9091"]);
5631        let args = match cli.command {
5632            Some(Commands::Web(args)) => args,
5633            other => panic!("expected web command, got {other:?}"),
5634        };
5635        assert_eq!(args.port, 9091);
5636        let forwarded = web_serve_passthrough(&args);
5637        assert_eq!(forwarded, ["serve", "--web", "--port", "9091"]);
5638        assert!(!forwarded.iter().any(|arg| arg.contains("token")));
5639    }
5640
5641    #[test]
5642    fn web_command_defaults_to_runtime_port_and_documents_bootstrap_boundary() {
5643        let cli = parse_ok(&["codewhale", "web"]);
5644        assert!(matches!(
5645            cli.command,
5646            Some(Commands::Web(WebArgs { port: 7878 }))
5647        ));
5648        let help = help_for(&["codewhale", "web", "--help"]);
5649        assert!(help.contains("--port"));
5650        assert!(help.contains("one-time loopback bootstrap"));
5651        assert!(!help.contains("--auth-token"));
5652    }
5653
5654    #[test]
5655    fn serve_help_documents_forwarded_runtime_modes() {
5656        let help = help_for(&["codewhale", "serve", "--help"]);
5657        for flag in ["--http", "--mobile", "--web", "--mcp", "--acp"] {
5658            assert!(
5659                help.contains(flag),
5660                "serve help should document forwarded flag {flag}; help was:\n{help}"
5661            );
5662        }
5663        assert!(help.contains("compatibility"));
5664    }
5665
5666    #[test]
5667    fn parses_direct_tui_command_aliases() {
5668        let cli = parse_ok(&["deepseek", "doctor"]);
5669        assert!(matches!(
5670            cli.command,
5671            Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
5672        ));
5673
5674        let cli = parse_ok(&["deepseek", "models", "--json"]);
5675        assert!(matches!(
5676            cli.command,
5677            Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
5678        ));
5679
5680        let cli = parse_ok(&["deepseek", "resume", "abc123"]);
5681        assert!(matches!(
5682            cli.command,
5683            Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
5684        ));
5685
5686        let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
5687        assert!(matches!(
5688            cli.command,
5689            Some(Commands::Setup(TuiPassthroughArgs { ref args }))
5690                if args == &["--skills", "--local"]
5691        ));
5692
5693        let cli = parse_ok(&["codewhale", "fleet", "init"]);
5694        assert!(cli.prompt.is_empty());
5695        assert!(matches!(
5696            cli.command,
5697            Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
5698        ));
5699
5700        let cli = parse_ok(&[
5701            "codewhale",
5702            "fleet",
5703            "run",
5704            "tasks.json",
5705            "--max-workers",
5706            "2",
5707        ]);
5708        assert!(cli.prompt.is_empty());
5709        assert!(matches!(
5710            cli.command,
5711            Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
5712                if args == &["run", "tasks.json", "--max-workers", "2"]
5713        ));
5714
5715        let cli = parse_ok(&[
5716            "codewhale",
5717            "workflow",
5718            "run",
5719            "stopship",
5720            "--fleet",
5721            "stopship",
5722            "--runtime",
5723            "tmux",
5724            "--issue",
5725            "4375",
5726        ]);
5727        assert!(matches!(
5728            cli.command,
5729            Some(Commands::Workflow(WorkflowArgs {
5730                command: WorkflowCommand::Run {
5731                    ref workflow,
5732                    ref fleet,
5733                    ref runtime,
5734                    ref issue,
5735                    ..
5736                }
5737            })) if workflow == "stopship"
5738                && fleet.as_deref() == Some("stopship")
5739                && runtime == "tmux"
5740                && issue.as_deref() == Some("4375")
5741        ));
5742    }
5743
5744    #[test]
5745    fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
5746        let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
5747        assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
5748        assert_eq!(
5749            top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
5750                .expect("built-in Exec provider"),
5751            Some(ProviderKind::Openrouter)
5752        );
5753
5754        for (provider, command) in [
5755            ("qianfan", vec!["exec", "Reply OK"]),
5756            ("lm-studio", vec!["exec", "Reply OK"]),
5757            ("lm-studio", vec!["fleet", "status"]),
5758        ] {
5759            let argv = std::iter::once("codewhale")
5760                .chain(["--provider", provider])
5761                .chain(command.iter().copied())
5762                .collect::<Vec<_>>();
5763            let cli = parse_ok(&argv);
5764            assert_eq!(cli.provider.as_deref(), Some(provider));
5765            assert_eq!(
5766                top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
5767                    .expect("raw TUI provider"),
5768                None,
5769                "{argv:?} should defer the raw provider id to the TUI"
5770            );
5771        }
5772    }
5773
5774    #[test]
5775    fn opencode_go_provider_aliases_parse_as_builtin() {
5776        for alias in ["opencode-go", "opencode_go", "opencodego"] {
5777            assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeGo));
5778        }
5779    }
5780
5781    #[test]
5782    fn legacy_dual_wire_provider_flag_keeps_named_table_kind() {
5783        // The CLI flag must resolve legacy spellings to the table-owning
5784        // dialect kind (mirroring TOML serde), never to the collapsed catalog
5785        // primary, or the user's own [providers.*] table is orphaned.
5786        for alias in [
5787            "minimax-anthropic",
5788            "minimax_anthropic",
5789            "mini-max-anthropic",
5790            "mini_max_anthropic",
5791        ] {
5792            assert_eq!(
5793                builtin_provider_arg(alias),
5794                Some(ProviderArg::MinimaxAnthropic),
5795                "{alias}"
5796            );
5797        }
5798        let cli = parse_ok(&[
5799            "codewhale",
5800            "--provider",
5801            "minimax-anthropic",
5802            "exec",
5803            "Reply OK",
5804        ]);
5805        assert_eq!(
5806            top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
5807                .expect("legacy dual-wire provider"),
5808            Some(ProviderKind::MinimaxAnthropic)
5809        );
5810    }
5811
5812    #[test]
5813    fn opencode_zen_provider_aliases_parse_as_builtin() {
5814        for alias in [
5815            "opencode-zen",
5816            "opencode_zen",
5817            "opencodezen",
5818            "zen",
5819            "opencode",
5820        ] {
5821            assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeZen));
5822        }
5823    }
5824
5825    #[test]
5826    fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
5827        let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
5828        let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
5829            .expect_err("model registry commands still require a built-in provider");
5830        assert!(
5831            err.to_string()
5832                .contains("configured custom providers are accepted only by exec and fleet")
5833        );
5834
5835        let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
5836            .expect_err("auth keeps enum-only provider validation");
5837        assert_eq!(err.kind(), ErrorKind::InvalidValue);
5838
5839        let err = Cli::try_parse_from([
5840            "codewhale",
5841            "--provider",
5842            "../../lm-studio",
5843            "exec",
5844            "Reply OK",
5845        ])
5846        .expect_err("provider ids must stay simple tokens");
5847        assert!(
5848            err.to_string()
5849                .contains("provider must be a simple identifier")
5850        );
5851    }
5852
5853    #[test]
5854    fn persisted_custom_provider_crosses_config_and_root_tui_launch_boundary() {
5855        let _lock = env_lock();
5856        let (_tui_dir, _tui_bin) = install_fake_tui_binary();
5857        let dir = tempfile::TempDir::new().expect("tempdir");
5858        let config_path = dir.path().join("config.toml");
5859        std::fs::write(
5860            &config_path,
5861            r#"provider = "lm-studio"
5862
5863[providers.lm-studio]
5864kind = "openai-compatible"
5865base_url = "http://127.0.0.1:1234/v1"
5866model = "qwen-2.5-7b"
5867"#,
5868        )
5869        .expect("custom provider config fixture");
5870        let store = ConfigStore::load(Some(config_path.clone()))
5871            .expect("a TUI-persisted custom provider must cross the dispatcher parser");
5872        assert_eq!(store.config.provider, ProviderKind::Custom);
5873        assert_eq!(store.config.provider_id(), "lm-studio");
5874
5875        let resolved = store
5876            .config
5877            .resolve_runtime_options(&CliRuntimeOverrides::default());
5878        assert_eq!(resolved.provider, ProviderKind::Custom);
5879        assert_eq!(resolved.base_url, "http://127.0.0.1:1234/v1");
5880        assert_eq!(resolved.model, "qwen-2.5-7b");
5881
5882        let config = config_path.to_string_lossy().into_owned();
5883        let root_cli = parse_ok(&["codewhale", "--config", &config]);
5884        let root_command = build_tui_command(&root_cli, &resolved, Vec::new())
5885            .expect("root launch should reach the TUI command boundary");
5886        let root_args = root_command
5887            .get_args()
5888            .map(|arg| arg.to_string_lossy().into_owned())
5889            .collect::<Vec<_>>();
5890        assert!(
5891            root_args
5892                .windows(2)
5893                .any(|args| args == ["--config", &config])
5894        );
5895        assert_eq!(command_env(&root_command, "CODEWHALE_PROVIDER"), None);
5896        assert_eq!(command_env(&root_command, "DEEPSEEK_PROVIDER"), None);
5897
5898        let cli = parse_ok(&[
5899            "codewhale",
5900            "--config",
5901            &config,
5902            "--provider",
5903            "lm-studio",
5904            "exec",
5905            "Reply OK",
5906        ]);
5907        let prepared = prepare_raw_provider_tui_dispatch(
5908            &cli,
5909            cli.command.as_ref(),
5910            &CliRuntimeOverrides::default(),
5911        )
5912        .expect("prepare raw provider dispatch")
5913        .expect("Exec with a raw provider should bypass dispatcher config resolution");
5914        assert_eq!(prepared.1, ["exec", "Reply OK"].map(str::to_string));
5915    }
5916
5917    #[test]
5918    fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
5919        let cli = parse_ok(&[
5920            "codewhale",
5921            "lane-log-proxy",
5922            "--log-path",
5923            "/tmp/lane.ndjson",
5924            "--receipt-path",
5925            "/tmp/lane.exit.json",
5926            "--receipt-tmp-path",
5927            "/tmp/lane.exit.json.tmp",
5928            "--environment-path",
5929            "/tmp/lane.env.json",
5930            "--lane-id",
5931            "lane-proof",
5932            "--",
5933            "/bin/echo",
5934            "--child-flag",
5935            "hello",
5936        ]);
5937        let (proxy, command) = split_lane_log_proxy_command(cli.command);
5938        assert!(command.is_none());
5939        let proxy = proxy.expect("proxy args");
5940        assert_eq!(proxy.lane_id, "lane-proof");
5941        assert_eq!(
5942            proxy.command,
5943            ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
5944        );
5945
5946        let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
5947        let (proxy, command) = split_lane_log_proxy_command(cli.command);
5948        assert!(proxy.is_none());
5949        assert!(matches!(
5950            command,
5951            Some(Commands::Lane(LaneArgs {
5952                command: LaneCommand::List { json: true }
5953            }))
5954        ));
5955    }
5956
5957    /// #1888: the CLI must expose exactly the Lane verbs the shared contract
5958    /// declares, under the same ids — no CLI-only verb, no missing verb.
5959    #[test]
5960    fn cli_lane_subcommands_cover_the_shared_control_contract() {
5961        use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
5962
5963        for descriptor in codewhale_lane::control::operations_for_domain(ControlDomain::Lane) {
5964            let argv = [
5965                "codewhale".to_string(),
5966                "lane".to_string(),
5967                descriptor.verb.to_string(),
5968            ];
5969            let mut argv: Vec<&str> = argv.iter().map(String::as_str).collect();
5970            if descriptor.target.requires_identity() {
5971                argv.push("lane-a1b2c3d4");
5972            }
5973            let cli = parse_ok(&argv);
5974            let Some(Commands::Lane(args)) = cli.command else {
5975                panic!("`{}` must parse as a lane subcommand", descriptor.verb);
5976            };
5977            let parsed = match args.command {
5978                LaneCommand::List { .. } => ControlOperation::LaneList,
5979                LaneCommand::Status { .. } => ControlOperation::LaneStatus,
5980                LaneCommand::Interrupt { .. } | LaneCommand::Stop { .. } => {
5981                    ControlOperation::LaneInterrupt
5982                }
5983                LaneCommand::Restart { .. } => ControlOperation::LaneRestart,
5984                LaneCommand::Resume { .. } => ControlOperation::LaneResume,
5985                other => panic!(
5986                    "unexpected lane subcommand for {}: {other:?}",
5987                    descriptor.verb
5988                ),
5989            };
5990            assert_eq!(
5991                parsed, descriptor.operation,
5992                "`codewhale lane {}` must map to {}",
5993                descriptor.verb, descriptor.id
5994            );
5995            assert!(
5996                descriptor.offers(ControlSurface::Cli),
5997                "{} must be declared on the CLI surface",
5998                descriptor.id
5999            );
6000        }
6001    }
6002
6003    /// `lane stop` is a compatibility spelling, not a second verb.
6004    #[test]
6005    fn lane_stop_and_interrupt_resolve_to_one_verb() {
6006        use codewhale_lane::{ControlDomain, ControlOperation};
6007
6008        for spelling in ["stop", "interrupt", "cancel", "kill"] {
6009            assert_eq!(
6010                ControlOperation::parse_verb(ControlDomain::Lane, spelling),
6011                Some(ControlOperation::LaneInterrupt),
6012                "{spelling}"
6013            );
6014        }
6015        let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
6016        assert!(matches!(
6017            stop.command,
6018            Some(Commands::Lane(LaneArgs {
6019                command: LaneCommand::Stop { .. }
6020            }))
6021        ));
6022    }
6023
6024    #[test]
6025    fn short_workflow_names_do_not_resolve_version_pinned_files() {
6026        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6027            .join("..")
6028            .join("..");
6029        // A bare short name must never expand to a version-pinned script.
6030        // The v0868_* lane scripts are gone, but the guard stays so a future
6031        // vXXXX_ naming habit cannot silently become resolvable.
6032        let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
6033        assert!(candidates.iter().all(|path| {
6034            !path
6035                .file_name()
6036                .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
6037        }));
6038        assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
6039
6040        // An explicit repo-relative path still resolves — checked against a
6041        // workflow that actually ships.
6042        let explicit =
6043            resolve_workflow_source_path("workflows/stopship.workflow.js", None, &workspace)
6044                .expect("explicit workflow path");
6045        assert!(explicit.ends_with("workflows/stopship.workflow.js"));
6046    }
6047
6048    #[test]
6049    fn workflow_run_resolves_stopship_alias_and_payload() {
6050        let _lock = env_lock();
6051        let (_dir, _tui) = install_fake_tui_binary();
6052        let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
6053        let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
6054        let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
6055        let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
6056        let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
6057        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6058            .join("..")
6059            .join("..");
6060        let cli = parse_ok(&[
6061            "codewhale",
6062            "--profile",
6063            "workflow-profile",
6064            "--model",
6065            "explicit-workflow-model",
6066            "--api-key",
6067            "explicit-profile-key",
6068            "--workspace",
6069            workspace.to_str().expect("workspace UTF-8"),
6070        ]);
6071        let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
6072        let source = resolve_workflow_source_path("stopship", None, &workspace)
6073            .expect("stopship workflow source");
6074        assert!(source.ends_with("workflows/stopship.workflow.js"));
6075
6076        let process = workflow_exec_command(WorkflowExecSpec {
6077            cli: &cli,
6078            resolved_runtime: &resolved,
6079            config_path: &workspace.join("config.toml"),
6080            source_root: &workspace,
6081            source_path: &source,
6082            workflow: "stopship",
6083            fleet: Some("stopship"),
6084            issue: Some("4375"),
6085            goal: Some("fix stopship"),
6086            token_budget: Some(25_000),
6087            verify: true,
6088        })
6089        .expect("command");
6090        let joined = process.command.join("\n");
6091        assert!(joined.contains("workflow-tool"));
6092        assert!(joined.contains("explicit-workflow-command"));
6093        assert!(joined.contains("--input-json"));
6094        assert!(!process.command.iter().any(|arg| arg == "exec"));
6095        assert!(!process.command.iter().any(|arg| arg == "--workspace"));
6096        assert!(
6097            process
6098                .command
6099                .windows(2)
6100                .any(|pair| pair == ["--profile", "workflow-profile"])
6101        );
6102        assert!(!joined.contains("Run the CodeWhale"));
6103        assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
6104        assert!(joined.contains("\"fleet\":\"stopship\""));
6105        assert!(joined.contains("\"issue\":\"4375\""));
6106        assert!(joined.contains("\"token_budget\":25000"));
6107        assert!(joined.contains("\"verify\":true"));
6108        assert!(
6109            process.environment.iter().any(|(key, value)| {
6110                key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
6111            })
6112        );
6113        assert!(
6114            !process
6115                .environment
6116                .iter()
6117                .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
6118        );
6119        assert!(
6120            !process
6121                .environment
6122                .iter()
6123                .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
6124        );
6125        assert!(
6126            !process
6127                .environment
6128                .iter()
6129                .any(|(key, _)| key == "DEEPSEEK_API_KEY")
6130        );
6131        assert!(process.environment.iter().any(|(key, value)| {
6132            key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
6133        }));
6134        assert!(
6135            !process
6136                .command
6137                .iter()
6138                .any(|argument| argument.contains("explicit-profile-key"))
6139        );
6140        assert!(
6141            process
6142                .environment
6143                .iter()
6144                .all(|(_, value)| value != "test-model")
6145        );
6146    }
6147
6148    #[test]
6149    fn exec_keeps_global_looking_flags_as_passthrough_args() {
6150        let cli = parse_ok(&[
6151            "codewhale",
6152            "exec",
6153            "--provider",
6154            "definitely-not-a-provider",
6155            "Reply OK",
6156        ]);
6157
6158        let Some(Commands::Exec(args)) = cli.command else {
6159            panic!("expected exec command");
6160        };
6161
6162        assert_eq!(
6163            args.args,
6164            vec![
6165                "--provider".to_string(),
6166                "definitely-not-a-provider".to_string(),
6167                "Reply OK".to_string(),
6168            ]
6169        );
6170    }
6171
6172    #[test]
6173    fn exec_rejects_provider_after_subcommand() {
6174        let args = vec![
6175            "--provider".to_string(),
6176            "definitely-not-a-provider".to_string(),
6177            "Reply OK".to_string(),
6178        ];
6179
6180        let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
6181
6182        assert!(
6183            err.to_string()
6184                .contains("--provider must be placed before `exec`")
6185        );
6186    }
6187
6188    #[test]
6189    fn exec_rejects_equals_form_provider_after_subcommand() {
6190        let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
6191
6192        let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
6193
6194        assert!(
6195            err.to_string()
6196                .contains("--provider must be placed before `exec`")
6197        );
6198    }
6199
6200    #[test]
6201    fn exec_allows_documented_forwarded_flags() {
6202        let args = vec![
6203            "--auto".to_string(),
6204            "--output-format".to_string(),
6205            "stream-json".to_string(),
6206            "fix tests".to_string(),
6207        ];
6208
6209        reject_exec_global_flags(&args).expect("documented exec flags should pass");
6210    }
6211
6212    #[test]
6213    fn exec_allows_literal_prompt_flags_after_separator() {
6214        let args = vec![
6215            "--".to_string(),
6216            "--provider".to_string(),
6217            "is literal prompt text".to_string(),
6218        ];
6219
6220        reject_exec_global_flags(&args).expect("separator should stop global flag validation");
6221    }
6222
6223    #[test]
6224    fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
6225        assert!(should_pick_resume_in_dispatcher(
6226            &["resume".to_string()],
6227            true
6228        ));
6229        assert!(!should_pick_resume_in_dispatcher(
6230            &["resume".to_string(), "--last".to_string()],
6231            true
6232        ));
6233        assert!(!should_pick_resume_in_dispatcher(
6234            &["resume".to_string(), "abc123".to_string()],
6235            true
6236        ));
6237        assert!(!should_pick_resume_in_dispatcher(
6238            &["resume".to_string()],
6239            false
6240        ));
6241    }
6242
6243    #[test]
6244    fn deepseek_login_uses_isolated_file_store_and_preserves_tui_defaults() {
6245        let _lock = env_lock();
6246        let dir = tempfile::TempDir::new().expect("tempdir");
6247        let codewhale_home = dir.path().join("codewhale-home");
6248        let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
6249        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
6250        let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
6251        let path = codewhale_home.join("config.toml");
6252        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6253        let secrets = Secrets::auto_detect();
6254
6255        run_login_command_with_secrets(
6256            &mut store,
6257            LoginArgs {
6258                provider: Some(ProviderArg::Deepseek),
6259                api_key: Some("sk-test".to_string()),
6260            },
6261            &secrets,
6262        )
6263        .expect("login should persist credential");
6264
6265        assert!(store.config.api_key.is_none());
6266        assert!(store.config.providers.deepseek.api_key.is_none());
6267        assert_eq!(
6268            store.config.default_text_model.as_deref(),
6269            Some("deepseek-v4-pro")
6270        );
6271        let saved = std::fs::read_to_string(&path).expect("config should be written");
6272        assert!(!saved.contains("sk-test"), "{saved}");
6273        assert!(
6274            !saved
6275                .lines()
6276                .any(|line| line.trim_start().starts_with("api_key ="))
6277        );
6278        assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
6279        assert_eq!(
6280            secrets.get("deepseek").expect("read secret").as_deref(),
6281            Some("sk-test")
6282        );
6283    }
6284
6285    /// #5198: with CODEWHALE_CONFIG_PATH pointing at a workspace-scoped
6286    /// `<repo>/.codewhale/config.toml`, login must write the provider binding
6287    /// and auth markers to the user-global document, never the repo file.
6288    #[test]
6289    fn login_with_repo_scoped_ambient_config_writes_user_global_metadata() {
6290        let _lock = env_lock();
6291        let dir = tempfile::TempDir::new().expect("tempdir");
6292        let repo = dir.path().join("repo");
6293        std::fs::create_dir_all(repo.join(".git")).expect("git marker");
6294        let repo_config_dir = repo.join(".codewhale");
6295        std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
6296        let repo_config = repo_config_dir.join("config.toml");
6297        std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
6298
6299        let codewhale_home = dir.path().join("codewhale-home");
6300        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
6301        let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
6302        let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6303        let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
6304        let mut store = ConfigStore::load(None).expect("ambient store should load");
6305        let secrets = Secrets::auto_detect();
6306
6307        run_login_command_with_secrets(
6308            &mut store,
6309            LoginArgs {
6310                provider: Some(ProviderArg::Deepseek),
6311                api_key: Some("sk-repo-scoped".to_string()),
6312            },
6313            &secrets,
6314        )
6315        .expect("login should persist credential");
6316
6317        assert_eq!(
6318            secrets.get("deepseek").expect("read secret").as_deref(),
6319            Some("sk-repo-scoped")
6320        );
6321        let global_config = codewhale_home.join("config.toml");
6322        let global = std::fs::read_to_string(&global_config).expect("user-global config");
6323        assert!(
6324            global.contains("auth_mode = \"api_key\""),
6325            "user-global config must carry the auth marker: {global}"
6326        );
6327        assert!(
6328            global.contains("provider = \"deepseek\""),
6329            "user-global config must carry the provider binding: {global}"
6330        );
6331        assert!(!global.contains("sk-repo-scoped"), "{global}");
6332        let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
6333        assert_eq!(
6334            repo_after, "approval_policy = \"never\"\n",
6335            "workspace config must stay untouched by credential metadata: {repo_after}"
6336        );
6337    }
6338
6339    /// #5198: `auth set` shares the login resolver — provider auth markers go
6340    /// user-global even when the ambient config is workspace-scoped.
6341    #[test]
6342    fn auth_set_with_repo_scoped_ambient_config_writes_user_global_metadata() {
6343        let _lock = env_lock();
6344        let dir = tempfile::TempDir::new().expect("tempdir");
6345        let repo = dir.path().join("repo");
6346        std::fs::create_dir_all(repo.join(".git")).expect("git marker");
6347        let repo_config_dir = repo.join(".codewhale");
6348        std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
6349        let repo_config = repo_config_dir.join("config.toml");
6350        std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
6351
6352        let codewhale_home = dir.path().join("codewhale-home");
6353        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
6354        let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
6355        let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6356        let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
6357        let mut store = ConfigStore::load(None).expect("ambient store should load");
6358        let secrets = Secrets::auto_detect();
6359
6360        run_auth_command_with_secrets(
6361            &mut store,
6362            AuthCommand::Set {
6363                provider: ProviderArg::Openrouter,
6364                api_key: Some("sk-or-repo-scoped".to_string()),
6365                api_key_stdin: false,
6366            },
6367            &secrets,
6368        )
6369        .expect("auth set should persist credential");
6370
6371        assert_eq!(
6372            secrets.get("openrouter").expect("read secret").as_deref(),
6373            Some("sk-or-repo-scoped")
6374        );
6375        let global = std::fs::read_to_string(codewhale_home.join("config.toml"))
6376            .expect("user-global config");
6377        assert!(
6378            global.contains("auth_mode = \"api_key\""),
6379            "user-global config must carry the auth markers: {global}"
6380        );
6381        assert!(
6382            global.contains("openrouter"),
6383            "user-global config must name the provider table: {global}"
6384        );
6385        assert!(!global.contains("sk-or-repo-scoped"), "{global}");
6386        let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
6387        assert_eq!(
6388            repo_after, "approval_policy = \"never\"\n",
6389            "workspace config must stay untouched by credential metadata: {repo_after}"
6390        );
6391    }
6392
6393    #[test]
6394    fn parses_auth_subcommand_matrix() {
6395        let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
6396        assert!(matches!(
6397            cli.command,
6398            Some(Commands::Auth(AuthArgs {
6399                command: AuthCommand::XaiDevice
6400            }))
6401        ));
6402
6403        let cli = parse_ok(&[
6404            "deepseek",
6405            "auth",
6406            "external-consent",
6407            "--provider",
6408            "openai-codex",
6409            "--mode",
6410            "read-only",
6411            "--path",
6412            "/tmp/codex-auth.json",
6413            "--yes",
6414        ]);
6415        assert!(matches!(
6416            cli.command,
6417            Some(Commands::Auth(AuthArgs {
6418                command: AuthCommand::ExternalConsent {
6419                    provider: ProviderArg::OpenaiCodex,
6420                    mode: ExternalCredentialModeArg::ReadOnly,
6421                    path: Some(_),
6422                    yes: true,
6423                }
6424            }))
6425        ));
6426
6427        let cli = parse_ok(&["deepseek", "auth", "external-revoke", "--provider", "xai"]);
6428        assert!(matches!(
6429            cli.command,
6430            Some(Commands::Auth(AuthArgs {
6431                command: AuthCommand::ExternalRevoke {
6432                    provider: ProviderArg::Xai,
6433                }
6434            }))
6435        ));
6436
6437        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
6438        assert!(matches!(
6439            cli.command,
6440            Some(Commands::Auth(AuthArgs {
6441                command: AuthCommand::Set {
6442                    provider: ProviderArg::Deepseek,
6443                    api_key: None,
6444                    api_key_stdin: false,
6445                }
6446            }))
6447        ));
6448
6449        let cli = parse_ok(&[
6450            "deepseek",
6451            "auth",
6452            "set",
6453            "--provider",
6454            "openrouter",
6455            "--api-key-stdin",
6456        ]);
6457        assert!(matches!(
6458            cli.command,
6459            Some(Commands::Auth(AuthArgs {
6460                command: AuthCommand::Set {
6461                    provider: ProviderArg::Openrouter,
6462                    api_key: None,
6463                    api_key_stdin: true,
6464                }
6465            }))
6466        ));
6467
6468        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
6469        assert!(matches!(
6470            cli.command,
6471            Some(Commands::Auth(AuthArgs {
6472                command: AuthCommand::Get {
6473                    provider: ProviderArg::Novita
6474                }
6475            }))
6476        ));
6477
6478        let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
6479        assert!(matches!(
6480            cli.command,
6481            Some(Commands::Auth(AuthArgs {
6482                command: AuthCommand::Clear {
6483                    provider: ProviderArg::NvidiaNim
6484                }
6485            }))
6486        ));
6487
6488        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
6489        assert!(matches!(
6490            cli.command,
6491            Some(Commands::Auth(AuthArgs {
6492                command: AuthCommand::Set {
6493                    provider: ProviderArg::Fireworks,
6494                    api_key: None,
6495                    api_key_stdin: false,
6496                }
6497            }))
6498        ));
6499
6500        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
6501        assert!(matches!(
6502            cli.command,
6503            Some(Commands::Auth(AuthArgs {
6504                command: AuthCommand::Set {
6505                    provider: ProviderArg::Siliconflow,
6506                    api_key: None,
6507                    api_key_stdin: false,
6508                }
6509            }))
6510        ));
6511
6512        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
6513        assert!(matches!(
6514            cli.command,
6515            Some(Commands::Auth(AuthArgs {
6516                command: AuthCommand::Set {
6517                    provider: ProviderArg::Arcee,
6518                    api_key: None,
6519                    api_key_stdin: false,
6520                }
6521            }))
6522        ));
6523
6524        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
6525        assert!(matches!(
6526            cli.command,
6527            Some(Commands::Auth(AuthArgs {
6528                command: AuthCommand::Set {
6529                    provider: ProviderArg::Moonshot,
6530                    api_key: None,
6531                    api_key_stdin: false,
6532                }
6533            }))
6534        ));
6535
6536        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
6537        assert!(matches!(
6538            cli.command,
6539            Some(Commands::Auth(AuthArgs {
6540                command: AuthCommand::Set {
6541                    provider: ProviderArg::WanjieArk,
6542                    api_key: None,
6543                    api_key_stdin: false,
6544                }
6545            }))
6546        ));
6547
6548        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
6549        assert!(matches!(
6550            cli.command,
6551            Some(Commands::Auth(AuthArgs {
6552                command: AuthCommand::Get {
6553                    provider: ProviderArg::Sglang
6554                }
6555            }))
6556        ));
6557
6558        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
6559        assert!(matches!(
6560            cli.command,
6561            Some(Commands::Auth(AuthArgs {
6562                command: AuthCommand::Get {
6563                    provider: ProviderArg::Vllm
6564                }
6565            }))
6566        ));
6567
6568        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
6569        assert!(matches!(
6570            cli.command,
6571            Some(Commands::Auth(AuthArgs {
6572                command: AuthCommand::Set {
6573                    provider: ProviderArg::Ollama,
6574                    api_key: None,
6575                    api_key_stdin: false,
6576                }
6577            }))
6578        ));
6579
6580        let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
6581        assert!(matches!(
6582            cli.command,
6583            Some(Commands::Auth(AuthArgs {
6584                command: AuthCommand::Status {
6585                    provider: Some(ProviderArg::OpenaiCodex)
6586                }
6587            }))
6588        ));
6589
6590        for (provider, expected) in [
6591            ("anthropic", ProviderArg::Anthropic),
6592            ("openmodel", ProviderArg::Openmodel),
6593            ("open-model", ProviderArg::Openmodel),
6594            ("zai", ProviderArg::Zai),
6595            ("stepfun", ProviderArg::Stepfun),
6596            ("minimax", ProviderArg::Minimax),
6597            ("minimax-anthropic", ProviderArg::MinimaxAnthropic),
6598            ("minimax_anthropic", ProviderArg::MinimaxAnthropic),
6599            ("deepinfra", ProviderArg::Deepinfra),
6600            ("deep-infra", ProviderArg::Deepinfra),
6601            ("siliconflow-cn", ProviderArg::SiliconflowCn),
6602            ("siliconflow-CN", ProviderArg::SiliconflowCn),
6603            ("siliconflow_china", ProviderArg::SiliconflowCn),
6604        ] {
6605            let cli = parse_ok(&[
6606                "deepseek",
6607                "auth",
6608                "set",
6609                "--provider",
6610                provider,
6611                "--api-key-stdin",
6612            ]);
6613            assert!(matches!(
6614                cli.command,
6615                Some(Commands::Auth(AuthArgs {
6616                    command: AuthCommand::Set {
6617                        provider,
6618                        api_key: None,
6619                        api_key_stdin: true,
6620                    }
6621                })) if provider == expected
6622            ));
6623        }
6624
6625        let cli = parse_ok(&["deepseek", "auth", "list"]);
6626        assert!(matches!(
6627            cli.command,
6628            Some(Commands::Auth(AuthArgs {
6629                command: AuthCommand::List
6630            }))
6631        ));
6632
6633        let cli = parse_ok(&["deepseek", "auth", "migrate"]);
6634        assert!(matches!(
6635            cli.command,
6636            Some(Commands::Auth(AuthArgs {
6637                command: AuthCommand::Migrate { dry_run: false }
6638            }))
6639        ));
6640
6641        let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
6642        assert!(matches!(
6643            cli.command,
6644            Some(Commands::Auth(AuthArgs {
6645                command: AuthCommand::Migrate { dry_run: true }
6646            }))
6647        ));
6648    }
6649
6650    #[test]
6651    fn auth_help_describes_runtime_effective_diagnostics() {
6652        let get = help_for(&["codewhale", "auth", "get", "--help"]);
6653        assert!(get.contains("effective credential route"), "{get}");
6654        assert!(get.contains("structural OAuth/repair state"), "{get}");
6655
6656        let status = help_for(&["codewhale", "auth", "status", "--help"]);
6657        assert!(
6658            status.contains("runtime-effective credential route state"),
6659            "{status}"
6660        );
6661
6662        let list = help_for(&["codewhale", "auth", "list", "--help"]);
6663        assert!(list.contains("runtime-effective auth state"), "{list}");
6664    }
6665
6666    #[test]
6667    fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
6668        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6669        use std::sync::Arc;
6670
6671        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6672        let path = std::env::temp_dir().join(format!(
6673            "deepseek-cli-auth-set-test-{}-{nanos}.toml",
6674            std::process::id()
6675        ));
6676        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6677        let inner = Arc::new(InMemoryKeyringStore::new());
6678        let secrets = Secrets::new(inner.clone());
6679
6680        run_auth_command_with_secrets(
6681            &mut store,
6682            AuthCommand::Set {
6683                provider: ProviderArg::Deepseek,
6684                api_key: Some("sk-keyring".to_string()),
6685                api_key_stdin: false,
6686            },
6687            &secrets,
6688        )
6689        .expect("set should succeed");
6690
6691        assert!(store.config.api_key.is_none());
6692        assert!(store.config.providers.deepseek.api_key.is_none());
6693        let saved = std::fs::read_to_string(&path).unwrap_or_default();
6694        assert!(!saved.contains("sk-keyring"), "{saved}");
6695        assert!(
6696            !saved
6697                .lines()
6698                .any(|line| line.trim_start().starts_with("api_key ="))
6699        );
6700        assert_eq!(
6701            inner.get("deepseek").unwrap().as_deref(),
6702            Some("sk-keyring")
6703        );
6704
6705        let _ = std::fs::remove_file(path);
6706    }
6707
6708    #[test]
6709    fn auth_set_refuses_plaintext_config_when_secret_store_write_fails() {
6710        use codewhale_secrets::{KeyringStore, SecretsError};
6711        use std::sync::Arc;
6712
6713        struct FailingStore;
6714
6715        impl KeyringStore for FailingStore {
6716            fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
6717                Ok(None)
6718            }
6719
6720            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
6721                Err(SecretsError::Keyring("test write failure".to_string()))
6722            }
6723
6724            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
6725                Ok(())
6726            }
6727
6728            fn backend_name(&self) -> &'static str {
6729                "failing test store"
6730            }
6731        }
6732
6733        let dir = tempfile::TempDir::new().expect("tempdir");
6734        let path = dir.path().join("config.toml");
6735        let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
6736        let secrets = Secrets::new(Arc::new(FailingStore));
6737
6738        let error = run_auth_command_with_secrets(
6739            &mut store,
6740            AuthCommand::Set {
6741                provider: ProviderArg::Openrouter,
6742                api_key: Some("fallback-test-credential".to_string()),
6743                api_key_stdin: false,
6744            },
6745            &secrets,
6746        )
6747        .expect_err("secret-store failure must not downgrade to plaintext");
6748
6749        let message = format!("{error:#}");
6750        assert!(message.contains("Secret storage write failed"), "{message}");
6751        assert!(message.contains("Refusing"), "{message}");
6752        assert!(
6753            message.contains(&codewhale_config::quote_os_path(store.path())),
6754            "{message}"
6755        );
6756        assert!(store.config.providers.openrouter.api_key.is_none());
6757        assert!(!path.exists(), "plaintext config must stay untouched");
6758    }
6759
6760    #[test]
6761    fn auth_set_provider_key_does_not_switch_active_provider() {
6762        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6763        let path = std::env::temp_dir().join(format!(
6764            "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
6765            std::process::id()
6766        ));
6767        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6768        store.config.provider = ProviderKind::Deepseek;
6769        let secrets = no_keyring_secrets();
6770
6771        run_auth_command_with_secrets(
6772            &mut store,
6773            AuthCommand::Set {
6774                provider: ProviderArg::Arcee,
6775                api_key: Some("arcee-key".to_string()),
6776                api_key_stdin: false,
6777            },
6778            &secrets,
6779        )
6780        .expect("set should succeed");
6781
6782        assert_eq!(store.config.provider, ProviderKind::Deepseek);
6783        assert!(store.config.providers.arcee.api_key.is_none());
6784        assert_eq!(
6785            store.config.providers.arcee.auth_mode.as_deref(),
6786            Some("api_key")
6787        );
6788
6789        let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
6790        assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
6791        assert!(reloaded.config.providers.arcee.api_key.is_none());
6792        assert_eq!(
6793            reloaded.config.providers.arcee.auth_mode.as_deref(),
6794            Some("api_key")
6795        );
6796
6797        let _ = std::fs::remove_file(path);
6798    }
6799
6800    #[test]
6801    fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
6802        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6803        let path = std::env::temp_dir().join(format!(
6804            "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
6805            std::process::id()
6806        ));
6807        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6808        store.config.provider = ProviderKind::Deepseek;
6809        let secrets = no_keyring_secrets();
6810
6811        run_auth_command_with_secrets(
6812            &mut store,
6813            AuthCommand::Set {
6814                provider: ProviderArg::Ollama,
6815                api_key: None,
6816                api_key_stdin: false,
6817            },
6818            &secrets,
6819        )
6820        .expect("ollama auth set should not require a key");
6821
6822        assert_eq!(store.config.provider, ProviderKind::Deepseek);
6823        assert_eq!(
6824            store.config.providers.ollama.base_url.as_deref(),
6825            Some("http://localhost:11434/v1")
6826        );
6827        assert_eq!(store.config.providers.ollama.api_key, None);
6828
6829        let _ = std::fs::remove_file(path);
6830    }
6831
6832    #[test]
6833    fn auth_clear_removes_from_config() {
6834        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6835        use std::sync::Arc;
6836
6837        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6838        let path = std::env::temp_dir().join(format!(
6839            "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
6840            std::process::id()
6841        ));
6842        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6843        store.config.api_key = Some("sk-stale".to_string());
6844        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
6845        store.save().unwrap();
6846
6847        let inner = Arc::new(InMemoryKeyringStore::new());
6848        inner.set("deepseek", "sk-stale").unwrap();
6849        let secrets = Secrets::new(inner.clone());
6850
6851        run_auth_command_with_secrets(
6852            &mut store,
6853            AuthCommand::Clear {
6854                provider: ProviderArg::Deepseek,
6855            },
6856            &secrets,
6857        )
6858        .expect("clear should succeed");
6859
6860        assert!(store.config.api_key.is_none());
6861        assert!(store.config.providers.deepseek.api_key.is_none());
6862        assert_eq!(inner.get("deepseek").unwrap(), None);
6863
6864        let _ = std::fs::remove_file(path);
6865    }
6866
6867    #[test]
6868    fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
6869        use codewhale_secrets::{KeyringStore, SecretsError};
6870        use std::sync::{Arc, Mutex};
6871
6872        #[derive(Default)]
6873        struct RecordingStore {
6874            gets: Mutex<Vec<String>>,
6875        }
6876
6877        impl KeyringStore for RecordingStore {
6878            fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
6879                self.gets.lock().unwrap().push(key.to_string());
6880                Ok(None)
6881            }
6882
6883            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
6884                Ok(())
6885            }
6886
6887            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
6888                Ok(())
6889            }
6890
6891            fn backend_name(&self) -> &'static str {
6892                "recording"
6893            }
6894        }
6895
6896        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6897        let path = std::env::temp_dir().join(format!(
6898            "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
6899            std::process::id()
6900        ));
6901        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6902        store.config.provider = ProviderKind::Deepseek;
6903        let inner = Arc::new(RecordingStore::default());
6904        let secrets = Secrets::new(inner.clone());
6905
6906        run_auth_command_with_secrets(
6907            &mut store,
6908            AuthCommand::Status {
6909                provider: Some(ProviderArg::Deepseek),
6910            },
6911            &secrets,
6912        )
6913        .expect("status should succeed");
6914        run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
6915            .expect("list should succeed");
6916
6917        let probed = inner.gets.lock().unwrap();
6918        // Scoped status probes only the requested provider.
6919        assert_eq!(probed[0], "deepseek");
6920        // List now probes all providers (not just active) to fix the
6921        // stale keyring-only-for-active-provider bug.
6922        assert!(probed.len() > 1, "list should probe all providers");
6923        assert!(
6924            ProviderKind::ALL
6925                .iter()
6926                .all(|p| probed.contains(&provider_slot(*p).to_string())),
6927            "every known provider should be probed by auth list: {:?}",
6928            *probed
6929        );
6930
6931        let _ = std::fs::remove_file(path);
6932    }
6933
6934    #[test]
6935    fn auth_status_reports_all_active_provider_sources_with_last4() {
6936        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6937        use std::sync::Arc;
6938
6939        let _lock = env_lock();
6940        let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
6941
6942        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6943        let path = std::env::temp_dir().join(format!(
6944            "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
6945            std::process::id()
6946        ));
6947        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6948        store.config.provider = ProviderKind::Deepseek;
6949        store.config.api_key = Some("sk-config-3333".to_string());
6950        store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
6951
6952        let inner = Arc::new(InMemoryKeyringStore::new());
6953        inner.set("deepseek", "sk-keyring-2222").unwrap();
6954        let secrets = Secrets::new(inner);
6955
6956        let output =
6957            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
6958
6959        assert!(output.contains("provider: deepseek"));
6960        assert!(output.contains("active source: config (last4: ...3333)"));
6961        assert!(output.contains("lookup order: config -> secret store -> env"));
6962        assert!(output.contains("config file: "));
6963        assert!(output.contains("set, last4: ...3333"));
6964        assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
6965        assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
6966        assert!(!output.contains("sk-config-3333"));
6967        assert!(!output.contains("sk-keyring-2222"));
6968        assert!(!output.contains("sk-env-1111"));
6969
6970        let _ = std::fs::remove_file(path);
6971    }
6972
6973    #[test]
6974    fn auth_status_all_providers_lists_every_known_provider() {
6975        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6976        use std::sync::Arc;
6977
6978        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6979        let path = std::env::temp_dir().join(format!(
6980            "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
6981            std::process::id()
6982        ));
6983        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6984        store.config.provider = ProviderKind::Deepseek;
6985        store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
6986
6987        let inner = Arc::new(InMemoryKeyringStore::new());
6988        inner.set("openrouter", "sk-or-test5678").unwrap();
6989        let secrets = Secrets::new(inner);
6990
6991        let output = auth_status_all_providers(&store, &secrets).join("\n");
6992
6993        // Should list all known providers
6994        assert!(output.contains("deepseek"));
6995        assert!(output.contains("arcee"));
6996        assert!(output.contains("openrouter"));
6997        assert!(output.contains("huggingface"));
6998        assert!(output.contains("ollama"));
6999
7000        // Active provider should be marked
7001        assert!(output.contains("deepseek") && output.contains("*"));
7002
7003        // Arcee should show config source
7004        assert!(output.contains("config"));
7005
7006        // Should NOT leak raw keys
7007        assert!(!output.contains("sk-arcee-test1234"));
7008        assert!(!output.contains("sk-or-test5678"));
7009
7010        let _ = std::fs::remove_file(path);
7011    }
7012
7013    #[test]
7014    fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
7015        use codewhale_secrets::InMemoryKeyringStore;
7016        use std::sync::Arc;
7017
7018        let _lock = env_lock();
7019        let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
7020        let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
7021
7022        let dir = tempfile::TempDir::new().expect("tempdir");
7023        let config_path = dir.path().join("config.toml");
7024        let auth_path = dir.path().join("auth.json");
7025        std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
7026            .expect("write auth file");
7027        let auth_path_str = auth_path.to_string_lossy().into_owned();
7028        let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
7029
7030        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7031        store.config.provider = ProviderKind::OpenaiCodex;
7032        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
7033
7034        let output =
7035            auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
7036
7037        assert!(output.contains("provider: openai-codex"));
7038        assert!(output.contains("auth mode: codex_oauth"));
7039        assert!(output.contains("active source: missing"));
7040        assert!(output.contains("lookup order: env -> consent-gated exact Codex CLI file"));
7041        assert!(output.contains("external credentials: disabled"));
7042        assert!(output.contains("scope_valid=false"));
7043        assert!(output.contains("disabled; no external-credential probing, reading"));
7044        assert!(output.contains("file not probed"));
7045        assert!(!output.contains("secret-token"));
7046
7047        store.config.providers.openai_codex.external_credentials =
7048            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7049                ProviderKind::OpenaiCodex,
7050                codewhale_config::ExternalCredentialSource::CodexCli,
7051                auth_path.clone(),
7052            ));
7053        let output =
7054            auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
7055        assert!(
7056            output.contains("active source: external read-only consent (availability not probed)")
7057        );
7058        assert!(output.contains("external credentials: read_only"));
7059        assert!(output.contains("provider=openai-codex"));
7060        assert!(output.contains("source=codex_cli"));
7061        assert!(output.contains(&format!(
7062            "path={}",
7063            codewhale_config::quote_os_path(&auth_path)
7064        )));
7065        assert!(output.contains(&format!(
7066            "consent_version={}",
7067            codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
7068        )));
7069        assert!(output.contains("file not probed"));
7070        assert!(!output.contains("secret-token"));
7071
7072        let ambient_path = dir.path().join("new-ambient-auth.json");
7073        let ambient_path_str = ambient_path.to_string_lossy().into_owned();
7074        let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
7075        let changed =
7076            auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
7077        assert!(changed.contains("state=active"), "{changed}");
7078        assert!(changed.contains("ambient_path_changed=true"), "{changed}");
7079        assert!(changed.contains("consent remains pinned"), "{changed}");
7080        assert!(
7081            changed.contains(&codewhale_config::quote_os_path(&auth_path)),
7082            "{changed}"
7083        );
7084        assert!(!changed.contains(&ambient_path_str), "{changed}");
7085    }
7086
7087    #[test]
7088    fn xai_valid_owned_generation_blocks_external_consent_without_storage_probes() {
7089        use std::sync::Arc;
7090
7091        let _lock = env_lock();
7092        let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
7093        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7094        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7095        let dir = tempfile::TempDir::new().expect("tempdir");
7096        let config_path = dir.path().join("config.toml");
7097        let external_path = dir.path().join("grok-auth.json");
7098        let external_raw = "external owner bytes must not be read";
7099        std::fs::write(&external_path, external_raw).expect("external auth trap");
7100        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7101
7102        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7103        store.config.provider = ProviderKind::Xai;
7104        store.config.providers.xai.auth_mode = Some("oauth".to_string());
7105        store.config.providers.xai.oauth_credential_generation =
7106            Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
7107        store.config.providers.xai.external_credentials =
7108            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7109                ProviderKind::Xai,
7110                codewhale_config::ExternalCredentialSource::GrokCli,
7111                external_path.clone(),
7112            ));
7113        let keyring = Arc::new(RecordingKeyringStore::default());
7114        let secrets = Secrets::new(keyring.clone());
7115
7116        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7117        assert!(
7118            scoped.contains(
7119                "credential route: Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
7120            ),
7121            "{scoped}"
7122        );
7123        assert!(scoped.contains("external credentials: blocked by the configured Codewhale-owned xAI OAuth generation"), "{scoped}");
7124        assert!(
7125            scoped.contains(
7126                "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
7127            ),
7128            "{scoped}"
7129        );
7130        assert!(
7131            !scoped.contains("active source: Codewhale-owned OAuth"),
7132            "a valid pointer is configured/unprobed, not an active credential: {scoped}"
7133        );
7134        assert!(
7135            !scoped.contains("fallback"),
7136            "an owned generation must never advertise Grok CLI fallback: {scoped}"
7137        );
7138
7139        let all = auth_status_all_providers(&store, &secrets).join("\n");
7140        let xai_row = all
7141            .lines()
7142            .find(|line| line.starts_with("xai"))
7143            .expect("xAI status row");
7144        assert!(
7145            xai_row.contains("Codewhale-owned OAuth configured/unprobed"),
7146            "{xai_row}"
7147        );
7148
7149        let list = auth_list_lines(&store, &secrets).join("\n");
7150        let xai_list_row = list
7151            .lines()
7152            .find(|line| line.starts_with("xai"))
7153            .expect("xAI list row");
7154        assert!(
7155            xai_list_row.ends_with("owned-oauth-configured"),
7156            "{xai_list_row}"
7157        );
7158
7159        let get = auth_get_line_with_runtime(
7160            &store,
7161            &secrets,
7162            ProviderKind::Xai,
7163            &CliRuntimeOverrides::default(),
7164        );
7165        assert!(
7166            get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
7167            "{get}"
7168        );
7169        assert!(!get.starts_with("xai: set"), "{get}");
7170        assert!(!get.contains("fallback"), "{get}");
7171        assert!(
7172            !keyring.queried().iter().any(|slot| slot == "xai"),
7173            "owned OAuth diagnostics must not query the xAI API-key store: {:?}",
7174            keyring.queried()
7175        );
7176        assert_eq!(
7177            std::fs::read_to_string(external_path).expect("external trap unchanged"),
7178            external_raw
7179        );
7180
7181        store.config.providers.xai.auth_mode = None;
7182        store.config.auth_mode = Some("oauth".to_string());
7183        assert_eq!(
7184            xai_auth_diagnostics(&store, &CliRuntimeOverrides::default()).route,
7185            XaiAuthDiagnosticRoute::ApiKey,
7186            "a root auth mode must not select the xAI OAuth runtime route"
7187        );
7188    }
7189
7190    #[test]
7191    fn xai_invalid_generation_requires_repair_blocks_external_and_keeps_api_key_diagnostics() {
7192        use std::sync::Arc;
7193
7194        let _lock = env_lock();
7195        let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
7196        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7197        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7198        let dir = tempfile::TempDir::new().expect("tempdir");
7199        let config_path = dir.path().join("config.toml");
7200        let external_path = dir.path().join("grok-auth.json");
7201        let external_raw = "external owner bytes must remain unread";
7202        std::fs::write(&external_path, external_raw).expect("external auth trap");
7203        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7204
7205        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7206        store.config.provider = ProviderKind::Xai;
7207        store.config.providers.xai.auth_mode = Some("oauth".to_string());
7208        store.config.providers.xai.api_key = Some("fake-cfg-key-1234".to_string());
7209        store.config.providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
7210        store.config.providers.xai.external_credentials =
7211            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7212                ProviderKind::Xai,
7213                codewhale_config::ExternalCredentialSource::GrokCli,
7214                external_path.clone(),
7215            ));
7216        let keyring = Arc::new(RecordingKeyringStore::default());
7217        let secrets = Secrets::new(keyring.clone());
7218
7219        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7220        assert!(
7221            scoped.contains("credential route: xAI OAuth needs repair"),
7222            "{scoped}"
7223        );
7224        assert!(
7225            scoped.contains("API-key fallback: config (last4: ...1234)"),
7226            "{scoped}"
7227        );
7228        assert!(scoped.contains("external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer"), "{scoped}");
7229        assert!(
7230            scoped.contains("repair: run `codewhale auth xai-device`"),
7231            "{scoped}"
7232        );
7233        assert!(
7234            !scoped.contains("external read-only consent (availability not probed)"),
7235            "invalid owned pointers must not activate Grok CLI consent: {scoped}"
7236        );
7237
7238        let all = auth_status_all_providers(&store, &secrets).join("\n");
7239        let xai_row = all
7240            .lines()
7241            .find(|line| line.starts_with("xai"))
7242            .expect("xAI status row");
7243        assert!(xai_row.contains("needs repair"), "{xai_row}");
7244        assert!(xai_row.contains("API-key fallback: config"), "{xai_row}");
7245
7246        let list = auth_list_lines(&store, &secrets).join("\n");
7247        let xai_list_row = list
7248            .lines()
7249            .find(|line| line.starts_with("xai"))
7250            .expect("xAI list row");
7251        assert!(xai_list_row.ends_with("needs-repair"), "{xai_list_row}");
7252
7253        let get = auth_get_line_with_runtime(
7254            &store,
7255            &secrets,
7256            ProviderKind::Xai,
7257            &CliRuntimeOverrides::default(),
7258        );
7259        assert!(get.contains("xai: needs repair"), "{get}");
7260        assert!(get.contains("API-key fallback: config-file"), "{get}");
7261        assert!(
7262            !keyring.queried().iter().any(|slot| slot == "xai"),
7263            "an invalid owned pointer must not query the xAI API-key store: {:?}",
7264            keyring.queried()
7265        );
7266        assert_eq!(
7267            std::fs::read_to_string(external_path).expect("external trap unchanged"),
7268            external_raw
7269        );
7270    }
7271
7272    #[test]
7273    fn xai_cli_custom_endpoint_rejects_inherited_api_key_sources() {
7274        use std::sync::Arc;
7275
7276        let _lock = env_lock();
7277        let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-3333");
7278        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7279        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7280        let dir = tempfile::TempDir::new().expect("tempdir");
7281        let config_path = dir.path().join("config.toml");
7282        let external_path = dir.path().join("grok-auth.json");
7283        let external_raw = "external owner bytes must remain unprobed";
7284        std::fs::write(&external_path, external_raw).expect("external auth trap");
7285        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7286
7287        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7288        store.config.provider = ProviderKind::Xai;
7289        store.config.providers.xai.api_key = Some("fake-cfg-key-1111".to_string());
7290        store.config.providers.xai.auth_mode = Some("oauth".to_string());
7291        store.config.providers.xai.oauth_credential_generation =
7292            Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
7293        store.config.providers.xai.external_credentials =
7294            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7295                ProviderKind::Xai,
7296                codewhale_config::ExternalCredentialSource::GrokCli,
7297                external_path.clone(),
7298            ));
7299        let keyring = Arc::new(RecordingKeyringStore::default());
7300        keyring.set_value("xai", "fake-store-key-2222");
7301        let secrets = Secrets::new(keyring.clone());
7302        let runtime_overrides = CliRuntimeOverrides {
7303            base_url: Some("https://gateway.example.test/v1".to_string()),
7304            ..CliRuntimeOverrides::default()
7305        };
7306
7307        let scoped = auth_status_lines_for_provider_with_runtime(
7308            &store,
7309            &secrets,
7310            ProviderKind::Xai,
7311            &runtime_overrides,
7312        )
7313        .join("\n");
7314        assert!(
7315            scoped.contains("route: https://gateway.example.test/v1"),
7316            "{scoped}"
7317        );
7318        assert!(scoped.contains("credential route: missing"), "{scoped}");
7319        assert!(
7320            scoped.contains("custom xAI endpoint; API-key-only"),
7321            "{scoped}"
7322        );
7323        assert!(
7324            scoped.contains("not eligible for this custom xAI endpoint"),
7325            "{scoped}"
7326        );
7327        assert!(
7328            scoped.contains("external credentials: unavailable on a custom xAI endpoint"),
7329            "{scoped}"
7330        );
7331        for redacted_tail in ["...1111", "...2222", "...3333"] {
7332            assert!(
7333                !scoped.contains(redacted_tail),
7334                "custom CLI route must not advertise an inherited credential: {scoped}"
7335            );
7336        }
7337
7338        let all =
7339            auth_status_all_providers_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
7340        let xai_row = all
7341            .lines()
7342            .find(|line| line.starts_with("xai"))
7343            .expect("xAI status row");
7344        assert!(xai_row.contains("unset"), "{xai_row}");
7345        assert!(
7346            !xai_row.contains("config") && !xai_row.contains("keyring") && !xai_row.contains("env"),
7347            "xAI summary must show runtime-effective sources only: {xai_row}"
7348        );
7349
7350        let list = auth_list_lines_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
7351        let xai_list_row = list
7352            .lines()
7353            .find(|line| line.starts_with("xai"))
7354            .expect("xAI list row");
7355        assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
7356
7357        let get =
7358            auth_get_line_with_runtime(&store, &secrets, ProviderKind::Xai, &runtime_overrides);
7359        assert_eq!(get, "xai: not set");
7360        assert!(
7361            !keyring.queried().iter().any(|slot| slot == "xai"),
7362            "a global custom endpoint must not query xAI keyring state: {:?}",
7363            keyring.queried()
7364        );
7365        assert_eq!(
7366            std::fs::read_to_string(external_path).expect("external trap unchanged"),
7367            external_raw
7368        );
7369    }
7370
7371    #[test]
7372    fn xai_env_custom_endpoint_rejects_inherited_api_key_sources() {
7373        use std::sync::Arc;
7374
7375        let _lock = env_lock();
7376        let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-6666");
7377        let _xai_base = ScopedEnvVar::set("XAI_BASE_URL", "https://env-gateway.example.test/v1");
7378        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7379        let dir = tempfile::TempDir::new().expect("tempdir");
7380        let config_path = dir.path().join("config.toml");
7381        let external_path = dir.path().join("grok-auth.json");
7382        let external_raw = "external owner bytes must remain unprobed";
7383        std::fs::write(&external_path, external_raw).expect("external auth trap");
7384        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7385
7386        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7387        store.config.provider = ProviderKind::Xai;
7388        store.config.providers.xai.api_key = Some("fake-cfg-key-4444".to_string());
7389        store.config.providers.xai.auth_mode = Some("oauth".to_string());
7390        store.config.providers.xai.oauth_credential_generation =
7391            Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
7392        store.config.providers.xai.external_credentials =
7393            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7394                ProviderKind::Xai,
7395                codewhale_config::ExternalCredentialSource::GrokCli,
7396                external_path.clone(),
7397            ));
7398        let keyring = Arc::new(RecordingKeyringStore::default());
7399        keyring.set_value("xai", "fake-store-key-5555");
7400        let secrets = Secrets::new(keyring.clone());
7401
7402        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7403        assert!(
7404            scoped.contains("route: https://env-gateway.example.test/v1"),
7405            "{scoped}"
7406        );
7407        assert!(scoped.contains("credential route: missing"), "{scoped}");
7408        assert!(
7409            scoped.contains("custom xAI endpoint; API-key-only"),
7410            "{scoped}"
7411        );
7412        for redacted_tail in ["...4444", "...5555", "...6666"] {
7413            assert!(
7414                !scoped.contains(redacted_tail),
7415                "custom env route must not advertise an inherited credential: {scoped}"
7416            );
7417        }
7418
7419        let all = auth_status_all_providers(&store, &secrets).join("\n");
7420        let xai_row = all
7421            .lines()
7422            .find(|line| line.starts_with("xai"))
7423            .expect("xAI status row");
7424        assert!(xai_row.contains("unset"), "{xai_row}");
7425
7426        let list = auth_list_lines(&store, &secrets).join("\n");
7427        let xai_list_row = list
7428            .lines()
7429            .find(|line| line.starts_with("xai"))
7430            .expect("xAI list row");
7431        assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
7432
7433        assert_eq!(
7434            auth_get_line_with_runtime(
7435                &store,
7436                &secrets,
7437                ProviderKind::Xai,
7438                &CliRuntimeOverrides::default(),
7439            ),
7440            "xai: not set"
7441        );
7442        assert!(
7443            !keyring.queried().iter().any(|slot| slot == "xai"),
7444            "an XAI_BASE_URL custom route must not query xAI keyring state: {:?}",
7445            keyring.queried()
7446        );
7447        assert_eq!(
7448            std::fs::read_to_string(external_path).expect("external trap unchanged"),
7449            external_raw
7450        );
7451    }
7452
7453    #[test]
7454    fn xai_config_bound_custom_endpoint_uses_its_route_key() {
7455        use std::sync::Arc;
7456
7457        let _lock = env_lock();
7458        let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
7459        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7460        let dir = tempfile::TempDir::new().expect("tempdir");
7461        let config_path = dir.path().join("config.toml");
7462        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7463        store.config.provider = ProviderKind::Xai;
7464        store.config.providers.xai.base_url =
7465            Some("https://bound-gateway.example.test/v1".to_string());
7466        store.config.providers.xai.api_key = Some("fake-bound-key-7777".to_string());
7467        let keyring = Arc::new(RecordingKeyringStore::default());
7468        keyring.set_value("xai", "fake-store-key-8888");
7469        let secrets = Secrets::new(keyring.clone());
7470
7471        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7472        assert!(
7473            scoped.contains("credential route: config (last4: ...7777)"),
7474            "{scoped}"
7475        );
7476        assert!(
7477            scoped.contains("config file:") && scoped.contains("runtime-effective, last4: ...7777"),
7478            "{scoped}"
7479        );
7480        assert_eq!(
7481            auth_get_line_with_runtime(
7482                &store,
7483                &secrets,
7484                ProviderKind::Xai,
7485                &CliRuntimeOverrides::default(),
7486            ),
7487            "xai: set (source: config-file)"
7488        );
7489        assert!(
7490            !keyring.queried().iter().any(|slot| slot == "xai"),
7491            "an endpoint-bound config key should resolve before the xAI keyring: {:?}",
7492            keyring.queried()
7493        );
7494    }
7495
7496    #[test]
7497    fn xai_absent_generation_with_consent_is_external_configured_and_unprobed() {
7498        use std::sync::Arc;
7499
7500        let _lock = env_lock();
7501        let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
7502        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
7503        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
7504        let dir = tempfile::TempDir::new().expect("tempdir");
7505        let config_path = dir.path().join("config.toml");
7506        let external_path = dir.path().join("grok-auth.json");
7507        let external_raw = "external owner bytes remain unprobed";
7508        std::fs::write(&external_path, external_raw).expect("external auth trap");
7509        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
7510
7511        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7512        store.config.provider = ProviderKind::Xai;
7513        store.config.providers.xai.auth_mode = Some("oauth".to_string());
7514        store.config.providers.xai.external_credentials =
7515            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7516                ProviderKind::Xai,
7517                codewhale_config::ExternalCredentialSource::GrokCli,
7518                external_path.clone(),
7519            ));
7520        let keyring = Arc::new(RecordingKeyringStore::default());
7521        let secrets = Secrets::new(keyring.clone());
7522
7523        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
7524        assert!(
7525            scoped.contains("credential route: external read-only consent configured/unprobed"),
7526            "{scoped}"
7527        );
7528        assert!(
7529            scoped.contains("external credentials: read_only"),
7530            "{scoped}"
7531        );
7532        assert!(
7533            scoped.contains(
7534                "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)"
7535            ),
7536            "{scoped}"
7537        );
7538
7539        let all = auth_status_all_providers(&store, &secrets).join("\n");
7540        let xai_row = all
7541            .lines()
7542            .find(|line| line.starts_with("xai"))
7543            .expect("xAI status row");
7544        assert!(
7545            xai_row.contains("external consent configured/unprobed"),
7546            "{xai_row}"
7547        );
7548
7549        let list = auth_list_lines(&store, &secrets).join("\n");
7550        let xai_list_row = list
7551            .lines()
7552            .find(|line| line.starts_with("xai"))
7553            .expect("xAI list row");
7554        assert!(
7555            xai_list_row.ends_with("external-consent-configured"),
7556            "{xai_list_row}"
7557        );
7558
7559        let get = auth_get_line_with_runtime(
7560            &store,
7561            &secrets,
7562            ProviderKind::Xai,
7563            &CliRuntimeOverrides::default(),
7564        );
7565        assert!(
7566            get.contains("source: external read-only consent; availability unprobed"),
7567            "{get}"
7568        );
7569        assert!(
7570            !keyring.queried().iter().any(|slot| slot == "xai"),
7571            "external-consent diagnostics must not query the xAI API-key store: {:?}",
7572            keyring.queried()
7573        );
7574        assert_eq!(
7575            std::fs::read_to_string(external_path).expect("external trap unchanged"),
7576            external_raw
7577        );
7578    }
7579
7580    #[test]
7581    fn auth_list_uses_persisted_consent_without_probing_codex_file() {
7582        use codewhale_secrets::InMemoryKeyringStore;
7583        use std::sync::Arc;
7584
7585        let _lock = env_lock();
7586        let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
7587        let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
7588
7589        let dir = tempfile::TempDir::new().expect("tempdir");
7590        let config_path = dir.path().join("config.toml");
7591        let auth_path = dir.path().join("auth.json");
7592        std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
7593            .expect("write auth file");
7594        let auth_path_str = auth_path.to_string_lossy().into_owned();
7595        let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
7596
7597        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7598        store.config.provider = ProviderKind::OpenaiCodex;
7599        store.config.providers.openai_codex.external_credentials =
7600            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7601                ProviderKind::OpenaiCodex,
7602                codewhale_config::ExternalCredentialSource::CodexCli,
7603                auth_path,
7604            ));
7605        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
7606
7607        let output = auth_list_lines(&store, &secrets).join("\n");
7608        let row = output
7609            .lines()
7610            .find(|line| line.starts_with("openai-codex"))
7611            .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
7612        assert!(row.ends_with("external-consent"), "{row}");
7613        assert!(!output.contains("secret-token"));
7614    }
7615
7616    #[test]
7617    fn external_consent_persists_exact_scope_and_api_key_or_revoke_disables_it() {
7618        let _lock = env_lock();
7619        let dir = tempfile::TempDir::new().expect("tempdir");
7620        let home = dir
7621            .path()
7622            .canonicalize()
7623            .expect("canonical temp root")
7624            .join("codewhale-home");
7625        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7626        let config_path = dir.path().join("config.toml");
7627        let external_path = dir.path().join("grok-auth.json");
7628        let external_raw = r#"{"secret":"must-never-be-read-or-written"}"#;
7629        std::fs::write(&external_path, external_raw).expect("external auth trap");
7630        let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
7631        let secrets = no_keyring_secrets();
7632
7633        let preview = external_consent_preview_lines(
7634            ProviderKind::Xai,
7635            codewhale_config::ExternalCredentialSource::GrokCli,
7636            &external_path,
7637        )
7638        .join("\n");
7639        assert!(preview.contains("owning CLI: Grok CLI"), "{preview}");
7640        assert!(
7641            preview.contains(&format!(
7642                "exact resolved path: {}",
7643                codewhale_config::quote_os_path(&external_path)
7644            )),
7645            "{preview}"
7646        );
7647        assert!(preview.contains("no refresh, identity-provider or discovery requests"));
7648        assert!(preview.contains("normal requests to the explicitly selected provider"));
7649        assert!(preview.contains("managed: unavailable"));
7650
7651        let mut prompt = Vec::new();
7652        confirm_external_consent_answer(&mut "yes\n".as_bytes(), &mut prompt)
7653            .expect("exact yes confirms");
7654        assert!(
7655            String::from_utf8(prompt)
7656                .unwrap()
7657                .contains("exact read-only")
7658        );
7659        let cancelled = confirm_external_consent_answer(&mut "YES\n".as_bytes(), &mut Vec::new())
7660            .expect_err("confirmation is deliberate and case-sensitive");
7661        assert!(cancelled.to_string().contains("cancelled"));
7662
7663        let unconfirmed = run_auth_command_with_secrets(
7664            &mut store,
7665            AuthCommand::ExternalConsent {
7666                provider: ProviderArg::Xai,
7667                mode: ExternalCredentialModeArg::ReadOnly,
7668                path: Some(external_path.clone()),
7669                yes: false,
7670            },
7671            &secrets,
7672        )
7673        .expect_err("non-interactive consent requires --yes");
7674        assert!(unconfirmed.to_string().contains("requires explicit --yes"));
7675        assert!(store.config.providers.xai.external_credentials.is_none());
7676        assert!(
7677            !config_path.exists(),
7678            "unconfirmed consent must not persist"
7679        );
7680
7681        run_auth_command_with_secrets(
7682            &mut store,
7683            AuthCommand::ExternalConsent {
7684                provider: ProviderArg::Xai,
7685                mode: ExternalCredentialModeArg::ReadOnly,
7686                path: Some(external_path.clone()),
7687                yes: true,
7688            },
7689            &secrets,
7690        )
7691        .expect("read-only consent should persist");
7692
7693        let consent = store
7694            .config
7695            .providers
7696            .xai
7697            .external_credentials
7698            .as_ref()
7699            .expect("persisted consent");
7700        assert_eq!(
7701            consent.access,
7702            codewhale_config::ExternalCredentialAccess::ReadOnly
7703        );
7704        assert_eq!(consent.provider, ProviderKind::Xai.as_str());
7705        assert_eq!(
7706            consent.source,
7707            codewhale_config::ExternalCredentialSource::GrokCli
7708        );
7709        assert_eq!(consent.path, external_path);
7710        assert_eq!(
7711            consent.consent_version,
7712            codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
7713        );
7714        assert_eq!(
7715            store.config.providers.xai.auth_mode.as_deref(),
7716            Some("oauth")
7717        );
7718        assert_eq!(
7719            std::fs::read_to_string(&consent.path).expect("external file unchanged"),
7720            external_raw
7721        );
7722
7723        let reloaded = ConfigStore::load(Some(config_path.clone())).expect("reload consent");
7724        let reloaded_consent = reloaded
7725            .config
7726            .providers
7727            .xai
7728            .external_credentials
7729            .as_ref()
7730            .expect("reloaded exact consent");
7731        assert_eq!(reloaded_consent.provider, ProviderKind::Xai.as_str());
7732        assert_eq!(
7733            reloaded_consent.source,
7734            codewhale_config::ExternalCredentialSource::GrokCli
7735        );
7736        assert_eq!(reloaded_consent.path, external_path);
7737        assert_eq!(
7738            reloaded_consent.consent_version,
7739            codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
7740        );
7741
7742        run_auth_command_with_secrets(
7743            &mut store,
7744            AuthCommand::Set {
7745                provider: ProviderArg::Xai,
7746                api_key: Some("xai-codewhale-owned-key".to_string()),
7747                api_key_stdin: false,
7748            },
7749            &secrets,
7750        )
7751        .expect("Codewhale-owned API key should supersede external consent");
7752        assert!(store.config.providers.xai.external_credentials.is_none());
7753        assert_eq!(
7754            std::fs::read_to_string(&external_path).expect("external file still unchanged"),
7755            external_raw
7756        );
7757
7758        run_auth_command_with_secrets(
7759            &mut store,
7760            AuthCommand::ExternalConsent {
7761                provider: ProviderArg::Xai,
7762                mode: ExternalCredentialModeArg::ReadOnly,
7763                path: Some(external_path.clone()),
7764                yes: true,
7765            },
7766            &secrets,
7767        )
7768        .expect("consent can be granted again");
7769        run_auth_command_with_secrets(
7770            &mut store,
7771            AuthCommand::ExternalRevoke {
7772                provider: ProviderArg::Xai,
7773            },
7774            &secrets,
7775        )
7776        .expect("revoke should persist");
7777        assert!(store.config.providers.xai.external_credentials.is_none());
7778        assert_eq!(
7779            std::fs::read_to_string(&external_path).expect("revoke never touches external file"),
7780            external_raw
7781        );
7782    }
7783
7784    #[test]
7785    fn unsupported_managed_and_kimi_external_consent_fail_closed() {
7786        let dir = tempfile::TempDir::new().expect("tempdir");
7787        let config_path = dir.path().join("config.toml");
7788        let external_path = dir.path().join("external-auth.json");
7789        std::fs::write(&external_path, "must remain unchanged").expect("external fixture");
7790        let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
7791        let secrets = no_keyring_secrets();
7792
7793        let managed = run_auth_command_with_secrets(
7794            &mut store,
7795            AuthCommand::ExternalConsent {
7796                provider: ProviderArg::OpenaiCodex,
7797                mode: ExternalCredentialModeArg::Managed,
7798                path: Some(external_path.clone()),
7799                yes: true,
7800            },
7801            &secrets,
7802        )
7803        .expect_err("managed access must fail without a preservation adapter");
7804        assert!(
7805            managed
7806                .to_string()
7807                .contains("schema-safe preservation adapter")
7808        );
7809
7810        let kimi = run_auth_command_with_secrets(
7811            &mut store,
7812            AuthCommand::ExternalConsent {
7813                provider: ProviderArg::Moonshot,
7814                mode: ExternalCredentialModeArg::ReadOnly,
7815                path: Some(external_path.clone()),
7816                yes: true,
7817            },
7818            &secrets,
7819        )
7820        .expect_err("Kimi must remain API-key-only");
7821        assert!(kimi.to_string().contains("API-key-only"));
7822        assert!(
7823            kimi.to_string()
7824                .contains("https://platform.kimi.ai/console/api-keys")
7825        );
7826        assert!(
7827            store
7828                .config
7829                .providers
7830                .openai_codex
7831                .external_credentials
7832                .is_none()
7833        );
7834        assert!(
7835            store
7836                .config
7837                .providers
7838                .moonshot
7839                .external_credentials
7840                .is_none()
7841        );
7842        assert_eq!(
7843            std::fs::read_to_string(external_path).expect("external fixture unchanged"),
7844            "must remain unchanged"
7845        );
7846        assert!(
7847            !config_path.exists(),
7848            "rejected consent must not write config"
7849        );
7850    }
7851
7852    #[test]
7853    fn api_key_config_failure_restores_absent_and_existing_secret_state() {
7854        let _lock = env_lock();
7855        for prior in [None, Some("prior-xai-key")] {
7856            let dir = tempfile::TempDir::new().expect("tempdir");
7857            let home = dir
7858                .path()
7859                .canonicalize()
7860                .expect("canonical temp root")
7861                .join("codewhale-home");
7862            let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7863            let config_path = dir.path().join("config.toml");
7864            let mut store = ConfigStore::load(Some(config_path.clone())).expect("load store");
7865            store.config.providers.xai.auth_mode = Some("oauth".to_string());
7866            store.config.providers.xai.external_credentials =
7867                Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7868                    ProviderKind::Xai,
7869                    codewhale_config::ExternalCredentialSource::GrokCli,
7870                    dir.path().join("external.json"),
7871                ));
7872            std::fs::create_dir(&config_path).expect("turn config target into a directory");
7873            let secrets = no_keyring_secrets();
7874            if let Some(prior) = prior {
7875                secrets.set("xai", prior).expect("seed prior secret");
7876            }
7877
7878            let error = run_auth_command_with_secrets(
7879                &mut store,
7880                AuthCommand::Set {
7881                    provider: ProviderArg::Xai,
7882                    api_key: Some("new-xai-key".to_string()),
7883                    api_key_stdin: false,
7884                },
7885                &secrets,
7886            )
7887            .expect_err("config write must fail");
7888            assert!(error.to_string().contains("config"), "{error:#}");
7889            assert_eq!(
7890                secrets.get("xai").expect("restored secret"),
7891                prior.map(str::to_string)
7892            );
7893            assert_eq!(
7894                store.config.providers.xai.auth_mode.as_deref(),
7895                Some("oauth")
7896            );
7897            assert!(store.config.providers.xai.external_credentials.is_some());
7898            assert!(store.config.providers.xai.api_key.is_none());
7899            assert!(config_path.is_dir());
7900        }
7901    }
7902
7903    #[test]
7904    fn auth_status_scoped_provider_shows_detailed_info() {
7905        use codewhale_secrets::InMemoryKeyringStore;
7906        use std::sync::Arc;
7907
7908        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
7909        let path = std::env::temp_dir().join(format!(
7910            "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
7911            std::process::id()
7912        ));
7913        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7914        store.config.provider = ProviderKind::Deepseek;
7915        store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
7916
7917        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
7918
7919        let output =
7920            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
7921
7922        assert!(output.contains("provider: arcee"));
7923        assert!(output.contains("active source: config (last4: ...9999)"));
7924        assert!(output.contains("route:"));
7925        assert!(output.contains("model:"));
7926        assert!(!output.contains("sk-arcee-9999"));
7927
7928        for sentinel in [codewhale_config::API_KEYRING_SENTINEL, "  __KEYRING__  "] {
7929            store.config.providers.arcee.api_key = Some(sentinel.to_string());
7930            assert_eq!(provider_config_api_key(&store, ProviderKind::Arcee), None);
7931        }
7932
7933        let _ = std::fs::remove_file(path);
7934    }
7935
7936    #[test]
7937    fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
7938        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
7939        use std::sync::Arc;
7940
7941        // Runtime resolution reads process-global provider environment overrides.
7942        // Serialize with the tests that temporarily set those overrides so this
7943        // in-memory DeepSeek credential is not resolved against another provider.
7944        let _lock = env_lock();
7945        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
7946        let path = std::env::temp_dir().join(format!(
7947            "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
7948            std::process::id()
7949        ));
7950        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7951        let inner = Arc::new(InMemoryKeyringStore::new());
7952        inner.set("deepseek", "ring-key").unwrap();
7953        let secrets = Secrets::new(inner);
7954
7955        let resolved = resolve_runtime_for_dispatch_with_secrets(
7956            &mut store,
7957            &CliRuntimeOverrides::default(),
7958            &secrets,
7959        );
7960
7961        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
7962        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7963        assert!(store.config.api_key.is_none());
7964        assert!(store.config.providers.deepseek.api_key.is_none());
7965        assert!(
7966            !path.exists(),
7967            "dispatch must not create config from a stored key"
7968        );
7969
7970        let resolved_again = resolve_runtime_for_dispatch_with_secrets(
7971            &mut store,
7972            &CliRuntimeOverrides::default(),
7973            &secrets,
7974        );
7975        assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
7976        assert_eq!(
7977            resolved_again.api_key_source,
7978            Some(RuntimeApiKeySource::Keyring)
7979        );
7980        assert!(
7981            !path.exists(),
7982            "repeat dispatch must remain credential-file free"
7983        );
7984
7985        let _ = std::fs::remove_file(path);
7986    }
7987
7988    #[test]
7989    fn logout_removes_plaintext_provider_keys() {
7990        let _lock = env_lock();
7991        let dir = tempfile::TempDir::new().expect("tempdir");
7992        let home = dir
7993            .path()
7994            .canonicalize()
7995            .expect("canonical temp root")
7996            .join("codewhale-home");
7997        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7998        let path = home.join("config.toml");
7999        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8000        store.config.api_key = Some("sk-stale".to_string());
8001        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
8002        store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
8003        store.config.providers.xai.auth_mode = Some("oauth".to_string());
8004        let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
8005        store.config.providers.xai.oauth_credential_generation = Some(generation.to_string());
8006        store.save().unwrap();
8007        let credentials = home.join("credentials");
8008        codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
8009            owned.write(generation, b"xai-generation", false)?;
8010            owned.write(
8011                codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
8012                b"legacy-xai",
8013                false,
8014            )?;
8015            Ok(())
8016        })
8017        .expect("seed Codewhale-owned xAI credentials");
8018        std::fs::write(credentials.join("other-provider.json"), "preserve").unwrap();
8019
8020        let secrets = no_keyring_secrets();
8021
8022        run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
8023
8024        assert!(store.config.api_key.is_none());
8025        assert!(store.config.providers.deepseek.api_key.is_none());
8026        assert!(store.config.providers.fireworks.api_key.is_none());
8027        assert!(store.config.providers.xai.auth_mode.is_none());
8028        assert!(
8029            store
8030                .config
8031                .providers
8032                .xai
8033                .oauth_credential_generation
8034                .is_none()
8035        );
8036        assert!(!credentials.join(generation).exists());
8037        assert!(!credentials.join("xai-auth.json").exists());
8038        assert!(credentials.join("other-provider.json").exists());
8039
8040        let _ = std::fs::remove_file(path);
8041    }
8042
8043    #[test]
8044    fn logout_clears_keyring_credentials_for_all_providers() {
8045        // Logout used to delete the keyring secret only for the *active*
8046        // provider, leaving credentials stored under other providers
8047        // behind while printing "logged out".
8048        let _lock = env_lock();
8049        let dir = tempfile::TempDir::new().expect("tempdir");
8050        let home = dir
8051            .path()
8052            .canonicalize()
8053            .expect("canonical temp root")
8054            .join("codewhale-home");
8055        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
8056        let path = home.join("config.toml");
8057        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8058        store.config.provider = ProviderKind::Deepseek;
8059
8060        let secrets = no_keyring_secrets();
8061        secrets
8062            .set(provider_slot(ProviderKind::Deepseek), "sk-deepseek")
8063            .expect("seed deepseek key");
8064        secrets
8065            .set(provider_slot(ProviderKind::Fireworks), "fw-stale")
8066            .expect("seed fireworks key");
8067
8068        run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
8069
8070        for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
8071            assert!(
8072                provider_keyring_api_key(&secrets, provider).is_none(),
8073                "keyring credential for {provider:?} survived logout"
8074            );
8075        }
8076
8077        let _ = std::fs::remove_file(path);
8078    }
8079
8080    #[test]
8081    fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
8082        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8083        use std::sync::Arc;
8084
8085        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8086        let path = std::env::temp_dir().join(format!(
8087            "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
8088            std::process::id()
8089        ));
8090        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8091        store.config.api_key = Some("sk-deep".to_string());
8092        store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
8093        store.config.providers.openrouter.api_key = Some("or-key".to_string());
8094        store.config.providers.novita.api_key = Some("nv-key".to_string());
8095        store.save().unwrap();
8096
8097        let inner = Arc::new(InMemoryKeyringStore::new());
8098        let secrets = Secrets::new(inner.clone());
8099
8100        run_auth_command_with_secrets(
8101            &mut store,
8102            AuthCommand::Migrate { dry_run: false },
8103            &secrets,
8104        )
8105        .expect("migrate should succeed");
8106
8107        assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
8108        assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
8109        assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
8110
8111        // Config file must no longer contain the api keys.
8112        assert!(store.config.api_key.is_none());
8113        assert!(store.config.providers.deepseek.api_key.is_none());
8114        assert!(store.config.providers.openrouter.api_key.is_none());
8115        assert!(store.config.providers.novita.api_key.is_none());
8116
8117        let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
8118        assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
8119        assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
8120        assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
8121
8122        let backup_path = path.with_file_name(format!(
8123            "{}.bak",
8124            path.file_name().unwrap_or_default().to_string_lossy()
8125        ));
8126        let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
8127        assert!(
8128            !backup.contains("sk-deep"),
8129            "plaintext leaked in backup: {backup}"
8130        );
8131        assert!(
8132            !backup.contains("or-key"),
8133            "plaintext leaked in backup: {backup}"
8134        );
8135        assert!(
8136            !backup.contains("nv-key"),
8137            "plaintext leaked in backup: {backup}"
8138        );
8139
8140        let resolved = resolve_runtime_for_dispatch_with_secrets(
8141            &mut store,
8142            &CliRuntimeOverrides::default(),
8143            &secrets,
8144        );
8145        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
8146        let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
8147        assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
8148        assert!(
8149            !after_dispatch
8150                .lines()
8151                .any(|line| line.trim_start().starts_with("api_key ="))
8152        );
8153
8154        let _ = std::fs::remove_file(path);
8155    }
8156
8157    #[test]
8158    fn auth_migrate_dry_run_does_not_modify_anything() {
8159        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8160        use std::sync::Arc;
8161
8162        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8163        let path = std::env::temp_dir().join(format!(
8164            "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
8165            std::process::id()
8166        ));
8167        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8168        store.config.providers.openrouter.api_key = Some("or-stay".to_string());
8169        store.save().unwrap();
8170
8171        let inner = Arc::new(InMemoryKeyringStore::new());
8172        let secrets = Secrets::new(inner.clone());
8173
8174        run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
8175            .expect("dry-run should succeed");
8176
8177        assert_eq!(inner.get("openrouter").unwrap(), None);
8178        assert_eq!(
8179            store.config.providers.openrouter.api_key.as_deref(),
8180            Some("or-stay")
8181        );
8182
8183        let _ = std::fs::remove_file(path);
8184    }
8185
8186    #[test]
8187    fn parses_global_override_flags() {
8188        let cli = parse_ok(&[
8189            "deepseek",
8190            "--provider",
8191            "openai",
8192            "--config",
8193            "/tmp/deepseek.toml",
8194            "--profile",
8195            "work",
8196            "--model",
8197            "deepseek-v4-pro",
8198            "--output-mode",
8199            "json",
8200            "--verbosity",
8201            "concise",
8202            "--log-level",
8203            "debug",
8204            "--telemetry",
8205            "true",
8206            "--approval-policy",
8207            "on-request",
8208            "--sandbox-mode",
8209            "workspace-write",
8210            "--base-url",
8211            "https://openai-compatible.example/v1",
8212            "--api-key",
8213            "sk-test",
8214            "--workspace",
8215            "/tmp/workspace",
8216            "--no-mouse-capture",
8217            "--skip-onboarding",
8218            "model",
8219            "resolve",
8220            "deepseek-v4-pro",
8221        ]);
8222
8223        assert_eq!(cli.provider.as_deref(), Some("openai"));
8224        assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
8225        assert_eq!(cli.profile.as_deref(), Some("work"));
8226        assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
8227        assert_eq!(cli.output_mode.as_deref(), Some("json"));
8228        assert_eq!(cli.verbosity.as_deref(), Some("concise"));
8229        assert_eq!(cli.log_level.as_deref(), Some("debug"));
8230        assert_eq!(cli.telemetry, Some(true));
8231        assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
8232        assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
8233        assert_eq!(
8234            cli.base_url.as_deref(),
8235            Some("https://openai-compatible.example/v1")
8236        );
8237        assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
8238        assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
8239        assert!(cli.no_mouse_capture);
8240        assert!(!cli.mouse_capture);
8241        assert!(cli.skip_onboarding);
8242    }
8243
8244    #[test]
8245    fn cli_provider_helpers_follow_config_metadata() {
8246        let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
8247            .iter()
8248            .map(|provider| provider.kind())
8249            .collect();
8250        // Full registry keeps legacy dialect/plan kinds; ALL is the catalog surface.
8251        assert_eq!(registry_kinds.len(), 41);
8252        assert_eq!(ProviderKind::ALL.len(), 36);
8253        for kind in ProviderKind::ALL {
8254            assert!(
8255                registry_kinds.contains(&kind),
8256                "catalog kind {kind:?} must remain in the full registry"
8257            );
8258        }
8259
8260        for provider in registry_kinds {
8261            assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
8262            // Shared-account families collapse onto one durable slot (see
8263            // ProviderKind::secret_store_slot); everything else uses its own id.
8264            assert_eq!(
8265                provider_slot(provider),
8266                provider.secret_store_slot(),
8267                "{provider:?} slot must match ProviderKind::secret_store_slot"
8268            );
8269            if provider == ProviderKind::SiliconflowCN {
8270                assert_eq!(
8271                    provider_slot(provider),
8272                    provider_slot(ProviderKind::Siliconflow)
8273                );
8274            } else if matches!(
8275                provider,
8276                ProviderKind::ModelstudioTokenPlan
8277                    | ProviderKind::ModelstudioTokenPlanAnthropic
8278                    | ProviderKind::ModelstudioCodingPlan
8279                    | ProviderKind::ModelstudioCodingPlanAnthropic
8280            ) {
8281                assert_eq!(
8282                    provider_slot(provider),
8283                    "modelstudio-token-plan",
8284                    "{provider:?} must share the Model Studio family slot"
8285                );
8286            } else {
8287                assert_eq!(provider_slot(provider), provider.provider().id());
8288            }
8289        }
8290    }
8291
8292    #[test]
8293    fn build_tui_command_forwards_raw_exec_and_fleet_provider_without_secret_bridge() {
8294        let _lock = env_lock();
8295        let (_dir, _bin) = install_fake_tui_binary();
8296        let _ambient_provider = ScopedEnvVar::set("CODEWHALE_PROVIDER", "openrouter");
8297
8298        let cases = [
8299            (
8300                parse_ok(&["codewhale", "--provider", "lm-studio", "exec", "Reply OK"]),
8301                vec!["exec".to_string(), "Reply OK".to_string()],
8302            ),
8303            (
8304                parse_ok(&["codewhale", "--provider", "lm-studio", "fleet", "status"]),
8305                vec!["fleet".to_string(), "status".to_string()],
8306            ),
8307        ];
8308
8309        for (cli, passthrough) in cases {
8310            let mut resolved =
8311                resolved_runtime_for_test(ProviderKind::Openrouter, ProviderSource::Config);
8312            resolved.api_key = Some("unrelated-keyring-secret".to_string());
8313            resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
8314
8315            let cmd = build_tui_command(&cli, &resolved, passthrough.clone())
8316                .expect("raw provider should dispatch to the TUI");
8317            assert_eq!(
8318                command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
8319                Some("lm-studio")
8320            );
8321            assert_eq!(
8322                command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8323                Some("lm-studio")
8324            );
8325            for secret_var in [
8326                "CODEWHALE_CLI_API_KEY",
8327                "DEEPSEEK_API_KEY",
8328                "OPENROUTER_API_KEY",
8329                "DEEPSEEK_API_KEY_SOURCE",
8330            ] {
8331                assert_eq!(
8332                    command_env(&cmd, secret_var),
8333                    None,
8334                    "raw provider dispatch must not bridge {secret_var}"
8335                );
8336            }
8337            assert_eq!(
8338                cmd.get_args()
8339                    .map(|arg| arg.to_string_lossy().into_owned())
8340                    .collect::<Vec<_>>(),
8341                passthrough
8342            );
8343        }
8344    }
8345
8346    #[test]
8347    fn build_tui_command_allows_openai_and_forwards_provider_key() {
8348        let _lock = env_lock();
8349        let dir = tempfile::TempDir::new().expect("tempdir");
8350        let custom = dir
8351            .path()
8352            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8353        std::fs::write(&custom, b"").unwrap();
8354        let custom_str = custom.to_string_lossy().into_owned();
8355        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8356
8357        let cli = parse_ok(&[
8358            "deepseek",
8359            "--provider",
8360            "openai",
8361            "--workspace",
8362            "/tmp/codewhale-workspace",
8363        ]);
8364        let resolved = ResolvedRuntimeOptions {
8365            provider: ProviderKind::Openai,
8366            provider_source: ProviderSource::Cli,
8367            model_source: ModelSource::ProviderDefault,
8368            model: "glm-5".to_string(),
8369            api_key: Some("resolved-openai-key".to_string()),
8370            api_key_source: Some(RuntimeApiKeySource::Keyring),
8371            base_url: "https://openai-compatible.example/v4".to_string(),
8372            auth_mode: Some("api_key".to_string()),
8373            insecure_skip_tls_verify: false,
8374            output_mode: None,
8375            log_level: None,
8376            telemetry: false,
8377            telemetry_explicit_off: false,
8378            telemetry_endpoint: None,
8379            approval_policy: None,
8380            sandbox_mode: None,
8381            yolo: None,
8382            verbosity: None,
8383            http_headers: std::collections::BTreeMap::new(),
8384        };
8385
8386        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8387        assert_eq!(
8388            command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
8389            Some("openai")
8390        );
8391        assert_eq!(
8392            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8393            Some("openai")
8394        );
8395        assert_eq!(
8396            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
8397            Some("resolved-openai-key")
8398        );
8399        assert_eq!(
8400            command_env(&cmd, "OPENAI_API_KEY").as_deref(),
8401            Some("resolved-openai-key")
8402        );
8403        assert_eq!(
8404            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
8405            Some("keyring")
8406        );
8407        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
8408        let args: Vec<String> = cmd
8409            .get_args()
8410            .map(|arg| arg.to_string_lossy().into_owned())
8411            .collect();
8412        assert!(
8413            args.windows(2)
8414                .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
8415            "expected workspace forwarding in args: {args:?}"
8416        );
8417    }
8418
8419    #[test]
8420    fn build_tui_command_forwards_the_resolved_telemetry_value_not_the_raw_flag() {
8421        let _lock = env_lock();
8422        let dir = tempfile::TempDir::new().expect("tempdir");
8423        let custom = dir
8424            .path()
8425            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8426        std::fs::write(&custom, b"").unwrap();
8427        let custom_str = custom.to_string_lossy().into_owned();
8428        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8429
8430        // The user passed `--telemetry true`, but the resolver applied the
8431        // kill-switch floor (an explicit `CODEWHALE_TELEMETRY=0`, or an
8432        // unreadable value) and resolved to off. The TUI holds every emission
8433        // site, so it is the resolved value that must reach it.
8434        let cli = parse_ok(&["codewhale", "--telemetry", "true", "exec", "hi"]);
8435        assert_eq!(cli.telemetry, Some(true));
8436        let mut resolved = telemetry_test_resolved();
8437        resolved.telemetry = false;
8438        resolved.telemetry_explicit_off = true;
8439
8440        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8441        assert_eq!(
8442            command_env(&cmd, "CODEWHALE_TELEMETRY").as_deref(),
8443            Some("false")
8444        );
8445        assert_eq!(
8446            command_env(&cmd, "DEEPSEEK_TELEMETRY").as_deref(),
8447            Some("false")
8448        );
8449    }
8450
8451    #[test]
8452    fn build_tui_command_forwards_the_endpoint_only_when_one_is_configured() {
8453        let _lock = env_lock();
8454        let dir = tempfile::TempDir::new().expect("tempdir");
8455        let custom = dir
8456            .path()
8457            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8458        std::fs::write(&custom, b"").unwrap();
8459        let custom_str = custom.to_string_lossy().into_owned();
8460        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8461
8462        let cli = parse_ok(&["codewhale", "exec", "hi"]);
8463
8464        // A resolved `None` is the dry-run sink — the user configured an empty
8465        // endpoint — and it must not be forwarded as an empty variable, which
8466        // would look like a configured endpoint to anything that only checks
8467        // for presence. Not forwarding is safe because the child re-resolves
8468        // from the same config file and the same inherited environment, so it
8469        // reaches the same `None`.
8470        let resolved = telemetry_test_resolved();
8471        assert_eq!(resolved.telemetry_endpoint, None);
8472        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8473        assert_eq!(command_env(&cmd, "CODEWHALE_TELEMETRY_ENDPOINT"), None);
8474        assert_eq!(command_env(&cmd, "DEEPSEEK_TELEMETRY_ENDPOINT"), None);
8475
8476        let mut resolved = telemetry_test_resolved();
8477        resolved.telemetry_endpoint = Some("https://telemetry.example/v1".to_string());
8478        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8479        assert_eq!(
8480            command_env(&cmd, "CODEWHALE_TELEMETRY_ENDPOINT").as_deref(),
8481            Some("https://telemetry.example/v1")
8482        );
8483        assert_eq!(
8484            command_env(&cmd, "DEEPSEEK_TELEMETRY_ENDPOINT").as_deref(),
8485            Some("https://telemetry.example/v1")
8486        );
8487    }
8488
8489    #[test]
8490    fn the_telemetry_flag_documents_itself_in_help() {
8491        // A consent control nobody can find is a consent control nobody has.
8492        let help = Cli::command().render_long_help().to_string();
8493        let telemetry_line = help
8494            .lines()
8495            .position(|line| line.contains("--telemetry"))
8496            .map(|index| help.lines().skip(index).take(3).collect::<String>())
8497            .expect("--telemetry must appear in --help");
8498        assert!(
8499            telemetry_line.contains("telemetry"),
8500            "expected a help string beside --telemetry, got: {telemetry_line}"
8501        );
8502        assert!(
8503            telemetry_line.contains("off"),
8504            "the help string must say the default is off: {telemetry_line}"
8505        );
8506    }
8507
8508    #[test]
8509    fn build_tui_command_always_states_telemetry_so_an_inherited_value_cannot_leak_through() {
8510        let _lock = env_lock();
8511        let dir = tempfile::TempDir::new().expect("tempdir");
8512        let custom = dir
8513            .path()
8514            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8515        std::fs::write(&custom, b"").unwrap();
8516        let custom_str = custom.to_string_lossy().into_owned();
8517        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8518
8519        // No `--telemetry` flag at all. The old code forwarded nothing here,
8520        // leaving the child to inherit whatever the shell happened to export.
8521        let cli = parse_ok(&["codewhale", "exec", "hi"]);
8522        assert_eq!(cli.telemetry, None);
8523        let mut resolved = telemetry_test_resolved();
8524        resolved.telemetry = true;
8525
8526        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8527        assert_eq!(
8528            command_env(&cmd, "CODEWHALE_TELEMETRY").as_deref(),
8529            Some("true")
8530        );
8531        assert_eq!(
8532            command_env(&cmd, "DEEPSEEK_TELEMETRY").as_deref(),
8533            Some("true")
8534        );
8535    }
8536
8537    #[test]
8538    fn thread_resume_and_fork_delegate_with_the_kill_switch_attached() {
8539        // Regression: both took a bare `Command::new(tui).args(args)` that
8540        // forwarded no arguments and set no environment, so the child
8541        // re-resolved from `$CODEWHALE_HOME/config.toml` with no overrides and
8542        // collected a full session — `install_or_upgrade`, `session_start`,
8543        // `session_end` — for a user who had passed `--telemetry false` or
8544        // pointed `--config` at a file saying `telemetry = false`.
8545        let _lock = env_lock();
8546        let dir = tempfile::TempDir::new().expect("tempdir");
8547        let custom = dir
8548            .path()
8549            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8550        std::fs::write(&custom, b"").unwrap();
8551        let custom_str = custom.to_string_lossy().into_owned();
8552        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8553
8554        let off_config = dir.path().join("off.toml");
8555        std::fs::write(&off_config, b"telemetry = false\n").unwrap();
8556        let off_config_str = off_config.to_string_lossy().into_owned();
8557
8558        for (subcommand, thread_command) in [
8559            (
8560                "resume",
8561                ThreadCommand::Resume {
8562                    thread_id: "t-1".to_string(),
8563                },
8564            ),
8565            (
8566                "fork",
8567                ThreadCommand::Fork {
8568                    thread_id: "t-1".to_string(),
8569                },
8570            ),
8571        ] {
8572            let passthrough =
8573                thread_delegation(&thread_command).expect("resume and fork must delegate");
8574            assert_eq!(passthrough, vec![subcommand.to_string(), "t-1".to_string()]);
8575
8576            let cli = parse_ok(&[
8577                "codewhale",
8578                "--config",
8579                &off_config_str,
8580                "--telemetry",
8581                "false",
8582                "thread",
8583                subcommand,
8584                "t-1",
8585            ]);
8586            let mut resolved = telemetry_test_resolved();
8587            resolved.telemetry = false;
8588
8589            let cmd = build_tui_command(&cli, &resolved, passthrough).expect("command");
8590            let args: Vec<String> = cmd
8591                .get_args()
8592                .map(|arg| arg.to_string_lossy().into_owned())
8593                .collect();
8594            assert!(
8595                args.windows(2)
8596                    .any(|pair| pair == ["--config", off_config_str.as_str()]),
8597                "thread {subcommand} must forward --config: {args:?}"
8598            );
8599            assert!(
8600                args.ends_with(&[subcommand.to_string(), "t-1".to_string()]),
8601                "thread {subcommand} must pass the session through: {args:?}"
8602            );
8603            assert_eq!(
8604                command_env(&cmd, "CODEWHALE_TELEMETRY").as_deref(),
8605                Some("false"),
8606                "thread {subcommand} must carry the resolved kill switch"
8607            );
8608            assert_eq!(
8609                command_env(&cmd, "DEEPSEEK_TELEMETRY").as_deref(),
8610                Some("false")
8611            );
8612        }
8613
8614        // The non-delegating verbs stay in this process; naming them here is
8615        // what makes the match above exhaustive, so a future variant that
8616        // starts a session cannot be added without stating its passthrough.
8617        assert!(
8618            thread_delegation(&ThreadCommand::List {
8619                all: false,
8620                limit: None
8621            })
8622            .is_none()
8623        );
8624    }
8625
8626    #[test]
8627    fn only_one_function_may_locate_and_spawn_the_tui() {
8628        // The finding above was not a wrong argument list; it was a *second*
8629        // way to start the TUI, one that had never been taught the floor. So
8630        // the property worth pinning is that there is one.
8631        let source = include_str!("lib.rs");
8632        let runtime = source
8633            .split_once("\nmod tests {")
8634            .map_or(source, |(before, _)| before);
8635        let call_sites = runtime
8636            .lines()
8637            .filter(|line| {
8638                line.contains("locate_sibling_tui_binary()")
8639                    && !line.trim_start().starts_with("//")
8640                    && !line.contains("fn locate_sibling_tui_binary")
8641            })
8642            .count();
8643        assert_eq!(
8644            call_sites, 1,
8645            "exactly one function may locate and spawn the sibling TUI, so that \
8646             one function is the only place the telemetry floor has to be applied"
8647        );
8648    }
8649
8650    #[test]
8651    fn build_tui_command_states_whether_a_kill_switch_is_in_force() {
8652        // The forwarded `CODEWHALE_TELEMETRY=false` is ambiguous by
8653        // construction: it is both the shipped default and a declared kill
8654        // switch. The child needs the difference for the first-run notice, so
8655        // the dispatcher states it on every run rather than leaving the child
8656        // to infer it from a value that cannot say.
8657        let _lock = env_lock();
8658        let dir = tempfile::TempDir::new().expect("tempdir");
8659        let custom = dir
8660            .path()
8661            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8662        std::fs::write(&custom, b"").unwrap();
8663        let custom_str = custom.to_string_lossy().into_owned();
8664        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8665        let _telemetry_env = ScopedEnvVar::remove("CODEWHALE_TELEMETRY");
8666        let _legacy_env = ScopedEnvVar::remove("DEEPSEEK_TELEMETRY");
8667        let _floor_env = ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV);
8668
8669        // An ordinary first run: off, but nobody declared anything.
8670        let cli = parse_ok(&["codewhale"]);
8671        let resolved = telemetry_test_resolved();
8672        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8673        assert_eq!(
8674            command_env(&cmd, codewhale_config::TELEMETRY_FLOOR_ENV).as_deref(),
8675            Some("0")
8676        );
8677
8678        // The per-run flag is a floor for the run it belongs to.
8679        let cli = parse_ok(&["codewhale", "--telemetry", "false"]);
8680        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8681        assert_eq!(
8682            command_env(&cmd, codewhale_config::TELEMETRY_FLOOR_ENV).as_deref(),
8683            Some("1")
8684        );
8685
8686        // So is the operator's environment.
8687        let _env_off = ScopedEnvVar::set("CODEWHALE_TELEMETRY", "0");
8688        let cli = parse_ok(&["codewhale"]);
8689        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8690        assert_eq!(
8691            command_env(&cmd, codewhale_config::TELEMETRY_FLOOR_ENV).as_deref(),
8692            Some("1")
8693        );
8694    }
8695
8696    fn telemetry_test_resolved() -> ResolvedRuntimeOptions {
8697        ResolvedRuntimeOptions {
8698            provider: ProviderKind::Deepseek,
8699            provider_source: ProviderSource::Config,
8700            model_source: ModelSource::ProviderDefault,
8701            model: "deepseek-chat".to_string(),
8702            api_key: None,
8703            api_key_source: None,
8704            base_url: "https://api.deepseek.com".to_string(),
8705            auth_mode: None,
8706            insecure_skip_tls_verify: false,
8707            output_mode: None,
8708            log_level: None,
8709            telemetry: false,
8710            telemetry_explicit_off: false,
8711            telemetry_endpoint: None,
8712            approval_policy: None,
8713            sandbox_mode: None,
8714            yolo: None,
8715            verbosity: None,
8716            http_headers: std::collections::BTreeMap::new(),
8717        }
8718    }
8719
8720    #[test]
8721    fn parses_no_project_config_before_subcommand() {
8722        let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "list the files"]);
8723        assert!(cli.no_project_config);
8724        match cli.command {
8725            Some(Commands::Exec(args)) => {
8726                assert_eq!(args.args, vec!["list the files".to_string()]);
8727            }
8728            other => panic!("expected exec subcommand, got {other:?}"),
8729        }
8730    }
8731
8732    #[test]
8733    fn no_project_config_after_passthrough_subcommand_is_not_the_dispatcher_flag() {
8734        // `exec` captures trailing args (`trailing_var_arg`), so a misplaced
8735        // `--no-project-config` is NOT honored as the dispatcher flag — it must
8736        // appear before the subcommand, exactly like `--skip-onboarding`.
8737        let cli = parse_ok(&["codewhale", "exec", "--no-project-config", "hi"]);
8738        assert!(!cli.no_project_config);
8739        match cli.command {
8740            Some(Commands::Exec(args)) => {
8741                assert!(args.args.iter().any(|a| a == "--no-project-config"));
8742            }
8743            other => panic!("expected exec subcommand, got {other:?}"),
8744        }
8745    }
8746
8747    #[test]
8748    fn build_tui_command_forwards_no_project_config_before_subcommand() {
8749        let _lock = env_lock();
8750        let dir = tempfile::TempDir::new().expect("tempdir");
8751        let custom = dir
8752            .path()
8753            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8754        std::fs::write(&custom, b"").unwrap();
8755        let custom_str = custom.to_string_lossy().into_owned();
8756        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8757
8758        let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "hi"]);
8759        let resolved = ResolvedRuntimeOptions {
8760            provider: ProviderKind::Openai,
8761            provider_source: ProviderSource::Cli,
8762            model_source: ModelSource::ProviderDefault,
8763            model: "glm-5".to_string(),
8764            api_key: Some("resolved-openai-key".to_string()),
8765            api_key_source: Some(RuntimeApiKeySource::Keyring),
8766            base_url: "https://openai-compatible.example/v4".to_string(),
8767            auth_mode: Some("api_key".to_string()),
8768            insecure_skip_tls_verify: false,
8769            output_mode: None,
8770            log_level: None,
8771            telemetry: false,
8772            telemetry_explicit_off: false,
8773            telemetry_endpoint: None,
8774            approval_policy: None,
8775            sandbox_mode: None,
8776            yolo: None,
8777            verbosity: None,
8778            http_headers: std::collections::BTreeMap::new(),
8779        };
8780
8781        let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string(), "hi".to_string()])
8782            .expect("command");
8783        let args: Vec<String> = cmd
8784            .get_args()
8785            .map(|arg| arg.to_string_lossy().into_owned())
8786            .collect();
8787        let flag = args
8788            .iter()
8789            .position(|a| a == "--no-project-config")
8790            .expect("--no-project-config forwarded");
8791        let subcommand = args
8792            .iter()
8793            .position(|a| a == "exec")
8794            .expect("exec forwarded");
8795        assert!(
8796            flag < subcommand,
8797            "--no-project-config must be forwarded before the subcommand: {args:?}"
8798        );
8799    }
8800
8801    #[test]
8802    fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
8803        let _lock = env_lock();
8804        let dir = tempfile::TempDir::new().expect("tempdir");
8805        let custom = dir
8806            .path()
8807            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8808        std::fs::write(&custom, b"").unwrap();
8809        let custom_str = custom.to_string_lossy().into_owned();
8810        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8811
8812        let cli = parse_ok(&["codewhale", "doctor"]);
8813        let resolved = ResolvedRuntimeOptions {
8814            provider: ProviderKind::OpenaiCodex,
8815            provider_source: ProviderSource::Config,
8816            model_source: ModelSource::ProviderDefault,
8817            model: "gpt-5.5".to_string(),
8818            api_key: None,
8819            api_key_source: None,
8820            base_url: "https://chatgpt.com/backend-api".to_string(),
8821            auth_mode: Some("oauth".to_string()),
8822            insecure_skip_tls_verify: false,
8823            output_mode: None,
8824            log_level: None,
8825            telemetry: false,
8826            telemetry_explicit_off: false,
8827            telemetry_endpoint: None,
8828            approval_policy: None,
8829            sandbox_mode: None,
8830            yolo: None,
8831            verbosity: None,
8832            http_headers: std::collections::BTreeMap::new(),
8833        };
8834
8835        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8836            .expect("openai-codex should be accepted by the facade");
8837        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
8838        let args: Vec<String> = cmd
8839            .get_args()
8840            .map(|arg| arg.to_string_lossy().into_owned())
8841            .collect();
8842        assert_eq!(args, vec!["doctor"]);
8843    }
8844
8845    #[test]
8846    fn build_tui_command_forwards_explicit_openai_codex_provider() {
8847        let _lock = env_lock();
8848        let dir = tempfile::TempDir::new().expect("tempdir");
8849        let custom = dir
8850            .path()
8851            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8852        std::fs::write(&custom, b"").unwrap();
8853        let custom_str = custom.to_string_lossy().into_owned();
8854        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8855
8856        let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
8857        let resolved = ResolvedRuntimeOptions {
8858            provider: ProviderKind::OpenaiCodex,
8859            provider_source: ProviderSource::Cli,
8860            model_source: ModelSource::ProviderDefault,
8861            model: "gpt-5.5".to_string(),
8862            api_key: None,
8863            api_key_source: None,
8864            base_url: "https://chatgpt.com/backend-api".to_string(),
8865            auth_mode: Some("oauth".to_string()),
8866            insecure_skip_tls_verify: false,
8867            output_mode: None,
8868            log_level: None,
8869            telemetry: false,
8870            telemetry_explicit_off: false,
8871            telemetry_endpoint: None,
8872            approval_policy: None,
8873            sandbox_mode: None,
8874            yolo: None,
8875            verbosity: None,
8876            http_headers: std::collections::BTreeMap::new(),
8877        };
8878
8879        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8880            .expect("openai-codex should be accepted by the facade");
8881        assert_eq!(
8882            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8883            Some("openai-codex")
8884        );
8885    }
8886
8887    #[test]
8888    fn build_tui_command_allows_anthropic_cli_provider() {
8889        let _lock = env_lock();
8890        let (_dir, _bin) = install_fake_tui_binary();
8891
8892        let cli = parse_ok(&["codewhale", "--provider", "anthropic", "doctor"]);
8893        let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
8894
8895        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8896            .expect("anthropic should be accepted by the facade");
8897        assert_eq!(
8898            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8899            Some("anthropic")
8900        );
8901    }
8902
8903    #[test]
8904    fn build_tui_command_allows_anthropic_env_provider() {
8905        let _lock = env_lock();
8906        let (_dir, _bin) = install_fake_tui_binary();
8907
8908        let cli = parse_ok(&["codewhale", "doctor"]);
8909        let resolved = resolved_runtime_for_test(
8910            ProviderKind::Anthropic,
8911            ProviderSource::Env("DEEPSEEK_PROVIDER"),
8912        );
8913
8914        build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8915            .expect("anthropic from provider env should be accepted by the facade");
8916    }
8917
8918    #[test]
8919    fn build_tui_command_bridges_anthropic_keyring_secret() {
8920        let _lock = env_lock();
8921        let (_dir, _bin) = install_fake_tui_binary();
8922
8923        let cli = parse_ok(&["codewhale", "doctor"]);
8924        let mut resolved =
8925            resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
8926        resolved.api_key = Some("anthropic-keyring-secret".to_string());
8927        resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
8928
8929        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8930            .expect("config-sourced anthropic provider should be accepted");
8931
8932        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
8933        assert_eq!(
8934            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
8935            Some("anthropic-keyring-secret")
8936        );
8937        assert_eq!(
8938            command_env(&cmd, "ANTHROPIC_API_KEY").as_deref(),
8939            Some("anthropic-keyring-secret")
8940        );
8941        assert_eq!(
8942            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
8943            Some("keyring")
8944        );
8945    }
8946
8947    #[test]
8948    fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
8949        let _lock = env_lock();
8950        let dir = tempfile::TempDir::new().expect("tempdir");
8951        let custom = dir
8952            .path()
8953            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8954        std::fs::write(&custom, b"").unwrap();
8955        let custom_str = custom.to_string_lossy().into_owned();
8956        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8957
8958        let cli = parse_ok(&["deepseek", "--profile", "google"]);
8959        let mut resolved_headers = std::collections::BTreeMap::new();
8960        resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
8961        let resolved = ResolvedRuntimeOptions {
8962            provider: ProviderKind::Deepseek,
8963            provider_source: ProviderSource::Config,
8964            model_source: ModelSource::ProviderDefault,
8965            model: "deepseek-v4-pro".to_string(),
8966            api_key: Some("config-file-key".to_string()),
8967            api_key_source: Some(RuntimeApiKeySource::ConfigFile),
8968            base_url: "https://api.deepseek.com/beta".to_string(),
8969            auth_mode: Some("api_key".to_string()),
8970            insecure_skip_tls_verify: false,
8971            output_mode: None,
8972            log_level: None,
8973            telemetry: false,
8974            telemetry_explicit_off: false,
8975            telemetry_endpoint: None,
8976            approval_policy: None,
8977            sandbox_mode: None,
8978            yolo: None,
8979            verbosity: Some("normal".to_string()),
8980            http_headers: resolved_headers,
8981        };
8982
8983        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8984
8985        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
8986        assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
8987        assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
8988        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
8989        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
8990        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
8991        assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
8992        assert_eq!(command_env(&cmd, "CODEWHALE_VERBOSITY"), None);
8993        assert_eq!(command_env(&cmd, "DEEPSEEK_VERBOSITY"), None);
8994        let args: Vec<String> = cmd
8995            .get_args()
8996            .map(|arg| arg.to_string_lossy().into_owned())
8997            .collect();
8998        assert!(
8999            args.windows(2).any(|pair| pair == ["--profile", "google"]),
9000            "expected profile forwarding in args: {args:?}"
9001        );
9002    }
9003
9004    #[test]
9005    fn build_tui_command_defaults_noninteractive_to_concise_verbosity() {
9006        let _lock = env_lock();
9007        let (_dir, _bin) = install_fake_tui_binary();
9008
9009        let cli = parse_ok(&["codewhale"]);
9010        let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
9011
9012        let cmd = build_tui_command(
9013            &cli,
9014            &resolved,
9015            vec!["exec".to_string(), "summarize".to_string()],
9016        )
9017        .expect("command");
9018
9019        assert_eq!(
9020            command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
9021            Some("concise")
9022        );
9023        assert_eq!(
9024            command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
9025            Some("concise")
9026        );
9027    }
9028
9029    #[test]
9030    fn build_tui_command_respects_resolved_verbosity_override() {
9031        let _lock = env_lock();
9032        let (_dir, _bin) = install_fake_tui_binary();
9033
9034        let cli = parse_ok(&["codewhale"]);
9035        let mut resolved =
9036            resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
9037        resolved.verbosity = Some("normal".to_string());
9038
9039        let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string()]).expect("command");
9040
9041        assert_eq!(
9042            command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
9043            Some("normal")
9044        );
9045        assert_eq!(
9046            command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
9047            Some("normal")
9048        );
9049    }
9050
9051    #[test]
9052    fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
9053        let _lock = env_lock();
9054        let dir = tempfile::TempDir::new().expect("tempdir");
9055        let custom = dir
9056            .path()
9057            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9058        std::fs::write(&custom, b"").unwrap();
9059        let custom_str = custom.to_string_lossy().into_owned();
9060        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9061
9062        let cli = parse_ok(&[
9063            "codewhale",
9064            "--provider",
9065            "moonshot",
9066            "--model",
9067            "kimi-k2.7-code",
9068            "--workspace",
9069            "/tmp/codewhale-workspace",
9070        ]);
9071        let resolved = ResolvedRuntimeOptions {
9072            provider: ProviderKind::Moonshot,
9073            provider_source: ProviderSource::Cli,
9074            model_source: ModelSource::ProviderDefault,
9075            model: "kimi-k2.7-code".to_string(),
9076            api_key: Some("resolved-kimi-key".to_string()),
9077            api_key_source: Some(RuntimeApiKeySource::Keyring),
9078            base_url: "https://api.moonshot.ai/v1".to_string(),
9079            auth_mode: Some("api_key".to_string()),
9080            insecure_skip_tls_verify: false,
9081            output_mode: None,
9082            log_level: None,
9083            telemetry: false,
9084            telemetry_explicit_off: false,
9085            telemetry_endpoint: None,
9086            approval_policy: None,
9087            sandbox_mode: None,
9088            yolo: None,
9089            verbosity: None,
9090            http_headers: std::collections::BTreeMap::new(),
9091        };
9092
9093        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
9094        assert_eq!(
9095            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
9096            Some("moonshot")
9097        );
9098        assert_eq!(
9099            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
9100            Some("kimi-k2.7-code")
9101        );
9102        assert_eq!(
9103            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
9104            Some("resolved-kimi-key")
9105        );
9106        assert_eq!(
9107            command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
9108            Some("resolved-kimi-key")
9109        );
9110        assert_eq!(
9111            command_env(&cmd, "KIMI_API_KEY").as_deref(),
9112            Some("resolved-kimi-key")
9113        );
9114        assert_eq!(
9115            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
9116            Some("keyring")
9117        );
9118        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
9119    }
9120
9121    #[test]
9122    fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
9123        let _lock = env_lock();
9124        let dir = tempfile::TempDir::new().expect("tempdir");
9125        let custom = dir
9126            .path()
9127            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9128        std::fs::write(&custom, b"").unwrap();
9129        let custom_str = custom.to_string_lossy().into_owned();
9130        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9131
9132        let cli = parse_ok(&[
9133            "codewhale",
9134            "--provider",
9135            "volcengine",
9136            "--model",
9137            "DeepSeek-V4-Pro",
9138            "--workspace",
9139            "/tmp/codewhale-workspace",
9140        ]);
9141        let resolved = ResolvedRuntimeOptions {
9142            provider: ProviderKind::Volcengine,
9143            provider_source: ProviderSource::Cli,
9144            model_source: ModelSource::ProviderDefault,
9145            model: "DeepSeek-V4-Pro".to_string(),
9146            api_key: Some("resolved-ark-key".to_string()),
9147            api_key_source: Some(RuntimeApiKeySource::Keyring),
9148            base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
9149            auth_mode: Some("api_key".to_string()),
9150            insecure_skip_tls_verify: false,
9151            output_mode: None,
9152            log_level: None,
9153            telemetry: false,
9154            telemetry_explicit_off: false,
9155            telemetry_endpoint: None,
9156            approval_policy: None,
9157            sandbox_mode: None,
9158            yolo: None,
9159            verbosity: None,
9160            http_headers: std::collections::BTreeMap::new(),
9161        };
9162
9163        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
9164        assert_eq!(
9165            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
9166            Some("volcengine")
9167        );
9168        assert_eq!(
9169            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
9170            Some("DeepSeek-V4-Pro")
9171        );
9172        assert_eq!(
9173            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
9174            Some("resolved-ark-key")
9175        );
9176        assert_eq!(
9177            command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
9178            Some("resolved-ark-key")
9179        );
9180        assert_eq!(
9181            command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
9182            Some("resolved-ark-key")
9183        );
9184        assert_eq!(
9185            command_env(&cmd, "ARK_API_KEY").as_deref(),
9186            Some("resolved-ark-key")
9187        );
9188    }
9189
9190    #[test]
9191    fn build_tui_command_exports_explicit_provider_model_and_base_url() {
9192        let _lock = env_lock();
9193        let dir = tempfile::TempDir::new().expect("tempdir");
9194        let custom = dir
9195            .path()
9196            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9197        std::fs::write(&custom, b"").unwrap();
9198        let custom_str = custom.to_string_lossy().into_owned();
9199        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9200
9201        let cli = parse_ok(&[
9202            "deepseek",
9203            "--profile",
9204            "google",
9205            "--provider",
9206            "openai",
9207            "--model",
9208            "glm-5",
9209            "--base-url",
9210            "https://openai-compatible.example/v4",
9211        ]);
9212        let resolved = ResolvedRuntimeOptions {
9213            provider: ProviderKind::Openai,
9214            provider_source: ProviderSource::Cli,
9215            model_source: ModelSource::ProviderDefault,
9216            model: "glm-5".to_string(),
9217            api_key: None,
9218            api_key_source: None,
9219            base_url: "https://openai-compatible.example/v4".to_string(),
9220            auth_mode: None,
9221            insecure_skip_tls_verify: false,
9222            output_mode: None,
9223            log_level: None,
9224            telemetry: false,
9225            telemetry_explicit_off: false,
9226            telemetry_endpoint: None,
9227            approval_policy: None,
9228            sandbox_mode: None,
9229            yolo: None,
9230            verbosity: None,
9231            http_headers: std::collections::BTreeMap::new(),
9232        };
9233
9234        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
9235
9236        assert_eq!(
9237            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
9238            Some("openai")
9239        );
9240        assert_eq!(
9241            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
9242            Some("glm-5")
9243        );
9244        assert_eq!(
9245            command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
9246            Some("https://openai-compatible.example/v4")
9247        );
9248    }
9249
9250    #[test]
9251    fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
9252        let _lock = env_lock();
9253        let dir = tempfile::TempDir::new().expect("tempdir");
9254        let custom = dir
9255            .path()
9256            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9257        std::fs::write(&custom, b"").unwrap();
9258        let custom_str = custom.to_string_lossy().into_owned();
9259        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9260
9261        for provider in ProviderKind::ALL {
9262            let cli = parse_ok(&["codewhale", "--workspace", "/tmp/codewhale-workspace"]);
9263            let resolved = ResolvedRuntimeOptions {
9264                provider,
9265                provider_source: ProviderSource::Config,
9266                model_source: ModelSource::ProviderDefault,
9267                model: "test-model".to_string(),
9268                api_key: Some("test-key".to_string()),
9269                api_key_source: Some(RuntimeApiKeySource::Keyring),
9270                base_url: "http://localhost:8000/v1".to_string(),
9271                auth_mode: Some("api_key".to_string()),
9272                insecure_skip_tls_verify: false,
9273                output_mode: None,
9274                log_level: None,
9275                telemetry: false,
9276                telemetry_explicit_off: false,
9277                telemetry_endpoint: None,
9278                approval_policy: None,
9279                sandbox_mode: None,
9280                yolo: None,
9281                verbosity: None,
9282                http_headers: std::collections::BTreeMap::new(),
9283            };
9284
9285            let cmd = build_tui_command(&cli, &resolved, Vec::new())
9286                .unwrap_or_else(|e| panic!("{}: {e}", provider.as_str()));
9287
9288            assert_eq!(
9289                command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
9290                Some("test-key"),
9291                "{}: DEEPSEEK_API_KEY not forwarded",
9292                provider.as_str()
9293            );
9294            for var in provider_env_vars(provider)
9295                .iter()
9296                .filter(|var| **var != "DEEPSEEK_API_KEY")
9297            {
9298                assert_eq!(
9299                    command_env(&cmd, var).as_deref(),
9300                    Some("test-key"),
9301                    "{}: {var} not forwarded",
9302                    provider.as_str()
9303                );
9304            }
9305            assert_eq!(
9306                command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
9307                Some("keyring"),
9308                "{}: expected keyring source bridge",
9309                provider.as_str()
9310            );
9311            assert_eq!(
9312                command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
9313                None,
9314                "{}: auth mode should come from config/profile, not env handoff",
9315                provider.as_str()
9316            );
9317        }
9318    }
9319
9320    #[test]
9321    fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
9322        let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
9323
9324        assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
9325        assert!(cli.prompt.is_empty());
9326        assert_eq!(
9327            root_tui_passthrough(&cli).unwrap(),
9328            vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
9329        );
9330    }
9331
9332    #[test]
9333    fn parses_top_level_continue_for_interactive_resume() {
9334        let cli = parse_ok(&["codewhale", "--continue"]);
9335
9336        assert!(cli.continue_session);
9337        assert!(cli.prompt_flag.is_none());
9338        assert!(cli.prompt.is_empty());
9339        assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
9340    }
9341
9342    #[test]
9343    fn parses_rc_as_the_account_owned_interactive_handoff() {
9344        let cli = parse_ok(&["codewhale", "rc"]);
9345
9346        let Some(Commands::Rc(args)) = cli.command else {
9347            panic!("rc should parse as the remote-control TUI handoff");
9348        };
9349        assert!(args.args.is_empty());
9350    }
9351
9352    #[test]
9353    fn top_level_continue_rejects_startup_prompt() {
9354        let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
9355
9356        let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
9357        assert!(
9358            err.to_string()
9359                .contains("codewhale exec --continue <PROMPT>")
9360        );
9361    }
9362
9363    #[test]
9364    fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
9365        let cli = parse_ok(&["deepseek", "hello", "world"]);
9366
9367        assert_eq!(cli.prompt, vec!["hello", "world"]);
9368        assert!(cli.command.is_none());
9369        assert_eq!(
9370            root_tui_passthrough(&cli).unwrap(),
9371            vec!["--prompt".to_string(), "hello world".to_string()]
9372        );
9373    }
9374
9375    #[test]
9376    fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
9377        let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
9378
9379        assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
9380        assert_eq!(cli.prompt, vec!["world"]);
9381        assert_eq!(
9382            root_tui_passthrough(&cli).unwrap(),
9383            vec!["--prompt".to_string(), "hello world".to_string()]
9384        );
9385    }
9386
9387    #[test]
9388    fn known_subcommands_still_parse_before_prompt_tail() {
9389        let cli = parse_ok(&["deepseek", "doctor"]);
9390
9391        assert!(cli.prompt.is_empty());
9392        assert!(matches!(cli.command, Some(Commands::Doctor(_))));
9393    }
9394
9395    #[test]
9396    fn root_help_surface_contains_expected_subcommands_and_globals() {
9397        let rendered = help_for(&["deepseek", "--help"]);
9398
9399        for token in [
9400            "run",
9401            "doctor",
9402            "models",
9403            "sessions",
9404            "resume",
9405            "setup",
9406            "login",
9407            "logout",
9408            "auth",
9409            "mcp-server",
9410            "config",
9411            "model",
9412            "thread",
9413            "sandbox",
9414            "app-server",
9415            "completion",
9416            "metrics",
9417            "--provider",
9418            "--model",
9419            "--config",
9420            "--profile",
9421            "--output-mode",
9422            "--log-level",
9423            "--telemetry",
9424            "--base-url",
9425            "--api-key",
9426            "--approval-policy",
9427            "--sandbox-mode",
9428            "--mouse-capture",
9429            "--no-mouse-capture",
9430            "--skip-onboarding",
9431            "--continue",
9432            "--prompt",
9433        ] {
9434            assert!(
9435                rendered.contains(token),
9436                "expected help to contain token: {token}"
9437            );
9438        }
9439    }
9440
9441    #[test]
9442    fn subcommand_help_surfaces_are_stable() {
9443        let cases = [
9444            ("config", vec!["get", "set", "unset", "list", "path"]),
9445            ("model", vec!["list", "resolve"]),
9446            (
9447                "thread",
9448                vec![
9449                    "list",
9450                    "read",
9451                    "resume",
9452                    "fork",
9453                    "archive",
9454                    "unarchive",
9455                    "set-name",
9456                    "clear-name",
9457                ],
9458            ),
9459            ("sandbox", vec!["check"]),
9460            (
9461                "exec",
9462                vec![
9463                    "--auto",
9464                    "--json",
9465                    "--resume",
9466                    "--session-id",
9467                    "--continue",
9468                    "--output-format",
9469                    "stream-json",
9470                ],
9471            ),
9472            (
9473                "app-server",
9474                vec!["--host", "--port", "--config", "--stdio"],
9475            ),
9476            (
9477                "completion",
9478                vec![
9479                    "<SHELL>",
9480                    "bash",
9481                    "source <(codewhale completion bash)",
9482                    "~/.local/share/bash-completion/completions/codewhale",
9483                    "fpath=(~/.zfunc $fpath)",
9484                    "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
9485                    "codewhale completion powershell | Out-String | Invoke-Expression",
9486                ],
9487            ),
9488            ("metrics", vec!["--json", "--since"]),
9489        ];
9490
9491        for (subcommand, expected_tokens) in cases {
9492            let argv = ["deepseek", subcommand, "--help"];
9493            let rendered = help_for(&argv);
9494            for token in expected_tokens {
9495                assert!(
9496                    rendered.contains(token),
9497                    "expected help for `{subcommand}` to include `{token}`"
9498                );
9499            }
9500        }
9501    }
9502
9503    /// Regression for issue #247: on Windows the dispatcher must find the
9504    /// sibling `codewhale-tui.exe`, not bail out looking for an
9505    /// extension-less `codewhale-tui`. The candidate resolver also accepts
9506    /// the suffix-less name on Windows so users who manually renamed the
9507    /// file as a workaround keep working after the upgrade.
9508    #[test]
9509    fn sibling_tui_candidate_picks_platform_correct_name() {
9510        let dir = tempfile::TempDir::new().expect("tempdir");
9511        let dispatcher = dir
9512            .path()
9513            .join("codewhale")
9514            .with_extension(std::env::consts::EXE_EXTENSION);
9515        // Touch the dispatcher so its parent dir is the lookup root.
9516        std::fs::write(&dispatcher, b"").unwrap();
9517
9518        // No sibling yet — resolver returns None.
9519        assert!(sibling_tui_candidate(&dispatcher).is_none());
9520
9521        let target =
9522            dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
9523        std::fs::write(&target, b"").unwrap();
9524
9525        let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
9526        assert_eq!(found, target, "primary platform-correct name wins");
9527    }
9528
9529    #[test]
9530    fn dispatcher_spawn_error_names_path_and_recovery_checks() {
9531        let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
9532        let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
9533
9534        assert!(message.contains("C:/tools/codewhale-tui.exe"));
9535        assert!(message.contains("access is denied"));
9536        assert!(message.contains("where codewhale"));
9537        assert!(message.contains("DEEPSEEK_TUI_BIN"));
9538    }
9539
9540    #[cfg(unix)]
9541    #[test]
9542    fn tui_child_exit_code_maps_unix_signal_to_shell_status() {
9543        use std::os::unix::process::ExitStatusExt;
9544
9545        let status = std::process::ExitStatus::from_raw(libc::SIGPIPE);
9546
9547        assert_eq!(tui_child_exit_code(status), Some(141));
9548    }
9549
9550    /// Windows-only fallback: the user from #247 manually renamed the
9551    /// file to drop `.exe`. After the fix lands, that workaround must
9552    /// still resolve via the suffix-less fallback so they don't have to
9553    /// rename it back.
9554    #[cfg(windows)]
9555    #[test]
9556    fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
9557        let dir = tempfile::TempDir::new().expect("tempdir");
9558        let dispatcher = dir.path().join("codewhale.exe");
9559        std::fs::write(&dispatcher, b"").unwrap();
9560
9561        // Only the suffixless name exists — emulates the manual rename.
9562        let suffixless = dispatcher.with_file_name("codewhale-tui");
9563        std::fs::write(&suffixless, b"").unwrap();
9564
9565        let found = sibling_tui_candidate(&dispatcher)
9566            .expect("Windows fallback must locate suffixless codewhale-tui");
9567        assert_eq!(found, suffixless);
9568    }
9569
9570    /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
9571    /// custom Windows install layouts and CI test rigs.
9572    #[test]
9573    fn locate_sibling_tui_binary_honours_env_override() {
9574        let _lock = env_lock();
9575        let dir = tempfile::TempDir::new().expect("tempdir");
9576        let custom = dir
9577            .path()
9578            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
9579        std::fs::write(&custom, b"").unwrap();
9580        let custom_str = custom.to_string_lossy().into_owned();
9581        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
9582
9583        let resolved = locate_sibling_tui_binary().expect("override must resolve");
9584        assert_eq!(resolved, custom);
9585    }
9586
9587    /// `CODEWHALE_TUI_BIN` is the canonical override name and outranks the
9588    /// legacy `DEEPSEEK_TUI_BIN` alias when both are set.
9589    #[test]
9590    fn locate_sibling_tui_binary_prefers_codewhale_env_override() {
9591        let _lock = env_lock();
9592        let dir = tempfile::TempDir::new().expect("tempdir");
9593        let canonical = dir
9594            .path()
9595            .join(format!("canonical-tui{}", std::env::consts::EXE_SUFFIX));
9596        let legacy = dir
9597            .path()
9598            .join(format!("legacy-tui{}", std::env::consts::EXE_SUFFIX));
9599        std::fs::write(&canonical, b"").unwrap();
9600        std::fs::write(&legacy, b"").unwrap();
9601        let _canonical_bin = ScopedEnvVar::set("CODEWHALE_TUI_BIN", &canonical.to_string_lossy());
9602        let _legacy_bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &legacy.to_string_lossy());
9603
9604        let resolved = locate_sibling_tui_binary().expect("override must resolve");
9605        assert_eq!(resolved, canonical);
9606    }
9607}