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