Skip to main content

codewhale_cli/
lib.rs

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