Skip to main content

codewhale_cli/
lib.rs

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