Skip to main content

codewhale_cli/
lib.rs

1#![allow(clippy::uninlined_format_args)]
2
3mod cloud;
4mod config_bundles;
5mod credential_handoff;
6mod dispatch;
7mod metrics;
8#[cfg(not(target_env = "ohos"))]
9mod update;
10
11use std::io::{self, IsTerminal, Read, Write};
12use std::net::SocketAddr;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use anyhow::{Context, Result, anyhow, bail};
17use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
18use clap_complete::{Shell, generate};
19use codewhale_agent::ModelRegistry;
20use codewhale_app_server::daemon_socket::{DaemonSocketOptions, run_daemon_socket};
21use codewhale_app_server::{
22    AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
23};
24use codewhale_config::route::{ProvidersExport, parse_route_kind};
25use codewhale_config::{
26    CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
27    ProviderSource, ResolvedRuntimeOptions, RuntimeApiKeySource, SetupState,
28    classify_config_api_key_value, provider_base_url_is_official,
29};
30use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
31use codewhale_mcp::{McpServerDefinition, run_stdio_server};
32use codewhale_secrets::Secrets;
33use codewhale_state::{StateStore, ThreadListFilters};
34use codewhale_telemetry::{
35    self as telemetry, Counters, DurationBucket, Errors, Event, ExitClass, SessionSource, Surface,
36    TelemetryDecision, TurnWall,
37};
38
39fn is_antigravity_legacy_selector(value: &str) -> bool {
40    matches!(
41        value.trim().to_ascii_lowercase().as_str(),
42        "antigravity" | "agy"
43    )
44}
45
46/// Catalog-backed `--provider` parser. Replaces the closed 47-arm `ProviderArg` enum.
47fn parse_catalog_route(value: &str) -> std::result::Result<ProviderKind, String> {
48    if is_antigravity_legacy_selector(value) {
49        return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string());
50    }
51    parse_route_kind(value).ok_or_else(|| {
52        format!(
53            "unknown route '{value}'; expected a catalog route id (see `codewhale providers export --json`)"
54        )
55    })
56}
57
58fn builtin_provider_arg(value: &str) -> Option<ProviderKind> {
59    parse_route_kind(value).filter(|provider| *provider != ProviderKind::Antigravity)
60}
61
62/// The legacy tombstone is accepted only by the local Codewhale-state clear
63/// command. Every selectable/auth-consuming parser continues through
64/// [`parse_catalog_route`], which rejects it.
65fn parse_auth_clear_provider(value: &str) -> std::result::Result<ProviderKind, String> {
66    if is_antigravity_legacy_selector(value) {
67        return Ok(ProviderKind::Antigravity);
68    }
69    parse_catalog_route(value)
70}
71
72fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
73    if value.is_empty()
74        || value == "__custom__"
75        || !value
76            .chars()
77            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
78    {
79        return Err(
80            "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
81                .to_string(),
82        );
83    }
84    Ok(value.to_string())
85}
86
87#[derive(Debug, Parser)]
88#[command(
89    name = "codewhale",
90    version = env!("CODEWHALE_BUILD_VERSION"),
91    bin_name = "codewhale",
92    override_usage = "codewhale [OPTIONS] [PROMPT]\n       codewhale [OPTIONS] <COMMAND> [ARGS]"
93)]
94struct Cli {
95    #[arg(long)]
96    config: Option<PathBuf>,
97    #[arg(long)]
98    profile: Option<String>,
99    #[arg(
100        long,
101        value_name = "PROVIDER",
102        value_parser = parse_provider_identifier,
103        help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
104    )]
105    provider: Option<String>,
106    #[arg(long)]
107    model: Option<String>,
108    #[arg(long = "output-mode")]
109    output_mode: Option<String>,
110    #[arg(
111        long = "verbosity",
112        value_name = "LEVEL",
113        help = "Controls transcript and output verbosity (normal, concise)"
114    )]
115    verbosity: Option<String>,
116    #[arg(long = "log-level")]
117    log_level: Option<String>,
118    #[arg(
119        long,
120        value_name = "BOOL",
121        help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
122                durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
123    )]
124    telemetry: Option<bool>,
125    #[arg(long)]
126    approval_policy: Option<String>,
127    #[arg(long)]
128    sandbox_mode: Option<String>,
129    #[arg(long)]
130    api_key: Option<String>,
131    #[arg(long)]
132    base_url: Option<String>,
133    /// Workspace directory for Codewhale file tools.
134    #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
135    workspace: Option<PathBuf>,
136    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
137    mouse_capture: bool,
138    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
139    no_mouse_capture: bool,
140    #[arg(long = "skip-onboarding")]
141    skip_onboarding: bool,
142    /// Start a fresh session without automatic resume or crash recovery.
143    #[arg(long)]
144    fresh: bool,
145    /// Skip loading project-level config, including the workspace-specific
146    /// `[workspace]`/`[projects]` overlay from user config. Must appear before
147    /// the subcommand; it is applied before subcommand dispatch.
148    #[arg(long = "no-project-config")]
149    no_project_config: bool,
150    /// Legacy compatibility alias for Act + Full Access.
151    #[arg(long, hide = true)]
152    yolo: bool,
153    /// Continue the most recent interactive session for this workspace.
154    #[arg(short = 'c', long = "continue")]
155    continue_session: bool,
156    /// Resume a saved interactive session by id or unique id prefix.
157    #[arg(
158        short = 'r',
159        long = "resume",
160        value_name = "SESSION_ID",
161        conflicts_with_all = ["continue_session", "session_id"]
162    )]
163    resume: Option<String>,
164    /// Alias of `--resume` matching `codewhale exec --session-id`.
165    #[arg(
166        long = "session-id",
167        value_name = "SESSION_ID",
168        conflicts_with_all = ["continue_session", "resume"]
169    )]
170    session_id: Option<String>,
171    #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
172    prompt_flag: Option<String>,
173    /// Per-run config override (`KEY=VALUE`), repeatable, never saved.
174    /// Runtime keys: provider, model/default_text_model, verbosity,
175    /// approval_policy, sandbox_mode, telemetry. Dedicated flags win;
176    /// managed policy still applies. `config set` persists instead. Long-only:
177    /// short `-c` is already `--continue`.
178    #[arg(long = "set", value_name = "KEY=VALUE")]
179    overrides: Vec<String>,
180    #[arg(
181        value_name = "PROMPT",
182        trailing_var_arg = true,
183        allow_hyphen_values = true
184    )]
185    prompt: Vec<String>,
186    #[command(subcommand)]
187    command: Option<Commands>,
188}
189
190#[derive(Debug, Subcommand)]
191enum Commands {
192    /// Run an interactive or non-interactive task.
193    Run(RunArgs),
194    /// Run Codewhale diagnostics.
195    Doctor(TuiPassthroughArgs),
196    /// List cached models; use --update to refresh configured provider catalogs.
197    #[command(
198        after_help = "Examples:\n  codewhale models --update\n  codewhale models --update --provider openai\n  codewhale models --provider openai-codex --json\n\n--update (alias: --refresh) refreshes configured provider catalogs. --provider ID limits the scope."
199    )]
200    Models(TuiPassthroughArgs),
201    /// Generate speech audio with Xiaomi MiMo TTS models.
202    #[command(visible_alias = "tts")]
203    Speech(TuiPassthroughArgs),
204    /// List saved sessions.
205    Sessions(TuiPassthroughArgs),
206    /// Resume a saved session.
207    Resume(TuiPassthroughArgs),
208    /// Launch an interactive session and hand it to the Codewhale web app.
209    Rc(TuiPassthroughArgs),
210    /// Fork a saved session.
211    Fork(TuiPassthroughArgs),
212    /// Create a default AGENTS.md in the current directory.
213    Init(TuiPassthroughArgs),
214    /// Bootstrap MCP config and/or skills directories.
215    Setup(TuiPassthroughArgs),
216    /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge).
217    RemoteSetup(RemoteSetupArgs),
218    /// Run a non-interactive prompt.
219    #[command(after_help = "\
220Examples:
221  codewhale exec \"explain this function\"
222  codewhale exec --auto \"list crates/ with ls\"
223  codewhale exec --auto --output-format stream-json \"fix the failing test\"
224
225Common forwarded flags:
226  --auto                           Enable tool-backed agent mode with auto-approvals
227  --json                           Emit summary JSON
228  --resume <SESSION_ID>            Resume a previous session by ID or prefix
229  --session-id <SESSION_ID>        Resume a previous session by ID or prefix
230  --continue                       Continue the most recent session for this workspace
231  --output-format <FORMAT>         Output format: text or stream-json
232
233Plain `codewhale exec` is a one-shot model response. Use `--auto` for
234non-interactive filesystem/shell tool use, matching the supported automation
235path used by stream-json wrappers.
236")]
237    Exec(TuiPassthroughArgs),
238    /// Manage durable Agent fleet runs.
239    #[command(
240        name = "fleet",
241        after_help = "\
242Examples:
243  codewhale fleet init
244  codewhale fleet run tasks.json --max-workers 4
245  codewhale fleet status
246
247The durable ledger `.codewhale/fleet.jsonl`, saved rosters `fleets/<name>.toml`,
248the `[fleet]` and `[fleets.*]` config tables, and `workflow run --fleet` keep
249the Fleet name across versions."
250    )]
251    Fleet(TuiPassthroughArgs),
252    /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
253    #[command(name = "workflow-tool", hide = true)]
254    WorkflowTool(TuiPassthroughArgs),
255    /// Internal detached-runtime output/receipt supervisor.
256    #[command(name = "lane-log-proxy", hide = true)]
257    LaneLogProxy(LaneLogProxyArgs),
258    /// Run checked-in Workflows through a Lane Runtime backend.
259    #[command(after_help = "\
260Examples:
261  codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
262  codewhale workflow run stopship --fleet stopship --runtime inline --verify
263
264`workflow run` validates the checked-in Workflow source and named Fleet roster,
265creates a Lane record, then dispatches the Workflow tool directly through the
266selected Runtime backend without an operator model turn.
267")]
268    Workflow(WorkflowArgs),
269    /// Manage running workflow instances (Lanes) and Runtime backends (#4176).
270    #[command(after_help = "\
271Examples:
272  codewhale lane list
273  codewhale lane status <lane-id>
274  codewhale lane attach <lane-id>
275  codewhale lane logs <lane-id>
276  codewhale lane interrupt <lane-id>
277  codewhale lane interrupt <lane-id>@<lifecycle-seq>
278  codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
279
280Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
281Runtime, not Fleet.
282
283list/status/interrupt/restart/resume share one control-plane contract with the
284`/lane` slash command and its hotbar action: same verb ids, same availability,
285same read-vs-write authority, same exact-identity target selection, and the
286same receipt (`--json`). `lane stop` is a compatibility spelling of
287`lane interrupt`. Appending `@<lifecycle-seq>` fences a write to the exact
288lifecycle generation you observed.
289")]
290    Lane(LaneArgs),
291    /// Run a Codewhale-powered code review over a git diff.
292    Review(TuiPassthroughArgs),
293    /// Apply a patch file or stdin to the working tree.
294    Apply(TuiPassthroughArgs),
295    /// Run the offline evaluation harness.
296    Eval(TuiPassthroughArgs),
297    /// Manage MCP servers.
298    Mcp(TuiPassthroughArgs),
299    /// Inspect feature flags.
300    Features(TuiPassthroughArgs),
301    /// Connect third-party harnesses through Codewhale (e.g. `integrations dsh status`).
302    Integrations(TuiPassthroughArgs),
303    /// Run a local Codewhale server.
304    #[command(after_help = "\
305Forwarded serve options:
306      --mcp                 Start MCP server over stdio
307      --http                Start runtime HTTP/SSE API server
308      --mobile              Start runtime HTTP/SSE API server with the mobile control page
309      --web                 Start the embedded loopback-only browser client
310      --qr                  Show a QR code for the mobile URL (requires --mobile)
311      --acp                 Start ACP server over stdio for editor clients
312      --host <HOST>         Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
313      --port <PORT>         Bind port [default: 7878]
314      --workers <WORKERS>   Background task worker count (1-8)
315      --cors-origin <URL>   Additional CORS origin to allow (repeatable)
316      --auth-token <TOKEN>  Require this bearer token for /v1/* runtime API routes
317      --insecure            Disable runtime API auth when no token is configured
318
319`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
320aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
321New integrations should prefer `codewhale app-server`.")]
322    Serve(TuiPassthroughArgs),
323    /// Open the first-class local browser client over the canonical Runtime API.
324    #[command(
325        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."
326    )]
327    Web(WebArgs),
328    /// Sign in to your Codewhale account (browser device flow).
329    Login(LoginArgs),
330    /// Remove saved authentication state.
331    Logout,
332    /// Manage authentication credentials and provider mode.
333    Auth(AuthArgs),
334    /// Sign in to your Codewhale account and manage account-scoped provider keys.
335    #[command(visible_alias = "cloud")]
336    Account(cloud::CloudArgs),
337    /// Offload a coding agent to the Codewhale cloud. Never spends or pushes without --confirm.
338    #[command(visible_alias = "cloud-agent")]
339    Dispatch(dispatch::DispatchArgs),
340    /// Run MCP server mode over stdio.
341    McpServer,
342    /// Read/write/list config values.
343    Config(ConfigArgs),
344    /// Resolve or list available models across providers.
345    Model(ModelArgs),
346    /// Manage thread/session metadata and resume/fork flows.
347    Thread(ThreadArgs),
348    /// Evaluate sandbox/approval policy decisions.
349    Sandbox(SandboxArgs),
350    /// Run the canonical runtime API / control plane (HTTP/SSE, mobile, stdio).
351    #[command(after_help = "\
352Transports:
353  codewhale app-server --http              Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
354  codewhale app-server --mobile            Runtime API + phone control page (binds 0.0.0.0)
355  codewhale app-server --stdio             JSON-RPC control transport over stdio (no listener)
356  codewhale app-server                     Legacy in-process app-server HTTP on 127.0.0.1:8787
357
358`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
359--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
360is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
361
362See docs/RUNTIME_API.md.")]
363    AppServer(AppServerArgs),
364    /// Generate shell completions.
365    #[command(
366        visible_alias = "completions",
367        after_help = r#"Every script completes both `codewhale` and the `codew` shorthand.
368
369Examples:
370  Bash (current shell only):
371    source <(codewhale completion bash)
372
373  Bash (persistent, Linux/bash-completion):
374    mkdir -p ~/.local/share/bash-completion/completions
375    codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
376    # Requires bash-completion to be installed and loaded by your shell.
377
378  Zsh:
379    mkdir -p ~/.zfunc
380    codewhale completion zsh > ~/.zfunc/_codewhale
381    # Add to ~/.zshrc if needed:
382    #   fpath=(~/.zfunc $fpath)
383    #   autoload -Uz compinit && compinit
384
385  Fish:
386    mkdir -p ~/.config/fish/completions
387    codewhale completion fish > ~/.config/fish/completions/codewhale.fish
388
389  PowerShell (current shell only):
390    codewhale completion powershell | Out-String | Invoke-Expression
391
392  PowerShell (persistent):
393    New-Item -ItemType Directory -Force -Path (Split-Path -Parent $PROFILE)
394    codewhale completion powershell >> $PROFILE
395
396  Elvish:
397    codewhale completion elvish >> ~/.config/elvish/rc.elv
398
399The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#
400    )]
401    Completion {
402        #[arg(value_enum)]
403        shell: Shell,
404    },
405    /// Print a usage rollup from the audit log and session store.
406    Metrics(MetricsArgs),
407    /// Update this release binary from GitHub (package-managed installs get migration instructions).
408    #[command(
409        after_help = "GitHub Releases is the default source. Supported mirrors are explicit overrides or manifest-failure fallbacks. Checksums are required; older releases never replace a newer build.\n\nThe command prints the executable it will update. If you have multiple installs, run the intended binary by its full path.\n\nNew macOS/Linux install: curl -fsSL https://codewhale.net/install.sh | sh\nInstallation and PATH help: https://github.com/Hmbown/CodeWhale/blob/main/docs/INSTALL.md"
410    )]
411    Update(UpdateArgs),
412    /// Export the route catalog (`providers export --json`).
413    Providers(ProvidersArgs),
414}
415
416#[derive(Debug, Args)]
417struct ProvidersArgs {
418    #[command(subcommand)]
419    command: ProvidersCommand,
420}
421
422#[derive(Debug, Subcommand)]
423enum ProvidersCommand {
424    /// Write the owned route catalog as JSON (cwc contract).
425    Export {
426        /// Required. The export is the generated cwc catalog source of truth.
427        #[arg(long)]
428        json: bool,
429    },
430}
431
432/// The name of this crate's `[[bin]]` target, and the command users actually
433/// type. Completion scripts must register *this*, not the in-tree
434/// `codewhale-tui` binary that used to render them (#5526).
435///
436/// GitHub releases do not ship a separately compiled TUI: `release-artifacts.yml`
437/// builds `-p codewhale-cli` and publishes `codewhale` plus a byte-identical
438/// `codew` copy. The `codewhale-tui-*` filenames still attached to the release
439/// are that same binary (a v0.9.4 updater bridge), not a third runtime.
440const COMPLETION_BIN_NAME: &str = "codewhale";
441
442/// Releases publish `codew` as a byte-identical copy of `codewhale`
443/// (`release-artifacts.yml` copies the binary and `cmp`s it), so a completion
444/// script that fires only for `codewhale` is half-installed for anyone who
445/// types the short name.
446const COMPLETION_ALIAS_NAME: &str = "codew";
447
448/// Render the completion script for `shell` from this binary's own clap tree,
449/// registered for both published command names.
450fn render_completion_script(shell: Shell) -> String {
451    let mut cmd = Cli::command();
452    let mut buf = Vec::new();
453    generate(shell, &mut cmd, COMPLETION_BIN_NAME, &mut buf);
454    let script = String::from_utf8_lossy(&buf).into_owned();
455    register_completion_alias(shell, script)
456}
457
458/// Extend a clap_complete script so the `codew` shorthand completes too.
459///
460/// Each shell gets its own idiomatic hook rather than a second copy of the
461/// script: bash re-binds the generated function, zsh widens the `#compdef`
462/// tag line, fish wraps the primary command, PowerShell registers an array
463/// of command names, and Elvish aliases the completer map entry. `Shell` is
464/// non-exhaustive, so any future variant falls through unchanged.
465fn register_completion_alias(shell: Shell, script: String) -> String {
466    let bin = COMPLETION_BIN_NAME;
467    let alias = COMPLETION_ALIAS_NAME;
468    match shell {
469        Shell::Bash => format!(
470            "{script}\n\
471             if [[ \"${{BASH_VERSINFO[0]}}\" -eq 4 && \"${{BASH_VERSINFO[1]}}\" -ge 4 || \"${{BASH_VERSINFO[0]}}\" -gt 4 ]]; then\n    \
472             complete -F _{bin} -o nosort -o bashdefault -o default {alias}\n\
473             else\n    \
474             complete -F _{bin} -o bashdefault -o default {alias}\n\
475             fi\n"
476        ),
477        // Two install paths, two hooks. Autoloaded from `fpath` the tag line
478        // on the first line is what binds the names; sourced directly, the
479        // `compdef` call clap emits at the bottom is. Cover both, and reuse
480        // clap's own `funcstack` guard so the appended call is skipped when
481        // the body runs as the completion function itself.
482        Shell::Zsh => {
483            let tagged = match script.strip_prefix(&format!("#compdef {bin}\n")) {
484                Some(rest) => format!("#compdef {bin} {alias}\n{rest}"),
485                None => script,
486            };
487            format!(
488                "{tagged}\nif [ \"$funcstack[1]\" != \"_{bin}\" ]; then\n    \
489                 compdef _{bin} {alias}\n\
490                 fi\n"
491            )
492        }
493        Shell::Fish => format!("{script}\ncomplete -c {alias} -w {bin}\n"),
494        Shell::PowerShell => script.replacen(
495            &format!("-CommandName '{bin}'"),
496            &format!("-CommandName '{bin}','{alias}'"),
497            1,
498        ),
499        Shell::Elvish => format!(
500            "{script}\n\
501             set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]\n"
502        ),
503        _ => script,
504    }
505}
506
507fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
508    matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
509}
510
511fn top_level_provider_override(
512    provider: Option<&str>,
513    command: Option<&Commands>,
514) -> Result<Option<ProviderKind>> {
515    let Some(provider) = provider else {
516        return Ok(None);
517    };
518    if is_antigravity_legacy_selector(provider) {
519        bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE);
520    }
521    if let Some(provider) = builtin_provider_arg(provider) {
522        return Ok(Some(provider));
523    }
524    if command_accepts_raw_provider(command) {
525        return Ok(None);
526    }
527
528    let expected = ProviderKind::names_hint();
529    bail!(
530        "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
531    )
532}
533
534fn prepare_raw_provider_tui_dispatch(
535    cli: &Cli,
536    command: Option<&Commands>,
537    runtime_overrides: &CliRuntimeOverrides,
538) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
539    let Some(provider) = cli.provider.as_deref() else {
540        return Ok(None);
541    };
542    if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
543        return Ok(None);
544    }
545
546    let passthrough = match command {
547        Some(Commands::Exec(args)) => {
548            reject_exec_global_flags(&args.args)?;
549            tui_args("exec", args.clone())
550        }
551        Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
552        _ => unreachable!("raw provider validation only permits Exec and Fleet"),
553    };
554
555    // Dynamic provider config belongs to the TUI schema. Do not parse it
556    // through the dispatcher's enum-backed ConfigStore or recover credentials
557    // for an unrelated fallback provider before the TUI sees the raw id.
558    let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
559    Ok(Some((resolved_runtime, passthrough)))
560}
561
562#[derive(Debug, Args)]
563struct UpdateArgs {
564    /// Update to the latest beta release instead of the latest stable release.
565    #[arg(long)]
566    beta: bool,
567    /// Only check the latest release; do not download or replace binaries.
568    #[arg(long)]
569    check: bool,
570    /// Proxy URL to use for update HTTP requests.
571    #[arg(long, value_name = "URL")]
572    proxy: Option<String>,
573}
574
575#[derive(Debug, Args)]
576struct MetricsArgs {
577    /// Emit machine-readable JSON.
578    #[arg(long)]
579    json: bool,
580    /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
581    #[arg(long, value_name = "DURATION")]
582    since: Option<String>,
583}
584
585#[derive(Debug, Args)]
586struct RunArgs {
587    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
588    args: Vec<String>,
589}
590
591#[derive(Debug, Args, Clone)]
592struct TuiPassthroughArgs {
593    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
594    args: Vec<String>,
595}
596
597#[derive(Debug, Args)]
598struct WebArgs {
599    /// Loopback port for the local Runtime API and embedded client.
600    #[arg(long, default_value_t = 7878)]
601    port: u16,
602}
603
604#[derive(Debug, Args)]
605struct LaneLogProxyArgs {
606    #[arg(long, value_name = "PATH")]
607    log_path: PathBuf,
608    #[arg(long, value_name = "PATH")]
609    receipt_path: PathBuf,
610    #[arg(long, value_name = "PATH")]
611    receipt_tmp_path: PathBuf,
612    #[arg(long, value_name = "PATH")]
613    environment_path: Option<PathBuf>,
614    #[arg(long)]
615    lane_id: String,
616    #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
617    command: Vec<String>,
618}
619
620/// `codewhale lane …` — running workflow instances (#4176).
621#[derive(Debug, Args)]
622struct LaneArgs {
623    #[command(subcommand)]
624    command: LaneCommand,
625}
626
627#[derive(Debug, Subcommand)]
628// Clap constructs this command enum once at process startup. Keeping the
629// fields inline makes the generated CLI shape explicit; boxing them only to
630// reduce this transient value would add indirection without runtime benefit.
631#[allow(clippy::large_enum_variant)]
632enum LaneCommand {
633    /// List known lanes (newest first).
634    List {
635        /// Emit JSON.
636        #[arg(long, default_value_t = false)]
637        json: bool,
638    },
639    /// Show one lane's status and attach metadata.
640    Status {
641        /// Lane id (e.g. `lane-a1b2c3d4`).
642        lane_id: String,
643        #[arg(long, default_value_t = false)]
644        json: bool,
645    },
646    /// Attach to a tmux-backed lane (prints attach command; execs when possible).
647    Attach {
648        lane_id: String,
649        /// Only print the attach command; do not exec.
650        #[arg(long, default_value_t = false)]
651        print: bool,
652    },
653    /// Tail the lane stream-json / NDJSON journal.
654    Logs {
655        lane_id: String,
656        /// Follow the log file (like `tail -f`).
657        #[arg(long, short = 'f', default_value_t = false)]
658        follow: bool,
659        /// Number of trailing lines when not following (default 50).
660        #[arg(long, default_value_t = 50)]
661        tail: usize,
662    },
663    /// Stop a running lane and run worktree TTL cleanup.
664    ///
665    /// Compatibility spelling for `lane interrupt`; both resolve to the
666    /// `lane.interrupt` control-plane verb (#1888).
667    Stop { lane_id: String },
668    /// Interrupt a running lane (durable `lane.interrupt`).
669    ///
670    /// Accepts an exact lane id, optionally fenced as `<lane-id>@<seq>` so the
671    /// stop only applies to the lifecycle generation you observed.
672    Interrupt {
673        lane_id: String,
674        #[arg(long, default_value_t = false)]
675        json: bool,
676    },
677    /// Restart a lane in place (declared, no backend — reports why).
678    Restart {
679        lane_id: String,
680        #[arg(long, default_value_t = false)]
681        json: bool,
682    },
683    /// Resume a stopped lane (declared, no backend — reports why).
684    Resume {
685        lane_id: String,
686        #[arg(long, default_value_t = false)]
687        json: bool,
688    },
689    /// Start a lane under a Runtime backend (tmux|inline|vm|ci).
690    Start {
691        /// Workflow name (e.g. `stopship`).
692        #[arg(long)]
693        workflow: Option<String>,
694        /// Fleet roster name (e.g. `stopship`); the flag keeps its compatibility spelling.
695        #[arg(long)]
696        fleet: Option<String>,
697        /// Issue id binding.
698        #[arg(long)]
699        issue: Option<String>,
700        /// Free-form goal text.
701        #[arg(long)]
702        goal: Option<String>,
703        /// Runtime backend: tmux, inline, vm, or ci.
704        #[arg(long, default_value = "tmux")]
705        runtime: String,
706        /// Create an isolated worktree under this repo root.
707        #[arg(long, value_name = "DIR")]
708        worktree_repo: Option<PathBuf>,
709        /// Branch name for the worktree (requires `--worktree-repo`).
710        #[arg(long)]
711        branch: Option<String>,
712        /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
713        #[arg(long, value_name = "DIR")]
714        worktree_path: Option<PathBuf>,
715        /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
716        #[arg(long)]
717        worktree_ttl_secs: Option<u64>,
718        /// Command to run in the runtime (after `--`).
719        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
720        command: Vec<String>,
721    },
722}
723
724/// `codewhale workflow …` — Workflow entrypoints backed by Lanes (#4177/#4178).
725#[derive(Debug, Args)]
726struct WorkflowArgs {
727    #[command(subcommand)]
728    command: WorkflowCommand,
729}
730
731#[derive(Debug, Subcommand)]
732enum WorkflowCommand {
733    /// Run a checked-in Workflow through a Runtime-backed Lane.
734    Run {
735        /// Workflow name or path. `stopship` maps to workflows/stopship.workflow.js.
736        workflow: String,
737        /// Named Fleet roster (e.g. stopship). The flag keeps its compatibility
738        /// spelling. Without one, roles resolve against the built-in roster
739        /// and the session route.
740        #[arg(long)]
741        fleet: Option<String>,
742        /// Issue id binding recorded on the Lane and passed into workflow args.
743        #[arg(long)]
744        issue: Option<String>,
745        /// Free-form goal text recorded on the Lane and passed into workflow args.
746        #[arg(long)]
747        goal: Option<String>,
748        /// Runtime backend: tmux, inline, vm, or ci.
749        #[arg(long, default_value = "tmux")]
750        runtime: String,
751        /// Explicit Workflow source path, overriding name-based resolution.
752        #[arg(long, value_name = "PATH")]
753        source_path: Option<PathBuf>,
754        /// Optional shared Workflow token budget.
755        #[arg(long)]
756        token_budget: Option<u64>,
757        /// Run verifier gates after a successful Workflow completion.
758        #[arg(long, default_value_t = false)]
759        verify: bool,
760        /// Create an isolated worktree under this repo root.
761        #[arg(long, value_name = "DIR")]
762        worktree_repo: Option<PathBuf>,
763        /// Branch name for the worktree (requires `--worktree-repo`).
764        #[arg(long)]
765        branch: Option<String>,
766        /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
767        #[arg(long, value_name = "DIR")]
768        worktree_path: Option<PathBuf>,
769        /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
770        #[arg(long)]
771        worktree_ttl_secs: Option<u64>,
772    },
773}
774
775struct LaneStartRequest {
776    workflow: Option<String>,
777    fleet: Option<String>,
778    issue: Option<String>,
779    goal: Option<String>,
780    runtime: String,
781    worktree_repo: Option<PathBuf>,
782    branch: Option<String>,
783    worktree_path: Option<PathBuf>,
784    worktree_ttl_secs: Option<u64>,
785    command: Vec<String>,
786    environment: Vec<(String, String)>,
787    cwd: Option<PathBuf>,
788}
789
790fn start_lane(request: LaneStartRequest) -> Result<()> {
791    use codewhale_lane::{
792        LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
793    };
794
795    let LaneStartRequest {
796        workflow,
797        fleet,
798        issue,
799        goal,
800        runtime,
801        worktree_repo,
802        branch,
803        worktree_path,
804        worktree_ttl_secs,
805        command,
806        environment,
807        cwd,
808    } = request;
809    let kind = RuntimeBackendKind::parse(&runtime)?;
810    let reg = LaneRegistry::open_default()?;
811    let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
812    let worktree = match (worktree_repo, branch) {
813        (Some(repo_root), Some(branch_name)) => {
814            let path = worktree_path
815                .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
816            Some(WorktreeProvision {
817                repo_root,
818                branch: branch_name,
819                path,
820                base_ref: None,
821            })
822        }
823        (None, None) => None,
824        _ => bail!("--worktree-repo and --branch must be provided together"),
825    };
826    let cmd = if command.is_empty() {
827        vec![
828            "sh".into(),
829            "-c".into(),
830            format!("echo lane {} started", record.id),
831        ]
832    } else {
833        command
834    };
835    let spec = LaneStartSpec {
836        command: cmd,
837        cwd,
838        environment,
839        log_proxy: (kind == RuntimeBackendKind::Tmux)
840            .then(std::env::current_exe)
841            .transpose()
842            .context("resolve current Codewhale executable for tmux log proxy")?,
843        worktree,
844    };
845    let backend = resolve_backend(kind);
846    backend.start(&reg, &mut record, &spec)?;
847    println!("started {}", record.id);
848    println!("status:  {}", record.status.as_str());
849    println!("runtime: {}", record.runtime.as_str());
850    println!("log:     {}", record.log_path.display());
851    if let Some(attach) = backend.attach_command(&record) {
852        println!("attach:  {attach}");
853    }
854    Ok(())
855}
856
857/// Print one shared control receipt on the CLI surface.
858///
859/// The CLI does not format Lane control results itself: it renders the same
860/// [`codewhale_lane::ControlReceipt`] the slash command and hotbar render, so
861/// the three surfaces cannot drift in what they report (#1888).
862fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
863    if json {
864        // v0.9.2 compatibility: `lane list --json` has always emitted an array
865        // of `LaneRecord`, and `lane status --json` a single one. Scripts
866        // select `.[].id`, `.worktree_path`, `.log_path` off that shape, so the
867        // receipt does not replace it. The receipt is what every other verb
868        // emits, and what the human renderer shows for these two.
869        match receipt.operation {
870            codewhale_lane::ControlOperation::LaneList => {
871                println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
872            }
873            codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
874                Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
875                // Legacy behaviour for an unknown id: `reg.load()` failed, so
876                // the command errored on stderr and printed *nothing* on
877                // stdout. Emitting a receipt (or a bare `null`) here would make
878                // `lane status --json <bad-id> | jq` succeed where it used to
879                // fail. Stay silent and let the bail! below set the exit code.
880                None if receipt.is_error() => {}
881                None => println!("{}", serde_json::to_string_pretty(receipt)?),
882            },
883            _ => println!("{}", serde_json::to_string_pretty(receipt)?),
884        }
885    } else if receipt.is_error() {
886        eprintln!("{}", receipt.render());
887    } else {
888        println!("{}", receipt.render());
889    }
890    if receipt.is_error() {
891        let detail = receipt
892            .failure
893            .as_ref()
894            .map(ToString::to_string)
895            .unwrap_or_else(|| receipt.outcome.as_str().to_string());
896        bail!("{}: {detail}", receipt.operation_id);
897    }
898    Ok(())
899}
900
901fn run_lane_control(
902    operation: codewhale_lane::ControlOperation,
903    lane_id: Option<&str>,
904    json: bool,
905) -> Result<()> {
906    let receipt = codewhale_lane::control::execute_lane_control(
907        codewhale_lane::ControlSurface::Cli,
908        operation,
909        lane_id,
910    );
911    emit_control_receipt(&receipt, json)
912}
913
914fn run_lane_command(args: LaneArgs) -> Result<()> {
915    use codewhale_lane::{ControlOperation, LaneRegistry, backend_for};
916    use std::io::{BufRead, Seek, Write};
917    use std::process::Command;
918    use std::thread;
919    use std::time::Duration;
920
921    match args.command {
922        LaneCommand::List { json } => run_lane_control(ControlOperation::LaneList, None, json),
923        LaneCommand::Status { lane_id, json } => {
924            run_lane_control(ControlOperation::LaneStatus, Some(&lane_id), json)
925        }
926        LaneCommand::Interrupt { lane_id, json } => {
927            run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
928        }
929        LaneCommand::Restart { lane_id, json } => {
930            run_lane_control(ControlOperation::LaneRestart, Some(&lane_id), json)
931        }
932        LaneCommand::Resume { lane_id, json } => {
933            run_lane_control(ControlOperation::LaneResume, Some(&lane_id), json)
934        }
935        LaneCommand::Attach { lane_id, print } => {
936            let reg = LaneRegistry::open_default()?;
937            let mut lane = reg.load(&lane_id)?;
938            let backend = backend_for(&lane);
939            backend.reconcile(&reg, &mut lane)?;
940            let Some(attach) = backend.attach_command(&lane) else {
941                if !lane.status.is_active() {
942                    bail!(
943                        "lane `{lane_id}` is {} and has no active attach target",
944                        lane.status.as_str()
945                    );
946                }
947                bail!(
948                    "lane `{lane_id}` runtime `{}` has no attach target",
949                    lane.runtime.as_str()
950                );
951            };
952            if print {
953                println!("{attach}");
954                return Ok(());
955            }
956            if let Some(session) = lane.tmux_session.as_deref() {
957                let socket = lane
958                    .tmux_socket
959                    .as_deref()
960                    .context("tmux lane is missing its pinned server socket")?;
961                let status = Command::new("tmux")
962                    .arg("-S")
963                    .arg(socket)
964                    .args(["attach", "-t", session])
965                    .status();
966                match status {
967                    Ok(s) if s.success() => Ok(()),
968                    Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
969                    Err(err) => {
970                        eprintln!("could not exec tmux: {err}");
971                        println!("{attach}");
972                        bail!("tmux attach unavailable");
973                    }
974                }
975            } else {
976                println!("{attach}");
977                Ok(())
978            }
979        }
980        LaneCommand::Logs {
981            lane_id,
982            follow,
983            tail,
984        } => {
985            let reg = LaneRegistry::open_default()?;
986            let lane = reg.load(&lane_id)?;
987            let path = lane.log_path;
988            if !path.exists() {
989                bail!("log file missing: {}", path.display());
990            }
991            let content = std::fs::read(&path)?;
992            let lines: Vec<&[u8]> = content
993                .split(|byte| *byte == b'\n')
994                .filter(|line| !line.is_empty())
995                .collect();
996            let start = lines.len().saturating_sub(tail);
997            let mut stdout = std::io::stdout().lock();
998            for line in &lines[start..] {
999                stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
1000                stdout.write_all(b"\n")?;
1001            }
1002            stdout.flush()?;
1003            if !follow {
1004                return Ok(());
1005            }
1006            let mut file = std::fs::File::open(&path)?;
1007            file.seek(std::io::SeekFrom::End(0))?;
1008            let mut reader = std::io::BufReader::new(file);
1009            loop {
1010                let mut line = Vec::new();
1011                match reader.read_until(b'\n', &mut line) {
1012                    Ok(0) => {
1013                        thread::sleep(Duration::from_millis(200));
1014                        continue;
1015                    }
1016                    Ok(_) => {
1017                        let mut stdout = std::io::stdout().lock();
1018                        stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
1019                        stdout.flush()?;
1020                    }
1021                    Err(err) => return Err(err.into()),
1022                }
1023            }
1024        }
1025        // `stop` is the historical spelling of `interrupt`. Both go through
1026        // the same verb so the durable transition, the lifecycle fence, and
1027        // the receipt are identical.
1028        LaneCommand::Stop { lane_id } => {
1029            run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), false)
1030        }
1031        LaneCommand::Start {
1032            workflow,
1033            fleet,
1034            issue,
1035            goal,
1036            runtime,
1037            worktree_repo,
1038            branch,
1039            worktree_path,
1040            worktree_ttl_secs,
1041            command,
1042        } => start_lane(LaneStartRequest {
1043            workflow,
1044            fleet,
1045            issue,
1046            goal,
1047            runtime,
1048            worktree_repo,
1049            branch,
1050            worktree_path,
1051            worktree_ttl_secs,
1052            command,
1053            environment: Vec::new(),
1054            cwd: None,
1055        }),
1056    }
1057}
1058
1059fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
1060    let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
1061        command: args.command,
1062        log_path: args.log_path,
1063        receipt_path: args.receipt_path,
1064        receipt_tmp_path: args.receipt_tmp_path,
1065        environment_path: args.environment_path,
1066        lane_id: args.lane_id,
1067    })?;
1068    std::process::exit(exit_code);
1069}
1070
1071fn run_workflow_command(
1072    cli: &Cli,
1073    resolved_runtime: &ResolvedRuntimeOptions,
1074    config_path: &Path,
1075    args: WorkflowArgs,
1076) -> Result<()> {
1077    match args.command {
1078        WorkflowCommand::Run {
1079            workflow,
1080            fleet,
1081            issue,
1082            goal,
1083            runtime,
1084            source_path,
1085            token_budget,
1086            verify,
1087            worktree_repo,
1088            branch,
1089            worktree_path,
1090            worktree_ttl_secs,
1091        } => {
1092            let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
1093            let source_path =
1094                resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
1095            validate_workflow_source_file(&source_path)?;
1096
1097            let source_root = if let Some(repo) = worktree_repo.as_deref() {
1098                repo.canonicalize()
1099                    .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
1100            } else {
1101                workspace.clone()
1102            };
1103
1104            // A fleet is an optional pin layer, not a requirement: role-only
1105            // tasks resolve against the built-in roster and the session route
1106            // (matching the TUI tool path). When a fleet IS given, it is
1107            // loaded and validated before the run starts.
1108            if let Some(name) = fleet.as_deref() {
1109                let roots = named_fleet_search_roots(&workspace);
1110                let loaded =
1111                    codewhale_workflow::load_named_fleet(name, &roots).with_context(|| {
1112                        format!("load Fleet `{name}` from {}", display_roots(&roots))
1113                    })?;
1114                if workflow == "stopship" || name == "stopship" {
1115                    loaded
1116                        .validate_stopship_roles()
1117                        .with_context(|| format!("validate stopship roles in Fleet `{name}`"))?;
1118                }
1119            }
1120
1121            let process = workflow_exec_command(WorkflowExecSpec {
1122                cli,
1123                resolved_runtime,
1124                config_path,
1125                source_root: &source_root,
1126                source_path: &source_path,
1127                workflow: &workflow,
1128                fleet: fleet.as_deref(),
1129                issue: issue.as_deref(),
1130                goal: goal.as_deref(),
1131                token_budget,
1132                verify,
1133            })?;
1134            start_lane(LaneStartRequest {
1135                workflow: Some(workflow),
1136                fleet,
1137                issue,
1138                goal,
1139                runtime,
1140                worktree_repo,
1141                branch,
1142                worktree_path,
1143                worktree_ttl_secs,
1144                command: process.command,
1145                environment: process.environment,
1146                cwd: Some(workspace),
1147            })
1148        }
1149    }
1150}
1151
1152fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
1153    if let Some(path) = explicit {
1154        return path
1155            .canonicalize()
1156            .with_context(|| format!("resolve workflow workspace {}", path.display()));
1157    }
1158    let cwd = std::env::current_dir().context("resolve current directory")?;
1159    let output = Command::new("git")
1160        .args(["rev-parse", "--show-toplevel"])
1161        .current_dir(&cwd)
1162        .output();
1163    if let Ok(output) = output
1164        && output.status.success()
1165    {
1166        let text = String::from_utf8_lossy(&output.stdout);
1167        let root = text.trim();
1168        if !root.is_empty() {
1169            let root = PathBuf::from(root);
1170            return Ok(root.canonicalize().unwrap_or(root));
1171        }
1172    }
1173    Ok(cwd)
1174}
1175
1176fn resolve_workflow_source_path(
1177    workflow: &str,
1178    source_path: Option<&PathBuf>,
1179    workspace: &Path,
1180) -> Result<PathBuf> {
1181    let candidates = workflow_source_candidates(workflow, source_path, workspace);
1182    for candidate in &candidates {
1183        if candidate.is_file() {
1184            return Ok(candidate.clone());
1185        }
1186    }
1187    bail!(
1188        "workflow source for `{workflow}` not found; tried {}",
1189        candidates
1190            .iter()
1191            .map(|p| p.display().to_string())
1192            .collect::<Vec<_>>()
1193            .join(", ")
1194    )
1195}
1196
1197fn workflow_source_candidates(
1198    workflow: &str,
1199    source_path: Option<&PathBuf>,
1200    workspace: &Path,
1201) -> Vec<PathBuf> {
1202    let mut candidates = Vec::new();
1203    if let Some(path) = source_path {
1204        candidates.push(resolve_against_workspace(path, workspace));
1205        return candidates;
1206    }
1207
1208    let raw = workflow.trim();
1209    let workflow_path = PathBuf::from(raw);
1210    if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1211        candidates.push(resolve_against_workspace(&workflow_path, workspace));
1212        return candidates;
1213    }
1214
1215    let normalized = raw.replace('-', "_");
1216    for rel in [
1217        format!("workflows/{raw}.workflow.js"),
1218        format!("workflows/{normalized}.workflow.js"),
1219    ] {
1220        let path = workspace.join(rel);
1221        if !candidates.iter().any(|existing| existing == &path) {
1222            candidates.push(path);
1223        }
1224    }
1225    candidates
1226}
1227
1228fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1229    if path.is_absolute() {
1230        path.to_path_buf()
1231    } else {
1232        workspace.join(path)
1233    }
1234}
1235
1236fn validate_workflow_source_file(path: &Path) -> Result<()> {
1237    let source =
1238        std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1239    if source.trim_start().starts_with("export default workflow(")
1240        || source.trim_start().starts_with("workflow(")
1241        || source.contains("\nworkflow(")
1242    {
1243        let identifier = path.display().to_string();
1244        if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1245            codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1246                .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1247        } else {
1248            codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1249                .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1250        }
1251    }
1252    Ok(())
1253}
1254
1255fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1256    let mut roots = Vec::new();
1257    if let Ok(home) = codewhale_config::codewhale_home() {
1258        roots.push(home);
1259    }
1260    roots.push(workspace.to_path_buf());
1261    roots
1262}
1263
1264fn display_roots(roots: &[PathBuf]) -> String {
1265    roots
1266        .iter()
1267        .map(|root| root.display().to_string())
1268        .collect::<Vec<_>>()
1269        .join(", ")
1270}
1271
1272struct WorkflowExecSpec<'a> {
1273    cli: &'a Cli,
1274    resolved_runtime: &'a ResolvedRuntimeOptions,
1275    config_path: &'a Path,
1276    source_root: &'a Path,
1277    source_path: &'a Path,
1278    workflow: &'a str,
1279    fleet: Option<&'a str>,
1280    issue: Option<&'a str>,
1281    goal: Option<&'a str>,
1282    token_budget: Option<u64>,
1283    verify: bool,
1284}
1285
1286struct WorkflowProcessSpec {
1287    command: Vec<String>,
1288    environment: Vec<(String, String)>,
1289}
1290
1291fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1292    let WorkflowExecSpec {
1293        cli,
1294        resolved_runtime,
1295        config_path,
1296        source_root,
1297        source_path,
1298        workflow,
1299        fleet,
1300        issue,
1301        goal,
1302        token_budget,
1303        verify,
1304    } = spec;
1305    let source_arg = source_path
1306        .strip_prefix(source_root)
1307        .with_context(|| {
1308            format!(
1309                "workflow source {} must be inside execution root {}",
1310                source_path.display(),
1311                source_root.display()
1312            )
1313        })?
1314        .display()
1315        .to_string();
1316    let mut payload = serde_json::json!({
1317        "action": "run",
1318        "source_path": source_arg,
1319        "fleet": fleet,
1320        "args": {
1321            "workflow": workflow,
1322            "fleet": fleet,
1323            "issue": issue,
1324            "goal": goal,
1325        },
1326        "verify": verify,
1327    });
1328    if let Some(token_budget) = token_budget {
1329        payload["token_budget"] = serde_json::json!(token_budget);
1330    }
1331    let input_json = serde_json::to_string(&payload)?;
1332    let passthrough = vec![
1333        "workflow-tool".to_string(),
1334        "--approval-source".to_string(),
1335        "explicit-workflow-command".to_string(),
1336        "--input-json".to_string(),
1337        input_json,
1338    ];
1339    let argv = {
1340        // Build argv with explicit config path like the previous dispatcher did.
1341        let mut args = Vec::new();
1342        let executable = std::env::current_exe()
1343            .context("resolve current Codewhale executable for workflow lane")?;
1344        let executable = executable.into_os_string().into_string().map_err(|path| {
1345            anyhow!(
1346                "current Codewhale executable path is not valid UTF-8: {}",
1347                PathBuf::from(path).display()
1348            )
1349        })?;
1350        args.push(executable);
1351        // config_path is the explicit workflow config path; prefer it over cli.config
1352        let cfg = Some(config_path);
1353        if let Some(cp) = cfg {
1354            args.push("--config".to_string());
1355            args.push(cp.display().to_string());
1356        } else if let Some(cp) = cli.config.as_deref() {
1357            args.push("--config".to_string());
1358            args.push(cp.display().to_string());
1359        }
1360        if let Some(profile) = cli.profile.as_ref() {
1361            args.push("--profile".to_string());
1362            args.push(profile.clone());
1363        }
1364
1365        if cli.mouse_capture {
1366            args.push("--mouse-capture".to_string());
1367        }
1368        if cli.no_mouse_capture {
1369            args.push("--no-mouse-capture".to_string());
1370        }
1371        if cli.skip_onboarding {
1372            args.push("--skip-onboarding".to_string());
1373        }
1374        if cli.no_project_config {
1375            args.push("--no-project-config".to_string());
1376        }
1377        args.extend(passthrough.clone());
1378        args
1379    };
1380    apply_tui_env(cli, resolved_runtime, &passthrough);
1381    lane_process_spec_from_argv(&argv)
1382}
1383
1384fn valid_lane_environment_key(key: &str) -> bool {
1385    let mut chars = key.chars();
1386    chars
1387        .next()
1388        .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1389        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1390}
1391
1392fn shell_owned_lane_environment(key: &str) -> bool {
1393    matches!(
1394        key,
1395        "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1396    )
1397}
1398
1399fn lane_process_spec_from_argv(argv: &[String]) -> Result<WorkflowProcessSpec> {
1400    let mut environment = std::collections::BTreeMap::new();
1401    for (key, value) in std::env::vars_os() {
1402        let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1403            continue;
1404        };
1405        if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1406            environment.insert(key.to_string(), value.to_string());
1407        }
1408    }
1409    Ok(WorkflowProcessSpec {
1410        command: argv.to_vec(),
1411        environment: environment.into_iter().collect(),
1412    })
1413}
1414
1415/// Flags for `codewhale remote-setup`. Forwarded to the TUI binary, which owns
1416/// the interactive wizard and bundle generation.
1417#[derive(Debug, Args, Clone, Default)]
1418struct RemoteSetupArgs {
1419    /// Cloud target slug (lighthouse, azure, digitalocean). Skips the prompt.
1420    #[arg(long)]
1421    cloud: Option<String>,
1422    /// Chat bridge slug (feishu, telegram). Skips the prompt.
1423    #[arg(long)]
1424    bridge: Option<String>,
1425    /// Provider slug; validated against the provider registry. Skips the prompt.
1426    #[arg(long)]
1427    provider: Option<String>,
1428    /// Bundle output directory (default `./codewhale-deploy/<cloud>-<bridge>`).
1429    #[arg(long, value_name = "DIR")]
1430    out: Option<PathBuf>,
1431    /// Emit the bundle, do not provision (default).
1432    #[arg(long, default_value_t = false)]
1433    generate_only: bool,
1434    /// Run the cloud CLI to auto-provision (not yet implemented).
1435    #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
1436    apply: bool,
1437    /// Skip the final confirmation gate (CI / non-interactive).
1438    #[arg(long, default_value_t = false)]
1439    yes: bool,
1440    /// Fail instead of prompting if any required value is missing.
1441    #[arg(long, default_value_t = false)]
1442    non_interactive: bool,
1443}
1444
1445/// Build the forwarded argv for the TUI `remote-setup` subcommand from the
1446/// structured CLI flags. Mirrors the named flags exactly so the TUI clap parser
1447/// re-derives the same `RemoteSetupArgs`.
1448fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1449    let mut forwarded = vec!["remote-setup".to_string()];
1450    if let Some(cloud) = args.cloud {
1451        forwarded.push("--cloud".to_string());
1452        forwarded.push(cloud);
1453    }
1454    if let Some(bridge) = args.bridge {
1455        forwarded.push("--bridge".to_string());
1456        forwarded.push(bridge);
1457    }
1458    if let Some(provider) = args.provider {
1459        forwarded.push("--provider".to_string());
1460        forwarded.push(provider);
1461    }
1462    if let Some(out) = args.out {
1463        forwarded.push("--out".to_string());
1464        forwarded.push(out.to_string_lossy().into_owned());
1465    }
1466    if args.generate_only {
1467        forwarded.push("--generate-only".to_string());
1468    }
1469    if args.apply {
1470        forwarded.push("--apply".to_string());
1471    }
1472    if args.yes {
1473        forwarded.push("--yes".to_string());
1474    }
1475    if args.non_interactive {
1476        forwarded.push("--non-interactive".to_string());
1477    }
1478    forwarded
1479}
1480
1481#[derive(Debug, Args)]
1482struct LoginArgs {
1483    /// Print the verification URL without trying to open a browser.
1484    #[arg(long, default_value_t = false)]
1485    no_open: bool,
1486    /// Maximum time to wait for browser authorization.
1487    #[arg(
1488        long = "timeout-seconds",
1489        default_value_t = cloud::DEFAULT_LOGIN_TIMEOUT_SECONDS,
1490        value_parser = clap::value_parser!(u64).range(1..=cloud::MAX_LOGIN_TIMEOUT_SECONDS)
1491    )]
1492    timeout_seconds: u64,
1493    /// Legacy provider-key flag: rejected with a redirect to `auth set`.
1494    #[arg(long, hide = true)]
1495    api_key: Option<String>,
1496    /// Legacy provider flag: rejected with a redirect to `auth set`.
1497    #[arg(long, value_parser = parse_catalog_route, hide = true)]
1498    provider: Option<ProviderKind>,
1499}
1500
1501#[derive(Debug, Args)]
1502struct AuthArgs {
1503    #[command(subcommand)]
1504    command: AuthCommand,
1505}
1506
1507#[derive(Debug, Subcommand)]
1508enum AuthCommand {
1509    /// Sign in to xAI/Grok with an SSH-friendly device code.
1510    #[command(name = "xai-device")]
1511    XaiDevice,
1512    /// Sign in with ChatGPT for Codex subscription access (PKCE loopback).
1513    #[command(name = "chatgpt")]
1514    Chatgpt,
1515    /// Revoke Codewhale-owned ChatGPT tokens. Codex CLI consent is unchanged.
1516    #[command(name = "chatgpt-revoke")]
1517    ChatgptRevoke,
1518    /// Explicitly allow read-only access to one credential file owned by
1519    /// another CLI. Managed mutation is currently unsupported and fails closed.
1520    #[command(name = "external-consent")]
1521    ExternalConsent {
1522        #[arg(long, value_parser = parse_catalog_route)]
1523        provider: ProviderKind,
1524        #[arg(long, value_enum)]
1525        mode: ExternalCredentialModeArg,
1526        /// Exact credential file path. Defaults to the selected CLI's resolved
1527        /// path without probing whether the file exists.
1528        #[arg(long, value_name = "PATH")]
1529        path: Option<PathBuf>,
1530        /// Confirm the disclosed exact read-only grant without an interactive
1531        /// prompt. Required when stdin is not a terminal.
1532        #[arg(long, default_value_t = false)]
1533        yes: bool,
1534    },
1535    /// Revoke access to another CLI's credential file for one provider.
1536    #[command(name = "external-revoke")]
1537    ExternalRevoke {
1538        #[arg(long, value_parser = parse_catalog_route)]
1539        provider: ProviderKind,
1540    },
1541    /// Show current provider and runtime-effective credential route state.
1542    /// Without `--provider`, shows all known providers.
1543    /// With `--provider`, shows detailed status for that provider.
1544    Status {
1545        /// Show status for a specific provider only.
1546        #[arg(long, value_parser = parse_catalog_route)]
1547        provider: Option<ProviderKind>,
1548        /// Report resolved home/config/settings/backend paths and structural
1549        /// credential-source presence without printing credential values or
1550        /// probing provider credential stores.
1551        #[arg(long, default_value_t = false)]
1552        diagnostic: bool,
1553    },
1554    /// Save an API key to the shared user config file. Reads from
1555    /// `--api-key`, `--api-key-stdin`, or prompts on stdin when
1556    /// neither is given. Does not echo the key.
1557    Set {
1558        #[arg(long, value_parser = parse_catalog_route)]
1559        provider: ProviderKind,
1560        /// Inline value (discouraged — appears in shell history).
1561        #[arg(long)]
1562        api_key: Option<String>,
1563        /// Read the key from stdin instead of prompting.
1564        #[arg(long = "api-key-stdin", default_value_t = false)]
1565        api_key_stdin: bool,
1566    },
1567    /// Report the effective credential route for a provider. Never prints a
1568    /// credential; reports the source layer or structural OAuth/repair state.
1569    Get {
1570        #[arg(long, value_parser = parse_catalog_route)]
1571        provider: ProviderKind,
1572    },
1573    /// Pipe the runtime-effective API key to a local client; refuses terminals.
1574    PrintApiKey {
1575        #[arg(long, value_parser = parse_catalog_route)]
1576        provider: ProviderKind,
1577    },
1578    /// Delete a provider's key from config and secret-store storage.
1579    Clear {
1580        #[arg(long, value_parser = parse_auth_clear_provider)]
1581        provider: ProviderKind,
1582    },
1583    /// List all known providers with their runtime-effective auth state,
1584    /// without revealing credentials.
1585    List,
1586    /// Advanced: migrate config-file keys into a platform credential store.
1587    #[command(hide = true)]
1588    Migrate {
1589        /// Don't actually write anything; print what would change.
1590        #[arg(long, default_value_t = false)]
1591        dry_run: bool,
1592    },
1593}
1594
1595#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1596enum ExternalCredentialModeArg {
1597    ReadOnly,
1598    Managed,
1599}
1600
1601#[derive(Debug, Args)]
1602struct ConfigArgs {
1603    #[command(subcommand)]
1604    command: ConfigCommand,
1605}
1606
1607#[derive(Debug, Subcommand)]
1608enum ConfigCommand {
1609    Get {
1610        key: String,
1611    },
1612    Set {
1613        key: String,
1614        value: String,
1615    },
1616    Unset {
1617        key: String,
1618    },
1619    /// Review aggregate usage counting by Codewhale and PostHog (default on).
1620    Telemetry {
1621        /// Optional compatibility form: enable future sessions under this policy version.
1622        #[arg(long, value_name = "VERSION")]
1623        accept_notice: Option<u32>,
1624    },
1625    List,
1626    Path,
1627    /// Open the config file in `$VISUAL`/`$EDITOR` (else `vi`).
1628    Edit,
1629    /// Check the loaded config: unknown keys, empty secrets, malformed
1630    /// URLs. Read-only; prints warnings, fails on errors, never prints a
1631    /// credential.
1632    Doctor,
1633    /// Print the effective config (including `--set` overlays) as TOML with
1634    /// secrets redacted by key name.
1635    Dump,
1636    /// Import a portable config bundle from a file, HTTPS URL, or stdin (-).
1637    Import(config_bundles::ImportArgs),
1638    /// Export a portable, secret-free config bundle.
1639    Export(config_bundles::ExportArgs),
1640}
1641
1642#[derive(Debug, Args)]
1643struct ModelArgs {
1644    #[command(subcommand)]
1645    command: ModelCommand,
1646}
1647
1648#[derive(Debug, Subcommand)]
1649enum ModelCommand {
1650    List {
1651        #[arg(long, value_parser = parse_catalog_route)]
1652        provider: Option<ProviderKind>,
1653    },
1654    Resolve {
1655        model: Option<String>,
1656        #[arg(long, value_parser = parse_catalog_route)]
1657        provider: Option<ProviderKind>,
1658    },
1659    /// Set the default model (e.g. "pro", "flash", "deepseek-v4-pro").
1660    Set { model: String },
1661}
1662
1663#[derive(Debug, Args)]
1664struct ThreadArgs {
1665    #[command(subcommand)]
1666    command: ThreadCommand,
1667}
1668
1669#[derive(Debug, Subcommand)]
1670enum ThreadCommand {
1671    List {
1672        #[arg(long, default_value_t = false)]
1673        all: bool,
1674        #[arg(long)]
1675        limit: Option<usize>,
1676    },
1677    Read {
1678        thread_id: String,
1679    },
1680    Resume {
1681        thread_id: String,
1682    },
1683    Fork {
1684        thread_id: String,
1685    },
1686    Archive {
1687        thread_id: String,
1688    },
1689    Unarchive {
1690        thread_id: String,
1691    },
1692    SetName {
1693        thread_id: String,
1694        name: String,
1695    },
1696    /// Remove the custom name from a thread, restoring the default
1697    /// `(unnamed)` rendering in `thread list`.
1698    ClearName {
1699        thread_id: String,
1700    },
1701}
1702
1703#[derive(Debug, Args)]
1704struct SandboxArgs {
1705    #[command(subcommand)]
1706    command: SandboxCommand,
1707}
1708
1709#[derive(Debug, Subcommand)]
1710enum SandboxCommand {
1711    Check {
1712        command: String,
1713        #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1714        ask: ApprovalModeArg,
1715    },
1716}
1717
1718#[derive(Debug, Clone, Copy, ValueEnum)]
1719enum ApprovalModeArg {
1720    UnlessTrusted,
1721    OnFailure,
1722    OnRequest,
1723    Never,
1724}
1725
1726impl From<ApprovalModeArg> for AskForApproval {
1727    fn from(value: ApprovalModeArg) -> Self {
1728        match value {
1729            ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1730            ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1731            ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1732            ApprovalModeArg::Never => AskForApproval::Never,
1733        }
1734    }
1735}
1736
1737#[derive(Debug, Args)]
1738struct AppServerArgs {
1739    /// Serve the full HTTP/SSE runtime API (`/v1/*`: sessions, threads, turns,
1740    /// approvals, events, usage, fleet, tasks). This is the canonical runtime
1741    /// API surface; it delegates to the same server as `codewhale serve --http`.
1742    #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1743    http: bool,
1744    /// Serve the runtime API plus the phone-friendly mobile control page.
1745    /// Equivalent to the legacy `codewhale serve --mobile`.
1746    #[arg(long, conflicts_with = "stdio")]
1747    mobile: bool,
1748    /// Run the app-server JSON-RPC control transport over stdio (no listener).
1749    /// Used by local SDKs and JSON-RPC integrations.
1750    #[arg(long, default_value_t = false)]
1751    stdio: bool,
1752    /// Run as the desktop daemon: the same JSON-RPC control transport as
1753    /// `--stdio`, served on a user-private unix domain socket under the
1754    /// Codewhale runtime directory. Clients must `daemon/attach` first.
1755    /// Not yet supported on Windows (fails with a typed error).
1756    #[arg(long, default_value_t = false, conflicts_with_all = ["stdio", "http", "mobile"])]
1757    socket: bool,
1758    /// Socket path override for --socket. Defaults to
1759    /// `$CODEWHALE_HOME/run/daemon.sock`, else `$XDG_RUNTIME_DIR/codewhale/daemon.sock`,
1760    /// else `~/Library/Application Support/codewhale/daemon.sock` (macOS) or
1761    /// `~/.codewhale/run/daemon.sock`.
1762    #[arg(long = "socket-path", requires = "socket")]
1763    socket_path: Option<PathBuf>,
1764    /// Show a QR code for the mobile URL in the terminal (requires --mobile).
1765    #[arg(long, requires = "mobile")]
1766    qr: bool,
1767    /// Bind host. Defaults to 127.0.0.1; with --mobile and no host, binds
1768    /// 0.0.0.0 so LAN devices can reach the mobile page.
1769    #[arg(long)]
1770    host: Option<String>,
1771    /// Bind port. Defaults to 7878 for --http/--mobile (the runtime API) and
1772    /// 8787 for the legacy in-process app-server HTTP transport.
1773    #[arg(long)]
1774    port: Option<u16>,
1775    /// Background task worker count (1-8). Only used with --http/--mobile.
1776    #[arg(long)]
1777    workers: Option<usize>,
1778    #[arg(long)]
1779    config: Option<PathBuf>,
1780    #[arg(long = "auth-token")]
1781    auth_token: Option<String>,
1782    #[arg(long, default_value_t = false)]
1783    insecure_no_auth: bool,
1784    #[arg(long = "cors-origin")]
1785    cors_origin: Vec<String>,
1786}
1787
1788const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
1789
1790fn install_rustls_crypto_provider() {
1791    let _ = rustls::crypto::ring::default_provider().install_default();
1792}
1793
1794pub fn run_cli() -> std::process::ExitCode {
1795    install_rustls_crypto_provider();
1796
1797    match run() {
1798        Ok(()) => std::process::ExitCode::SUCCESS,
1799        Err(err) => {
1800            // Use the full anyhow chain so callers see the underlying
1801            // cause (e.g. the actual TOML parse error with line/column)
1802            // instead of just the top-level context message. The bare
1803            // `{err}` Display impl drops the chain — see #767, where
1804            // users hit "failed to parse config at <path>" with no
1805            // hint that the real error was a stray BOM or unbalanced
1806            // quote a few lines down.
1807            eprintln!("error: {err}");
1808            for cause in err.chain().skip(1) {
1809                eprintln!("  caused by: {cause}");
1810            }
1811            // A Codewhale account failure carries a class: CI logs must be
1812            // able to tell a bad credential from an unconfigured agent model
1813            // without parsing English, and the machine-readable code beside
1814            // it names the control-plane branch that was taken.
1815            if let Some(machine) = err.downcast_ref::<cloud::machine::MachineError>() {
1816                eprintln!(
1817                    "  codewhale: code={} status={}",
1818                    machine.code, machine.status
1819                );
1820                if let Ok(code) = u8::try_from(machine.exit_code) {
1821                    return std::process::ExitCode::from(code);
1822                }
1823            }
1824            std::process::ExitCode::FAILURE
1825        }
1826    }
1827}
1828
1829fn split_lane_log_proxy_command(
1830    command: Option<Commands>,
1831) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
1832    match command {
1833        Some(Commands::LaneLogProxy(args)) => (Some(args), None),
1834        command => (None, command),
1835    }
1836}
1837
1838fn config_command_targets_project(matches: &clap::ArgMatches) -> bool {
1839    let Some(config_matches) = matches.subcommand_matches("config") else {
1840        return false;
1841    };
1842    let Some((command, command_matches)) = config_matches.subcommand() else {
1843        return false;
1844    };
1845    if !matches!(command, "import" | "export") {
1846        return false;
1847    }
1848    command_matches
1849        .try_get_one::<bool>("project")
1850        .ok()
1851        .flatten()
1852        .copied()
1853        .unwrap_or(false)
1854}
1855
1856fn config_store_path_for_dispatch(
1857    explicit_path: Option<PathBuf>,
1858    project_bundle_scope: bool,
1859    cwd: &Path,
1860) -> Option<PathBuf> {
1861    if explicit_path.is_none() && project_bundle_scope {
1862        // Mirror the project-config loader: the current app dir wins, but a
1863        // workspace that still keeps its document under the legacy app dir
1864        // must be read and updated in place rather than shadowed by a new
1865        // empty document.
1866        let current = cwd
1867            .join(codewhale_config::CODEWHALE_APP_DIR)
1868            .join(codewhale_config::CONFIG_FILE_NAME);
1869        let legacy = cwd
1870            .join(codewhale_config::LEGACY_APP_DIR)
1871            .join(codewhale_config::CONFIG_FILE_NAME);
1872        if !current.is_file() && legacy.is_file() {
1873            return Some(legacy);
1874        }
1875        return Some(current);
1876    }
1877    explicit_path
1878}
1879
1880/// Runtime `--set` uses the dedicated flag handoff, so the existing loader
1881/// owns profile, provider, managed-policy and requirements precedence. Keep
1882/// config read/write commands on their separate, never-saved store overlay.
1883fn apply_runtime_set_overrides(cli: &mut Cli) -> Result<()> {
1884    let mut values = CliRuntimeOverrides::default();
1885    let mut provider = None;
1886    for spec in &cli.overrides {
1887        let (key, value) = spec
1888            .split_once('=')
1889            .context("invalid --set: expected KEY=VALUE (value omitted)")?;
1890        match key.trim() {
1891            "provider" => {
1892                provider = Some(
1893                    parse_provider_identifier(value)
1894                        .map_err(|_| anyhow!("invalid --set provider (value omitted)"))?,
1895                );
1896            }
1897            "model" | "default_text_model" => values.model = Some(value.to_string()),
1898            "verbosity" => values.verbosity = Some(value.to_string()),
1899            "approval_policy" => values.approval_policy = Some(value.to_string()),
1900            "sandbox_mode" => values.sandbox_mode = Some(value.to_string()),
1901            "telemetry" => {
1902                let mut config = ConfigToml::default();
1903                config
1904                    .set_value("telemetry", value)
1905                    .map_err(|_| anyhow!("invalid --set telemetry: expected a boolean"))?;
1906                values.telemetry = config.telemetry;
1907            }
1908            _ => bail!(
1909                "unsupported runtime --set key (value omitted): supported keys are provider, \
1910                 model, default_text_model, verbosity, approval_policy, sandbox_mode and \
1911                 telemetry; use the dedicated option or config set for other keys"
1912            ),
1913        }
1914        if value.trim().is_empty() {
1915            bail!("invalid runtime --set: value must not be empty");
1916        }
1917    }
1918    // A dedicated flag is more specific than a generic --set for the same
1919    // field. Repeated --set keys otherwise keep their last value.
1920    cli.provider = cli.provider.take().or(provider);
1921    cli.model = cli.model.take().or(values.model);
1922    cli.verbosity = cli.verbosity.take().or(values.verbosity);
1923    cli.approval_policy = cli.approval_policy.take().or(values.approval_policy);
1924    cli.sandbox_mode = cli.sandbox_mode.take().or(values.sandbox_mode);
1925    cli.telemetry = cli.telemetry.or(values.telemetry);
1926    Ok(())
1927}
1928
1929fn run() -> Result<()> {
1930    let matches = Cli::command().get_matches();
1931    let project_bundle_scope = config_command_targets_project(&matches);
1932    let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
1933
1934    // The detached log proxy must not depend on user config parsing: its job
1935    // is to frame child output and publish a terminal receipt even when the
1936    // delegated command's own config is malformed.
1937    let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
1938    if let Some(args) = proxy {
1939        return run_lane_log_proxy_command(args);
1940    }
1941
1942    if !cli.overrides.is_empty() && matches!(command, Some(Commands::Auth(_))) {
1943        bail!("--set is not supported by auth commands; use a saved config");
1944    }
1945    if !cli.overrides.is_empty()
1946        && matches!(&command, Some(Commands::AppServer(args)) if !args.http && !args.mobile)
1947    {
1948        bail!(
1949            "--set is not supported by the legacy app-server transport; use app-server --http or a saved config"
1950        );
1951    }
1952    if !matches!(command, Some(Commands::Config(_))) {
1953        apply_runtime_set_overrides(&mut cli)?;
1954    }
1955
1956    let pipe_api_key_handoff = matches!(
1957        &command,
1958        Some(Commands::Auth(AuthArgs {
1959            command: AuthCommand::PrintApiKey { .. }
1960        }))
1961    );
1962    if pipe_api_key_handoff {
1963        credential_handoff::prepare_stdout(io::stdout().is_terminal())?;
1964    }
1965    let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
1966    let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
1967    let runtime_overrides = CliRuntimeOverrides {
1968        provider: runtime_provider,
1969        model: cli.model.clone(),
1970        api_key: cli.api_key.clone(),
1971        base_url: cli.base_url.clone(),
1972        auth_mode: None,
1973        output_mode: cli.output_mode.clone(),
1974        log_level: cli.log_level.clone(),
1975        telemetry: cli.telemetry,
1976        approval_policy: cli.approval_policy.clone(),
1977        sandbox_mode: cli.sandbox_mode.clone(),
1978        yolo: Some(cli.yolo),
1979        verbosity: cli.verbosity.clone(),
1980    };
1981    if uses_raw_tui_provider
1982        && let Some((resolved_runtime, passthrough)) =
1983            prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
1984    {
1985        return run_tui_in_process(&cli, &resolved_runtime, passthrough);
1986    }
1987
1988    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1989    let config_path =
1990        config_store_path_for_dispatch(cli.config.clone(), project_bundle_scope, &cwd);
1991    let mut store = ConfigStore::load(config_path).map_err(|error| {
1992        if pipe_api_key_handoff {
1993            anyhow!("unavailable credential")
1994        } else {
1995            error
1996        }
1997    })?;
1998    // Root session flags only reach the TUI through the `None` branch below;
1999    // no subcommand handler reads them. Accepting them silently resumes
2000    // nothing -- `codewhale --resume abc exec "..."` would start a fresh
2001    // session while looking like it continued one.
2002    if command.is_some()
2003        && (cli.continue_session || cli.resume.is_some() || cli.session_id.is_some())
2004    {
2005        anyhow::bail!(
2006            "--continue/--resume/--session-id apply to the interactive session and \
2007             cannot be combined with a subcommand. Run them without a subcommand, or \
2008             use the subcommand's own flag (for example `codewhale exec --session-id <id>`)."
2009        );
2010    }
2011    // Only config inspection needs the store overlay. Runtime overrides use
2012    // the dedicated flags above and must never enter a store that another
2013    // command (or legacy credential migration) can save.
2014    if matches!(command, Some(Commands::Config(_))) {
2015        apply_per_run_overrides(&mut store, &cli.overrides)?;
2016    }
2017
2018    match command {
2019        Some(Commands::Run(args)) => {
2020            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2021            run_tui_in_process(&cli, &resolved_runtime, args.args)
2022        }
2023        Some(Commands::Doctor(args)) => {
2024            let resolved_runtime =
2025                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2026            run_tui_in_process(&cli, &resolved_runtime, tui_args("doctor", args))
2027        }
2028        Some(Commands::Models(args)) => {
2029            let resolved_runtime =
2030                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2031            run_tui_in_process(&cli, &resolved_runtime, tui_args("models", args))
2032        }
2033        Some(Commands::Speech(args)) => {
2034            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2035            run_tui_in_process(&cli, &resolved_runtime, tui_args("speech", args))
2036        }
2037        Some(Commands::Sessions(args)) => {
2038            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2039            run_tui_in_process(&cli, &resolved_runtime, tui_args("sessions", args))
2040        }
2041        Some(Commands::Resume(args)) => {
2042            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2043            run_resume_command(&cli, &resolved_runtime, args)
2044        }
2045        Some(Commands::Rc(args)) => {
2046            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2047            let mut passthrough = vec!["--remote-control".to_string()];
2048            passthrough.extend(args.args);
2049            run_tui_in_process(&cli, &resolved_runtime, passthrough)
2050        }
2051        Some(Commands::Fork(args)) => {
2052            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2053            run_tui_in_process(&cli, &resolved_runtime, tui_args("fork", args))
2054        }
2055        Some(Commands::Init(args)) => {
2056            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2057            run_tui_in_process(&cli, &resolved_runtime, tui_args("init", args))
2058        }
2059        Some(Commands::Setup(args)) => {
2060            let resolved_runtime = if setup_is_status_report(&args) {
2061                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
2062            } else {
2063                resolve_runtime_for_dispatch(&mut store, &runtime_overrides)
2064            };
2065            run_tui_in_process(&cli, &resolved_runtime, tui_args("setup", args))
2066        }
2067        Some(Commands::RemoteSetup(args)) => {
2068            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2069            run_tui_in_process(&cli, &resolved_runtime, remote_setup_tui_args(args))
2070        }
2071        Some(Commands::Exec(args)) => {
2072            reject_exec_global_flags(&args.args)?;
2073            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2074            run_tui_in_process(&cli, &resolved_runtime, tui_args("exec", args))
2075        }
2076        Some(Commands::Fleet(args)) => {
2077            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2078            run_tui_in_process(&cli, &resolved_runtime, tui_args("fleet", args))
2079        }
2080        Some(Commands::WorkflowTool(args)) => {
2081            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2082            run_tui_in_process(&cli, &resolved_runtime, tui_args("workflow-tool", args))
2083        }
2084        Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
2085        Some(Commands::Workflow(args)) => {
2086            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2087            let config_path = store.path().to_path_buf();
2088            run_workflow_command(&cli, &resolved_runtime, &config_path, args)
2089        }
2090        Some(Commands::Lane(args)) => run_lane_command(args),
2091        Some(Commands::Review(args)) => {
2092            // CI path: a machine token authenticates as the account with no
2093            // local session and no browser. The account's own configured
2094            // provider then disambiguates a model that maps to several
2095            // configured routes, which review otherwise hard-errors on.
2096            let mut overrides = runtime_overrides.clone();
2097            if overrides.provider.is_none()
2098                && let Some(provider) = cloud::machine_review_provider()?
2099            {
2100                overrides.provider = Some(provider);
2101            }
2102            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &overrides);
2103            run_tui_in_process(&cli, &resolved_runtime, tui_args("review", args))
2104        }
2105        Some(Commands::Apply(args)) => {
2106            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2107            run_tui_in_process(&cli, &resolved_runtime, tui_args("apply", args))
2108        }
2109        Some(Commands::Eval(args)) => {
2110            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2111            run_tui_in_process(&cli, &resolved_runtime, tui_args("eval", args))
2112        }
2113        Some(Commands::Mcp(args)) => {
2114            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2115            run_tui_in_process(&cli, &resolved_runtime, tui_args("mcp", args))
2116        }
2117        Some(Commands::Integrations(args)) => {
2118            // Integrations only need route *identity*. Do not recover or
2119            // export a stored credential just to plan/launch a third-party
2120            // harness: it resolves its own keys from its own environment.
2121            let resolved_runtime =
2122                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2123            run_tui_in_process(&cli, &resolved_runtime, tui_args("integrations", args))
2124        }
2125        Some(Commands::Features(args)) => {
2126            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2127            run_tui_in_process(&cli, &resolved_runtime, tui_args("features", args))
2128        }
2129        Some(Commands::Serve(args)) => {
2130            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2131            // `serve` starts a long-running runtime API listener; supervise the
2132            // delegated child so it is torn down with the dispatcher (#3259).
2133            run_tui_server_in_process(&cli, &resolved_runtime, tui_args("serve", args))
2134        }
2135        Some(Commands::Web(args)) => {
2136            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2137            run_tui_server_in_process(&cli, &resolved_runtime, web_serve_passthrough(&args))
2138        }
2139        Some(Commands::Login(args)) => {
2140            reject_legacy_login_provider_args(&args)?;
2141            cloud::reject_inline_api_key(cli.api_key.as_deref())?;
2142            cloud::run_account_login(
2143                args.no_open,
2144                args.timeout_seconds,
2145                cli.profile.as_deref(),
2146                &store,
2147            )
2148        }
2149        Some(Commands::Logout) => run_logout_command(&mut store, cli.profile.as_deref()),
2150        Some(Commands::Auth(args)) => match args.command {
2151            AuthCommand::XaiDevice => {
2152                let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2153                run_tui_in_process(
2154                    &cli,
2155                    &resolved_runtime,
2156                    vec!["auth".to_string(), "xai-device".to_string()],
2157                )
2158            }
2159            AuthCommand::Chatgpt => {
2160                let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2161                run_tui_in_process(
2162                    &cli,
2163                    &resolved_runtime,
2164                    vec!["auth".to_string(), "chatgpt".to_string()],
2165                )
2166            }
2167            AuthCommand::ChatgptRevoke => {
2168                let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2169                run_tui_in_process(
2170                    &cli,
2171                    &resolved_runtime,
2172                    vec!["auth".to_string(), "chatgpt-revoke".to_string()],
2173                )
2174            }
2175            command @ AuthCommand::Status {
2176                diagnostic: true, ..
2177            } => {
2178                // Like `doctor`, this is a read-only diagnostic. Starting a
2179                // telemetry session here would create
2180                // `$CODEWHALE_HOME/telemetry` before the report could truthfully
2181                // say the isolated home is missing.
2182                run_auth_command_with_runtime(&mut store, command, &runtime_overrides)
2183            }
2184            command => {
2185                let resolved_runtime =
2186                    resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2187                let session = start_cli_telemetry(
2188                    &resolved_runtime,
2189                    Some(store.path().to_path_buf()),
2190                    Surface::Cli,
2191                );
2192                let outcome =
2193                    run_auth_command_with_runtime(&mut store, command, &runtime_overrides);
2194                finish_cli_telemetry(session, &outcome);
2195                outcome
2196            }
2197        },
2198        Some(Commands::Account(args)) => {
2199            cloud::reject_inline_api_key(cli.api_key.as_deref())?;
2200            cloud::run(args, cli.profile.as_deref(), &store)
2201        }
2202        Some(Commands::Dispatch(args)) => dispatch::run(args),
2203        Some(Commands::McpServer) => {
2204            // `codewhale serve --mcp` delegates to the TUI and arms there, so
2205            // without this the same user action reported differently depending
2206            // on which spelling they typed — and `mcp-server`, a surface the
2207            // schema documents as emitting, could only ever read zero. A
2208            // structural zero a maintainer mistakes for an adoption zero is
2209            // the thing the "which surfaces emit" section exists to prevent.
2210            let resolved_runtime =
2211                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2212            let session = start_cli_telemetry(
2213                &resolved_runtime,
2214                Some(store.path().to_path_buf()),
2215                Surface::McpServer,
2216            );
2217            let outcome = run_mcp_server_command(&mut store);
2218            finish_cli_telemetry(session, &outcome);
2219            outcome
2220        }
2221        Some(Commands::Config(args)) => {
2222            let resolved_runtime =
2223                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2224            let session = start_cli_telemetry(
2225                &resolved_runtime,
2226                Some(store.path().to_path_buf()),
2227                Surface::Cli,
2228            );
2229            let outcome = run_config_command(
2230                &mut store,
2231                args.command,
2232                project_bundle_scope,
2233                &cli.overrides,
2234            );
2235            finish_cli_telemetry(session, &outcome);
2236            outcome
2237        }
2238        Some(Commands::Model(args)) => {
2239            // `model resolve` is a diagnostic: it must report the same route
2240            // the runtime would take, so it resolves through the same
2241            // read-only path `doctor` uses rather than looking only at flags.
2242            let resolved_runtime =
2243                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2244            run_model_command(
2245                &mut store,
2246                args.command,
2247                runtime_overrides.provider,
2248                &resolved_runtime,
2249            )
2250        }
2251        Some(Commands::Thread(args)) => {
2252            run_thread_command(&cli, &mut store, &runtime_overrides, args.command)
2253        }
2254        Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
2255        Some(Commands::AppServer(args)) => {
2256            // The HTTP/mobile runtime API is delegated to the mature `serve` path
2257            // in the TUI binary, which reads the *global* --config. app-server has
2258            // historically taken a subcommand-level --config, so bridge it before
2259            // resolving runtime options (provider/keyring) for the delegated run.
2260            if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
2261                cli.config = args.config.clone();
2262                store = ConfigStore::load(cli.config.clone())?;
2263            }
2264            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2265            run_app_server_command(&cli, &resolved_runtime, args)
2266        }
2267        Some(Commands::Completion { shell }) => {
2268            let mut stdout = io::stdout();
2269            stdout.write_all(render_completion_script(shell).as_bytes())?;
2270            stdout.flush()?;
2271            Ok(())
2272        }
2273        Some(Commands::Metrics(args)) => run_metrics_command(args),
2274        Some(Commands::Update(args)) => {
2275            let resolved_runtime =
2276                resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2277            let session = start_cli_telemetry(
2278                &resolved_runtime,
2279                Some(store.path().to_path_buf()),
2280                Surface::Cli,
2281            );
2282            #[cfg(not(target_env = "ohos"))]
2283            let outcome = update::run_update(args.beta, args.check, args.proxy);
2284            #[cfg(target_env = "ohos")]
2285            let outcome = {
2286                let _ = args;
2287                Err(anyhow!(
2288                    "self-update is not supported on HarmonyOS/OpenHarmony yet"
2289                ))
2290            };
2291            finish_cli_telemetry(session, &outcome);
2292            outcome
2293        }
2294        Some(Commands::Providers(args)) => run_providers_command(args),
2295        None => {
2296            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2297            let forwarded = root_tui_passthrough(&cli)?;
2298            run_tui_in_process(&cli, &resolved_runtime, forwarded)
2299        }
2300    }
2301}
2302
2303fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
2304    let mut forwarded = Vec::new();
2305    if cli.continue_session {
2306        forwarded.push("--continue".to_string());
2307    }
2308    let resume_session_id = cli
2309        .resume
2310        .as_deref()
2311        .or(cli.session_id.as_deref())
2312        .map(str::trim);
2313    if resume_session_id.is_some_and(str::is_empty) {
2314        // A shell expanding an unset variable -- `codewhale --resume
2315        // "$SESSION_ID"` -- must not quietly become a fresh session. The user
2316        // asked to resume; starting new loses the session they meant, and the
2317        // mistake is invisible until the history is gone.
2318        bail!(
2319            "--resume/--session-id needs a session id, but got an empty value \
2320             (an unset shell variable?). Use `codewhale --continue` to resume \
2321             the most recent session."
2322        );
2323    }
2324    if let Some(session_id) = resume_session_id {
2325        forwarded.push("--resume".to_string());
2326        forwarded.push(session_id.to_string());
2327    }
2328
2329    let prompt =
2330        cli.prompt_flag
2331            .iter()
2332            .chain(cli.prompt.iter())
2333            .fold(String::new(), |mut acc, part| {
2334                if !acc.is_empty() {
2335                    acc.push(' ');
2336                }
2337                acc.push_str(part);
2338                acc
2339            });
2340    if !prompt.is_empty() {
2341        if cli.continue_session {
2342            bail!(
2343                "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
2344            );
2345        }
2346        if let Some(session_id) = resume_session_id {
2347            bail!(
2348                "`codewhale --resume {session_id}` resumes the interactive TUI. Use `codewhale exec --resume {session_id} <PROMPT>` to continue a session non-interactively."
2349            );
2350        }
2351        forwarded.push("--prompt".to_string());
2352        forwarded.push(prompt);
2353    }
2354
2355    Ok(forwarded)
2356}
2357
2358fn resolve_runtime_for_dispatch(
2359    store: &mut ConfigStore,
2360    runtime_overrides: &CliRuntimeOverrides,
2361) -> ResolvedRuntimeOptions {
2362    let runtime_secrets = Secrets::auto_detect();
2363    resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
2364}
2365
2366/// Resolve enough routing state to delegate a static diagnostic without
2367/// reading or migrating the durable secret store.
2368///
2369/// The TUI's doctor/setup-status path performs its own read-only source check,
2370/// so this dispatcher must not recover and export a credential merely to start
2371/// that report. Regular runtime and authentication commands keep using
2372/// [`resolve_runtime_for_dispatch`].
2373fn resolve_runtime_for_diagnostic_dispatch(
2374    store: &ConfigStore,
2375    runtime_overrides: &CliRuntimeOverrides,
2376) -> ResolvedRuntimeOptions {
2377    store.config.resolve_runtime_options(runtime_overrides)
2378}
2379
2380/// An armed telemetry session belonging to a subcommand that runs *in this
2381/// process*.
2382///
2383/// Existing at all is the permission: it is only ever constructed behind
2384/// [`TelemetryDecision::Enabled`] after persistent and run-scoped opt-outs are
2385/// applied.
2386struct CliTelemetrySession {
2387    started: std::time::Instant,
2388}
2389
2390/// Arm telemetry for a subcommand the dispatcher executes itself.
2391///
2392/// Only the terminal branches take this path. Everything that delegates to the
2393/// TUI binary is armed over there, under its own surface, from the environment
2394/// this dispatcher forwards — naming a surface here for a delegated command
2395/// would report one run twice under two identities.
2396///
2397/// Persistent config and setup-state opt-outs are applied inside
2398/// [`telemetry::decide`].
2399fn start_cli_telemetry(
2400    resolved: &ResolvedRuntimeOptions,
2401    config_path: Option<PathBuf>,
2402    surface: Surface,
2403) -> Option<CliTelemetrySession> {
2404    let consent = resolve_cli_telemetry_consent(
2405        resolved,
2406        config_path,
2407        surface,
2408        telemetry::load_setup_state_for_decision(),
2409    )?;
2410    telemetry::init(consent);
2411    telemetry::record(Event::SessionStart {
2412        source: SessionSource::Unknown,
2413    });
2414    Some(CliTelemetrySession {
2415        started: std::time::Instant::now(),
2416    })
2417}
2418
2419fn resolve_cli_telemetry_consent(
2420    resolved: &ResolvedRuntimeOptions,
2421    config_path: Option<PathBuf>,
2422    surface: Surface,
2423    setup: Option<SetupState>,
2424) -> Option<telemetry::TelemetryConsent> {
2425    let setup = setup?;
2426    let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
2427        return None;
2428    };
2429    Some(consent.with_config_path(config_path))
2430}
2431
2432/// Close the session opened by [`start_cli_telemetry`] and flush, bounded.
2433///
2434/// The exit class comes from what actually happened, never from an exit code:
2435/// a cancelled run and a SIGINT both exit 130, so a code-derived class would
2436/// mislabel every cancel as a signal.
2437///
2438/// The flush re-resolves telemetry from disk before it sends anything, which is
2439/// what makes `codewhale config set telemetry false` take effect on the very run
2440/// that wrote it rather than on the next one.
2441fn finish_cli_telemetry(session: Option<CliTelemetrySession>, outcome: &Result<()>) {
2442    let Some(session) = session else {
2443        return;
2444    };
2445    telemetry::set_exit_class(if outcome.is_ok() {
2446        ExitClass::Clean
2447    } else {
2448        ExitClass::Error
2449    });
2450    telemetry::record(Event::SessionEnd {
2451        duration_bucket: DurationBucket::from_secs(session.started.elapsed().as_secs()),
2452        exit_class: telemetry::exit_class(),
2453        // Cold start is measured by the TUI's startup trace. This surface has
2454        // no equivalent, and inventing one from process start would be a
2455        // different measurement wearing the same name.
2456        cold_start_bucket: None,
2457        providers: Vec::new(),
2458        counters: Counters::default(),
2459        errors: Errors::default(),
2460        turn_wall: TurnWall::default(),
2461    });
2462    let _ = telemetry::shutdown_blocking(telemetry::SHUTDOWN_FLUSH_TIMEOUT);
2463}
2464
2465fn resolve_runtime_for_dispatch_with_secrets(
2466    store: &mut ConfigStore,
2467    runtime_overrides: &CliRuntimeOverrides,
2468    secrets: &Secrets,
2469) -> ResolvedRuntimeOptions {
2470    store
2471        .config
2472        .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
2473}
2474
2475fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
2476    let mut forwarded = Vec::with_capacity(args.args.len() + 1);
2477    forwarded.push(command.to_string());
2478    forwarded.extend(args.args);
2479    forwarded
2480}
2481
2482fn setup_is_status_report(args: &TuiPassthroughArgs) -> bool {
2483    args.args.iter().any(|arg| arg == "--status")
2484}
2485
2486fn reject_exec_global_flags(args: &[String]) -> Result<()> {
2487    const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
2488
2489    for arg in args {
2490        if arg == "--" {
2491            break;
2492        }
2493        let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
2494        if GLOBAL_ONLY_FLAGS.contains(&flag) {
2495            bail!(
2496                "{flag} must be placed before `exec`.\n\nUse:\n  codewhale {flag} <value> exec \"<prompt>\""
2497            );
2498        }
2499    }
2500
2501    Ok(())
2502}
2503
2504/// `codewhale login` used to configure provider API keys; that surface moved
2505/// to `auth set --provider`. The hidden legacy flags stay parseable so the
2506/// redirect below can name the replacement instead of an unknown-flag error.
2507fn reject_legacy_login_provider_args(args: &LoginArgs) -> Result<()> {
2508    if args.api_key.is_none() && args.provider.is_none() {
2509        return Ok(());
2510    }
2511    bail!(
2512        "`codewhale login` now signs in to your Codewhale account via the browser device flow. \
2513         To configure a provider key, run `codewhale auth set --provider <provider>` (hidden prompt) \
2514         or `codewhale auth set --provider <provider> --api-key-stdin`."
2515    )
2516}
2517
2518fn run_logout_command(store: &mut ConfigStore, profile: Option<&str>) -> Result<()> {
2519    run_logout_command_with_secrets(store, &Secrets::auto_detect(), profile)
2520}
2521
2522fn run_logout_command_with_secrets(
2523    store: &mut ConfigStore,
2524    secrets: &Secrets,
2525    profile: Option<&str>,
2526) -> Result<()> {
2527    codewhale_config::with_xai_oauth_revocation_transaction(|| {
2528        run_logout_command_with_secrets_unlocked(store, secrets, profile)
2529    })
2530}
2531
2532fn run_logout_command_with_secrets_unlocked(
2533    store: &mut ConfigStore,
2534    secrets: &Secrets,
2535    profile: Option<&str>,
2536) -> Result<()> {
2537    let original_config = store.config.clone();
2538    store.config.api_key = None;
2539    for provider in ProviderKind::ALL {
2540        clear_provider_api_key_from_config(store, provider);
2541        store
2542            .config
2543            .providers
2544            .for_provider_mut(provider)
2545            .external_credentials = None;
2546    }
2547    let xai = store.config.providers.for_provider_mut(ProviderKind::Xai);
2548    xai.oauth_credential_generation = None;
2549    xai.auth_mode = None;
2550    let openai_codex = store
2551        .config
2552        .providers
2553        .for_provider_mut(ProviderKind::OpenaiCodex);
2554    if openai_codex
2555        .oauth_credential_generation
2556        .as_deref()
2557        .is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
2558    {
2559        openai_codex.oauth_credential_generation = None;
2560        if openai_codex.auth_mode.as_deref() == Some("oauth") {
2561            openai_codex.auth_mode = None;
2562        }
2563    }
2564    store.config.auth_mode = None;
2565    if let Err(error) = store.save() {
2566        store.config = original_config;
2567        return Err(error);
2568    }
2569    let mut keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
2570    // Already inside with_xai_oauth_revocation_transaction: the locked
2571    // variant must not re-enter the non-reentrant lifecycle mutex.
2572    if let Err(error) = codewhale_config::clear_all_chatgpt_oauth_credentials_locked() {
2573        keyring_failures.push(format!("chatgpt oauth: {error}"));
2574    }
2575    if let Err(error) = clear_daytona_slot(secrets) {
2576        keyring_failures.push(format!(
2577            "{}: {error}",
2578            codewhale_secrets::DAYTONA_TOKEN_SLOT
2579        ));
2580    }
2581    if let Err(error) = clear_account_session(profile) {
2582        keyring_failures.push(format!("account session: {error}"));
2583    }
2584    if keyring_failures.is_empty() {
2585        println!("logged out");
2586    } else {
2587        eprintln!(
2588            "failed to delete stored credentials for: {}",
2589            keyring_failures.join(", ")
2590        );
2591        println!("logged out (some stored credentials could not be deleted)");
2592    }
2593    Ok(())
2594}
2595
2596fn clear_daytona_slot(secrets: &Secrets) -> Result<(), codewhale_secrets::SecretsError> {
2597    if secrets
2598        .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)?
2599        .is_some_and(|value| !value.trim().is_empty())
2600    {
2601        secrets.delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)?;
2602    }
2603    Ok(())
2604}
2605
2606fn clear_account_session(profile: Option<&str>) -> Result<(), String> {
2607    use codewhale_secrets::account::{
2608        ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
2609        secure_account_session_secrets,
2610    };
2611    let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?;
2612    let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
2613        .ok()
2614        .map(|value| value.trim().trim_end_matches('/').to_string())
2615        .filter(|value| !value.is_empty())
2616        .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
2617    AccountSessionStore::new(secrets, profile, &api_base)
2618        .clear()
2619        .map_err(|error| error.to_string())
2620}
2621
2622/// Map [`ProviderKind`] to the canonical provider credential slot.
2623fn provider_slot(provider: ProviderKind) -> &'static str {
2624    // Shared-account families (SiliconFlow China, the four Model Studio
2625    // variants) collapse onto one slot; see ProviderKind::secret_store_slot.
2626    provider.secret_store_slot()
2627}
2628
2629/// Resolve the store for credential-adjacent writes: provider selection,
2630/// `auth_mode` markers, and the plaintext-free metadata that accompanies a
2631/// saved key.
2632///
2633/// Credentials and their metadata are user-global — a key saved while
2634/// working in one repo must be visible from every other repo, and the secret
2635/// store already is (#5045). When the ambient config path is a
2636/// workspace-scoped document (`<repo>/.codewhale/config.toml`), login and
2637/// `auth set` must not bind the provider or write auth markers there: the
2638/// binding would be invisible from every other repo and would invite
2639/// plaintext keys into a committable repo file (#5198). Returns a store
2640/// loaded on the user-global document in that case, or `None` when the
2641/// ambient store is already correctly scoped, so key + provider binding +
2642/// auth markers share one user-global scope by default.
2643fn credential_metadata_store(store: &ConfigStore) -> Result<Option<ConfigStore>> {
2644    if !codewhale_config::config_path_is_workspace_scoped(store.path()) {
2645        return Ok(None);
2646    }
2647    let global = codewhale_config::default_config_path()?;
2648    eprintln!(
2649        "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
2650        codewhale_config::quote_os_path(store.path()),
2651        codewhale_config::quote_os_path(&global),
2652    );
2653    ConfigStore::load(Some(global)).map(Some)
2654}
2655
2656#[cfg(test)]
2657fn no_keyring_secrets() -> Secrets {
2658    Secrets::new(std::sync::Arc::new(
2659        codewhale_secrets::InMemoryKeyringStore::new(),
2660    ))
2661}
2662
2663fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
2664    store.config.auth_mode = Some("api_key".to_string());
2665    let provider_config = store.config.providers.for_provider_mut(provider);
2666    provider_config.auth_mode = Some("api_key".to_string());
2667    provider_config.external_credentials = None;
2668    if provider == ProviderKind::Xai {
2669        provider_config.oauth_credential_generation = None;
2670    }
2671    if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() {
2672        store.config.default_text_model = Some(
2673            store
2674                .config
2675                .providers
2676                .deepseek
2677                .model
2678                .clone()
2679                .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
2680        );
2681    }
2682}
2683
2684/// Persist a provider credential to the durable secret store without silently
2685/// downgrading a backend failure to plaintext config storage.
2686fn persist_provider_api_key(
2687    store: &mut ConfigStore,
2688    secrets: &Secrets,
2689    provider: ProviderKind,
2690    api_key: &str,
2691) -> Result<bool> {
2692    if provider == ProviderKind::Xai {
2693        return codewhale_config::with_xai_oauth_revocation_transaction(|| {
2694            persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2695        });
2696    }
2697    persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2698}
2699
2700fn persist_provider_api_key_unlocked(
2701    store: &mut ConfigStore,
2702    secrets: &Secrets,
2703    provider: ProviderKind,
2704    api_key: &str,
2705) -> Result<bool> {
2706    let original_config = store.config.clone();
2707    prepare_provider_api_key_metadata(store, provider);
2708    let slot = provider_slot(provider);
2709    // A readable prior value is required before a secret-store write so a
2710    // later config failure can restore the exact prior state. If the backend
2711    // cannot provide that snapshot, fail before changing the config file.
2712    let prior_secret = secrets.get(slot);
2713    let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
2714        Ok(_) => match secrets.set(slot, api_key) {
2715            Ok(()) => {
2716                clear_provider_api_key_from_config(store, provider);
2717                true
2718            }
2719            Err(err) => {
2720                store.config = original_config;
2721                return Err(anyhow::anyhow!(
2722                    "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.",
2723                    codewhale_config::quote_os_path(store.path())
2724                ));
2725            }
2726        },
2727        Err(error) => {
2728            store.config = original_config;
2729            return Err(anyhow::anyhow!(
2730                "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.",
2731                codewhale_config::quote_os_path(store.path())
2732            ));
2733        }
2734    };
2735    if let Err(error) = store.save() {
2736        store.config = original_config;
2737        if secret_store_saved {
2738            let current = secrets
2739                .get(slot)
2740                .map_err(|rollback| anyhow::anyhow!(
2741                    "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
2742                ))?;
2743            if current.as_deref() == Some(api_key) {
2744                match prior_secret.expect("snapshot succeeded before secret write") {
2745                    Some(previous) => secrets.set(slot, &previous),
2746                    None => secrets.delete(slot),
2747                }
2748                .map_err(|rollback| anyhow::anyhow!(
2749                    "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
2750                ))?;
2751            }
2752        }
2753        return Err(error);
2754    }
2755    codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
2756    Ok(secret_store_saved)
2757}
2758
2759fn clear_auth_provider(
2760    store: &mut ConfigStore,
2761    secrets: &Secrets,
2762    provider: ProviderKind,
2763) -> Result<()> {
2764    if provider == ProviderKind::Antigravity {
2765        return clear_legacy_antigravity_config(store, secrets);
2766    }
2767    let slot = provider_slot(provider);
2768    let original_config = store.config.clone();
2769    clear_provider_api_key_from_config(store, provider);
2770    if provider == ProviderKind::Xai {
2771        let xai = store.config.providers.for_provider_mut(provider);
2772        xai.oauth_credential_generation = None;
2773        xai.auth_mode = None;
2774        xai.external_credentials = None;
2775    }
2776    if let Err(error) = store.save() {
2777        store.config = original_config;
2778        return Err(error);
2779    }
2780    clear_provider_api_key_from_keyring(secrets, provider);
2781    if provider == ProviderKind::Xai {
2782        println!("cleared xAI credentials from config, secret store, and owned OAuth storage");
2783    } else {
2784        println!("cleared API key for {slot} from config and secret store");
2785    }
2786    Ok(())
2787}
2788
2789/// Remove only Codewhale-owned state for the retired Antigravity route.
2790///
2791/// This deliberately operates on the already-loaded Codewhale config and its
2792/// own secret slot. It never resolves an external credential path, reads an
2793/// environment credential, or invokes a Google/Antigravity logout or revoke
2794/// flow.
2795fn clear_legacy_antigravity_config(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
2796    let provider = ProviderKind::Antigravity;
2797    let slot = provider_slot(provider);
2798    let original_config = store.config.clone();
2799    let prior_secret = secrets.get(slot).map_err(|error| {
2800        anyhow!(
2801            "could not snapshot the Codewhale-owned legacy {slot} secret slot before clearing it: {error}; config was not changed"
2802        )
2803    })?;
2804
2805    store.config.providers.antigravity = Default::default();
2806    store
2807        .config
2808        .fallback_providers
2809        .retain(|fallback| *fallback != provider);
2810    if store.config.provider == provider {
2811        store.config.provider = ProviderKind::default();
2812        store.config.selected_provider_id = None;
2813    }
2814
2815    if let Err(error) = secrets.delete(slot) {
2816        store.config = original_config;
2817        return Err(anyhow!(
2818            "could not clear the Codewhale-owned legacy {slot} secret slot: {error}; config was not changed"
2819        ));
2820    }
2821
2822    if let Err(error) = store.save() {
2823        store.config = original_config;
2824        if let Some(previous) = prior_secret {
2825            let current = secrets.get(slot).map_err(|rollback| {
2826                anyhow!(
2827                    "{error}; additionally could not verify rollback of the Codewhale-owned legacy {slot} secret slot: {rollback}"
2828                )
2829            })?;
2830            match current {
2831                None => secrets.set(slot, &previous).map_err(|rollback| {
2832                    anyhow!(
2833                        "{error}; additionally failed to restore the Codewhale-owned legacy {slot} secret slot: {rollback}"
2834                    )
2835                })?,
2836                Some(current) if current == previous => {}
2837                Some(_) => {
2838                    return Err(anyhow!(
2839                        "{error}; additionally the Codewhale-owned legacy {slot} secret slot changed concurrently and was not overwritten during rollback"
2840                    ));
2841                }
2842            }
2843        }
2844        return Err(error);
2845    }
2846
2847    codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
2848    codewhale_config::scrub_legacy_antigravity_from_config_backup(store.path())?;
2849    println!(
2850        "cleared Codewhale-owned legacy Antigravity config, consent, selection, fallback entries, and secret-store slot; Google and Antigravity sessions were not read, revoked, or changed. For Gemini, configure provider google and set GEMINI_API_KEY"
2851    );
2852    Ok(())
2853}
2854
2855fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
2856    store.config.providers.for_provider_mut(provider).api_key = None;
2857    if provider == ProviderKind::Deepseek {
2858        store.config.api_key = None;
2859    }
2860}
2861
2862fn provider_env_set(provider: ProviderKind) -> bool {
2863    provider_env_value(provider).is_some()
2864}
2865
2866fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
2867    provider.provider().env_vars()
2868}
2869
2870fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
2871    provider_env_vars(provider).iter().find_map(|var| {
2872        std::env::var(var)
2873            .ok()
2874            .filter(|value| !value.trim().is_empty())
2875            .map(|value| (*var, value))
2876    })
2877}
2878
2879fn openai_codex_auth_file_path() -> PathBuf {
2880    if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
2881        let path = PathBuf::from(path);
2882        if !path.as_os_str().is_empty() {
2883            return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2884        }
2885    }
2886
2887    let codex_home = std::env::var("CODEX_HOME")
2888        .map(PathBuf::from)
2889        .unwrap_or_else(|_| {
2890            dirs::home_dir()
2891                .unwrap_or_else(|| PathBuf::from("."))
2892                .join(".codex")
2893        });
2894    let path = codex_home.join("auth.json");
2895    codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2896}
2897
2898fn grok_auth_file_path() -> PathBuf {
2899    for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
2900        if let Ok(path) = std::env::var(key) {
2901            let path = PathBuf::from(path.trim());
2902            if !path.as_os_str().is_empty() {
2903                return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2904            }
2905        }
2906    }
2907    if let Ok(home) = std::env::var("GROK_HOME") {
2908        let home = PathBuf::from(home.trim());
2909        if !home.as_os_str().is_empty() {
2910            let path = home.join("auth.json");
2911            return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2912        }
2913    }
2914    let path = dirs::home_dir()
2915        .unwrap_or_else(|| PathBuf::from("."))
2916        .join(".grok")
2917        .join("auth.json");
2918    codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2919}
2920
2921fn external_credential_target(
2922    provider: ProviderKind,
2923    path_override: Option<PathBuf>,
2924) -> Result<(codewhale_config::ExternalCredentialSource, PathBuf)> {
2925    let (source, default_path) = match provider {
2926        ProviderKind::OpenaiCodex => (
2927            codewhale_config::ExternalCredentialSource::CodexCli,
2928            openai_codex_auth_file_path(),
2929        ),
2930        ProviderKind::Xai => (
2931            codewhale_config::ExternalCredentialSource::GrokCli,
2932            grok_auth_file_path(),
2933        ),
2934        ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => (
2935            codewhale_config::ExternalCredentialSource::DshCli,
2936            codewhale_config::default_dsh_credentials_path(),
2937        ),
2938        ProviderKind::Moonshot => bail!(
2939            "Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported."
2940        ),
2941        _ => bail!(
2942            "{} has no supported external CLI credential source",
2943            provider.as_str()
2944        ),
2945    };
2946    let path =
2947        codewhale_config::resolve_external_credential_path(path_override.unwrap_or(default_path))?;
2948    Ok((source, path))
2949}
2950
2951fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
2952    let slot = store
2953        .config
2954        .providers
2955        .for_provider(provider)
2956        .api_key
2957        .as_deref();
2958    let root = (provider == ProviderKind::Deepseek)
2959        .then_some(store.config.api_key.as_deref())
2960        .flatten();
2961    slot.or(root)
2962        .filter(|value| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal)
2963}
2964
2965fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
2966    provider_config_api_key(store, provider).is_some()
2967}
2968
2969fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
2970    secrets
2971        .get(provider_slot(provider))
2972        .ok()
2973        .flatten()
2974        .filter(|v| !v.trim().is_empty())
2975}
2976
2977fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
2978    provider_keyring_api_key(secrets, provider).is_some()
2979}
2980
2981fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
2982    let _ = secrets.delete(provider_slot(provider));
2983}
2984
2985/// Delete the keyring credential of every provider that has one stored.
2986///
2987/// Returns a human-readable entry per slot whose deletion failed, so the
2988/// caller can report the failure instead of claiming a clean logout while
2989/// credentials linger in the keyring. Slots shared by several providers
2990/// (e.g. the historical `siliconflow` slot) are deleted once.
2991fn clear_all_provider_api_keys_from_keyring(secrets: &Secrets) -> Vec<String> {
2992    let mut failures = Vec::new();
2993    let mut cleared_slots = std::collections::HashSet::new();
2994    for provider in ProviderKind::ALL {
2995        let slot = provider_slot(provider);
2996        if !cleared_slots.insert(slot) {
2997            continue;
2998        }
2999        if !provider_keyring_set(secrets, provider) {
3000            continue;
3001        }
3002        if let Err(error) = secrets.delete(slot) {
3003            failures.push(format!("{slot}: {error}"));
3004        }
3005    }
3006    failures
3007}
3008
3009fn external_consent(
3010    store: &ConfigStore,
3011    provider: ProviderKind,
3012) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
3013    store
3014        .config
3015        .providers
3016        .for_provider(provider)
3017        .external_credentials
3018        .as_ref()
3019}
3020
3021fn external_read_consent(
3022    store: &ConfigStore,
3023    provider: ProviderKind,
3024) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
3025    let (source, expected_path) = external_credential_target(provider, None).ok()?;
3026    external_consent(store, provider)
3027        .filter(|consent| consent.read_grant(provider, source, &expected_path).is_ok())
3028}
3029
3030fn external_oauth_selected(store: &ConfigStore, provider: ProviderKind) -> bool {
3031    if external_read_consent(store, provider).is_none() {
3032        return false;
3033    }
3034    if provider == ProviderKind::OpenaiCodex {
3035        return true;
3036    }
3037    provider == ProviderKind::Xai
3038        && xai_oauth_mode_selected(store.config.providers.xai.auth_mode.as_deref())
3039}
3040
3041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3042enum XaiOAuthGenerationPointer {
3043    Absent,
3044    Valid,
3045    Invalid,
3046}
3047
3048#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3049enum XaiAuthDiagnosticRoute {
3050    /// Normal API-key diagnostics apply. This includes custom endpoints, where
3051    /// xAI OAuth is intentionally inactive.
3052    ApiKey,
3053    /// A syntactically valid Codewhale-owned generation pointer selects the
3054    /// owned OAuth route. Diagnostics deliberately do not inspect the file.
3055    OwnedOAuth,
3056    /// A configured but unsafe/malformed generation pointer blocks external
3057    /// Grok CLI access. The runtime can still fall back to API-key sources.
3058    NeedsRepair,
3059    /// With no configured generation, an exact read-only Grok CLI consent can
3060    /// be selected structurally. The external file is never probed here.
3061    ExternalConsent,
3062}
3063
3064#[derive(Debug, Clone)]
3065struct XaiAuthDiagnostics {
3066    base_url: String,
3067    official_endpoint: bool,
3068    auth_mode: Option<String>,
3069    oauth_selected: bool,
3070    generation: XaiOAuthGenerationPointer,
3071    route: XaiAuthDiagnosticRoute,
3072}
3073
3074impl XaiAuthDiagnostics {
3075    /// API-key routes are reported from the same endpoint-bound resolver that
3076    /// dispatch uses. Owned OAuth and consent-only routes remain structural so
3077    /// diagnostics cannot turn into a credential-store probe.
3078    fn evaluates_runtime_api_key(&self) -> bool {
3079        matches!(
3080            self.route,
3081            XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::NeedsRepair
3082        )
3083    }
3084
3085    fn is_custom_endpoint(&self) -> bool {
3086        !self.official_endpoint
3087    }
3088}
3089
3090/// Source and redacted tail from the shared runtime resolver. Keeping only a
3091/// redacted tail prevents the presentation layer from accidentally retaining a
3092/// plaintext credential after it has derived the effective route.
3093#[derive(Debug, Clone, Default)]
3094struct XaiRuntimeApiKey {
3095    source: Option<RuntimeApiKeySource>,
3096    last4: Option<String>,
3097}
3098
3099impl XaiRuntimeApiKey {
3100    fn source_name(&self) -> Option<&'static str> {
3101        match self.source {
3102            Some(RuntimeApiKeySource::Cli) => Some("cli"),
3103            Some(RuntimeApiKeySource::ConfigFile) => Some("config"),
3104            Some(RuntimeApiKeySource::Keyring) => Some("secret store"),
3105            Some(RuntimeApiKeySource::Env) => Some("env"),
3106            None => None,
3107        }
3108    }
3109
3110    fn source_with_last4(&self) -> Option<String> {
3111        self.source_name()
3112            .map(|source| match self.last4.as_deref() {
3113                Some(last4) => format!("{source} (last4: {last4})"),
3114                None => source.to_string(),
3115            })
3116    }
3117
3118    fn uses(&self, source: RuntimeApiKeySource) -> bool {
3119        self.source == Some(source)
3120    }
3121}
3122
3123fn runtime_overrides_for_provider(
3124    runtime_overrides: &CliRuntimeOverrides,
3125    provider: ProviderKind,
3126) -> CliRuntimeOverrides {
3127    let mut overrides = runtime_overrides.clone();
3128    overrides.provider = Some(provider);
3129    overrides
3130}
3131
3132fn xai_oauth_mode_selected(auth_mode: Option<&str>) -> bool {
3133    auth_mode.is_some_and(|mode| {
3134        matches!(
3135            mode.trim()
3136                .to_ascii_lowercase()
3137                .replace(['-', ' '], "_")
3138                .as_str(),
3139            "oauth"
3140                | "xai_oauth"
3141                | "xai"
3142                | "grok"
3143                | "grok_oauth"
3144                | "grok_cli"
3145                | "device"
3146                | "device_code"
3147                | "device_auth"
3148        )
3149    })
3150}
3151
3152fn xai_oauth_generation_pointer(store: &ConfigStore) -> XaiOAuthGenerationPointer {
3153    match store
3154        .config
3155        .providers
3156        .xai
3157        .oauth_credential_generation
3158        .as_deref()
3159    {
3160        None => XaiOAuthGenerationPointer::Absent,
3161        Some(generation) if codewhale_config::is_valid_xai_oauth_generation(generation) => {
3162            XaiOAuthGenerationPointer::Valid
3163        }
3164        Some(_) => XaiOAuthGenerationPointer::Invalid,
3165    }
3166}
3167
3168/// Resolve the same xAI route facts the runtime uses, without asking the
3169/// durable credential store for a secret. `ConfigToml::resolve_runtime_options`
3170/// deliberately uses an in-memory store, so this is safe for diagnostic output
3171/// that must remain structural/non-probing.
3172fn xai_auth_diagnostics(
3173    store: &ConfigStore,
3174    runtime_overrides: &CliRuntimeOverrides,
3175) -> XaiAuthDiagnostics {
3176    // We only need the effective endpoint here. Suppressing API-key
3177    // resolution keeps valid-owned and consent-only diagnostics structural:
3178    // they must not read ambient credential state merely to describe a route.
3179    let mut route_overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai);
3180    route_overrides.api_key = None;
3181    route_overrides.auth_mode = Some("none".to_string());
3182    let resolved = store.config.resolve_runtime_options(&route_overrides);
3183    let official_endpoint =
3184        provider_base_url_is_official(ProviderKind::Xai, resolved.base_url.as_str());
3185    // The TUI activates xAI OAuth only from `[providers.xai] auth_mode`; a
3186    // root-level auth mode may influence generic API-key policy but must never
3187    // turn an inert xAI generation pointer into an OAuth route.
3188    let auth_mode = store.config.providers.xai.auth_mode.clone();
3189    let generation = xai_oauth_generation_pointer(store);
3190    let oauth_selected = xai_oauth_mode_selected(auth_mode.as_deref());
3191    let route = if !official_endpoint || !oauth_selected {
3192        XaiAuthDiagnosticRoute::ApiKey
3193    } else {
3194        match generation {
3195            XaiOAuthGenerationPointer::Valid => XaiAuthDiagnosticRoute::OwnedOAuth,
3196            XaiOAuthGenerationPointer::Invalid => XaiAuthDiagnosticRoute::NeedsRepair,
3197            XaiOAuthGenerationPointer::Absent
3198                if external_read_consent(store, ProviderKind::Xai).is_some() =>
3199            {
3200                XaiAuthDiagnosticRoute::ExternalConsent
3201            }
3202            XaiOAuthGenerationPointer::Absent => XaiAuthDiagnosticRoute::ApiKey,
3203        }
3204    };
3205
3206    XaiAuthDiagnostics {
3207        base_url: resolved.base_url,
3208        official_endpoint,
3209        auth_mode,
3210        oauth_selected,
3211        generation,
3212        route,
3213    }
3214}
3215
3216/// Return the API-key route exactly as the dispatcher would resolve it. This
3217/// is the critical distinction for a global `--base-url` or `XAI_BASE_URL`:
3218/// official-provider config, keyring, and ambient keys must not cross onto an
3219/// unrelated custom endpoint.
3220fn xai_runtime_api_key(
3221    store: &ConfigStore,
3222    secrets: &Secrets,
3223    runtime_overrides: &CliRuntimeOverrides,
3224) -> XaiRuntimeApiKey {
3225    let resolved = store.config.resolve_runtime_options_with_secrets(
3226        &runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
3227        secrets,
3228    );
3229    debug_assert_eq!(resolved.provider, ProviderKind::Xai);
3230    XaiRuntimeApiKey {
3231        source: resolved.api_key_source,
3232        last4: resolved.api_key.as_deref().map(last4_label),
3233    }
3234}
3235
3236fn api_key_source_name(
3237    config_key: Option<&str>,
3238    keyring_key: Option<&str>,
3239    env_key: Option<&(&'static str, String)>,
3240) -> Option<&'static str> {
3241    if config_key.is_some() {
3242        Some("config")
3243    } else if keyring_key.is_some() {
3244        Some("secret store")
3245    } else if env_key.is_some() {
3246        Some("env")
3247    } else {
3248        None
3249    }
3250}
3251
3252fn xai_status_summary_source(
3253    diagnostics: &XaiAuthDiagnostics,
3254    api_key: Option<&XaiRuntimeApiKey>,
3255) -> String {
3256    match diagnostics.route {
3257        XaiAuthDiagnosticRoute::OwnedOAuth => {
3258            "Codewhale-owned OAuth configured/unprobed (valid generation pointer)".to_string()
3259        }
3260        XaiAuthDiagnosticRoute::NeedsRepair => {
3261            let api_key = api_key
3262                .and_then(XaiRuntimeApiKey::source_name)
3263                .unwrap_or("no runtime-effective API key");
3264            format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
3265        }
3266        XaiAuthDiagnosticRoute::ExternalConsent => {
3267            "external consent configured/unprobed".to_string()
3268        }
3269        XaiAuthDiagnosticRoute::ApiKey => api_key
3270            .and_then(XaiRuntimeApiKey::source_name)
3271            .unwrap_or("unset")
3272            .to_string(),
3273    }
3274}
3275
3276fn xai_credential_route_label(
3277    diagnostics: &XaiAuthDiagnostics,
3278    api_key: Option<&XaiRuntimeApiKey>,
3279) -> String {
3280    match diagnostics.route {
3281        XaiAuthDiagnosticRoute::OwnedOAuth => {
3282            "Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
3283                .to_string()
3284        }
3285        XaiAuthDiagnosticRoute::NeedsRepair => {
3286            let api_key = api_key
3287                .and_then(XaiRuntimeApiKey::source_with_last4)
3288                .unwrap_or_else(|| "no runtime-effective API key".to_string());
3289            format!(
3290                "xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
3291            )
3292        }
3293        XaiAuthDiagnosticRoute::ExternalConsent => {
3294            "external read-only consent configured/unprobed".to_string()
3295        }
3296        XaiAuthDiagnosticRoute::ApiKey => api_key
3297            .and_then(XaiRuntimeApiKey::source_with_last4)
3298            .unwrap_or_else(|| "missing".to_string()),
3299    }
3300}
3301
3302fn xai_table_storage_status(
3303    api_key: Option<&XaiRuntimeApiKey>,
3304    source: RuntimeApiKeySource,
3305) -> &'static str {
3306    match api_key {
3307        Some(api_key) if api_key.uses(source) => "set",
3308        Some(_) => "-",
3309        // The selected structural OAuth/consent route intentionally does not
3310        // establish whether any API-key storage is populated.
3311        None => "unprobed",
3312    }
3313}
3314
3315fn xai_list_storage_status(
3316    api_key: Option<&XaiRuntimeApiKey>,
3317    source: RuntimeApiKeySource,
3318) -> &'static str {
3319    match api_key {
3320        Some(api_key) if api_key.uses(source) => "yes",
3321        Some(_) => "no",
3322        None => "?",
3323    }
3324}
3325
3326fn xai_list_route(
3327    diagnostics: &XaiAuthDiagnostics,
3328    api_key: Option<&XaiRuntimeApiKey>,
3329) -> &'static str {
3330    match diagnostics.route {
3331        XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
3332        XaiAuthDiagnosticRoute::NeedsRepair => "needs-repair",
3333        XaiAuthDiagnosticRoute::ExternalConsent => "external-consent-configured",
3334        XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(|api_key| api_key.source) {
3335            Some(RuntimeApiKeySource::Cli) => "cli",
3336            Some(RuntimeApiKeySource::ConfigFile) => "config",
3337            Some(RuntimeApiKeySource::Keyring) => "store",
3338            Some(RuntimeApiKeySource::Env) => "env",
3339            None => "missing",
3340        },
3341    }
3342}
3343
3344fn xai_storage_detail(
3345    diagnostics: &XaiAuthDiagnostics,
3346    api_key: Option<&XaiRuntimeApiKey>,
3347    source: RuntimeApiKeySource,
3348) -> String {
3349    match api_key {
3350        Some(api_key) if api_key.uses(source) => api_key
3351            .last4
3352            .as_deref()
3353            .map(|last4| format!("runtime-effective, last4: {last4}"))
3354            .unwrap_or_else(|| "runtime-effective".to_string()),
3355        Some(_) if diagnostics.is_custom_endpoint() => {
3356            "not eligible for this custom xAI endpoint".to_string()
3357        }
3358        Some(_) => "not selected by the runtime resolver".to_string(),
3359        None if diagnostics.evaluates_runtime_api_key() && diagnostics.is_custom_endpoint() => {
3360            "not eligible for this custom xAI endpoint".to_string()
3361        }
3362        None if diagnostics.evaluates_runtime_api_key() => {
3363            "not set for this runtime route".to_string()
3364        }
3365        None => "unprobed (structural OAuth/consent route)".to_string(),
3366    }
3367}
3368
3369fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
3370    match diagnostics.route {
3371        XaiAuthDiagnosticRoute::OwnedOAuth => {
3372            "lookup order: configured Codewhale-owned OAuth generation (storage unprobed); Grok CLI consent blocked".to_string()
3373        }
3374        XaiAuthDiagnosticRoute::NeedsRepair => {
3375            "lookup order: invalid Codewhale-owned OAuth generation blocks Grok CLI consent; runtime-effective API-key fallback: CLI -> config -> secret store -> env".to_string()
3376        }
3377        XaiAuthDiagnosticRoute::ExternalConsent => {
3378            "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)".to_string()
3379        }
3380        XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
3381            "lookup order: endpoint-bound API key only for this custom xAI endpoint (explicit CLI key or route-bound config key)".to_string()
3382        }
3383        XaiAuthDiagnosticRoute::ApiKey => {
3384            "lookup order: CLI -> config -> secret store -> env".to_string()
3385        }
3386    }
3387}
3388
3389fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
3390    match diagnostics.route {
3391        XaiAuthDiagnosticRoute::OwnedOAuth => {
3392            "xai: configured (source: Codewhale-owned OAuth generation; valid pointer; storage unprobed)".to_string()
3393        }
3394        XaiAuthDiagnosticRoute::NeedsRepair => {
3395            let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
3396                Some("config") => "config-file".to_string(),
3397                Some("secret store") => "secret-store".to_string(),
3398                Some("env") => "env".to_string(),
3399                Some("cli") => "cli".to_string(),
3400                Some(other) => other.to_string(),
3401                None => "no runtime-effective API key".to_string(),
3402            };
3403            format!(
3404                "xai: needs repair (invalid Codewhale-owned OAuth generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
3405            )
3406        }
3407        XaiAuthDiagnosticRoute::ExternalConsent => {
3408            "xai: configured (source: external read-only consent; availability unprobed)".to_string()
3409        }
3410        XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
3411                Some("config") => "xai: set (source: config-file)".to_string(),
3412                Some("secret store") => "xai: set (source: secret-store)".to_string(),
3413                Some("env") => "xai: set (source: env)".to_string(),
3414                Some("cli") => "xai: set (source: cli)".to_string(),
3415                Some(other) => format!("xai: set (source: {other})"),
3416                None => "xai: not set".to_string(),
3417            },
3418    }
3419}
3420
3421fn auth_get_line_with_runtime(
3422    store: &ConfigStore,
3423    secrets: &Secrets,
3424    provider: ProviderKind,
3425    runtime_overrides: &CliRuntimeOverrides,
3426) -> String {
3427    let slot = provider_slot(provider);
3428    if provider == ProviderKind::Xai {
3429        let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3430        let api_key = diagnostics
3431            .evaluates_runtime_api_key()
3432            .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3433        return xai_get_line(&diagnostics, api_key.as_ref());
3434    }
3435
3436    let config_key = provider_config_api_key(store, provider);
3437    let keyring_key = config_key
3438        .is_none()
3439        .then(|| provider_keyring_api_key(secrets, provider))
3440        .flatten();
3441    let env_key = provider_env_value(provider);
3442
3443    match api_key_source_name(config_key, keyring_key.as_deref(), env_key.as_ref()) {
3444        Some("config") => format!("{slot}: set (source: config-file)"),
3445        Some("secret store") => format!("{slot}: set (source: secret-store)"),
3446        Some("env") => format!("{slot}: set (source: env)"),
3447        Some(other) => format!("{slot}: set (source: {other})"),
3448        None => format!("{slot}: not set"),
3449    }
3450}
3451
3452#[cfg(test)]
3453fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3454    auth_status_all_providers_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3455}
3456
3457fn auth_status_all_providers_with_runtime(
3458    store: &ConfigStore,
3459    secrets: &Secrets,
3460    runtime_overrides: &CliRuntimeOverrides,
3461) -> Vec<String> {
3462    let active_provider = store.config.provider;
3463    let mut lines = Vec::new();
3464    lines.push(account_status_line());
3465    lines.push(String::new());
3466    lines.push(format!(
3467        "active provider: {} (set via config or CODEWHALE_PROVIDER)",
3468        active_provider.as_str()
3469    ));
3470    lines.push(String::new());
3471    lines.push(format!(
3472        "{:<14} {:<8} {:<10} {:<8} {}",
3473        "provider", "config", "keyring", "env", "status"
3474    ));
3475    lines.push("-".repeat(70));
3476
3477    for provider in ProviderKind::ALL {
3478        if provider == ProviderKind::Xai {
3479            let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3480            let api_key = diagnostics
3481                .evaluates_runtime_api_key()
3482                .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3483            let active_marker = if provider == active_provider {
3484                " *"
3485            } else {
3486                ""
3487            };
3488            lines.push(format!(
3489                "{:<14} {:<8} {:<10} {:<8} {}{}",
3490                provider.as_str(),
3491                xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3492                xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3493                xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3494                xai_status_summary_source(&diagnostics, api_key.as_ref()),
3495                active_marker
3496            ));
3497            continue;
3498        }
3499
3500        let config_key = provider_config_api_key(store, provider);
3501        let keyring_key = provider_keyring_api_key(secrets, provider);
3502        let env_key = provider_env_value(provider);
3503        let external_selected = external_oauth_selected(store, provider);
3504
3505        let config_status = config_key.map(|_| "set").unwrap_or("-");
3506        let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
3507        let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
3508
3509        let source = if provider == ProviderKind::OpenaiCodex {
3510            // Keep the summary consistent with `auth status`: Codex auth is
3511            // OAuth-file (or env token) based — config/keyring keys are not
3512            // consulted for it.
3513            if env_key.is_some() {
3514                "env".to_string()
3515            } else if external_selected {
3516                "external consent (not probed)".to_string()
3517            } else {
3518                "unset".to_string()
3519            }
3520        } else if external_selected {
3521            "external consent (not probed)".to_string()
3522        } else if config_key.is_some() {
3523            "config".to_string()
3524        } else if keyring_key.is_some() {
3525            "keyring".to_string()
3526        } else if env_key.is_some() {
3527            "env".to_string()
3528        } else {
3529            "unset".to_string()
3530        };
3531
3532        let active_marker = if provider == active_provider {
3533            " *"
3534        } else {
3535            ""
3536        };
3537
3538        lines.push(format!(
3539            "{:<14} {:<8} {:<10} {:<8} {}{}",
3540            provider.as_str(),
3541            config_status,
3542            keyring_status,
3543            env_status,
3544            source,
3545            active_marker
3546        ));
3547    }
3548
3549    lines.push(String::new());
3550    lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
3551    lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
3552    lines.push("Account sign-in is `codewhale login`.".to_string());
3553    lines
3554}
3555
3556fn account_status_line() -> String {
3557    use codewhale_secrets::account::{
3558        ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
3559        secure_account_session_secrets,
3560    };
3561    let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
3562        .ok()
3563        .map(|value| value.trim().trim_end_matches('/').to_string())
3564        .filter(|value| !value.is_empty())
3565        .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
3566    match secure_account_session_secrets() {
3567        Ok(secrets) => {
3568            match AccountSessionStore::new(secrets, None, &api_base)
3569                .runtime_info_at(chrono::Utc::now())
3570            {
3571                Ok(info) => {
3572                    let state = match info.state {
3573                        AccountSessionState::SignedOut => "not signed in",
3574                        AccountSessionState::Authenticated => "signed in",
3575                        AccountSessionState::OfflineCached => "offline (cached)",
3576                        AccountSessionState::Expired => "expired",
3577                        AccountSessionState::Revoked => "revoked",
3578                    };
3579                    format!("account: {state} (api {api_base})")
3580                }
3581                Err(error) => format!("account: unavailable ({error})"),
3582            }
3583        }
3584        Err(error) => format!("account: unavailable ({error})"),
3585    }
3586}
3587
3588fn diagnostic_path_state(path: &Path, directory: bool) -> &'static str {
3589    match std::fs::symlink_metadata(path) {
3590        Ok(metadata) if metadata.file_type().is_symlink() => "present (symlink; not followed)",
3591        Ok(metadata) if directory && metadata.is_dir() => "present",
3592        Ok(metadata) if !directory && metadata.is_file() => "present",
3593        Ok(_) => "present (unexpected type)",
3594        Err(error) if error.kind() == std::io::ErrorKind::NotFound => "missing",
3595        Err(_) => "unknown",
3596    }
3597}
3598
3599const fn secret_backend_kind_label(
3600    kind: codewhale_secrets::SecretBackendDiagnosticKind,
3601) -> &'static str {
3602    match kind {
3603        codewhale_secrets::SecretBackendDiagnosticKind::File => "file",
3604        codewhale_secrets::SecretBackendDiagnosticKind::System => "system",
3605        codewhale_secrets::SecretBackendDiagnosticKind::Unknown => "unknown",
3606    }
3607}
3608
3609const fn secret_backend_inspection_label(
3610    inspection: codewhale_secrets::SecretBackendInspection,
3611) -> &'static str {
3612    match inspection {
3613        codewhale_secrets::SecretBackendInspection::MetadataOnly => "metadata_only",
3614        codewhale_secrets::SecretBackendInspection::NotProbed => "not_probed",
3615    }
3616}
3617
3618const fn secret_backend_presence_label(
3619    presence: codewhale_secrets::SecretBackendPresence,
3620) -> &'static str {
3621    match presence {
3622        codewhale_secrets::SecretBackendPresence::Present => "present",
3623        codewhale_secrets::SecretBackendPresence::Absent => "missing",
3624        codewhale_secrets::SecretBackendPresence::Unknown => "unknown",
3625    }
3626}
3627
3628/// Value-free home and credential-source report for `auth status --diagnostic`.
3629///
3630/// Unlike ordinary `auth status`, this path never constructs [`Secrets`] and
3631/// never asks a provider keyring for a value. File presence comes from metadata
3632/// only; provider environment variables are checked with the runtime's
3633/// non-empty-string semantics and their contents are never formatted.
3634fn auth_diagnostic_lines(store: &ConfigStore, provider: Option<ProviderKind>) -> Vec<String> {
3635    let explicit_home = codewhale_paths::codewhale_home_is_explicit();
3636    let resolved_home = codewhale_paths::codewhale_home();
3637    let mut lines = vec![
3638        "auth diagnostic (structural only; credential values are never printed and provider credential stores were not opened)".to_string(),
3639        String::new(),
3640    ];
3641
3642    let home = match resolved_home {
3643        Ok(Some(path)) => {
3644            lines.push(format!(
3645                "codewhale home: {} (source: {}; state: {})",
3646                codewhale_config::quote_os_path(&path),
3647                if explicit_home {
3648                    "CODEWHALE_HOME (isolated)"
3649                } else {
3650                    "platform home"
3651                },
3652                diagnostic_path_state(&path, true),
3653            ));
3654            Some(path)
3655        }
3656        Ok(None) => {
3657            lines.push("codewhale home: unavailable (no user home resolved)".to_string());
3658            None
3659        }
3660        Err(error) => {
3661            lines.push(format!("codewhale home: unavailable ({error})"));
3662            None
3663        }
3664    };
3665
3666    lines.push(format!(
3667        "config: {} ({})",
3668        codewhale_config::quote_os_path(store.path()),
3669        diagnostic_path_state(store.path(), false),
3670    ));
3671    if let Some(home) = home.as_ref() {
3672        let settings = home.join("settings.toml");
3673        lines.push(format!(
3674            "settings: {} ({})",
3675            codewhale_config::quote_os_path(&settings),
3676            diagnostic_path_state(&settings, false),
3677        ));
3678    } else {
3679        lines.push("settings: unavailable (Codewhale home unresolved)".to_string());
3680    }
3681
3682    let backend = codewhale_secrets::diagnose_secret_backend();
3683    lines.push(format!(
3684        "secret backend: {} (inspection: {})",
3685        secret_backend_kind_label(backend.backend),
3686        secret_backend_inspection_label(backend.inspection),
3687    ));
3688    if let Some(path) = backend.path.as_ref() {
3689        lines.push(format!(
3690            "secret store: {} ({})",
3691            codewhale_config::quote_os_path(path),
3692            secret_backend_presence_label(backend.presence),
3693        ));
3694    } else {
3695        lines.push(format!(
3696            "secret store: unavailable ({})",
3697            secret_backend_presence_label(backend.presence),
3698        ));
3699    }
3700    if let Some(path) = backend.legacy_path.as_ref() {
3701        lines.push(format!(
3702            "legacy secret store: {} ({})",
3703            codewhale_config::quote_os_path(path),
3704            secret_backend_presence_label(backend.legacy_presence),
3705        ));
3706    } else if explicit_home {
3707        lines.push(
3708            "legacy secret store: suppressed by explicit CODEWHALE_HOME isolation".to_string(),
3709        );
3710    } else {
3711        lines.push("legacy secret store: unavailable (not probed)".to_string());
3712    }
3713
3714    lines.push(String::new());
3715    // Diagnostic mode answers "which sources will this shell use?" for one
3716    // route. Ordinary `auth status` remains the all-provider inventory; a
3717    // different provider can be inspected explicitly with `--provider`.
3718    let providers = [provider.unwrap_or(store.config.provider)];
3719    for provider in providers {
3720        let config_present = provider_config_api_key(store, provider).is_some();
3721        let environment_present = provider_env_vars(provider)
3722            .iter()
3723            .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
3724        let environment_names = match provider_env_vars(provider) {
3725            [] => "none configured".to_string(),
3726            names => names.join("/"),
3727        };
3728        let external_configured = external_consent(store, provider).is_some();
3729        lines.push(format!(
3730            "provider {} sources: config_literal={}, secret_backend={} (provider entry unprobed), environment={} ({}), external_consent={}",
3731            provider.as_str(),
3732            if config_present { "present" } else { "missing" },
3733            secret_backend_presence_label(backend.presence),
3734            if environment_present { "present" } else { "missing" },
3735            environment_names,
3736            if external_configured {
3737                "configured"
3738            } else {
3739                "missing"
3740            },
3741        ));
3742    }
3743    lines
3744}
3745
3746fn run_auth_diagnostic(store: &ConfigStore, provider: Option<ProviderKind>) -> Result<()> {
3747    for line in auth_diagnostic_lines(store, provider) {
3748        println!("{line}");
3749    }
3750    Ok(())
3751}
3752
3753#[cfg(test)]
3754fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3755    auth_list_lines_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3756}
3757
3758fn auth_list_lines_with_runtime(
3759    store: &ConfigStore,
3760    secrets: &Secrets,
3761    runtime_overrides: &CliRuntimeOverrides,
3762) -> Vec<String> {
3763    let mut lines = Vec::new();
3764    lines.push("provider     config store env  route".to_string());
3765    for provider in ProviderKind::ALL {
3766        // Label the row by the provider, not by its credential slot. This
3767        // table has one row per ProviderKind, but several kinds share a slot
3768        // (ProviderKind::secret_store_slot): SiliconflowCN shares
3769        // `siliconflow`, and the four Model Studio variants share
3770        // `modelstudio-token-plan`. Labelling by slot printed `siliconflow`
3771        // twice and `modelstudio-token-plan` four times, so the reader could
3772        // not tell which row was which provider. The status columns still
3773        // read the shared slot, which is what makes one saved key light up
3774        // the whole family.
3775        let label = provider.as_str();
3776        if provider == ProviderKind::Xai {
3777            let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3778            let api_key = diagnostics
3779                .evaluates_runtime_api_key()
3780                .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3781            lines.push(format!(
3782                "{label:<12}  {}     {}      {}   {}",
3783                xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3784                xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3785                xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3786                xai_list_route(&diagnostics, api_key.as_ref())
3787            ));
3788            continue;
3789        }
3790
3791        let file = provider_config_set(store, provider);
3792        let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
3793        let env = provider_env_set(provider);
3794        let external_selected = external_oauth_selected(store, provider);
3795        let active = if provider == ProviderKind::OpenaiCodex {
3796            if env {
3797                "env"
3798            } else if external_selected {
3799                "external-consent"
3800            } else {
3801                "missing"
3802            }
3803        } else if external_selected {
3804            "external-consent"
3805        } else if file {
3806            "config"
3807        } else if keyring == Some(true) {
3808            "store"
3809        } else if env {
3810            "env"
3811        } else {
3812            "missing"
3813        };
3814        lines.push(format!(
3815            "{label:<12}  {}     {}      {}   {active}",
3816            yes_no(file),
3817            keyring_status_short(keyring),
3818            yes_no(env)
3819        ));
3820    }
3821    lines
3822}
3823
3824#[cfg(test)]
3825fn auth_status_lines_for_provider(
3826    store: &ConfigStore,
3827    secrets: &Secrets,
3828    provider: ProviderKind,
3829) -> Vec<String> {
3830    auth_status_lines_for_provider_with_runtime(
3831        store,
3832        secrets,
3833        provider,
3834        &CliRuntimeOverrides::default(),
3835    )
3836}
3837
3838fn auth_status_lines_for_provider_with_runtime(
3839    store: &ConfigStore,
3840    secrets: &Secrets,
3841    provider: ProviderKind,
3842    runtime_overrides: &CliRuntimeOverrides,
3843) -> Vec<String> {
3844    if provider == ProviderKind::Xai {
3845        return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
3846    }
3847
3848    let config_key = provider_config_api_key(store, provider);
3849    let keyring_key = provider_keyring_api_key(secrets, provider);
3850    let env_key = provider_env_value(provider);
3851    let external = external_consent(store, provider);
3852    let external_selected = external_oauth_selected(store, provider);
3853
3854    let active_label = {
3855        let active_source = if provider == ProviderKind::OpenaiCodex {
3856            if env_key.is_some() {
3857                "env"
3858            } else if store
3859                .config
3860                .providers
3861                .openai_codex
3862                .oauth_credential_generation
3863                .as_deref()
3864                .is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
3865            {
3866                "codewhale-owned ChatGPT sign-in (availability not probed)"
3867            } else if external_selected {
3868                "external read-only consent (availability not probed)"
3869            } else {
3870                "missing"
3871            }
3872        } else if external_selected {
3873            "external read-only consent (availability not probed)"
3874        } else if config_key.is_some() {
3875            "config"
3876        } else if keyring_key.is_some() {
3877            "secret store"
3878        } else if env_key.is_some() {
3879            "env"
3880        } else {
3881            "missing"
3882        };
3883        let active_last4 = if provider == ProviderKind::OpenaiCodex {
3884            env_key.as_ref().map(|(_, value)| last4_label(value))
3885        } else {
3886            config_key
3887                .map(last4_label)
3888                .or_else(|| keyring_key.as_deref().map(last4_label))
3889                .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
3890        };
3891        active_last4
3892            .map(|last4| format!("{active_source} (last4: {last4})"))
3893            .unwrap_or_else(|| active_source.to_string())
3894    };
3895
3896    let env_var_label = env_key
3897        .as_ref()
3898        .map(|(name, _)| (*name).to_string())
3899        .unwrap_or_else(|| provider_env_vars(provider).join("/"));
3900    let env_status = env_key
3901        .as_ref()
3902        .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
3903        .unwrap_or_else(|| "unset".to_string());
3904
3905    let is_active = provider == store.config.provider;
3906    let active_marker = if is_active { " (active provider)" } else { "" };
3907
3908    let provider_cfg = store.config.providers.for_provider(provider);
3909    let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
3910    let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3911
3912    let lookup_order = if provider == ProviderKind::OpenaiCodex {
3913        "lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
3914            .to_string()
3915    } else {
3916        "lookup order: config -> secret store -> env".to_string()
3917    };
3918    let auth_mode = if provider == ProviderKind::OpenaiCodex {
3919        "codex_oauth".to_string()
3920    } else {
3921        provider_cfg
3922            .auth_mode
3923            .as_deref()
3924            .or(store.config.auth_mode.as_deref())
3925            .unwrap_or("api_key")
3926            .to_string()
3927    };
3928
3929    let mut lines = vec![
3930        format!("provider: {}{}", provider.as_str(), active_marker),
3931        format!("route: {}", base_url),
3932        format!("model: {}", model),
3933        format!("auth mode: {auth_mode}"),
3934        format!("active source: {active_label}"),
3935        lookup_order,
3936        format!(
3937            "config file: {} ({})",
3938            codewhale_config::quote_os_path(store.path()),
3939            source_status(config_key, "missing")
3940        ),
3941        format!(
3942            "secret store: {} ({})",
3943            secrets.backend_name(),
3944            source_status(keyring_key.as_deref(), "missing")
3945        ),
3946        format!("env var: {env_var_label} ({env_status})"),
3947    ];
3948
3949    if let Ok((source, expected_path)) = external_credential_target(provider, None) {
3950        let status = codewhale_config::external_credential_consent_status(
3951            external,
3952            provider,
3953            source,
3954            &expected_path,
3955            store.config.provider,
3956        );
3957        lines.push(format!(
3958            "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3959            status.access.as_str(),
3960            status.provider,
3961            status.source.as_str(),
3962            status.owner,
3963            codewhale_config::quote_os_path(&status.path),
3964            status.consent_version,
3965            status.route_state,
3966            status.scope_valid,
3967            status.ambient_path_changed,
3968        ));
3969        lines.push(format!("semantics: {}", status.semantics));
3970        lines.push(format!("revoke: {}", status.revoke_command));
3971        if let Some(warning) = status.ambient_path_warning() {
3972            lines.push(warning);
3973        }
3974    } else {
3975        lines.push("external credentials: disabled (no file was probed)".to_string());
3976    }
3977    lines
3978}
3979
3980fn xai_auth_status_lines_for_provider(
3981    store: &ConfigStore,
3982    secrets: &Secrets,
3983    runtime_overrides: &CliRuntimeOverrides,
3984) -> Vec<String> {
3985    let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3986    let api_key = diagnostics
3987        .evaluates_runtime_api_key()
3988        .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3989    let external = external_consent(store, ProviderKind::Xai);
3990    let selected_marker = if store.config.provider == ProviderKind::Xai {
3991        " (selected provider)"
3992    } else {
3993        ""
3994    };
3995    let provider_cfg = &store.config.providers.xai;
3996    let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3997    let auth_mode = diagnostics.auth_mode.as_deref().unwrap_or("api_key");
3998
3999    let mut lines = vec![
4000        format!("provider: xai{selected_marker}"),
4001        format!("route: {}", diagnostics.base_url),
4002        format!("model: {model}"),
4003        format!("auth mode: {auth_mode}"),
4004        format!(
4005            "credential route: {}",
4006            xai_credential_route_label(&diagnostics, api_key.as_ref())
4007        ),
4008        xai_lookup_order(&diagnostics),
4009        format!(
4010            "config file: {} ({})",
4011            codewhale_config::quote_os_path(store.path()),
4012            xai_storage_detail(
4013                &diagnostics,
4014                api_key.as_ref(),
4015                RuntimeApiKeySource::ConfigFile
4016            )
4017        ),
4018        format!(
4019            "secret store: {} ({})",
4020            secrets.backend_name(),
4021            xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Keyring)
4022        ),
4023        format!(
4024            "env var: {} ({})",
4025            provider_env_vars(ProviderKind::Xai).join("/"),
4026            xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Env)
4027        ),
4028        format!(
4029            "endpoint policy: {}",
4030            if diagnostics.official_endpoint {
4031                "official xAI endpoint"
4032            } else {
4033                "custom xAI endpoint; API-key-only (owned and external OAuth are inactive)"
4034            }
4035        ),
4036    ];
4037
4038    lines.push(match diagnostics.generation {
4039        XaiOAuthGenerationPointer::Absent => "xAI OAuth generation: absent".to_string(),
4040        XaiOAuthGenerationPointer::Valid
4041            if diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth =>
4042        {
4043            "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
4044                .to_string()
4045        }
4046        XaiOAuthGenerationPointer::Valid => {
4047            "xAI OAuth generation: valid but inactive for this route".to_string()
4048        }
4049        XaiOAuthGenerationPointer::Invalid => {
4050            "xAI OAuth generation: invalid Codewhale-owned pointer".to_string()
4051        }
4052    });
4053
4054    match diagnostics.route {
4055        XaiAuthDiagnosticRoute::OwnedOAuth => {
4056            lines.push(
4057                "external credentials: blocked by the configured Codewhale-owned xAI OAuth generation (file not probed)"
4058                    .to_string(),
4059            );
4060            return lines;
4061        }
4062        XaiAuthDiagnosticRoute::NeedsRepair => {
4063            lines.push(
4064                "external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer (file not probed)"
4065                    .to_string(),
4066            );
4067            lines.push(
4068                "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."
4069                    .to_string(),
4070            );
4071            return lines;
4072        }
4073        XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
4074            lines.push(
4075                "external credentials: unavailable on a custom xAI endpoint (API-key-only; file not probed)"
4076                    .to_string(),
4077            );
4078            return lines;
4079        }
4080        XaiAuthDiagnosticRoute::ApiKey if !diagnostics.oauth_selected && external.is_some() => {
4081            lines.push(
4082                "external credentials: configured but inactive because xAI OAuth mode is not selected (file not probed)"
4083                    .to_string(),
4084            );
4085            return lines;
4086        }
4087        XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::ExternalConsent => {}
4088    }
4089
4090    if let Ok((source, expected_path)) = external_credential_target(ProviderKind::Xai, None) {
4091        let status = codewhale_config::external_credential_consent_status(
4092            external,
4093            ProviderKind::Xai,
4094            source,
4095            &expected_path,
4096            store.config.provider,
4097        );
4098        lines.push(format!(
4099            "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
4100            status.access.as_str(),
4101            status.provider,
4102            status.source.as_str(),
4103            status.owner,
4104            codewhale_config::quote_os_path(&status.path),
4105            status.consent_version,
4106            status.route_state,
4107            status.scope_valid,
4108            status.ambient_path_changed,
4109        ));
4110        lines.push(format!("semantics: {}", status.semantics));
4111        lines.push(format!("revoke: {}", status.revoke_command));
4112        if let Some(warning) = status.ambient_path_warning() {
4113            lines.push(warning);
4114        }
4115    } else {
4116        lines.push("external credentials: disabled (no file was probed)".to_string());
4117    }
4118    lines
4119}
4120
4121fn source_status(value: Option<&str>, missing_label: &str) -> String {
4122    value
4123        .map(|v| format!("set, last4: {}", last4_label(v)))
4124        .unwrap_or_else(|| missing_label.to_string())
4125}
4126
4127fn last4_label(value: &str) -> String {
4128    let trimmed = value.trim();
4129    let chars: Vec<char> = trimmed.chars().collect();
4130    if chars.len() <= 4 {
4131        return "<redacted>".to_string();
4132    }
4133    let last4: String = chars[chars.len() - 4..].iter().collect();
4134    format!("...{last4}")
4135}
4136
4137fn run_auth_command_with_runtime(
4138    store: &mut ConfigStore,
4139    command: AuthCommand,
4140    runtime_overrides: &CliRuntimeOverrides,
4141) -> Result<()> {
4142    let command = match command {
4143        AuthCommand::Status {
4144            provider,
4145            diagnostic: true,
4146        } => {
4147            // Keep the structural diagnostic structurally read-only: ordinary
4148            // status constructs the configured credential facade so it can report
4149            // runtime-effective sources, but diagnostic mode must not even create
4150            // a system-keyring handle or inspect a file-backed store.
4151            return run_auth_diagnostic(store, provider);
4152        }
4153        command => command,
4154    };
4155    run_auth_command_with_secrets_and_runtime(
4156        store,
4157        command,
4158        &Secrets::auto_detect(),
4159        runtime_overrides,
4160    )
4161}
4162
4163#[cfg(test)]
4164fn run_auth_command_with_secrets(
4165    store: &mut ConfigStore,
4166    command: AuthCommand,
4167    secrets: &Secrets,
4168) -> Result<()> {
4169    run_auth_command_with_secrets_and_runtime(
4170        store,
4171        command,
4172        secrets,
4173        &CliRuntimeOverrides::default(),
4174    )
4175}
4176
4177fn run_auth_command_with_secrets_and_runtime(
4178    store: &mut ConfigStore,
4179    command: AuthCommand,
4180    secrets: &Secrets,
4181    runtime_overrides: &CliRuntimeOverrides,
4182) -> Result<()> {
4183    match command {
4184        AuthCommand::XaiDevice => {
4185            let argv = vec![
4186                "codewhale".to_string(),
4187                "auth".to_string(),
4188                "xai-device".to_string(),
4189            ];
4190            let code = codewhale_tui::run(argv);
4191            std::process::exit(if code == std::process::ExitCode::SUCCESS {
4192                0
4193            } else {
4194                1
4195            })
4196        }
4197        AuthCommand::Chatgpt => {
4198            let argv = vec![
4199                "codewhale".to_string(),
4200                "auth".to_string(),
4201                "chatgpt".to_string(),
4202            ];
4203            let code = codewhale_tui::run(argv);
4204            std::process::exit(if code == std::process::ExitCode::SUCCESS {
4205                0
4206            } else {
4207                1
4208            })
4209        }
4210        AuthCommand::ChatgptRevoke => {
4211            let argv = vec![
4212                "codewhale".to_string(),
4213                "auth".to_string(),
4214                "chatgpt-revoke".to_string(),
4215            ];
4216            let code = codewhale_tui::run(argv);
4217            std::process::exit(if code == std::process::ExitCode::SUCCESS {
4218                0
4219            } else {
4220                1
4221            })
4222        }
4223        AuthCommand::ExternalConsent {
4224            provider,
4225            mode,
4226            path,
4227            yes,
4228        } => {
4229            let (source, path) = external_credential_target(provider, path)?;
4230            let preview = external_consent_preview_lines(provider, source, &path);
4231            for line in &preview {
4232                println!("{line}");
4233            }
4234            if mode == ExternalCredentialModeArg::Managed {
4235                bail!(
4236                    "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."
4237                );
4238            }
4239            confirm_external_consent(yes)?;
4240            let path_value = path.to_str().context(
4241                "external credential path cannot be persisted losslessly because it is not valid UTF-8",
4242            )?;
4243            let provider_key = provider.provider().provider_config_key();
4244            codewhale_config::mutate_config_document(store.path(), |document| {
4245                if matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai) {
4246                    codewhale_config::set_config_document_value(
4247                        document,
4248                        &["providers", provider_key, "auth_mode"],
4249                        "oauth",
4250                    )?;
4251                }
4252                let prefix = &["providers", provider_key, "external_credentials"];
4253                codewhale_config::set_config_document_value(
4254                    document,
4255                    &[prefix[0], prefix[1], prefix[2], "access"],
4256                    "read_only",
4257                )?;
4258                codewhale_config::set_config_document_value(
4259                    document,
4260                    &[prefix[0], prefix[1], prefix[2], "provider"],
4261                    provider.as_str(),
4262                )?;
4263                codewhale_config::set_config_document_value(
4264                    document,
4265                    &[prefix[0], prefix[1], prefix[2], "source"],
4266                    source.as_str(),
4267                )?;
4268                codewhale_config::set_config_document_value(
4269                    document,
4270                    &[prefix[0], prefix[1], prefix[2], "path"],
4271                    path_value,
4272                )?;
4273                codewhale_config::set_config_document_value(
4274                    document,
4275                    &[prefix[0], prefix[1], prefix[2], "consent_version"],
4276                    i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
4277                )
4278            })?;
4279            store
4280                .reload()
4281                .context("external consent was saved, but config reload failed")?;
4282            println!(
4283                "saved read-only external credential consent: provider={}, owner={}, path={}, consent_version={} ({})",
4284                provider.as_str(),
4285                source.as_str(),
4286                codewhale_config::quote_os_path(&path),
4287                codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION,
4288                codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
4289            );
4290            println!(
4291                "revoke with: codewhale auth external-revoke --provider {}",
4292                provider.as_str()
4293            );
4294            Ok(())
4295        }
4296        AuthCommand::ExternalRevoke { provider } => {
4297            let provider_key = provider.provider().provider_config_key();
4298            codewhale_config::mutate_config_document(store.path(), |document| {
4299                codewhale_config::unset_config_document_value(
4300                    document,
4301                    &["providers", provider_key, "external_credentials"],
4302                )?;
4303                Ok(())
4304            })?;
4305            store
4306                .reload()
4307                .context("external consent was revoked, but config reload failed")?;
4308            println!(
4309                "external credential access disabled for {}",
4310                provider.as_str()
4311            );
4312            Ok(())
4313        }
4314        AuthCommand::Status {
4315            provider,
4316            diagnostic,
4317        } => {
4318            if diagnostic {
4319                return run_auth_diagnostic(store, provider);
4320            }
4321            match provider {
4322                Some(provider) => {
4323                    for line in auth_status_lines_for_provider_with_runtime(
4324                        store,
4325                        secrets,
4326                        provider,
4327                        runtime_overrides,
4328                    ) {
4329                        println!("{line}");
4330                    }
4331                }
4332                None => {
4333                    for line in
4334                        auth_status_all_providers_with_runtime(store, secrets, runtime_overrides)
4335                    {
4336                        println!("{line}");
4337                    }
4338                }
4339            }
4340            Ok(())
4341        }
4342        AuthCommand::Set {
4343            provider,
4344            api_key,
4345            api_key_stdin,
4346        } => {
4347            let slot = provider_slot(provider);
4348            if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
4349                let provider_cfg = store.config.providers.for_provider_mut(provider);
4350                if provider_cfg.base_url.is_none() {
4351                    provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
4352                }
4353                store.save()?;
4354                println!(
4355                    "configured {slot} provider in {} (API key optional)",
4356                    store.path().display()
4357                );
4358                return Ok(());
4359            }
4360            let api_key = match (api_key, api_key_stdin) {
4361                (Some(v), _) => v,
4362                (None, true) => read_api_key_from_stdin()?,
4363                (None, false) => prompt_api_key(slot)?,
4364            };
4365            let mut credential_store = credential_metadata_store(store)?;
4366            let store = credential_store.as_mut().unwrap_or(store);
4367            let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
4368            // Don't print the key. Don't echo length.
4369            if secret_store_saved {
4370                println!(
4371                    "saved API key for {slot} to {} (config contains metadata only)",
4372                    secrets.backend_name(),
4373                );
4374            } else {
4375                println!("saved API key for {slot} to {}", store.path().display());
4376            }
4377            Ok(())
4378        }
4379        AuthCommand::Get { provider } => {
4380            println!(
4381                "{}",
4382                auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
4383            );
4384            Ok(())
4385        }
4386        AuthCommand::PrintApiKey { provider } => {
4387            let mut stdout = io::stdout().lock();
4388            credential_handoff::handoff_secret_line(&mut stdout, io::stdout().is_terminal(), || {
4389                credential_handoff::resolve_api_key(store, secrets, provider, runtime_overrides)
4390            })
4391        }
4392        AuthCommand::Clear { provider } => {
4393            if provider == ProviderKind::Xai {
4394                codewhale_config::with_xai_oauth_revocation_transaction(|| {
4395                    clear_auth_provider(store, secrets, provider)
4396                })
4397            } else {
4398                clear_auth_provider(store, secrets, provider)
4399            }
4400        }
4401        AuthCommand::List => {
4402            for line in auth_list_lines_with_runtime(store, secrets, runtime_overrides) {
4403                println!("{line}");
4404            }
4405            Ok(())
4406        }
4407        AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
4408    }
4409}
4410
4411fn external_consent_preview_lines(
4412    provider: ProviderKind,
4413    source: codewhale_config::ExternalCredentialSource,
4414    path: &Path,
4415) -> Vec<String> {
4416    vec![
4417        "External credential consent preview (nothing has been saved):".to_string(),
4418        format!("  provider: {}", provider.as_str()),
4419        format!(
4420            "  owning CLI: {} ({})",
4421            source.owner_label(),
4422            source.as_str()
4423        ),
4424        format!(
4425            "  exact resolved path: {}",
4426            codewhale_config::quote_os_path(path)
4427        ),
4428        format!(
4429            "  access: read_only ({})",
4430            codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS
4431        ),
4432        "  managed: unavailable (no reviewed schema-safe preservation adapter)".to_string(),
4433        format!(
4434            "  revoke: codewhale auth external-revoke --provider {}",
4435            provider.as_str()
4436        ),
4437    ]
4438}
4439
4440fn confirm_external_consent(yes: bool) -> Result<()> {
4441    use std::io::IsTerminal;
4442
4443    if yes {
4444        return Ok(());
4445    }
4446    if !std::io::stdin().is_terminal() {
4447        bail!(
4448            "external credential consent was not saved: non-interactive use requires explicit --yes after reviewing the preview"
4449        );
4450    }
4451    confirm_external_consent_answer(&mut std::io::stdin().lock(), &mut std::io::stdout().lock())
4452}
4453
4454fn confirm_external_consent_answer(
4455    reader: &mut impl std::io::BufRead,
4456    writer: &mut impl std::io::Write,
4457) -> Result<()> {
4458    write!(writer, "Type 'yes' to grant this exact read-only access: ")?;
4459    writer.flush()?;
4460    let mut answer = String::new();
4461    reader
4462        .read_line(&mut answer)
4463        .context("reading external credential consent confirmation")?;
4464    if answer.trim() != "yes" {
4465        bail!("external credential consent cancelled; no configuration was changed");
4466    }
4467    Ok(())
4468}
4469
4470fn yes_no(b: bool) -> &'static str {
4471    if b { "yes" } else { "no " }
4472}
4473
4474fn keyring_status_short(state: Option<bool>) -> &'static str {
4475    match state {
4476        Some(true) => "yes",
4477        Some(false) => "no ",
4478        None => "n/a",
4479    }
4480}
4481
4482fn prompt_api_key(slot: &str) -> Result<String> {
4483    use std::io::{IsTerminal, Write};
4484    eprint!("Enter API key for {slot}: ");
4485    io::stderr().flush().ok();
4486    if !io::stdin().is_terminal() {
4487        // Non-interactive: read directly without prompting twice.
4488        return read_api_key_from_stdin();
4489    }
4490    let mut buf = String::new();
4491    io::stdin()
4492        .read_line(&mut buf)
4493        .context("failed to read API key from stdin")?;
4494    let key = buf.trim().to_string();
4495    if key.is_empty() {
4496        bail!("empty API key provided");
4497    }
4498    Ok(key)
4499}
4500
4501/// Move plaintext keys from config.toml into the configured secret store.
4502/// Hidden in v0.8.8 because the normal setup path is config/env only.
4503fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
4504    let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
4505    let mut warnings: Vec<String> = Vec::new();
4506    let literal =
4507        |value: &String| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal;
4508
4509    for provider in ProviderKind::ALL {
4510        let slot = provider_slot(provider);
4511        let from_provider_block = store
4512            .config
4513            .providers
4514            .for_provider(provider)
4515            .api_key
4516            .clone()
4517            .filter(literal);
4518        let from_root = (provider == ProviderKind::Deepseek)
4519            .then(|| store.config.api_key.clone())
4520            .flatten()
4521            .filter(literal);
4522        let value = from_provider_block.or(from_root);
4523        let Some(value) = value else { continue };
4524
4525        if let Ok(Some(existing)) = secrets.get(slot)
4526            && existing == value
4527        {
4528            // Already migrated; safe to strip the file slot.
4529        } else if dry_run {
4530            migrated.push((provider, slot));
4531            continue;
4532        } else if let Err(err) = secrets.set(slot, &value) {
4533            warnings.push(format!(
4534                "skipped {slot}: failed to write to secret store: {err}"
4535            ));
4536            continue;
4537        }
4538        if !dry_run {
4539            store.config.providers.for_provider_mut(provider).api_key = None;
4540            if provider == ProviderKind::Deepseek {
4541                store.config.api_key = None;
4542            }
4543        }
4544        migrated.push((provider, slot));
4545    }
4546
4547    if !dry_run && !migrated.is_empty() {
4548        store
4549            .save()
4550            .context("failed to write updated config.toml")?;
4551    }
4552    if !dry_run {
4553        codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
4554            .context("failed to remove plaintext API keys from config backup")?;
4555    }
4556
4557    println!("secret store backend: {}", secrets.backend_name());
4558    if migrated.is_empty() {
4559        println!("nothing to migrate (config.toml has no plaintext api_key entries)");
4560    } else {
4561        println!(
4562            "{} {} provider key(s):",
4563            if dry_run { "would migrate" } else { "migrated" },
4564            migrated.len()
4565        );
4566        for (_, slot) in &migrated {
4567            println!("  - {slot}");
4568        }
4569        if !dry_run {
4570            println!(
4571                "config.toml at {} no longer contains api_key entries for migrated providers.",
4572                store.path().display()
4573            );
4574        }
4575    }
4576    for w in warnings {
4577        eprintln!("warning: {w}");
4578    }
4579    Ok(())
4580}
4581
4582fn run_config_command(
4583    store: &mut ConfigStore,
4584    command: ConfigCommand,
4585    project_bundle_scope: bool,
4586    per_run_overrides: &[String],
4587) -> Result<()> {
4588    if project_bundle_scope && !codewhale_config::config_path_is_workspace_scoped(store.path()) {
4589        bail!(
4590            "--project requires a workspace config ({} is the user-global document)",
4591            store.path().display()
4592        );
4593    }
4594    // A per-run overlay must never leak into the file: commands that write
4595    // the store refuse it outright instead of saving a merged document.
4596    if !per_run_overrides.is_empty()
4597        && matches!(
4598            command,
4599            ConfigCommand::Set { .. }
4600                | ConfigCommand::Unset { .. }
4601                | ConfigCommand::Import(_)
4602                | ConfigCommand::Telemetry {
4603                    accept_notice: Some(_)
4604                }
4605        )
4606    {
4607        bail!(
4608            "--set is per-run and never saved; it cannot be combined with `config set`, \
4609             `config unset`, or `config import`. Drop --set, or use a read command."
4610        );
4611    }
4612    match command {
4613        ConfigCommand::Get { key } => {
4614            if per_run_overrides.is_empty() && codewhale_tui::route_preferences::is_route_key(&key)
4615            {
4616                if let Some(value) = codewhale_tui::route_preferences::get(store.path(), &key)? {
4617                    println!("{value}");
4618                    return Ok(());
4619                }
4620                bail!("key not found: {key}");
4621            }
4622            if codewhale_config::notifications::in_namespace(&key) {
4623                let config = codewhale_config::notifications::from_extras(&store.config.extras)?;
4624                let keys = if key.eq_ignore_ascii_case("notifications") {
4625                    codewhale_config::notifications::NotificationSetting::ALL.to_vec()
4626                } else {
4627                    vec![codewhale_config::notifications::NotificationSetting::required(&key)?]
4628                };
4629                for setting in keys {
4630                    if key.eq_ignore_ascii_case("notifications") {
4631                        println!(
4632                            "notifications.{} = {}",
4633                            setting.key(),
4634                            config.display(setting)
4635                        );
4636                    } else {
4637                        println!("{}", config.display(setting));
4638                    }
4639                }
4640                return Ok(());
4641            }
4642            if let Some(value) = store.config.get_display_value(&key) {
4643                if key == "telemetry" {
4644                    println!(
4645                        "Usage reporting: {}",
4646                        telemetry_preference_status(store.config.telemetry)
4647                    );
4648                    println!("Details: codewhale config telemetry");
4649                } else {
4650                    println!("{value}");
4651                }
4652                return Ok(());
4653            }
4654            bail!("key not found: {key}");
4655        }
4656        ConfigCommand::Set { key, value } => {
4657            if codewhale_tui::route_preferences::is_route_key(&key) {
4658                codewhale_tui::route_preferences::set(store.path(), &key, &value)?;
4659                store.reload()?;
4660                println!("set {key}");
4661                return Ok(());
4662            }
4663            if codewhale_config::notifications::in_namespace(&key) {
4664                let setting = codewhale_config::notifications::NotificationSetting::required(&key)?;
4665                codewhale_config::notifications::NotificationConfigUpdate::parse(setting, &value)?
4666                    .persist(store.path())?;
4667                store.reload()?;
4668                println!("set notifications.{}", setting.key());
4669                return Ok(());
4670            }
4671            store.config.set_value(&key, &value)?;
4672            if key == "telemetry" {
4673                let enabled = store
4674                    .config
4675                    .telemetry
4676                    .context("telemetry must be true or false")?;
4677                let receipt = codewhale_tui::set_telemetry_preference(
4678                    Some(store.path().to_path_buf()),
4679                    enabled,
4680                )?;
4681                println!("{receipt}");
4682                if enabled {
4683                    println!("{}", telemetry::notice::STARTUP_DISCLOSURE);
4684                }
4685            } else {
4686                store.save()?;
4687                println!("set {key}");
4688            }
4689            Ok(())
4690        }
4691        ConfigCommand::Telemetry { accept_notice } => {
4692            println!("{}\n", telemetry::notice::NOTICE_BODY);
4693            if let Some(version) = accept_notice {
4694                let receipt = codewhale_tui::accept_telemetry_notice(
4695                    Some(store.path().to_path_buf()),
4696                    version,
4697                )?;
4698                println!("{receipt}");
4699            } else {
4700                println!(
4701                    "Usage reporting: {}",
4702                    telemetry_preference_status(store.config.telemetry)
4703                );
4704                println!("To enable: codewhale config set telemetry true");
4705                println!("To opt out: codewhale config set telemetry false");
4706            }
4707            Ok(())
4708        }
4709        ConfigCommand::Unset { key } => {
4710            if codewhale_tui::route_preferences::is_route_key(&key) {
4711                codewhale_tui::route_preferences::unset(store.path(), &key)?;
4712                store.reload()?;
4713                println!("unset {key}");
4714                return Ok(());
4715            }
4716            if codewhale_config::notifications::in_namespace(&key) {
4717                let setting = codewhale_config::notifications::NotificationSetting::required(&key)?;
4718                setting.unset(store.path())?;
4719                store.reload()?;
4720                println!("unset notifications.{}", setting.key());
4721                return Ok(());
4722            }
4723            store.config.unset_value(&key)?;
4724            store.save()?;
4725            println!("unset {key}");
4726            Ok(())
4727        }
4728        ConfigCommand::List => {
4729            // Configured truth, not live-session truth (DGF-01): a running
4730            // session keeps the route it resolved at launch, so these values
4731            // must not be read as "what the current session is serving".
4732            // `#` keeps the header safe for `key = value` line parsers.
4733            println!("# configured values ({})", store.path().display());
4734            println!(
4735                "# a running session keeps the route it resolved at launch; `codewhale model resolve` reports the route a new session would take"
4736            );
4737            for (key, value) in store.config.list_values() {
4738                println!("{key} = {value}");
4739            }
4740            Ok(())
4741        }
4742        ConfigCommand::Path => {
4743            println!("{}", store.path().display());
4744            Ok(())
4745        }
4746        ConfigCommand::Edit => {
4747            let path = store.path().to_path_buf();
4748            println!("{}", path.display());
4749            let editor = std::env::var("VISUAL")
4750                .or_else(|_| std::env::var("EDITOR"))
4751                .unwrap_or_else(|_| "vi".to_string());
4752            let status = Command::new(&editor)
4753                .arg(&path)
4754                .status()
4755                .with_context(|| format!("failed to launch editor {editor:?}"))?;
4756            if !status.success() {
4757                bail!("editor {editor:?} exited with {status}");
4758            }
4759            Ok(())
4760        }
4761        ConfigCommand::Doctor => run_config_doctor(store),
4762        ConfigCommand::Dump => {
4763            if !per_run_overrides.is_empty() {
4764                println!(
4765                    "# {} per-run --set override(s), not saved",
4766                    per_run_overrides.len()
4767                );
4768            }
4769            println!("# {}", store.path().display());
4770            print!(
4771                "{}",
4772                toml::to_string_pretty(&store.config.redacted_toml_value())?
4773            );
4774            Ok(())
4775        }
4776        ConfigCommand::Import(args) => {
4777            let workspace = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
4778            config_bundles::run_import(&args, store, &workspace)
4779        }
4780        ConfigCommand::Export(args) => config_bundles::run_export(&args, store),
4781    }
4782}
4783
4784/// Apply per-run `--set KEY=VALUE` overlays to the loaded store in memory.
4785/// Nothing is saved; callers that persist must refuse overrides first
4786/// (see `run_config_command`).
4787fn apply_per_run_overrides(store: &mut ConfigStore, specs: &[String]) -> Result<()> {
4788    for spec in specs {
4789        let (key, value) = spec
4790            .split_once('=')
4791            .with_context(|| format!("invalid --set {spec:?}: expected KEY=VALUE"))?;
4792        store
4793            .config
4794            .set_value(key.trim(), value)
4795            .with_context(|| format!("invalid --set {spec:?}"))?;
4796    }
4797    Ok(())
4798}
4799
4800/// Read-only credential and endpoint check. The dispatcher's extras also
4801/// contain settings owned by runtime readers; they are not unknown keys.
4802/// Never prints a credential — presence and shape only.
4803fn run_config_doctor(store: &ConfigStore) -> Result<()> {
4804    println!("# {}", store.path().display());
4805    let mut errors: Vec<String> = Vec::new();
4806    if !store.config.extras.is_empty() {
4807        println!(
4808            "note: additional settings are preserved for runtime readers; this check does not classify their support"
4809        );
4810    }
4811
4812    let mut secrets: Vec<(String, Option<String>)> =
4813        vec![("api_key".to_string(), store.config.api_key.clone())];
4814    let mut endpoints: Vec<(String, Option<String>)> =
4815        vec![("base_url".to_string(), store.config.base_url.clone())];
4816    for provider in ProviderKind::ALL {
4817        let table = store.config.providers.for_provider(provider);
4818        secrets.push((format!("{provider:?}.api_key"), table.api_key.clone()));
4819        endpoints.push((format!("{provider:?}.base_url"), table.base_url.clone()));
4820    }
4821    for (name, secret) in secrets {
4822        if secret
4823            .as_deref()
4824            .is_some_and(|value| value.trim().is_empty())
4825        {
4826            errors.push(format!("`{name}` is set but empty"));
4827        }
4828    }
4829    for (name, endpoint) in endpoints {
4830        if let Some(url) = endpoint.as_deref()
4831            && !url.starts_with("http://")
4832            && !url.starts_with("https://")
4833        {
4834            errors.push(format!("`{name}` is not an http(s) URL: {url}"));
4835        }
4836    }
4837
4838    if !errors.is_empty() {
4839        for error in &errors {
4840            println!("error: {error}");
4841        }
4842        bail!("doctor: {} error(s): {}", errors.len(), errors.join("; "));
4843    }
4844    println!("doctor: credentials and endpoints clean");
4845    Ok(())
4846}
4847
4848fn telemetry_preference_status(preference: Option<bool>) -> &'static str {
4849    let (enabled, source) = codewhale_config::resolved_telemetry_consent(preference);
4850    if !enabled {
4851        return match source {
4852            codewhale_config::TelemetrySource::Env => "Off (environment or run kill switch)",
4853            _ => "Off (saved preference)",
4854        };
4855    }
4856    match telemetry::load_setup_state_for_decision() {
4857        Some(state) if state.telemetry_opted_out() => "Off (saved opt-out)",
4858        None => "Off (privacy state unreadable)",
4859        Some(_) => match source {
4860            codewhale_config::TelemetrySource::Default => "On (default)",
4861            codewhale_config::TelemetrySource::Env | codewhale_config::TelemetrySource::Cli => {
4862                "On (environment or run preference)"
4863            }
4864            codewhale_config::TelemetrySource::Config => "On (saved preference)",
4865        },
4866    }
4867}
4868
4869fn model_command_provider_hint(
4870    command_provider: Option<ProviderKind>,
4871    top_level_provider: Option<ProviderKind>,
4872) -> Option<ProviderKind> {
4873    command_provider.or(top_level_provider)
4874}
4875
4876fn provider_source_label(source: ProviderSource) -> String {
4877    match source {
4878        ProviderSource::Cli => "--provider".to_string(),
4879        ProviderSource::Env(name) => format!("environment ({name})"),
4880        ProviderSource::Config => "config".to_string(),
4881    }
4882}
4883
4884fn canonical_model_for_set(model: &str) -> &str {
4885    match model.to_ascii_lowercase().as_str() {
4886        "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
4887        "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
4888        "flash-vision" | "deepseek-v4flashvisionexp" => "deepseek-v4-flash-vision-exp",
4889        "auto" => "auto",
4890        _ => model,
4891    }
4892}
4893
4894fn run_model_command(
4895    store: &mut ConfigStore,
4896    command: ModelCommand,
4897    top_level_provider: Option<ProviderKind>,
4898    resolved_runtime: &ResolvedRuntimeOptions,
4899) -> Result<()> {
4900    let registry = ModelRegistry::default();
4901    match command {
4902        ModelCommand::List { provider } => {
4903            let filter = model_command_provider_hint(provider, top_level_provider);
4904            for model in registry.list().into_iter().filter(|m| match filter {
4905                Some(p) => m.provider == p,
4906                None => true,
4907            }) {
4908                println!("{} ({})", model.id, model.provider.as_str());
4909            }
4910            Ok(())
4911        }
4912        ModelCommand::Resolve { model, provider } => {
4913            // Only `model resolve --provider X` is a hypothetical. The
4914            // top-level `--provider` is the route this process is actually on,
4915            // and it is already folded into `resolved_runtime` — treating it as
4916            // a hypothetical made `codewhale --provider moonshot --model
4917            // kimi-k3 model resolve` re-derive a registry default and report
4918            // `kimi-k2.7-code` while the runtime used `kimi-k3` (v0.9.1 kimi-k3 dogfood report). The
4919            // top-level `--model` was not consulted at all on that path.
4920            let subcommand_provider = provider;
4921            let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
4922
4923            // With no explicit query, this reports the route the runtime would
4924            // actually take — the same answer `doctor` gives — rather than
4925            // re-deriving one from an empty flag set. Re-deriving is what made
4926            // a Z.ai config report `provider: deepseek` (#4832).
4927            if queried.is_none() && subcommand_provider.is_none() {
4928                let saved = if matches!(resolved_runtime.provider_source, ProviderSource::Config)
4929                    && !matches!(
4930                        resolved_runtime.model_source,
4931                        codewhale_config::ModelSource::Cli | codewhale_config::ModelSource::Env
4932                    ) {
4933                    Some(codewhale_tui::route_preferences::selected_route(
4934                        store.path(),
4935                    )?)
4936                } else {
4937                    None
4938                };
4939                let provider = saved
4940                    .as_ref()
4941                    .map_or(resolved_runtime.provider.as_str(), |(provider, _, _)| {
4942                        provider.as_str()
4943                    });
4944                let model = saved
4945                    .as_ref()
4946                    .map_or(resolved_runtime.model.as_str(), |(_, model, _)| {
4947                        model.as_str()
4948                    });
4949                let source = saved
4950                    .as_ref()
4951                    .map_or(resolved_runtime.model_source, |(_, _, source)| *source);
4952                println!(
4953                    "requested: {}",
4954                    if source.is_explicit() { model } else { "" }
4955                );
4956                println!("resolved: {model}");
4957                println!("provider: {provider}");
4958                println!("used_fallback: {}", !source.is_explicit());
4959                println!(
4960                    "provider_source: {}",
4961                    provider_source_label(resolved_runtime.provider_source)
4962                );
4963                println!("model_source: {}", source.as_str());
4964                return Ok(());
4965            }
4966
4967            // An explicit model or provider makes this a hypothetical query
4968            // inside a named route. The subcommand provider wins; otherwise
4969            // the configured runtime provider remains authoritative. Model
4970            // text never authorizes switching providers or credential slots.
4971            let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
4972            let resolved = registry.resolve(queried, provider_hint)?;
4973            println!("requested: {}", resolved.requested.unwrap_or_default());
4974            println!("resolved: {}", resolved.resolved.id);
4975            println!("provider: {}", resolved.resolved.provider.as_str());
4976            println!("used_fallback: {}", resolved.used_fallback);
4977            println!(
4978                "provider_source: {}",
4979                if subcommand_provider.is_some() {
4980                    "--provider".to_string()
4981                } else {
4982                    provider_source_label(resolved_runtime.provider_source)
4983                }
4984            );
4985            println!(
4986                "model_source: {}",
4987                if queried.is_some() {
4988                    "argument"
4989                } else {
4990                    // This branch is reachable only for an explicit
4991                    // subcommand provider with no requested model. The model
4992                    // therefore came from that provider's default, not from
4993                    // the configured runtime route we deliberately overrode.
4994                    "provider default"
4995                }
4996            );
4997            Ok(())
4998        }
4999        ModelCommand::Set { model } => {
5000            let trimmed = model.trim();
5001            if trimmed.is_empty() {
5002                bail!("Model name cannot be empty");
5003            }
5004            let canonical = canonical_model_for_set(trimmed);
5005            codewhale_tui::route_preferences::set(store.path(), "model", canonical)?;
5006            store.reload()?;
5007            println!("Default model set to '{canonical}'");
5008            Ok(())
5009        }
5010    }
5011}
5012
5013/// The TUI passthrough a thread subcommand delegates as, if it delegates.
5014///
5015/// Exhaustive on purpose: a future `ThreadCommand` variant that starts a
5016/// session has to state its passthrough here, where the caller below routes it
5017/// through the one command builder that applies the telemetry floor.
5018fn thread_delegation(command: &ThreadCommand) -> Option<Vec<String>> {
5019    match command {
5020        ThreadCommand::Resume { thread_id } => Some(vec!["resume".to_string(), thread_id.clone()]),
5021        ThreadCommand::Fork { thread_id } => Some(vec!["fork".to_string(), thread_id.clone()]),
5022        ThreadCommand::List { .. }
5023        | ThreadCommand::Read { .. }
5024        | ThreadCommand::Archive { .. }
5025        | ThreadCommand::Unarchive { .. }
5026        | ThreadCommand::SetName { .. }
5027        | ThreadCommand::ClearName { .. } => None,
5028    }
5029}
5030
5031fn run_thread_command(
5032    cli: &Cli,
5033    store: &mut ConfigStore,
5034    runtime_overrides: &CliRuntimeOverrides,
5035    command: ThreadCommand,
5036) -> Result<()> {
5037    // `thread resume`/`thread fork` start a full interactive session in the TUI
5038    // binary, so they delegate exactly like the top-level `resume` does —
5039    // through dispatcher, which forwards `--config` and states the
5040    // resolved telemetry value in the child's environment. They used to take a
5041    // bare command invocation that forwarded neither, so a session
5042    // launched this way re-resolved from `$CODEWHALE_HOME/config.toml` with no
5043    // overrides and armed telemetry even when the user had passed
5044    // `--telemetry false` or pointed `--config` at a file that said
5045    // `telemetry = false`.
5046    if let Some(passthrough) = thread_delegation(&command) {
5047        let resolved_runtime = resolve_runtime_for_dispatch(store, runtime_overrides);
5048        return run_tui_in_process(cli, &resolved_runtime, passthrough);
5049    }
5050    let state = StateStore::open(None)?;
5051    match command {
5052        ThreadCommand::List { all, limit } => {
5053            let threads = state.list_threads(ThreadListFilters {
5054                include_archived: all,
5055                limit,
5056            })?;
5057            for thread in threads {
5058                println!(
5059                    "{} | {} | {} | {}",
5060                    thread.id,
5061                    thread
5062                        .name
5063                        .clone()
5064                        .unwrap_or_else(|| "(unnamed)".to_string()),
5065                    thread.model_provider,
5066                    thread.cwd.display()
5067                );
5068            }
5069            Ok(())
5070        }
5071        ThreadCommand::Read { thread_id } => {
5072            let thread = state.get_thread(&thread_id)?;
5073            println!("{}", serde_json::to_string_pretty(&thread)?);
5074            Ok(())
5075        }
5076        ThreadCommand::Resume { .. } | ThreadCommand::Fork { .. } => {
5077            unreachable!("thread_delegation routes resume and fork before this match")
5078        }
5079        ThreadCommand::Archive { thread_id } => {
5080            state.mark_archived(&thread_id)?;
5081            println!("archived {thread_id}");
5082            Ok(())
5083        }
5084        ThreadCommand::Unarchive { thread_id } => {
5085            state.mark_unarchived(&thread_id)?;
5086            println!("unarchived {thread_id}");
5087            Ok(())
5088        }
5089        ThreadCommand::SetName { thread_id, name } => {
5090            let mut thread = state
5091                .get_thread(&thread_id)?
5092                .with_context(|| format!("thread not found: {thread_id}"))?;
5093            thread.name = Some(name);
5094            thread.updated_at = chrono::Utc::now().timestamp();
5095            state.upsert_thread(&thread)?;
5096            println!("renamed {thread_id}");
5097            Ok(())
5098        }
5099        ThreadCommand::ClearName { thread_id } => {
5100            let mut thread = state
5101                .get_thread(&thread_id)?
5102                .with_context(|| format!("thread not found: {thread_id}"))?;
5103            thread.name = None;
5104            thread.updated_at = chrono::Utc::now().timestamp();
5105            state.upsert_thread(&thread)?;
5106            println!("cleared name for {thread_id}");
5107            Ok(())
5108        }
5109    }
5110}
5111
5112fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
5113    match command {
5114        SandboxCommand::Check { command, ask } => {
5115            let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
5116            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
5117            let decision = engine.check(ExecPolicyContext {
5118                command: &command,
5119                cwd: &cwd.display().to_string(),
5120                tool: Some("exec_shell"),
5121                path: None,
5122                ask_for_approval: ask.into(),
5123                sandbox_mode: Some("workspace-write"),
5124            })?;
5125            println!("{}", serde_json::to_string_pretty(&decision)?);
5126            Ok(())
5127        }
5128    }
5129}
5130
5131fn run_app_server_command(
5132    cli: &Cli,
5133    resolved_runtime: &ResolvedRuntimeOptions,
5134    args: AppServerArgs,
5135) -> Result<()> {
5136    // The full runtime API lives in the TUI crate behind `serve --http`/`--mobile`.
5137    // Rather than duplicate ~6.5k lines or add a CLI→TUI crate dependency, the
5138    // canonical `app-server --http`/`--mobile` entrypoint reuses that mature server
5139    // by delegating to the sibling TUI binary (the same mechanism `serve` uses).
5140    if args.http || args.mobile {
5141        // Delegated runtime API listener — supervise it so the child does not
5142        // outlive the dispatcher (#3259).
5143        return run_tui_server_in_process(
5144            cli,
5145            resolved_runtime,
5146            app_server_serve_passthrough(&args),
5147        );
5148    }
5149
5150    // Everything below runs the app-server *in this process*, which is why the
5151    // surface cannot be derived from the executable: `current_exe()` would
5152    // report every one of these sessions as `cli`.
5153    let session = start_cli_telemetry(
5154        resolved_runtime,
5155        args.config.clone().or_else(|| cli.config.clone()),
5156        Surface::AppServer,
5157    );
5158
5159    let runtime = match tokio::runtime::Builder::new_multi_thread()
5160        .enable_all()
5161        .build()
5162        .context("failed to create tokio runtime")
5163    {
5164        Ok(runtime) => runtime,
5165        Err(error) => {
5166            let outcome = Err(error);
5167            finish_cli_telemetry(session, &outcome);
5168            return outcome;
5169        }
5170    };
5171    if args.stdio {
5172        let outcome = runtime.block_on(run_app_server_stdio(args.config));
5173        finish_cli_telemetry(session, &outcome);
5174        return outcome;
5175    }
5176    if args.socket {
5177        let outcome = runtime.block_on(run_daemon_socket(DaemonSocketOptions {
5178            socket_path: args.socket_path,
5179            config_path: args.config,
5180        }));
5181        finish_cli_telemetry(session, &outcome);
5182        return outcome;
5183    }
5184    // Legacy in-process app-server HTTP transport (`/healthz`, `/thread`, `/app`,
5185    // `/prompt`, `/tool`, `/jobs`). Kept for backward compatibility; defaults to
5186    // 127.0.0.1:8787 to avoid colliding with the runtime API default of :7878.
5187    // `/prompt` and `/thread` messages are not served locally: they run a real
5188    // turn by bridging to a runtime API child, and fail with an explicit
5189    // `runtime_unavailable` when one cannot be started.
5190    let host = args.host.as_deref().unwrap_or("127.0.0.1");
5191    let port = args.port.unwrap_or(8787);
5192    let outcome = format!("{host}:{port}")
5193        .parse::<SocketAddr>()
5194        .with_context(|| format!("invalid app-server listen address {host}:{port}"))
5195        .and_then(|listen| {
5196            runtime.block_on(run_app_server(AppServerOptions {
5197                listen,
5198                config_path: args.config,
5199                auth_token: args.auth_token.or_else(app_server_token_from_env),
5200                insecure_no_auth: args.insecure_no_auth,
5201                cors_origins: args.cors_origin,
5202            }))
5203        });
5204    finish_cli_telemetry(session, &outcome);
5205    outcome
5206}
5207
5208/// Build the `serve` argv forwarded to the TUI binary for
5209/// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the
5210/// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The
5211/// subcommand-level `--config` is bridged through the global `--config` in the
5212/// dispatcher, so it is intentionally not part of this passthrough. An auth
5213/// token from the environment is deliberately *not* forwarded into child argv;
5214/// the runtime API reads CODEWHALE_RUNTIME_TOKEN/DEEPSEEK_RUNTIME_TOKEN itself.
5215fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
5216    let mut forwarded = vec!["serve".to_string()];
5217    forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
5218    if let Some(host) = args.host.as_ref() {
5219        forwarded.push("--host".to_string());
5220        forwarded.push(host.clone());
5221    }
5222    if let Some(port) = args.port {
5223        forwarded.push("--port".to_string());
5224        forwarded.push(port.to_string());
5225    }
5226    if let Some(workers) = args.workers {
5227        forwarded.push("--workers".to_string());
5228        forwarded.push(workers.to_string());
5229    }
5230    for origin in &args.cors_origin {
5231        forwarded.push("--cors-origin".to_string());
5232        forwarded.push(origin.clone());
5233    }
5234    if let Some(token) = args.auth_token.as_ref() {
5235        forwarded.push("--auth-token".to_string());
5236        forwarded.push(token.clone());
5237    }
5238    if args.insecure_no_auth {
5239        forwarded.push("--insecure".to_string());
5240    }
5241    if args.qr {
5242        forwarded.push("--qr".to_string());
5243    }
5244    forwarded
5245}
5246
5247fn web_serve_passthrough(args: &WebArgs) -> Vec<String> {
5248    vec![
5249        "serve".to_string(),
5250        "--web".to_string(),
5251        "--port".to_string(),
5252        args.port.to_string(),
5253    ]
5254}
5255
5256fn app_server_token_from_env() -> Option<String> {
5257    std::env::var("CODEWHALE_APP_SERVER_TOKEN")
5258        .ok()
5259        .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
5260}
5261
5262fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
5263    let persisted = load_mcp_server_definitions(store);
5264    let updated = run_stdio_server(persisted)?;
5265    persist_mcp_server_definitions(store, &updated)
5266}
5267
5268fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
5269    // `get_raw_string` first: `get_value` re-renders the extras entry as TOML,
5270    // which quotes a JSON payload into `'[{"config":…}]'` and makes it
5271    // unparseable — so every persisted definition was silently dropped and
5272    // `mcp-server` started with an empty server list (#4727). `get_value`
5273    // remains as the fallback for keys that are not plain extras strings.
5274    let raw = store
5275        .config
5276        .get_raw_string(MCP_SERVER_DEFINITIONS_KEY)
5277        .map(ToOwned::to_owned)
5278        .or_else(|| store.config.get_value(MCP_SERVER_DEFINITIONS_KEY));
5279    let Some(raw) = raw else {
5280        return Vec::new();
5281    };
5282
5283    match parse_mcp_server_definitions(&raw) {
5284        Ok(definitions) => definitions,
5285        Err(err) => {
5286            eprintln!(
5287                "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
5288            );
5289            Vec::new()
5290        }
5291    }
5292}
5293
5294fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
5295    if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
5296        return Ok(parsed);
5297    }
5298
5299    let unwrapped: String = serde_json::from_str(raw).map_err(|_| {
5300        anyhow!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted")
5301    })?;
5302    serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).map_err(|_| {
5303        anyhow!(
5304            "invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted"
5305        )
5306    })
5307}
5308
5309fn persist_mcp_server_definitions(
5310    store: &mut ConfigStore,
5311    definitions: &[McpServerDefinition],
5312) -> Result<()> {
5313    let encoded =
5314        serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
5315    store
5316        .config
5317        .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
5318    store.save()
5319}
5320
5321/// Delegate a long-running server command (`serve --http`/`--mobile`,
5322/// `app-server --http`/`--mobile`) to the sibling TUI binary, supervising the
5323/// child so its listener does not outlive the dispatcher (#3259).
5324///
5325/// Plain [`run_tui_in_process`] blocks on `Command::status()`, which reaps the
5326/// child only on the child's own exit. If the dispatcher is terminated while
5327/// the delegated server is still running, the child can be reparented and keep
5328/// its listener bound. Here the child runs under a Tokio supervisor that
5329/// forwards termination (Ctrl+C / SIGTERM / SIGHUP) by killing and reaping the
5330/// child before the dispatcher exits, and `kill_on_drop` tears the child down
5331/// if the dispatcher unwinds.
5332///
5333/// For an *uncatchable* dispatcher death (SIGKILL, a hard crash) the Tokio
5334/// supervisor above can't run, so two OS-level safety nets are installed as
5335/// well (#3259): on Linux the child sets `PR_SET_PDEATHSIG` so the kernel
5336/// signals it when the dispatcher dies; on Windows the child is placed in a
5337/// kill-on-job-close Job Object so closing the dispatcher's handle (which the
5338/// OS does on process death) terminates it. macOS has no equivalent primitive,
5339/// so an uncatchable dispatcher death there can still orphan the child.
5340
5341/// On Linux, ask the kernel to terminate the delegated server if the dispatcher
5342/// dies before it can run the graceful shutdown supervisor. This covers the
5343/// hard parent-death edge of #3259 for `SIGKILL`, OOM, or abrupt process exit.
5344#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
5345#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
5346
5347/// Outcome of supervising a delegated server child.
5348#[derive(Debug)]
5349
5350/// Wait for the server `child` to exit, or for `shutdown` to fire first. On
5351/// shutdown, kill the child and reap it so no listener is left reparented.
5352
5353/// Resolve when the dispatcher should tear down a delegated server child, and
5354/// the conventional `128 + signal` exit code to propagate: Ctrl+C on every
5355/// platform (130), plus SIGTERM (143) and SIGHUP (129) on Unix.
5356#[cfg(unix)]
5357#[cfg(not(unix))]
5358
5359/// Assign the delegated server `child` to a kill-on-job-close Job Object so the
5360/// OS terminates it when the dispatcher's handle to the job closes — which it
5361/// does on any dispatcher exit, including an uncatchable kill (#3259). The
5362/// returned guard must be held for the dispatcher's lifetime. Best-effort:
5363/// returns `None` if the job cannot be created or assigned. Mirrors the Job
5364/// Object idiom in `crates/tui/src/tools/shell.rs`.
5365#[cfg(windows)]
5366#[cfg(windows)]
5367// SAFETY: the wrapped value is a process-wide kernel handle; moving it across
5368// threads does not invalidate it, and it is only ever closed once, on drop.
5369#[cfg(windows)]
5370unsafe impl Send for ServerChildJob {}
5371
5372fn run_resume_command(
5373    cli: &Cli,
5374    resolved_runtime: &ResolvedRuntimeOptions,
5375    args: TuiPassthroughArgs,
5376) -> Result<()> {
5377    let passthrough = tui_args("resume", args);
5378    if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
5379        return run_dispatcher_resume_picker(cli, resolved_runtime);
5380    }
5381    run_tui_in_process(cli, resolved_runtime, passthrough)
5382}
5383
5384fn run_dispatcher_resume_picker(
5385    cli: &Cli,
5386    resolved_runtime: &ResolvedRuntimeOptions,
5387) -> Result<()> {
5388    let argv = tui_argv(cli, vec!["sessions".to_string()]);
5389    apply_tui_env(cli, resolved_runtime, &argv);
5390    let code = codewhale_tui::run(argv);
5391    if code != std::process::ExitCode::SUCCESS {
5392        std::process::exit(if code == std::process::ExitCode::SUCCESS {
5393            0
5394        } else {
5395            1
5396        })
5397    }
5398
5399    println!();
5400    println!("Windows note: enter a session id or prefix from the list above.");
5401    println!("You can also run `codewhale resume --last` to skip this prompt.");
5402    print!("Session id/prefix (Enter to cancel): ");
5403    io::stdout().flush()?;
5404
5405    let mut input = String::new();
5406    io::stdin()
5407        .read_line(&mut input)
5408        .context("failed to read session selection")?;
5409    let session_id = input.trim();
5410    if session_id.is_empty() {
5411        bail!("No session selected.");
5412    }
5413
5414    run_tui_in_process(
5415        cli,
5416        resolved_runtime,
5417        vec!["resume".to_string(), session_id.to_string()],
5418    )
5419}
5420
5421fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
5422    is_windows && passthrough == ["resume"]
5423}
5424
5425fn run_tui_in_process(
5426    cli: &Cli,
5427    resolved_runtime: &ResolvedRuntimeOptions,
5428    passthrough: Vec<String>,
5429) -> Result<()> {
5430    let argv = tui_argv(cli, passthrough.clone());
5431    apply_tui_env(cli, resolved_runtime, &passthrough);
5432    let code = codewhale_tui::run(argv);
5433    std::process::exit(if code == std::process::ExitCode::SUCCESS {
5434        0
5435    } else {
5436        1
5437    })
5438}
5439
5440fn run_tui_server_in_process(
5441    cli: &Cli,
5442    resolved_runtime: &ResolvedRuntimeOptions,
5443    passthrough: Vec<String>,
5444) -> Result<()> {
5445    let argv = tui_argv(cli, passthrough.clone());
5446    apply_tui_env(cli, resolved_runtime, &passthrough);
5447    let code = codewhale_tui::run(argv);
5448    std::process::exit(if code == std::process::ExitCode::SUCCESS {
5449        0
5450    } else {
5451        1
5452    })
5453}
5454
5455fn tui_argv(cli: &Cli, passthrough: Vec<String>) -> Vec<String> {
5456    let mut args = Vec::new();
5457    args.push("codewhale".to_string());
5458    if let Some(config) = cli.config.as_deref() {
5459        args.push("--config".to_string());
5460        args.push(config.display().to_string());
5461    }
5462    if let Some(profile) = cli.profile.as_ref() {
5463        args.push("--profile".to_string());
5464        args.push(profile.clone());
5465    }
5466    if let Some(workspace) = cli.workspace.as_deref() {
5467        args.push("--workspace".to_string());
5468        args.push(workspace.display().to_string());
5469    }
5470    if cli.mouse_capture {
5471        args.push("--mouse-capture".to_string());
5472    }
5473    if cli.no_mouse_capture {
5474        args.push("--no-mouse-capture".to_string());
5475    }
5476    if cli.skip_onboarding {
5477        args.push("--skip-onboarding".to_string());
5478    }
5479    if cli.fresh {
5480        args.push("--fresh".to_string());
5481    }
5482    if cli.no_project_config {
5483        args.push("--no-project-config".to_string());
5484    }
5485    args.extend(passthrough);
5486    args
5487}
5488
5489fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrough: &[String]) {
5490    let mut verbosity = if cli.profile.is_some() {
5491        cli.verbosity.clone()
5492    } else {
5493        resolved_runtime.verbosity.clone()
5494    };
5495    if verbosity.is_none()
5496        && passthrough
5497            .iter()
5498            .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
5499    {
5500        verbosity = Some("concise".to_string());
5501    }
5502    let uses_raw_tui_provider = cli
5503        .provider
5504        .as_deref()
5505        .is_some_and(|provider| builtin_provider_arg(provider).is_none());
5506    let keyring_bridge_provider = resolved_runtime.provider;
5507    let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
5508    let keyring_bridge_source = resolved_runtime.api_key_source;
5509    if let Some(provider) = cli.provider.as_deref() {
5510        let provider = builtin_provider_arg(provider).map_or_else(
5511            || provider.to_string(),
5512            |provider| provider.as_str().to_string(),
5513        );
5514        unsafe {
5515            std::env::set_var("CODEWHALE_PROVIDER", &provider);
5516            std::env::set_var("DEEPSEEK_PROVIDER", provider);
5517        }
5518    }
5519    if !(uses_raw_tui_provider
5520        || (cli.profile.is_some()
5521            && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
5522        && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
5523        && let Some(api_key) = keyring_bridge_api_key
5524    {
5525        unsafe {
5526            for var in provider_env_vars(keyring_bridge_provider) {
5527                std::env::set_var(var, api_key);
5528            }
5529            std::env::set_var(
5530                codewhale_config::CLI_API_KEY_SOURCE_ENV,
5531                RuntimeApiKeySource::Keyring.as_env_value(),
5532            );
5533        }
5534    }
5535    if let Some(model) = cli.model.as_ref() {
5536        unsafe {
5537            std::env::set_var("CODEWHALE_MODEL", model);
5538            std::env::set_var("DEEPSEEK_MODEL", model);
5539        }
5540    }
5541    if let Some(output_mode) = cli.output_mode.as_ref() {
5542        unsafe {
5543            std::env::set_var("CODEWHALE_OUTPUT_MODE", output_mode);
5544            std::env::set_var("DEEPSEEK_OUTPUT_MODE", output_mode);
5545        }
5546    }
5547    if let Some(v) = verbosity.as_ref() {
5548        unsafe {
5549            std::env::set_var("CODEWHALE_VERBOSITY", v);
5550            std::env::set_var("DEEPSEEK_VERBOSITY", v);
5551        }
5552    }
5553    if let Some(log_level) = cli.log_level.as_ref() {
5554        unsafe {
5555            std::env::set_var("CODEWHALE_LOG_LEVEL", log_level);
5556            std::env::set_var("DEEPSEEK_LOG_LEVEL", log_level);
5557        }
5558    }
5559    let telemetry = resolved_runtime.telemetry.to_string();
5560    unsafe {
5561        std::env::set_var("CODEWHALE_TELEMETRY", &telemetry);
5562        std::env::set_var("DEEPSEEK_TELEMETRY", &telemetry);
5563    }
5564    let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force();
5565    unsafe {
5566        std::env::set_var(
5567            codewhale_config::TELEMETRY_FLOOR_ENV,
5568            if floor { "1" } else { "0" },
5569        );
5570    }
5571    if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
5572        unsafe {
5573            std::env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
5574            std::env::set_var("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint);
5575        }
5576    }
5577    if let Some(policy) = cli.approval_policy.as_ref() {
5578        unsafe {
5579            std::env::set_var("CODEWHALE_APPROVAL_POLICY", policy);
5580            std::env::set_var("DEEPSEEK_APPROVAL_POLICY", policy);
5581        }
5582    }
5583    if let Some(mode) = cli.sandbox_mode.as_ref() {
5584        unsafe {
5585            std::env::set_var("CODEWHALE_SANDBOX_MODE", mode);
5586            std::env::set_var("DEEPSEEK_SANDBOX_MODE", mode);
5587        }
5588    }
5589    if cli.yolo {
5590        unsafe {
5591            std::env::set_var("CODEWHALE_YOLO", "true");
5592        }
5593    }
5594    if let Some(api_key) = cli.api_key.as_ref() {
5595        unsafe {
5596            std::env::set_var(codewhale_config::CLI_API_KEY_ENV, api_key);
5597        }
5598        if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
5599            unsafe {
5600                for var in provider_env_vars(resolved_runtime.provider) {
5601                    std::env::set_var(var, api_key);
5602                }
5603            }
5604        }
5605        unsafe {
5606            std::env::set_var(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli");
5607        }
5608    }
5609    if let Some(base_url) = cli.base_url.as_ref() {
5610        unsafe {
5611            std::env::set_var("CODEWHALE_BASE_URL", base_url);
5612            std::env::set_var("DEEPSEEK_BASE_URL", base_url);
5613        }
5614    }
5615}
5616
5617// There is deliberately no "just run the TUI with these args" helper here. One
5618// existed, `thread resume`/`thread fork` used it, and it forwarded neither
5619// `--config` nor the resolved telemetry value — so the kill switch the
5620// dispatcher had already applied never reached the process that emits. Every
5621// delegation is now in-process, and
5622// `only_one_function_may_locate_and_spawn_the_tui` pins that.
5623
5624fn run_providers_command(args: ProvidersArgs) -> Result<()> {
5625    match args.command {
5626        ProvidersCommand::Export { json } => {
5627            if !json {
5628                bail!("`codewhale providers export` requires `--json`");
5629            }
5630            let export = ProvidersExport::from_registry(env!("CODEWHALE_BUILD_VERSION"));
5631            serde_json::to_writer_pretty(io::stdout(), &export)
5632                .context("failed to write providers export")?;
5633            println!();
5634            Ok(())
5635        }
5636    }
5637}
5638
5639fn run_metrics_command(args: MetricsArgs) -> Result<()> {
5640    let since = match args.since.as_deref() {
5641        Some(s) => {
5642            Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
5643        }
5644        None => None,
5645    };
5646    metrics::run(metrics::MetricsArgs {
5647        json: args.json,
5648        since,
5649    })
5650}
5651
5652fn read_api_key_from_stdin() -> Result<String> {
5653    let mut input = String::new();
5654    io::stdin()
5655        .read_to_string(&mut input)
5656        .context("failed to read api key from stdin")?;
5657    let key = input.trim().to_string();
5658    if key.is_empty() {
5659        bail!("empty API key provided");
5660    }
5661    Ok(key)
5662}
5663
5664#[cfg(test)]
5665mod tests {
5666    use super::*;
5667    use clap::error::ErrorKind;
5668    use codewhale_config::{ModelSource, ProviderSource};
5669    use std::ffi::OsString;
5670    use std::sync::{Mutex, OnceLock};
5671
5672    fn parse_ok(argv: &[&str]) -> Cli {
5673        Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
5674    }
5675
5676    fn help_for(argv: &[&str]) -> String {
5677        let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
5678        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
5679        err.to_string()
5680    }
5681
5682    pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
5683        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5684        LOCK.get_or_init(|| Mutex::new(()))
5685            .lock()
5686            .unwrap_or_else(|p| p.into_inner())
5687    }
5688
5689    pub(crate) struct ScopedEnvVar {
5690        name: &'static str,
5691        previous: Option<OsString>,
5692    }
5693
5694    impl ScopedEnvVar {
5695        pub(crate) fn set(name: &'static str, value: &str) -> Self {
5696            let previous = std::env::var_os(name);
5697            // Safety: tests using this helper serialize with env_lock() and
5698            // restore the original value in Drop.
5699            unsafe { std::env::set_var(name, value) };
5700            Self { name, previous }
5701        }
5702
5703        pub(crate) fn remove(name: &'static str) -> Self {
5704            let previous = std::env::var_os(name);
5705            // Safety: tests using this helper serialize with env_lock() and
5706            // restore the original value in Drop.
5707            unsafe { std::env::remove_var(name) };
5708            Self { name, previous }
5709        }
5710    }
5711
5712    impl Drop for ScopedEnvVar {
5713        fn drop(&mut self) {
5714            // Safety: tests using this helper serialize with env_lock().
5715            unsafe {
5716                if let Some(previous) = self.previous.take() {
5717                    std::env::set_var(self.name, previous.clone());
5718                } else {
5719                    std::env::remove_var(self.name);
5720                }
5721            }
5722        }
5723    }
5724
5725    #[derive(Default)]
5726    struct RecordingKeyringStore {
5727        gets: Mutex<Vec<String>>,
5728        values: Mutex<std::collections::BTreeMap<String, String>>,
5729    }
5730
5731    impl RecordingKeyringStore {
5732        fn set_value(&self, key: &str, value: &str) {
5733            self.values
5734                .lock()
5735                .expect("recording values lock")
5736                .insert(key.to_string(), value.to_string());
5737        }
5738
5739        fn queried(&self) -> Vec<String> {
5740            self.gets.lock().expect("recording gets lock").clone()
5741        }
5742    }
5743
5744    impl codewhale_secrets::KeyringStore for RecordingKeyringStore {
5745        fn get(
5746            &self,
5747            key: &str,
5748        ) -> std::result::Result<Option<String>, codewhale_secrets::SecretsError> {
5749            self.gets
5750                .lock()
5751                .expect("recording gets lock")
5752                .push(key.to_string());
5753            Ok(self
5754                .values
5755                .lock()
5756                .expect("recording values lock")
5757                .get(key)
5758                .cloned())
5759        }
5760
5761        fn set(
5762            &self,
5763            key: &str,
5764            value: &str,
5765        ) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5766            self.set_value(key, value);
5767            Ok(())
5768        }
5769
5770        fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5771            self.values
5772                .lock()
5773                .expect("recording values lock")
5774                .remove(key);
5775            Ok(())
5776        }
5777
5778        fn backend_name(&self) -> &'static str {
5779            "recording"
5780        }
5781    }
5782
5783    fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
5784        let dir = tempfile::TempDir::new().expect("tempdir");
5785        let custom = dir
5786            .path()
5787            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5788        std::fs::write(&custom, b"").unwrap();
5789        let custom_str = custom.to_string_lossy();
5790        let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5791        (dir, bin)
5792    }
5793
5794    fn resolved_runtime_for_test(
5795        provider: ProviderKind,
5796        provider_source: ProviderSource,
5797    ) -> ResolvedRuntimeOptions {
5798        ResolvedRuntimeOptions {
5799            provider,
5800            provider_source,
5801            model: "test-model".to_string(),
5802            model_source: ModelSource::ProviderDefault,
5803            api_key: None,
5804            api_key_source: None,
5805            base_url: "http://localhost:8000/v1".to_string(),
5806            auth_mode: None,
5807            insecure_skip_tls_verify: false,
5808            output_mode: None,
5809            log_level: None,
5810            telemetry: false,
5811            telemetry_source: codewhale_config::TelemetrySource::Default,
5812            telemetry_explicit_off: false,
5813            telemetry_endpoint: None,
5814            approval_policy: None,
5815            sandbox_mode: None,
5816            yolo: None,
5817            verbosity: None,
5818            http_headers: std::collections::BTreeMap::new(),
5819            route: None,
5820        }
5821    }
5822
5823    #[test]
5824    fn tui_credential_handoff_stays_with_the_selected_provider() {
5825        let _lock = env_lock();
5826        let mut names = ProviderKind::ALL
5827            .into_iter()
5828            .flat_map(provider_env_vars)
5829            .copied()
5830            .collect::<Vec<_>>();
5831        names.extend([
5832            codewhale_config::CLI_API_KEY_ENV,
5833            codewhale_config::CLI_API_KEY_SOURCE_ENV,
5834            codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV,
5835            "CODEWHALE_PROVIDER",
5836            "DEEPSEEK_PROVIDER",
5837            "CODEWHALE_TELEMETRY",
5838            "DEEPSEEK_TELEMETRY",
5839            codewhale_config::TELEMETRY_FLOOR_ENV,
5840        ]);
5841        names.sort_unstable();
5842        names.dedup();
5843        let _clean_env = names
5844            .into_iter()
5845            .map(ScopedEnvVar::remove)
5846            .collect::<Vec<_>>();
5847        let _deepseek_key = ScopedEnvVar::set("DEEPSEEK_API_KEY", "existing-deepseek-key");
5848
5849        let clear_bridge = || {
5850            // Safety: this test holds env_lock() and the guards above restore
5851            // every touched variable.
5852            unsafe {
5853                for var in ProviderKind::ALL
5854                    .into_iter()
5855                    .flat_map(provider_env_vars)
5856                    .filter(|var| **var != "DEEPSEEK_API_KEY")
5857                {
5858                    std::env::remove_var(var);
5859                }
5860                std::env::remove_var(codewhale_config::CLI_API_KEY_ENV);
5861                std::env::remove_var(codewhale_config::CLI_API_KEY_SOURCE_ENV);
5862                std::env::remove_var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV);
5863            }
5864        };
5865
5866        for (provider_arg, provider) in [
5867            ("nvidia-nim", ProviderKind::NvidiaNim),
5868            ("openrouter", ProviderKind::Openrouter),
5869            ("anthropic", ProviderKind::Anthropic),
5870        ] {
5871            clear_bridge();
5872            let keyring_key = format!("{provider_arg}-keyring-key");
5873            let mut keyring_runtime = resolved_runtime_for_test(provider, ProviderSource::Cli);
5874            keyring_runtime.api_key = Some(keyring_key.clone());
5875            keyring_runtime.api_key_source = Some(RuntimeApiKeySource::Keyring);
5876            let keyring_cli = parse_ok(&["codewhale", "--provider", provider_arg]);
5877
5878            apply_tui_env(&keyring_cli, &keyring_runtime, &[]);
5879
5880            assert_eq!(
5881                std::env::var("DEEPSEEK_API_KEY").as_deref(),
5882                Ok("existing-deepseek-key"),
5883                "{provider_arg} keyring handoff replaced DeepSeek's credential"
5884            );
5885            for var in provider_env_vars(provider) {
5886                assert_eq!(
5887                    std::env::var(var).as_deref(),
5888                    Ok(keyring_key.as_str()),
5889                    "{provider_arg} keyring handoff missed {var}"
5890                );
5891            }
5892            assert_eq!(
5893                std::env::var(codewhale_config::CLI_API_KEY_SOURCE_ENV).as_deref(),
5894                Ok("keyring")
5895            );
5896            assert!(std::env::var(codewhale_config::CLI_API_KEY_ENV).is_err());
5897            assert!(
5898                std::env::var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV).is_err(),
5899                "new dispatchers must not write the retired vendor-named marker"
5900            );
5901
5902            clear_bridge();
5903            let explicit_key = format!("{provider_arg}-explicit-key");
5904            let explicit_cli = parse_ok(&[
5905                "codewhale",
5906                "--provider",
5907                provider_arg,
5908                "--api-key",
5909                explicit_key.as_str(),
5910            ]);
5911            let explicit_runtime = resolved_runtime_for_test(provider, ProviderSource::Cli);
5912
5913            apply_tui_env(&explicit_cli, &explicit_runtime, &[]);
5914
5915            assert_eq!(
5916                std::env::var("DEEPSEEK_API_KEY").as_deref(),
5917                Ok("existing-deepseek-key"),
5918                "{provider_arg} explicit CLI credential handoff replaced DeepSeek's credential"
5919            );
5920            for var in provider_env_vars(provider) {
5921                assert_eq!(
5922                    std::env::var(var).as_deref(),
5923                    Ok(explicit_key.as_str()),
5924                    "{provider_arg} explicit CLI credential handoff missed {var}"
5925                );
5926            }
5927            assert_eq!(
5928                std::env::var(codewhale_config::CLI_API_KEY_ENV).as_deref(),
5929                Ok(explicit_key.as_str())
5930            );
5931            assert_eq!(
5932                std::env::var(codewhale_config::CLI_API_KEY_SOURCE_ENV).as_deref(),
5933                Ok("cli")
5934            );
5935            assert!(std::env::var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV).is_err());
5936        }
5937    }
5938
5939    #[test]
5940    fn yolo_flag_writes_only_the_codewhale_env_var() {
5941        let _lock = env_lock();
5942        let _guards = [
5943            ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
5944            ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
5945            ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
5946            ScopedEnvVar::remove("CODEWHALE_YOLO"),
5947            ScopedEnvVar::remove("DEEPSEEK_YOLO"),
5948        ];
5949
5950        let cli = parse_ok(&["codewhale", "--yolo"]);
5951        let runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
5952        apply_tui_env(&cli, &runtime, &[]);
5953
5954        assert_eq!(
5955            std::env::var("CODEWHALE_YOLO").as_deref(),
5956            Ok("true"),
5957            "--yolo must still enable the posture via CODEWHALE_YOLO"
5958        );
5959        assert!(
5960            std::env::var("DEEPSEEK_YOLO").is_err(),
5961            "--yolo must not write the retired DEEPSEEK_YOLO alias (#5443)"
5962        );
5963    }
5964
5965    #[test]
5966    fn clap_command_definition_is_consistent() {
5967        Cli::command().debug_assert();
5968    }
5969
5970    // Regression for #767: `run_cli` prints the full anyhow chain so users
5971    // see the underlying TOML parser error (line/column, expected token)
5972    // instead of just the top-level "failed to parse config at <path>"
5973    // wrapper. anyhow's bare `Display` impl drops the chain — pin both
5974    // pieces here so a future refactor of the printing path doesn't
5975    // silently regress.
5976    #[test]
5977    fn anyhow_chain_surfaces_toml_parse_cause() {
5978        use anyhow::Context;
5979        let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
5980        let err = Err::<(), _>(inner)
5981            .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
5982            .unwrap_err();
5983
5984        // What `eprintln!("error: {err}")` prints (top context only).
5985        assert_eq!(
5986            err.to_string(),
5987            "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
5988        );
5989
5990        // What the `for cause in err.chain().skip(1)` loop iterates over.
5991        let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
5992        assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
5993    }
5994
5995    #[test]
5996    fn malformed_persisted_mcp_json_omits_secret_contents_and_keys() {
5997        let secret = "sentinel";
5998        let raw =
5999            format!(r#"[{{"name":"private","env":{{"PRIVATE_TOKEN":"{secret}"}} trailing-junk}}]"#);
6000        let error = parse_mcp_server_definitions(&raw).expect_err("malformed JSON must fail");
6001        let diagnostic = format!("{error:#}");
6002        assert!(!diagnostic.contains(secret), "{diagnostic}");
6003        assert!(!diagnostic.contains("PRIVATE_TOKEN"), "{diagnostic}");
6004        assert!(diagnostic.contains("contents were omitted"), "{diagnostic}");
6005    }
6006
6007    #[test]
6008    fn parses_config_command_matrix() {
6009        let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
6010        assert!(matches!(
6011            cli.command,
6012            Some(Commands::Config(ConfigArgs {
6013                command: ConfigCommand::Get { ref key }
6014            })) if key == "provider"
6015        ));
6016
6017        let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
6018        assert!(matches!(
6019            cli.command,
6020            Some(Commands::Config(ConfigArgs {
6021                command: ConfigCommand::Set { ref key, ref value }
6022            })) if key == "model" && value == "deepseek-v4-flash"
6023        ));
6024
6025        let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
6026        assert!(matches!(
6027            cli.command,
6028            Some(Commands::Config(ConfigArgs {
6029                command: ConfigCommand::Unset { ref key }
6030            })) if key == "model"
6031        ));
6032
6033        assert!(matches!(
6034            parse_ok(&["deepseek", "config", "list"]).command,
6035            Some(Commands::Config(ConfigArgs {
6036                command: ConfigCommand::List
6037            }))
6038        ));
6039        assert!(matches!(
6040            parse_ok(&["deepseek", "config", "path"]).command,
6041            Some(Commands::Config(ConfigArgs {
6042                command: ConfigCommand::Path
6043            }))
6044        ));
6045        assert!(matches!(
6046            parse_ok(&["codewhale", "config", "edit"]).command,
6047            Some(Commands::Config(ConfigArgs {
6048                command: ConfigCommand::Edit
6049            }))
6050        ));
6051        assert!(matches!(
6052            parse_ok(&["codewhale", "config", "doctor"]).command,
6053            Some(Commands::Config(ConfigArgs {
6054                command: ConfigCommand::Doctor
6055            }))
6056        ));
6057        assert!(matches!(
6058            parse_ok(&["codewhale", "config", "dump"]).command,
6059            Some(Commands::Config(ConfigArgs {
6060                command: ConfigCommand::Dump
6061            }))
6062        ));
6063    }
6064
6065    #[test]
6066    fn parses_repeatable_global_set_overrides() {
6067        let cli = parse_ok(&[
6068            "codewhale",
6069            "--set",
6070            "verbosity=concise",
6071            "--set",
6072            "model=deepseek-v4-flash",
6073            "config",
6074            "get",
6075            "verbosity",
6076        ]);
6077        assert_eq!(
6078            cli.overrides,
6079            vec![
6080                "verbosity=concise".to_string(),
6081                "model=deepseek-v4-flash".to_string()
6082            ]
6083        );
6084    }
6085
6086    #[test]
6087    fn config_doctor_is_clean_on_minimal_config() {
6088        let temp = tempfile::tempdir().expect("tempdir");
6089        let path = temp.path().join("config.toml");
6090        write_config_fixture(&path, "verbosity = \"concise\"\n");
6091        let store = ConfigStore::load(Some(path)).expect("load fixture");
6092        run_config_doctor(&store).expect("clean doctor");
6093    }
6094
6095    #[test]
6096    fn config_doctor_preserves_keys_owned_by_other_readers() {
6097        let temp = tempfile::tempdir().expect("tempdir");
6098        let path = temp.path().join("config.toml");
6099        write_config_fixture(&path, "zzz_unknown = 1\n");
6100        let store = ConfigStore::load(Some(path)).expect("load fixture");
6101        assert!(!store.config.extras.is_empty());
6102        run_config_doctor(&store).expect("extras do not establish unsupported settings");
6103    }
6104
6105    #[test]
6106    fn config_doctor_fails_on_empty_secret_and_bad_url() {
6107        let temp = tempfile::tempdir().expect("tempdir");
6108        let path = temp.path().join("config.toml");
6109        write_config_fixture(&path, "api_key = \"\"\nbase_url = \"gopher://x\"\n");
6110        let store = ConfigStore::load(Some(path)).expect("load fixture");
6111        let error = run_config_doctor(&store).expect_err("doctor must fail");
6112        let message = format!("{error:#}");
6113        assert!(
6114            message.contains("api_key") && message.contains("empty"),
6115            "{message}"
6116        );
6117        assert!(
6118            message.contains("base_url") && message.contains("http"),
6119            "{message}"
6120        );
6121    }
6122
6123    #[test]
6124    fn per_run_overrides_apply_in_memory_and_never_save() {
6125        let temp = tempfile::tempdir().expect("tempdir");
6126        let path = temp.path().join("config.toml");
6127        write_config_fixture(&path, "verbosity = \"normal\"\n");
6128        let mut store = ConfigStore::load(Some(path.clone())).expect("load fixture");
6129        apply_per_run_overrides(&mut store, &["verbosity=concise".to_string()])
6130            .expect("overlay applies");
6131        assert_eq!(store.config.verbosity.as_deref(), Some("concise"));
6132        let error = apply_per_run_overrides(&mut store, &["no-equals-here".to_string()])
6133            .expect_err("missing = must fail");
6134        assert!(format!("{error:#}").contains("KEY=VALUE"));
6135        // Nothing was saved: a reload sees the file, not the overlay.
6136        let reloaded = ConfigStore::load(Some(path)).expect("reload");
6137        assert_eq!(reloaded.config.verbosity.as_deref(), Some("normal"));
6138    }
6139
6140    #[test]
6141    fn unsupported_nested_config_set_preserves_original_file_bytes() {
6142        let temp = tempfile::tempdir().unwrap();
6143        let path = temp.path().join("config.toml");
6144        let original =
6145            "# Keep this comment and spacing\n[tools]\nuser_input_timeout_seconds = 7 # fixture\n";
6146        write_config_fixture(&path, original);
6147        let mut store = ConfigStore::load(Some(path.clone())).unwrap();
6148        let err = run_config_command(
6149            &mut store,
6150            ConfigCommand::Set {
6151                key: "tools.user_input_timeout_seconds".into(),
6152                value: "0".into(),
6153            },
6154            false,
6155            &[],
6156        )
6157        .unwrap_err();
6158        assert!(err.to_string().contains("[tools]"));
6159        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6160    }
6161
6162    #[test]
6163    fn mutating_config_commands_refuse_per_run_overrides() {
6164        let temp = tempfile::tempdir().expect("tempdir");
6165        let path = temp.path().join("config.toml");
6166        write_config_fixture(&path, "verbosity = \"normal\"\n");
6167        let mut store = ConfigStore::load(Some(path)).expect("load fixture");
6168        let overrides = vec!["verbosity=concise".to_string()];
6169        let error = run_config_command(
6170            &mut store,
6171            ConfigCommand::Set {
6172                key: "verbosity".to_string(),
6173                value: "concise".to_string(),
6174            },
6175            false,
6176            &overrides,
6177        )
6178        .expect_err("set with --set must refuse");
6179        assert!(format!("{error:#}").contains("--set"), "{error:#}");
6180        // Reads still work under an overlay.
6181        run_config_command(&mut store, ConfigCommand::List, false, &overrides)
6182            .expect("list with --set");
6183    }
6184
6185    fn config_dispatch_from(
6186        argv: &[OsString],
6187        cwd: &Path,
6188    ) -> (Option<PathBuf>, ConfigCommand, bool) {
6189        let matches = Cli::command()
6190            .try_get_matches_from(argv.iter().cloned())
6191            .unwrap_or_else(|error| panic!("config command should parse: {error}"));
6192        let project_bundle_scope = config_command_targets_project(&matches);
6193        let cli = Cli::from_arg_matches(&matches)
6194            .unwrap_or_else(|error| panic!("config command should decode: {error}"));
6195        let selected_path = config_store_path_for_dispatch(cli.config, project_bundle_scope, cwd);
6196        let Some(Commands::Config(ConfigArgs { command })) = cli.command else {
6197            panic!("expected config command");
6198        };
6199        (selected_path, command, project_bundle_scope)
6200    }
6201
6202    fn write_config_fixture(path: &Path, body: &str) {
6203        std::fs::create_dir_all(path.parent().expect("config should have a parent"))
6204            .expect("create config parent");
6205        std::fs::write(path, body).expect("write config fixture");
6206    }
6207
6208    #[test]
6209    fn project_config_dispatch_prefers_current_app_dir_and_falls_back_to_legacy() {
6210        let temp = tempfile::tempdir().expect("tempdir");
6211        let workspace = temp.path().join("workspace");
6212        let current = workspace.join(".codewhale/config.toml");
6213        let legacy = workspace.join(".deepseek/config.toml");
6214
6215        // Fresh workspace: create under the current app dir.
6216        std::fs::create_dir_all(&workspace).expect("workspace");
6217        assert_eq!(
6218            config_store_path_for_dispatch(None, true, &workspace),
6219            Some(current.clone())
6220        );
6221
6222        // Legacy-only workspace: operate on the legacy document in place.
6223        write_config_fixture(&legacy, "verbosity = \"legacy\"\n");
6224        assert_eq!(
6225            config_store_path_for_dispatch(None, true, &workspace),
6226            Some(legacy.clone())
6227        );
6228
6229        // Both present: the current app dir wins, matching the loader.
6230        write_config_fixture(&current, "verbosity = \"current\"\n");
6231        assert_eq!(
6232            config_store_path_for_dispatch(None, true, &workspace),
6233            Some(current.clone())
6234        );
6235
6236        // An explicit --config path always wins; without --project nothing is selected.
6237        let explicit = temp.path().join("explicit.toml");
6238        assert_eq!(
6239            config_store_path_for_dispatch(Some(explicit.clone()), true, &workspace),
6240            Some(explicit)
6241        );
6242        assert_eq!(
6243            config_store_path_for_dispatch(None, false, &workspace),
6244            None
6245        );
6246    }
6247
6248    #[test]
6249    fn project_config_import_dispatches_to_the_cwd_document() {
6250        let temp = tempfile::tempdir().expect("tempdir");
6251        let workspace = temp.path().join("workspace");
6252        std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker");
6253        let project_path = workspace.join(".codewhale/config.toml");
6254        let global_path = temp.path().join("global-config.toml");
6255        write_config_fixture(&project_path, "verbosity = \"project-before\"\n");
6256        write_config_fixture(&global_path, "verbosity = \"global-only\"\n");
6257
6258        let bundle_path = temp.path().join("project-bundle.toml");
6259        std::fs::write(
6260            &bundle_path,
6261            r#"schema_version = 1
6262kind = "codewhale.portable-config"
6263
6264[project]
6265verbosity = "project-imported"
6266"#,
6267        )
6268        .expect("write project bundle");
6269        let argv = [
6270            OsString::from("codewhale"),
6271            OsString::from("config"),
6272            OsString::from("import"),
6273            bundle_path.as_os_str().to_owned(),
6274            OsString::from("--yes"),
6275            OsString::from("--project"),
6276        ];
6277        let (selected_path, command, project_bundle_scope) =
6278            config_dispatch_from(&argv, &workspace);
6279        assert_eq!(selected_path.as_deref(), Some(project_path.as_path()));
6280
6281        let mut store = ConfigStore::load(selected_path).expect("load selected project config");
6282        run_config_command(&mut store, command, project_bundle_scope, &[])
6283            .expect("import project bundle");
6284        let project = ConfigStore::load(Some(project_path.clone())).expect("reload project");
6285        let global = ConfigStore::load(Some(global_path.clone())).expect("reload global");
6286        assert_eq!(
6287            project.config.verbosity.as_deref(),
6288            Some("project-imported")
6289        );
6290        assert_eq!(global.config.verbosity.as_deref(), Some("global-only"));
6291
6292        let explicit_argv = [
6293            OsString::from("codewhale"),
6294            OsString::from("--config"),
6295            global_path.as_os_str().to_owned(),
6296            OsString::from("config"),
6297            OsString::from("import"),
6298            bundle_path.as_os_str().to_owned(),
6299            OsString::from("--yes"),
6300            OsString::from("--project"),
6301        ];
6302        let global_before = std::fs::read(&global_path).expect("read global before refusal");
6303        let (selected_path, command, project_bundle_scope) =
6304            config_dispatch_from(&explicit_argv, &workspace);
6305        assert_eq!(selected_path.as_deref(), Some(global_path.as_path()));
6306        let mut explicit_store =
6307            ConfigStore::load(selected_path).expect("load explicit global config");
6308        let error = run_config_command(&mut explicit_store, command, project_bundle_scope, &[])
6309            .expect_err("project import must reject an explicit non-workspace config");
6310        assert!(
6311            error
6312                .to_string()
6313                .contains("--project requires a workspace config"),
6314            "{error:#}"
6315        );
6316        assert_eq!(
6317            std::fs::read(&global_path).expect("read global after refusal"),
6318            global_before
6319        );
6320    }
6321
6322    #[test]
6323    fn project_config_export_reads_the_cwd_document() {
6324        let temp = tempfile::tempdir().expect("tempdir");
6325        let workspace = temp.path().join("workspace");
6326        std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker");
6327        let project_path = workspace.join(".codewhale/config.toml");
6328        let global_path = temp.path().join("global-config.toml");
6329        let output_path = temp.path().join("portable.toml");
6330        write_config_fixture(&project_path, "verbosity = \"project-only\"\n");
6331        write_config_fixture(&global_path, "verbosity = \"global-only\"\n");
6332
6333        let argv = [
6334            OsString::from("codewhale"),
6335            OsString::from("config"),
6336            OsString::from("export"),
6337            OsString::from("--portable"),
6338            OsString::from("--project"),
6339            OsString::from("--out"),
6340            output_path.as_os_str().to_owned(),
6341        ];
6342        let (selected_path, command, project_bundle_scope) =
6343            config_dispatch_from(&argv, &workspace);
6344        assert_eq!(selected_path.as_deref(), Some(project_path.as_path()));
6345
6346        let mut store = ConfigStore::load(selected_path).expect("load selected project config");
6347        run_config_command(&mut store, command, project_bundle_scope, &[])
6348            .expect("export project bundle");
6349        let body = std::fs::read_to_string(&output_path).expect("read portable export");
6350        let bundle = config_bundles::parse_bundle_str(&body, "portable.toml")
6351            .expect("parse portable export");
6352        assert_eq!(
6353            bundle
6354                .project
6355                .entries
6356                .get("verbosity")
6357                .and_then(toml::Value::as_str),
6358            Some("project-only")
6359        );
6360        assert!(bundle.global.entries.is_empty());
6361
6362        let explicit_output_path = temp.path().join("explicit-portable.toml");
6363        let explicit_argv = [
6364            OsString::from("codewhale"),
6365            OsString::from("--config"),
6366            global_path.as_os_str().to_owned(),
6367            OsString::from("config"),
6368            OsString::from("export"),
6369            OsString::from("--portable"),
6370            OsString::from("--project"),
6371            OsString::from("--out"),
6372            explicit_output_path.as_os_str().to_owned(),
6373        ];
6374        let global_before = std::fs::read(&global_path).expect("read global before refusal");
6375        let (selected_path, command, project_bundle_scope) =
6376            config_dispatch_from(&explicit_argv, &workspace);
6377        assert_eq!(selected_path.as_deref(), Some(global_path.as_path()));
6378        let mut explicit_store =
6379            ConfigStore::load(selected_path).expect("load explicit global config");
6380        let error = run_config_command(&mut explicit_store, command, project_bundle_scope, &[])
6381            .expect_err("project export must reject an explicit non-workspace config");
6382        assert!(
6383            error
6384                .to_string()
6385                .contains("--project requires a workspace config"),
6386            "{error:#}"
6387        );
6388        assert!(!explicit_output_path.exists());
6389        assert_eq!(
6390            std::fs::read(&global_path).expect("read global after refusal"),
6391            global_before
6392        );
6393    }
6394
6395    #[test]
6396    fn parses_update_beta_flag() {
6397        let cli = parse_ok(&["codewhale", "update"]);
6398        assert!(matches!(
6399            cli.command,
6400            Some(Commands::Update(UpdateArgs {
6401                beta: false,
6402                check: false,
6403                proxy: None
6404            }))
6405        ));
6406
6407        let cli = parse_ok(&["codewhale", "update", "--beta"]);
6408        assert!(matches!(
6409            cli.command,
6410            Some(Commands::Update(UpdateArgs {
6411                beta: true,
6412                check: false,
6413                proxy: None
6414            }))
6415        ));
6416
6417        let cli = parse_ok(&["codewhale", "update", "--check"]);
6418        assert!(matches!(
6419            cli.command,
6420            Some(Commands::Update(UpdateArgs {
6421                beta: false,
6422                check: true,
6423                proxy: None
6424            }))
6425        ));
6426
6427        let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
6428        let Some(Commands::Update(args)) = cli.command else {
6429            panic!("expected update command");
6430        };
6431        assert!(!args.beta);
6432        assert!(!args.check);
6433        assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
6434    }
6435
6436    #[test]
6437    fn parses_model_command_matrix() {
6438        let cli = parse_ok(&["deepseek", "model", "list"]);
6439        assert!(matches!(
6440            cli.command,
6441            Some(Commands::Model(ModelArgs {
6442                command: ModelCommand::List { provider: None }
6443            }))
6444        ));
6445
6446        let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
6447        assert!(matches!(
6448            cli.command,
6449            Some(Commands::Model(ModelArgs {
6450                command: ModelCommand::List {
6451                    provider: Some(ProviderKind::Openai)
6452                }
6453            }))
6454        ));
6455
6456        let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
6457        assert!(matches!(
6458            cli.command,
6459            Some(Commands::Model(ModelArgs {
6460                command: ModelCommand::Resolve {
6461                    model: Some(ref model),
6462                    provider: None
6463                }
6464            })) if model == "deepseek-v4-flash"
6465        ));
6466
6467        let cli = parse_ok(&[
6468            "deepseek",
6469            "model",
6470            "resolve",
6471            "--provider",
6472            "deepseek",
6473            "deepseek-v4-pro",
6474        ]);
6475        assert!(matches!(
6476            cli.command,
6477            Some(Commands::Model(ModelArgs {
6478                command: ModelCommand::Resolve {
6479                    model: Some(ref model),
6480                    provider: Some(ProviderKind::Deepseek)
6481                }
6482            })) if model == "deepseek-v4-pro"
6483        ));
6484
6485        let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
6486        assert!(matches!(
6487            cli.command,
6488            Some(Commands::Model(ModelArgs {
6489                command: ModelCommand::Set { ref model }
6490            })) if model == "pro"
6491        ));
6492    }
6493
6494    #[test]
6495    fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
6496        assert_eq!(
6497            model_command_provider_hint(None, Some(ProviderKind::Zai)),
6498            Some(ProviderKind::Zai)
6499        );
6500        assert_eq!(
6501            model_command_provider_hint(Some(ProviderKind::Minimax), Some(ProviderKind::Zai)),
6502            Some(ProviderKind::Minimax)
6503        );
6504        assert_eq!(model_command_provider_hint(None, None), None);
6505
6506        let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
6507        assert_eq!(cli.provider.as_deref(), Some("zai"));
6508        assert!(matches!(
6509            cli.command,
6510            Some(Commands::Model(ModelArgs {
6511                command: ModelCommand::List { provider: None }
6512            }))
6513        ));
6514    }
6515
6516    #[test]
6517    fn durable_cli_route_edits_use_canonical_config_and_keep_temporary_overrides_unsaved() {
6518        let _env = env_lock();
6519        let home = tempfile::tempdir().expect("isolated home");
6520        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
6521        let _config_path = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
6522        let _legacy_config_path = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6523        let path = home.path().join("config.toml");
6524        std::fs::write(&path, "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n[providers.zai]\nmodel = \"GLM-5.2\"\n").unwrap();
6525        let settings_path = home.path().join("settings.toml");
6526        let settings = "default_provider = \"zai\"\n[provider_models]\nzai = \"GLM-5.3\"\n";
6527        std::fs::write(&settings_path, settings).unwrap();
6528        let mut store = ConfigStore::load(Some(path.clone())).unwrap();
6529        let runtime = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
6530        run_model_command(
6531            &mut store,
6532            ModelCommand::Set {
6533                model: "GLM-5.2".into(),
6534            },
6535            None,
6536            &runtime,
6537        )
6538        .unwrap();
6539        assert_eq!(store.config.provider, ProviderKind::Zai);
6540        assert_eq!(store.config.providers.zai.model.as_deref(), Some("GLM-5.2"));
6541        assert_eq!(
6542            store.config.extras["route_preferences_version"].as_integer(),
6543            Some(1)
6544        );
6545
6546        run_config_command(
6547            &mut store,
6548            ConfigCommand::Set {
6549                key: "default_text_model".into(),
6550                value: "GLM-5.1".into(),
6551            },
6552            false,
6553            &[],
6554        )
6555        .unwrap();
6556        assert_eq!(store.config.providers.zai.model.as_deref(), Some("GLM-5.1"));
6557        assert_eq!(
6558            codewhale_tui::route_preferences::get(&path, "model")
6559                .unwrap()
6560                .as_deref(),
6561            Some("GLM-5.1")
6562        );
6563        run_config_command(
6564            &mut store,
6565            ConfigCommand::Unset {
6566                key: "providers.zai.model".into(),
6567            },
6568            false,
6569            &[],
6570        )
6571        .unwrap();
6572        assert!(store.config.providers.zai.model.is_none());
6573        assert_eq!(std::fs::read_to_string(settings_path).unwrap(), settings);
6574
6575        let before = std::fs::read(&path).unwrap();
6576        let overrides = vec!["model=temporary-model".to_string()];
6577        assert!(
6578            run_config_command(
6579                &mut store,
6580                ConfigCommand::Set {
6581                    key: "model".into(),
6582                    value: "GLM-5.2".into(),
6583                },
6584                false,
6585                &overrides
6586            )
6587            .is_err()
6588        );
6589        apply_per_run_overrides(&mut store, &overrides).unwrap();
6590        assert_eq!(store.config.model.as_deref(), Some("temporary-model"));
6591        assert_eq!(std::fs::read(&path).unwrap(), before);
6592    }
6593
6594    #[test]
6595    fn model_set_canonicalizes_deepseek_vision_aliases() {
6596        for alias in ["flash-vision", "deepseek-v4flashvisionexp"] {
6597            assert_eq!(
6598                canonical_model_for_set(alias),
6599                "deepseek-v4-flash-vision-exp"
6600            );
6601        }
6602        assert_eq!(
6603            canonical_model_for_set("deepseek-v4-flash-vision-exp"),
6604            "deepseek-v4-flash-vision-exp"
6605        );
6606    }
6607
6608    #[test]
6609    fn parses_thread_command_matrix() {
6610        let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
6611        assert!(matches!(
6612            cli.command,
6613            Some(Commands::Thread(ThreadArgs {
6614                command: ThreadCommand::List {
6615                    all: true,
6616                    limit: Some(50)
6617                }
6618            }))
6619        ));
6620
6621        let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
6622        assert!(matches!(
6623            cli.command,
6624            Some(Commands::Thread(ThreadArgs {
6625                command: ThreadCommand::Read { ref thread_id }
6626            })) if thread_id == "thread-1"
6627        ));
6628
6629        let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
6630        assert!(matches!(
6631            cli.command,
6632            Some(Commands::Thread(ThreadArgs {
6633                command: ThreadCommand::Resume { ref thread_id }
6634            })) if thread_id == "thread-2"
6635        ));
6636
6637        let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
6638        assert!(matches!(
6639            cli.command,
6640            Some(Commands::Thread(ThreadArgs {
6641                command: ThreadCommand::Fork { ref thread_id }
6642            })) if thread_id == "thread-3"
6643        ));
6644
6645        let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
6646        assert!(matches!(
6647            cli.command,
6648            Some(Commands::Thread(ThreadArgs {
6649                command: ThreadCommand::Archive { ref thread_id }
6650            })) if thread_id == "thread-4"
6651        ));
6652
6653        let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
6654        assert!(matches!(
6655            cli.command,
6656            Some(Commands::Thread(ThreadArgs {
6657                command: ThreadCommand::Unarchive { ref thread_id }
6658            })) if thread_id == "thread-5"
6659        ));
6660
6661        let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
6662        assert!(matches!(
6663            cli.command,
6664            Some(Commands::Thread(ThreadArgs {
6665                command: ThreadCommand::SetName {
6666                    ref thread_id,
6667                    ref name
6668                }
6669            })) if thread_id == "thread-6" && name == "My Thread"
6670        ));
6671
6672        let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
6673        assert!(matches!(
6674            cli.command,
6675            Some(Commands::Thread(ThreadArgs {
6676                command: ThreadCommand::ClearName { ref thread_id }
6677            })) if thread_id == "thread-7"
6678        ));
6679    }
6680
6681    #[test]
6682    fn parses_sandbox_app_server_and_completion_matrix() {
6683        let cli = parse_ok(&[
6684            "deepseek",
6685            "sandbox",
6686            "check",
6687            "echo hello",
6688            "--ask",
6689            "on-failure",
6690        ]);
6691        assert!(matches!(
6692            cli.command,
6693            Some(Commands::Sandbox(SandboxArgs {
6694                command: SandboxCommand::Check {
6695                    ref command,
6696                    ask: ApprovalModeArg::OnFailure
6697                }
6698            })) if command == "echo hello"
6699        ));
6700
6701        let cli = parse_ok(&[
6702            "deepseek",
6703            "app-server",
6704            "--host",
6705            "0.0.0.0",
6706            "--port",
6707            "9999",
6708        ]);
6709        assert!(matches!(
6710            cli.command,
6711            Some(Commands::AppServer(AppServerArgs {
6712                host: Some(ref host),
6713                port: Some(9999),
6714                stdio: false,
6715                http: false,
6716                mobile: false,
6717                ..
6718            })) if host == "0.0.0.0"
6719        ));
6720
6721        let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
6722        assert!(matches!(
6723            cli.command,
6724            Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
6725        ));
6726
6727        let cli = parse_ok(&["deepseek", "completion", "bash"]);
6728        assert!(matches!(
6729            cli.command,
6730            Some(Commands::Completion { shell: Shell::Bash })
6731        ));
6732    }
6733
6734    /// The `[[bin]] name` declared in this crate's manifest is the only thing a
6735    /// user ever types. Read it from disk rather than restating it, so renaming
6736    /// the binary without re-pointing the completion generator fails here
6737    /// instead of silently shipping a script nobody's shell loads (#5526).
6738    fn declared_bin_name() -> String {
6739        let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
6740            .expect("read crates/cli/Cargo.toml");
6741        let bin_section = manifest
6742            .split("[[bin]]")
6743            .nth(1)
6744            .expect("crates/cli/Cargo.toml declares a [[bin]] target");
6745        for line in bin_section.lines() {
6746            let line = line.trim();
6747            if let Some(rest) = line.strip_prefix("name") {
6748                let value = rest.trim_start().trim_start_matches('=').trim();
6749                return value.trim_matches('"').to_string();
6750            }
6751        }
6752        panic!("[[bin]] section has no name key");
6753    }
6754
6755    #[test]
6756    fn completion_bin_name_matches_the_declared_bin_target() {
6757        assert_eq!(
6758            COMPLETION_BIN_NAME,
6759            declared_bin_name(),
6760            "completion scripts must register the binary this crate actually builds"
6761        );
6762    }
6763
6764    /// Issue #5526: `codewhale completions <shell>` used to forward to the
6765    /// in-tree `codewhale-tui` binary, so every generated script registered
6766    /// `codewhale-tui` — not a GitHub-release command — and exposed the TUI's
6767    /// smaller subcommand tree. Pin the registered names per shell.
6768    #[test]
6769    fn generated_completion_scripts_register_the_published_command_names() {
6770        let bin = declared_bin_name();
6771        let alias = COMPLETION_ALIAS_NAME;
6772
6773        // Match whole lines throughout: `codew` is a prefix of `codewhale`,
6774        // so a substring check for the alias is satisfied by the primary
6775        // binding and would pass on an unfixed build.
6776        let has_line =
6777            |script: &str, wanted: &str| script.lines().any(|line| line.trim() == wanted);
6778
6779        let bash = render_completion_script(Shell::Bash);
6780        assert!(
6781            has_line(
6782                &bash,
6783                &format!("complete -F _{bin} -o bashdefault -o default {bin}")
6784            ),
6785            "bash script must bind the real binary name:\n{bash}"
6786        );
6787        assert!(
6788            has_line(
6789                &bash,
6790                &format!("complete -F _{bin} -o bashdefault -o default {alias}")
6791            ),
6792            "bash script must bind the {alias} shorthand too"
6793        );
6794
6795        let zsh = render_completion_script(Shell::Zsh);
6796        assert_eq!(
6797            zsh.lines().next(),
6798            Some(format!("#compdef {bin} {alias}").as_str()),
6799            "zsh compdef tag line must list both published command names"
6800        );
6801        assert!(
6802            has_line(&zsh, &format!("compdef _{bin} {bin}")),
6803            "zsh script must bind {bin} on the sourced path"
6804        );
6805        assert!(
6806            has_line(&zsh, &format!("compdef _{bin} {alias}")),
6807            "zsh script must bind {alias} on the sourced path too"
6808        );
6809
6810        let fish = render_completion_script(Shell::Fish);
6811        assert!(
6812            fish.contains(&format!("complete -c {bin} ")),
6813            "fish script must complete the real binary name"
6814        );
6815        assert!(
6816            has_line(&fish, &format!("complete -c {alias} -w {bin}")),
6817            "fish script must wrap the {alias} shorthand onto {bin}"
6818        );
6819
6820        let powershell = render_completion_script(Shell::PowerShell);
6821        assert!(
6822            powershell.contains(&format!(
6823                "Register-ArgumentCompleter -Native -CommandName '{bin}','{alias}'"
6824            )),
6825            "PowerShell script must register both published command names"
6826        );
6827
6828        let elvish = render_completion_script(Shell::Elvish);
6829        assert!(
6830            has_line(
6831                &elvish,
6832                &format!("set edit:completion:arg-completer[{bin}] = {{|@words|")
6833            ),
6834            "elvish script must bind the real binary name:\n{elvish}"
6835        );
6836        assert!(
6837            has_line(
6838                &elvish,
6839                &format!(
6840                    "set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]"
6841                )
6842            ),
6843            "elvish script must alias the {alias} shorthand onto {bin}"
6844        );
6845
6846        for (shell, script) in [
6847            ("bash", &bash),
6848            ("zsh", &zsh),
6849            ("fish", &fish),
6850            ("powershell", &powershell),
6851            ("elvish", &elvish),
6852        ] {
6853            assert!(
6854                !script.contains("codewhale-tui"),
6855                "{shell} completions leaked the in-tree codewhale-tui name (#5526)"
6856            );
6857        }
6858    }
6859
6860    /// The other half of #5526: the script has to describe *this* CLI's
6861    /// commands. Rendering from a different clap tree would drop or invent
6862    /// subcommands, which is exactly how the forwarded script went stale.
6863    #[test]
6864    fn generated_completion_scripts_cover_the_real_subcommand_surface() {
6865        let bash = render_completion_script(Shell::Bash);
6866        for sub in Cli::command().get_subcommands() {
6867            if sub.is_hide_set() {
6868                continue;
6869            }
6870            let name = sub.get_name();
6871            assert!(
6872                bash.contains(name),
6873                "bash completions omit the `{name}` subcommand"
6874            );
6875        }
6876    }
6877
6878    /// `completions` is what the issue reporter typed and what the TUI called
6879    /// it; keep it working, now as an alias that renders in-process.
6880    #[test]
6881    fn completions_is_an_alias_for_completion() {
6882        assert!(matches!(
6883            parse_ok(&["codewhale", "completions", "powershell"]).command,
6884            Some(Commands::Completion {
6885                shell: Shell::PowerShell
6886            })
6887        ));
6888    }
6889
6890    #[test]
6891    fn app_server_transports_are_mutually_exclusive() {
6892        assert!(matches!(
6893            parse_ok(&["deepseek", "app-server", "--http"]).command,
6894            Some(Commands::AppServer(AppServerArgs {
6895                http: true,
6896                mobile: false,
6897                stdio: false,
6898                ..
6899            }))
6900        ));
6901        assert!(matches!(
6902            parse_ok(&["deepseek", "app-server", "--mobile"]).command,
6903            Some(Commands::AppServer(AppServerArgs {
6904                mobile: true,
6905                http: false,
6906                stdio: false,
6907                ..
6908            }))
6909        ));
6910
6911        assert!(matches!(
6912            parse_ok(&["deepseek", "app-server", "--socket"]).command,
6913            Some(Commands::AppServer(AppServerArgs {
6914                socket: true,
6915                socket_path: None,
6916                http: false,
6917                mobile: false,
6918                stdio: false,
6919                ..
6920            }))
6921        ));
6922
6923        for argv in [
6924            ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
6925            ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
6926            ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
6927            ["deepseek", "app-server", "--socket", "--stdio"].as_slice(),
6928            ["deepseek", "app-server", "--socket", "--http"].as_slice(),
6929            ["deepseek", "app-server", "--socket", "--mobile"].as_slice(),
6930        ] {
6931            let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
6932            assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
6933        }
6934    }
6935
6936    #[test]
6937    fn app_server_socket_path_requires_socket() {
6938        let err = Cli::try_parse_from(["deepseek", "app-server", "--socket-path", "/tmp/d.sock"])
6939            .expect_err("--socket-path without --socket must fail");
6940        assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
6941        match parse_ok(&[
6942            "deepseek",
6943            "app-server",
6944            "--socket",
6945            "--socket-path",
6946            "/tmp/d.sock",
6947        ])
6948        .command
6949        {
6950            Some(Commands::AppServer(AppServerArgs {
6951                socket: true,
6952                socket_path: Some(path),
6953                ..
6954            })) => assert_eq!(path, PathBuf::from("/tmp/d.sock")),
6955            other => panic!("unexpected parse: {other:?}"),
6956        }
6957    }
6958
6959    #[test]
6960    fn app_server_qr_requires_mobile() {
6961        let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
6962            .expect_err("--qr without --mobile must fail");
6963        assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
6964        assert!(matches!(
6965            parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
6966            Some(Commands::AppServer(AppServerArgs {
6967                mobile: true,
6968                qr: true,
6969                ..
6970            }))
6971        ));
6972    }
6973
6974    #[test]
6975    fn app_server_serve_passthrough_maps_flags_to_serve() {
6976        let args = AppServerArgs {
6977            http: true,
6978            mobile: false,
6979            stdio: false,
6980            socket: false,
6981            socket_path: None,
6982            qr: false,
6983            host: Some("127.0.0.1".to_string()),
6984            port: Some(9000),
6985            workers: Some(4),
6986            config: None,
6987            auth_token: Some("tok".to_string()),
6988            insecure_no_auth: true,
6989            cors_origin: vec!["http://localhost:5173".to_string()],
6990        };
6991        let argv = app_server_serve_passthrough(&args);
6992        let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
6993        // app-server's --insecure-no-auth maps onto serve's --insecure.
6994        assert_eq!(
6995            as_str,
6996            vec![
6997                "serve",
6998                "--http",
6999                "--host",
7000                "127.0.0.1",
7001                "--port",
7002                "9000",
7003                "--workers",
7004                "4",
7005                "--cors-origin",
7006                "http://localhost:5173",
7007                "--auth-token",
7008                "tok",
7009                "--insecure",
7010            ]
7011        );
7012    }
7013
7014    #[test]
7015    fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
7016        let args = AppServerArgs {
7017            http: false,
7018            mobile: true,
7019            stdio: false,
7020            socket: false,
7021            socket_path: None,
7022            qr: true,
7023            host: None,
7024            port: None,
7025            workers: None,
7026            config: None,
7027            auth_token: None,
7028            insecure_no_auth: false,
7029            cors_origin: vec![],
7030        };
7031        let argv = app_server_serve_passthrough(&args);
7032        let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
7033        // No host/port forwarded → serve applies its own --mobile 0.0.0.0 default.
7034        // No auth token is injected from the environment into child argv.
7035        assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
7036    }
7037
7038    #[test]
7039    fn web_command_is_typed_and_delegates_without_auth_material() {
7040        let cli = parse_ok(&["codewhale", "web", "--port", "9091"]);
7041        let args = match cli.command {
7042            Some(Commands::Web(args)) => args,
7043            other => panic!("expected web command, got {other:?}"),
7044        };
7045        assert_eq!(args.port, 9091);
7046        let forwarded = web_serve_passthrough(&args);
7047        assert_eq!(forwarded, ["serve", "--web", "--port", "9091"]);
7048        assert!(!forwarded.iter().any(|arg| arg.contains("token")));
7049    }
7050
7051    #[test]
7052    fn web_command_defaults_to_runtime_port_and_documents_bootstrap_boundary() {
7053        let cli = parse_ok(&["codewhale", "web"]);
7054        assert!(matches!(
7055            cli.command,
7056            Some(Commands::Web(WebArgs { port: 7878 }))
7057        ));
7058        let help = help_for(&["codewhale", "web", "--help"]);
7059        assert!(help.contains("--port"));
7060        assert!(help.contains("one-time loopback bootstrap"));
7061        assert!(!help.contains("--auth-token"));
7062    }
7063
7064    #[test]
7065    fn serve_help_documents_forwarded_runtime_modes() {
7066        let help = help_for(&["codewhale", "serve", "--help"]);
7067        for flag in ["--http", "--mobile", "--web", "--mcp", "--acp"] {
7068            assert!(
7069                help.contains(flag),
7070                "serve help should document forwarded flag {flag}; help was:\n{help}"
7071            );
7072        }
7073        assert!(help.contains("compatibility"));
7074    }
7075
7076    #[test]
7077    fn parses_direct_tui_command_aliases() {
7078        let cli = parse_ok(&["deepseek", "doctor"]);
7079        assert!(matches!(
7080            cli.command,
7081            Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
7082        ));
7083
7084        let cli = parse_ok(&["deepseek", "models", "--json"]);
7085        assert!(matches!(
7086            cli.command,
7087            Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
7088        ));
7089
7090        let cli = parse_ok(&["deepseek", "resume", "abc123"]);
7091        assert!(matches!(
7092            cli.command,
7093            Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
7094        ));
7095
7096        let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
7097        assert!(matches!(
7098            cli.command,
7099            Some(Commands::Setup(TuiPassthroughArgs { ref args }))
7100                if args == &["--skills", "--local"]
7101        ));
7102
7103        let cli = parse_ok(&["codewhale", "fleet", "init"]);
7104        assert!(cli.prompt.is_empty());
7105        assert!(matches!(
7106            cli.command,
7107            Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
7108        ));
7109
7110        let cli = parse_ok(&[
7111            "codewhale",
7112            "fleet",
7113            "run",
7114            "tasks.json",
7115            "--max-workers",
7116            "2",
7117        ]);
7118        assert!(cli.prompt.is_empty());
7119        assert!(matches!(
7120            cli.command,
7121            Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
7122                if args == &["run", "tasks.json", "--max-workers", "2"]
7123        ));
7124
7125        let cli = parse_ok(&[
7126            "codewhale",
7127            "workflow",
7128            "run",
7129            "stopship",
7130            "--fleet",
7131            "stopship",
7132            "--runtime",
7133            "tmux",
7134            "--issue",
7135            "4375",
7136        ]);
7137        assert!(matches!(
7138            cli.command,
7139            Some(Commands::Workflow(WorkflowArgs {
7140                command: WorkflowCommand::Run {
7141                    ref workflow,
7142                    ref fleet,
7143                    ref runtime,
7144                    ref issue,
7145                    ..
7146                }
7147            })) if workflow == "stopship"
7148                && fleet.as_deref() == Some("stopship")
7149                && runtime == "tmux"
7150                && issue.as_deref() == Some("4375")
7151        ));
7152    }
7153
7154    /// Fleet is the only top-level spelling for durable runs. The retired
7155    /// `pod` spelling must fail to parse instead of dispatching.
7156    #[test]
7157    fn fleet_is_the_only_top_level_command_and_pod_is_rejected() {
7158        for tail in [
7159            vec!["init"],
7160            vec!["status"],
7161            vec!["run", "tasks.json", "--max-workers", "2"],
7162        ] {
7163            let fleet = parse_ok(
7164                &std::iter::once("codewhale")
7165                    .chain(["fleet"])
7166                    .chain(tail.iter().copied())
7167                    .collect::<Vec<_>>(),
7168            );
7169            let Some(Commands::Fleet(fleet_args)) = &fleet.command else {
7170                panic!("fleet must parse into the fleet command: {tail:?}");
7171            };
7172            assert_eq!(fleet_args.args, tail, "{tail:?}");
7173            assert!(fleet.prompt.is_empty(), "{tail:?}");
7174
7175            let retired = parse_ok(
7176                &std::iter::once("codewhale")
7177                    .chain(["pod"])
7178                    .chain(tail.iter().copied())
7179                    .collect::<Vec<_>>(),
7180            );
7181            assert!(
7182                retired.command.is_none(),
7183                "retired pod must not dispatch to any command: {tail:?}"
7184            );
7185            assert_eq!(
7186                retired.prompt.first().map(String::as_str),
7187                Some("pod"),
7188                "retired pod words fall through to prompt text: {tail:?}"
7189            );
7190        }
7191
7192        // Help advertises fleet only.
7193        let help = help_for(&["codewhale", "--help"]);
7194        let commands = help
7195            .lines()
7196            .map(str::trim_start)
7197            .filter(|line| line.starts_with("fleet"))
7198            .collect::<Vec<_>>();
7199        assert_eq!(
7200            commands.len(),
7201            1,
7202            "expected exactly one entry: {commands:?}"
7203        );
7204        assert!(commands[0].starts_with("fleet"), "{commands:?}");
7205        assert!(
7206            commands[0].contains("fleet"),
7207            "help summary should name fleet: {commands:?}"
7208        );
7209        assert!(
7210            !help.contains("Manage durable Agent Fleet runs"),
7211            "the retired Fleet-led summary must be gone from top-level help"
7212        );
7213
7214        let fleet_help = help_for(&["codewhale", "fleet", "--help"]);
7215        assert!(fleet_help.contains("Manage durable Agent fleet runs"));
7216        assert!(fleet_help.contains("codewhale fleet run tasks.json --max-workers 4"));
7217
7218        // The inner command token matches the canonical name so receipts
7219        // and any echoed invocation never regress to the retired name.
7220        let args = TuiPassthroughArgs {
7221            args: vec!["status".into()],
7222        };
7223        assert_eq!(
7224            tui_args("fleet", args.clone()),
7225            vec!["fleet".to_string(), "status".to_string()]
7226        );
7227        assert!(command_accepts_raw_provider(Some(&Commands::Fleet(args))));
7228    }
7229
7230    #[test]
7231    fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
7232        let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
7233        assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
7234        assert_eq!(
7235            top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
7236                .expect("built-in Exec provider"),
7237            Some(ProviderKind::Openrouter)
7238        );
7239
7240        assert_eq!(
7241            top_level_provider_override(
7242                Some("qianfan"),
7243                Some(&Commands::Exec(TuiPassthroughArgs {
7244                    args: vec!["Reply OK".into()]
7245                }))
7246            )
7247            .expect("qianfan is a catalog route"),
7248            Some(ProviderKind::Qianfan)
7249        );
7250
7251        for (provider, command) in [
7252            ("lm-studio", vec!["exec", "Reply OK"]),
7253            ("lm-studio", vec!["fleet", "status"]),
7254        ] {
7255            let argv = std::iter::once("codewhale")
7256                .chain(["--provider", provider])
7257                .chain(command.iter().copied())
7258                .collect::<Vec<_>>();
7259            let cli = parse_ok(&argv);
7260            assert_eq!(cli.provider.as_deref(), Some(provider));
7261            assert_eq!(
7262                top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7263                    .expect("raw TUI provider"),
7264                None,
7265                "{argv:?} should defer the raw provider id to the TUI"
7266            );
7267        }
7268    }
7269
7270    #[test]
7271    fn opencode_go_provider_aliases_parse_as_builtin() {
7272        for alias in ["opencode-go", "opencode_go", "opencodego"] {
7273            assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeGo));
7274        }
7275    }
7276
7277    #[test]
7278    fn ollama_cloud_provider_aliases_parse_as_builtin() {
7279        for alias in ["ollama-cloud", "ollama_cloud"] {
7280            assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OllamaCloud));
7281        }
7282    }
7283
7284    #[test]
7285    fn antigravity_provider_aliases_are_clear_only_and_never_raw_custom() {
7286        for alias in ["antigravity", "agy"] {
7287            assert_eq!(builtin_provider_arg(alias), None, "{alias}");
7288            assert_eq!(
7289                parse_auth_clear_provider(alias),
7290                Ok(ProviderKind::Antigravity),
7291                "{alias}"
7292            );
7293            let error = parse_catalog_route(alias).expect_err("legacy route is not selectable");
7294            assert!(error.contains("non-runnable legacy provider"), "{error}");
7295            assert!(error.contains("--provider antigravity"), "{error}");
7296            assert!(error.contains("google"), "{error}");
7297            assert!(error.contains("GEMINI_API_KEY"), "{error}");
7298
7299            let clear = parse_ok(&["codewhale", "auth", "clear", "--provider", alias]);
7300            assert!(matches!(
7301                clear.command,
7302                Some(Commands::Auth(AuthArgs {
7303                    command: AuthCommand::Clear {
7304                        provider: ProviderKind::Antigravity,
7305                    }
7306                }))
7307            ));
7308
7309            for argv in [
7310                vec!["codewhale", "auth", "set", "--provider", alias],
7311                vec!["codewhale", "auth", "get", "--provider", alias],
7312                vec!["codewhale", "auth", "print-api-key", "--provider", alias],
7313                vec!["codewhale", "auth", "status", "--provider", alias],
7314                vec!["codewhale", "auth", "external-revoke", "--provider", alias],
7315                vec![
7316                    "codewhale",
7317                    "auth",
7318                    "external-consent",
7319                    "--provider",
7320                    alias,
7321                    "--mode",
7322                    "read-only",
7323                    "--yes",
7324                ],
7325                vec!["codewhale", "model", "list", "--provider", alias],
7326                vec!["codewhale", "model", "resolve", "--provider", alias],
7327            ] {
7328                let error = Cli::try_parse_from(argv)
7329                    .expect_err("legacy Antigravity route must be rejected outside auth clear");
7330                assert_eq!(error.kind(), ErrorKind::ValueValidation);
7331                assert!(
7332                    error.to_string().contains("non-runnable legacy provider"),
7333                    "{error}"
7334                );
7335            }
7336
7337            for command in [
7338                Commands::Exec(TuiPassthroughArgs {
7339                    args: vec!["Reply OK".into()],
7340                }),
7341                Commands::Fleet(TuiPassthroughArgs {
7342                    args: vec!["status".into()],
7343                }),
7344            ] {
7345                let error = top_level_provider_override(Some(alias), Some(&command))
7346                    .expect_err("legacy alias must not fall through as a raw custom provider");
7347                assert!(
7348                    error.to_string().contains("non-runnable legacy provider"),
7349                    "{error}"
7350                );
7351            }
7352        }
7353    }
7354
7355    #[test]
7356    fn legacy_dual_wire_provider_flag_keeps_named_table_kind() {
7357        // The CLI flag must resolve legacy spellings to the table-owning
7358        // dialect kind (mirroring TOML serde), never to the collapsed catalog
7359        // primary, or the user's own [providers.*] table is orphaned.
7360        for alias in [
7361            "minimax-anthropic",
7362            "minimax_anthropic",
7363            "mini-max-anthropic",
7364            "mini_max_anthropic",
7365        ] {
7366            assert_eq!(
7367                builtin_provider_arg(alias),
7368                Some(ProviderKind::MinimaxAnthropic),
7369                "{alias}"
7370            );
7371        }
7372        let cli = parse_ok(&[
7373            "codewhale",
7374            "--provider",
7375            "minimax-anthropic",
7376            "exec",
7377            "Reply OK",
7378        ]);
7379        assert_eq!(
7380            top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7381                .expect("legacy dual-wire provider"),
7382            Some(ProviderKind::MinimaxAnthropic)
7383        );
7384    }
7385
7386    #[test]
7387    fn opencode_zen_provider_aliases_parse_as_builtin() {
7388        for alias in [
7389            "opencode-zen",
7390            "opencode_zen",
7391            "opencodezen",
7392            "zen",
7393            "opencode",
7394        ] {
7395            assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeZen));
7396        }
7397    }
7398
7399    #[test]
7400    fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
7401        let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
7402        let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7403            .expect_err("model registry commands still require a built-in provider");
7404        assert!(
7405            err.to_string()
7406                .contains("configured custom providers are accepted only by exec and fleet")
7407        );
7408
7409        let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
7410            .expect_err("auth keeps enum-only provider validation");
7411        assert_eq!(err.kind(), ErrorKind::ValueValidation);
7412
7413        let err = Cli::try_parse_from([
7414            "codewhale",
7415            "--provider",
7416            "../../lm-studio",
7417            "exec",
7418            "Reply OK",
7419        ])
7420        .expect_err("provider ids must stay simple tokens");
7421        assert!(
7422            err.to_string()
7423                .contains("provider must be a simple identifier")
7424        );
7425    }
7426
7427    #[test]
7428    fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
7429        let cli = parse_ok(&[
7430            "codewhale",
7431            "lane-log-proxy",
7432            "--log-path",
7433            "/tmp/lane.ndjson",
7434            "--receipt-path",
7435            "/tmp/lane.exit.json",
7436            "--receipt-tmp-path",
7437            "/tmp/lane.exit.json.tmp",
7438            "--environment-path",
7439            "/tmp/lane.env.json",
7440            "--lane-id",
7441            "lane-proof",
7442            "--",
7443            "/bin/echo",
7444            "--child-flag",
7445            "hello",
7446        ]);
7447        let (proxy, command) = split_lane_log_proxy_command(cli.command);
7448        assert!(command.is_none());
7449        let proxy = proxy.expect("proxy args");
7450        assert_eq!(proxy.lane_id, "lane-proof");
7451        assert_eq!(
7452            proxy.command,
7453            ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
7454        );
7455
7456        let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
7457        let (proxy, command) = split_lane_log_proxy_command(cli.command);
7458        assert!(proxy.is_none());
7459        assert!(matches!(
7460            command,
7461            Some(Commands::Lane(LaneArgs {
7462                command: LaneCommand::List { json: true }
7463            }))
7464        ));
7465    }
7466
7467    /// #1888: the CLI must expose exactly the Lane verbs the shared contract
7468    /// declares, under the same ids — no CLI-only verb, no missing verb.
7469    #[test]
7470    fn cli_lane_subcommands_cover_the_shared_control_contract() {
7471        use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
7472
7473        for descriptor in codewhale_lane::control::operations_for_domain(ControlDomain::Lane) {
7474            let argv = [
7475                "codewhale".to_string(),
7476                "lane".to_string(),
7477                descriptor.verb.to_string(),
7478            ];
7479            let mut argv: Vec<&str> = argv.iter().map(String::as_str).collect();
7480            if descriptor.target.requires_identity() {
7481                argv.push("lane-a1b2c3d4");
7482            }
7483            let cli = parse_ok(&argv);
7484            let Some(Commands::Lane(args)) = cli.command else {
7485                panic!("`{}` must parse as a lane subcommand", descriptor.verb);
7486            };
7487            let parsed = match args.command {
7488                LaneCommand::List { .. } => ControlOperation::LaneList,
7489                LaneCommand::Status { .. } => ControlOperation::LaneStatus,
7490                LaneCommand::Interrupt { .. } | LaneCommand::Stop { .. } => {
7491                    ControlOperation::LaneInterrupt
7492                }
7493                LaneCommand::Restart { .. } => ControlOperation::LaneRestart,
7494                LaneCommand::Resume { .. } => ControlOperation::LaneResume,
7495                other => panic!(
7496                    "unexpected lane subcommand for {}: {other:?}",
7497                    descriptor.verb
7498                ),
7499            };
7500            assert_eq!(
7501                parsed, descriptor.operation,
7502                "`codewhale lane {}` must map to {}",
7503                descriptor.verb, descriptor.id
7504            );
7505            assert!(
7506                descriptor.offers(ControlSurface::Cli),
7507                "{} must be declared on the CLI surface",
7508                descriptor.id
7509            );
7510        }
7511    }
7512
7513    /// `lane stop` is a compatibility spelling, not a second verb.
7514    #[test]
7515    fn lane_stop_and_interrupt_resolve_to_one_verb() {
7516        use codewhale_lane::{ControlDomain, ControlOperation};
7517
7518        for spelling in ["stop", "interrupt", "cancel", "kill"] {
7519            assert_eq!(
7520                ControlOperation::parse_verb(ControlDomain::Lane, spelling),
7521                Some(ControlOperation::LaneInterrupt),
7522                "{spelling}"
7523            );
7524        }
7525        let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
7526        assert!(matches!(
7527            stop.command,
7528            Some(Commands::Lane(LaneArgs {
7529                command: LaneCommand::Stop { .. }
7530            }))
7531        ));
7532    }
7533
7534    #[test]
7535    fn short_workflow_names_do_not_resolve_version_pinned_files() {
7536        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7537            .join("..")
7538            .join("..");
7539        // A bare short name must never expand to a version-pinned script.
7540        // The v0868_* lane scripts are gone, but the guard stays so a future
7541        // vXXXX_ naming habit cannot silently become resolvable.
7542        let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
7543        assert!(candidates.iter().all(|path| {
7544            !path
7545                .file_name()
7546                .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
7547        }));
7548        assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
7549
7550        // An explicit repo-relative path still resolves — checked against a
7551        // workflow that actually ships.
7552        let explicit =
7553            resolve_workflow_source_path("workflows/stopship.workflow.js", None, &workspace)
7554                .expect("explicit workflow path");
7555        assert!(explicit.ends_with("workflows/stopship.workflow.js"));
7556    }
7557
7558    #[test]
7559    fn workflow_run_resolves_stopship_alias_and_payload() {
7560        let _lock = env_lock();
7561        let (_dir, _tui) = install_fake_tui_binary();
7562        let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
7563        let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
7564        let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
7565        let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
7566        let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
7567        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7568            .join("..")
7569            .join("..");
7570        let cli = parse_ok(&[
7571            "codewhale",
7572            "--profile",
7573            "workflow-profile",
7574            "--model",
7575            "explicit-workflow-model",
7576            "--api-key",
7577            "explicit-profile-key",
7578            "--workspace",
7579            workspace.to_str().expect("workspace UTF-8"),
7580        ]);
7581        let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
7582        let source = resolve_workflow_source_path("stopship", None, &workspace)
7583            .expect("stopship workflow source");
7584        assert!(source.ends_with("workflows/stopship.workflow.js"));
7585
7586        let process = workflow_exec_command(WorkflowExecSpec {
7587            cli: &cli,
7588            resolved_runtime: &resolved,
7589            config_path: &workspace.join("config.toml"),
7590            source_root: &workspace,
7591            source_path: &source,
7592            workflow: "stopship",
7593            fleet: Some("stopship"),
7594            issue: Some("4375"),
7595            goal: Some("fix stopship"),
7596            token_budget: Some(25_000),
7597            verify: true,
7598        })
7599        .expect("command");
7600        let current_executable = std::env::current_exe().expect("current executable");
7601        assert_eq!(
7602            process.command.first().map(String::as_str),
7603            current_executable.to_str(),
7604            "workflow lanes must launch the exact runtime that built their process spec"
7605        );
7606        let joined = process.command.join("\n");
7607        assert!(joined.contains("workflow-tool"));
7608        assert!(joined.contains("explicit-workflow-command"));
7609        assert!(joined.contains("--input-json"));
7610        assert!(!process.command.iter().any(|arg| arg == "exec"));
7611        assert!(!process.command.iter().any(|arg| arg == "--workspace"));
7612        assert!(
7613            process
7614                .command
7615                .windows(2)
7616                .any(|pair| pair == ["--profile", "workflow-profile"])
7617        );
7618        assert!(!joined.contains("Run the CodeWhale"));
7619        assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
7620        assert!(joined.contains("\"fleet\":\"stopship\""));
7621        assert!(joined.contains("\"issue\":\"4375\""));
7622        assert!(joined.contains("\"token_budget\":25000"));
7623        assert!(joined.contains("\"verify\":true"));
7624        assert!(
7625            process.environment.iter().any(|(key, value)| {
7626                key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
7627            })
7628        );
7629        assert!(
7630            !process
7631                .environment
7632                .iter()
7633                .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
7634        );
7635        assert!(
7636            !process
7637                .environment
7638                .iter()
7639                .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
7640        );
7641        assert!(
7642            !process
7643                .environment
7644                .iter()
7645                .any(|(key, _)| key == "DEEPSEEK_API_KEY")
7646        );
7647        assert!(process.environment.iter().any(|(key, value)| {
7648            key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
7649        }));
7650        assert!(
7651            !process
7652                .command
7653                .iter()
7654                .any(|argument| argument.contains("explicit-profile-key"))
7655        );
7656        assert!(
7657            process
7658                .environment
7659                .iter()
7660                .all(|(_, value)| value != "test-model")
7661        );
7662    }
7663
7664    #[test]
7665    fn exec_keeps_global_looking_flags_as_passthrough_args() {
7666        let cli = parse_ok(&[
7667            "codewhale",
7668            "exec",
7669            "--provider",
7670            "definitely-not-a-provider",
7671            "Reply OK",
7672        ]);
7673
7674        let Some(Commands::Exec(args)) = cli.command else {
7675            panic!("expected exec command");
7676        };
7677
7678        assert_eq!(
7679            args.args,
7680            vec![
7681                "--provider".to_string(),
7682                "definitely-not-a-provider".to_string(),
7683                "Reply OK".to_string(),
7684            ]
7685        );
7686    }
7687
7688    #[test]
7689    fn exec_rejects_provider_after_subcommand() {
7690        let args = vec![
7691            "--provider".to_string(),
7692            "definitely-not-a-provider".to_string(),
7693            "Reply OK".to_string(),
7694        ];
7695
7696        let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
7697
7698        assert!(
7699            err.to_string()
7700                .contains("--provider must be placed before `exec`")
7701        );
7702    }
7703
7704    #[test]
7705    fn exec_rejects_equals_form_provider_after_subcommand() {
7706        let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
7707
7708        let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
7709
7710        assert!(
7711            err.to_string()
7712                .contains("--provider must be placed before `exec`")
7713        );
7714    }
7715
7716    #[test]
7717    fn exec_allows_documented_forwarded_flags() {
7718        let args = vec![
7719            "--auto".to_string(),
7720            "--output-format".to_string(),
7721            "stream-json".to_string(),
7722            "fix tests".to_string(),
7723        ];
7724
7725        reject_exec_global_flags(&args).expect("documented exec flags should pass");
7726    }
7727
7728    #[test]
7729    fn exec_allows_literal_prompt_flags_after_separator() {
7730        let args = vec![
7731            "--".to_string(),
7732            "--provider".to_string(),
7733            "is literal prompt text".to_string(),
7734        ];
7735
7736        reject_exec_global_flags(&args).expect("separator should stop global flag validation");
7737    }
7738
7739    #[test]
7740    fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
7741        assert!(should_pick_resume_in_dispatcher(
7742            &["resume".to_string()],
7743            true
7744        ));
7745        assert!(!should_pick_resume_in_dispatcher(
7746            &["resume".to_string(), "--last".to_string()],
7747            true
7748        ));
7749        assert!(!should_pick_resume_in_dispatcher(
7750            &["resume".to_string(), "abc123".to_string()],
7751            true
7752        ));
7753        assert!(!should_pick_resume_in_dispatcher(
7754            &["resume".to_string()],
7755            false
7756        ));
7757    }
7758
7759    #[test]
7760    fn auth_set_uses_isolated_file_store_and_preserves_tui_defaults() {
7761        let _lock = env_lock();
7762        let dir = tempfile::TempDir::new().expect("tempdir");
7763        let codewhale_home = dir.path().join("codewhale-home");
7764        let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
7765        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
7766        let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
7767        let path = codewhale_home.join("config.toml");
7768        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7769        let secrets = Secrets::auto_detect();
7770
7771        run_auth_command_with_secrets(
7772            &mut store,
7773            AuthCommand::Set {
7774                provider: ProviderKind::Deepseek,
7775                api_key: Some("sk-test".to_string()),
7776                api_key_stdin: false,
7777            },
7778            &secrets,
7779        )
7780        .expect("auth set should persist credential");
7781
7782        assert!(store.config.api_key.is_none());
7783        assert!(store.config.providers.deepseek.api_key.is_none());
7784        assert_eq!(
7785            store.config.default_text_model.as_deref(),
7786            Some("deepseek-v4-pro")
7787        );
7788        let saved = std::fs::read_to_string(&path).expect("config should be written");
7789        assert!(!saved.contains("sk-test"), "{saved}");
7790        assert!(
7791            !saved
7792                .lines()
7793                .any(|line| line.trim_start().starts_with("api_key="))
7794        );
7795        assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
7796        assert_eq!(
7797            secrets.get("deepseek").expect("read secret").as_deref(),
7798            Some("sk-test")
7799        );
7800    }
7801
7802    /// `codewhale login` now means the Codewhale account device flow: the
7803    /// account-login flags parse through and reach the cloud path.
7804    #[test]
7805    fn login_parses_account_device_flow_flags() {
7806        let cli = parse_ok(&["codewhale", "login", "--no-open", "--timeout-seconds", "5"]);
7807        let Some(Commands::Login(args)) = cli.command else {
7808            panic!("expected Login");
7809        };
7810        assert!(args.no_open);
7811        assert_eq!(args.timeout_seconds, 5);
7812        assert!(args.api_key.is_none());
7813        assert!(args.provider.is_none());
7814
7815        let cli = parse_ok(&["codewhale", "login"]);
7816        let Some(Commands::Login(args)) = cli.command else {
7817            panic!("expected Login");
7818        };
7819        assert!(!args.no_open);
7820        assert_eq!(args.timeout_seconds, 600);
7821    }
7822
7823    /// The provider-key surface moved to `auth set --provider`; the hidden
7824    /// legacy flags must redirect loudly instead of silently configuring a key.
7825    #[test]
7826    fn login_rejects_legacy_provider_flags_with_redirect() {
7827        let err = reject_legacy_login_provider_args(&LoginArgs {
7828            no_open: false,
7829            timeout_seconds: 600,
7830            api_key: Some("sk-x".to_string()),
7831            provider: None,
7832        })
7833        .expect_err("legacy --api-key must be rejected");
7834        let rendered = err.to_string();
7835        assert!(
7836            rendered.contains("auth set --provider"),
7837            "redirect must name `auth set --provider`: {rendered}"
7838        );
7839
7840        let err = reject_legacy_login_provider_args(&LoginArgs {
7841            no_open: false,
7842            timeout_seconds: 600,
7843            api_key: None,
7844            provider: Some(ProviderKind::Deepseek),
7845        })
7846        .expect_err("legacy --provider must be rejected");
7847        assert!(
7848            err.to_string().contains("auth set --provider"),
7849            "redirect must name `auth set --provider`"
7850        );
7851
7852        reject_legacy_login_provider_args(&LoginArgs {
7853            no_open: false,
7854            timeout_seconds: 600,
7855            api_key: None,
7856            provider: None,
7857        })
7858        .expect("plain account login carries no legacy flags");
7859    }
7860
7861    /// Root help keeps the `login` token, but its meaning is now the account
7862    /// sign-in; the subcommand help must say so.
7863    #[test]
7864    fn login_help_describes_account_signin() {
7865        let help = help_for(&["codewhale", "login", "--help"]);
7866        assert!(
7867            help.contains("Codewhale account"),
7868            "login help must describe account sign-in: {help}"
7869        );
7870        assert!(
7871            !help.to_lowercase().contains("api key"),
7872            "login help must not advertise provider API keys: {help}"
7873        );
7874    }
7875
7876    #[test]
7877    fn auth_parses_daytona_slot_commands_as_unknown() {
7878        // The internal cloud-agent slot must not be a user command: parsing
7879        // rejects it and `auth --help` never teaches it.
7880        for argv in [
7881            vec![
7882                "codewhale",
7883                "auth",
7884                "set-slot",
7885                "daytona",
7886                "--api-key-stdin",
7887            ],
7888            vec!["codewhale", "auth", "clear-slot", "daytona"],
7889        ] {
7890            let error = Cli::try_parse_from(argv).expect_err("slot commands must not parse");
7891            assert_eq!(error.kind(), ErrorKind::InvalidSubcommand, "{error}");
7892        }
7893        let help = help_for(&["codewhale", "auth", "--help"]);
7894        assert!(!help.contains("set-slot"), "{help}");
7895        assert!(!help.contains("clear-slot"), "{help}");
7896        assert!(!help.to_lowercase().contains("daytona"), "{help}");
7897    }
7898
7899    /// #5198: `auth set` shares the login resolver — provider auth markers go
7900    /// user-global even when the ambient config is workspace-scoped.
7901    #[test]
7902    fn auth_set_with_repo_scoped_ambient_config_writes_user_global_metadata() {
7903        let _lock = env_lock();
7904        let dir = tempfile::TempDir::new().expect("tempdir");
7905        let repo = dir.path().join("repo");
7906        std::fs::create_dir_all(repo.join(".git")).expect("git marker");
7907        let repo_config_dir = repo.join(".codewhale");
7908        std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
7909        let repo_config = repo_config_dir.join("config.toml");
7910        std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
7911
7912        let codewhale_home = dir.path().join("codewhale-home");
7913        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
7914        let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
7915        let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
7916        let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
7917        let mut store = ConfigStore::load(None).expect("ambient store should load");
7918        let secrets = Secrets::auto_detect();
7919
7920        run_auth_command_with_secrets(
7921            &mut store,
7922            AuthCommand::Set {
7923                provider: ProviderKind::Openrouter,
7924                api_key: Some("sk-or-repo-scoped".to_string()),
7925                api_key_stdin: false,
7926            },
7927            &secrets,
7928        )
7929        .expect("auth set should persist credential");
7930
7931        assert_eq!(
7932            secrets.get("openrouter").expect("read secret").as_deref(),
7933            Some("sk-or-repo-scoped")
7934        );
7935        let global = std::fs::read_to_string(codewhale_home.join("config.toml"))
7936            .expect("user-global config");
7937        assert!(
7938            global.contains("auth_mode = \"api_key\""),
7939            "user-global config must carry the auth markers: {global}"
7940        );
7941        assert!(
7942            global.contains("openrouter"),
7943            "user-global config must name the provider table: {global}"
7944        );
7945        assert!(!global.contains("sk-or-repo-scoped"), "{global}");
7946        let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
7947        assert_eq!(
7948            repo_after, "approval_policy = \"never\"\n",
7949            "workspace config must stay untouched by credential metadata: {repo_after}"
7950        );
7951    }
7952
7953    #[test]
7954    fn parses_auth_subcommand_matrix() {
7955        let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
7956        assert!(matches!(
7957            cli.command,
7958            Some(Commands::Auth(AuthArgs {
7959                command: AuthCommand::XaiDevice
7960            }))
7961        ));
7962
7963        let cli = parse_ok(&["deepseek", "auth", "chatgpt"]);
7964        assert!(matches!(
7965            cli.command,
7966            Some(Commands::Auth(AuthArgs {
7967                command: AuthCommand::Chatgpt
7968            }))
7969        ));
7970
7971        let cli = parse_ok(&["deepseek", "auth", "chatgpt-revoke"]);
7972        assert!(matches!(
7973            cli.command,
7974            Some(Commands::Auth(AuthArgs {
7975                command: AuthCommand::ChatgptRevoke
7976            }))
7977        ));
7978
7979        let cli = parse_ok(&[
7980            "deepseek",
7981            "auth",
7982            "external-consent",
7983            "--provider",
7984            "openai-codex",
7985            "--mode",
7986            "read-only",
7987            "--path",
7988            "/tmp/codex-auth.json",
7989            "--yes",
7990        ]);
7991        assert!(matches!(
7992            cli.command,
7993            Some(Commands::Auth(AuthArgs {
7994                command: AuthCommand::ExternalConsent {
7995                    provider: ProviderKind::OpenaiCodex,
7996                    mode: ExternalCredentialModeArg::ReadOnly,
7997                    path: Some(_),
7998                    yes: true,
7999                }
8000            }))
8001        ));
8002
8003        let cli = parse_ok(&["deepseek", "auth", "external-revoke", "--provider", "xai"]);
8004        assert!(matches!(
8005            cli.command,
8006            Some(Commands::Auth(AuthArgs {
8007                command: AuthCommand::ExternalRevoke {
8008                    provider: ProviderKind::Xai,
8009                }
8010            }))
8011        ));
8012
8013        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
8014        assert!(matches!(
8015            cli.command,
8016            Some(Commands::Auth(AuthArgs {
8017                command: AuthCommand::Set {
8018                    provider: ProviderKind::Deepseek,
8019                    api_key: None,
8020                    api_key_stdin: false,
8021                }
8022            }))
8023        ));
8024
8025        let cli = parse_ok(&[
8026            "deepseek",
8027            "auth",
8028            "set",
8029            "--provider",
8030            "openrouter",
8031            "--api-key-stdin",
8032        ]);
8033        assert!(matches!(
8034            cli.command,
8035            Some(Commands::Auth(AuthArgs {
8036                command: AuthCommand::Set {
8037                    provider: ProviderKind::Openrouter,
8038                    api_key: None,
8039                    api_key_stdin: true,
8040                }
8041            }))
8042        ));
8043
8044        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
8045        assert!(matches!(
8046            cli.command,
8047            Some(Commands::Auth(AuthArgs {
8048                command: AuthCommand::Get {
8049                    provider: ProviderKind::Novita
8050                }
8051            }))
8052        ));
8053
8054        let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
8055        assert!(matches!(
8056            cli.command,
8057            Some(Commands::Auth(AuthArgs {
8058                command: AuthCommand::Clear {
8059                    provider: ProviderKind::NvidiaNim
8060                }
8061            }))
8062        ));
8063
8064        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
8065        assert!(matches!(
8066            cli.command,
8067            Some(Commands::Auth(AuthArgs {
8068                command: AuthCommand::Set {
8069                    provider: ProviderKind::Fireworks,
8070                    api_key: None,
8071                    api_key_stdin: false,
8072                }
8073            }))
8074        ));
8075
8076        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
8077        assert!(matches!(
8078            cli.command,
8079            Some(Commands::Auth(AuthArgs {
8080                command: AuthCommand::Set {
8081                    provider: ProviderKind::Siliconflow,
8082                    api_key: None,
8083                    api_key_stdin: false,
8084                }
8085            }))
8086        ));
8087
8088        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
8089        assert!(matches!(
8090            cli.command,
8091            Some(Commands::Auth(AuthArgs {
8092                command: AuthCommand::Set {
8093                    provider: ProviderKind::Arcee,
8094                    api_key: None,
8095                    api_key_stdin: false,
8096                }
8097            }))
8098        ));
8099
8100        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
8101        assert!(matches!(
8102            cli.command,
8103            Some(Commands::Auth(AuthArgs {
8104                command: AuthCommand::Set {
8105                    provider: ProviderKind::Moonshot,
8106                    api_key: None,
8107                    api_key_stdin: false,
8108                }
8109            }))
8110        ));
8111
8112        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
8113        assert!(matches!(
8114            cli.command,
8115            Some(Commands::Auth(AuthArgs {
8116                command: AuthCommand::Set {
8117                    provider: ProviderKind::WanjieArk,
8118                    api_key: None,
8119                    api_key_stdin: false,
8120                }
8121            }))
8122        ));
8123
8124        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
8125        assert!(matches!(
8126            cli.command,
8127            Some(Commands::Auth(AuthArgs {
8128                command: AuthCommand::Get {
8129                    provider: ProviderKind::Sglang
8130                }
8131            }))
8132        ));
8133
8134        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
8135        assert!(matches!(
8136            cli.command,
8137            Some(Commands::Auth(AuthArgs {
8138                command: AuthCommand::Get {
8139                    provider: ProviderKind::Vllm
8140                }
8141            }))
8142        ));
8143
8144        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
8145        assert!(matches!(
8146            cli.command,
8147            Some(Commands::Auth(AuthArgs {
8148                command: AuthCommand::Set {
8149                    provider: ProviderKind::Ollama,
8150                    api_key: None,
8151                    api_key_stdin: false,
8152                }
8153            }))
8154        ));
8155
8156        let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
8157        assert!(matches!(
8158            cli.command,
8159            Some(Commands::Auth(AuthArgs {
8160                command: AuthCommand::Status {
8161                    provider: Some(ProviderKind::OpenaiCodex),
8162                    diagnostic: false,
8163                }
8164            }))
8165        ));
8166
8167        let cli = parse_ok(&[
8168            "deepseek",
8169            "auth",
8170            "status",
8171            "--diagnostic",
8172            "--provider",
8173            "deepseek",
8174        ]);
8175        assert!(matches!(
8176            cli.command,
8177            Some(Commands::Auth(AuthArgs {
8178                command: AuthCommand::Status {
8179                    provider: Some(ProviderKind::Deepseek),
8180                    diagnostic: true,
8181                }
8182            }))
8183        ));
8184
8185        for (provider, expected) in [
8186            ("anthropic", ProviderKind::Anthropic),
8187            ("openmodel", ProviderKind::Openmodel),
8188            ("open-model", ProviderKind::Openmodel),
8189            ("zai", ProviderKind::Zai),
8190            ("stepfun", ProviderKind::Stepfun),
8191            ("minimax", ProviderKind::Minimax),
8192            ("minimax-anthropic", ProviderKind::MinimaxAnthropic),
8193            ("minimax_anthropic", ProviderKind::MinimaxAnthropic),
8194            ("deepinfra", ProviderKind::Deepinfra),
8195            ("deep-infra", ProviderKind::Deepinfra),
8196            ("siliconflow-cn", ProviderKind::SiliconflowCN),
8197            ("siliconflow-CN", ProviderKind::SiliconflowCN),
8198            ("siliconflow_china", ProviderKind::SiliconflowCN),
8199        ] {
8200            let cli = parse_ok(&[
8201                "deepseek",
8202                "auth",
8203                "set",
8204                "--provider",
8205                provider,
8206                "--api-key-stdin",
8207            ]);
8208            assert!(matches!(
8209                cli.command,
8210                Some(Commands::Auth(AuthArgs {
8211                    command: AuthCommand::Set {
8212                        provider,
8213                        api_key: None,
8214                        api_key_stdin: true,
8215                    }
8216                })) if provider == expected
8217            ));
8218        }
8219
8220        let cli = parse_ok(&["deepseek", "auth", "list"]);
8221        assert!(matches!(
8222            cli.command,
8223            Some(Commands::Auth(AuthArgs {
8224                command: AuthCommand::List
8225            }))
8226        ));
8227
8228        let cli = parse_ok(&["deepseek", "auth", "migrate"]);
8229        assert!(matches!(
8230            cli.command,
8231            Some(Commands::Auth(AuthArgs {
8232                command: AuthCommand::Migrate { dry_run: false }
8233            }))
8234        ));
8235
8236        let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
8237        assert!(matches!(
8238            cli.command,
8239            Some(Commands::Auth(AuthArgs {
8240                command: AuthCommand::Migrate { dry_run: true }
8241            }))
8242        ));
8243    }
8244
8245    #[test]
8246    fn auth_help_describes_runtime_effective_diagnostics() {
8247        let get = help_for(&["codewhale", "auth", "get", "--help"]);
8248        assert!(get.contains("effective credential route"), "{get}");
8249        assert!(get.contains("structural OAuth/repair state"), "{get}");
8250
8251        let status = help_for(&["codewhale", "auth", "status", "--help"]);
8252        assert!(
8253            status.contains("runtime-effective credential route state"),
8254            "{status}"
8255        );
8256
8257        let list = help_for(&["codewhale", "auth", "list", "--help"]);
8258        assert!(list.contains("runtime-effective auth state"), "{list}");
8259    }
8260
8261    #[test]
8262    fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
8263        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8264        use std::sync::Arc;
8265
8266        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8267        let path = std::env::temp_dir().join(format!(
8268            "deepseek-cli-auth-set-test-{}-{nanos}.toml",
8269            std::process::id()
8270        ));
8271        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8272        let inner = Arc::new(InMemoryKeyringStore::new());
8273        let secrets = Secrets::new(inner.clone());
8274
8275        run_auth_command_with_secrets(
8276            &mut store,
8277            AuthCommand::Set {
8278                provider: ProviderKind::Deepseek,
8279                api_key: Some("sk-keyring".to_string()),
8280                api_key_stdin: false,
8281            },
8282            &secrets,
8283        )
8284        .expect("set should succeed");
8285
8286        assert!(store.config.api_key.is_none());
8287        assert!(store.config.providers.deepseek.api_key.is_none());
8288        let saved = std::fs::read_to_string(&path).unwrap_or_default();
8289        assert!(!saved.contains("sk-keyring"), "{saved}");
8290        assert!(
8291            !saved
8292                .lines()
8293                .any(|line| line.trim_start().starts_with("api_key ="))
8294        );
8295        assert_eq!(
8296            inner.get("deepseek").unwrap().as_deref(),
8297            Some("sk-keyring")
8298        );
8299
8300        let _ = std::fs::remove_file(path);
8301    }
8302
8303    #[test]
8304    fn auth_set_refuses_plaintext_config_when_secret_store_write_fails() {
8305        use codewhale_secrets::{KeyringStore, SecretsError};
8306        use std::sync::Arc;
8307
8308        struct FailingStore;
8309
8310        impl KeyringStore for FailingStore {
8311            fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
8312                Ok(None)
8313            }
8314
8315            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
8316                Err(SecretsError::Keyring("test write failure".to_string()))
8317            }
8318
8319            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
8320                Ok(())
8321            }
8322
8323            fn backend_name(&self) -> &'static str {
8324                "failing test store"
8325            }
8326        }
8327
8328        let dir = tempfile::TempDir::new().expect("tempdir");
8329        let path = dir.path().join("config.toml");
8330        let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
8331        let secrets = Secrets::new(Arc::new(FailingStore));
8332
8333        let error = run_auth_command_with_secrets(
8334            &mut store,
8335            AuthCommand::Set {
8336                provider: ProviderKind::Openrouter,
8337                api_key: Some("fallback-test-credential".to_string()),
8338                api_key_stdin: false,
8339            },
8340            &secrets,
8341        )
8342        .expect_err("secret-store failure must not downgrade to plaintext");
8343
8344        let message = format!("{error:#}");
8345        assert!(message.contains("Secret storage write failed"), "{message}");
8346        assert!(message.contains("Refusing"), "{message}");
8347        assert!(
8348            message.contains(&codewhale_config::quote_os_path(store.path())),
8349            "{message}"
8350        );
8351        assert!(store.config.providers.openrouter.api_key.is_none());
8352        assert!(!path.exists(), "plaintext config must stay untouched");
8353    }
8354
8355    #[test]
8356    fn auth_set_provider_key_does_not_switch_active_provider() {
8357        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8358        let path = std::env::temp_dir().join(format!(
8359            "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
8360            std::process::id()
8361        ));
8362        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8363        store.config.provider = ProviderKind::Deepseek;
8364        let secrets = no_keyring_secrets();
8365
8366        run_auth_command_with_secrets(
8367            &mut store,
8368            AuthCommand::Set {
8369                provider: ProviderKind::Arcee,
8370                api_key: Some("arcee-key".to_string()),
8371                api_key_stdin: false,
8372            },
8373            &secrets,
8374        )
8375        .expect("set should succeed");
8376
8377        assert_eq!(store.config.provider, ProviderKind::Deepseek);
8378        assert!(store.config.providers.arcee.api_key.is_none());
8379        assert_eq!(
8380            store.config.providers.arcee.auth_mode.as_deref(),
8381            Some("api_key")
8382        );
8383
8384        let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
8385        assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
8386        assert!(reloaded.config.providers.arcee.api_key.is_none());
8387        assert_eq!(
8388            reloaded.config.providers.arcee.auth_mode.as_deref(),
8389            Some("api_key")
8390        );
8391
8392        let _ = std::fs::remove_file(path);
8393    }
8394
8395    #[test]
8396    fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
8397        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8398        let path = std::env::temp_dir().join(format!(
8399            "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
8400            std::process::id()
8401        ));
8402        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8403        store.config.provider = ProviderKind::Deepseek;
8404        let secrets = no_keyring_secrets();
8405
8406        run_auth_command_with_secrets(
8407            &mut store,
8408            AuthCommand::Set {
8409                provider: ProviderKind::Ollama,
8410                api_key: None,
8411                api_key_stdin: false,
8412            },
8413            &secrets,
8414        )
8415        .expect("ollama auth set should not require a key");
8416
8417        assert_eq!(store.config.provider, ProviderKind::Deepseek);
8418        assert_eq!(
8419            store.config.providers.ollama.base_url.as_deref(),
8420            Some("http://localhost:11434/v1")
8421        );
8422        assert_eq!(store.config.providers.ollama.api_key, None);
8423
8424        let _ = std::fs::remove_file(path);
8425    }
8426
8427    #[test]
8428    fn auth_clear_removes_from_config() {
8429        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8430        use std::sync::Arc;
8431
8432        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8433        let path = std::env::temp_dir().join(format!(
8434            "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
8435            std::process::id()
8436        ));
8437        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8438        store.config.api_key = Some("sk-stale".to_string());
8439        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
8440        store.save().unwrap();
8441
8442        let inner = Arc::new(InMemoryKeyringStore::new());
8443        inner.set("deepseek", "sk-stale").unwrap();
8444        let secrets = Secrets::new(inner.clone());
8445
8446        run_auth_command_with_secrets(
8447            &mut store,
8448            AuthCommand::Clear {
8449                provider: ProviderKind::Deepseek,
8450            },
8451            &secrets,
8452        )
8453        .expect("clear should succeed");
8454
8455        assert!(store.config.api_key.is_none());
8456        assert!(store.config.providers.deepseek.api_key.is_none());
8457        assert_eq!(inner.get("deepseek").unwrap(), None);
8458
8459        let _ = std::fs::remove_file(path);
8460    }
8461
8462    #[test]
8463    fn antigravity_clear_removes_only_codewhale_owned_legacy_state() {
8464        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8465        use std::sync::Arc;
8466
8467        let dir = tempfile::TempDir::new().expect("isolated legacy fixture");
8468        let config_path = dir.path().join("config.toml");
8469        let external_session_path = dir.path().join("external-antigravity-session.db");
8470        let external_session = b"external session bytes must remain unchanged";
8471        std::fs::write(&external_session_path, external_session)
8472            .expect("write external session trap");
8473
8474        let mut store = ConfigStore::load(Some(config_path.clone())).expect("load empty config");
8475        store.config.provider = ProviderKind::Antigravity;
8476        store.config.fallback_providers = vec![ProviderKind::Antigravity, ProviderKind::Google];
8477        {
8478            let legacy = &mut store.config.providers.antigravity;
8479            legacy.api_key = Some("legacy-codewhale-fixture-key".to_string());
8480            legacy.base_url = Some("https://legacy.invalid/v1".to_string());
8481            legacy.model = Some("legacy-fixture-model".to_string());
8482            legacy.context_window = Some(1234);
8483            legacy.mode = Some("legacy-fixture-mode".to_string());
8484            legacy.wire = Some("legacy-fixture-wire".to_string());
8485            legacy.auth_mode = Some("oauth".to_string());
8486            legacy.insecure_skip_tls_verify = Some(true);
8487            legacy
8488                .http_headers
8489                .insert("X-Legacy-Fixture".to_string(), "fixture".to_string());
8490            legacy.path_suffix = Some("legacy-fixture-path".to_string());
8491            legacy.external_credentials =
8492                Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8493                    ProviderKind::Antigravity,
8494                    codewhale_config::ExternalCredentialSource::AgyCli,
8495                    external_session_path.clone(),
8496                ));
8497            legacy.extras.insert(
8498                "legacy_fixture_extra".to_string(),
8499                toml::Value::String("remove-me".to_string()),
8500            );
8501        }
8502        store.config.providers.google.api_key = Some("google-fixture-key".to_string());
8503        store.config.providers.google.base_url = Some("https://google.example/v1".to_string());
8504        store.config.providers.google.model = Some("google-fixture-model".to_string());
8505        store.save().expect("save legacy fixture");
8506
8507        // Released configs accepted the short `[providers.agy]` table alias.
8508        // Exercise that on-disk spelling as well as the clear command's alias.
8509        let canonical = std::fs::read_to_string(&config_path).expect("read canonical fixture");
8510        let alias = canonical.replace("[providers.antigravity", "[providers.agy");
8511        std::fs::write(&config_path, alias).expect("write legacy alias fixture");
8512        let mut store = ConfigStore::load(Some(config_path.clone())).expect("reload alias fixture");
8513
8514        let inner = Arc::new(InMemoryKeyringStore::new());
8515        inner
8516            .set("antigravity", "legacy-codewhale-secret-slot")
8517            .expect("seed Codewhale-owned legacy secret slot");
8518        let secrets = Secrets::new(inner.clone());
8519
8520        run_auth_command_with_secrets(
8521            &mut store,
8522            AuthCommand::Clear {
8523                provider: ProviderKind::Antigravity,
8524            },
8525            &secrets,
8526        )
8527        .expect("legacy clear should succeed");
8528
8529        assert_eq!(store.config.provider, ProviderKind::default());
8530        assert_eq!(store.config.fallback_providers, vec![ProviderKind::Google]);
8531        assert!(store.config.providers.antigravity.is_empty());
8532        assert_eq!(inner.get("antigravity").unwrap(), None);
8533        assert_eq!(
8534            store.config.providers.google.api_key.as_deref(),
8535            Some("google-fixture-key")
8536        );
8537        assert_eq!(
8538            store.config.providers.google.base_url.as_deref(),
8539            Some("https://google.example/v1")
8540        );
8541        assert_eq!(
8542            store.config.providers.google.model.as_deref(),
8543            Some("google-fixture-model")
8544        );
8545        assert_eq!(
8546            std::fs::read(&external_session_path).expect("external session trap still exists"),
8547            external_session
8548        );
8549
8550        let raw = std::fs::read_to_string(&config_path).expect("read cleared config");
8551        assert!(!raw.contains("[providers.antigravity"), "{raw}");
8552        assert!(!raw.contains("[providers.agy"), "{raw}");
8553        assert!(!raw.contains("legacy_fixture_extra"), "{raw}");
8554        assert!(raw.contains("[providers.google]"), "{raw}");
8555
8556        let backup_path = config_path.with_file_name(format!(
8557            "{}.bak",
8558            config_path
8559                .file_name()
8560                .expect("config fixture has a file name")
8561                .to_string_lossy()
8562        ));
8563        let backup = std::fs::read_to_string(backup_path).expect("read cleared config backup");
8564        assert!(!backup.contains("[providers.antigravity"), "{backup}");
8565        assert!(!backup.contains("[providers.agy"), "{backup}");
8566        assert!(!backup.contains("legacy_fixture_extra"), "{backup}");
8567        assert!(
8568            !backup.contains(&external_session_path.to_string_lossy().to_string()),
8569            "{backup}"
8570        );
8571        assert!(
8572            backup.contains("base_url = \"https://google.example/v1\""),
8573            "{backup}"
8574        );
8575        assert!(
8576            backup.contains("model = \"google-fixture-model\""),
8577            "{backup}"
8578        );
8579
8580        let reloaded = ConfigStore::load(Some(config_path)).expect("reload cleared config");
8581        assert_eq!(reloaded.config.provider, ProviderKind::default());
8582        assert!(reloaded.config.providers.antigravity.is_empty());
8583        assert_eq!(
8584            reloaded.config.providers.google.api_key.as_deref(),
8585            Some("google-fixture-key")
8586        );
8587    }
8588
8589    #[test]
8590    fn antigravity_clear_restores_codewhale_secret_when_config_write_fails() {
8591        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8592        use std::sync::Arc;
8593
8594        let dir = tempfile::TempDir::new().expect("isolated rollback fixture");
8595        let config_path = dir.path().join("config.toml");
8596        let external_session_path = dir.path().join("external-session.db");
8597        let external_session = b"external session rollback trap";
8598        std::fs::write(&external_session_path, external_session)
8599            .expect("write external session trap");
8600        let mut store = ConfigStore::load(Some(config_path.clone())).expect("load absent config");
8601        store.config.provider = ProviderKind::Antigravity;
8602        store.config.fallback_providers = vec![ProviderKind::Antigravity];
8603        store.config.providers.antigravity.api_key = Some("legacy-config-fixture".to_string());
8604        store.config.providers.antigravity.external_credentials =
8605            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8606                ProviderKind::Antigravity,
8607                codewhale_config::ExternalCredentialSource::AgyCli,
8608                external_session_path.clone(),
8609            ));
8610        std::fs::create_dir(&config_path).expect("make config target unwritable as a file");
8611
8612        let inner = Arc::new(InMemoryKeyringStore::new());
8613        inner
8614            .set("antigravity", "legacy-secret-fixture")
8615            .expect("seed Codewhale-owned legacy slot");
8616        let secrets = Secrets::new(inner.clone());
8617
8618        run_auth_command_with_secrets(
8619            &mut store,
8620            AuthCommand::Clear {
8621                provider: ProviderKind::Antigravity,
8622            },
8623            &secrets,
8624        )
8625        .expect_err("config failure must fail the clear transaction");
8626
8627        assert_eq!(store.config.provider, ProviderKind::Antigravity);
8628        assert_eq!(
8629            store.config.fallback_providers,
8630            vec![ProviderKind::Antigravity]
8631        );
8632        assert_eq!(
8633            store.config.providers.antigravity.api_key.as_deref(),
8634            Some("legacy-config-fixture")
8635        );
8636        assert!(
8637            store
8638                .config
8639                .providers
8640                .antigravity
8641                .external_credentials
8642                .is_some()
8643        );
8644        assert_eq!(
8645            inner
8646                .get("antigravity")
8647                .expect("read restored slot")
8648                .as_deref(),
8649            Some("legacy-secret-fixture")
8650        );
8651        assert_eq!(
8652            std::fs::read(external_session_path).expect("external session trap still exists"),
8653            external_session
8654        );
8655    }
8656
8657    #[test]
8658    fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
8659        use codewhale_secrets::{KeyringStore, SecretsError};
8660        use std::sync::{Arc, Mutex};
8661
8662        #[derive(Default)]
8663        struct RecordingStore {
8664            gets: Mutex<Vec<String>>,
8665        }
8666
8667        impl KeyringStore for RecordingStore {
8668            fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
8669                self.gets.lock().unwrap().push(key.to_string());
8670                Ok(None)
8671            }
8672
8673            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
8674                Ok(())
8675            }
8676
8677            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
8678                Ok(())
8679            }
8680
8681            fn backend_name(&self) -> &'static str {
8682                "recording"
8683            }
8684        }
8685
8686        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8687        let path = std::env::temp_dir().join(format!(
8688            "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
8689            std::process::id()
8690        ));
8691        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8692        store.config.provider = ProviderKind::Deepseek;
8693        let inner = Arc::new(RecordingStore::default());
8694        let secrets = Secrets::new(inner.clone());
8695
8696        run_auth_command_with_secrets(
8697            &mut store,
8698            AuthCommand::Status {
8699                provider: Some(ProviderKind::Deepseek),
8700                diagnostic: false,
8701            },
8702            &secrets,
8703        )
8704        .expect("status should succeed");
8705        run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
8706            .expect("list should succeed");
8707
8708        let probed = inner.gets.lock().unwrap();
8709        // Scoped status probes only the requested provider.
8710        assert_eq!(probed[0], "deepseek");
8711        // List now probes all providers (not just active) to fix the
8712        // stale keyring-only-for-active-provider bug.
8713        assert!(probed.len() > 1, "list should probe all providers");
8714        assert!(
8715            ProviderKind::ALL
8716                .iter()
8717                .all(|p| probed.contains(&provider_slot(*p).to_string())),
8718            "every known provider should be probed by auth list: {:?}",
8719            *probed
8720        );
8721
8722        let _ = std::fs::remove_file(path);
8723    }
8724
8725    #[test]
8726    fn auth_diagnostic_reports_paths_and_presence_without_values() {
8727        let _lock = env_lock();
8728        let fixture = tempfile::TempDir::new().expect("fixture root");
8729        // macOS spells /var through a /private symlink. Canonicalize the
8730        // fixture root so the metadata-only backend diagnostic can prove every
8731        // ancestor is a real directory instead of truthfully returning
8732        // `unknown` for the symlinked spelling.
8733        let home = fixture
8734            .path()
8735            .canonicalize()
8736            .expect("canonical fixture root")
8737            .join("isolated-codewhale-home");
8738        let config_path = home.join("config.toml");
8739        let settings_path = home.join("settings.toml");
8740        let secret_path = home.join("secrets").join("secrets.json");
8741        std::fs::create_dir_all(secret_path.parent().expect("secret parent"))
8742            .expect("create diagnostic fixture");
8743        std::fs::write(
8744            &config_path,
8745            "api_key = \"diagnostic-config-secret-1234\"\n",
8746        )
8747        .expect("write config fixture");
8748        std::fs::write(&settings_path, "default_mode = \"plan\"\n")
8749            .expect("write settings fixture");
8750        std::fs::write(
8751            &secret_path,
8752            r#"{"deepseek":"diagnostic-store-secret-5678"}"#,
8753        )
8754        .expect("write secret fixture");
8755
8756        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
8757        let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
8758        let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "diagnostic-env-secret-9012");
8759        let store = ConfigStore::load(Some(config_path.clone())).expect("load config fixture");
8760
8761        let output = auth_diagnostic_lines(&store, Some(ProviderKind::Deepseek)).join("\n");
8762        assert!(
8763            output.contains(&format!(
8764                "codewhale home: {} (source: CODEWHALE_HOME (isolated); state: present)",
8765                codewhale_config::quote_os_path(&home)
8766            )),
8767            "{output}"
8768        );
8769        assert!(
8770            output.contains(&format!(
8771                "config: {} (present)",
8772                codewhale_config::quote_os_path(&config_path)
8773            )),
8774            "{output}"
8775        );
8776        assert!(
8777            output.contains(&format!(
8778                "settings: {} (present)",
8779                codewhale_config::quote_os_path(&settings_path)
8780            )),
8781            "{output}"
8782        );
8783        assert!(
8784            output.contains("secret backend: file (inspection: metadata_only)"),
8785            "{output}"
8786        );
8787        assert!(
8788            output.contains(&format!(
8789                "secret store: {} (present)",
8790                codewhale_config::quote_os_path(&secret_path)
8791            )),
8792            "{output}"
8793        );
8794        assert!(
8795            output.contains("provider deepseek sources: config_literal=present, secret_backend=present (provider entry unprobed), environment=present (DEEPSEEK_API_KEY)"),
8796            "{output}"
8797        );
8798        assert!(
8799            output.contains("legacy secret store: suppressed by explicit CODEWHALE_HOME isolation"),
8800            "{output}"
8801        );
8802        for secret_fragment in [
8803            "diagnostic-config-secret",
8804            "diagnostic-store-secret",
8805            "diagnostic-env-secret",
8806            "1234",
8807            "5678",
8808            "9012",
8809            "last4",
8810        ] {
8811            assert!(
8812                !output.contains(secret_fragment),
8813                "diagnostic leaked {secret_fragment:?}: {output}"
8814            );
8815        }
8816    }
8817
8818    #[test]
8819    fn auth_status_reports_all_active_provider_sources_with_last4() {
8820        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8821        use std::sync::Arc;
8822
8823        let _lock = env_lock();
8824        let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
8825
8826        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8827        let path = std::env::temp_dir().join(format!(
8828            "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
8829            std::process::id()
8830        ));
8831        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8832        store.config.provider = ProviderKind::Deepseek;
8833        store.config.api_key = Some("sk-config-3333".to_string());
8834        store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
8835
8836        let inner = Arc::new(InMemoryKeyringStore::new());
8837        inner.set("deepseek", "sk-keyring-2222").unwrap();
8838        let secrets = Secrets::new(inner);
8839
8840        let output =
8841            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
8842
8843        assert!(output.contains("provider: deepseek"));
8844        assert!(output.contains("active source: config (last4: ...3333)"));
8845        assert!(output.contains("lookup order: config -> secret store -> env"));
8846        assert!(output.contains("config file: "));
8847        assert!(output.contains("set, last4: ...3333"));
8848        assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
8849        assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
8850        assert!(!output.contains("sk-config-3333"));
8851        assert!(!output.contains("sk-keyring-2222"));
8852        assert!(!output.contains("sk-env-1111"));
8853
8854        let _ = std::fs::remove_file(path);
8855    }
8856
8857    #[test]
8858    fn auth_status_all_providers_lists_every_known_provider() {
8859        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8860        use std::sync::Arc;
8861
8862        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8863        let path = std::env::temp_dir().join(format!(
8864            "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
8865            std::process::id()
8866        ));
8867        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8868        store.config.provider = ProviderKind::Deepseek;
8869        store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
8870
8871        let inner = Arc::new(InMemoryKeyringStore::new());
8872        inner.set("openrouter", "sk-or-test5678").unwrap();
8873        let secrets = Secrets::new(inner);
8874
8875        let output = auth_status_all_providers(&store, &secrets).join("\n");
8876
8877        assert!(output.contains("account:"), "{output}");
8878        assert!(output.contains("codewhale login"), "{output}");
8879        // No-brand invariant: the internal cloud-agent slot is not user
8880        // surface, so status never names it or teaches a set-slot command.
8881        assert!(!output.to_lowercase().contains("daytona"), "{output}");
8882        assert!(!output.contains("set-slot"), "{output}");
8883
8884        // Should list all known providers
8885        assert!(output.contains("deepseek"));
8886        assert!(output.contains("arcee"));
8887        assert!(output.contains("openrouter"));
8888        assert!(output.contains("huggingface"));
8889        assert!(output.contains("ollama"));
8890
8891        // Active provider should be marked
8892        assert!(output.contains("deepseek") && output.contains("*"));
8893
8894        // Arcee should show config source
8895        assert!(output.contains("config"));
8896
8897        // Should NOT leak raw keys
8898        assert!(!output.contains("sk-arcee-test1234"));
8899        assert!(!output.contains("sk-or-test5678"));
8900
8901        let _ = std::fs::remove_file(path);
8902    }
8903
8904    #[test]
8905    fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
8906        use codewhale_secrets::InMemoryKeyringStore;
8907        use std::sync::Arc;
8908
8909        let _lock = env_lock();
8910        let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
8911        let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
8912
8913        let dir = tempfile::TempDir::new().expect("tempdir");
8914        let config_path = dir.path().join("config.toml");
8915        let auth_path = dir.path().join("auth.json");
8916        std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
8917            .expect("write auth file");
8918        let auth_path_str = auth_path.to_string_lossy().into_owned();
8919        let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
8920
8921        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
8922        store.config.provider = ProviderKind::OpenaiCodex;
8923        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
8924
8925        let output =
8926            auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8927
8928        assert!(output.contains("provider: openai-codex"));
8929        assert!(output.contains("auth mode: codex_oauth"));
8930        assert!(output.contains("active source: missing"));
8931        assert!(output.contains(
8932            "lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
8933        ));
8934        assert!(output.contains("external credentials: disabled"));
8935        assert!(output.contains("scope_valid=false"));
8936        assert!(output.contains("disabled; no external-credential probing, reading"));
8937        assert!(output.contains("file not probed"));
8938        assert!(!output.contains("secret-token"));
8939
8940        store.config.providers.openai_codex.external_credentials =
8941            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8942                ProviderKind::OpenaiCodex,
8943                codewhale_config::ExternalCredentialSource::CodexCli,
8944                auth_path.clone(),
8945            ));
8946        let output =
8947            auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8948        assert!(
8949            output.contains("active source: external read-only consent (availability not probed)")
8950        );
8951        assert!(output.contains("external credentials: read_only"));
8952        assert!(output.contains("provider=openai-codex"));
8953        assert!(output.contains("source=codex_cli"));
8954        assert!(output.contains(&format!(
8955            "path={}",
8956            codewhale_config::quote_os_path(&auth_path)
8957        )));
8958        assert!(output.contains(&format!(
8959            "consent_version={}",
8960            codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
8961        )));
8962        assert!(output.contains("file not probed"));
8963        assert!(!output.contains("secret-token"));
8964
8965        let ambient_path = dir.path().join("new-ambient-auth.json");
8966        let ambient_path_str = ambient_path.to_string_lossy().into_owned();
8967        let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
8968        let changed =
8969            auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8970        assert!(changed.contains("state=active"), "{changed}");
8971        assert!(changed.contains("ambient_path_changed=true"), "{changed}");
8972        assert!(changed.contains("consent remains pinned"), "{changed}");
8973        assert!(
8974            changed.contains(&codewhale_config::quote_os_path(&auth_path)),
8975            "{changed}"
8976        );
8977        assert!(!changed.contains(&ambient_path_str), "{changed}");
8978    }
8979
8980    #[test]
8981    fn xai_valid_owned_generation_blocks_external_consent_without_storage_probes() {
8982        use std::sync::Arc;
8983
8984        let _lock = env_lock();
8985        let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
8986        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
8987        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
8988        let dir = tempfile::TempDir::new().expect("tempdir");
8989        let config_path = dir.path().join("config.toml");
8990        let external_path = dir.path().join("grok-auth.json");
8991        let external_raw = "external owner bytes must not be read";
8992        std::fs::write(&external_path, external_raw).expect("external auth trap");
8993        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
8994
8995        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
8996        store.config.provider = ProviderKind::Xai;
8997        store.config.providers.xai.auth_mode = Some("oauth".to_string());
8998        store.config.providers.xai.oauth_credential_generation =
8999            Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
9000        store.config.providers.xai.external_credentials =
9001            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9002                ProviderKind::Xai,
9003                codewhale_config::ExternalCredentialSource::GrokCli,
9004                external_path.clone(),
9005            ));
9006        let keyring = Arc::new(RecordingKeyringStore::default());
9007        let secrets = Secrets::new(keyring.clone());
9008
9009        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9010        assert!(
9011            scoped.contains(
9012                "credential route: Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
9013            ),
9014            "{scoped}"
9015        );
9016        assert!(scoped.contains("external credentials: blocked by the configured Codewhale-owned xAI OAuth generation"), "{scoped}");
9017        assert!(
9018            scoped.contains(
9019                "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
9020            ),
9021            "{scoped}"
9022        );
9023        assert!(
9024            !scoped.contains("active source: Codewhale-owned OAuth"),
9025            "a valid pointer is configured/unprobed, not an active credential: {scoped}"
9026        );
9027        assert!(
9028            !scoped.contains("fallback"),
9029            "an owned generation must never advertise Grok CLI fallback: {scoped}"
9030        );
9031
9032        let all = auth_status_all_providers(&store, &secrets).join("\n");
9033        let xai_row = all
9034            .lines()
9035            .find(|line| line.starts_with("xai"))
9036            .expect("xAI status row");
9037        assert!(
9038            xai_row.contains("Codewhale-owned OAuth configured/unprobed"),
9039            "{xai_row}"
9040        );
9041
9042        let list = auth_list_lines(&store, &secrets).join("\n");
9043        let xai_list_row = list
9044            .lines()
9045            .find(|line| line.starts_with("xai"))
9046            .expect("xAI list row");
9047        assert!(
9048            xai_list_row.ends_with("owned-oauth-configured"),
9049            "{xai_list_row}"
9050        );
9051
9052        let get = auth_get_line_with_runtime(
9053            &store,
9054            &secrets,
9055            ProviderKind::Xai,
9056            &CliRuntimeOverrides::default(),
9057        );
9058        assert!(
9059            get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
9060            "{get}"
9061        );
9062        assert!(!get.starts_with("xai: set"), "{get}");
9063        assert!(!get.contains("fallback"), "{get}");
9064        assert!(
9065            !keyring.queried().iter().any(|slot| slot == "xai"),
9066            "owned OAuth diagnostics must not query the xAI API-key store: {:?}",
9067            keyring.queried()
9068        );
9069        assert_eq!(
9070            std::fs::read_to_string(external_path).expect("external trap unchanged"),
9071            external_raw
9072        );
9073
9074        store.config.providers.xai.auth_mode = None;
9075        store.config.auth_mode = Some("oauth".to_string());
9076        assert_eq!(
9077            xai_auth_diagnostics(&store, &CliRuntimeOverrides::default()).route,
9078            XaiAuthDiagnosticRoute::ApiKey,
9079            "a root auth mode must not select the xAI OAuth runtime route"
9080        );
9081    }
9082
9083    #[test]
9084    fn xai_invalid_generation_requires_repair_blocks_external_and_keeps_api_key_diagnostics() {
9085        use std::sync::Arc;
9086
9087        let _lock = env_lock();
9088        let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
9089        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9090        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9091        let dir = tempfile::TempDir::new().expect("tempdir");
9092        let config_path = dir.path().join("config.toml");
9093        let external_path = dir.path().join("grok-auth.json");
9094        let external_raw = "external owner bytes must remain unread";
9095        std::fs::write(&external_path, external_raw).expect("external auth trap");
9096        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9097
9098        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9099        store.config.provider = ProviderKind::Xai;
9100        store.config.providers.xai.auth_mode = Some("oauth".to_string());
9101        store.config.providers.xai.api_key = Some("fake-cfg-key-1234".to_string());
9102        store.config.providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
9103        store.config.providers.xai.external_credentials =
9104            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9105                ProviderKind::Xai,
9106                codewhale_config::ExternalCredentialSource::GrokCli,
9107                external_path.clone(),
9108            ));
9109        let keyring = Arc::new(RecordingKeyringStore::default());
9110        let secrets = Secrets::new(keyring.clone());
9111
9112        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9113        assert!(
9114            scoped.contains("credential route: xAI OAuth needs repair"),
9115            "{scoped}"
9116        );
9117        assert!(
9118            scoped.contains("API-key fallback: config (last4: ...1234)"),
9119            "{scoped}"
9120        );
9121        assert!(scoped.contains("external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer"), "{scoped}");
9122        assert!(
9123            scoped.contains("repair: run `codewhale auth xai-device`"),
9124            "{scoped}"
9125        );
9126        assert!(
9127            !scoped.contains("external read-only consent (availability not probed)"),
9128            "invalid owned pointers must not activate Grok CLI consent: {scoped}"
9129        );
9130
9131        let all = auth_status_all_providers(&store, &secrets).join("\n");
9132        let xai_row = all
9133            .lines()
9134            .find(|line| line.starts_with("xai"))
9135            .expect("xAI status row");
9136        assert!(xai_row.contains("needs repair"), "{xai_row}");
9137        assert!(xai_row.contains("API-key fallback: config"), "{xai_row}");
9138
9139        let list = auth_list_lines(&store, &secrets).join("\n");
9140        let xai_list_row = list
9141            .lines()
9142            .find(|line| line.starts_with("xai"))
9143            .expect("xAI list row");
9144        assert!(xai_list_row.ends_with("needs-repair"), "{xai_list_row}");
9145
9146        let get = auth_get_line_with_runtime(
9147            &store,
9148            &secrets,
9149            ProviderKind::Xai,
9150            &CliRuntimeOverrides::default(),
9151        );
9152        assert!(get.contains("xai: needs repair"), "{get}");
9153        assert!(get.contains("API-key fallback: config-file"), "{get}");
9154        assert!(
9155            !keyring.queried().iter().any(|slot| slot == "xai"),
9156            "an invalid owned pointer must not query the xAI API-key store: {:?}",
9157            keyring.queried()
9158        );
9159        assert_eq!(
9160            std::fs::read_to_string(external_path).expect("external trap unchanged"),
9161            external_raw
9162        );
9163    }
9164
9165    #[test]
9166    fn xai_cli_custom_endpoint_rejects_inherited_api_key_sources() {
9167        use std::sync::Arc;
9168
9169        let _lock = env_lock();
9170        let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-3333");
9171        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9172        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9173        let dir = tempfile::TempDir::new().expect("tempdir");
9174        let config_path = dir.path().join("config.toml");
9175        let external_path = dir.path().join("grok-auth.json");
9176        let external_raw = "external owner bytes must remain unprobed";
9177        std::fs::write(&external_path, external_raw).expect("external auth trap");
9178        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9179
9180        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9181        store.config.provider = ProviderKind::Xai;
9182        store.config.providers.xai.api_key = Some("fake-cfg-key-1111".to_string());
9183        store.config.providers.xai.auth_mode = Some("oauth".to_string());
9184        store.config.providers.xai.oauth_credential_generation =
9185            Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
9186        store.config.providers.xai.external_credentials =
9187            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9188                ProviderKind::Xai,
9189                codewhale_config::ExternalCredentialSource::GrokCli,
9190                external_path.clone(),
9191            ));
9192        let keyring = Arc::new(RecordingKeyringStore::default());
9193        keyring.set_value("xai", "fake-store-key-2222");
9194        let secrets = Secrets::new(keyring.clone());
9195        let runtime_overrides = CliRuntimeOverrides {
9196            base_url: Some("https://gateway.example.test/v1".to_string()),
9197            ..CliRuntimeOverrides::default()
9198        };
9199
9200        let scoped = auth_status_lines_for_provider_with_runtime(
9201            &store,
9202            &secrets,
9203            ProviderKind::Xai,
9204            &runtime_overrides,
9205        )
9206        .join("\n");
9207        assert!(
9208            scoped.contains("route: https://gateway.example.test/v1"),
9209            "{scoped}"
9210        );
9211        assert!(scoped.contains("credential route: missing"), "{scoped}");
9212        assert!(
9213            scoped.contains("custom xAI endpoint; API-key-only"),
9214            "{scoped}"
9215        );
9216        assert!(
9217            scoped.contains("not eligible for this custom xAI endpoint"),
9218            "{scoped}"
9219        );
9220        assert!(
9221            scoped.contains("external credentials: unavailable on a custom xAI endpoint"),
9222            "{scoped}"
9223        );
9224        for redacted_tail in ["...1111", "...2222", "...3333"] {
9225            assert!(
9226                !scoped.contains(redacted_tail),
9227                "custom CLI route must not advertise an inherited credential: {scoped}"
9228            );
9229        }
9230
9231        let all =
9232            auth_status_all_providers_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
9233        let xai_row = all
9234            .lines()
9235            .find(|line| line.starts_with("xai"))
9236            .expect("xAI status row");
9237        assert!(xai_row.contains("unset"), "{xai_row}");
9238        assert!(
9239            !xai_row.contains("config") && !xai_row.contains("keyring") && !xai_row.contains("env"),
9240            "xAI summary must show runtime-effective sources only: {xai_row}"
9241        );
9242
9243        let list = auth_list_lines_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
9244        let xai_list_row = list
9245            .lines()
9246            .find(|line| line.starts_with("xai"))
9247            .expect("xAI list row");
9248        assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
9249
9250        let get =
9251            auth_get_line_with_runtime(&store, &secrets, ProviderKind::Xai, &runtime_overrides);
9252        assert_eq!(get, "xai: not set");
9253        assert!(
9254            !keyring.queried().iter().any(|slot| slot == "xai"),
9255            "a global custom endpoint must not query xAI keyring state: {:?}",
9256            keyring.queried()
9257        );
9258        assert_eq!(
9259            std::fs::read_to_string(external_path).expect("external trap unchanged"),
9260            external_raw
9261        );
9262    }
9263
9264    #[test]
9265    fn xai_env_custom_endpoint_rejects_inherited_api_key_sources() {
9266        use std::sync::Arc;
9267
9268        let _lock = env_lock();
9269        let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-6666");
9270        let _xai_base = ScopedEnvVar::set("XAI_BASE_URL", "https://env-gateway.example.test/v1");
9271        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9272        let dir = tempfile::TempDir::new().expect("tempdir");
9273        let config_path = dir.path().join("config.toml");
9274        let external_path = dir.path().join("grok-auth.json");
9275        let external_raw = "external owner bytes must remain unprobed";
9276        std::fs::write(&external_path, external_raw).expect("external auth trap");
9277        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9278
9279        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9280        store.config.provider = ProviderKind::Xai;
9281        store.config.providers.xai.api_key = Some("fake-cfg-key-4444".to_string());
9282        store.config.providers.xai.auth_mode = Some("oauth".to_string());
9283        store.config.providers.xai.oauth_credential_generation =
9284            Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
9285        store.config.providers.xai.external_credentials =
9286            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9287                ProviderKind::Xai,
9288                codewhale_config::ExternalCredentialSource::GrokCli,
9289                external_path.clone(),
9290            ));
9291        let keyring = Arc::new(RecordingKeyringStore::default());
9292        keyring.set_value("xai", "fake-store-key-5555");
9293        let secrets = Secrets::new(keyring.clone());
9294
9295        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9296        assert!(
9297            scoped.contains("route: https://env-gateway.example.test/v1"),
9298            "{scoped}"
9299        );
9300        assert!(scoped.contains("credential route: missing"), "{scoped}");
9301        assert!(
9302            scoped.contains("custom xAI endpoint; API-key-only"),
9303            "{scoped}"
9304        );
9305        for redacted_tail in ["...4444", "...5555", "...6666"] {
9306            assert!(
9307                !scoped.contains(redacted_tail),
9308                "custom env route must not advertise an inherited credential: {scoped}"
9309            );
9310        }
9311
9312        let all = auth_status_all_providers(&store, &secrets).join("\n");
9313        let xai_row = all
9314            .lines()
9315            .find(|line| line.starts_with("xai"))
9316            .expect("xAI status row");
9317        assert!(xai_row.contains("unset"), "{xai_row}");
9318
9319        let list = auth_list_lines(&store, &secrets).join("\n");
9320        let xai_list_row = list
9321            .lines()
9322            .find(|line| line.starts_with("xai"))
9323            .expect("xAI list row");
9324        assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
9325
9326        assert_eq!(
9327            auth_get_line_with_runtime(
9328                &store,
9329                &secrets,
9330                ProviderKind::Xai,
9331                &CliRuntimeOverrides::default(),
9332            ),
9333            "xai: not set"
9334        );
9335        assert!(
9336            !keyring.queried().iter().any(|slot| slot == "xai"),
9337            "an XAI_BASE_URL custom route must not query xAI keyring state: {:?}",
9338            keyring.queried()
9339        );
9340        assert_eq!(
9341            std::fs::read_to_string(external_path).expect("external trap unchanged"),
9342            external_raw
9343        );
9344    }
9345
9346    #[test]
9347    fn xai_config_bound_custom_endpoint_uses_its_route_key() {
9348        use std::sync::Arc;
9349
9350        let _lock = env_lock();
9351        let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
9352        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9353        let dir = tempfile::TempDir::new().expect("tempdir");
9354        let config_path = dir.path().join("config.toml");
9355        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9356        store.config.provider = ProviderKind::Xai;
9357        store.config.providers.xai.base_url =
9358            Some("https://bound-gateway.example.test/v1".to_string());
9359        store.config.providers.xai.api_key = Some("fake-bound-key-7777".to_string());
9360        let keyring = Arc::new(RecordingKeyringStore::default());
9361        keyring.set_value("xai", "fake-store-key-8888");
9362        let secrets = Secrets::new(keyring.clone());
9363
9364        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9365        assert!(
9366            scoped.contains("credential route: config (last4: ...7777)"),
9367            "{scoped}"
9368        );
9369        assert!(
9370            scoped.contains("config file:") && scoped.contains("runtime-effective, last4: ...7777"),
9371            "{scoped}"
9372        );
9373        assert_eq!(
9374            auth_get_line_with_runtime(
9375                &store,
9376                &secrets,
9377                ProviderKind::Xai,
9378                &CliRuntimeOverrides::default(),
9379            ),
9380            "xai: set (source: config-file)"
9381        );
9382        assert!(
9383            !keyring.queried().iter().any(|slot| slot == "xai"),
9384            "an endpoint-bound config key should resolve before the xAI keyring: {:?}",
9385            keyring.queried()
9386        );
9387    }
9388
9389    #[test]
9390    fn xai_absent_generation_with_consent_is_external_configured_and_unprobed() {
9391        use std::sync::Arc;
9392
9393        let _lock = env_lock();
9394        let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
9395        let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9396        let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9397        let dir = tempfile::TempDir::new().expect("tempdir");
9398        let config_path = dir.path().join("config.toml");
9399        let external_path = dir.path().join("grok-auth.json");
9400        let external_raw = "external owner bytes remain unprobed";
9401        std::fs::write(&external_path, external_raw).expect("external auth trap");
9402        let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9403
9404        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9405        store.config.provider = ProviderKind::Xai;
9406        store.config.providers.xai.auth_mode = Some("oauth".to_string());
9407        store.config.providers.xai.external_credentials =
9408            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9409                ProviderKind::Xai,
9410                codewhale_config::ExternalCredentialSource::GrokCli,
9411                external_path.clone(),
9412            ));
9413        let keyring = Arc::new(RecordingKeyringStore::default());
9414        let secrets = Secrets::new(keyring.clone());
9415
9416        let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9417        assert!(
9418            scoped.contains("credential route: external read-only consent configured/unprobed"),
9419            "{scoped}"
9420        );
9421        assert!(
9422            scoped.contains("external credentials: read_only"),
9423            "{scoped}"
9424        );
9425        assert!(
9426            scoped.contains(
9427                "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)"
9428            ),
9429            "{scoped}"
9430        );
9431
9432        let all = auth_status_all_providers(&store, &secrets).join("\n");
9433        let xai_row = all
9434            .lines()
9435            .find(|line| line.starts_with("xai"))
9436            .expect("xAI status row");
9437        assert!(
9438            xai_row.contains("external consent configured/unprobed"),
9439            "{xai_row}"
9440        );
9441
9442        let list = auth_list_lines(&store, &secrets).join("\n");
9443        let xai_list_row = list
9444            .lines()
9445            .find(|line| line.starts_with("xai"))
9446            .expect("xAI list row");
9447        assert!(
9448            xai_list_row.ends_with("external-consent-configured"),
9449            "{xai_list_row}"
9450        );
9451
9452        let get = auth_get_line_with_runtime(
9453            &store,
9454            &secrets,
9455            ProviderKind::Xai,
9456            &CliRuntimeOverrides::default(),
9457        );
9458        assert!(
9459            get.contains("source: external read-only consent; availability unprobed"),
9460            "{get}"
9461        );
9462        assert!(
9463            !keyring.queried().iter().any(|slot| slot == "xai"),
9464            "external-consent diagnostics must not query the xAI API-key store: {:?}",
9465            keyring.queried()
9466        );
9467        assert_eq!(
9468            std::fs::read_to_string(external_path).expect("external trap unchanged"),
9469            external_raw
9470        );
9471    }
9472
9473    #[test]
9474    fn auth_list_uses_persisted_consent_without_probing_codex_file() {
9475        use codewhale_secrets::InMemoryKeyringStore;
9476        use std::sync::Arc;
9477
9478        let _lock = env_lock();
9479        let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
9480        let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
9481
9482        let dir = tempfile::TempDir::new().expect("tempdir");
9483        let config_path = dir.path().join("config.toml");
9484        let auth_path = dir.path().join("auth.json");
9485        std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
9486            .expect("write auth file");
9487        let auth_path_str = auth_path.to_string_lossy().into_owned();
9488        let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
9489
9490        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9491        store.config.provider = ProviderKind::OpenaiCodex;
9492        store.config.providers.openai_codex.external_credentials =
9493            Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9494                ProviderKind::OpenaiCodex,
9495                codewhale_config::ExternalCredentialSource::CodexCli,
9496                auth_path,
9497            ));
9498        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
9499
9500        let output = auth_list_lines(&store, &secrets).join("\n");
9501        let row = output
9502            .lines()
9503            .find(|line| line.starts_with("openai-codex"))
9504            .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
9505        assert!(row.ends_with("external-consent"), "{row}");
9506        assert!(!output.contains("secret-token"));
9507    }
9508
9509    #[test]
9510    fn auth_list_labels_each_row_by_its_own_provider() {
9511        // ProviderKind::secret_store_slot collapses families onto one durable
9512        // slot -- SiliconflowCN onto `siliconflow`, the four Model Studio
9513        // variants onto `modelstudio-token-plan` -- but this table has one row
9514        // per kind. Labelling rows by slot printed `siliconflow` twice and
9515        // `modelstudio-token-plan` four times, so a reader could not tell which
9516        // row belonged to which provider.
9517        let _lock = env_lock();
9518        let dir = tempfile::TempDir::new().expect("tempdir");
9519        let store =
9520            ConfigStore::load(Some(dir.path().join("config.toml"))).expect("store should load");
9521        let secrets = Secrets::new(std::sync::Arc::new(
9522            codewhale_secrets::InMemoryKeyringStore::new(),
9523        ));
9524
9525        let lines = auth_list_lines(&store, &secrets);
9526        let labels: Vec<&str> = lines
9527            .iter()
9528            .skip(1)
9529            .filter_map(|line| line.split_whitespace().next())
9530            .collect();
9531
9532        assert_eq!(
9533            labels.len(),
9534            ProviderKind::ALL.len(),
9535            "one row per provider kind: {labels:?}"
9536        );
9537        let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
9538        assert_eq!(
9539            unique.len(),
9540            labels.len(),
9541            "every row must name its own provider, not a shared slot: {labels:?}"
9542        );
9543    }
9544
9545    #[test]
9546    fn external_consent_persists_exact_scope_and_api_key_or_revoke_disables_it() {
9547        let _lock = env_lock();
9548        let dir = tempfile::TempDir::new().expect("tempdir");
9549        let home = dir
9550            .path()
9551            .canonicalize()
9552            .expect("canonical temp root")
9553            .join("codewhale-home");
9554        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9555        let config_path = dir.path().join("config.toml");
9556        let external_path = dir.path().join("grok-auth.json");
9557        let external_raw = r#"{"secret":"must-never-be-read-or-written"}"#;
9558        std::fs::write(&external_path, external_raw).expect("external auth trap");
9559        let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
9560        let secrets = no_keyring_secrets();
9561
9562        let preview = external_consent_preview_lines(
9563            ProviderKind::Xai,
9564            codewhale_config::ExternalCredentialSource::GrokCli,
9565            &external_path,
9566        )
9567        .join("\n");
9568        assert!(preview.contains("owning CLI: Grok CLI"), "{preview}");
9569        assert!(
9570            preview.contains(&format!(
9571                "exact resolved path: {}",
9572                codewhale_config::quote_os_path(&external_path)
9573            )),
9574            "{preview}"
9575        );
9576        assert!(preview.contains("no refresh, identity-provider or discovery requests"));
9577        assert!(preview.contains("normal requests to the explicitly selected provider"));
9578        assert!(preview.contains("managed: unavailable"));
9579
9580        let mut prompt = Vec::new();
9581        confirm_external_consent_answer(&mut "yes\n".as_bytes(), &mut prompt)
9582            .expect("exact yes confirms");
9583        assert!(
9584            String::from_utf8(prompt)
9585                .unwrap()
9586                .contains("exact read-only")
9587        );
9588        let cancelled = confirm_external_consent_answer(&mut "YES\n".as_bytes(), &mut Vec::new())
9589            .expect_err("confirmation is deliberate and case-sensitive");
9590        assert!(cancelled.to_string().contains("cancelled"));
9591
9592        let unconfirmed = run_auth_command_with_secrets(
9593            &mut store,
9594            AuthCommand::ExternalConsent {
9595                provider: ProviderKind::Xai,
9596                mode: ExternalCredentialModeArg::ReadOnly,
9597                path: Some(external_path.clone()),
9598                yes: false,
9599            },
9600            &secrets,
9601        )
9602        .expect_err("non-interactive consent requires --yes");
9603        assert!(unconfirmed.to_string().contains("requires explicit --yes"));
9604        assert!(store.config.providers.xai.external_credentials.is_none());
9605        assert!(
9606            !config_path.exists(),
9607            "unconfirmed consent must not persist"
9608        );
9609
9610        run_auth_command_with_secrets(
9611            &mut store,
9612            AuthCommand::ExternalConsent {
9613                provider: ProviderKind::Xai,
9614                mode: ExternalCredentialModeArg::ReadOnly,
9615                path: Some(external_path.clone()),
9616                yes: true,
9617            },
9618            &secrets,
9619        )
9620        .expect("read-only consent should persist");
9621
9622        let consent = store
9623            .config
9624            .providers
9625            .xai
9626            .external_credentials
9627            .as_ref()
9628            .expect("persisted consent");
9629        assert_eq!(
9630            consent.access,
9631            codewhale_config::ExternalCredentialAccess::ReadOnly
9632        );
9633        assert_eq!(consent.provider, ProviderKind::Xai.as_str());
9634        assert_eq!(
9635            consent.source,
9636            codewhale_config::ExternalCredentialSource::GrokCli
9637        );
9638        assert_eq!(consent.path, external_path);
9639        assert_eq!(
9640            consent.consent_version,
9641            codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
9642        );
9643        assert_eq!(
9644            store.config.providers.xai.auth_mode.as_deref(),
9645            Some("oauth")
9646        );
9647        assert_eq!(
9648            std::fs::read_to_string(&consent.path).expect("external file unchanged"),
9649            external_raw
9650        );
9651
9652        let reloaded = ConfigStore::load(Some(config_path.clone())).expect("reload consent");
9653        let reloaded_consent = reloaded
9654            .config
9655            .providers
9656            .xai
9657            .external_credentials
9658            .as_ref()
9659            .expect("reloaded exact consent");
9660        assert_eq!(reloaded_consent.provider, ProviderKind::Xai.as_str());
9661        assert_eq!(
9662            reloaded_consent.source,
9663            codewhale_config::ExternalCredentialSource::GrokCli
9664        );
9665        assert_eq!(reloaded_consent.path, external_path);
9666        assert_eq!(
9667            reloaded_consent.consent_version,
9668            codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
9669        );
9670
9671        run_auth_command_with_secrets(
9672            &mut store,
9673            AuthCommand::Set {
9674                provider: ProviderKind::Xai,
9675                api_key: Some("xai-codewhale-owned-key".to_string()),
9676                api_key_stdin: false,
9677            },
9678            &secrets,
9679        )
9680        .expect("Codewhale-owned API key should supersede external consent");
9681        assert!(store.config.providers.xai.external_credentials.is_none());
9682        assert_eq!(
9683            std::fs::read_to_string(&external_path).expect("external file still unchanged"),
9684            external_raw
9685        );
9686
9687        run_auth_command_with_secrets(
9688            &mut store,
9689            AuthCommand::ExternalConsent {
9690                provider: ProviderKind::Xai,
9691                mode: ExternalCredentialModeArg::ReadOnly,
9692                path: Some(external_path.clone()),
9693                yes: true,
9694            },
9695            &secrets,
9696        )
9697        .expect("consent can be granted again");
9698        run_auth_command_with_secrets(
9699            &mut store,
9700            AuthCommand::ExternalRevoke {
9701                provider: ProviderKind::Xai,
9702            },
9703            &secrets,
9704        )
9705        .expect("revoke should persist");
9706        assert!(store.config.providers.xai.external_credentials.is_none());
9707        assert_eq!(
9708            std::fs::read_to_string(&external_path).expect("revoke never touches external file"),
9709            external_raw
9710        );
9711    }
9712
9713    #[test]
9714    fn unsupported_managed_and_kimi_external_consent_fail_closed() {
9715        let dir = tempfile::TempDir::new().expect("tempdir");
9716        let config_path = dir.path().join("config.toml");
9717        let external_path = dir.path().join("external-auth.json");
9718        std::fs::write(&external_path, "must remain unchanged").expect("external fixture");
9719        let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
9720        let secrets = no_keyring_secrets();
9721
9722        let managed = run_auth_command_with_secrets(
9723            &mut store,
9724            AuthCommand::ExternalConsent {
9725                provider: ProviderKind::OpenaiCodex,
9726                mode: ExternalCredentialModeArg::Managed,
9727                path: Some(external_path.clone()),
9728                yes: true,
9729            },
9730            &secrets,
9731        )
9732        .expect_err("managed access must fail without a preservation adapter");
9733        assert!(
9734            managed
9735                .to_string()
9736                .contains("schema-safe preservation adapter")
9737        );
9738
9739        let kimi = run_auth_command_with_secrets(
9740            &mut store,
9741            AuthCommand::ExternalConsent {
9742                provider: ProviderKind::Moonshot,
9743                mode: ExternalCredentialModeArg::ReadOnly,
9744                path: Some(external_path.clone()),
9745                yes: true,
9746            },
9747            &secrets,
9748        )
9749        .expect_err("Kimi must remain API-key-only");
9750        assert!(kimi.to_string().contains("API-key-only"));
9751        assert!(
9752            kimi.to_string()
9753                .contains("https://platform.kimi.ai/console/api-keys")
9754        );
9755        assert!(
9756            store
9757                .config
9758                .providers
9759                .openai_codex
9760                .external_credentials
9761                .is_none()
9762        );
9763        assert!(
9764            store
9765                .config
9766                .providers
9767                .moonshot
9768                .external_credentials
9769                .is_none()
9770        );
9771        assert_eq!(
9772            std::fs::read_to_string(external_path).expect("external fixture unchanged"),
9773            "must remain unchanged"
9774        );
9775        assert!(
9776            !config_path.exists(),
9777            "rejected consent must not write config"
9778        );
9779    }
9780
9781    #[test]
9782    fn api_key_config_failure_restores_absent_and_existing_secret_state() {
9783        let _lock = env_lock();
9784        for prior in [None, Some("prior-xai-key")] {
9785            let dir = tempfile::TempDir::new().expect("tempdir");
9786            let home = dir
9787                .path()
9788                .canonicalize()
9789                .expect("canonical temp root")
9790                .join("codewhale-home");
9791            let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9792            let config_path = dir.path().join("config.toml");
9793            let mut store = ConfigStore::load(Some(config_path.clone())).expect("load store");
9794            store.config.providers.xai.auth_mode = Some("oauth".to_string());
9795            store.config.providers.xai.external_credentials =
9796                Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9797                    ProviderKind::Xai,
9798                    codewhale_config::ExternalCredentialSource::GrokCli,
9799                    dir.path().join("external.json"),
9800                ));
9801            std::fs::create_dir(&config_path).expect("turn config target into a directory");
9802            let secrets = no_keyring_secrets();
9803            if let Some(prior) = prior {
9804                secrets.set("xai", prior).expect("seed prior secret");
9805            }
9806
9807            let error = run_auth_command_with_secrets(
9808                &mut store,
9809                AuthCommand::Set {
9810                    provider: ProviderKind::Xai,
9811                    api_key: Some("new-xai-key".to_string()),
9812                    api_key_stdin: false,
9813                },
9814                &secrets,
9815            )
9816            .expect_err("config write must fail");
9817            assert!(error.to_string().contains("config"), "{error:#}");
9818            assert_eq!(
9819                secrets.get("xai").expect("restored secret"),
9820                prior.map(str::to_string)
9821            );
9822            assert_eq!(
9823                store.config.providers.xai.auth_mode.as_deref(),
9824                Some("oauth")
9825            );
9826            assert!(store.config.providers.xai.external_credentials.is_some());
9827            assert!(store.config.providers.xai.api_key.is_none());
9828            assert!(config_path.is_dir());
9829        }
9830    }
9831
9832    #[test]
9833    fn auth_status_scoped_provider_shows_detailed_info() {
9834        use codewhale_secrets::InMemoryKeyringStore;
9835        use std::sync::Arc;
9836
9837        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
9838        let path = std::env::temp_dir().join(format!(
9839            "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
9840            std::process::id()
9841        ));
9842        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9843        store.config.provider = ProviderKind::Deepseek;
9844        store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
9845
9846        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
9847
9848        let output =
9849            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
9850
9851        assert!(output.contains("provider: arcee"));
9852        assert!(output.contains("active source: config (last4: ...9999)"));
9853        assert!(output.contains("route:"));
9854        assert!(output.contains("model:"));
9855        assert!(!output.contains("sk-arcee-9999"));
9856
9857        for sentinel in [codewhale_config::API_KEYRING_SENTINEL, "  __KEYRING__  "] {
9858            store.config.providers.arcee.api_key = Some(sentinel.to_string());
9859            assert_eq!(provider_config_api_key(&store, ProviderKind::Arcee), None);
9860        }
9861
9862        let _ = std::fs::remove_file(path);
9863    }
9864
9865    #[test]
9866    fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
9867        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
9868        use std::sync::Arc;
9869
9870        // Runtime resolution reads process-global provider environment overrides.
9871        // Serialize with the tests that temporarily set those overrides so this
9872        // in-memory DeepSeek credential is not resolved against another provider.
9873        let _lock = env_lock();
9874        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
9875        let path = std::env::temp_dir().join(format!(
9876            "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
9877            std::process::id()
9878        ));
9879        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9880        let inner = Arc::new(InMemoryKeyringStore::new());
9881        inner.set("deepseek", "ring-key").unwrap();
9882        let secrets = Secrets::new(inner);
9883
9884        let resolved = resolve_runtime_for_dispatch_with_secrets(
9885            &mut store,
9886            &CliRuntimeOverrides::default(),
9887            &secrets,
9888        );
9889
9890        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
9891        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
9892        assert!(store.config.api_key.is_none());
9893        assert!(store.config.providers.deepseek.api_key.is_none());
9894        assert!(
9895            !path.exists(),
9896            "dispatch must not create config from a stored key"
9897        );
9898
9899        let resolved_again = resolve_runtime_for_dispatch_with_secrets(
9900            &mut store,
9901            &CliRuntimeOverrides::default(),
9902            &secrets,
9903        );
9904        assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
9905        assert_eq!(
9906            resolved_again.api_key_source,
9907            Some(RuntimeApiKeySource::Keyring)
9908        );
9909        assert!(
9910            !path.exists(),
9911            "repeat dispatch must remain credential-file free"
9912        );
9913
9914        let _ = std::fs::remove_file(path);
9915    }
9916
9917    #[test]
9918    fn logout_removes_plaintext_provider_keys() {
9919        let _lock = env_lock();
9920        let dir = tempfile::TempDir::new().expect("tempdir");
9921        let home = dir
9922            .path()
9923            .canonicalize()
9924            .expect("canonical temp root")
9925            .join("codewhale-home");
9926        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9927        let path = home.join("config.toml");
9928        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9929        store.config.api_key = Some("sk-stale".to_string());
9930        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
9931        store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
9932        store.config.providers.xai.auth_mode = Some("oauth".to_string());
9933        let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
9934        store.config.providers.xai.oauth_credential_generation = Some(generation.to_string());
9935        store.save().unwrap();
9936        let credentials = home.join("credentials");
9937        codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
9938            owned.write(generation, b"xai-generation", false)?;
9939            owned.write(
9940                codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
9941                b"legacy-xai",
9942                false,
9943            )?;
9944            Ok(())
9945        })
9946        .expect("seed Codewhale-owned xAI credentials");
9947        std::fs::write(credentials.join("other-provider.json"), "preserve").unwrap();
9948
9949        let secrets = no_keyring_secrets();
9950
9951        run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
9952
9953        assert!(store.config.api_key.is_none());
9954        assert!(store.config.providers.deepseek.api_key.is_none());
9955        assert!(store.config.providers.fireworks.api_key.is_none());
9956        assert!(store.config.providers.xai.auth_mode.is_none());
9957        assert!(
9958            store
9959                .config
9960                .providers
9961                .xai
9962                .oauth_credential_generation
9963                .is_none()
9964        );
9965        assert!(!credentials.join(generation).exists());
9966        assert!(!credentials.join("xai-auth.json").exists());
9967        assert!(credentials.join("other-provider.json").exists());
9968
9969        let _ = std::fs::remove_file(path);
9970    }
9971
9972    #[test]
9973    fn logout_clears_keyring_credentials_for_all_providers() {
9974        // Logout used to delete the keyring secret only for the *active*
9975        // provider, leaving credentials stored under other providers
9976        // behind while printing "logged out".
9977        let _lock = env_lock();
9978        let dir = tempfile::TempDir::new().expect("tempdir");
9979        let home = dir
9980            .path()
9981            .canonicalize()
9982            .expect("canonical temp root")
9983            .join("codewhale-home");
9984        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9985        let path = home.join("config.toml");
9986        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9987        store.config.provider = ProviderKind::Deepseek;
9988
9989        let secrets = no_keyring_secrets();
9990        secrets
9991            .set(provider_slot(ProviderKind::Deepseek), "sk-deepseek")
9992            .expect("seed deepseek key");
9993        secrets
9994            .set(provider_slot(ProviderKind::Fireworks), "fw-stale")
9995            .expect("seed fireworks key");
9996
9997        run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
9998
9999        for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
10000            assert!(
10001                provider_keyring_api_key(&secrets, provider).is_none(),
10002                "keyring credential for {provider:?} survived logout"
10003            );
10004        }
10005
10006        let _ = std::fs::remove_file(path);
10007    }
10008
10009    #[test]
10010    fn logout_clears_account_session_and_daytona_slot() {
10011        use codewhale_secrets::account::{
10012            AccountAuthBundle, AccountSession, AccountSessionStore, AccountUser,
10013            DEFAULT_ACCOUNT_API_BASE, secure_account_session_secrets,
10014        };
10015
10016        let _lock = env_lock();
10017        let dir = tempfile::TempDir::new().expect("tempdir");
10018        let home = dir
10019            .path()
10020            .canonicalize()
10021            .expect("canonical temp root")
10022            .join("codewhale-home");
10023        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
10024        let path = home.join("config.toml");
10025        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10026
10027        let secrets = no_keyring_secrets();
10028        secrets
10029            .set(codewhale_secrets::DAYTONA_TOKEN_SLOT, "dtn_logout")
10030            .expect("seed daytona token");
10031
10032        let account = secure_account_session_secrets().expect("account store");
10033        AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
10034            .save(AccountAuthBundle {
10035                token_type: "Bearer".to_string(),
10036                access_token: "access-logout".to_string(),
10037                refresh_token: "refresh-logout".to_string(),
10038                session: Some(AccountSession {
10039                    id: "session-logout".to_string(),
10040                    ..AccountSession::default()
10041                }),
10042                user: Some(AccountUser {
10043                    id: "acct-logout".to_string(),
10044                    ..AccountUser::default()
10045                }),
10046            })
10047            .expect("seed account session");
10048
10049        run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
10050
10051        assert!(
10052            secrets
10053                .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
10054                .expect("read daytona")
10055                .is_none(),
10056            "daytona slot survived logout"
10057        );
10058        let account = secure_account_session_secrets().expect("account store after logout");
10059        assert!(
10060            AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
10061                .load()
10062                .expect("load account")
10063                .is_none(),
10064            "account session survived logout"
10065        );
10066
10067        let _ = std::fs::remove_file(path);
10068    }
10069
10070    #[test]
10071    fn auth_set_slot_daytona_has_no_user_surface() {
10072        // The internal cloud-agent credential is managed by Codewhale, not
10073        // users: no CLI command may write or clear it, and no help text may
10074        // teach it. Membership (`codewhale login`) is the only door.
10075        use codewhale_secrets::InMemoryKeyringStore;
10076        use std::sync::Arc;
10077
10078        for argv in [
10079            vec![
10080                "codewhale",
10081                "auth",
10082                "set-slot",
10083                "daytona",
10084                "--api-key",
10085                "dtn_saved",
10086            ],
10087            vec!["codewhale", "auth", "clear-slot", "daytona"],
10088        ] {
10089            assert!(
10090                Cli::try_parse_from(argv).is_err(),
10091                "slot commands must not parse"
10092            );
10093        }
10094
10095        let inner = Arc::new(InMemoryKeyringStore::new());
10096        let secrets = Secrets::new(inner);
10097        assert!(
10098            secrets
10099                .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
10100                .expect("read slot")
10101                .is_none()
10102        );
10103    }
10104
10105    #[test]
10106    fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
10107        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10108        use std::sync::Arc;
10109
10110        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10111        let path = std::env::temp_dir().join(format!(
10112            "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
10113            std::process::id()
10114        ));
10115        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10116        store.config.api_key = Some("sk-deep".to_string());
10117        store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
10118        store.config.providers.openrouter.api_key = Some("or-key".to_string());
10119        store.config.providers.novita.api_key = Some("nv-key".to_string());
10120        store.save().unwrap();
10121
10122        let inner = Arc::new(InMemoryKeyringStore::new());
10123        let secrets = Secrets::new(inner.clone());
10124
10125        run_auth_command_with_secrets(
10126            &mut store,
10127            AuthCommand::Migrate { dry_run: false },
10128            &secrets,
10129        )
10130        .expect("migrate should succeed");
10131
10132        assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
10133        assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
10134        assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
10135
10136        // Config file must no longer contain the api keys.
10137        assert!(store.config.api_key.is_none());
10138        assert!(store.config.providers.deepseek.api_key.is_none());
10139        assert!(store.config.providers.openrouter.api_key.is_none());
10140        assert!(store.config.providers.novita.api_key.is_none());
10141
10142        let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
10143        assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
10144        assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
10145        assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
10146
10147        let backup_path = path.with_file_name(format!(
10148            "{}.bak",
10149            path.file_name().unwrap_or_default().to_string_lossy()
10150        ));
10151        let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
10152        assert!(
10153            !backup.contains("sk-deep"),
10154            "plaintext leaked in backup: {backup}"
10155        );
10156        assert!(
10157            !backup.contains("or-key"),
10158            "plaintext leaked in backup: {backup}"
10159        );
10160        assert!(
10161            !backup.contains("nv-key"),
10162            "plaintext leaked in backup: {backup}"
10163        );
10164
10165        let resolved = resolve_runtime_for_dispatch_with_secrets(
10166            &mut store,
10167            &CliRuntimeOverrides::default(),
10168            &secrets,
10169        );
10170        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
10171        let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
10172        assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
10173        assert!(
10174            !after_dispatch
10175                .lines()
10176                .any(|line| line.trim_start().starts_with("api_key ="))
10177        );
10178
10179        let _ = std::fs::remove_file(path);
10180    }
10181
10182    #[test]
10183    fn auth_migrate_dry_run_does_not_modify_anything() {
10184        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10185        use std::sync::Arc;
10186
10187        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10188        let path = std::env::temp_dir().join(format!(
10189            "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
10190            std::process::id()
10191        ));
10192        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10193        store.config.providers.openrouter.api_key = Some("or-stay".to_string());
10194        store.save().unwrap();
10195
10196        let inner = Arc::new(InMemoryKeyringStore::new());
10197        let secrets = Secrets::new(inner.clone());
10198
10199        run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
10200            .expect("dry-run should succeed");
10201
10202        assert_eq!(inner.get("openrouter").unwrap(), None);
10203        assert_eq!(
10204            store.config.providers.openrouter.api_key.as_deref(),
10205            Some("or-stay")
10206        );
10207
10208        let _ = std::fs::remove_file(path);
10209    }
10210
10211    #[test]
10212    fn parses_global_override_flags() {
10213        let cli = parse_ok(&[
10214            "deepseek",
10215            "--provider",
10216            "openai",
10217            "--config",
10218            "/tmp/deepseek.toml",
10219            "--profile",
10220            "work",
10221            "--model",
10222            "deepseek-v4-pro",
10223            "--output-mode",
10224            "json",
10225            "--verbosity",
10226            "concise",
10227            "--log-level",
10228            "debug",
10229            "--telemetry",
10230            "true",
10231            "--approval-policy",
10232            "on-request",
10233            "--sandbox-mode",
10234            "workspace-write",
10235            "--base-url",
10236            "https://openai-compatible.example/v1",
10237            "--api-key",
10238            "sk-test",
10239            "--workspace",
10240            "/tmp/workspace",
10241            "--no-mouse-capture",
10242            "--skip-onboarding",
10243            "model",
10244            "resolve",
10245            "deepseek-v4-pro",
10246        ]);
10247
10248        assert_eq!(cli.provider.as_deref(), Some("openai"));
10249        assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
10250        assert_eq!(cli.profile.as_deref(), Some("work"));
10251        assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
10252        assert_eq!(cli.output_mode.as_deref(), Some("json"));
10253        assert_eq!(cli.verbosity.as_deref(), Some("concise"));
10254        assert_eq!(cli.log_level.as_deref(), Some("debug"));
10255        assert_eq!(cli.telemetry, Some(true));
10256        assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
10257        assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
10258        assert_eq!(
10259            cli.base_url.as_deref(),
10260            Some("https://openai-compatible.example/v1")
10261        );
10262        assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
10263        assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
10264        assert!(cli.no_mouse_capture);
10265        assert!(!cli.mouse_capture);
10266        assert!(cli.skip_onboarding);
10267    }
10268
10269    #[test]
10270    fn cli_provider_helpers_follow_config_metadata() {
10271        let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
10272            .iter()
10273            .map(|provider| provider.kind())
10274            .collect();
10275        // Full registry keeps legacy dialect/plan kinds; ALL is the catalog surface.
10276        assert_eq!(registry_kinds.len(), 49);
10277        // The tombstone stays in the registry (old config must still parse
10278        // and clear) and left the catalog surface when it stopped being
10279        // selectable.
10280        assert_eq!(ProviderKind::ALL.len(), 43);
10281        for kind in ProviderKind::ALL {
10282            assert!(
10283                registry_kinds.contains(&kind),
10284                "catalog kind {kind:?} must remain in the full registry"
10285            );
10286        }
10287
10288        for provider in registry_kinds {
10289            assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
10290            // Shared-account families collapse onto one durable slot (see
10291            // ProviderKind::secret_store_slot); everything else uses its own id.
10292            assert_eq!(
10293                provider_slot(provider),
10294                provider.secret_store_slot(),
10295                "{provider:?} slot must match ProviderKind::secret_store_slot"
10296            );
10297            if provider == ProviderKind::SiliconflowCN {
10298                assert_eq!(
10299                    provider_slot(provider),
10300                    provider_slot(ProviderKind::Siliconflow)
10301                );
10302            } else if matches!(
10303                provider,
10304                ProviderKind::ModelstudioTokenPlan
10305                    | ProviderKind::ModelstudioTokenPlanAnthropic
10306                    | ProviderKind::ModelstudioCodingPlan
10307                    | ProviderKind::ModelstudioCodingPlanAnthropic
10308            ) {
10309                assert_eq!(
10310                    provider_slot(provider),
10311                    "modelstudio-token-plan",
10312                    "{provider:?} must share the Model Studio family slot"
10313                );
10314            } else {
10315                assert_eq!(provider_slot(provider), provider.provider().id());
10316            }
10317        }
10318    }
10319
10320    #[test]
10321    fn the_telemetry_flag_documents_itself_in_help() {
10322        // A consent control nobody can find is a consent control nobody has.
10323        let help = Cli::command().render_long_help().to_string();
10324        let telemetry_line = help
10325            .lines()
10326            .position(|line| line.contains("--telemetry"))
10327            .map(|index| help.lines().skip(index).take(3).collect::<String>())
10328            .expect("--telemetry must appear in --help");
10329        assert!(
10330            telemetry_line.contains("telemetry"),
10331            "expected a help string beside --telemetry, got: {telemetry_line}"
10332        );
10333        assert!(
10334            telemetry_line.contains("default on"),
10335            "the help string must disclose the default: {telemetry_line}"
10336        );
10337        assert!(
10338            telemetry_line.contains("CODEWHALE_TELEMETRY=0 always")
10339                && telemetry_line.contains("wins"),
10340            "the help string must document the always-winning opt-out: {telemetry_line}"
10341        );
10342        let help = help_for(&["codewhale", "config", "telemetry", "--help"]);
10343        assert!(help.contains("PostHog"));
10344        assert!(help.contains("--accept-notice"));
10345    }
10346
10347    #[test]
10348    fn cli_telemetry_acceptance_is_versioned_and_reuses_settings_persistence() {
10349        let _lock = env_lock();
10350        let temp = tempfile::tempdir().expect("tempdir");
10351        let _home = ScopedEnvVar::set("CODEWHALE_HOME", temp.path().to_str().unwrap());
10352        let path = temp.path().join("config.toml");
10353        write_config_fixture(&path, "telemetry = false\nverbosity = \"concise\"\n");
10354        let mut state = SetupState::default();
10355        state.record_telemetry_notice("3", false);
10356        state.save().expect("seed decline");
10357        let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
10358
10359        // An explicit enable command clears a historical decline through Settings.
10360        run_config_command(
10361            &mut store,
10362            ConfigCommand::Set {
10363                key: "telemetry".into(),
10364                value: "true".into(),
10365            },
10366            false,
10367            &[],
10368        )
10369        .expect("save configuration preference");
10370        assert!(!SetupState::load().unwrap().unwrap().telemetry_opted_out());
10371        assert_eq!(
10372            telemetry_preference_status(Some(true)),
10373            "On (saved preference)"
10374        );
10375        let before = std::fs::read(SetupState::path().unwrap()).unwrap();
10376        run_config_command(
10377            &mut store,
10378            ConfigCommand::Telemetry {
10379                accept_notice: None,
10380            },
10381            false,
10382            &[],
10383        )
10384        .expect("read notice");
10385        assert!(
10386            run_config_command(
10387                &mut store,
10388                ConfigCommand::Telemetry {
10389                    accept_notice: Some(3)
10390                },
10391                false,
10392                &[]
10393            )
10394            .is_err()
10395        );
10396        assert_eq!(std::fs::read(SetupState::path().unwrap()).unwrap(), before);
10397
10398        run_config_command(
10399            &mut store,
10400            ConfigCommand::Telemetry {
10401                accept_notice: Some(telemetry::NOTICE_VERSION),
10402            },
10403            false,
10404            &[],
10405        )
10406        .expect("accept current processor notice");
10407        assert_eq!(
10408            telemetry_preference_status(Some(true)),
10409            "On (saved preference)"
10410        );
10411        let saved = ConfigStore::load(Some(path)).unwrap();
10412        assert_eq!(saved.config.telemetry, Some(true));
10413        assert_eq!(saved.config.verbosity.as_deref(), Some("concise"));
10414        assert!(
10415            !temp.path().join("telemetry").exists(),
10416            "acceptance never arms this process"
10417        );
10418    }
10419
10420    #[test]
10421    fn cli_telemetry_acceptance_refuses_overlays_and_corrupt_privacy_records() {
10422        let _lock = env_lock();
10423        let temp = tempfile::tempdir().expect("tempdir");
10424        let _home = ScopedEnvVar::set("CODEWHALE_HOME", temp.path().to_str().unwrap());
10425        let path = temp.path().join("config.toml");
10426        write_config_fixture(&path, "telemetry = false\n");
10427        std::fs::write(SetupState::path().unwrap(), "not-json").unwrap();
10428        let before = std::fs::read(&path).unwrap();
10429        let mut store = ConfigStore::load(Some(path.clone())).unwrap();
10430        for overrides in [vec![], vec!["telemetry=true".to_string()]] {
10431            assert!(
10432                run_config_command(
10433                    &mut store,
10434                    ConfigCommand::Telemetry {
10435                        accept_notice: Some(telemetry::NOTICE_VERSION),
10436                    },
10437                    false,
10438                    &overrides
10439                )
10440                .is_err()
10441            );
10442            assert_eq!(std::fs::read(&path).unwrap(), before);
10443            assert_eq!(
10444                std::fs::read_to_string(SetupState::path().unwrap()).unwrap(),
10445                "not-json"
10446            );
10447        }
10448    }
10449
10450    #[test]
10451    fn root_help_describes_product_actions_not_internal_tui_layers() {
10452        let help = Cli::command().render_long_help().to_string();
10453        assert!(
10454            !help.contains("TUI"),
10455            "root help must describe what Codewhale does, not its internal UI/runtime layers:\n{help}"
10456        );
10457    }
10458
10459    #[test]
10460    fn only_one_function_may_locate_and_spawn_the_tui() {
10461        // Single-binary invariant: no sibling TUI discovery exists. The
10462        // two-process glue has been deleted; the only TUI entry is codewhale_tui::run.
10463        let source = include_str!("lib.rs");
10464        let a = format!("{}{}", "locate_sibling", "_tui_binary");
10465        let b = format!("{}{}", "tui_spawn", "_error");
10466        let c = format!("{}{}", "build_tui", "_command");
10467        let d = format!("{}{}", "Command::new", "(&tui)");
10468        assert!(
10469            !source.contains(&a),
10470            "single binary must not contain sibling TUI discovery"
10471        );
10472        assert!(
10473            !source.contains(&b),
10474            "single binary must not contain tui spawn error"
10475        );
10476        assert!(
10477            !source.contains(&c),
10478            "single binary must not contain build_tui dispatch"
10479        );
10480        assert!(
10481            !source.contains(&d),
10482            "single binary must not contain Command new tui"
10483        );
10484    }
10485
10486    #[test]
10487    fn parses_no_project_config_before_subcommand() {
10488        let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "list the files"]);
10489        assert!(cli.no_project_config);
10490        match cli.command {
10491            Some(Commands::Exec(args)) => {
10492                assert_eq!(args.args, vec!["list the files".to_string()]);
10493            }
10494            other => panic!("expected exec subcommand, got {other:?}"),
10495        }
10496    }
10497
10498    #[test]
10499    fn no_project_config_after_passthrough_subcommand_is_not_the_dispatcher_flag() {
10500        // `exec` captures trailing args (`trailing_var_arg`), so a misplaced
10501        // `--no-project-config` is NOT honored as the dispatcher flag — it must
10502        // appear before the subcommand, exactly like `--skip-onboarding`.
10503        let cli = parse_ok(&["codewhale", "exec", "--no-project-config", "hi"]);
10504        assert!(!cli.no_project_config);
10505        match cli.command {
10506            Some(Commands::Exec(args)) => {
10507                assert!(args.args.iter().any(|a| a == "--no-project-config"));
10508            }
10509            other => panic!("expected exec subcommand, got {other:?}"),
10510        }
10511    }
10512
10513    #[test]
10514    fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
10515        let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
10516
10517        assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
10518        assert!(cli.prompt.is_empty());
10519        assert_eq!(
10520            root_tui_passthrough(&cli).unwrap(),
10521            vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
10522        );
10523    }
10524
10525    #[test]
10526    fn root_fresh_and_mouse_flags_forward_as_separate_tui_arguments() {
10527        for flags in [
10528            ["--fresh", "--mouse-capture"],
10529            ["--mouse-capture", "--fresh"],
10530        ] {
10531            let cli = parse_ok(&[
10532                "codewhale",
10533                "--workspace",
10534                "workspace with spaces",
10535                "--no-project-config",
10536                flags[0],
10537                flags[1],
10538            ]);
10539            assert_eq!(
10540                tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10541                [
10542                    "codewhale",
10543                    "--workspace",
10544                    "workspace with spaces",
10545                    "--mouse-capture",
10546                    "--fresh",
10547                    "--no-project-config",
10548                ],
10549                "{flags:?} must remain launch flags, not a joined prompt"
10550            );
10551        }
10552    }
10553
10554    #[test]
10555    fn root_fresh_preserves_quoted_prompt_whitespace_and_split_tail() {
10556        let cli = parse_ok(&[
10557            "codewhale",
10558            "--fresh",
10559            "--mouse-capture",
10560            "--prompt",
10561            "Keep  two spaces\nand a tab\there",
10562            "then",
10563            "explain them",
10564        ]);
10565        assert_eq!(
10566            tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10567            [
10568                "codewhale",
10569                "--mouse-capture",
10570                "--fresh",
10571                "--prompt",
10572                "Keep  two spaces\nand a tab\there then explain them",
10573            ]
10574        );
10575    }
10576
10577    #[test]
10578    fn root_prompt_tail_does_not_reinterpret_literal_launch_flags() {
10579        let cli = parse_ok(&[
10580            "codewhale",
10581            "Explain",
10582            "--fresh",
10583            "--mouse-capture",
10584            "as literal flags",
10585        ]);
10586        assert_eq!(
10587            tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10588            [
10589                "codewhale",
10590                "--prompt",
10591                "Explain --fresh --mouse-capture as literal flags",
10592            ]
10593        );
10594    }
10595
10596    #[test]
10597    fn parses_top_level_continue_for_interactive_resume() {
10598        let cli = parse_ok(&["codewhale", "--continue"]);
10599
10600        assert!(cli.continue_session);
10601        assert!(cli.prompt_flag.is_none());
10602        assert!(cli.prompt.is_empty());
10603        assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
10604    }
10605
10606    #[test]
10607    fn parses_top_level_resume_flags_for_interactive_resume() {
10608        // The operations runbook advertises `codewhale --resume <id>`. Before
10609        // the root flag existed, the trailing prompt positional swallowed it
10610        // and forwarded `--prompt "--resume <id>"` to the TUI (exit 2).
10611        for argv in [
10612            &["codewhale", "--resume", "800596e6"][..],
10613            &["codewhale", "--resume=800596e6"][..],
10614            &["codewhale", "-r", "800596e6"][..],
10615            &["codewhale", "--session-id", "800596e6"][..],
10616            &["codewhale", "--session-id=800596e6"][..],
10617        ] {
10618            let cli = parse_ok(argv);
10619            assert!(
10620                cli.prompt.is_empty(),
10621                "{argv:?} must not be swallowed as a prompt: {:?}",
10622                cli.prompt
10623            );
10624            assert!(cli.prompt_flag.is_none(), "{argv:?}");
10625            assert!(cli.command.is_none(), "{argv:?}");
10626            assert_eq!(
10627                root_tui_passthrough(&cli).unwrap(),
10628                vec!["--resume".to_string(), "800596e6".to_string()],
10629                "{argv:?}"
10630            );
10631        }
10632    }
10633
10634    #[test]
10635    fn empty_resume_identifier_is_rejected_rather_than_starting_fresh() {
10636        // `codewhale --resume "$SESSION_ID"` with the variable unset used to
10637        // trim to empty, filter to None, and start a brand-new session while
10638        // looking like it resumed one. Losing the session the user asked for
10639        // must be loud.
10640        for argv in [
10641            &["codewhale", "--resume", ""][..],
10642            &["codewhale", "--session-id", "   "][..],
10643        ] {
10644            let cli = parse_ok(argv);
10645            let err = root_tui_passthrough(&cli)
10646                .expect_err("an empty resume id must not silently start a fresh session");
10647            assert!(
10648                err.to_string().contains("needs a session id"),
10649                "{argv:?}: {err}"
10650            );
10651        }
10652    }
10653
10654    #[test]
10655    fn top_level_resume_rejects_startup_prompt_and_conflicting_flags() {
10656        let cli = parse_ok(&["codewhale", "--resume", "800596e6", "-p", "follow up"]);
10657        let err = root_tui_passthrough(&cli).expect_err("prompted resume should be rejected");
10658        assert!(
10659            err.to_string()
10660                .contains("codewhale exec --resume 800596e6 <PROMPT>"),
10661            "{err}"
10662        );
10663
10664        assert!(Cli::try_parse_from(["codewhale", "--resume", "800596e6", "--continue"]).is_err());
10665        assert!(Cli::try_parse_from(["codewhale", "--resume", "a", "--session-id", "b"]).is_err());
10666        assert!(Cli::try_parse_from(["codewhale", "--session-id", "b", "-c"]).is_err());
10667    }
10668
10669    #[test]
10670    fn parses_rc_as_the_account_owned_interactive_handoff() {
10671        let cli = parse_ok(&["codewhale", "rc"]);
10672
10673        let Some(Commands::Rc(args)) = cli.command else {
10674            panic!("rc should parse as the remote-control TUI handoff");
10675        };
10676        assert!(args.args.is_empty());
10677    }
10678
10679    #[test]
10680    fn top_level_continue_rejects_startup_prompt() {
10681        let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
10682
10683        let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
10684        assert!(
10685            err.to_string()
10686                .contains("codewhale exec --continue <PROMPT>")
10687        );
10688    }
10689
10690    #[test]
10691    fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
10692        let cli = parse_ok(&["deepseek", "hello", "world"]);
10693
10694        assert_eq!(cli.prompt, vec!["hello", "world"]);
10695        assert!(cli.command.is_none());
10696        assert_eq!(
10697            root_tui_passthrough(&cli).unwrap(),
10698            vec!["--prompt".to_string(), "hello world".to_string()]
10699        );
10700    }
10701
10702    #[test]
10703    fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
10704        let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
10705
10706        assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
10707        assert_eq!(cli.prompt, vec!["world"]);
10708        assert_eq!(
10709            root_tui_passthrough(&cli).unwrap(),
10710            vec!["--prompt".to_string(), "hello world".to_string()]
10711        );
10712    }
10713
10714    #[test]
10715    fn known_subcommands_still_parse_before_prompt_tail() {
10716        let cli = parse_ok(&["deepseek", "doctor"]);
10717
10718        assert!(cli.prompt.is_empty());
10719        assert!(matches!(cli.command, Some(Commands::Doctor(_))));
10720    }
10721
10722    #[test]
10723    fn root_help_surface_contains_expected_subcommands_and_globals() {
10724        let rendered = help_for(&["deepseek", "--help"]);
10725
10726        for token in [
10727            "run",
10728            "doctor",
10729            "models",
10730            "sessions",
10731            "resume",
10732            "setup",
10733            "login",
10734            "logout",
10735            "auth",
10736            "mcp-server",
10737            "config",
10738            "model",
10739            "providers",
10740            "thread",
10741            "sandbox",
10742            "app-server",
10743            "completion",
10744            "metrics",
10745            "--provider",
10746            "--model",
10747            "--config",
10748            "--profile",
10749            "--output-mode",
10750            "--log-level",
10751            "--telemetry",
10752            "--base-url",
10753            "--api-key",
10754            "--approval-policy",
10755            "--sandbox-mode",
10756            "--mouse-capture",
10757            "--no-mouse-capture",
10758            "--skip-onboarding",
10759            "--fresh",
10760            "--continue",
10761            "--prompt",
10762        ] {
10763            assert!(
10764                rendered.contains(token),
10765                "expected help to contain token: {token}"
10766            );
10767        }
10768    }
10769
10770    #[test]
10771    fn subcommand_help_surfaces_are_stable() {
10772        let cases = [
10773            ("config", vec!["get", "set", "unset", "list", "path"]),
10774            ("model", vec!["list", "resolve"]),
10775            (
10776                "thread",
10777                vec![
10778                    "list",
10779                    "read",
10780                    "resume",
10781                    "fork",
10782                    "archive",
10783                    "unarchive",
10784                    "set-name",
10785                    "clear-name",
10786                ],
10787            ),
10788            ("sandbox", vec!["check"]),
10789            (
10790                "exec",
10791                vec![
10792                    "--auto",
10793                    "--json",
10794                    "--resume",
10795                    "--session-id",
10796                    "--continue",
10797                    "--output-format",
10798                    "stream-json",
10799                ],
10800            ),
10801            (
10802                "app-server",
10803                vec!["--host", "--port", "--config", "--stdio"],
10804            ),
10805            (
10806                "completion",
10807                vec![
10808                    "<SHELL>",
10809                    "bash",
10810                    "Every script completes both `codewhale` and the `codew` shorthand.",
10811                    "source <(codewhale completion bash)",
10812                    "~/.local/share/bash-completion/completions/codewhale",
10813                    "fpath=(~/.zfunc $fpath)",
10814                    "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
10815                    "codewhale completion powershell | Out-String | Invoke-Expression",
10816                    "codewhale completion elvish >> ~/.config/elvish/rc.elv",
10817                ],
10818            ),
10819            ("metrics", vec!["--json", "--since"]),
10820        ];
10821
10822        for (subcommand, expected_tokens) in cases {
10823            let argv = ["deepseek", subcommand, "--help"];
10824            let rendered = help_for(&argv);
10825            for token in expected_tokens {
10826                assert!(
10827                    rendered.contains(token),
10828                    "expected help for `{subcommand}` to include `{token}`"
10829                );
10830            }
10831        }
10832    }
10833
10834    #[test]
10835    fn cli_telemetry_start_fails_closed_on_a_corrupt_setup_state() {
10836        let dir = tempfile::TempDir::new().expect("tempdir");
10837        let setup_path = dir.path().join("setup_state.json");
10838        std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
10839        let setup = telemetry::load_setup_state_for_decision_at(&setup_path);
10840        assert!(setup.is_none(), "corrupt privacy state must not default on");
10841
10842        let resolved =
10843            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
10844        assert!(
10845            resolve_cli_telemetry_consent(&resolved, None, Surface::Cli, setup).is_none(),
10846            "CLI startup must not obtain permission from an unreadable privacy record"
10847        );
10848    }
10849}