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