Skip to main content

codewhale_cli/
lib.rs

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