Skip to main content

codewhale_cli/
lib.rs

1#![allow(clippy::uninlined_format_args)]
2
3mod metrics;
4#[cfg(not(target_env = "ohos"))]
5mod update;
6
7use std::io::{self, Read, Write};
8use std::net::SocketAddr;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use anyhow::{Context, Result, anyhow, bail};
13use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
14use clap_complete::{Shell, generate};
15use codewhale_agent::ModelRegistry;
16use codewhale_app_server::{
17    AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
18};
19use codewhale_config::{
20    CliRuntimeOverrides, ConfigStore, ConfigToml, ProviderKind, ProviderSource,
21    ResolvedRuntimeOptions, RuntimeApiKeySource,
22};
23use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
24use codewhale_mcp::{McpServerDefinition, run_stdio_server};
25use codewhale_secrets::Secrets;
26use codewhale_state::{StateStore, ThreadListFilters};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
29enum ProviderArg {
30    Deepseek,
31    NvidiaNim,
32    Openai,
33    Atlascloud,
34    WanjieArk,
35    Volcengine,
36    Openrouter,
37    XiaomiMimo,
38    Novita,
39    Fireworks,
40    Siliconflow,
41    #[value(
42        alias = "silicon-flow-cn",
43        alias = "siliconflow-CN",
44        alias = "silicon_flow_cn",
45        alias = "siliconflow_cn",
46        alias = "siliconflow-china",
47        alias = "siliconflow_china"
48    )]
49    SiliconflowCn,
50    Arcee,
51    Moonshot,
52    Sglang,
53    Vllm,
54    Ollama,
55    Huggingface,
56    Together,
57    OpenaiCodex,
58    Anthropic,
59    #[value(alias = "open-model", alias = "open_model")]
60    Openmodel,
61    Zai,
62    Stepfun,
63    Minimax,
64    #[value(
65        alias = "minimax_anthropic",
66        alias = "mini-max-anthropic",
67        alias = "mini_max_anthropic"
68    )]
69    MinimaxAnthropic,
70    #[value(alias = "deep-infra", alias = "deep_infra")]
71    Deepinfra,
72    #[value(alias = "fugu", alias = "sakana-ai", alias = "sakana_ai")]
73    Sakana,
74    #[value(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")]
75    LongCat,
76    #[value(
77        alias = "meta-ai",
78        alias = "meta_ai",
79        alias = "meta-model-api",
80        alias = "muse",
81        alias = "muse-spark"
82    )]
83    Meta,
84    #[value(alias = "x-ai", alias = "x_ai", alias = "grok")]
85    Xai,
86}
87
88impl From<ProviderArg> for ProviderKind {
89    fn from(value: ProviderArg) -> Self {
90        match value {
91            ProviderArg::Deepseek => ProviderKind::Deepseek,
92            ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
93            ProviderArg::Openai => ProviderKind::Openai,
94            ProviderArg::Atlascloud => ProviderKind::Atlascloud,
95            ProviderArg::WanjieArk => ProviderKind::WanjieArk,
96            ProviderArg::Volcengine => ProviderKind::Volcengine,
97            ProviderArg::Openrouter => ProviderKind::Openrouter,
98            ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
99            ProviderArg::Novita => ProviderKind::Novita,
100            ProviderArg::Fireworks => ProviderKind::Fireworks,
101            ProviderArg::Siliconflow => ProviderKind::Siliconflow,
102            ProviderArg::SiliconflowCn => ProviderKind::SiliconflowCN,
103            ProviderArg::Arcee => ProviderKind::Arcee,
104            ProviderArg::Moonshot => ProviderKind::Moonshot,
105            ProviderArg::Sglang => ProviderKind::Sglang,
106            ProviderArg::Vllm => ProviderKind::Vllm,
107            ProviderArg::Ollama => ProviderKind::Ollama,
108            ProviderArg::Huggingface => ProviderKind::Huggingface,
109            ProviderArg::Together => ProviderKind::Together,
110            ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
111            ProviderArg::Anthropic => ProviderKind::Anthropic,
112            ProviderArg::Openmodel => ProviderKind::Openmodel,
113            ProviderArg::Zai => ProviderKind::Zai,
114            ProviderArg::Stepfun => ProviderKind::Stepfun,
115            ProviderArg::Minimax => ProviderKind::Minimax,
116            ProviderArg::MinimaxAnthropic => ProviderKind::MinimaxAnthropic,
117            ProviderArg::Deepinfra => ProviderKind::Deepinfra,
118            ProviderArg::Sakana => ProviderKind::Sakana,
119            ProviderArg::LongCat => ProviderKind::LongCat,
120            ProviderArg::Meta => ProviderKind::Meta,
121            ProviderArg::Xai => ProviderKind::Xai,
122        }
123    }
124}
125
126fn builtin_provider_arg(value: &str) -> Option<ProviderArg> {
127    ProviderArg::from_str(value, false).ok()
128}
129
130fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
131    if value.is_empty()
132        || value == "__custom__"
133        || !value
134            .chars()
135            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
136    {
137        return Err(
138            "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
139                .to_string(),
140        );
141    }
142    Ok(value.to_string())
143}
144
145#[derive(Debug, Parser)]
146#[command(
147    name = "codewhale",
148    version = env!("DEEPSEEK_BUILD_VERSION"),
149    bin_name = "codewhale",
150    override_usage = "codewhale [OPTIONS] [PROMPT]\n       codewhale [OPTIONS] <COMMAND> [ARGS]"
151)]
152struct Cli {
153    #[arg(long)]
154    config: Option<PathBuf>,
155    #[arg(long)]
156    profile: Option<String>,
157    #[arg(
158        long,
159        value_name = "PROVIDER",
160        value_parser = parse_provider_identifier,
161        help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
162    )]
163    provider: Option<String>,
164    #[arg(long)]
165    model: Option<String>,
166    #[arg(long = "output-mode")]
167    output_mode: Option<String>,
168    #[arg(
169        long = "verbosity",
170        value_name = "LEVEL",
171        help = "Controls transcript and output verbosity (normal, concise)"
172    )]
173    verbosity: Option<String>,
174    #[arg(long = "log-level")]
175    log_level: Option<String>,
176    #[arg(long)]
177    telemetry: Option<bool>,
178    #[arg(long)]
179    approval_policy: Option<String>,
180    #[arg(long)]
181    sandbox_mode: Option<String>,
182    #[arg(long)]
183    api_key: Option<String>,
184    #[arg(long)]
185    base_url: Option<String>,
186    /// Workspace directory for TUI file tools
187    #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
188    workspace: Option<PathBuf>,
189    #[arg(long = "no-alt-screen", hide = true)]
190    no_alt_screen: bool,
191    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
192    mouse_capture: bool,
193    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
194    no_mouse_capture: bool,
195    #[arg(long = "skip-onboarding")]
196    skip_onboarding: bool,
197    /// Legacy compatibility alias for Act + Full Access.
198    #[arg(long, hide = true)]
199    yolo: bool,
200    /// Continue the most recent interactive session for this workspace.
201    #[arg(short = 'c', long = "continue")]
202    continue_session: bool,
203    #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
204    prompt_flag: Option<String>,
205    #[arg(
206        value_name = "PROMPT",
207        trailing_var_arg = true,
208        allow_hyphen_values = true
209    )]
210    prompt: Vec<String>,
211    #[command(subcommand)]
212    command: Option<Commands>,
213}
214
215#[derive(Debug, Subcommand)]
216enum Commands {
217    /// Run interactive/non-interactive flows via the TUI binary.
218    Run(RunArgs),
219    /// Run Codewhale diagnostics.
220    Doctor(TuiPassthroughArgs),
221    /// List live provider API models via the TUI binary.
222    Models(TuiPassthroughArgs),
223    /// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary.
224    #[command(visible_alias = "tts")]
225    Speech(TuiPassthroughArgs),
226    /// List saved TUI sessions.
227    Sessions(TuiPassthroughArgs),
228    /// Resume a saved TUI session.
229    Resume(TuiPassthroughArgs),
230    /// Fork a saved TUI session.
231    Fork(TuiPassthroughArgs),
232    /// Create a default AGENTS.md in the current directory.
233    Init(TuiPassthroughArgs),
234    /// Bootstrap MCP config and/or skills directories.
235    Setup(TuiPassthroughArgs),
236    /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge).
237    RemoteSetup(RemoteSetupArgs),
238    /// Run a non-interactive prompt through the TUI runtime.
239    #[command(after_help = "\
240Examples:
241  codewhale exec \"explain this function\"
242  codewhale exec --auto \"list crates/ with ls\"
243  codewhale exec --auto --output-format stream-json \"fix the failing test\"
244
245Common forwarded flags:
246  --auto                           Enable tool-backed agent mode with auto-approvals
247  --json                           Emit summary JSON
248  --resume <SESSION_ID>            Resume a previous session by ID or prefix
249  --session-id <SESSION_ID>        Resume a previous session by ID or prefix
250  --continue                       Continue the most recent session for this workspace
251  --output-format <FORMAT>         Output format: text or stream-json
252
253Plain `codewhale exec` is a one-shot model response. Use `--auto` for
254non-interactive filesystem/shell tool use, matching the supported automation
255path used by stream-json wrappers.
256")]
257    Exec(TuiPassthroughArgs),
258    /// Manage durable Agent Fleet runs via the TUI runtime.
259    Fleet(TuiPassthroughArgs),
260    /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
261    #[command(name = "workflow-tool", hide = true)]
262    WorkflowTool(TuiPassthroughArgs),
263    /// Internal detached-runtime output/receipt supervisor.
264    #[command(name = "lane-log-proxy", hide = true)]
265    LaneLogProxy(LaneLogProxyArgs),
266    /// Run checked-in Workflows through a Lane Runtime backend.
267    #[command(after_help = "\
268Examples:
269  codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
270  codewhale workflow run stopship --fleet stopship --runtime inline --verify
271
272`workflow run` validates the checked-in Workflow source and named Fleet roster,
273creates a Lane record, then dispatches the Workflow tool directly through the
274selected Runtime backend without an operator model turn.
275")]
276    Workflow(WorkflowArgs),
277    /// Manage running workflow instances (Lanes) and Runtime backends (#4176).
278    #[command(after_help = "\
279Examples:
280  codewhale lane list
281  codewhale lane status <lane-id>
282  codewhale lane attach <lane-id>
283  codewhale lane logs <lane-id>
284  codewhale lane stop <lane-id>
285  codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
286
287Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
288Runtime, not Fleet.
289")]
290    Lane(LaneArgs),
291    /// Run a Codewhale-powered code review over a git diff.
292    Review(TuiPassthroughArgs),
293    /// Apply a patch file or stdin to the working tree.
294    Apply(TuiPassthroughArgs),
295    /// Run the offline TUI evaluation harness.
296    Eval(TuiPassthroughArgs),
297    /// Manage TUI MCP servers.
298    Mcp(TuiPassthroughArgs),
299    /// Inspect TUI feature flags.
300    Features(TuiPassthroughArgs),
301    /// Run a local TUI server.
302    #[command(after_help = "\
303Forwarded serve options:
304      --mcp                 Start MCP server over stdio
305      --http                Start runtime HTTP/SSE API server
306      --mobile              Start runtime HTTP/SSE API server with the mobile control page
307      --qr                  Show a QR code for the mobile URL (requires --mobile)
308      --acp                 Start ACP server over stdio for editor clients
309      --host <HOST>         Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
310      --port <PORT>         Bind port [default: 7878]
311      --workers <WORKERS>   Background task worker count (1-8)
312      --cors-origin <URL>   Additional CORS origin to allow (repeatable)
313      --auth-token <TOKEN>  Require this bearer token for /v1/* runtime API routes
314      --insecure            Disable runtime API auth when no token is configured
315
316`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
317aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
318New integrations should prefer `codewhale app-server`.")]
319    Serve(TuiPassthroughArgs),
320    /// Generate shell completions for the TUI binary.
321    Completions(TuiPassthroughArgs),
322    /// Configure provider credentials.
323    Login(LoginArgs),
324    /// Remove saved authentication state.
325    Logout,
326    /// Manage authentication credentials and provider mode.
327    Auth(AuthArgs),
328    /// Run MCP server mode over stdio.
329    McpServer,
330    /// Read/write/list config values.
331    Config(ConfigArgs),
332    /// Resolve or list available models across providers.
333    Model(ModelArgs),
334    /// Manage thread/session metadata and resume/fork flows.
335    Thread(ThreadArgs),
336    /// Evaluate sandbox/approval policy decisions.
337    Sandbox(SandboxArgs),
338    /// Run the canonical runtime API / control plane (HTTP/SSE, mobile, stdio).
339    #[command(after_help = "\
340Transports:
341  codewhale app-server --http              Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
342  codewhale app-server --mobile            Runtime API + phone control page (binds 0.0.0.0)
343  codewhale app-server --stdio             JSON-RPC control transport over stdio (no listener)
344  codewhale app-server                     Legacy in-process app-server HTTP on 127.0.0.1:8787
345
346`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
347--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
348is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
349
350See docs/RUNTIME_API.md.")]
351    AppServer(AppServerArgs),
352    /// Generate shell completions.
353    #[command(after_help = r#"Examples:
354  Bash (current shell only):
355    source <(codewhale completion bash)
356
357  Bash (persistent, Linux/bash-completion):
358    mkdir -p ~/.local/share/bash-completion/completions
359    codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
360    # Requires bash-completion to be installed and loaded by your shell.
361
362  Zsh:
363    mkdir -p ~/.zfunc
364    codewhale completion zsh > ~/.zfunc/_codewhale
365    # Add to ~/.zshrc if needed:
366    #   fpath=(~/.zfunc $fpath)
367    #   autoload -Uz compinit && compinit
368
369  Fish:
370    mkdir -p ~/.config/fish/completions
371    codewhale completion fish > ~/.config/fish/completions/codewhale.fish
372
373  PowerShell (current shell only):
374    codewhale completion powershell | Out-String | Invoke-Expression
375
376The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
377    Completion {
378        #[arg(value_enum)]
379        shell: Shell,
380    },
381    /// Print a usage rollup from the audit log and session store.
382    Metrics(MetricsArgs),
383    /// Check for and apply updates to the `codewhale` binary.
384    Update(UpdateArgs),
385}
386
387fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
388    matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
389}
390
391fn top_level_provider_override(
392    provider: Option<&str>,
393    command: Option<&Commands>,
394) -> Result<Option<ProviderKind>> {
395    let Some(provider) = provider else {
396        return Ok(None);
397    };
398    if let Some(provider) = builtin_provider_arg(provider) {
399        return Ok(Some(provider.into()));
400    }
401    if command_accepts_raw_provider(command) {
402        return Ok(None);
403    }
404
405    let expected = ProviderArg::value_variants()
406        .iter()
407        .filter_map(ValueEnum::to_possible_value)
408        .map(|value| value.get_name().to_string())
409        .collect::<Vec<_>>()
410        .join(", ");
411    bail!(
412        "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
413    )
414}
415
416fn prepare_raw_provider_tui_dispatch(
417    cli: &Cli,
418    command: Option<&Commands>,
419    runtime_overrides: &CliRuntimeOverrides,
420) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
421    let Some(provider) = cli.provider.as_deref() else {
422        return Ok(None);
423    };
424    if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
425        return Ok(None);
426    }
427
428    let passthrough = match command {
429        Some(Commands::Exec(args)) => {
430            reject_exec_global_flags(&args.args)?;
431            tui_args("exec", args.clone())
432        }
433        Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
434        _ => unreachable!("raw provider validation only permits Exec and Fleet"),
435    };
436
437    // Dynamic provider config belongs to the TUI schema. Do not parse it
438    // through the dispatcher's enum-backed ConfigStore or recover credentials
439    // for an unrelated fallback provider before the TUI sees the raw id.
440    let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
441    Ok(Some((resolved_runtime, passthrough)))
442}
443
444#[derive(Debug, Args)]
445struct UpdateArgs {
446    /// Update to the latest beta release instead of the latest stable release.
447    #[arg(long)]
448    beta: bool,
449    /// Only check the latest release; do not download or replace binaries.
450    #[arg(long)]
451    check: bool,
452    /// Proxy URL to use for update HTTP requests.
453    #[arg(long, value_name = "URL")]
454    proxy: Option<String>,
455}
456
457#[derive(Debug, Args)]
458struct MetricsArgs {
459    /// Emit machine-readable JSON.
460    #[arg(long)]
461    json: bool,
462    /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
463    #[arg(long, value_name = "DURATION")]
464    since: Option<String>,
465}
466
467#[derive(Debug, Args)]
468struct RunArgs {
469    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
470    args: Vec<String>,
471}
472
473#[derive(Debug, Args, Clone)]
474struct TuiPassthroughArgs {
475    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
476    args: Vec<String>,
477}
478
479#[derive(Debug, Args)]
480struct LaneLogProxyArgs {
481    #[arg(long, value_name = "PATH")]
482    log_path: PathBuf,
483    #[arg(long, value_name = "PATH")]
484    receipt_path: PathBuf,
485    #[arg(long, value_name = "PATH")]
486    receipt_tmp_path: PathBuf,
487    #[arg(long, value_name = "PATH")]
488    environment_path: Option<PathBuf>,
489    #[arg(long)]
490    lane_id: String,
491    #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
492    command: Vec<String>,
493}
494
495/// `codewhale lane …` — running workflow instances (#4176).
496#[derive(Debug, Args)]
497struct LaneArgs {
498    #[command(subcommand)]
499    command: LaneCommand,
500}
501
502#[derive(Debug, Subcommand)]
503// Clap constructs this command enum once at process startup. Keeping the
504// fields inline makes the generated CLI shape explicit; boxing them only to
505// reduce this transient value would add indirection without runtime benefit.
506#[allow(clippy::large_enum_variant)]
507enum LaneCommand {
508    /// List known lanes (newest first).
509    List {
510        /// Emit JSON.
511        #[arg(long, default_value_t = false)]
512        json: bool,
513    },
514    /// Show one lane's status and attach metadata.
515    Status {
516        /// Lane id (e.g. `lane-a1b2c3d4`).
517        lane_id: String,
518        #[arg(long, default_value_t = false)]
519        json: bool,
520    },
521    /// Attach to a tmux-backed lane (prints attach command; execs when possible).
522    Attach {
523        lane_id: String,
524        /// Only print the attach command; do not exec.
525        #[arg(long, default_value_t = false)]
526        print: bool,
527    },
528    /// Tail the lane stream-json / NDJSON journal.
529    Logs {
530        lane_id: String,
531        /// Follow the log file (like `tail -f`).
532        #[arg(long, short = 'f', default_value_t = false)]
533        follow: bool,
534        /// Number of trailing lines when not following (default 50).
535        #[arg(long, default_value_t = 50)]
536        tail: usize,
537    },
538    /// Stop a running lane and run worktree TTL cleanup.
539    Stop { lane_id: String },
540    /// Start a lane under a Runtime backend (tmux|inline|vm|ci).
541    Start {
542        /// Workflow name (e.g. `stopship`).
543        #[arg(long)]
544        workflow: Option<String>,
545        /// Fleet roster name (e.g. `stopship`).
546        #[arg(long)]
547        fleet: Option<String>,
548        /// Issue id binding.
549        #[arg(long)]
550        issue: Option<String>,
551        /// Free-form goal text.
552        #[arg(long)]
553        goal: Option<String>,
554        /// Runtime backend: tmux, inline, vm, or ci.
555        #[arg(long, default_value = "tmux")]
556        runtime: String,
557        /// Create an isolated worktree under this repo root.
558        #[arg(long, value_name = "DIR")]
559        worktree_repo: Option<PathBuf>,
560        /// Branch name for the worktree (requires `--worktree-repo`).
561        #[arg(long)]
562        branch: Option<String>,
563        /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
564        #[arg(long, value_name = "DIR")]
565        worktree_path: Option<PathBuf>,
566        /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
567        #[arg(long)]
568        worktree_ttl_secs: Option<u64>,
569        /// Command to run in the runtime (after `--`).
570        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
571        command: Vec<String>,
572    },
573}
574
575/// `codewhale workflow …` — Workflow entrypoints backed by Lanes (#4177/#4178).
576#[derive(Debug, Args)]
577struct WorkflowArgs {
578    #[command(subcommand)]
579    command: WorkflowCommand,
580}
581
582#[derive(Debug, Subcommand)]
583enum WorkflowCommand {
584    /// Run a checked-in Workflow through a Runtime-backed Lane.
585    Run {
586        /// Workflow name or path. `stopship` maps to workflows/stopship.workflow.js.
587        workflow: String,
588        /// Named Fleet roster (e.g. stopship). Required for role-resolved Workflow runs.
589        #[arg(long)]
590        fleet: String,
591        /// Issue id binding recorded on the Lane and passed into workflow args.
592        #[arg(long)]
593        issue: Option<String>,
594        /// Free-form goal text recorded on the Lane and passed into workflow args.
595        #[arg(long)]
596        goal: Option<String>,
597        /// Runtime backend: tmux, inline, vm, or ci.
598        #[arg(long, default_value = "tmux")]
599        runtime: String,
600        /// Explicit Workflow source path, overriding name-based resolution.
601        #[arg(long, value_name = "PATH")]
602        source_path: Option<PathBuf>,
603        /// Optional shared Workflow token budget.
604        #[arg(long)]
605        token_budget: Option<u64>,
606        /// Run verifier gates after a successful Workflow completion.
607        #[arg(long, default_value_t = false)]
608        verify: bool,
609        /// Create an isolated worktree under this repo root.
610        #[arg(long, value_name = "DIR")]
611        worktree_repo: Option<PathBuf>,
612        /// Branch name for the worktree (requires `--worktree-repo`).
613        #[arg(long)]
614        branch: Option<String>,
615        /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
616        #[arg(long, value_name = "DIR")]
617        worktree_path: Option<PathBuf>,
618        /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
619        #[arg(long)]
620        worktree_ttl_secs: Option<u64>,
621    },
622}
623
624struct LaneStartRequest {
625    workflow: Option<String>,
626    fleet: Option<String>,
627    issue: Option<String>,
628    goal: Option<String>,
629    runtime: String,
630    worktree_repo: Option<PathBuf>,
631    branch: Option<String>,
632    worktree_path: Option<PathBuf>,
633    worktree_ttl_secs: Option<u64>,
634    command: Vec<String>,
635    environment: Vec<(String, String)>,
636    cwd: Option<PathBuf>,
637}
638
639fn start_lane(request: LaneStartRequest) -> Result<()> {
640    use codewhale_lane::{
641        LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
642    };
643
644    let LaneStartRequest {
645        workflow,
646        fleet,
647        issue,
648        goal,
649        runtime,
650        worktree_repo,
651        branch,
652        worktree_path,
653        worktree_ttl_secs,
654        command,
655        environment,
656        cwd,
657    } = request;
658    let kind = RuntimeBackendKind::parse(&runtime)?;
659    let reg = LaneRegistry::open_default()?;
660    let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
661    let worktree = match (worktree_repo, branch) {
662        (Some(repo_root), Some(branch_name)) => {
663            let path = worktree_path
664                .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
665            Some(WorktreeProvision {
666                repo_root,
667                branch: branch_name,
668                path,
669                base_ref: None,
670            })
671        }
672        (None, None) => None,
673        _ => bail!("--worktree-repo and --branch must be provided together"),
674    };
675    let cmd = if command.is_empty() {
676        vec![
677            "sh".into(),
678            "-c".into(),
679            format!("echo lane {} started", record.id),
680        ]
681    } else {
682        command
683    };
684    let spec = LaneStartSpec {
685        command: cmd,
686        cwd,
687        environment,
688        log_proxy: (kind == RuntimeBackendKind::Tmux)
689            .then(std::env::current_exe)
690            .transpose()
691            .context("resolve current Codewhale executable for tmux log proxy")?,
692        worktree,
693    };
694    let backend = resolve_backend(kind);
695    backend.start(&reg, &mut record, &spec)?;
696    println!("started {}", record.id);
697    println!("status:  {}", record.status.as_str());
698    println!("runtime: {}", record.runtime.as_str());
699    println!("log:     {}", record.log_path.display());
700    if let Some(attach) = backend.attach_command(&record) {
701        println!("attach:  {attach}");
702    }
703    Ok(())
704}
705
706fn run_lane_command(args: LaneArgs) -> Result<()> {
707    use codewhale_lane::{LaneRegistry, backend_for};
708    use std::io::{BufRead, Seek, Write};
709    use std::process::Command;
710    use std::thread;
711    use std::time::Duration;
712
713    match args.command {
714        LaneCommand::List { json } => {
715            let reg = LaneRegistry::open_default()?;
716            let mut lanes = reg.list()?;
717            for lane in &mut lanes {
718                if let Err(err) = backend_for(lane).reconcile(&reg, lane) {
719                    eprintln!("warning: could not reconcile lane `{}`: {err:#}", lane.id);
720                }
721            }
722            if json {
723                println!("{}", serde_json::to_string_pretty(&lanes)?);
724            } else if lanes.is_empty() {
725                println!("No lanes under {}", reg.root().display());
726            } else {
727                println!(
728                    "{:<16} {:<10} {:<12} {:<16} {:<10} STARTED",
729                    "ID", "STATUS", "RUNTIME", "WORKFLOW", "ISSUE"
730                );
731                for lane in lanes {
732                    println!(
733                        "{:<16} {:<10} {:<12} {:<16} {:<10} {}",
734                        lane.id,
735                        lane.status.as_str(),
736                        lane.runtime.as_str(),
737                        lane.workflow.as_deref().unwrap_or("-"),
738                        lane.issue.as_deref().unwrap_or("-"),
739                        lane.started_at,
740                    );
741                }
742            }
743            Ok(())
744        }
745        LaneCommand::Status { lane_id, json } => {
746            let reg = LaneRegistry::open_default()?;
747            let mut lane = reg.load(&lane_id)?;
748            backend_for(&lane).reconcile(&reg, &mut lane)?;
749            if json {
750                println!("{}", serde_json::to_string_pretty(&lane)?);
751            } else {
752                println!("lane:     {}", lane.id);
753                println!("status:   {}", lane.status.as_str());
754                println!("runtime:  {}", lane.runtime.as_str());
755                println!("workflow: {}", lane.workflow.as_deref().unwrap_or("-"));
756                println!("fleet:    {}", lane.fleet.as_deref().unwrap_or("-"));
757                println!("issue:    {}", lane.issue.as_deref().unwrap_or("-"));
758                println!("goal:     {}", lane.goal.as_deref().unwrap_or("-"));
759                println!("started:  {}", lane.started_at);
760                println!("stopped:  {}", lane.stopped_at.as_deref().unwrap_or("-"));
761                println!(
762                    "worktree: {}",
763                    lane.worktree_path
764                        .as_ref()
765                        .map(|p| p.display().to_string())
766                        .unwrap_or_else(|| "-".into())
767                );
768                println!("branch:   {}", lane.branch.as_deref().unwrap_or("-"));
769                println!("tmux:     {}", lane.tmux_session.as_deref().unwrap_or("-"));
770                println!(
771                    "socket:   {}",
772                    lane.tmux_socket
773                        .as_ref()
774                        .map(|path| path.display().to_string())
775                        .unwrap_or_else(|| "-".to_string())
776                );
777                println!("attach:   {}", lane.attach_target.as_deref().unwrap_or("-"));
778                println!("log:      {}", lane.log_path.display());
779            }
780            Ok(())
781        }
782        LaneCommand::Attach { lane_id, print } => {
783            let reg = LaneRegistry::open_default()?;
784            let mut lane = reg.load(&lane_id)?;
785            let backend = backend_for(&lane);
786            backend.reconcile(&reg, &mut lane)?;
787            let Some(attach) = backend.attach_command(&lane) else {
788                if !lane.status.is_active() {
789                    bail!(
790                        "lane `{lane_id}` is {} and has no active attach target",
791                        lane.status.as_str()
792                    );
793                }
794                bail!(
795                    "lane `{lane_id}` runtime `{}` has no attach target",
796                    lane.runtime.as_str()
797                );
798            };
799            if print {
800                println!("{attach}");
801                return Ok(());
802            }
803            if let Some(session) = lane.tmux_session.as_deref() {
804                let socket = lane
805                    .tmux_socket
806                    .as_deref()
807                    .context("tmux lane is missing its pinned server socket")?;
808                let status = Command::new("tmux")
809                    .arg("-S")
810                    .arg(socket)
811                    .args(["attach", "-t", session])
812                    .status();
813                match status {
814                    Ok(s) if s.success() => Ok(()),
815                    Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
816                    Err(err) => {
817                        eprintln!("could not exec tmux: {err}");
818                        println!("{attach}");
819                        bail!("tmux attach unavailable");
820                    }
821                }
822            } else {
823                println!("{attach}");
824                Ok(())
825            }
826        }
827        LaneCommand::Logs {
828            lane_id,
829            follow,
830            tail,
831        } => {
832            let reg = LaneRegistry::open_default()?;
833            let lane = reg.load(&lane_id)?;
834            let path = lane.log_path;
835            if !path.exists() {
836                bail!("log file missing: {}", path.display());
837            }
838            let content = std::fs::read(&path)?;
839            let lines: Vec<&[u8]> = content
840                .split(|byte| *byte == b'\n')
841                .filter(|line| !line.is_empty())
842                .collect();
843            let start = lines.len().saturating_sub(tail);
844            let mut stdout = std::io::stdout().lock();
845            for line in &lines[start..] {
846                stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
847                stdout.write_all(b"\n")?;
848            }
849            stdout.flush()?;
850            if !follow {
851                return Ok(());
852            }
853            let mut file = std::fs::File::open(&path)?;
854            file.seek(std::io::SeekFrom::End(0))?;
855            let mut reader = std::io::BufReader::new(file);
856            loop {
857                let mut line = Vec::new();
858                match reader.read_until(b'\n', &mut line) {
859                    Ok(0) => {
860                        thread::sleep(Duration::from_millis(200));
861                        continue;
862                    }
863                    Ok(_) => {
864                        let mut stdout = std::io::stdout().lock();
865                        stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
866                        stdout.flush()?;
867                    }
868                    Err(err) => return Err(err.into()),
869                }
870            }
871        }
872        LaneCommand::Stop { lane_id } => {
873            let reg = LaneRegistry::open_default()?;
874            let mut lane = reg.load(&lane_id)?;
875            let backend = backend_for(&lane);
876            backend.stop(&reg, &mut lane)?;
877            println!("stopped {}", lane.id);
878            Ok(())
879        }
880        LaneCommand::Start {
881            workflow,
882            fleet,
883            issue,
884            goal,
885            runtime,
886            worktree_repo,
887            branch,
888            worktree_path,
889            worktree_ttl_secs,
890            command,
891        } => start_lane(LaneStartRequest {
892            workflow,
893            fleet,
894            issue,
895            goal,
896            runtime,
897            worktree_repo,
898            branch,
899            worktree_path,
900            worktree_ttl_secs,
901            command,
902            environment: Vec::new(),
903            cwd: None,
904        }),
905    }
906}
907
908fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
909    let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
910        command: args.command,
911        log_path: args.log_path,
912        receipt_path: args.receipt_path,
913        receipt_tmp_path: args.receipt_tmp_path,
914        environment_path: args.environment_path,
915        lane_id: args.lane_id,
916    })?;
917    std::process::exit(exit_code);
918}
919
920fn run_workflow_command(
921    cli: &Cli,
922    resolved_runtime: &ResolvedRuntimeOptions,
923    config_path: &Path,
924    args: WorkflowArgs,
925) -> Result<()> {
926    match args.command {
927        WorkflowCommand::Run {
928            workflow,
929            fleet,
930            issue,
931            goal,
932            runtime,
933            source_path,
934            token_budget,
935            verify,
936            worktree_repo,
937            branch,
938            worktree_path,
939            worktree_ttl_secs,
940        } => {
941            let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
942            let source_path =
943                resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
944            validate_workflow_source_file(&source_path)?;
945
946            let source_root = if let Some(repo) = worktree_repo.as_deref() {
947                repo.canonicalize()
948                    .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
949            } else {
950                workspace.clone()
951            };
952
953            let roots = named_fleet_search_roots(&workspace);
954            let named_fleet = codewhale_workflow::load_named_fleet(&fleet, &roots)
955                .with_context(|| format!("load fleet `{fleet}` from {}", display_roots(&roots)))?;
956            if workflow == "stopship" || fleet == "stopship" || fleet == "v0868-stopship" {
957                named_fleet
958                    .validate_stopship_roles()
959                    .with_context(|| format!("validate stopship roles in fleet `{fleet}`"))?;
960            }
961
962            let process = workflow_exec_command(WorkflowExecSpec {
963                cli,
964                resolved_runtime,
965                config_path,
966                source_root: &source_root,
967                source_path: &source_path,
968                workflow: &workflow,
969                fleet: &fleet,
970                issue: issue.as_deref(),
971                goal: goal.as_deref(),
972                token_budget,
973                verify,
974            })?;
975            start_lane(LaneStartRequest {
976                workflow: Some(workflow),
977                fleet: Some(fleet),
978                issue,
979                goal,
980                runtime,
981                worktree_repo,
982                branch,
983                worktree_path,
984                worktree_ttl_secs,
985                command: process.command,
986                environment: process.environment,
987                cwd: Some(workspace),
988            })
989        }
990    }
991}
992
993fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
994    if let Some(path) = explicit {
995        return path
996            .canonicalize()
997            .with_context(|| format!("resolve workflow workspace {}", path.display()));
998    }
999    let cwd = std::env::current_dir().context("resolve current directory")?;
1000    let output = Command::new("git")
1001        .args(["rev-parse", "--show-toplevel"])
1002        .current_dir(&cwd)
1003        .output();
1004    if let Ok(output) = output
1005        && output.status.success()
1006    {
1007        let text = String::from_utf8_lossy(&output.stdout);
1008        let root = text.trim();
1009        if !root.is_empty() {
1010            let root = PathBuf::from(root);
1011            return Ok(root.canonicalize().unwrap_or(root));
1012        }
1013    }
1014    Ok(cwd)
1015}
1016
1017fn resolve_workflow_source_path(
1018    workflow: &str,
1019    source_path: Option<&PathBuf>,
1020    workspace: &Path,
1021) -> Result<PathBuf> {
1022    let candidates = workflow_source_candidates(workflow, source_path, workspace);
1023    for candidate in &candidates {
1024        if candidate.is_file() {
1025            return Ok(candidate.clone());
1026        }
1027    }
1028    bail!(
1029        "workflow source for `{workflow}` not found; tried {}",
1030        candidates
1031            .iter()
1032            .map(|p| p.display().to_string())
1033            .collect::<Vec<_>>()
1034            .join(", ")
1035    )
1036}
1037
1038fn workflow_source_candidates(
1039    workflow: &str,
1040    source_path: Option<&PathBuf>,
1041    workspace: &Path,
1042) -> Vec<PathBuf> {
1043    let mut candidates = Vec::new();
1044    if let Some(path) = source_path {
1045        candidates.push(resolve_against_workspace(path, workspace));
1046        return candidates;
1047    }
1048
1049    let raw = workflow.trim();
1050    let workflow_path = PathBuf::from(raw);
1051    if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1052        candidates.push(resolve_against_workspace(&workflow_path, workspace));
1053        return candidates;
1054    }
1055
1056    let normalized = raw.replace('-', "_");
1057    for rel in [
1058        format!("workflows/{raw}.workflow.js"),
1059        format!("workflows/{normalized}.workflow.js"),
1060    ] {
1061        let path = workspace.join(rel);
1062        if !candidates.iter().any(|existing| existing == &path) {
1063            candidates.push(path);
1064        }
1065    }
1066    candidates
1067}
1068
1069fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1070    if path.is_absolute() {
1071        path.to_path_buf()
1072    } else {
1073        workspace.join(path)
1074    }
1075}
1076
1077fn validate_workflow_source_file(path: &Path) -> Result<()> {
1078    let source =
1079        std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1080    if source.trim_start().starts_with("export default workflow(")
1081        || source.trim_start().starts_with("workflow(")
1082        || source.contains("\nworkflow(")
1083    {
1084        let identifier = path.display().to_string();
1085        if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1086            codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1087                .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1088        } else {
1089            codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1090                .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1091        }
1092    }
1093    Ok(())
1094}
1095
1096fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1097    let mut roots = Vec::new();
1098    if let Ok(home) = codewhale_config::codewhale_home() {
1099        roots.push(home);
1100    }
1101    roots.push(workspace.to_path_buf());
1102    roots
1103}
1104
1105fn display_roots(roots: &[PathBuf]) -> String {
1106    roots
1107        .iter()
1108        .map(|root| root.display().to_string())
1109        .collect::<Vec<_>>()
1110        .join(", ")
1111}
1112
1113struct WorkflowExecSpec<'a> {
1114    cli: &'a Cli,
1115    resolved_runtime: &'a ResolvedRuntimeOptions,
1116    config_path: &'a Path,
1117    source_root: &'a Path,
1118    source_path: &'a Path,
1119    workflow: &'a str,
1120    fleet: &'a str,
1121    issue: Option<&'a str>,
1122    goal: Option<&'a str>,
1123    token_budget: Option<u64>,
1124    verify: bool,
1125}
1126
1127struct WorkflowProcessSpec {
1128    command: Vec<String>,
1129    environment: Vec<(String, String)>,
1130}
1131
1132fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1133    let WorkflowExecSpec {
1134        cli,
1135        resolved_runtime,
1136        config_path,
1137        source_root,
1138        source_path,
1139        workflow,
1140        fleet,
1141        issue,
1142        goal,
1143        token_budget,
1144        verify,
1145    } = spec;
1146    let source_arg = source_path
1147        .strip_prefix(source_root)
1148        .with_context(|| {
1149            format!(
1150                "workflow source {} must be inside execution root {}",
1151                source_path.display(),
1152                source_root.display()
1153            )
1154        })?
1155        .display()
1156        .to_string();
1157    let mut payload = serde_json::json!({
1158        "action": "run",
1159        "source_path": source_arg,
1160        "fleet": fleet,
1161        "args": {
1162            "workflow": workflow,
1163            "fleet": fleet,
1164            "issue": issue,
1165            "goal": goal,
1166        },
1167        "verify": verify,
1168    });
1169    if let Some(token_budget) = token_budget {
1170        payload["token_budget"] = serde_json::json!(token_budget);
1171    }
1172    let input_json = serde_json::to_string(&payload)?;
1173    let passthrough = vec![
1174        "workflow-tool".to_string(),
1175        "--approval-source".to_string(),
1176        "explicit-workflow-command".to_string(),
1177        "--input-json".to_string(),
1178        input_json,
1179    ];
1180    let command =
1181        build_tui_command_with_paths(cli, resolved_runtime, passthrough, Some(config_path), None)?;
1182    lane_process_spec_from_command(&command)
1183}
1184
1185fn valid_lane_environment_key(key: &str) -> bool {
1186    let mut chars = key.chars();
1187    chars
1188        .next()
1189        .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1190        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1191}
1192
1193fn shell_owned_lane_environment(key: &str) -> bool {
1194    matches!(
1195        key,
1196        "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1197    )
1198}
1199
1200fn lane_process_spec_from_command(command: &Command) -> Result<WorkflowProcessSpec> {
1201    let mut argv = Vec::new();
1202    argv.push(command.get_program().to_string_lossy().into_owned());
1203    argv.extend(
1204        command
1205            .get_args()
1206            .map(|arg| arg.to_string_lossy().into_owned()),
1207    );
1208    let mut environment = std::collections::BTreeMap::new();
1209    for (key, value) in std::env::vars_os() {
1210        let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1211            continue;
1212        };
1213        if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1214            environment.insert(key.to_string(), value.to_string());
1215        }
1216    }
1217    for (key, value) in command.get_envs() {
1218        let key = key
1219            .to_str()
1220            .context("workflow runtime environment key is not UTF-8")?
1221            .to_string();
1222        if let Some(value) = value {
1223            environment.insert(
1224                key,
1225                value
1226                    .to_str()
1227                    .context("workflow runtime environment value is not UTF-8")?
1228                    .to_string(),
1229            );
1230        } else {
1231            environment.remove(&key);
1232        }
1233    }
1234    Ok(WorkflowProcessSpec {
1235        command: argv,
1236        environment: environment.into_iter().collect(),
1237    })
1238}
1239
1240/// Flags for `codewhale remote-setup`. Forwarded to the TUI binary, which owns
1241/// the interactive wizard and bundle generation.
1242#[derive(Debug, Args, Clone, Default)]
1243struct RemoteSetupArgs {
1244    /// Cloud target slug (lighthouse, azure, digitalocean). Skips the prompt.
1245    #[arg(long)]
1246    cloud: Option<String>,
1247    /// Chat bridge slug (feishu, telegram). Skips the prompt.
1248    #[arg(long)]
1249    bridge: Option<String>,
1250    /// Provider slug; validated against the provider registry. Skips the prompt.
1251    #[arg(long)]
1252    provider: Option<String>,
1253    /// Bundle output directory (default `./codewhale-deploy/<cloud>-<bridge>`).
1254    #[arg(long, value_name = "DIR")]
1255    out: Option<PathBuf>,
1256    /// Emit the bundle, do not provision (default).
1257    #[arg(long, default_value_t = false)]
1258    generate_only: bool,
1259    /// Run the cloud CLI to auto-provision (not yet implemented).
1260    #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
1261    apply: bool,
1262    /// Skip the final confirmation gate (CI / non-interactive).
1263    #[arg(long, default_value_t = false)]
1264    yes: bool,
1265    /// Fail instead of prompting if any required value is missing.
1266    #[arg(long, default_value_t = false)]
1267    non_interactive: bool,
1268}
1269
1270/// Build the forwarded argv for the TUI `remote-setup` subcommand from the
1271/// structured CLI flags. Mirrors the named flags exactly so the TUI clap parser
1272/// re-derives the same `RemoteSetupArgs`.
1273fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1274    let mut forwarded = vec!["remote-setup".to_string()];
1275    if let Some(cloud) = args.cloud {
1276        forwarded.push("--cloud".to_string());
1277        forwarded.push(cloud);
1278    }
1279    if let Some(bridge) = args.bridge {
1280        forwarded.push("--bridge".to_string());
1281        forwarded.push(bridge);
1282    }
1283    if let Some(provider) = args.provider {
1284        forwarded.push("--provider".to_string());
1285        forwarded.push(provider);
1286    }
1287    if let Some(out) = args.out {
1288        forwarded.push("--out".to_string());
1289        forwarded.push(out.to_string_lossy().into_owned());
1290    }
1291    if args.generate_only {
1292        forwarded.push("--generate-only".to_string());
1293    }
1294    if args.apply {
1295        forwarded.push("--apply".to_string());
1296    }
1297    if args.yes {
1298        forwarded.push("--yes".to_string());
1299    }
1300    if args.non_interactive {
1301        forwarded.push("--non-interactive".to_string());
1302    }
1303    forwarded
1304}
1305
1306#[derive(Debug, Args)]
1307struct LoginArgs {
1308    #[arg(long, value_enum, hide = true)]
1309    provider: Option<ProviderArg>,
1310    #[arg(long)]
1311    api_key: Option<String>,
1312}
1313
1314#[derive(Debug, Args)]
1315struct AuthArgs {
1316    #[command(subcommand)]
1317    command: AuthCommand,
1318}
1319
1320#[derive(Debug, Subcommand)]
1321enum AuthCommand {
1322    /// Sign in to xAI/Grok with an SSH-friendly device code.
1323    #[command(name = "xai-device")]
1324    XaiDevice,
1325    /// Show current provider and credential source state.
1326    /// Without `--provider`, shows all known providers.
1327    /// With `--provider`, shows detailed status for that provider.
1328    Status {
1329        /// Show status for a specific provider only.
1330        #[arg(long, value_enum)]
1331        provider: Option<ProviderArg>,
1332    },
1333    /// Save an API key to the shared user config file. Reads from
1334    /// `--api-key`, `--api-key-stdin`, or prompts on stdin when
1335    /// neither is given. Does not echo the key.
1336    Set {
1337        #[arg(long, value_enum)]
1338        provider: ProviderArg,
1339        /// Inline value (discouraged — appears in shell history).
1340        #[arg(long)]
1341        api_key: Option<String>,
1342        /// Read the key from stdin instead of prompting.
1343        #[arg(long = "api-key-stdin", default_value_t = false)]
1344        api_key_stdin: bool,
1345    },
1346    /// Report whether a provider has a key configured. Never prints
1347    /// the value; just `set` / `not set` plus the source layer.
1348    Get {
1349        #[arg(long, value_enum)]
1350        provider: ProviderArg,
1351    },
1352    /// Delete a provider's key from config and secret-store storage.
1353    Clear {
1354        #[arg(long, value_enum)]
1355        provider: ProviderArg,
1356    },
1357    /// List all known providers with their auth state, without
1358    /// revealing keys.
1359    List,
1360    /// Advanced: migrate config-file keys into a platform credential store.
1361    #[command(hide = true)]
1362    Migrate {
1363        /// Don't actually write anything; print what would change.
1364        #[arg(long, default_value_t = false)]
1365        dry_run: bool,
1366    },
1367}
1368
1369#[derive(Debug, Args)]
1370struct ConfigArgs {
1371    #[command(subcommand)]
1372    command: ConfigCommand,
1373}
1374
1375#[derive(Debug, Subcommand)]
1376enum ConfigCommand {
1377    Get { key: String },
1378    Set { key: String, value: String },
1379    Unset { key: String },
1380    List,
1381    Path,
1382}
1383
1384#[derive(Debug, Args)]
1385struct ModelArgs {
1386    #[command(subcommand)]
1387    command: ModelCommand,
1388}
1389
1390#[derive(Debug, Subcommand)]
1391enum ModelCommand {
1392    List {
1393        #[arg(long, value_enum)]
1394        provider: Option<ProviderArg>,
1395    },
1396    Resolve {
1397        model: Option<String>,
1398        #[arg(long, value_enum)]
1399        provider: Option<ProviderArg>,
1400    },
1401    /// Set the default model (e.g. "pro", "flash", "deepseek-v4-pro").
1402    Set { model: String },
1403}
1404
1405#[derive(Debug, Args)]
1406struct ThreadArgs {
1407    #[command(subcommand)]
1408    command: ThreadCommand,
1409}
1410
1411#[derive(Debug, Subcommand)]
1412enum ThreadCommand {
1413    List {
1414        #[arg(long, default_value_t = false)]
1415        all: bool,
1416        #[arg(long)]
1417        limit: Option<usize>,
1418    },
1419    Read {
1420        thread_id: String,
1421    },
1422    Resume {
1423        thread_id: String,
1424    },
1425    Fork {
1426        thread_id: String,
1427    },
1428    Archive {
1429        thread_id: String,
1430    },
1431    Unarchive {
1432        thread_id: String,
1433    },
1434    SetName {
1435        thread_id: String,
1436        name: String,
1437    },
1438    /// Remove the custom name from a thread, restoring the default
1439    /// `(unnamed)` rendering in `thread list`.
1440    ClearName {
1441        thread_id: String,
1442    },
1443}
1444
1445#[derive(Debug, Args)]
1446struct SandboxArgs {
1447    #[command(subcommand)]
1448    command: SandboxCommand,
1449}
1450
1451#[derive(Debug, Subcommand)]
1452enum SandboxCommand {
1453    Check {
1454        command: String,
1455        #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1456        ask: ApprovalModeArg,
1457    },
1458}
1459
1460#[derive(Debug, Clone, Copy, ValueEnum)]
1461enum ApprovalModeArg {
1462    UnlessTrusted,
1463    OnFailure,
1464    OnRequest,
1465    Never,
1466}
1467
1468impl From<ApprovalModeArg> for AskForApproval {
1469    fn from(value: ApprovalModeArg) -> Self {
1470        match value {
1471            ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1472            ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1473            ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1474            ApprovalModeArg::Never => AskForApproval::Never,
1475        }
1476    }
1477}
1478
1479#[derive(Debug, Args)]
1480struct AppServerArgs {
1481    /// Serve the full HTTP/SSE runtime API (`/v1/*`: sessions, threads, turns,
1482    /// approvals, events, usage, fleet, tasks). This is the canonical runtime
1483    /// API surface; it delegates to the same server as `codewhale serve --http`.
1484    #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1485    http: bool,
1486    /// Serve the runtime API plus the phone-friendly mobile control page.
1487    /// Equivalent to the legacy `codewhale serve --mobile`.
1488    #[arg(long, conflicts_with = "stdio")]
1489    mobile: bool,
1490    /// Run the app-server JSON-RPC control transport over stdio (no listener).
1491    /// Used by local SDKs and JSON-RPC integrations.
1492    #[arg(long, default_value_t = false)]
1493    stdio: bool,
1494    /// Show a QR code for the mobile URL in the terminal (requires --mobile).
1495    #[arg(long, requires = "mobile")]
1496    qr: bool,
1497    /// Bind host. Defaults to 127.0.0.1; with --mobile and no host, binds
1498    /// 0.0.0.0 so LAN devices can reach the mobile page.
1499    #[arg(long)]
1500    host: Option<String>,
1501    /// Bind port. Defaults to 7878 for --http/--mobile (the runtime API) and
1502    /// 8787 for the legacy in-process app-server HTTP transport.
1503    #[arg(long)]
1504    port: Option<u16>,
1505    /// Background task worker count (1-8). Only used with --http/--mobile.
1506    #[arg(long)]
1507    workers: Option<usize>,
1508    #[arg(long)]
1509    config: Option<PathBuf>,
1510    #[arg(long = "auth-token")]
1511    auth_token: Option<String>,
1512    #[arg(long, default_value_t = false)]
1513    insecure_no_auth: bool,
1514    #[arg(long = "cors-origin")]
1515    cors_origin: Vec<String>,
1516}
1517
1518const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
1519
1520fn install_rustls_crypto_provider() {
1521    let _ = rustls::crypto::ring::default_provider().install_default();
1522}
1523
1524pub fn run_cli() -> std::process::ExitCode {
1525    install_rustls_crypto_provider();
1526
1527    match run() {
1528        Ok(()) => std::process::ExitCode::SUCCESS,
1529        Err(err) => {
1530            // Use the full anyhow chain so callers see the underlying
1531            // cause (e.g. the actual TOML parse error with line/column)
1532            // instead of just the top-level context message. The bare
1533            // `{err}` Display impl drops the chain — see #767, where
1534            // users hit "failed to parse config at <path>" with no
1535            // hint that the real error was a stray BOM or unbalanced
1536            // quote a few lines down.
1537            eprintln!("error: {err}");
1538            for cause in err.chain().skip(1) {
1539                eprintln!("  caused by: {cause}");
1540            }
1541            std::process::ExitCode::FAILURE
1542        }
1543    }
1544}
1545
1546fn split_lane_log_proxy_command(
1547    command: Option<Commands>,
1548) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
1549    match command {
1550        Some(Commands::LaneLogProxy(args)) => (Some(args), None),
1551        command => (None, command),
1552    }
1553}
1554
1555fn run() -> Result<()> {
1556    let mut cli = Cli::parse();
1557
1558    // The detached log proxy must not depend on user config parsing: its job
1559    // is to frame child output and publish a terminal receipt even when the
1560    // delegated command's own config is malformed.
1561    let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
1562    if let Some(args) = proxy {
1563        return run_lane_log_proxy_command(args);
1564    }
1565
1566    let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
1567    let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
1568    let runtime_overrides = CliRuntimeOverrides {
1569        provider: runtime_provider,
1570        model: cli.model.clone(),
1571        api_key: cli.api_key.clone(),
1572        base_url: cli.base_url.clone(),
1573        auth_mode: None,
1574        output_mode: cli.output_mode.clone(),
1575        log_level: cli.log_level.clone(),
1576        telemetry: cli.telemetry,
1577        approval_policy: cli.approval_policy.clone(),
1578        sandbox_mode: cli.sandbox_mode.clone(),
1579        yolo: Some(cli.yolo),
1580        verbosity: cli.verbosity.clone(),
1581    };
1582    if uses_raw_tui_provider
1583        && let Some((resolved_runtime, passthrough)) =
1584            prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
1585    {
1586        return delegate_to_tui(&cli, &resolved_runtime, passthrough);
1587    }
1588
1589    let mut store = ConfigStore::load(cli.config.clone())?;
1590    match command {
1591        Some(Commands::Run(args)) => {
1592            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1593            delegate_to_tui(&cli, &resolved_runtime, args.args)
1594        }
1595        Some(Commands::Doctor(args)) => {
1596            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1597            delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
1598        }
1599        Some(Commands::Models(args)) => {
1600            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1601            delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
1602        }
1603        Some(Commands::Speech(args)) => {
1604            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1605            delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
1606        }
1607        Some(Commands::Sessions(args)) => {
1608            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1609            delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
1610        }
1611        Some(Commands::Resume(args)) => {
1612            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1613            run_resume_command(&cli, &resolved_runtime, args)
1614        }
1615        Some(Commands::Fork(args)) => {
1616            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1617            delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
1618        }
1619        Some(Commands::Init(args)) => {
1620            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1621            delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
1622        }
1623        Some(Commands::Setup(args)) => {
1624            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1625            delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
1626        }
1627        Some(Commands::RemoteSetup(args)) => {
1628            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1629            delegate_to_tui(&cli, &resolved_runtime, remote_setup_tui_args(args))
1630        }
1631        Some(Commands::Exec(args)) => {
1632            reject_exec_global_flags(&args.args)?;
1633            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1634            delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
1635        }
1636        Some(Commands::Fleet(args)) => {
1637            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1638            delegate_to_tui(&cli, &resolved_runtime, tui_args("fleet", args))
1639        }
1640        Some(Commands::WorkflowTool(args)) => {
1641            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1642            delegate_to_tui(&cli, &resolved_runtime, tui_args("workflow-tool", args))
1643        }
1644        Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
1645        Some(Commands::Workflow(args)) => {
1646            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1647            let config_path = store.path().to_path_buf();
1648            run_workflow_command(&cli, &resolved_runtime, &config_path, args)
1649        }
1650        Some(Commands::Lane(args)) => run_lane_command(args),
1651        Some(Commands::Review(args)) => {
1652            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1653            delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
1654        }
1655        Some(Commands::Apply(args)) => {
1656            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1657            delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
1658        }
1659        Some(Commands::Eval(args)) => {
1660            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1661            delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
1662        }
1663        Some(Commands::Mcp(args)) => {
1664            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1665            delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
1666        }
1667        Some(Commands::Features(args)) => {
1668            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1669            delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
1670        }
1671        Some(Commands::Serve(args)) => {
1672            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1673            // `serve` starts a long-running runtime API listener; supervise the
1674            // delegated child so it is torn down with the dispatcher (#3259).
1675            delegate_server_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
1676        }
1677        Some(Commands::Completions(args)) => {
1678            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1679            delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
1680        }
1681        Some(Commands::Login(args)) => run_login_command(&mut store, args),
1682        Some(Commands::Logout) => run_logout_command(&mut store),
1683        Some(Commands::Auth(args)) => match args.command {
1684            AuthCommand::XaiDevice => {
1685                let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1686                delegate_to_tui(
1687                    &cli,
1688                    &resolved_runtime,
1689                    vec!["auth".to_string(), "xai-device".to_string()],
1690                )
1691            }
1692            command => run_auth_command(&mut store, command),
1693        },
1694        Some(Commands::McpServer) => run_mcp_server_command(&mut store),
1695        Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
1696        Some(Commands::Model(args)) => {
1697            run_model_command(&mut store, args.command, runtime_overrides.provider)
1698        }
1699        Some(Commands::Thread(args)) => run_thread_command(args.command),
1700        Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
1701        Some(Commands::AppServer(args)) => {
1702            // The HTTP/mobile runtime API is delegated to the mature `serve` path
1703            // in the TUI binary, which reads the *global* --config. app-server has
1704            // historically taken a subcommand-level --config, so bridge it before
1705            // resolving runtime options (provider/keyring) for the delegated run.
1706            if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
1707                cli.config = args.config.clone();
1708                store = ConfigStore::load(cli.config.clone())?;
1709            }
1710            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1711            run_app_server_command(&cli, &resolved_runtime, args)
1712        }
1713        Some(Commands::Completion { shell }) => {
1714            let mut cmd = Cli::command();
1715            generate(shell, &mut cmd, "codewhale", &mut io::stdout());
1716            Ok(())
1717        }
1718        Some(Commands::Metrics(args)) => run_metrics_command(args),
1719        Some(Commands::Update(args)) => {
1720            #[cfg(not(target_env = "ohos"))]
1721            {
1722                update::run_update(args.beta, args.check, args.proxy)
1723            }
1724            #[cfg(target_env = "ohos")]
1725            {
1726                let _ = args;
1727                bail!("self-update is not supported on HarmonyOS/OpenHarmony yet");
1728            }
1729        }
1730        None => {
1731            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1732            let forwarded = root_tui_passthrough(&cli)?;
1733            delegate_to_tui(&cli, &resolved_runtime, forwarded)
1734        }
1735    }
1736}
1737
1738fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
1739    let mut forwarded = Vec::new();
1740    if cli.continue_session {
1741        forwarded.push("--continue".to_string());
1742    }
1743
1744    let prompt =
1745        cli.prompt_flag
1746            .iter()
1747            .chain(cli.prompt.iter())
1748            .fold(String::new(), |mut acc, part| {
1749                if !acc.is_empty() {
1750                    acc.push(' ');
1751                }
1752                acc.push_str(part);
1753                acc
1754            });
1755    if !prompt.is_empty() {
1756        if cli.continue_session {
1757            bail!(
1758                "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
1759            );
1760        }
1761        forwarded.push("--prompt".to_string());
1762        forwarded.push(prompt);
1763    }
1764
1765    Ok(forwarded)
1766}
1767
1768fn resolve_runtime_for_dispatch(
1769    store: &mut ConfigStore,
1770    runtime_overrides: &CliRuntimeOverrides,
1771) -> ResolvedRuntimeOptions {
1772    let runtime_secrets = Secrets::auto_detect();
1773    resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
1774}
1775
1776fn resolve_runtime_for_dispatch_with_secrets(
1777    store: &mut ConfigStore,
1778    runtime_overrides: &CliRuntimeOverrides,
1779    secrets: &Secrets,
1780) -> ResolvedRuntimeOptions {
1781    store
1782        .config
1783        .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
1784}
1785
1786fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
1787    let mut forwarded = Vec::with_capacity(args.args.len() + 1);
1788    forwarded.push(command.to_string());
1789    forwarded.extend(args.args);
1790    forwarded
1791}
1792
1793fn reject_exec_global_flags(args: &[String]) -> Result<()> {
1794    const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
1795
1796    for arg in args {
1797        if arg == "--" {
1798            break;
1799        }
1800        let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
1801        if GLOBAL_ONLY_FLAGS.contains(&flag) {
1802            bail!(
1803                "{flag} must be placed before `exec`.\n\nUse:\n  codewhale {flag} <value> exec \"<prompt>\""
1804            );
1805        }
1806    }
1807
1808    Ok(())
1809}
1810
1811fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
1812    run_login_command_with_secrets(store, args, &Secrets::auto_detect())
1813}
1814
1815fn run_login_command_with_secrets(
1816    store: &mut ConfigStore,
1817    args: LoginArgs,
1818    secrets: &Secrets,
1819) -> Result<()> {
1820    let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
1821    store.config.provider = provider;
1822
1823    let api_key = match args.api_key {
1824        Some(v) => v,
1825        None => read_api_key_from_stdin()?,
1826    };
1827    let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
1828    let destination = if secret_store_saved {
1829        secrets.backend_name().to_string()
1830    } else {
1831        store.path().display().to_string()
1832    };
1833    if provider == ProviderKind::Deepseek {
1834        println!("logged in using API key mode (deepseek); saved key to {destination}");
1835    } else {
1836        println!(
1837            "logged in using API key mode ({}); saved key to {destination}",
1838            provider.as_str(),
1839        );
1840    }
1841    Ok(())
1842}
1843
1844fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
1845    run_logout_command_with_secrets(store, &Secrets::auto_detect())
1846}
1847
1848fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
1849    let active_provider = store.config.provider;
1850    store.config.api_key = None;
1851    for provider in ProviderKind::ALL {
1852        clear_provider_api_key_from_config(store, provider);
1853    }
1854    clear_provider_api_key_from_keyring(secrets, active_provider);
1855    store.config.auth_mode = None;
1856    store.save()?;
1857    println!("logged out");
1858    Ok(())
1859}
1860
1861/// Map [`ProviderKind`] to the canonical provider credential slot.
1862fn provider_slot(provider: ProviderKind) -> &'static str {
1863    match provider {
1864        // Keep the historical shared credential slot for the China endpoint.
1865        ProviderKind::SiliconflowCN => "siliconflow",
1866        _ => provider.provider().id(),
1867    }
1868}
1869
1870#[cfg(test)]
1871fn no_keyring_secrets() -> Secrets {
1872    Secrets::new(std::sync::Arc::new(
1873        codewhale_secrets::InMemoryKeyringStore::new(),
1874    ))
1875}
1876
1877fn write_provider_api_key_to_config(
1878    store: &mut ConfigStore,
1879    provider: ProviderKind,
1880    api_key: &str,
1881) {
1882    prepare_provider_api_key_metadata(store, provider);
1883    store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
1884    if provider == ProviderKind::Deepseek {
1885        store.config.api_key = Some(api_key.to_string());
1886    }
1887}
1888
1889fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
1890    store.config.auth_mode = Some("api_key".to_string());
1891    store.config.providers.for_provider_mut(provider).auth_mode = Some("api_key".to_string());
1892    if provider == ProviderKind::Deepseek {
1893        if store.config.default_text_model.is_none() {
1894            store.config.default_text_model = Some(
1895                store
1896                    .config
1897                    .providers
1898                    .deepseek
1899                    .model
1900                    .clone()
1901                    .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
1902            );
1903        }
1904    }
1905}
1906
1907/// Persist a provider credential to the durable secret store first. A
1908/// plaintext config slot is used only when that write fails.
1909fn persist_provider_api_key(
1910    store: &mut ConfigStore,
1911    secrets: &Secrets,
1912    provider: ProviderKind,
1913    api_key: &str,
1914) -> Result<bool> {
1915    prepare_provider_api_key_metadata(store, provider);
1916    let secret_store_saved = match secrets.set(provider_slot(provider), api_key) {
1917        Ok(()) => {
1918            clear_provider_api_key_from_config(store, provider);
1919            true
1920        }
1921        Err(err) => {
1922            eprintln!(
1923                "warning: secret-store write failed for {}; using owner-only config fallback: {err}",
1924                provider_slot(provider)
1925            );
1926            write_provider_api_key_to_config(store, provider, api_key);
1927            false
1928        }
1929    };
1930    store.save()?;
1931    codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
1932    Ok(secret_store_saved)
1933}
1934
1935fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
1936    store.config.providers.for_provider_mut(provider).api_key = None;
1937    if provider == ProviderKind::Deepseek {
1938        store.config.api_key = None;
1939    }
1940}
1941
1942fn provider_env_set(provider: ProviderKind) -> bool {
1943    provider_env_value(provider).is_some()
1944}
1945
1946fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
1947    provider.provider().env_vars()
1948}
1949
1950fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
1951    provider_env_vars(provider).iter().find_map(|var| {
1952        std::env::var(var)
1953            .ok()
1954            .filter(|value| !value.trim().is_empty())
1955            .map(|value| (*var, value))
1956    })
1957}
1958
1959fn openai_codex_auth_file_path() -> PathBuf {
1960    if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
1961        let path = PathBuf::from(path);
1962        if !path.as_os_str().is_empty() {
1963            return path;
1964        }
1965    }
1966
1967    let codex_home = std::env::var("CODEX_HOME")
1968        .map(PathBuf::from)
1969        .unwrap_or_else(|_| {
1970            dirs::home_dir()
1971                .unwrap_or_else(|| PathBuf::from("."))
1972                .join(".codex")
1973        });
1974    codex_home.join("auth.json")
1975}
1976
1977fn provider_oauth_file_path(provider: ProviderKind) -> Option<PathBuf> {
1978    (provider == ProviderKind::OpenaiCodex).then(openai_codex_auth_file_path)
1979}
1980
1981fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
1982    let slot = store
1983        .config
1984        .providers
1985        .for_provider(provider)
1986        .api_key
1987        .as_deref();
1988    let root = (provider == ProviderKind::Deepseek)
1989        .then_some(store.config.api_key.as_deref())
1990        .flatten();
1991    slot.or(root).filter(|v| !v.trim().is_empty())
1992}
1993
1994fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
1995    provider_config_api_key(store, provider).is_some()
1996}
1997
1998fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
1999    secrets
2000        .get(provider_slot(provider))
2001        .ok()
2002        .flatten()
2003        .filter(|v| !v.trim().is_empty())
2004}
2005
2006fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
2007    provider_keyring_api_key(secrets, provider).is_some()
2008}
2009
2010fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
2011    let _ = secrets.delete(provider_slot(provider));
2012}
2013
2014fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
2015    let active_provider = store.config.provider;
2016    let mut lines = Vec::new();
2017    lines.push(format!(
2018        "active provider: {} (set via config or CODEWHALE_PROVIDER)",
2019        active_provider.as_str()
2020    ));
2021    lines.push(String::new());
2022    lines.push(format!(
2023        "{:<14} {:<8} {:<10} {:<8} {}",
2024        "provider", "config", "keyring", "env", "status"
2025    ));
2026    lines.push("-".repeat(70));
2027
2028    for provider in ProviderKind::ALL {
2029        let config_key = provider_config_api_key(store, provider);
2030        let keyring_key = provider_keyring_api_key(secrets, provider);
2031        let env_key = provider_env_value(provider);
2032        let oauth_file_present = provider_oauth_file_path(provider).is_some_and(|p| p.exists());
2033
2034        let config_status = config_key.map(|_| "set").unwrap_or("-");
2035        let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
2036        let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
2037
2038        let source = if provider == ProviderKind::OpenaiCodex {
2039            // Keep the summary consistent with `auth status`: Codex auth is
2040            // OAuth-file (or env token) based — config/keyring keys are not
2041            // consulted for it.
2042            if env_key.is_some() {
2043                "env"
2044            } else if oauth_file_present {
2045                "oauth file"
2046            } else {
2047                "unset"
2048            }
2049        } else if config_key.is_some() {
2050            "config"
2051        } else if keyring_key.is_some() {
2052            "keyring"
2053        } else if env_key.is_some() {
2054            "env"
2055        } else if oauth_file_present {
2056            "oauth file"
2057        } else {
2058            "unset"
2059        };
2060
2061        let active_marker = if provider == active_provider {
2062            " *"
2063        } else {
2064            ""
2065        };
2066
2067        lines.push(format!(
2068            "{:<14} {:<8} {:<10} {:<8} {}{}",
2069            provider.as_str(),
2070            config_status,
2071            keyring_status,
2072            env_status,
2073            source,
2074            active_marker
2075        ));
2076    }
2077
2078    lines.push(String::new());
2079    lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
2080    lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
2081    lines
2082}
2083
2084fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
2085    let mut lines = Vec::new();
2086    lines.push("provider     config store env  active".to_string());
2087    for provider in ProviderKind::ALL {
2088        let slot = provider_slot(provider);
2089        let file = provider_config_set(store, provider);
2090        let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
2091        let env = provider_env_set(provider);
2092        let oauth_file = provider_oauth_file_path(provider).is_some_and(|p| p.exists());
2093        let active = if provider == ProviderKind::OpenaiCodex {
2094            if env {
2095                "env"
2096            } else if oauth_file {
2097                "oauth"
2098            } else {
2099                "missing"
2100            }
2101        } else if file {
2102            "config"
2103        } else if keyring == Some(true) {
2104            "store"
2105        } else if env {
2106            "env"
2107        } else {
2108            "missing"
2109        };
2110        lines.push(format!(
2111            "{slot:<12}  {}     {}      {}   {active}",
2112            yes_no(file),
2113            keyring_status_short(keyring),
2114            yes_no(env)
2115        ));
2116    }
2117    lines
2118}
2119
2120fn auth_status_lines_for_provider(
2121    store: &ConfigStore,
2122    secrets: &Secrets,
2123    provider: ProviderKind,
2124) -> Vec<String> {
2125    let config_key = provider_config_api_key(store, provider);
2126    let keyring_key = provider_keyring_api_key(secrets, provider);
2127    let env_key = provider_env_value(provider);
2128    let oauth_file = provider_oauth_file_path(provider);
2129    let oauth_file_present = oauth_file.as_ref().is_some_and(|path| path.exists());
2130
2131    let active_source = if provider == ProviderKind::OpenaiCodex {
2132        if env_key.is_some() {
2133            "env"
2134        } else if oauth_file_present {
2135            "Codex OAuth file"
2136        } else {
2137            "missing"
2138        }
2139    } else if config_key.is_some() {
2140        "config"
2141    } else if keyring_key.is_some() {
2142        "secret store"
2143    } else if env_key.is_some() {
2144        "env"
2145    } else {
2146        "missing"
2147    };
2148    let active_last4 = if provider == ProviderKind::OpenaiCodex {
2149        env_key.as_ref().map(|(_, value)| last4_label(value))
2150    } else {
2151        config_key
2152            .map(last4_label)
2153            .or_else(|| keyring_key.as_deref().map(last4_label))
2154            .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
2155    };
2156    let active_label = active_last4
2157        .map(|last4| format!("{active_source} (last4: {last4})"))
2158        .unwrap_or_else(|| active_source.to_string());
2159
2160    let env_var_label = env_key
2161        .as_ref()
2162        .map(|(name, _)| (*name).to_string())
2163        .unwrap_or_else(|| provider_env_vars(provider).join("/"));
2164    let env_status = env_key
2165        .as_ref()
2166        .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
2167        .unwrap_or_else(|| "unset".to_string());
2168
2169    let is_active = provider == store.config.provider;
2170    let active_marker = if is_active { " (active provider)" } else { "" };
2171
2172    let provider_cfg = store.config.providers.for_provider(provider);
2173    let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
2174    let model = provider_cfg.model.as_deref().unwrap_or("(default)");
2175
2176    let lookup_order = if provider == ProviderKind::OpenaiCodex {
2177        "lookup order: env -> Codex OAuth file".to_string()
2178    } else {
2179        "lookup order: config -> secret store -> env".to_string()
2180    };
2181    let auth_mode = if provider == ProviderKind::OpenaiCodex {
2182        "codex_oauth"
2183    } else {
2184        store.config.auth_mode.as_deref().unwrap_or("api_key")
2185    };
2186
2187    let mut lines = vec![
2188        format!("provider: {}{}", provider.as_str(), active_marker),
2189        format!("route: {}", base_url),
2190        format!("model: {}", model),
2191        format!("auth mode: {auth_mode}"),
2192        format!("active source: {active_label}"),
2193        lookup_order,
2194        format!(
2195            "config file: {} ({})",
2196            store.path().display(),
2197            source_status(config_key, "missing")
2198        ),
2199        format!(
2200            "secret store: {} ({})",
2201            secrets.backend_name(),
2202            source_status(keyring_key.as_deref(), "missing")
2203        ),
2204        format!("env var: {env_var_label} ({env_status})"),
2205    ];
2206    if let Some(path) = oauth_file {
2207        let status = if path.exists() { "present" } else { "missing" };
2208        lines.push(format!("Codex OAuth file: {} ({status})", path.display()));
2209    }
2210    lines
2211}
2212
2213fn source_status(value: Option<&str>, missing_label: &str) -> String {
2214    value
2215        .map(|v| format!("set, last4: {}", last4_label(v)))
2216        .unwrap_or_else(|| missing_label.to_string())
2217}
2218
2219fn last4_label(value: &str) -> String {
2220    let trimmed = value.trim();
2221    let chars: Vec<char> = trimmed.chars().collect();
2222    if chars.len() <= 4 {
2223        return "<redacted>".to_string();
2224    }
2225    let last4: String = chars[chars.len() - 4..].iter().collect();
2226    format!("...{last4}")
2227}
2228
2229fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
2230    run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
2231}
2232
2233fn run_auth_command_with_secrets(
2234    store: &mut ConfigStore,
2235    command: AuthCommand,
2236    secrets: &Secrets,
2237) -> Result<()> {
2238    match command {
2239        AuthCommand::XaiDevice => {
2240            bail!("xAI device authentication must be delegated to codewhale-tui")
2241        }
2242        AuthCommand::Status { provider } => {
2243            match provider {
2244                Some(p) => {
2245                    let provider: ProviderKind = p.into();
2246                    for line in auth_status_lines_for_provider(store, secrets, provider) {
2247                        println!("{line}");
2248                    }
2249                }
2250                None => {
2251                    for line in auth_status_all_providers(store, secrets) {
2252                        println!("{line}");
2253                    }
2254                }
2255            }
2256            Ok(())
2257        }
2258        AuthCommand::Set {
2259            provider,
2260            api_key,
2261            api_key_stdin,
2262        } => {
2263            let provider: ProviderKind = provider.into();
2264            let slot = provider_slot(provider);
2265            if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
2266                let provider_cfg = store.config.providers.for_provider_mut(provider);
2267                if provider_cfg.base_url.is_none() {
2268                    provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
2269                }
2270                store.save()?;
2271                println!(
2272                    "configured {slot} provider in {} (API key optional)",
2273                    store.path().display()
2274                );
2275                return Ok(());
2276            }
2277            let api_key = match (api_key, api_key_stdin) {
2278                (Some(v), _) => v,
2279                (None, true) => read_api_key_from_stdin()?,
2280                (None, false) => prompt_api_key(slot)?,
2281            };
2282            let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
2283            // Don't print the key. Don't echo length.
2284            if secret_store_saved {
2285                println!(
2286                    "saved API key for {slot} to {} (config contains metadata only)",
2287                    secrets.backend_name(),
2288                );
2289            } else {
2290                println!("saved API key for {slot} to {}", store.path().display());
2291            }
2292            Ok(())
2293        }
2294        AuthCommand::Get { provider } => {
2295            let provider: ProviderKind = provider.into();
2296            let slot = provider_slot(provider);
2297            let in_file = provider_config_set(store, provider);
2298            let in_keyring = !in_file && provider_keyring_set(secrets, provider);
2299            let in_env = provider_env_set(provider);
2300            // Report the highest-priority source that has it.
2301            let source = if in_file {
2302                Some("config-file")
2303            } else if in_keyring {
2304                Some("secret-store")
2305            } else if in_env {
2306                Some("env")
2307            } else {
2308                None
2309            };
2310            match source {
2311                Some(source) => println!("{slot}: set (source: {source})"),
2312                None => println!("{slot}: not set"),
2313            }
2314            Ok(())
2315        }
2316        AuthCommand::Clear { provider } => {
2317            let provider: ProviderKind = provider.into();
2318            let slot = provider_slot(provider);
2319            clear_provider_api_key_from_config(store, provider);
2320            clear_provider_api_key_from_keyring(secrets, provider);
2321            store.save()?;
2322            println!("cleared API key for {slot} from config and secret store");
2323            Ok(())
2324        }
2325        AuthCommand::List => {
2326            for line in auth_list_lines(store, secrets) {
2327                println!("{line}");
2328            }
2329            Ok(())
2330        }
2331        AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
2332    }
2333}
2334
2335fn yes_no(b: bool) -> &'static str {
2336    if b { "yes" } else { "no " }
2337}
2338
2339fn keyring_status_short(state: Option<bool>) -> &'static str {
2340    match state {
2341        Some(true) => "yes",
2342        Some(false) => "no ",
2343        None => "n/a",
2344    }
2345}
2346
2347fn prompt_api_key(slot: &str) -> Result<String> {
2348    use std::io::{IsTerminal, Write};
2349    eprint!("Enter API key for {slot}: ");
2350    io::stderr().flush().ok();
2351    if !io::stdin().is_terminal() {
2352        // Non-interactive: read directly without prompting twice.
2353        return read_api_key_from_stdin();
2354    }
2355    let mut buf = String::new();
2356    io::stdin()
2357        .read_line(&mut buf)
2358        .context("failed to read API key from stdin")?;
2359    let key = buf.trim().to_string();
2360    if key.is_empty() {
2361        bail!("empty API key provided");
2362    }
2363    Ok(key)
2364}
2365
2366/// Move plaintext keys from config.toml into the configured secret store.
2367/// Hidden in v0.8.8 because the normal setup path is config/env only.
2368fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
2369    let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
2370    let mut warnings: Vec<String> = Vec::new();
2371
2372    for provider in ProviderKind::ALL {
2373        let slot = provider_slot(provider);
2374        let from_provider_block = store
2375            .config
2376            .providers
2377            .for_provider(provider)
2378            .api_key
2379            .clone()
2380            .filter(|v| !v.trim().is_empty());
2381        let from_root = (provider == ProviderKind::Deepseek)
2382            .then(|| store.config.api_key.clone())
2383            .flatten()
2384            .filter(|v| !v.trim().is_empty());
2385        let value = from_provider_block.or(from_root);
2386        let Some(value) = value else { continue };
2387
2388        if let Ok(Some(existing)) = secrets.get(slot)
2389            && existing == value
2390        {
2391            // Already migrated; safe to strip the file slot.
2392        } else if dry_run {
2393            migrated.push((provider, slot));
2394            continue;
2395        } else if let Err(err) = secrets.set(slot, &value) {
2396            warnings.push(format!(
2397                "skipped {slot}: failed to write to secret store: {err}"
2398            ));
2399            continue;
2400        }
2401        if !dry_run {
2402            store.config.providers.for_provider_mut(provider).api_key = None;
2403            if provider == ProviderKind::Deepseek {
2404                store.config.api_key = None;
2405            }
2406        }
2407        migrated.push((provider, slot));
2408    }
2409
2410    if !dry_run && !migrated.is_empty() {
2411        store
2412            .save()
2413            .context("failed to write updated config.toml")?;
2414    }
2415    if !dry_run {
2416        codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
2417            .context("failed to remove plaintext API keys from config backup")?;
2418    }
2419
2420    println!("secret store backend: {}", secrets.backend_name());
2421    if migrated.is_empty() {
2422        println!("nothing to migrate (config.toml has no plaintext api_key entries)");
2423    } else {
2424        println!(
2425            "{} {} provider key(s):",
2426            if dry_run { "would migrate" } else { "migrated" },
2427            migrated.len()
2428        );
2429        for (_, slot) in &migrated {
2430            println!("  - {slot}");
2431        }
2432        if !dry_run {
2433            println!(
2434                "config.toml at {} no longer contains api_key entries for migrated providers.",
2435                store.path().display()
2436            );
2437        }
2438    }
2439    for w in warnings {
2440        eprintln!("warning: {w}");
2441    }
2442    Ok(())
2443}
2444
2445fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
2446    match command {
2447        ConfigCommand::Get { key } => {
2448            if let Some(value) = store.config.get_display_value(&key) {
2449                println!("{value}");
2450                return Ok(());
2451            }
2452            bail!("key not found: {key}");
2453        }
2454        ConfigCommand::Set { key, value } => {
2455            store.config.set_value(&key, &value)?;
2456            store.save()?;
2457            println!("set {key}");
2458            Ok(())
2459        }
2460        ConfigCommand::Unset { key } => {
2461            store.config.unset_value(&key)?;
2462            store.save()?;
2463            println!("unset {key}");
2464            Ok(())
2465        }
2466        ConfigCommand::List => {
2467            for (key, value) in store.config.list_values() {
2468                println!("{key} = {value}");
2469            }
2470            Ok(())
2471        }
2472        ConfigCommand::Path => {
2473            println!("{}", store.path().display());
2474            Ok(())
2475        }
2476    }
2477}
2478
2479fn model_command_provider_hint(
2480    command_provider: Option<ProviderArg>,
2481    top_level_provider: Option<ProviderKind>,
2482) -> Option<ProviderKind> {
2483    command_provider
2484        .map(ProviderKind::from)
2485        .or(top_level_provider)
2486}
2487
2488fn run_model_command(
2489    store: &mut ConfigStore,
2490    command: ModelCommand,
2491    top_level_provider: Option<ProviderKind>,
2492) -> Result<()> {
2493    let registry = ModelRegistry::default();
2494    match command {
2495        ModelCommand::List { provider } => {
2496            let filter = model_command_provider_hint(provider, top_level_provider);
2497            for model in registry.list().into_iter().filter(|m| match filter {
2498                Some(p) => m.provider == p,
2499                None => true,
2500            }) {
2501                println!("{} ({})", model.id, model.provider.as_str());
2502            }
2503            Ok(())
2504        }
2505        ModelCommand::Resolve { model, provider } => {
2506            let provider = model_command_provider_hint(provider, top_level_provider);
2507            let resolved = registry.resolve(model.as_deref(), provider);
2508            println!("requested: {}", resolved.requested.unwrap_or_default());
2509            println!("resolved: {}", resolved.resolved.id);
2510            println!("provider: {}", resolved.resolved.provider.as_str());
2511            println!("used_fallback: {}", resolved.used_fallback);
2512            Ok(())
2513        }
2514        ModelCommand::Set { model } => {
2515            let trimmed = model.trim();
2516            if trimmed.is_empty() {
2517                bail!("Model name cannot be empty");
2518            }
2519            let canonical = match trimmed.to_ascii_lowercase().as_str() {
2520                "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
2521                "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
2522                _ => trimmed,
2523            };
2524            store.config.default_text_model = Some(canonical.to_string());
2525            store.save()?;
2526            println!("Default model set to '{canonical}'");
2527            Ok(())
2528        }
2529    }
2530}
2531
2532fn run_thread_command(command: ThreadCommand) -> Result<()> {
2533    let state = StateStore::open(None)?;
2534    match command {
2535        ThreadCommand::List { all, limit } => {
2536            let threads = state.list_threads(ThreadListFilters {
2537                include_archived: all,
2538                limit,
2539            })?;
2540            for thread in threads {
2541                println!(
2542                    "{} | {} | {} | {}",
2543                    thread.id,
2544                    thread
2545                        .name
2546                        .clone()
2547                        .unwrap_or_else(|| "(unnamed)".to_string()),
2548                    thread.model_provider,
2549                    thread.cwd.display()
2550                );
2551            }
2552            Ok(())
2553        }
2554        ThreadCommand::Read { thread_id } => {
2555            let thread = state.get_thread(&thread_id)?;
2556            println!("{}", serde_json::to_string_pretty(&thread)?);
2557            Ok(())
2558        }
2559        ThreadCommand::Resume { thread_id } => {
2560            let args = vec!["resume".to_string(), thread_id];
2561            delegate_simple_tui(args)
2562        }
2563        ThreadCommand::Fork { thread_id } => {
2564            let args = vec!["fork".to_string(), thread_id];
2565            delegate_simple_tui(args)
2566        }
2567        ThreadCommand::Archive { thread_id } => {
2568            state.mark_archived(&thread_id)?;
2569            println!("archived {thread_id}");
2570            Ok(())
2571        }
2572        ThreadCommand::Unarchive { thread_id } => {
2573            state.mark_unarchived(&thread_id)?;
2574            println!("unarchived {thread_id}");
2575            Ok(())
2576        }
2577        ThreadCommand::SetName { thread_id, name } => {
2578            let mut thread = state
2579                .get_thread(&thread_id)?
2580                .with_context(|| format!("thread not found: {thread_id}"))?;
2581            thread.name = Some(name);
2582            thread.updated_at = chrono::Utc::now().timestamp();
2583            state.upsert_thread(&thread)?;
2584            println!("renamed {thread_id}");
2585            Ok(())
2586        }
2587        ThreadCommand::ClearName { thread_id } => {
2588            let mut thread = state
2589                .get_thread(&thread_id)?
2590                .with_context(|| format!("thread not found: {thread_id}"))?;
2591            thread.name = None;
2592            thread.updated_at = chrono::Utc::now().timestamp();
2593            state.upsert_thread(&thread)?;
2594            println!("cleared name for {thread_id}");
2595            Ok(())
2596        }
2597    }
2598}
2599
2600fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
2601    match command {
2602        SandboxCommand::Check { command, ask } => {
2603            let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
2604            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2605            let decision = engine.check(ExecPolicyContext {
2606                command: &command,
2607                cwd: &cwd.display().to_string(),
2608                tool: Some("exec_shell"),
2609                path: None,
2610                ask_for_approval: ask.into(),
2611                sandbox_mode: Some("workspace-write"),
2612            })?;
2613            println!("{}", serde_json::to_string_pretty(&decision)?);
2614            Ok(())
2615        }
2616    }
2617}
2618
2619fn run_app_server_command(
2620    cli: &Cli,
2621    resolved_runtime: &ResolvedRuntimeOptions,
2622    args: AppServerArgs,
2623) -> Result<()> {
2624    // The full runtime API lives in the TUI crate behind `serve --http`/`--mobile`.
2625    // Rather than duplicate ~6.5k lines or add a CLI→TUI crate dependency, the
2626    // canonical `app-server --http`/`--mobile` entrypoint reuses that mature server
2627    // by delegating to the sibling TUI binary (the same mechanism `serve` uses).
2628    if args.http || args.mobile {
2629        // Delegated runtime API listener — supervise it so the child does not
2630        // outlive the dispatcher (#3259).
2631        return delegate_server_to_tui(cli, resolved_runtime, app_server_serve_passthrough(&args));
2632    }
2633
2634    let runtime = tokio::runtime::Builder::new_multi_thread()
2635        .enable_all()
2636        .build()
2637        .context("failed to create tokio runtime")?;
2638    if args.stdio {
2639        return runtime.block_on(run_app_server_stdio(args.config));
2640    }
2641    // Legacy in-process app-server HTTP transport (`/healthz`, `/thread`, `/app`,
2642    // `/prompt`, `/tool`, `/jobs`). Kept for backward compatibility; defaults to
2643    // 127.0.0.1:8787 to avoid colliding with the runtime API default of :7878.
2644    let host = args.host.as_deref().unwrap_or("127.0.0.1");
2645    let port = args.port.unwrap_or(8787);
2646    let listen: SocketAddr = format!("{host}:{port}")
2647        .parse()
2648        .with_context(|| format!("invalid app-server listen address {host}:{port}"))?;
2649    runtime.block_on(run_app_server(AppServerOptions {
2650        listen,
2651        config_path: args.config,
2652        auth_token: args.auth_token.or_else(app_server_token_from_env),
2653        insecure_no_auth: args.insecure_no_auth,
2654        cors_origins: args.cors_origin,
2655    }))
2656}
2657
2658/// Build the `serve` argv forwarded to the TUI binary for
2659/// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the
2660/// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The
2661/// subcommand-level `--config` is bridged through the global `--config` in the
2662/// dispatcher, so it is intentionally not part of this passthrough. An auth
2663/// token from the environment is deliberately *not* forwarded into child argv;
2664/// the runtime API reads CODEWHALE_RUNTIME_TOKEN/DEEPSEEK_RUNTIME_TOKEN itself.
2665fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
2666    let mut forwarded = vec!["serve".to_string()];
2667    forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
2668    if let Some(host) = args.host.as_ref() {
2669        forwarded.push("--host".to_string());
2670        forwarded.push(host.clone());
2671    }
2672    if let Some(port) = args.port {
2673        forwarded.push("--port".to_string());
2674        forwarded.push(port.to_string());
2675    }
2676    if let Some(workers) = args.workers {
2677        forwarded.push("--workers".to_string());
2678        forwarded.push(workers.to_string());
2679    }
2680    for origin in &args.cors_origin {
2681        forwarded.push("--cors-origin".to_string());
2682        forwarded.push(origin.clone());
2683    }
2684    if let Some(token) = args.auth_token.as_ref() {
2685        forwarded.push("--auth-token".to_string());
2686        forwarded.push(token.clone());
2687    }
2688    if args.insecure_no_auth {
2689        forwarded.push("--insecure".to_string());
2690    }
2691    if args.qr {
2692        forwarded.push("--qr".to_string());
2693    }
2694    forwarded
2695}
2696
2697fn app_server_token_from_env() -> Option<String> {
2698    std::env::var("CODEWHALE_APP_SERVER_TOKEN")
2699        .ok()
2700        .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
2701}
2702
2703fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
2704    let persisted = load_mcp_server_definitions(store);
2705    let updated = run_stdio_server(persisted)?;
2706    persist_mcp_server_definitions(store, &updated)
2707}
2708
2709fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
2710    let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
2711        return Vec::new();
2712    };
2713
2714    match parse_mcp_server_definitions(&raw) {
2715        Ok(definitions) => definitions,
2716        Err(err) => {
2717            eprintln!(
2718                "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
2719            );
2720            Vec::new()
2721        }
2722    }
2723}
2724
2725fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
2726    if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
2727        return Ok(parsed);
2728    }
2729
2730    let unwrapped: String = serde_json::from_str(raw)
2731        .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
2732    serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
2733        format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
2734    })
2735}
2736
2737fn persist_mcp_server_definitions(
2738    store: &mut ConfigStore,
2739    definitions: &[McpServerDefinition],
2740) -> Result<()> {
2741    let encoded =
2742        serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
2743    store
2744        .config
2745        .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
2746    store.save()
2747}
2748
2749fn delegate_to_tui(
2750    cli: &Cli,
2751    resolved_runtime: &ResolvedRuntimeOptions,
2752    passthrough: Vec<String>,
2753) -> Result<()> {
2754    let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
2755    let tui = PathBuf::from(cmd.get_program());
2756    let status = cmd
2757        .status()
2758        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
2759    exit_with_tui_status(status)
2760}
2761
2762/// Delegate a long-running server command (`serve --http`/`--mobile`,
2763/// `app-server --http`/`--mobile`) to the sibling TUI binary, supervising the
2764/// child so its listener does not outlive the dispatcher (#3259).
2765///
2766/// Plain [`delegate_to_tui`] blocks on `Command::status()`, which reaps the
2767/// child only on the child's own exit. If the dispatcher is terminated while
2768/// the delegated server is still running, the child can be reparented and keep
2769/// its listener bound. Here the child runs under a Tokio supervisor that
2770/// forwards termination (Ctrl+C / SIGTERM / SIGHUP) by killing and reaping the
2771/// child before the dispatcher exits, and `kill_on_drop` tears the child down
2772/// if the dispatcher unwinds.
2773///
2774/// For an *uncatchable* dispatcher death (SIGKILL, a hard crash) the Tokio
2775/// supervisor above can't run, so two OS-level safety nets are installed as
2776/// well (#3259): on Linux the child sets `PR_SET_PDEATHSIG` so the kernel
2777/// signals it when the dispatcher dies; on Windows the child is placed in a
2778/// kill-on-job-close Job Object so closing the dispatcher's handle (which the
2779/// OS does on process death) terminates it. macOS has no equivalent primitive,
2780/// so an uncatchable dispatcher death there can still orphan the child.
2781fn delegate_server_to_tui(
2782    cli: &Cli,
2783    resolved_runtime: &ResolvedRuntimeOptions,
2784    passthrough: Vec<String>,
2785) -> Result<()> {
2786    let mut std_cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
2787    install_server_parent_death_signal(&mut std_cmd);
2788    let tui = PathBuf::from(std_cmd.get_program());
2789    let runtime = tokio::runtime::Builder::new_current_thread()
2790        .enable_all()
2791        .build()
2792        .context("failed to create server-teardown runtime")?;
2793    runtime.block_on(async move {
2794        let mut cmd = tokio::process::Command::from(std_cmd);
2795        cmd.kill_on_drop(true);
2796        let mut child = cmd
2797            .spawn()
2798            .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
2799        // Windows: hold a kill-on-job-close Job Object for the dispatcher's
2800        // lifetime so an uncatchable dispatcher death tears the child down.
2801        // Bound for the whole `block_on` scope; never dropped early because the
2802        // match arms below `std::process::exit`.
2803        #[cfg(windows)]
2804        let _child_job = attach_server_child_job(&child);
2805        match supervise_server_child(&mut child, server_shutdown_signal()).await? {
2806            ServerTeardown::Exited(status) => exit_with_tui_status(status),
2807            // The child has been killed and reaped; exit with the conventional
2808            // 128 + signal code for the signal that initiated the shutdown.
2809            ServerTeardown::Signaled(code) => std::process::exit(code),
2810        }
2811    })
2812}
2813
2814/// On Linux, ask the kernel to terminate the delegated server if the dispatcher
2815/// dies before it can run the graceful shutdown supervisor. This covers the
2816/// hard parent-death edge of #3259 for `SIGKILL`, OOM, or abrupt process exit.
2817#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
2818fn install_server_parent_death_signal(cmd: &mut Command) {
2819    use std::os::unix::process::CommandExt;
2820    // SAFETY: `pre_exec` runs in the child between fork and exec. The closure
2821    // only calls `libc::prctl` with constant arguments and does not touch heap
2822    // memory or parent-held locks.
2823    unsafe {
2824        cmd.pre_exec(|| {
2825            let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
2826            if result == -1 {
2827                // Best effort: the child only loses this OS-level safety net.
2828                let _ = std::io::Error::last_os_error();
2829            }
2830            Ok(())
2831        });
2832    }
2833}
2834
2835#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
2836fn install_server_parent_death_signal(_cmd: &mut Command) {}
2837
2838/// Outcome of supervising a delegated server child.
2839#[derive(Debug)]
2840enum ServerTeardown {
2841    /// The child exited on its own; its status is carried for propagation.
2842    Exited(std::process::ExitStatus),
2843    /// A shutdown signal fired; the child was killed and reaped. Carries the
2844    /// conventional `128 + signal` exit code to propagate.
2845    Signaled(i32),
2846}
2847
2848/// Wait for the server `child` to exit, or for `shutdown` to fire first. On
2849/// shutdown, kill the child and reap it so no listener is left reparented.
2850async fn supervise_server_child<F>(
2851    child: &mut tokio::process::Child,
2852    shutdown: F,
2853) -> io::Result<ServerTeardown>
2854where
2855    F: std::future::Future<Output = i32>,
2856{
2857    tokio::select! {
2858        status = child.wait() => Ok(ServerTeardown::Exited(status?)),
2859        code = shutdown => {
2860            // Send the kill, then wait so the PID is reaped before the
2861            // dispatcher returns and exits.
2862            let _ = child.start_kill();
2863            let _ = child.wait().await;
2864            Ok(ServerTeardown::Signaled(code))
2865        }
2866    }
2867}
2868
2869/// Resolve when the dispatcher should tear down a delegated server child, and
2870/// the conventional `128 + signal` exit code to propagate: Ctrl+C on every
2871/// platform (130), plus SIGTERM (143) and SIGHUP (129) on Unix.
2872#[cfg(unix)]
2873async fn server_shutdown_signal() -> i32 {
2874    use tokio::signal::unix::{SignalKind, signal};
2875    let mut terminate = signal(SignalKind::terminate()).ok();
2876    let mut hangup = signal(SignalKind::hangup()).ok();
2877    let term = async {
2878        match terminate.as_mut() {
2879            Some(s) => {
2880                s.recv().await;
2881            }
2882            None => std::future::pending::<()>().await,
2883        }
2884    };
2885    let hup = async {
2886        match hangup.as_mut() {
2887            Some(s) => {
2888                s.recv().await;
2889            }
2890            None => std::future::pending::<()>().await,
2891        }
2892    };
2893    tokio::select! {
2894        _ = tokio::signal::ctrl_c() => 130,
2895        _ = term => 143,
2896        _ = hup => 129,
2897    }
2898}
2899
2900#[cfg(not(unix))]
2901async fn server_shutdown_signal() -> i32 {
2902    let _ = tokio::signal::ctrl_c().await;
2903    130
2904}
2905
2906/// Assign the delegated server `child` to a kill-on-job-close Job Object so the
2907/// OS terminates it when the dispatcher's handle to the job closes — which it
2908/// does on any dispatcher exit, including an uncatchable kill (#3259). The
2909/// returned guard must be held for the dispatcher's lifetime. Best-effort:
2910/// returns `None` if the job cannot be created or assigned. Mirrors the Job
2911/// Object idiom in `crates/tui/src/tools/shell.rs`.
2912#[cfg(windows)]
2913fn attach_server_child_job(child: &tokio::process::Child) -> Option<ServerChildJob> {
2914    let Some(child_handle) = child.raw_handle() else {
2915        tracing::warn!("delegated server child exited before a job object could be attached");
2916        return None;
2917    };
2918
2919    match ServerChildJob::attach(child_handle) {
2920        Ok(job) => Some(job),
2921        Err(err) => {
2922            tracing::warn!("failed to place delegated server child in a job object: {err}");
2923            None
2924        }
2925    }
2926}
2927
2928#[cfg(windows)]
2929struct ServerChildJob {
2930    handle: windows::Win32::Foundation::HANDLE,
2931}
2932
2933// SAFETY: the wrapped value is a process-wide kernel handle; moving it across
2934// threads does not invalidate it, and it is only ever closed once, on drop.
2935#[cfg(windows)]
2936unsafe impl Send for ServerChildJob {}
2937
2938#[cfg(windows)]
2939impl ServerChildJob {
2940    fn attach(child_handle: std::os::windows::io::RawHandle) -> std::io::Result<Self> {
2941        use windows::Win32::Foundation::HANDLE;
2942        use windows::Win32::System::JobObjects::{
2943            AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
2944            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
2945            SetInformationJobObject,
2946        };
2947        use windows::core::PCWSTR;
2948
2949        // SAFETY: FFI calls with valid arguments; results are checked via the
2950        // `windows` Result wrappers and the handle is stored for close-on-drop.
2951        let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(win_io_error)?;
2952        let job = Self { handle };
2953
2954        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
2955        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
2956        unsafe {
2957            SetInformationJobObject(
2958                job.handle,
2959                JobObjectExtendedLimitInformation,
2960                &limits as *const _ as *const core::ffi::c_void,
2961                std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
2962            )
2963            .map_err(win_io_error)?;
2964            AssignProcessToJobObject(job.handle, HANDLE(child_handle)).map_err(win_io_error)?;
2965        }
2966        Ok(job)
2967    }
2968}
2969
2970#[cfg(windows)]
2971impl Drop for ServerChildJob {
2972    fn drop(&mut self) {
2973        // Closing the last handle triggers KILL_ON_JOB_CLOSE. On a normal return
2974        // the child has already been reaped, so this is a no-op cleanup; an
2975        // uncatchable dispatcher death closes the handle via the OS instead.
2976        unsafe {
2977            let _ = windows::Win32::Foundation::CloseHandle(self.handle);
2978        }
2979    }
2980}
2981
2982#[cfg(windows)]
2983fn win_io_error(err: windows::core::Error) -> std::io::Error {
2984    std::io::Error::other(err)
2985}
2986
2987#[cfg(all(test, unix))]
2988mod server_teardown_tests {
2989    use super::*;
2990
2991    #[tokio::test]
2992    async fn supervisor_propagates_child_exit_when_no_shutdown() {
2993        // `true` exits immediately with success; a never-firing shutdown must
2994        // let the child's own exit win.
2995        let mut child = tokio::process::Command::new("true")
2996            .kill_on_drop(true)
2997            .spawn()
2998            .expect("spawn true");
2999        let outcome = supervise_server_child(&mut child, std::future::pending::<i32>())
3000            .await
3001            .expect("supervise");
3002        match outcome {
3003            ServerTeardown::Exited(status) => assert!(status.success()),
3004            other => panic!("expected Exited, got {other:?}"),
3005        }
3006    }
3007
3008    #[tokio::test]
3009    async fn shutdown_signal_kills_and_reaps_long_running_child() {
3010        // A long-lived child stands in for the delegated server listener; the
3011        // regression is that it outlives dispatcher teardown (#3259).
3012        let mut child = tokio::process::Command::new("sleep")
3013            .arg("30")
3014            .kill_on_drop(true)
3015            .spawn()
3016            .expect("spawn sleep");
3017        assert!(
3018            child.id().is_some(),
3019            "child should be running before shutdown"
3020        );
3021        // A ready future models an immediate shutdown signal carrying the
3022        // SIGTERM exit code (143).
3023        let outcome = supervise_server_child(&mut child, async { 143 })
3024            .await
3025            .expect("supervise");
3026        assert!(matches!(outcome, ServerTeardown::Signaled(143)));
3027        // Once supervise returns the child has been killed AND reaped, so tokio
3028        // drops the recorded pid — no listener is left reparented.
3029        assert!(
3030            child.id().is_none(),
3031            "delegated child must be reaped after dispatcher teardown"
3032        );
3033    }
3034
3035    #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
3036    #[test]
3037    fn parent_death_signal_hook_does_not_break_spawn() {
3038        let mut cmd = Command::new("true");
3039        install_server_parent_death_signal(&mut cmd);
3040        let status = cmd.status().expect("spawn true with parent-death hook");
3041        assert!(status.success());
3042    }
3043}
3044
3045fn run_resume_command(
3046    cli: &Cli,
3047    resolved_runtime: &ResolvedRuntimeOptions,
3048    args: TuiPassthroughArgs,
3049) -> Result<()> {
3050    let passthrough = tui_args("resume", args);
3051    if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
3052        return run_dispatcher_resume_picker(cli, resolved_runtime);
3053    }
3054    delegate_to_tui(cli, resolved_runtime, passthrough)
3055}
3056
3057fn run_dispatcher_resume_picker(
3058    cli: &Cli,
3059    resolved_runtime: &ResolvedRuntimeOptions,
3060) -> Result<()> {
3061    let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
3062    let tui = PathBuf::from(sessions_cmd.get_program());
3063    let status = sessions_cmd
3064        .status()
3065        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
3066    if !status.success() {
3067        return exit_with_tui_status(status);
3068    }
3069
3070    println!();
3071    println!("Windows note: enter a session id or prefix from the list above.");
3072    println!("You can also run `codewhale resume --last` to skip this prompt.");
3073    print!("Session id/prefix (Enter to cancel): ");
3074    io::stdout().flush()?;
3075
3076    let mut input = String::new();
3077    io::stdin()
3078        .read_line(&mut input)
3079        .context("failed to read session selection")?;
3080    let session_id = input.trim();
3081    if session_id.is_empty() {
3082        bail!("No session selected.");
3083    }
3084
3085    delegate_to_tui(
3086        cli,
3087        resolved_runtime,
3088        vec!["resume".to_string(), session_id.to_string()],
3089    )
3090}
3091
3092fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
3093    is_windows && passthrough == ["resume"]
3094}
3095
3096fn build_tui_command(
3097    cli: &Cli,
3098    resolved_runtime: &ResolvedRuntimeOptions,
3099    passthrough: Vec<String>,
3100) -> Result<Command> {
3101    build_tui_command_with_paths(
3102        cli,
3103        resolved_runtime,
3104        passthrough,
3105        cli.config.as_deref(),
3106        cli.workspace.as_deref(),
3107    )
3108}
3109
3110fn build_tui_command_with_paths(
3111    cli: &Cli,
3112    resolved_runtime: &ResolvedRuntimeOptions,
3113    passthrough: Vec<String>,
3114    config_path: Option<&Path>,
3115    workspace_path: Option<&Path>,
3116) -> Result<Command> {
3117    let tui = locate_sibling_tui_binary()?;
3118    let mut verbosity = if cli.profile.is_some() {
3119        cli.verbosity.clone()
3120    } else {
3121        resolved_runtime.verbosity.clone()
3122    };
3123    if verbosity.is_none()
3124        && passthrough
3125            .iter()
3126            .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
3127    {
3128        verbosity = Some("concise".to_string());
3129    }
3130
3131    let mut cmd = Command::new(&tui);
3132    if let Some(config) = config_path {
3133        cmd.arg("--config").arg(config);
3134    }
3135    if let Some(profile) = cli.profile.as_ref() {
3136        cmd.arg("--profile").arg(profile);
3137    }
3138    if let Some(workspace) = workspace_path {
3139        cmd.arg("--workspace").arg(workspace);
3140    }
3141    // Accepted for older scripts, but no longer forwarded: the interactive TUI
3142    // always owns the alternate screen to avoid host scrollback hijacking.
3143    let _ = cli.no_alt_screen;
3144    if cli.mouse_capture {
3145        cmd.arg("--mouse-capture");
3146    }
3147    if cli.no_mouse_capture {
3148        cmd.arg("--no-mouse-capture");
3149    }
3150    if cli.skip_onboarding {
3151        cmd.arg("--skip-onboarding");
3152    }
3153    cmd.args(passthrough);
3154
3155    let uses_raw_tui_provider = cli
3156        .provider
3157        .as_deref()
3158        .is_some_and(|provider| builtin_provider_arg(provider).is_none());
3159    let keyring_bridge_provider = resolved_runtime.provider;
3160    let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
3161    let keyring_bridge_source = resolved_runtime.api_key_source;
3162
3163    if let Some(provider) = cli.provider.as_deref() {
3164        let provider = builtin_provider_arg(provider)
3165            .map(ProviderKind::from)
3166            .map_or_else(
3167                || provider.to_string(),
3168                |provider| provider.as_str().to_string(),
3169            );
3170        // Set both names so an inherited CODEWHALE_PROVIDER cannot outrank the
3171        // explicit CLI pin when the TUI applies its environment overrides.
3172        cmd.env("CODEWHALE_PROVIDER", &provider);
3173        cmd.env("DEEPSEEK_PROVIDER", provider);
3174    }
3175    if !(uses_raw_tui_provider
3176        || (cli.profile.is_some()
3177            && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
3178        && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
3179        && let Some(api_key) = keyring_bridge_api_key
3180    {
3181        // TUI reloads auth_mode from config/profile, but it does not re-query the
3182        // platform keyring on normal startup. Bridge only the recovered secret;
3183        // replaying auth_mode here would turn it back into a profile override.
3184        cmd.env("DEEPSEEK_API_KEY", api_key);
3185        for var in provider_env_vars(keyring_bridge_provider) {
3186            if *var != "DEEPSEEK_API_KEY" {
3187                cmd.env(var, api_key);
3188            }
3189        }
3190        cmd.env(
3191            "DEEPSEEK_API_KEY_SOURCE",
3192            RuntimeApiKeySource::Keyring.as_env_value(),
3193        );
3194    }
3195
3196    if let Some(model) = cli.model.as_ref() {
3197        cmd.env("DEEPSEEK_MODEL", model);
3198    }
3199    if let Some(output_mode) = cli.output_mode.as_ref() {
3200        cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
3201    }
3202    if let Some(v) = verbosity.as_ref() {
3203        cmd.env("CODEWHALE_VERBOSITY", v);
3204        cmd.env("DEEPSEEK_VERBOSITY", v);
3205    }
3206    if let Some(log_level) = cli.log_level.as_ref() {
3207        cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
3208    }
3209    if let Some(telemetry) = cli.telemetry {
3210        cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
3211    }
3212    if let Some(policy) = cli.approval_policy.as_ref() {
3213        cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
3214    }
3215    if let Some(mode) = cli.sandbox_mode.as_ref() {
3216        cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
3217    }
3218    if cli.yolo {
3219        cmd.env("DEEPSEEK_YOLO", "true");
3220    }
3221    if let Some(api_key) = cli.api_key.as_ref() {
3222        // `--profile` is resolved by the TUI after this facade starts it, so
3223        // the base ConfigStore provider may not be the effective provider.
3224        // Carry the explicit secret through a provider-neutral, source-marked
3225        // slot; the TUI applies it after profile/OAuth resolution and before
3226        // saved API-key slots. Preserve legacy provider envs only when their
3227        // identity is already unambiguous here.
3228        cmd.env("CODEWHALE_CLI_API_KEY", api_key);
3229        if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
3230            cmd.env("DEEPSEEK_API_KEY", api_key);
3231            for var in provider_env_vars(resolved_runtime.provider) {
3232                if *var != "DEEPSEEK_API_KEY" {
3233                    cmd.env(var, api_key);
3234                }
3235            }
3236        }
3237        cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
3238    }
3239    if let Some(base_url) = cli.base_url.as_ref() {
3240        cmd.env("DEEPSEEK_BASE_URL", base_url);
3241    }
3242
3243    Ok(cmd)
3244}
3245
3246fn tui_child_exit_code(status: std::process::ExitStatus) -> Option<i32> {
3247    if let Some(code) = status.code() {
3248        return Some(code);
3249    }
3250
3251    #[cfg(unix)]
3252    {
3253        use std::os::unix::process::ExitStatusExt;
3254
3255        status.signal().map(|signal| 128 + signal)
3256    }
3257
3258    #[cfg(not(unix))]
3259    {
3260        None
3261    }
3262}
3263
3264fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
3265    if let Some(code) = tui_child_exit_code(status) {
3266        std::process::exit(code);
3267    }
3268    bail!("codewhale-tui terminated without an exit code")
3269}
3270
3271fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
3272    let tui = locate_sibling_tui_binary()?;
3273    let status = Command::new(&tui)
3274        .args(args)
3275        .status()
3276        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
3277    exit_with_tui_status(status)
3278}
3279
3280fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
3281    format!(
3282        "failed to spawn companion TUI binary at {}: {err}\n\
3283\n\
3284The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
3285to execute it. Common fixes:\n\
3286  - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
3287  - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
3288come from the same install directory.\n\
3289  - If you downloaded release assets manually, keep both `codewhale` and \
3290`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
3291  - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \
3292binary.",
3293        tui.display()
3294    )
3295}
3296
3297/// Resolve the sibling `codewhale-tui` executable next to the running
3298/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
3299/// the npm-distributed Windows package — which ships
3300/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).
3301///
3302/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for
3303/// custom installs and CI test layouts. On Windows we additionally try
3304/// the suffix-less name as a fallback for users who already manually
3305/// renamed the file before this fix landed.
3306fn locate_sibling_tui_binary() -> Result<PathBuf> {
3307    if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
3308        let candidate = PathBuf::from(override_path);
3309        if candidate.is_file() {
3310            return Ok(candidate);
3311        }
3312        bail!(
3313            "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
3314            candidate.display()
3315        );
3316    }
3317
3318    let current = std::env::current_exe().context("failed to locate current executable path")?;
3319    if let Some(found) = sibling_tui_candidate(&current) {
3320        return Ok(found);
3321    }
3322
3323    // Build a stable error path so the user sees the platform-correct
3324    // expected name, not "codewhale-tui" on Windows.
3325    let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
3326    bail!(
3327        "Companion `codewhale-tui` binary not found at {}.\n\
3328\n\
3329The `codewhale` dispatcher delegates interactive sessions to a sibling \
3330`codewhale-tui` binary. To fix this, install one of:\n\
3331  • npm:    npm install -g codewhale                (downloads both binaries)\n\
3332  • cargo:  cargo install codewhale-cli codewhale-tui --locked\n\
3333  • GitHub Releases: download BOTH `codewhale-<platform>` AND \
3334`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
3335and place them in the same directory.\n\
3336\n\
3337Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.",
3338        expected.display()
3339    );
3340}
3341
3342/// Return the first existing sibling-binary path under any of the names
3343/// `codewhale-tui` might use on this platform. Pure function to keep
3344/// `locate_sibling_tui_binary` testable.
3345fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
3346    // Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
3347    // on Windows.
3348    let primary =
3349        dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
3350    if primary.is_file() {
3351        return Some(primary);
3352    }
3353    // Windows fallback: a user who manually renamed `.exe` away (per the
3354    // workaround in #247) still launches successfully under the new code.
3355    if cfg!(windows) {
3356        let suffixless = dispatcher.with_file_name("codewhale-tui");
3357        if suffixless.is_file() {
3358            return Some(suffixless);
3359        }
3360    }
3361    None
3362}
3363
3364fn run_metrics_command(args: MetricsArgs) -> Result<()> {
3365    let since = match args.since.as_deref() {
3366        Some(s) => {
3367            Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
3368        }
3369        None => None,
3370    };
3371    metrics::run(metrics::MetricsArgs {
3372        json: args.json,
3373        since,
3374    })
3375}
3376
3377fn read_api_key_from_stdin() -> Result<String> {
3378    let mut input = String::new();
3379    io::stdin()
3380        .read_to_string(&mut input)
3381        .context("failed to read api key from stdin")?;
3382    let key = input.trim().to_string();
3383    if key.is_empty() {
3384        bail!("empty API key provided");
3385    }
3386    Ok(key)
3387}
3388
3389#[cfg(test)]
3390mod tests {
3391    use super::*;
3392    use clap::error::ErrorKind;
3393    use codewhale_config::ProviderSource;
3394    use std::ffi::OsString;
3395    use std::sync::{Mutex, OnceLock};
3396
3397    fn parse_ok(argv: &[&str]) -> Cli {
3398        Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
3399    }
3400
3401    fn help_for(argv: &[&str]) -> String {
3402        let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
3403        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
3404        err.to_string()
3405    }
3406
3407    fn command_env(cmd: &Command, name: &str) -> Option<String> {
3408        let name = std::ffi::OsStr::new(name);
3409        cmd.get_envs().find_map(|(key, value)| {
3410            if key == name {
3411                value.map(|v| v.to_string_lossy().into_owned())
3412            } else {
3413                None
3414            }
3415        })
3416    }
3417
3418    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
3419        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3420        LOCK.get_or_init(|| Mutex::new(()))
3421            .lock()
3422            .unwrap_or_else(|p| p.into_inner())
3423    }
3424
3425    struct ScopedEnvVar {
3426        name: &'static str,
3427        previous: Option<OsString>,
3428    }
3429
3430    impl ScopedEnvVar {
3431        fn set(name: &'static str, value: &str) -> Self {
3432            let previous = std::env::var_os(name);
3433            // Safety: tests using this helper serialize with env_lock() and
3434            // restore the original value in Drop.
3435            unsafe { std::env::set_var(name, value) };
3436            Self { name, previous }
3437        }
3438
3439        fn remove(name: &'static str) -> Self {
3440            let previous = std::env::var_os(name);
3441            // Safety: tests using this helper serialize with env_lock() and
3442            // restore the original value in Drop.
3443            unsafe { std::env::remove_var(name) };
3444            Self { name, previous }
3445        }
3446    }
3447
3448    impl Drop for ScopedEnvVar {
3449        fn drop(&mut self) {
3450            // Safety: tests using this helper serialize with env_lock().
3451            unsafe {
3452                if let Some(previous) = self.previous.take() {
3453                    std::env::set_var(self.name, previous);
3454                } else {
3455                    std::env::remove_var(self.name);
3456                }
3457            }
3458        }
3459    }
3460
3461    fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
3462        let dir = tempfile::TempDir::new().expect("tempdir");
3463        let custom = dir
3464            .path()
3465            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3466        std::fs::write(&custom, b"").unwrap();
3467        let custom_str = custom.to_string_lossy().into_owned();
3468        let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3469        (dir, bin)
3470    }
3471
3472    fn resolved_runtime_for_test(
3473        provider: ProviderKind,
3474        provider_source: ProviderSource,
3475    ) -> ResolvedRuntimeOptions {
3476        ResolvedRuntimeOptions {
3477            provider,
3478            provider_source,
3479            model: "test-model".to_string(),
3480            api_key: None,
3481            api_key_source: None,
3482            base_url: "http://localhost:8000/v1".to_string(),
3483            auth_mode: None,
3484            insecure_skip_tls_verify: false,
3485            output_mode: None,
3486            log_level: None,
3487            telemetry: false,
3488            approval_policy: None,
3489            sandbox_mode: None,
3490            yolo: None,
3491            verbosity: None,
3492            http_headers: std::collections::BTreeMap::new(),
3493        }
3494    }
3495
3496    #[test]
3497    fn clap_command_definition_is_consistent() {
3498        Cli::command().debug_assert();
3499    }
3500
3501    // Regression for #767: `run_cli` prints the full anyhow chain so users
3502    // see the underlying TOML parser error (line/column, expected token)
3503    // instead of just the top-level "failed to parse config at <path>"
3504    // wrapper. anyhow's bare `Display` impl drops the chain — pin both
3505    // pieces here so a future refactor of the printing path doesn't
3506    // silently regress.
3507    #[test]
3508    fn anyhow_chain_surfaces_toml_parse_cause() {
3509        use anyhow::Context;
3510        let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
3511        let err = Err::<(), _>(inner)
3512            .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
3513            .unwrap_err();
3514
3515        // What `eprintln!("error: {err}")` prints (top context only).
3516        assert_eq!(
3517            err.to_string(),
3518            "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
3519        );
3520
3521        // What the `for cause in err.chain().skip(1)` loop iterates over.
3522        let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
3523        assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
3524    }
3525
3526    #[test]
3527    fn parses_config_command_matrix() {
3528        let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
3529        assert!(matches!(
3530            cli.command,
3531            Some(Commands::Config(ConfigArgs {
3532                command: ConfigCommand::Get { ref key }
3533            })) if key == "provider"
3534        ));
3535
3536        let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
3537        assert!(matches!(
3538            cli.command,
3539            Some(Commands::Config(ConfigArgs {
3540                command: ConfigCommand::Set { ref key, ref value }
3541            })) if key == "model" && value == "deepseek-v4-flash"
3542        ));
3543
3544        let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
3545        assert!(matches!(
3546            cli.command,
3547            Some(Commands::Config(ConfigArgs {
3548                command: ConfigCommand::Unset { ref key }
3549            })) if key == "model"
3550        ));
3551
3552        assert!(matches!(
3553            parse_ok(&["deepseek", "config", "list"]).command,
3554            Some(Commands::Config(ConfigArgs {
3555                command: ConfigCommand::List
3556            }))
3557        ));
3558        assert!(matches!(
3559            parse_ok(&["deepseek", "config", "path"]).command,
3560            Some(Commands::Config(ConfigArgs {
3561                command: ConfigCommand::Path
3562            }))
3563        ));
3564    }
3565
3566    #[test]
3567    fn parses_update_beta_flag() {
3568        let cli = parse_ok(&["codewhale", "update"]);
3569        assert!(matches!(
3570            cli.command,
3571            Some(Commands::Update(UpdateArgs {
3572                beta: false,
3573                check: false,
3574                proxy: None
3575            }))
3576        ));
3577
3578        let cli = parse_ok(&["codewhale", "update", "--beta"]);
3579        assert!(matches!(
3580            cli.command,
3581            Some(Commands::Update(UpdateArgs {
3582                beta: true,
3583                check: false,
3584                proxy: None
3585            }))
3586        ));
3587
3588        let cli = parse_ok(&["codewhale", "update", "--check"]);
3589        assert!(matches!(
3590            cli.command,
3591            Some(Commands::Update(UpdateArgs {
3592                beta: false,
3593                check: true,
3594                proxy: None
3595            }))
3596        ));
3597
3598        let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
3599        let Some(Commands::Update(args)) = cli.command else {
3600            panic!("expected update command");
3601        };
3602        assert!(!args.beta);
3603        assert!(!args.check);
3604        assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
3605    }
3606
3607    #[test]
3608    fn parses_model_command_matrix() {
3609        let cli = parse_ok(&["deepseek", "model", "list"]);
3610        assert!(matches!(
3611            cli.command,
3612            Some(Commands::Model(ModelArgs {
3613                command: ModelCommand::List { provider: None }
3614            }))
3615        ));
3616
3617        let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
3618        assert!(matches!(
3619            cli.command,
3620            Some(Commands::Model(ModelArgs {
3621                command: ModelCommand::List {
3622                    provider: Some(ProviderArg::Openai)
3623                }
3624            }))
3625        ));
3626
3627        let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
3628        assert!(matches!(
3629            cli.command,
3630            Some(Commands::Model(ModelArgs {
3631                command: ModelCommand::Resolve {
3632                    model: Some(ref model),
3633                    provider: None
3634                }
3635            })) if model == "deepseek-v4-flash"
3636        ));
3637
3638        let cli = parse_ok(&[
3639            "deepseek",
3640            "model",
3641            "resolve",
3642            "--provider",
3643            "deepseek",
3644            "deepseek-v4-pro",
3645        ]);
3646        assert!(matches!(
3647            cli.command,
3648            Some(Commands::Model(ModelArgs {
3649                command: ModelCommand::Resolve {
3650                    model: Some(ref model),
3651                    provider: Some(ProviderArg::Deepseek)
3652                }
3653            })) if model == "deepseek-v4-pro"
3654        ));
3655
3656        let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
3657        assert!(matches!(
3658            cli.command,
3659            Some(Commands::Model(ModelArgs {
3660                command: ModelCommand::Set { ref model }
3661            })) if model == "pro"
3662        ));
3663    }
3664
3665    #[test]
3666    fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
3667        assert_eq!(
3668            model_command_provider_hint(None, Some(ProviderKind::Zai)),
3669            Some(ProviderKind::Zai)
3670        );
3671        assert_eq!(
3672            model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
3673            Some(ProviderKind::Minimax)
3674        );
3675        assert_eq!(model_command_provider_hint(None, None), None);
3676
3677        let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
3678        assert_eq!(cli.provider.as_deref(), Some("zai"));
3679        assert!(matches!(
3680            cli.command,
3681            Some(Commands::Model(ModelArgs {
3682                command: ModelCommand::List { provider: None }
3683            }))
3684        ));
3685    }
3686
3687    #[test]
3688    fn parses_thread_command_matrix() {
3689        let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
3690        assert!(matches!(
3691            cli.command,
3692            Some(Commands::Thread(ThreadArgs {
3693                command: ThreadCommand::List {
3694                    all: true,
3695                    limit: Some(50)
3696                }
3697            }))
3698        ));
3699
3700        let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
3701        assert!(matches!(
3702            cli.command,
3703            Some(Commands::Thread(ThreadArgs {
3704                command: ThreadCommand::Read { ref thread_id }
3705            })) if thread_id == "thread-1"
3706        ));
3707
3708        let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
3709        assert!(matches!(
3710            cli.command,
3711            Some(Commands::Thread(ThreadArgs {
3712                command: ThreadCommand::Resume { ref thread_id }
3713            })) if thread_id == "thread-2"
3714        ));
3715
3716        let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
3717        assert!(matches!(
3718            cli.command,
3719            Some(Commands::Thread(ThreadArgs {
3720                command: ThreadCommand::Fork { ref thread_id }
3721            })) if thread_id == "thread-3"
3722        ));
3723
3724        let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
3725        assert!(matches!(
3726            cli.command,
3727            Some(Commands::Thread(ThreadArgs {
3728                command: ThreadCommand::Archive { ref thread_id }
3729            })) if thread_id == "thread-4"
3730        ));
3731
3732        let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
3733        assert!(matches!(
3734            cli.command,
3735            Some(Commands::Thread(ThreadArgs {
3736                command: ThreadCommand::Unarchive { ref thread_id }
3737            })) if thread_id == "thread-5"
3738        ));
3739
3740        let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
3741        assert!(matches!(
3742            cli.command,
3743            Some(Commands::Thread(ThreadArgs {
3744                command: ThreadCommand::SetName {
3745                    ref thread_id,
3746                    ref name
3747                }
3748            })) if thread_id == "thread-6" && name == "My Thread"
3749        ));
3750
3751        let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
3752        assert!(matches!(
3753            cli.command,
3754            Some(Commands::Thread(ThreadArgs {
3755                command: ThreadCommand::ClearName { ref thread_id }
3756            })) if thread_id == "thread-7"
3757        ));
3758    }
3759
3760    #[test]
3761    fn parses_sandbox_app_server_and_completion_matrix() {
3762        let cli = parse_ok(&[
3763            "deepseek",
3764            "sandbox",
3765            "check",
3766            "echo hello",
3767            "--ask",
3768            "on-failure",
3769        ]);
3770        assert!(matches!(
3771            cli.command,
3772            Some(Commands::Sandbox(SandboxArgs {
3773                command: SandboxCommand::Check {
3774                    ref command,
3775                    ask: ApprovalModeArg::OnFailure
3776                }
3777            })) if command == "echo hello"
3778        ));
3779
3780        let cli = parse_ok(&[
3781            "deepseek",
3782            "app-server",
3783            "--host",
3784            "0.0.0.0",
3785            "--port",
3786            "9999",
3787        ]);
3788        assert!(matches!(
3789            cli.command,
3790            Some(Commands::AppServer(AppServerArgs {
3791                host: Some(ref host),
3792                port: Some(9999),
3793                stdio: false,
3794                http: false,
3795                mobile: false,
3796                ..
3797            })) if host == "0.0.0.0"
3798        ));
3799
3800        let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
3801        assert!(matches!(
3802            cli.command,
3803            Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
3804        ));
3805
3806        let cli = parse_ok(&["deepseek", "completion", "bash"]);
3807        assert!(matches!(
3808            cli.command,
3809            Some(Commands::Completion { shell: Shell::Bash })
3810        ));
3811    }
3812
3813    #[test]
3814    fn app_server_transports_are_mutually_exclusive() {
3815        assert!(matches!(
3816            parse_ok(&["deepseek", "app-server", "--http"]).command,
3817            Some(Commands::AppServer(AppServerArgs {
3818                http: true,
3819                mobile: false,
3820                stdio: false,
3821                ..
3822            }))
3823        ));
3824        assert!(matches!(
3825            parse_ok(&["deepseek", "app-server", "--mobile"]).command,
3826            Some(Commands::AppServer(AppServerArgs {
3827                mobile: true,
3828                http: false,
3829                stdio: false,
3830                ..
3831            }))
3832        ));
3833
3834        for argv in [
3835            ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
3836            ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
3837            ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
3838        ] {
3839            let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
3840            assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
3841        }
3842    }
3843
3844    #[test]
3845    fn app_server_qr_requires_mobile() {
3846        let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
3847            .expect_err("--qr without --mobile must fail");
3848        assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
3849        assert!(matches!(
3850            parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
3851            Some(Commands::AppServer(AppServerArgs {
3852                mobile: true,
3853                qr: true,
3854                ..
3855            }))
3856        ));
3857    }
3858
3859    #[test]
3860    fn app_server_serve_passthrough_maps_flags_to_serve() {
3861        let args = AppServerArgs {
3862            http: true,
3863            mobile: false,
3864            stdio: false,
3865            qr: false,
3866            host: Some("127.0.0.1".to_string()),
3867            port: Some(9000),
3868            workers: Some(4),
3869            config: None,
3870            auth_token: Some("tok".to_string()),
3871            insecure_no_auth: true,
3872            cors_origin: vec!["http://localhost:5173".to_string()],
3873        };
3874        let argv = app_server_serve_passthrough(&args);
3875        let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
3876        // app-server's --insecure-no-auth maps onto serve's --insecure.
3877        assert_eq!(
3878            as_str,
3879            vec![
3880                "serve",
3881                "--http",
3882                "--host",
3883                "127.0.0.1",
3884                "--port",
3885                "9000",
3886                "--workers",
3887                "4",
3888                "--cors-origin",
3889                "http://localhost:5173",
3890                "--auth-token",
3891                "tok",
3892                "--insecure",
3893            ]
3894        );
3895    }
3896
3897    #[test]
3898    fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
3899        let args = AppServerArgs {
3900            http: false,
3901            mobile: true,
3902            stdio: false,
3903            qr: true,
3904            host: None,
3905            port: None,
3906            workers: None,
3907            config: None,
3908            auth_token: None,
3909            insecure_no_auth: false,
3910            cors_origin: vec![],
3911        };
3912        let argv = app_server_serve_passthrough(&args);
3913        let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
3914        // No host/port forwarded → serve applies its own --mobile 0.0.0.0 default.
3915        // No auth token is injected from the environment into child argv.
3916        assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
3917    }
3918
3919    #[test]
3920    fn serve_help_documents_forwarded_runtime_modes() {
3921        let help = help_for(&["codewhale", "serve", "--help"]);
3922        for flag in ["--http", "--mobile", "--mcp", "--acp"] {
3923            assert!(
3924                help.contains(flag),
3925                "serve help should document forwarded flag {flag}; help was:\n{help}"
3926            );
3927        }
3928        assert!(help.contains("compatibility"));
3929    }
3930
3931    #[test]
3932    fn parses_direct_tui_command_aliases() {
3933        let cli = parse_ok(&["deepseek", "doctor"]);
3934        assert!(matches!(
3935            cli.command,
3936            Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
3937        ));
3938
3939        let cli = parse_ok(&["deepseek", "models", "--json"]);
3940        assert!(matches!(
3941            cli.command,
3942            Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
3943        ));
3944
3945        let cli = parse_ok(&["deepseek", "resume", "abc123"]);
3946        assert!(matches!(
3947            cli.command,
3948            Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
3949        ));
3950
3951        let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
3952        assert!(matches!(
3953            cli.command,
3954            Some(Commands::Setup(TuiPassthroughArgs { ref args }))
3955                if args == &["--skills", "--local"]
3956        ));
3957
3958        let cli = parse_ok(&["codewhale", "fleet", "init"]);
3959        assert!(cli.prompt.is_empty());
3960        assert!(matches!(
3961            cli.command,
3962            Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
3963        ));
3964
3965        let cli = parse_ok(&[
3966            "codewhale",
3967            "fleet",
3968            "run",
3969            "tasks.json",
3970            "--max-workers",
3971            "2",
3972        ]);
3973        assert!(cli.prompt.is_empty());
3974        assert!(matches!(
3975            cli.command,
3976            Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
3977                if args == &["run", "tasks.json", "--max-workers", "2"]
3978        ));
3979
3980        let cli = parse_ok(&[
3981            "codewhale",
3982            "workflow",
3983            "run",
3984            "stopship",
3985            "--fleet",
3986            "stopship",
3987            "--runtime",
3988            "tmux",
3989            "--issue",
3990            "4375",
3991        ]);
3992        assert!(matches!(
3993            cli.command,
3994            Some(Commands::Workflow(WorkflowArgs {
3995                command: WorkflowCommand::Run {
3996                    ref workflow,
3997                    ref fleet,
3998                    ref runtime,
3999                    ref issue,
4000                    ..
4001                }
4002            })) if workflow == "stopship"
4003                && fleet == "stopship"
4004                && runtime == "tmux"
4005                && issue.as_deref() == Some("4375")
4006        ));
4007    }
4008
4009    #[test]
4010    fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
4011        let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
4012        assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
4013        assert_eq!(
4014            top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
4015                .expect("built-in Exec provider"),
4016            Some(ProviderKind::Openrouter)
4017        );
4018
4019        for (provider, command) in [
4020            ("qianfan", vec!["exec", "Reply OK"]),
4021            ("lm-studio", vec!["exec", "Reply OK"]),
4022            ("lm-studio", vec!["fleet", "status"]),
4023        ] {
4024            let argv = std::iter::once("codewhale")
4025                .chain(["--provider", provider])
4026                .chain(command.iter().copied())
4027                .collect::<Vec<_>>();
4028            let cli = parse_ok(&argv);
4029            assert_eq!(cli.provider.as_deref(), Some(provider));
4030            assert_eq!(
4031                top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
4032                    .expect("raw TUI provider"),
4033                None,
4034                "{argv:?} should defer the raw provider id to the TUI"
4035            );
4036        }
4037    }
4038
4039    #[test]
4040    fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
4041        let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
4042        let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
4043            .expect_err("model registry commands still require a built-in provider");
4044        assert!(
4045            err.to_string()
4046                .contains("configured custom providers are accepted only by exec and fleet")
4047        );
4048
4049        let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
4050            .expect_err("auth keeps enum-only provider validation");
4051        assert_eq!(err.kind(), ErrorKind::InvalidValue);
4052
4053        let err = Cli::try_parse_from([
4054            "codewhale",
4055            "--provider",
4056            "../../lm-studio",
4057            "exec",
4058            "Reply OK",
4059        ])
4060        .expect_err("provider ids must stay simple tokens");
4061        assert!(
4062            err.to_string()
4063                .contains("provider must be a simple identifier")
4064        );
4065    }
4066
4067    #[test]
4068    fn raw_provider_dispatch_defers_dynamic_config_to_the_tui() {
4069        let dir = tempfile::TempDir::new().expect("tempdir");
4070        let config_path = dir.path().join("config.toml");
4071        std::fs::write(
4072            &config_path,
4073            r#"provider = "lm-studio"
4074
4075[providers.lm-studio]
4076kind = "openai-compatible"
4077base_url = "http://127.0.0.1:1234/v1"
4078model = "qwen-2.5-7b"
4079"#,
4080        )
4081        .expect("custom provider config fixture");
4082        assert!(
4083            ConfigStore::load(Some(config_path.clone())).is_err(),
4084            "the enum-backed dispatcher store must not be the owner of dynamic provider config"
4085        );
4086
4087        let config = config_path.to_string_lossy().into_owned();
4088        let cli = parse_ok(&[
4089            "codewhale",
4090            "--config",
4091            &config,
4092            "--provider",
4093            "lm-studio",
4094            "exec",
4095            "Reply OK",
4096        ]);
4097        let prepared = prepare_raw_provider_tui_dispatch(
4098            &cli,
4099            cli.command.as_ref(),
4100            &CliRuntimeOverrides::default(),
4101        )
4102        .expect("prepare raw provider dispatch")
4103        .expect("Exec with a raw provider should bypass dispatcher config resolution");
4104        assert_eq!(prepared.1, ["exec", "Reply OK"].map(str::to_string));
4105    }
4106
4107    #[test]
4108    fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
4109        let cli = parse_ok(&[
4110            "codewhale",
4111            "lane-log-proxy",
4112            "--log-path",
4113            "/tmp/lane.ndjson",
4114            "--receipt-path",
4115            "/tmp/lane.exit.json",
4116            "--receipt-tmp-path",
4117            "/tmp/lane.exit.json.tmp",
4118            "--environment-path",
4119            "/tmp/lane.env.json",
4120            "--lane-id",
4121            "lane-proof",
4122            "--",
4123            "/bin/echo",
4124            "--child-flag",
4125            "hello",
4126        ]);
4127        let (proxy, command) = split_lane_log_proxy_command(cli.command);
4128        assert!(command.is_none());
4129        let proxy = proxy.expect("proxy args");
4130        assert_eq!(proxy.lane_id, "lane-proof");
4131        assert_eq!(
4132            proxy.command,
4133            ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
4134        );
4135
4136        let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
4137        let (proxy, command) = split_lane_log_proxy_command(cli.command);
4138        assert!(proxy.is_none());
4139        assert!(matches!(
4140            command,
4141            Some(Commands::Lane(LaneArgs {
4142                command: LaneCommand::List { json: true }
4143            }))
4144        ));
4145    }
4146
4147    #[test]
4148    fn short_workflow_names_do_not_resolve_historical_v0868_files() {
4149        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4150            .join("..")
4151            .join("..");
4152        let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
4153        assert!(candidates.iter().all(|path| {
4154            !path
4155                .file_name()
4156                .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
4157        }));
4158        assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
4159
4160        let historical = resolve_workflow_source_path(
4161            "workflows/v0868_issue_sweep.workflow.js",
4162            None,
4163            &workspace,
4164        )
4165        .expect("explicit historical workflow path");
4166        assert!(historical.ends_with("workflows/v0868_issue_sweep.workflow.js"));
4167    }
4168
4169    #[test]
4170    fn workflow_run_resolves_stopship_alias_and_payload() {
4171        let _lock = env_lock();
4172        let (_dir, _tui) = install_fake_tui_binary();
4173        let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
4174        let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
4175        let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
4176        let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
4177        let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
4178        let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4179            .join("..")
4180            .join("..");
4181        let cli = parse_ok(&[
4182            "codewhale",
4183            "--profile",
4184            "workflow-profile",
4185            "--model",
4186            "explicit-workflow-model",
4187            "--api-key",
4188            "explicit-profile-key",
4189            "--workspace",
4190            workspace.to_str().expect("workspace UTF-8"),
4191        ]);
4192        let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
4193        let source = resolve_workflow_source_path("stopship", None, &workspace)
4194            .expect("stopship workflow source");
4195        assert!(source.ends_with("workflows/stopship.workflow.js"));
4196
4197        let process = workflow_exec_command(WorkflowExecSpec {
4198            cli: &cli,
4199            resolved_runtime: &resolved,
4200            config_path: &workspace.join("config.toml"),
4201            source_root: &workspace,
4202            source_path: &source,
4203            workflow: "stopship",
4204            fleet: "stopship",
4205            issue: Some("4375"),
4206            goal: Some("fix stopship"),
4207            token_budget: Some(25_000),
4208            verify: true,
4209        })
4210        .expect("command");
4211        let joined = process.command.join("\n");
4212        assert!(joined.contains("workflow-tool"));
4213        assert!(joined.contains("explicit-workflow-command"));
4214        assert!(joined.contains("--input-json"));
4215        assert!(!process.command.iter().any(|arg| arg == "exec"));
4216        assert!(!process.command.iter().any(|arg| arg == "--workspace"));
4217        assert!(
4218            process
4219                .command
4220                .windows(2)
4221                .any(|pair| pair == ["--profile", "workflow-profile"])
4222        );
4223        assert!(!joined.contains("Run the CodeWhale"));
4224        assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
4225        assert!(joined.contains("\"fleet\":\"stopship\""));
4226        assert!(joined.contains("\"issue\":\"4375\""));
4227        assert!(joined.contains("\"token_budget\":25000"));
4228        assert!(joined.contains("\"verify\":true"));
4229        assert!(
4230            process.environment.iter().any(|(key, value)| {
4231                key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
4232            })
4233        );
4234        assert!(
4235            !process
4236                .environment
4237                .iter()
4238                .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
4239        );
4240        assert!(
4241            !process
4242                .environment
4243                .iter()
4244                .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
4245        );
4246        assert!(
4247            !process
4248                .environment
4249                .iter()
4250                .any(|(key, _)| key == "DEEPSEEK_API_KEY")
4251        );
4252        assert!(process.environment.iter().any(|(key, value)| {
4253            key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
4254        }));
4255        assert!(
4256            !process
4257                .command
4258                .iter()
4259                .any(|argument| argument.contains("explicit-profile-key"))
4260        );
4261        assert!(
4262            process
4263                .environment
4264                .iter()
4265                .all(|(_, value)| value != "test-model")
4266        );
4267    }
4268
4269    #[test]
4270    fn exec_keeps_global_looking_flags_as_passthrough_args() {
4271        let cli = parse_ok(&[
4272            "codewhale",
4273            "exec",
4274            "--provider",
4275            "definitely-not-a-provider",
4276            "Reply OK",
4277        ]);
4278
4279        let Some(Commands::Exec(args)) = cli.command else {
4280            panic!("expected exec command");
4281        };
4282
4283        assert_eq!(
4284            args.args,
4285            vec![
4286                "--provider".to_string(),
4287                "definitely-not-a-provider".to_string(),
4288                "Reply OK".to_string(),
4289            ]
4290        );
4291    }
4292
4293    #[test]
4294    fn exec_rejects_provider_after_subcommand() {
4295        let args = vec![
4296            "--provider".to_string(),
4297            "definitely-not-a-provider".to_string(),
4298            "Reply OK".to_string(),
4299        ];
4300
4301        let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
4302
4303        assert!(
4304            err.to_string()
4305                .contains("--provider must be placed before `exec`")
4306        );
4307    }
4308
4309    #[test]
4310    fn exec_rejects_equals_form_provider_after_subcommand() {
4311        let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
4312
4313        let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
4314
4315        assert!(
4316            err.to_string()
4317                .contains("--provider must be placed before `exec`")
4318        );
4319    }
4320
4321    #[test]
4322    fn exec_allows_documented_forwarded_flags() {
4323        let args = vec![
4324            "--auto".to_string(),
4325            "--output-format".to_string(),
4326            "stream-json".to_string(),
4327            "fix tests".to_string(),
4328        ];
4329
4330        reject_exec_global_flags(&args).expect("documented exec flags should pass");
4331    }
4332
4333    #[test]
4334    fn exec_allows_literal_prompt_flags_after_separator() {
4335        let args = vec![
4336            "--".to_string(),
4337            "--provider".to_string(),
4338            "is literal prompt text".to_string(),
4339        ];
4340
4341        reject_exec_global_flags(&args).expect("separator should stop global flag validation");
4342    }
4343
4344    #[test]
4345    fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
4346        assert!(should_pick_resume_in_dispatcher(
4347            &["resume".to_string()],
4348            true
4349        ));
4350        assert!(!should_pick_resume_in_dispatcher(
4351            &["resume".to_string(), "--last".to_string()],
4352            true
4353        ));
4354        assert!(!should_pick_resume_in_dispatcher(
4355            &["resume".to_string(), "abc123".to_string()],
4356            true
4357        ));
4358        assert!(!should_pick_resume_in_dispatcher(
4359            &["resume".to_string()],
4360            false
4361        ));
4362    }
4363
4364    #[test]
4365    fn deepseek_login_uses_isolated_file_store_and_preserves_tui_defaults() {
4366        let _lock = env_lock();
4367        let dir = tempfile::TempDir::new().expect("tempdir");
4368        let codewhale_home = dir.path().join("codewhale-home");
4369        let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
4370        let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
4371        let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
4372        let path = codewhale_home.join("config.toml");
4373        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4374        let secrets = Secrets::auto_detect();
4375
4376        run_login_command_with_secrets(
4377            &mut store,
4378            LoginArgs {
4379                provider: Some(ProviderArg::Deepseek),
4380                api_key: Some("sk-test".to_string()),
4381            },
4382            &secrets,
4383        )
4384        .expect("login should persist credential");
4385
4386        assert!(store.config.api_key.is_none());
4387        assert!(store.config.providers.deepseek.api_key.is_none());
4388        assert_eq!(
4389            store.config.default_text_model.as_deref(),
4390            Some("deepseek-v4-pro")
4391        );
4392        let saved = std::fs::read_to_string(&path).expect("config should be written");
4393        assert!(!saved.contains("sk-test"), "{saved}");
4394        assert!(
4395            !saved
4396                .lines()
4397                .any(|line| line.trim_start().starts_with("api_key ="))
4398        );
4399        assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
4400        assert_eq!(
4401            secrets.get("deepseek").expect("read secret").as_deref(),
4402            Some("sk-test")
4403        );
4404    }
4405
4406    #[test]
4407    fn parses_auth_subcommand_matrix() {
4408        let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
4409        assert!(matches!(
4410            cli.command,
4411            Some(Commands::Auth(AuthArgs {
4412                command: AuthCommand::XaiDevice
4413            }))
4414        ));
4415
4416        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
4417        assert!(matches!(
4418            cli.command,
4419            Some(Commands::Auth(AuthArgs {
4420                command: AuthCommand::Set {
4421                    provider: ProviderArg::Deepseek,
4422                    api_key: None,
4423                    api_key_stdin: false,
4424                }
4425            }))
4426        ));
4427
4428        let cli = parse_ok(&[
4429            "deepseek",
4430            "auth",
4431            "set",
4432            "--provider",
4433            "openrouter",
4434            "--api-key-stdin",
4435        ]);
4436        assert!(matches!(
4437            cli.command,
4438            Some(Commands::Auth(AuthArgs {
4439                command: AuthCommand::Set {
4440                    provider: ProviderArg::Openrouter,
4441                    api_key: None,
4442                    api_key_stdin: true,
4443                }
4444            }))
4445        ));
4446
4447        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
4448        assert!(matches!(
4449            cli.command,
4450            Some(Commands::Auth(AuthArgs {
4451                command: AuthCommand::Get {
4452                    provider: ProviderArg::Novita
4453                }
4454            }))
4455        ));
4456
4457        let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
4458        assert!(matches!(
4459            cli.command,
4460            Some(Commands::Auth(AuthArgs {
4461                command: AuthCommand::Clear {
4462                    provider: ProviderArg::NvidiaNim
4463                }
4464            }))
4465        ));
4466
4467        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
4468        assert!(matches!(
4469            cli.command,
4470            Some(Commands::Auth(AuthArgs {
4471                command: AuthCommand::Set {
4472                    provider: ProviderArg::Fireworks,
4473                    api_key: None,
4474                    api_key_stdin: false,
4475                }
4476            }))
4477        ));
4478
4479        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
4480        assert!(matches!(
4481            cli.command,
4482            Some(Commands::Auth(AuthArgs {
4483                command: AuthCommand::Set {
4484                    provider: ProviderArg::Siliconflow,
4485                    api_key: None,
4486                    api_key_stdin: false,
4487                }
4488            }))
4489        ));
4490
4491        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
4492        assert!(matches!(
4493            cli.command,
4494            Some(Commands::Auth(AuthArgs {
4495                command: AuthCommand::Set {
4496                    provider: ProviderArg::Arcee,
4497                    api_key: None,
4498                    api_key_stdin: false,
4499                }
4500            }))
4501        ));
4502
4503        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
4504        assert!(matches!(
4505            cli.command,
4506            Some(Commands::Auth(AuthArgs {
4507                command: AuthCommand::Set {
4508                    provider: ProviderArg::Moonshot,
4509                    api_key: None,
4510                    api_key_stdin: false,
4511                }
4512            }))
4513        ));
4514
4515        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
4516        assert!(matches!(
4517            cli.command,
4518            Some(Commands::Auth(AuthArgs {
4519                command: AuthCommand::Set {
4520                    provider: ProviderArg::WanjieArk,
4521                    api_key: None,
4522                    api_key_stdin: false,
4523                }
4524            }))
4525        ));
4526
4527        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
4528        assert!(matches!(
4529            cli.command,
4530            Some(Commands::Auth(AuthArgs {
4531                command: AuthCommand::Get {
4532                    provider: ProviderArg::Sglang
4533                }
4534            }))
4535        ));
4536
4537        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
4538        assert!(matches!(
4539            cli.command,
4540            Some(Commands::Auth(AuthArgs {
4541                command: AuthCommand::Get {
4542                    provider: ProviderArg::Vllm
4543                }
4544            }))
4545        ));
4546
4547        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
4548        assert!(matches!(
4549            cli.command,
4550            Some(Commands::Auth(AuthArgs {
4551                command: AuthCommand::Set {
4552                    provider: ProviderArg::Ollama,
4553                    api_key: None,
4554                    api_key_stdin: false,
4555                }
4556            }))
4557        ));
4558
4559        let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
4560        assert!(matches!(
4561            cli.command,
4562            Some(Commands::Auth(AuthArgs {
4563                command: AuthCommand::Status {
4564                    provider: Some(ProviderArg::OpenaiCodex)
4565                }
4566            }))
4567        ));
4568
4569        for (provider, expected) in [
4570            ("anthropic", ProviderArg::Anthropic),
4571            ("openmodel", ProviderArg::Openmodel),
4572            ("open-model", ProviderArg::Openmodel),
4573            ("zai", ProviderArg::Zai),
4574            ("stepfun", ProviderArg::Stepfun),
4575            ("minimax", ProviderArg::Minimax),
4576            ("minimax-anthropic", ProviderArg::MinimaxAnthropic),
4577            ("minimax_anthropic", ProviderArg::MinimaxAnthropic),
4578            ("deepinfra", ProviderArg::Deepinfra),
4579            ("deep-infra", ProviderArg::Deepinfra),
4580            ("siliconflow-cn", ProviderArg::SiliconflowCn),
4581            ("siliconflow-CN", ProviderArg::SiliconflowCn),
4582            ("siliconflow_china", ProviderArg::SiliconflowCn),
4583        ] {
4584            let cli = parse_ok(&[
4585                "deepseek",
4586                "auth",
4587                "set",
4588                "--provider",
4589                provider,
4590                "--api-key-stdin",
4591            ]);
4592            assert!(matches!(
4593                cli.command,
4594                Some(Commands::Auth(AuthArgs {
4595                    command: AuthCommand::Set {
4596                        provider,
4597                        api_key: None,
4598                        api_key_stdin: true,
4599                    }
4600                })) if provider == expected
4601            ));
4602        }
4603
4604        let cli = parse_ok(&["deepseek", "auth", "list"]);
4605        assert!(matches!(
4606            cli.command,
4607            Some(Commands::Auth(AuthArgs {
4608                command: AuthCommand::List
4609            }))
4610        ));
4611
4612        let cli = parse_ok(&["deepseek", "auth", "migrate"]);
4613        assert!(matches!(
4614            cli.command,
4615            Some(Commands::Auth(AuthArgs {
4616                command: AuthCommand::Migrate { dry_run: false }
4617            }))
4618        ));
4619
4620        let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
4621        assert!(matches!(
4622            cli.command,
4623            Some(Commands::Auth(AuthArgs {
4624                command: AuthCommand::Migrate { dry_run: true }
4625            }))
4626        ));
4627    }
4628
4629    #[test]
4630    fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
4631        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
4632        use std::sync::Arc;
4633
4634        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4635        let path = std::env::temp_dir().join(format!(
4636            "deepseek-cli-auth-set-test-{}-{nanos}.toml",
4637            std::process::id()
4638        ));
4639        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4640        let inner = Arc::new(InMemoryKeyringStore::new());
4641        let secrets = Secrets::new(inner.clone());
4642
4643        run_auth_command_with_secrets(
4644            &mut store,
4645            AuthCommand::Set {
4646                provider: ProviderArg::Deepseek,
4647                api_key: Some("sk-keyring".to_string()),
4648                api_key_stdin: false,
4649            },
4650            &secrets,
4651        )
4652        .expect("set should succeed");
4653
4654        assert!(store.config.api_key.is_none());
4655        assert!(store.config.providers.deepseek.api_key.is_none());
4656        let saved = std::fs::read_to_string(&path).unwrap_or_default();
4657        assert!(!saved.contains("sk-keyring"), "{saved}");
4658        assert!(
4659            !saved
4660                .lines()
4661                .any(|line| line.trim_start().starts_with("api_key ="))
4662        );
4663        assert_eq!(
4664            inner.get("deepseek").unwrap().as_deref(),
4665            Some("sk-keyring")
4666        );
4667
4668        let _ = std::fs::remove_file(path);
4669    }
4670
4671    #[test]
4672    fn auth_set_uses_plaintext_config_only_when_secret_store_write_fails() {
4673        use codewhale_secrets::{KeyringStore, SecretsError};
4674        use std::sync::Arc;
4675
4676        struct FailingStore;
4677
4678        impl KeyringStore for FailingStore {
4679            fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
4680                Ok(None)
4681            }
4682
4683            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
4684                Err(SecretsError::Keyring("test write failure".to_string()))
4685            }
4686
4687            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
4688                Ok(())
4689            }
4690
4691            fn backend_name(&self) -> &'static str {
4692                "failing test store"
4693            }
4694        }
4695
4696        let dir = tempfile::TempDir::new().expect("tempdir");
4697        let path = dir.path().join("config.toml");
4698        let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
4699        let secrets = Secrets::new(Arc::new(FailingStore));
4700
4701        run_auth_command_with_secrets(
4702            &mut store,
4703            AuthCommand::Set {
4704                provider: ProviderArg::Openrouter,
4705                api_key: Some("fallback-test-credential".to_string()),
4706                api_key_stdin: false,
4707            },
4708            &secrets,
4709        )
4710        .expect("config fallback");
4711
4712        assert_eq!(
4713            store.config.providers.openrouter.api_key.as_deref(),
4714            Some("fallback-test-credential")
4715        );
4716        let saved = std::fs::read_to_string(path).expect("config fallback file");
4717        assert!(saved.contains("fallback-test-credential"));
4718    }
4719
4720    #[test]
4721    fn auth_set_provider_key_does_not_switch_active_provider() {
4722        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4723        let path = std::env::temp_dir().join(format!(
4724            "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
4725            std::process::id()
4726        ));
4727        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4728        store.config.provider = ProviderKind::Deepseek;
4729        let secrets = no_keyring_secrets();
4730
4731        run_auth_command_with_secrets(
4732            &mut store,
4733            AuthCommand::Set {
4734                provider: ProviderArg::Arcee,
4735                api_key: Some("arcee-key".to_string()),
4736                api_key_stdin: false,
4737            },
4738            &secrets,
4739        )
4740        .expect("set should succeed");
4741
4742        assert_eq!(store.config.provider, ProviderKind::Deepseek);
4743        assert!(store.config.providers.arcee.api_key.is_none());
4744        assert_eq!(
4745            store.config.providers.arcee.auth_mode.as_deref(),
4746            Some("api_key")
4747        );
4748
4749        let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
4750        assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
4751        assert!(reloaded.config.providers.arcee.api_key.is_none());
4752        assert_eq!(
4753            reloaded.config.providers.arcee.auth_mode.as_deref(),
4754            Some("api_key")
4755        );
4756
4757        let _ = std::fs::remove_file(path);
4758    }
4759
4760    #[test]
4761    fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
4762        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4763        let path = std::env::temp_dir().join(format!(
4764            "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
4765            std::process::id()
4766        ));
4767        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4768        store.config.provider = ProviderKind::Deepseek;
4769        let secrets = no_keyring_secrets();
4770
4771        run_auth_command_with_secrets(
4772            &mut store,
4773            AuthCommand::Set {
4774                provider: ProviderArg::Ollama,
4775                api_key: None,
4776                api_key_stdin: false,
4777            },
4778            &secrets,
4779        )
4780        .expect("ollama auth set should not require a key");
4781
4782        assert_eq!(store.config.provider, ProviderKind::Deepseek);
4783        assert_eq!(
4784            store.config.providers.ollama.base_url.as_deref(),
4785            Some("http://localhost:11434/v1")
4786        );
4787        assert_eq!(store.config.providers.ollama.api_key, None);
4788
4789        let _ = std::fs::remove_file(path);
4790    }
4791
4792    #[test]
4793    fn auth_clear_removes_from_config() {
4794        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
4795        use std::sync::Arc;
4796
4797        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4798        let path = std::env::temp_dir().join(format!(
4799            "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
4800            std::process::id()
4801        ));
4802        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4803        store.config.api_key = Some("sk-stale".to_string());
4804        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
4805        store.save().unwrap();
4806
4807        let inner = Arc::new(InMemoryKeyringStore::new());
4808        inner.set("deepseek", "sk-stale").unwrap();
4809        let secrets = Secrets::new(inner.clone());
4810
4811        run_auth_command_with_secrets(
4812            &mut store,
4813            AuthCommand::Clear {
4814                provider: ProviderArg::Deepseek,
4815            },
4816            &secrets,
4817        )
4818        .expect("clear should succeed");
4819
4820        assert!(store.config.api_key.is_none());
4821        assert!(store.config.providers.deepseek.api_key.is_none());
4822        assert_eq!(inner.get("deepseek").unwrap(), None);
4823
4824        let _ = std::fs::remove_file(path);
4825    }
4826
4827    #[test]
4828    fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
4829        use codewhale_secrets::{KeyringStore, SecretsError};
4830        use std::sync::{Arc, Mutex};
4831
4832        #[derive(Default)]
4833        struct RecordingStore {
4834            gets: Mutex<Vec<String>>,
4835        }
4836
4837        impl KeyringStore for RecordingStore {
4838            fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
4839                self.gets.lock().unwrap().push(key.to_string());
4840                Ok(None)
4841            }
4842
4843            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
4844                Ok(())
4845            }
4846
4847            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
4848                Ok(())
4849            }
4850
4851            fn backend_name(&self) -> &'static str {
4852                "recording"
4853            }
4854        }
4855
4856        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4857        let path = std::env::temp_dir().join(format!(
4858            "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
4859            std::process::id()
4860        ));
4861        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4862        store.config.provider = ProviderKind::Deepseek;
4863        let inner = Arc::new(RecordingStore::default());
4864        let secrets = Secrets::new(inner.clone());
4865
4866        run_auth_command_with_secrets(
4867            &mut store,
4868            AuthCommand::Status {
4869                provider: Some(ProviderArg::Deepseek),
4870            },
4871            &secrets,
4872        )
4873        .expect("status should succeed");
4874        run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
4875            .expect("list should succeed");
4876
4877        let probed = inner.gets.lock().unwrap();
4878        // Scoped status probes only the requested provider.
4879        assert_eq!(probed[0], "deepseek");
4880        // List now probes all providers (not just active) to fix the
4881        // stale keyring-only-for-active-provider bug.
4882        assert!(probed.len() > 1, "list should probe all providers");
4883        assert!(
4884            ProviderKind::ALL
4885                .iter()
4886                .all(|p| probed.contains(&provider_slot(*p).to_string())),
4887            "every known provider should be probed by auth list: {:?}",
4888            *probed
4889        );
4890
4891        let _ = std::fs::remove_file(path);
4892    }
4893
4894    #[test]
4895    fn auth_status_reports_all_active_provider_sources_with_last4() {
4896        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
4897        use std::sync::Arc;
4898
4899        let _lock = env_lock();
4900        let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
4901
4902        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4903        let path = std::env::temp_dir().join(format!(
4904            "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
4905            std::process::id()
4906        ));
4907        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4908        store.config.provider = ProviderKind::Deepseek;
4909        store.config.api_key = Some("sk-config-3333".to_string());
4910        store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
4911
4912        let inner = Arc::new(InMemoryKeyringStore::new());
4913        inner.set("deepseek", "sk-keyring-2222").unwrap();
4914        let secrets = Secrets::new(inner);
4915
4916        let output =
4917            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
4918
4919        assert!(output.contains("provider: deepseek"));
4920        assert!(output.contains("active source: config (last4: ...3333)"));
4921        assert!(output.contains("lookup order: config -> secret store -> env"));
4922        assert!(output.contains("config file: "));
4923        assert!(output.contains("set, last4: ...3333"));
4924        assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
4925        assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
4926        assert!(!output.contains("sk-config-3333"));
4927        assert!(!output.contains("sk-keyring-2222"));
4928        assert!(!output.contains("sk-env-1111"));
4929
4930        let _ = std::fs::remove_file(path);
4931    }
4932
4933    #[test]
4934    fn auth_status_all_providers_lists_every_known_provider() {
4935        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
4936        use std::sync::Arc;
4937
4938        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4939        let path = std::env::temp_dir().join(format!(
4940            "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
4941            std::process::id()
4942        ));
4943        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4944        store.config.provider = ProviderKind::Deepseek;
4945        store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
4946
4947        let inner = Arc::new(InMemoryKeyringStore::new());
4948        inner.set("openrouter", "sk-or-test5678").unwrap();
4949        let secrets = Secrets::new(inner);
4950
4951        let output = auth_status_all_providers(&store, &secrets).join("\n");
4952
4953        // Should list all known providers
4954        assert!(output.contains("deepseek"));
4955        assert!(output.contains("arcee"));
4956        assert!(output.contains("openrouter"));
4957        assert!(output.contains("huggingface"));
4958        assert!(output.contains("ollama"));
4959
4960        // Active provider should be marked
4961        assert!(output.contains("deepseek") && output.contains("*"));
4962
4963        // Arcee should show config source
4964        assert!(output.contains("config"));
4965
4966        // Should NOT leak raw keys
4967        assert!(!output.contains("sk-arcee-test1234"));
4968        assert!(!output.contains("sk-or-test5678"));
4969
4970        let _ = std::fs::remove_file(path);
4971    }
4972
4973    #[test]
4974    fn auth_status_openai_codex_reports_codex_oauth_file() {
4975        use codewhale_secrets::InMemoryKeyringStore;
4976        use std::sync::Arc;
4977
4978        let _lock = env_lock();
4979        let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
4980        let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
4981
4982        let dir = tempfile::TempDir::new().expect("tempdir");
4983        let config_path = dir.path().join("config.toml");
4984        let auth_path = dir.path().join("auth.json");
4985        std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
4986            .expect("write auth file");
4987        let auth_path_str = auth_path.to_string_lossy().into_owned();
4988        let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
4989
4990        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
4991        store.config.provider = ProviderKind::OpenaiCodex;
4992        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
4993
4994        let output =
4995            auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
4996
4997        assert!(output.contains("provider: openai-codex"));
4998        assert!(output.contains("auth mode: codex_oauth"));
4999        assert!(output.contains("active source: Codex OAuth file"));
5000        assert!(output.contains("lookup order: env -> Codex OAuth file"));
5001        assert!(output.contains(&format!(
5002            "Codex OAuth file: {} (present)",
5003            auth_path.display()
5004        )));
5005        assert!(!output.contains("secret-token"));
5006    }
5007
5008    #[test]
5009    fn auth_list_treats_openai_codex_oauth_file_as_active() {
5010        use codewhale_secrets::InMemoryKeyringStore;
5011        use std::sync::Arc;
5012
5013        let _lock = env_lock();
5014        let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
5015        let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
5016
5017        let dir = tempfile::TempDir::new().expect("tempdir");
5018        let config_path = dir.path().join("config.toml");
5019        let auth_path = dir.path().join("auth.json");
5020        std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
5021            .expect("write auth file");
5022        let auth_path_str = auth_path.to_string_lossy().into_owned();
5023        let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
5024
5025        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
5026        store.config.provider = ProviderKind::OpenaiCodex;
5027        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
5028
5029        let output = auth_list_lines(&store, &secrets).join("\n");
5030        let row = output
5031            .lines()
5032            .find(|line| line.starts_with("openai-codex"))
5033            .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
5034        assert!(row.ends_with("oauth"), "{row}");
5035        assert!(!output.contains("secret-token"));
5036    }
5037
5038    #[test]
5039    fn auth_status_scoped_provider_shows_detailed_info() {
5040        use codewhale_secrets::InMemoryKeyringStore;
5041        use std::sync::Arc;
5042
5043        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5044        let path = std::env::temp_dir().join(format!(
5045            "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
5046            std::process::id()
5047        ));
5048        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5049        store.config.provider = ProviderKind::Deepseek;
5050        store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
5051
5052        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
5053
5054        let output =
5055            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
5056
5057        assert!(output.contains("provider: arcee"));
5058        assert!(output.contains("active source: config (last4: ...9999)"));
5059        assert!(output.contains("route:"));
5060        assert!(output.contains("model:"));
5061        assert!(!output.contains("sk-arcee-9999"));
5062
5063        let _ = std::fs::remove_file(path);
5064    }
5065
5066    #[test]
5067    fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
5068        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
5069        use std::sync::Arc;
5070
5071        // Runtime resolution reads process-global provider environment overrides.
5072        // Serialize with the tests that temporarily set those overrides so this
5073        // in-memory DeepSeek credential is not resolved against another provider.
5074        let _lock = env_lock();
5075        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5076        let path = std::env::temp_dir().join(format!(
5077            "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
5078            std::process::id()
5079        ));
5080        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5081        let inner = Arc::new(InMemoryKeyringStore::new());
5082        inner.set("deepseek", "ring-key").unwrap();
5083        let secrets = Secrets::new(inner);
5084
5085        let resolved = resolve_runtime_for_dispatch_with_secrets(
5086            &mut store,
5087            &CliRuntimeOverrides::default(),
5088            &secrets,
5089        );
5090
5091        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
5092        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
5093        assert!(store.config.api_key.is_none());
5094        assert!(store.config.providers.deepseek.api_key.is_none());
5095        assert!(
5096            !path.exists(),
5097            "dispatch must not create config from a stored key"
5098        );
5099
5100        let resolved_again = resolve_runtime_for_dispatch_with_secrets(
5101            &mut store,
5102            &CliRuntimeOverrides::default(),
5103            &secrets,
5104        );
5105        assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
5106        assert_eq!(
5107            resolved_again.api_key_source,
5108            Some(RuntimeApiKeySource::Keyring)
5109        );
5110        assert!(
5111            !path.exists(),
5112            "repeat dispatch must remain credential-file free"
5113        );
5114
5115        let _ = std::fs::remove_file(path);
5116    }
5117
5118    #[test]
5119    fn logout_removes_plaintext_provider_keys() {
5120        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5121        let path = std::env::temp_dir().join(format!(
5122            "deepseek-cli-logout-test-{}-{nanos}.toml",
5123            std::process::id()
5124        ));
5125        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5126        store.config.api_key = Some("sk-stale".to_string());
5127        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
5128        store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
5129        store.save().unwrap();
5130
5131        let secrets = no_keyring_secrets();
5132
5133        run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
5134
5135        assert!(store.config.api_key.is_none());
5136        assert!(store.config.providers.deepseek.api_key.is_none());
5137        assert!(store.config.providers.fireworks.api_key.is_none());
5138
5139        let _ = std::fs::remove_file(path);
5140    }
5141
5142    #[test]
5143    fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
5144        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
5145        use std::sync::Arc;
5146
5147        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5148        let path = std::env::temp_dir().join(format!(
5149            "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
5150            std::process::id()
5151        ));
5152        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5153        store.config.api_key = Some("sk-deep".to_string());
5154        store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
5155        store.config.providers.openrouter.api_key = Some("or-key".to_string());
5156        store.config.providers.novita.api_key = Some("nv-key".to_string());
5157        store.save().unwrap();
5158
5159        let inner = Arc::new(InMemoryKeyringStore::new());
5160        let secrets = Secrets::new(inner.clone());
5161
5162        run_auth_command_with_secrets(
5163            &mut store,
5164            AuthCommand::Migrate { dry_run: false },
5165            &secrets,
5166        )
5167        .expect("migrate should succeed");
5168
5169        assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
5170        assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
5171        assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
5172
5173        // Config file must no longer contain the api keys.
5174        assert!(store.config.api_key.is_none());
5175        assert!(store.config.providers.deepseek.api_key.is_none());
5176        assert!(store.config.providers.openrouter.api_key.is_none());
5177        assert!(store.config.providers.novita.api_key.is_none());
5178
5179        let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
5180        assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
5181        assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
5182        assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
5183
5184        let backup_path = path.with_file_name(format!(
5185            "{}.bak",
5186            path.file_name().unwrap_or_default().to_string_lossy()
5187        ));
5188        let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
5189        assert!(
5190            !backup.contains("sk-deep"),
5191            "plaintext leaked in backup: {backup}"
5192        );
5193        assert!(
5194            !backup.contains("or-key"),
5195            "plaintext leaked in backup: {backup}"
5196        );
5197        assert!(
5198            !backup.contains("nv-key"),
5199            "plaintext leaked in backup: {backup}"
5200        );
5201
5202        let resolved = resolve_runtime_for_dispatch_with_secrets(
5203            &mut store,
5204            &CliRuntimeOverrides::default(),
5205            &secrets,
5206        );
5207        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
5208        let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
5209        assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
5210        assert!(
5211            !after_dispatch
5212                .lines()
5213                .any(|line| line.trim_start().starts_with("api_key ="))
5214        );
5215
5216        let _ = std::fs::remove_file(path);
5217    }
5218
5219    #[test]
5220    fn auth_migrate_dry_run_does_not_modify_anything() {
5221        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
5222        use std::sync::Arc;
5223
5224        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5225        let path = std::env::temp_dir().join(format!(
5226            "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
5227            std::process::id()
5228        ));
5229        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5230        store.config.providers.openrouter.api_key = Some("or-stay".to_string());
5231        store.save().unwrap();
5232
5233        let inner = Arc::new(InMemoryKeyringStore::new());
5234        let secrets = Secrets::new(inner.clone());
5235
5236        run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
5237            .expect("dry-run should succeed");
5238
5239        assert_eq!(inner.get("openrouter").unwrap(), None);
5240        assert_eq!(
5241            store.config.providers.openrouter.api_key.as_deref(),
5242            Some("or-stay")
5243        );
5244
5245        let _ = std::fs::remove_file(path);
5246    }
5247
5248    #[test]
5249    fn parses_global_override_flags() {
5250        let cli = parse_ok(&[
5251            "deepseek",
5252            "--provider",
5253            "openai",
5254            "--config",
5255            "/tmp/deepseek.toml",
5256            "--profile",
5257            "work",
5258            "--model",
5259            "deepseek-v4-pro",
5260            "--output-mode",
5261            "json",
5262            "--verbosity",
5263            "concise",
5264            "--log-level",
5265            "debug",
5266            "--telemetry",
5267            "true",
5268            "--approval-policy",
5269            "on-request",
5270            "--sandbox-mode",
5271            "workspace-write",
5272            "--base-url",
5273            "https://openai-compatible.example/v1",
5274            "--api-key",
5275            "sk-test",
5276            "--workspace",
5277            "/tmp/workspace",
5278            "--no-alt-screen",
5279            "--no-mouse-capture",
5280            "--skip-onboarding",
5281            "model",
5282            "resolve",
5283            "deepseek-v4-pro",
5284        ]);
5285
5286        assert_eq!(cli.provider.as_deref(), Some("openai"));
5287        assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
5288        assert_eq!(cli.profile.as_deref(), Some("work"));
5289        assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
5290        assert_eq!(cli.output_mode.as_deref(), Some("json"));
5291        assert_eq!(cli.verbosity.as_deref(), Some("concise"));
5292        assert_eq!(cli.log_level.as_deref(), Some("debug"));
5293        assert_eq!(cli.telemetry, Some(true));
5294        assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
5295        assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
5296        assert_eq!(
5297            cli.base_url.as_deref(),
5298            Some("https://openai-compatible.example/v1")
5299        );
5300        assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
5301        assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
5302        assert!(cli.no_alt_screen);
5303        assert!(cli.no_mouse_capture);
5304        assert!(!cli.mouse_capture);
5305        assert!(cli.skip_onboarding);
5306    }
5307
5308    #[test]
5309    fn cli_provider_helpers_follow_config_metadata() {
5310        let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
5311            .iter()
5312            .map(|provider| provider.kind())
5313            .collect();
5314        assert_eq!(registry_kinds, ProviderKind::ALL);
5315
5316        for provider in ProviderKind::ALL {
5317            assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
5318            if provider == ProviderKind::SiliconflowCN {
5319                assert_eq!(
5320                    provider_slot(provider),
5321                    provider_slot(ProviderKind::Siliconflow)
5322                );
5323            } else {
5324                assert_eq!(provider_slot(provider), provider.provider().id());
5325            }
5326        }
5327    }
5328
5329    #[test]
5330    fn build_tui_command_forwards_raw_exec_and_fleet_provider_without_secret_bridge() {
5331        let _lock = env_lock();
5332        let (_dir, _bin) = install_fake_tui_binary();
5333        let _ambient_provider = ScopedEnvVar::set("CODEWHALE_PROVIDER", "openrouter");
5334
5335        let cases = [
5336            (
5337                parse_ok(&["codewhale", "--provider", "lm-studio", "exec", "Reply OK"]),
5338                vec!["exec".to_string(), "Reply OK".to_string()],
5339            ),
5340            (
5341                parse_ok(&["codewhale", "--provider", "lm-studio", "fleet", "status"]),
5342                vec!["fleet".to_string(), "status".to_string()],
5343            ),
5344        ];
5345
5346        for (cli, passthrough) in cases {
5347            let mut resolved =
5348                resolved_runtime_for_test(ProviderKind::Openrouter, ProviderSource::Config);
5349            resolved.api_key = Some("unrelated-keyring-secret".to_string());
5350            resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
5351
5352            let cmd = build_tui_command(&cli, &resolved, passthrough.clone())
5353                .expect("raw provider should dispatch to the TUI");
5354            assert_eq!(
5355                command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
5356                Some("lm-studio")
5357            );
5358            assert_eq!(
5359                command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5360                Some("lm-studio")
5361            );
5362            for secret_var in [
5363                "CODEWHALE_CLI_API_KEY",
5364                "DEEPSEEK_API_KEY",
5365                "OPENROUTER_API_KEY",
5366                "DEEPSEEK_API_KEY_SOURCE",
5367            ] {
5368                assert_eq!(
5369                    command_env(&cmd, secret_var),
5370                    None,
5371                    "raw provider dispatch must not bridge {secret_var}"
5372                );
5373            }
5374            assert_eq!(
5375                cmd.get_args()
5376                    .map(|arg| arg.to_string_lossy().into_owned())
5377                    .collect::<Vec<_>>(),
5378                passthrough
5379            );
5380        }
5381    }
5382
5383    #[test]
5384    fn build_tui_command_allows_openai_and_forwards_provider_key() {
5385        let _lock = env_lock();
5386        let dir = tempfile::TempDir::new().expect("tempdir");
5387        let custom = dir
5388            .path()
5389            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5390        std::fs::write(&custom, b"").unwrap();
5391        let custom_str = custom.to_string_lossy().into_owned();
5392        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5393
5394        let cli = parse_ok(&[
5395            "deepseek",
5396            "--provider",
5397            "openai",
5398            "--workspace",
5399            "/tmp/codewhale-workspace",
5400        ]);
5401        let resolved = ResolvedRuntimeOptions {
5402            provider: ProviderKind::Openai,
5403            provider_source: ProviderSource::Cli,
5404            model: "glm-5".to_string(),
5405            api_key: Some("resolved-openai-key".to_string()),
5406            api_key_source: Some(RuntimeApiKeySource::Keyring),
5407            base_url: "https://openai-compatible.example/v4".to_string(),
5408            auth_mode: Some("api_key".to_string()),
5409            insecure_skip_tls_verify: false,
5410            output_mode: None,
5411            log_level: None,
5412            telemetry: false,
5413            approval_policy: None,
5414            sandbox_mode: None,
5415            yolo: None,
5416            verbosity: None,
5417            http_headers: std::collections::BTreeMap::new(),
5418        };
5419
5420        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5421        assert_eq!(
5422            command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
5423            Some("openai")
5424        );
5425        assert_eq!(
5426            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5427            Some("openai")
5428        );
5429        assert_eq!(
5430            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5431            Some("resolved-openai-key")
5432        );
5433        assert_eq!(
5434            command_env(&cmd, "OPENAI_API_KEY").as_deref(),
5435            Some("resolved-openai-key")
5436        );
5437        assert_eq!(
5438            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
5439            Some("keyring")
5440        );
5441        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
5442        let args: Vec<String> = cmd
5443            .get_args()
5444            .map(|arg| arg.to_string_lossy().into_owned())
5445            .collect();
5446        assert!(
5447            args.windows(2)
5448                .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
5449            "expected workspace forwarding in args: {args:?}"
5450        );
5451    }
5452
5453    #[test]
5454    fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
5455        let _lock = env_lock();
5456        let dir = tempfile::TempDir::new().expect("tempdir");
5457        let custom = dir
5458            .path()
5459            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5460        std::fs::write(&custom, b"").unwrap();
5461        let custom_str = custom.to_string_lossy().into_owned();
5462        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5463
5464        let cli = parse_ok(&["codewhale", "doctor"]);
5465        let resolved = ResolvedRuntimeOptions {
5466            provider: ProviderKind::OpenaiCodex,
5467            provider_source: ProviderSource::Config,
5468            model: "gpt-5.5".to_string(),
5469            api_key: None,
5470            api_key_source: None,
5471            base_url: "https://chatgpt.com/backend-api".to_string(),
5472            auth_mode: Some("oauth".to_string()),
5473            insecure_skip_tls_verify: false,
5474            output_mode: None,
5475            log_level: None,
5476            telemetry: false,
5477            approval_policy: None,
5478            sandbox_mode: None,
5479            yolo: None,
5480            verbosity: None,
5481            http_headers: std::collections::BTreeMap::new(),
5482        };
5483
5484        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5485            .expect("openai-codex should be accepted by the facade");
5486        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
5487        let args: Vec<String> = cmd
5488            .get_args()
5489            .map(|arg| arg.to_string_lossy().into_owned())
5490            .collect();
5491        assert_eq!(args, vec!["doctor"]);
5492    }
5493
5494    #[test]
5495    fn build_tui_command_forwards_explicit_openai_codex_provider() {
5496        let _lock = env_lock();
5497        let dir = tempfile::TempDir::new().expect("tempdir");
5498        let custom = dir
5499            .path()
5500            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5501        std::fs::write(&custom, b"").unwrap();
5502        let custom_str = custom.to_string_lossy().into_owned();
5503        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5504
5505        let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
5506        let resolved = ResolvedRuntimeOptions {
5507            provider: ProviderKind::OpenaiCodex,
5508            provider_source: ProviderSource::Cli,
5509            model: "gpt-5.5".to_string(),
5510            api_key: None,
5511            api_key_source: None,
5512            base_url: "https://chatgpt.com/backend-api".to_string(),
5513            auth_mode: Some("oauth".to_string()),
5514            insecure_skip_tls_verify: false,
5515            output_mode: None,
5516            log_level: None,
5517            telemetry: false,
5518            approval_policy: None,
5519            sandbox_mode: None,
5520            yolo: None,
5521            verbosity: None,
5522            http_headers: std::collections::BTreeMap::new(),
5523        };
5524
5525        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5526            .expect("openai-codex should be accepted by the facade");
5527        assert_eq!(
5528            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5529            Some("openai-codex")
5530        );
5531    }
5532
5533    #[test]
5534    fn build_tui_command_allows_anthropic_cli_provider() {
5535        let _lock = env_lock();
5536        let (_dir, _bin) = install_fake_tui_binary();
5537
5538        let cli = parse_ok(&["codewhale", "--provider", "anthropic", "doctor"]);
5539        let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
5540
5541        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5542            .expect("anthropic should be accepted by the facade");
5543        assert_eq!(
5544            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5545            Some("anthropic")
5546        );
5547    }
5548
5549    #[test]
5550    fn build_tui_command_allows_anthropic_env_provider() {
5551        let _lock = env_lock();
5552        let (_dir, _bin) = install_fake_tui_binary();
5553
5554        let cli = parse_ok(&["codewhale", "doctor"]);
5555        let resolved = resolved_runtime_for_test(
5556            ProviderKind::Anthropic,
5557            ProviderSource::Env("DEEPSEEK_PROVIDER"),
5558        );
5559
5560        build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5561            .expect("anthropic from provider env should be accepted by the facade");
5562    }
5563
5564    #[test]
5565    fn build_tui_command_bridges_anthropic_keyring_secret() {
5566        let _lock = env_lock();
5567        let (_dir, _bin) = install_fake_tui_binary();
5568
5569        let cli = parse_ok(&["codewhale", "doctor"]);
5570        let mut resolved =
5571            resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
5572        resolved.api_key = Some("anthropic-keyring-secret".to_string());
5573        resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
5574
5575        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5576            .expect("config-sourced anthropic provider should be accepted");
5577
5578        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
5579        assert_eq!(
5580            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5581            Some("anthropic-keyring-secret")
5582        );
5583        assert_eq!(
5584            command_env(&cmd, "ANTHROPIC_API_KEY").as_deref(),
5585            Some("anthropic-keyring-secret")
5586        );
5587        assert_eq!(
5588            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
5589            Some("keyring")
5590        );
5591    }
5592
5593    #[test]
5594    fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
5595        let _lock = env_lock();
5596        let dir = tempfile::TempDir::new().expect("tempdir");
5597        let custom = dir
5598            .path()
5599            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5600        std::fs::write(&custom, b"").unwrap();
5601        let custom_str = custom.to_string_lossy().into_owned();
5602        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5603
5604        let cli = parse_ok(&["deepseek", "--profile", "google"]);
5605        let mut resolved_headers = std::collections::BTreeMap::new();
5606        resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
5607        let resolved = ResolvedRuntimeOptions {
5608            provider: ProviderKind::Deepseek,
5609            provider_source: ProviderSource::Config,
5610            model: "deepseek-v4-pro".to_string(),
5611            api_key: Some("config-file-key".to_string()),
5612            api_key_source: Some(RuntimeApiKeySource::ConfigFile),
5613            base_url: "https://api.deepseek.com/beta".to_string(),
5614            auth_mode: Some("api_key".to_string()),
5615            insecure_skip_tls_verify: false,
5616            output_mode: None,
5617            log_level: None,
5618            telemetry: false,
5619            approval_policy: None,
5620            sandbox_mode: None,
5621            yolo: None,
5622            verbosity: Some("normal".to_string()),
5623            http_headers: resolved_headers,
5624        };
5625
5626        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5627
5628        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
5629        assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
5630        assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
5631        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
5632        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
5633        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
5634        assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
5635        assert_eq!(command_env(&cmd, "CODEWHALE_VERBOSITY"), None);
5636        assert_eq!(command_env(&cmd, "DEEPSEEK_VERBOSITY"), None);
5637        let args: Vec<String> = cmd
5638            .get_args()
5639            .map(|arg| arg.to_string_lossy().into_owned())
5640            .collect();
5641        assert!(
5642            args.windows(2).any(|pair| pair == ["--profile", "google"]),
5643            "expected profile forwarding in args: {args:?}"
5644        );
5645    }
5646
5647    #[test]
5648    fn build_tui_command_defaults_noninteractive_to_concise_verbosity() {
5649        let _lock = env_lock();
5650        let (_dir, _bin) = install_fake_tui_binary();
5651
5652        let cli = parse_ok(&["codewhale"]);
5653        let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
5654
5655        let cmd = build_tui_command(
5656            &cli,
5657            &resolved,
5658            vec!["exec".to_string(), "summarize".to_string()],
5659        )
5660        .expect("command");
5661
5662        assert_eq!(
5663            command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
5664            Some("concise")
5665        );
5666        assert_eq!(
5667            command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
5668            Some("concise")
5669        );
5670    }
5671
5672    #[test]
5673    fn build_tui_command_respects_resolved_verbosity_override() {
5674        let _lock = env_lock();
5675        let (_dir, _bin) = install_fake_tui_binary();
5676
5677        let cli = parse_ok(&["codewhale"]);
5678        let mut resolved =
5679            resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
5680        resolved.verbosity = Some("normal".to_string());
5681
5682        let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string()]).expect("command");
5683
5684        assert_eq!(
5685            command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
5686            Some("normal")
5687        );
5688        assert_eq!(
5689            command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
5690            Some("normal")
5691        );
5692    }
5693
5694    #[test]
5695    fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
5696        let _lock = env_lock();
5697        let dir = tempfile::TempDir::new().expect("tempdir");
5698        let custom = dir
5699            .path()
5700            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5701        std::fs::write(&custom, b"").unwrap();
5702        let custom_str = custom.to_string_lossy().into_owned();
5703        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5704
5705        let cli = parse_ok(&[
5706            "codewhale",
5707            "--provider",
5708            "moonshot",
5709            "--model",
5710            "kimi-k2.7-code",
5711            "--workspace",
5712            "/tmp/codewhale-workspace",
5713        ]);
5714        let resolved = ResolvedRuntimeOptions {
5715            provider: ProviderKind::Moonshot,
5716            provider_source: ProviderSource::Cli,
5717            model: "kimi-k2.7-code".to_string(),
5718            api_key: Some("resolved-kimi-key".to_string()),
5719            api_key_source: Some(RuntimeApiKeySource::Keyring),
5720            base_url: "https://api.moonshot.ai/v1".to_string(),
5721            auth_mode: Some("api_key".to_string()),
5722            insecure_skip_tls_verify: false,
5723            output_mode: None,
5724            log_level: None,
5725            telemetry: false,
5726            approval_policy: None,
5727            sandbox_mode: None,
5728            yolo: None,
5729            verbosity: None,
5730            http_headers: std::collections::BTreeMap::new(),
5731        };
5732
5733        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5734        assert_eq!(
5735            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5736            Some("moonshot")
5737        );
5738        assert_eq!(
5739            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
5740            Some("kimi-k2.7-code")
5741        );
5742        assert_eq!(
5743            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5744            Some("resolved-kimi-key")
5745        );
5746        assert_eq!(
5747            command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
5748            Some("resolved-kimi-key")
5749        );
5750        assert_eq!(
5751            command_env(&cmd, "KIMI_API_KEY").as_deref(),
5752            Some("resolved-kimi-key")
5753        );
5754        assert_eq!(
5755            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
5756            Some("keyring")
5757        );
5758        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
5759    }
5760
5761    #[test]
5762    fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
5763        let _lock = env_lock();
5764        let dir = tempfile::TempDir::new().expect("tempdir");
5765        let custom = dir
5766            .path()
5767            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5768        std::fs::write(&custom, b"").unwrap();
5769        let custom_str = custom.to_string_lossy().into_owned();
5770        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5771
5772        let cli = parse_ok(&[
5773            "codewhale",
5774            "--provider",
5775            "volcengine",
5776            "--model",
5777            "DeepSeek-V4-Pro",
5778            "--workspace",
5779            "/tmp/codewhale-workspace",
5780        ]);
5781        let resolved = ResolvedRuntimeOptions {
5782            provider: ProviderKind::Volcengine,
5783            provider_source: ProviderSource::Cli,
5784            model: "DeepSeek-V4-Pro".to_string(),
5785            api_key: Some("resolved-ark-key".to_string()),
5786            api_key_source: Some(RuntimeApiKeySource::Keyring),
5787            base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
5788            auth_mode: Some("api_key".to_string()),
5789            insecure_skip_tls_verify: false,
5790            output_mode: None,
5791            log_level: None,
5792            telemetry: false,
5793            approval_policy: None,
5794            sandbox_mode: None,
5795            yolo: None,
5796            verbosity: None,
5797            http_headers: std::collections::BTreeMap::new(),
5798        };
5799
5800        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5801        assert_eq!(
5802            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5803            Some("volcengine")
5804        );
5805        assert_eq!(
5806            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
5807            Some("DeepSeek-V4-Pro")
5808        );
5809        assert_eq!(
5810            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5811            Some("resolved-ark-key")
5812        );
5813        assert_eq!(
5814            command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
5815            Some("resolved-ark-key")
5816        );
5817        assert_eq!(
5818            command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
5819            Some("resolved-ark-key")
5820        );
5821        assert_eq!(
5822            command_env(&cmd, "ARK_API_KEY").as_deref(),
5823            Some("resolved-ark-key")
5824        );
5825    }
5826
5827    #[test]
5828    fn build_tui_command_exports_explicit_provider_model_and_base_url() {
5829        let _lock = env_lock();
5830        let dir = tempfile::TempDir::new().expect("tempdir");
5831        let custom = dir
5832            .path()
5833            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5834        std::fs::write(&custom, b"").unwrap();
5835        let custom_str = custom.to_string_lossy().into_owned();
5836        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5837
5838        let cli = parse_ok(&[
5839            "deepseek",
5840            "--profile",
5841            "google",
5842            "--provider",
5843            "openai",
5844            "--model",
5845            "glm-5",
5846            "--base-url",
5847            "https://openai-compatible.example/v4",
5848        ]);
5849        let resolved = ResolvedRuntimeOptions {
5850            provider: ProviderKind::Openai,
5851            provider_source: ProviderSource::Cli,
5852            model: "glm-5".to_string(),
5853            api_key: None,
5854            api_key_source: None,
5855            base_url: "https://openai-compatible.example/v4".to_string(),
5856            auth_mode: None,
5857            insecure_skip_tls_verify: false,
5858            output_mode: None,
5859            log_level: None,
5860            telemetry: false,
5861            approval_policy: None,
5862            sandbox_mode: None,
5863            yolo: None,
5864            verbosity: None,
5865            http_headers: std::collections::BTreeMap::new(),
5866        };
5867
5868        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5869
5870        assert_eq!(
5871            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5872            Some("openai")
5873        );
5874        assert_eq!(
5875            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
5876            Some("glm-5")
5877        );
5878        assert_eq!(
5879            command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
5880            Some("https://openai-compatible.example/v4")
5881        );
5882    }
5883
5884    #[test]
5885    fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
5886        let _lock = env_lock();
5887        let dir = tempfile::TempDir::new().expect("tempdir");
5888        let custom = dir
5889            .path()
5890            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5891        std::fs::write(&custom, b"").unwrap();
5892        let custom_str = custom.to_string_lossy().into_owned();
5893        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5894
5895        for provider in ProviderKind::ALL {
5896            let cli = parse_ok(&["codewhale", "--workspace", "/tmp/codewhale-workspace"]);
5897            let resolved = ResolvedRuntimeOptions {
5898                provider,
5899                provider_source: ProviderSource::Config,
5900                model: "test-model".to_string(),
5901                api_key: Some("test-key".to_string()),
5902                api_key_source: Some(RuntimeApiKeySource::Keyring),
5903                base_url: "http://localhost:8000/v1".to_string(),
5904                auth_mode: Some("api_key".to_string()),
5905                insecure_skip_tls_verify: false,
5906                output_mode: None,
5907                log_level: None,
5908                telemetry: false,
5909                approval_policy: None,
5910                sandbox_mode: None,
5911                yolo: None,
5912                verbosity: None,
5913                http_headers: std::collections::BTreeMap::new(),
5914            };
5915
5916            let cmd = build_tui_command(&cli, &resolved, Vec::new())
5917                .unwrap_or_else(|e| panic!("{}: {e}", provider.as_str()));
5918
5919            assert_eq!(
5920                command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5921                Some("test-key"),
5922                "{}: DEEPSEEK_API_KEY not forwarded",
5923                provider.as_str()
5924            );
5925            for var in provider_env_vars(provider)
5926                .iter()
5927                .filter(|var| **var != "DEEPSEEK_API_KEY")
5928            {
5929                assert_eq!(
5930                    command_env(&cmd, var).as_deref(),
5931                    Some("test-key"),
5932                    "{}: {var} not forwarded",
5933                    provider.as_str()
5934                );
5935            }
5936            assert_eq!(
5937                command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
5938                Some("keyring"),
5939                "{}: expected keyring source bridge",
5940                provider.as_str()
5941            );
5942            assert_eq!(
5943                command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
5944                None,
5945                "{}: auth mode should come from config/profile, not env handoff",
5946                provider.as_str()
5947            );
5948        }
5949    }
5950
5951    #[test]
5952    fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
5953        let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
5954
5955        assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
5956        assert!(cli.prompt.is_empty());
5957        assert_eq!(
5958            root_tui_passthrough(&cli).unwrap(),
5959            vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
5960        );
5961    }
5962
5963    #[test]
5964    fn parses_top_level_continue_for_interactive_resume() {
5965        let cli = parse_ok(&["codewhale", "--continue"]);
5966
5967        assert!(cli.continue_session);
5968        assert!(cli.prompt_flag.is_none());
5969        assert!(cli.prompt.is_empty());
5970        assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
5971    }
5972
5973    #[test]
5974    fn top_level_continue_rejects_startup_prompt() {
5975        let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
5976
5977        let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
5978        assert!(
5979            err.to_string()
5980                .contains("codewhale exec --continue <PROMPT>")
5981        );
5982    }
5983
5984    #[test]
5985    fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
5986        let cli = parse_ok(&["deepseek", "hello", "world"]);
5987
5988        assert_eq!(cli.prompt, vec!["hello", "world"]);
5989        assert!(cli.command.is_none());
5990        assert_eq!(
5991            root_tui_passthrough(&cli).unwrap(),
5992            vec!["--prompt".to_string(), "hello world".to_string()]
5993        );
5994    }
5995
5996    #[test]
5997    fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
5998        let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
5999
6000        assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
6001        assert_eq!(cli.prompt, vec!["world"]);
6002        assert_eq!(
6003            root_tui_passthrough(&cli).unwrap(),
6004            vec!["--prompt".to_string(), "hello world".to_string()]
6005        );
6006    }
6007
6008    #[test]
6009    fn known_subcommands_still_parse_before_prompt_tail() {
6010        let cli = parse_ok(&["deepseek", "doctor"]);
6011
6012        assert!(cli.prompt.is_empty());
6013        assert!(matches!(cli.command, Some(Commands::Doctor(_))));
6014    }
6015
6016    #[test]
6017    fn root_help_surface_contains_expected_subcommands_and_globals() {
6018        let rendered = help_for(&["deepseek", "--help"]);
6019
6020        for token in [
6021            "run",
6022            "doctor",
6023            "models",
6024            "sessions",
6025            "resume",
6026            "setup",
6027            "login",
6028            "logout",
6029            "auth",
6030            "mcp-server",
6031            "config",
6032            "model",
6033            "thread",
6034            "sandbox",
6035            "app-server",
6036            "completion",
6037            "metrics",
6038            "--provider",
6039            "--model",
6040            "--config",
6041            "--profile",
6042            "--output-mode",
6043            "--log-level",
6044            "--telemetry",
6045            "--base-url",
6046            "--api-key",
6047            "--approval-policy",
6048            "--sandbox-mode",
6049            "--mouse-capture",
6050            "--no-mouse-capture",
6051            "--skip-onboarding",
6052            "--continue",
6053            "--prompt",
6054        ] {
6055            assert!(
6056                rendered.contains(token),
6057                "expected help to contain token: {token}"
6058            );
6059        }
6060    }
6061
6062    #[test]
6063    fn subcommand_help_surfaces_are_stable() {
6064        let cases = [
6065            ("config", vec!["get", "set", "unset", "list", "path"]),
6066            ("model", vec!["list", "resolve"]),
6067            (
6068                "thread",
6069                vec![
6070                    "list",
6071                    "read",
6072                    "resume",
6073                    "fork",
6074                    "archive",
6075                    "unarchive",
6076                    "set-name",
6077                    "clear-name",
6078                ],
6079            ),
6080            ("sandbox", vec!["check"]),
6081            (
6082                "exec",
6083                vec![
6084                    "--auto",
6085                    "--json",
6086                    "--resume",
6087                    "--session-id",
6088                    "--continue",
6089                    "--output-format",
6090                    "stream-json",
6091                ],
6092            ),
6093            (
6094                "app-server",
6095                vec!["--host", "--port", "--config", "--stdio"],
6096            ),
6097            (
6098                "completion",
6099                vec![
6100                    "<SHELL>",
6101                    "bash",
6102                    "source <(codewhale completion bash)",
6103                    "~/.local/share/bash-completion/completions/codewhale",
6104                    "fpath=(~/.zfunc $fpath)",
6105                    "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
6106                    "codewhale completion powershell | Out-String | Invoke-Expression",
6107                ],
6108            ),
6109            ("metrics", vec!["--json", "--since"]),
6110        ];
6111
6112        for (subcommand, expected_tokens) in cases {
6113            let argv = ["deepseek", subcommand, "--help"];
6114            let rendered = help_for(&argv);
6115            for token in expected_tokens {
6116                assert!(
6117                    rendered.contains(token),
6118                    "expected help for `{subcommand}` to include `{token}`"
6119                );
6120            }
6121        }
6122    }
6123
6124    /// Regression for issue #247: on Windows the dispatcher must find the
6125    /// sibling `codewhale-tui.exe`, not bail out looking for an
6126    /// extension-less `codewhale-tui`. The candidate resolver also accepts
6127    /// the suffix-less name on Windows so users who manually renamed the
6128    /// file as a workaround keep working after the upgrade.
6129    #[test]
6130    fn sibling_tui_candidate_picks_platform_correct_name() {
6131        let dir = tempfile::TempDir::new().expect("tempdir");
6132        let dispatcher = dir
6133            .path()
6134            .join("codewhale")
6135            .with_extension(std::env::consts::EXE_EXTENSION);
6136        // Touch the dispatcher so its parent dir is the lookup root.
6137        std::fs::write(&dispatcher, b"").unwrap();
6138
6139        // No sibling yet — resolver returns None.
6140        assert!(sibling_tui_candidate(&dispatcher).is_none());
6141
6142        let target =
6143            dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
6144        std::fs::write(&target, b"").unwrap();
6145
6146        let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
6147        assert_eq!(found, target, "primary platform-correct name wins");
6148    }
6149
6150    #[test]
6151    fn dispatcher_spawn_error_names_path_and_recovery_checks() {
6152        let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
6153        let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
6154
6155        assert!(message.contains("C:/tools/codewhale-tui.exe"));
6156        assert!(message.contains("access is denied"));
6157        assert!(message.contains("where codewhale"));
6158        assert!(message.contains("DEEPSEEK_TUI_BIN"));
6159    }
6160
6161    #[cfg(unix)]
6162    #[test]
6163    fn tui_child_exit_code_maps_unix_signal_to_shell_status() {
6164        use std::os::unix::process::ExitStatusExt;
6165
6166        let status = std::process::ExitStatus::from_raw(libc::SIGPIPE);
6167
6168        assert_eq!(tui_child_exit_code(status), Some(141));
6169    }
6170
6171    /// Windows-only fallback: the user from #247 manually renamed the
6172    /// file to drop `.exe`. After the fix lands, that workaround must
6173    /// still resolve via the suffix-less fallback so they don't have to
6174    /// rename it back.
6175    #[cfg(windows)]
6176    #[test]
6177    fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
6178        let dir = tempfile::TempDir::new().expect("tempdir");
6179        let dispatcher = dir.path().join("codewhale.exe");
6180        std::fs::write(&dispatcher, b"").unwrap();
6181
6182        // Only the suffixless name exists — emulates the manual rename.
6183        let suffixless = dispatcher.with_file_name("codewhale-tui");
6184        std::fs::write(&suffixless, b"").unwrap();
6185
6186        let found = sibling_tui_candidate(&dispatcher)
6187            .expect("Windows fallback must locate suffixless codewhale-tui");
6188        assert_eq!(found, suffixless);
6189    }
6190
6191    /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
6192    /// custom Windows install layouts and CI test rigs.
6193    #[test]
6194    fn locate_sibling_tui_binary_honours_env_override() {
6195        let _lock = env_lock();
6196        let dir = tempfile::TempDir::new().expect("tempdir");
6197        let custom = dir
6198            .path()
6199            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
6200        std::fs::write(&custom, b"").unwrap();
6201        let custom_str = custom.to_string_lossy().into_owned();
6202        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
6203
6204        let resolved = locate_sibling_tui_binary().expect("override must resolve");
6205        assert_eq!(resolved, custom);
6206    }
6207}