Skip to main content

codewhale_cli/
lib.rs

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