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