Skip to main content

codewhale_cli/
lib.rs

1mod metrics;
2mod update;
3
4use std::io::{self, Read, Write};
5use std::net::SocketAddr;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result, anyhow, bail};
10use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::{Shell, generate};
12use codewhale_agent::ModelRegistry;
13use codewhale_app_server::{
14    AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
15};
16use codewhale_config::{
17    CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,
18};
19use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
20use codewhale_mcp::{McpServerDefinition, run_stdio_server};
21use codewhale_secrets::Secrets;
22use codewhale_state::{StateStore, ThreadListFilters};
23
24#[derive(Debug, Clone, Copy, ValueEnum)]
25enum ProviderArg {
26    Deepseek,
27    NvidiaNim,
28    Openai,
29    Atlascloud,
30    WanjieArk,
31    Volcengine,
32    Openrouter,
33    XiaomiMimo,
34    Novita,
35    Fireworks,
36    Siliconflow,
37    Arcee,
38    Moonshot,
39    Sglang,
40    Vllm,
41    Ollama,
42    Huggingface,
43}
44
45impl From<ProviderArg> for ProviderKind {
46    fn from(value: ProviderArg) -> Self {
47        match value {
48            ProviderArg::Deepseek => ProviderKind::Deepseek,
49            ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
50            ProviderArg::Openai => ProviderKind::Openai,
51            ProviderArg::Atlascloud => ProviderKind::Atlascloud,
52            ProviderArg::WanjieArk => ProviderKind::WanjieArk,
53            ProviderArg::Volcengine => ProviderKind::Volcengine,
54            ProviderArg::Openrouter => ProviderKind::Openrouter,
55            ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
56            ProviderArg::Novita => ProviderKind::Novita,
57            ProviderArg::Fireworks => ProviderKind::Fireworks,
58            ProviderArg::Siliconflow => ProviderKind::Siliconflow,
59            ProviderArg::Arcee => ProviderKind::Arcee,
60            ProviderArg::Moonshot => ProviderKind::Moonshot,
61            ProviderArg::Sglang => ProviderKind::Sglang,
62            ProviderArg::Vllm => ProviderKind::Vllm,
63            ProviderArg::Ollama => ProviderKind::Ollama,
64            ProviderArg::Huggingface => ProviderKind::Huggingface,
65        }
66    }
67}
68
69#[derive(Debug, Parser)]
70#[command(
71    name = "codewhale",
72    version = env!("DEEPSEEK_BUILD_VERSION"),
73    bin_name = "codewhale",
74    override_usage = "codewhale [OPTIONS] [PROMPT]\n       codewhale [OPTIONS] <COMMAND> [ARGS]"
75)]
76struct Cli {
77    #[arg(long)]
78    config: Option<PathBuf>,
79    #[arg(long)]
80    profile: Option<String>,
81    #[arg(
82        long,
83        value_enum,
84        help = "Advanced provider selector for non-TUI registry/config commands"
85    )]
86    provider: Option<ProviderArg>,
87    #[arg(long)]
88    model: Option<String>,
89    #[arg(long = "output-mode")]
90    output_mode: Option<String>,
91    #[arg(long = "log-level")]
92    log_level: Option<String>,
93    #[arg(long)]
94    telemetry: Option<bool>,
95    #[arg(long)]
96    approval_policy: Option<String>,
97    #[arg(long)]
98    sandbox_mode: Option<String>,
99    #[arg(long)]
100    api_key: Option<String>,
101    #[arg(long)]
102    base_url: Option<String>,
103    /// Workspace directory for TUI file tools
104    #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
105    workspace: Option<PathBuf>,
106    #[arg(long = "no-alt-screen", hide = true)]
107    no_alt_screen: bool,
108    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
109    mouse_capture: bool,
110    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
111    no_mouse_capture: bool,
112    #[arg(long = "skip-onboarding")]
113    skip_onboarding: bool,
114    /// YOLO mode: auto-approve all tools
115    #[arg(long)]
116    yolo: bool,
117    /// Continue the most recent interactive session for this workspace.
118    #[arg(short = 'c', long = "continue")]
119    continue_session: bool,
120    #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
121    prompt_flag: Option<String>,
122    #[arg(
123        value_name = "PROMPT",
124        trailing_var_arg = true,
125        allow_hyphen_values = true
126    )]
127    prompt: Vec<String>,
128    #[command(subcommand)]
129    command: Option<Commands>,
130}
131
132#[derive(Debug, Subcommand)]
133enum Commands {
134    /// Run interactive/non-interactive flows via the TUI binary.
135    Run(RunArgs),
136    /// Run CodeWhale diagnostics.
137    Doctor(TuiPassthroughArgs),
138    /// List live provider API models via the TUI binary.
139    Models(TuiPassthroughArgs),
140    /// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary.
141    #[command(visible_alias = "tts")]
142    Speech(TuiPassthroughArgs),
143    /// List saved TUI sessions.
144    Sessions(TuiPassthroughArgs),
145    /// Resume a saved TUI session.
146    Resume(TuiPassthroughArgs),
147    /// Fork a saved TUI session.
148    Fork(TuiPassthroughArgs),
149    /// Create a default AGENTS.md in the current directory.
150    Init(TuiPassthroughArgs),
151    /// Bootstrap MCP config and/or skills directories.
152    Setup(TuiPassthroughArgs),
153    /// Run a non-interactive prompt through the TUI runtime.
154    #[command(after_help = "\
155Examples:
156  codewhale exec \"explain this function\"
157  codewhale exec --auto \"list crates/ with ls\"
158  codewhale exec --auto --output-format stream-json \"fix the failing test\"
159
160Common forwarded flags:
161  --auto                           Enable tool-backed agent mode with auto-approvals
162  --json                           Emit summary JSON
163  --resume <SESSION_ID>            Resume a previous session by ID or prefix
164  --session-id <SESSION_ID>        Resume a previous session by ID or prefix
165  --continue                       Continue the most recent session for this workspace
166  --output-format <FORMAT>         Output format: text or stream-json
167
168Plain `codewhale exec` is a one-shot model response. Use `--auto` for
169non-interactive filesystem/shell tool use, matching the supported automation
170path used by stream-json wrappers.
171")]
172    Exec(TuiPassthroughArgs),
173    /// Generate SWE-bench prediction rows from CodeWhale runs.
174    #[command(after_help = "\
175Examples:
176  codewhale swebench run --instance-id django__django-12345 --issue-file issue.md
177  codewhale swebench export --instance-id django__django-12345 --predictions-path all_preds.jsonl
178
179This command forwards to the TUI runtime. `run` invokes tool-backed agent mode
180and writes a SWE-bench-compatible JSONL prediction row from the resulting
181working-tree diff. `export` only writes the current diff.
182")]
183    Swebench(TuiPassthroughArgs),
184    /// Run a CodeWhale-powered code review over a git diff.
185    Review(TuiPassthroughArgs),
186    /// Apply a patch file or stdin to the working tree.
187    Apply(TuiPassthroughArgs),
188    /// Run the offline TUI evaluation harness.
189    Eval(TuiPassthroughArgs),
190    /// Manage TUI MCP servers.
191    Mcp(TuiPassthroughArgs),
192    /// Inspect TUI feature flags.
193    Features(TuiPassthroughArgs),
194    /// Run a local TUI server.
195    Serve(TuiPassthroughArgs),
196    /// Generate shell completions for the TUI binary.
197    Completions(TuiPassthroughArgs),
198    /// Configure provider credentials.
199    Login(LoginArgs),
200    /// Remove saved authentication state.
201    Logout,
202    /// Manage authentication credentials and provider mode.
203    Auth(AuthArgs),
204    /// Run MCP server mode over stdio.
205    McpServer,
206    /// Read/write/list config values.
207    Config(ConfigArgs),
208    /// Resolve or list available models across providers.
209    Model(ModelArgs),
210    /// Manage thread/session metadata and resume/fork flows.
211    Thread(ThreadArgs),
212    /// Evaluate sandbox/approval policy decisions.
213    Sandbox(SandboxArgs),
214    /// Run the app-server transport.
215    AppServer(AppServerArgs),
216    /// Generate shell completions.
217    #[command(after_help = r#"Examples:
218  Bash (current shell only):
219    source <(codewhale completion bash)
220
221  Bash (persistent, Linux/bash-completion):
222    mkdir -p ~/.local/share/bash-completion/completions
223    codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
224    # Requires bash-completion to be installed and loaded by your shell.
225
226  Zsh:
227    mkdir -p ~/.zfunc
228    codewhale completion zsh > ~/.zfunc/_codewhale
229    # Add to ~/.zshrc if needed:
230    #   fpath=(~/.zfunc $fpath)
231    #   autoload -Uz compinit && compinit
232
233  Fish:
234    mkdir -p ~/.config/fish/completions
235    codewhale completion fish > ~/.config/fish/completions/codewhale.fish
236
237  PowerShell (current shell only):
238    codewhale completion powershell | Out-String | Invoke-Expression
239
240The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
241    Completion {
242        #[arg(value_enum)]
243        shell: Shell,
244    },
245    /// Print a usage rollup from the audit log and session store.
246    Metrics(MetricsArgs),
247    /// Check for and apply updates to the `codewhale` binary.
248    Update(UpdateArgs),
249}
250
251#[derive(Debug, Args)]
252struct UpdateArgs {
253    /// Update to the latest beta release instead of the latest stable release.
254    #[arg(long)]
255    beta: bool,
256    /// Only check the latest release; do not download or replace binaries.
257    #[arg(long)]
258    check: bool,
259    /// Proxy URL to use for update HTTP requests.
260    #[arg(long, value_name = "URL")]
261    proxy: Option<String>,
262}
263
264#[derive(Debug, Args)]
265struct MetricsArgs {
266    /// Emit machine-readable JSON.
267    #[arg(long)]
268    json: bool,
269    /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
270    #[arg(long, value_name = "DURATION")]
271    since: Option<String>,
272}
273
274#[derive(Debug, Args)]
275struct RunArgs {
276    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
277    args: Vec<String>,
278}
279
280#[derive(Debug, Args, Clone)]
281struct TuiPassthroughArgs {
282    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
283    args: Vec<String>,
284}
285
286#[derive(Debug, Args)]
287struct LoginArgs {
288    #[arg(long, value_enum, hide = true)]
289    provider: Option<ProviderArg>,
290    #[arg(long)]
291    api_key: Option<String>,
292}
293
294#[derive(Debug, Args)]
295struct AuthArgs {
296    #[command(subcommand)]
297    command: AuthCommand,
298}
299
300#[derive(Debug, Subcommand)]
301enum AuthCommand {
302    /// Show current provider and credential source state.
303    /// Without `--provider`, shows all known providers.
304    /// With `--provider`, shows detailed status for that provider.
305    Status {
306        /// Show status for a specific provider only.
307        #[arg(long, value_enum)]
308        provider: Option<ProviderArg>,
309    },
310    /// Save an API key to the shared user config file. Reads from
311    /// `--api-key`, `--api-key-stdin`, or prompts on stdin when
312    /// neither is given. Does not echo the key.
313    Set {
314        #[arg(long, value_enum)]
315        provider: ProviderArg,
316        /// Inline value (discouraged — appears in shell history).
317        #[arg(long)]
318        api_key: Option<String>,
319        /// Read the key from stdin instead of prompting.
320        #[arg(long = "api-key-stdin", default_value_t = false)]
321        api_key_stdin: bool,
322    },
323    /// Report whether a provider has a key configured. Never prints
324    /// the value; just `set` / `not set` plus the source layer.
325    Get {
326        #[arg(long, value_enum)]
327        provider: ProviderArg,
328    },
329    /// Delete a provider's key from config and secret-store storage.
330    Clear {
331        #[arg(long, value_enum)]
332        provider: ProviderArg,
333    },
334    /// List all known providers with their auth state, without
335    /// revealing keys.
336    List,
337    /// Advanced: migrate config-file keys into a platform credential store.
338    #[command(hide = true)]
339    Migrate {
340        /// Don't actually write anything; print what would change.
341        #[arg(long, default_value_t = false)]
342        dry_run: bool,
343    },
344}
345
346#[derive(Debug, Args)]
347struct ConfigArgs {
348    #[command(subcommand)]
349    command: ConfigCommand,
350}
351
352#[derive(Debug, Subcommand)]
353enum ConfigCommand {
354    Get { key: String },
355    Set { key: String, value: String },
356    Unset { key: String },
357    List,
358    Path,
359}
360
361#[derive(Debug, Args)]
362struct ModelArgs {
363    #[command(subcommand)]
364    command: ModelCommand,
365}
366
367#[derive(Debug, Subcommand)]
368enum ModelCommand {
369    List {
370        #[arg(long, value_enum)]
371        provider: Option<ProviderArg>,
372    },
373    Resolve {
374        model: Option<String>,
375        #[arg(long, value_enum)]
376        provider: Option<ProviderArg>,
377    },
378}
379
380#[derive(Debug, Args)]
381struct ThreadArgs {
382    #[command(subcommand)]
383    command: ThreadCommand,
384}
385
386#[derive(Debug, Subcommand)]
387enum ThreadCommand {
388    List {
389        #[arg(long, default_value_t = false)]
390        all: bool,
391        #[arg(long)]
392        limit: Option<usize>,
393    },
394    Read {
395        thread_id: String,
396    },
397    Resume {
398        thread_id: String,
399    },
400    Fork {
401        thread_id: String,
402    },
403    Archive {
404        thread_id: String,
405    },
406    Unarchive {
407        thread_id: String,
408    },
409    SetName {
410        thread_id: String,
411        name: String,
412    },
413    /// Remove the custom name from a thread, restoring the default
414    /// `(unnamed)` rendering in `thread list`.
415    ClearName {
416        thread_id: String,
417    },
418}
419
420#[derive(Debug, Args)]
421struct SandboxArgs {
422    #[command(subcommand)]
423    command: SandboxCommand,
424}
425
426#[derive(Debug, Subcommand)]
427enum SandboxCommand {
428    Check {
429        command: String,
430        #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
431        ask: ApprovalModeArg,
432    },
433}
434
435#[derive(Debug, Clone, Copy, ValueEnum)]
436enum ApprovalModeArg {
437    UnlessTrusted,
438    OnFailure,
439    OnRequest,
440    Never,
441}
442
443impl From<ApprovalModeArg> for AskForApproval {
444    fn from(value: ApprovalModeArg) -> Self {
445        match value {
446            ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
447            ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
448            ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
449            ApprovalModeArg::Never => AskForApproval::Never,
450        }
451    }
452}
453
454#[derive(Debug, Args)]
455struct AppServerArgs {
456    #[arg(long, default_value = "127.0.0.1")]
457    host: String,
458    #[arg(long, default_value_t = 8787)]
459    port: u16,
460    #[arg(long)]
461    config: Option<PathBuf>,
462    #[arg(long = "auth-token")]
463    auth_token: Option<String>,
464    #[arg(long, default_value_t = false)]
465    insecure_no_auth: bool,
466    #[arg(long = "cors-origin")]
467    cors_origin: Vec<String>,
468    #[arg(long, default_value_t = false)]
469    stdio: bool,
470}
471
472const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
473
474fn install_rustls_crypto_provider() {
475    let _ = rustls::crypto::ring::default_provider().install_default();
476}
477
478pub fn run_cli() -> std::process::ExitCode {
479    install_rustls_crypto_provider();
480
481    match run() {
482        Ok(()) => std::process::ExitCode::SUCCESS,
483        Err(err) => {
484            // Use the full anyhow chain so callers see the underlying
485            // cause (e.g. the actual TOML parse error with line/column)
486            // instead of just the top-level context message. The bare
487            // `{err}` Display impl drops the chain — see #767, where
488            // users hit "failed to parse config at <path>" with no
489            // hint that the real error was a stray BOM or unbalanced
490            // quote a few lines down.
491            eprintln!("error: {err}");
492            for cause in err.chain().skip(1) {
493                eprintln!("  caused by: {cause}");
494            }
495            std::process::ExitCode::FAILURE
496        }
497    }
498}
499
500fn run() -> Result<()> {
501    let mut cli = Cli::parse();
502
503    let mut store = ConfigStore::load(cli.config.clone())?;
504    let runtime_overrides = CliRuntimeOverrides {
505        provider: cli.provider.map(Into::into),
506        model: cli.model.clone(),
507        api_key: cli.api_key.clone(),
508        base_url: cli.base_url.clone(),
509        auth_mode: None,
510        output_mode: cli.output_mode.clone(),
511        log_level: cli.log_level.clone(),
512        telemetry: cli.telemetry,
513        approval_policy: cli.approval_policy.clone(),
514        sandbox_mode: cli.sandbox_mode.clone(),
515        yolo: Some(cli.yolo),
516    };
517    let command = cli.command.take();
518
519    match command {
520        Some(Commands::Run(args)) => {
521            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
522            delegate_to_tui(&cli, &resolved_runtime, args.args)
523        }
524        Some(Commands::Doctor(args)) => {
525            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
526            delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
527        }
528        Some(Commands::Models(args)) => {
529            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
530            delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
531        }
532        Some(Commands::Speech(args)) => {
533            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
534            delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
535        }
536        Some(Commands::Sessions(args)) => {
537            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
538            delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
539        }
540        Some(Commands::Resume(args)) => {
541            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
542            run_resume_command(&cli, &resolved_runtime, args)
543        }
544        Some(Commands::Fork(args)) => {
545            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
546            delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
547        }
548        Some(Commands::Init(args)) => {
549            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
550            delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
551        }
552        Some(Commands::Setup(args)) => {
553            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
554            delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
555        }
556        Some(Commands::Exec(args)) => {
557            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
558            delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
559        }
560        Some(Commands::Swebench(args)) => {
561            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
562            delegate_to_tui(&cli, &resolved_runtime, tui_args("swebench", args))
563        }
564        Some(Commands::Review(args)) => {
565            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
566            delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
567        }
568        Some(Commands::Apply(args)) => {
569            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
570            delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
571        }
572        Some(Commands::Eval(args)) => {
573            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
574            delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
575        }
576        Some(Commands::Mcp(args)) => {
577            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
578            delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
579        }
580        Some(Commands::Features(args)) => {
581            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
582            delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
583        }
584        Some(Commands::Serve(args)) => {
585            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
586            delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
587        }
588        Some(Commands::Completions(args)) => {
589            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
590            delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
591        }
592        Some(Commands::Login(args)) => run_login_command(&mut store, args),
593        Some(Commands::Logout) => run_logout_command(&mut store),
594        Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
595        Some(Commands::McpServer) => run_mcp_server_command(&mut store),
596        Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
597        Some(Commands::Model(args)) => run_model_command(args.command),
598        Some(Commands::Thread(args)) => run_thread_command(args.command),
599        Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
600        Some(Commands::AppServer(args)) => run_app_server_command(args),
601        Some(Commands::Completion { shell }) => {
602            let mut cmd = Cli::command();
603            generate(shell, &mut cmd, "codewhale", &mut io::stdout());
604            Ok(())
605        }
606        Some(Commands::Metrics(args)) => run_metrics_command(args),
607        Some(Commands::Update(args)) => update::run_update(args.beta, args.check, args.proxy),
608        None => {
609            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
610            let forwarded = root_tui_passthrough(&cli)?;
611            delegate_to_tui(&cli, &resolved_runtime, forwarded)
612        }
613    }
614}
615
616fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
617    let mut forwarded = Vec::new();
618    if cli.continue_session {
619        forwarded.push("--continue".to_string());
620    }
621
622    let prompt =
623        cli.prompt_flag
624            .iter()
625            .chain(cli.prompt.iter())
626            .fold(String::new(), |mut acc, part| {
627                if !acc.is_empty() {
628                    acc.push(' ');
629                }
630                acc.push_str(part);
631                acc
632            });
633    if !prompt.is_empty() {
634        if cli.continue_session {
635            bail!(
636                "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
637            );
638        }
639        forwarded.push("--prompt".to_string());
640        forwarded.push(prompt);
641    }
642
643    Ok(forwarded)
644}
645
646fn resolve_runtime_for_dispatch(
647    store: &mut ConfigStore,
648    runtime_overrides: &CliRuntimeOverrides,
649) -> ResolvedRuntimeOptions {
650    let runtime_secrets = Secrets::auto_detect();
651    resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
652}
653
654fn resolve_runtime_for_dispatch_with_secrets(
655    store: &mut ConfigStore,
656    runtime_overrides: &CliRuntimeOverrides,
657    secrets: &Secrets,
658) -> ResolvedRuntimeOptions {
659    let mut resolved = store
660        .config
661        .resolve_runtime_options_with_secrets(runtime_overrides, secrets);
662
663    if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)
664        && !provider_config_set(store, resolved.provider)
665        && let Some(api_key) = resolved.api_key.clone()
666    {
667        write_provider_api_key_to_config(store, resolved.provider, &api_key);
668        match store.save() {
669            Ok(()) => {
670                eprintln!(
671                    "info: recovered API key from secret store and saved it to {}",
672                    store.path().display()
673                );
674                resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);
675            }
676            Err(err) => {
677                eprintln!(
678                    "warning: recovered API key from secret store but failed to save {}: {err}",
679                    store.path().display()
680                );
681            }
682        }
683    }
684
685    resolved
686}
687
688fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
689    let mut forwarded = Vec::with_capacity(args.args.len() + 1);
690    forwarded.push(command.to_string());
691    forwarded.extend(args.args);
692    forwarded
693}
694
695fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
696    run_login_command_with_secrets(store, args, &Secrets::auto_detect())
697}
698
699fn run_login_command_with_secrets(
700    store: &mut ConfigStore,
701    args: LoginArgs,
702    secrets: &Secrets,
703) -> Result<()> {
704    let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
705    store.config.provider = provider;
706
707    let api_key = match args.api_key {
708        Some(v) => v,
709        None => read_api_key_from_stdin()?,
710    };
711    write_provider_api_key_to_config(store, provider, &api_key);
712    let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
713    store.save()?;
714    let destination = if keyring_saved {
715        format!("{} and {}", store.path().display(), secrets.backend_name())
716    } else {
717        store.path().display().to_string()
718    };
719    if provider == ProviderKind::Deepseek {
720        println!("logged in using API key mode (deepseek); saved key to {destination}");
721    } else {
722        println!(
723            "logged in using API key mode ({}); saved key to {destination}",
724            provider.as_str(),
725        );
726    }
727    Ok(())
728}
729
730fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
731    run_logout_command_with_secrets(store, &Secrets::auto_detect())
732}
733
734fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
735    let active_provider = store.config.provider;
736    store.config.api_key = None;
737    for provider in PROVIDER_LIST {
738        clear_provider_api_key_from_config(store, provider);
739    }
740    clear_provider_api_key_from_keyring(secrets, active_provider);
741    store.config.auth_mode = None;
742    store.save()?;
743    println!("logged out");
744    Ok(())
745}
746
747/// Map [`ProviderKind`] to the canonical provider credential slot.
748fn provider_slot(provider: ProviderKind) -> &'static str {
749    match provider {
750        ProviderKind::Deepseek => "deepseek",
751        ProviderKind::NvidiaNim => "nvidia-nim",
752        ProviderKind::Openai => "openai",
753        ProviderKind::Atlascloud => "atlascloud",
754        ProviderKind::WanjieArk => "wanjie-ark",
755        ProviderKind::Volcengine => "volcengine",
756        ProviderKind::Openrouter => "openrouter",
757        ProviderKind::XiaomiMimo => "xiaomi-mimo",
758        ProviderKind::Novita => "novita",
759        ProviderKind::Fireworks => "fireworks",
760        ProviderKind::Siliconflow => "siliconflow",
761        ProviderKind::SiliconflowCN => "siliconflow",
762        ProviderKind::Arcee => "arcee",
763        ProviderKind::Moonshot => "moonshot",
764        ProviderKind::Sglang => "sglang",
765        ProviderKind::Vllm => "vllm",
766        ProviderKind::Ollama => "ollama",
767        ProviderKind::Huggingface => "huggingface",
768    }
769}
770
771/// Provider order used by the `auth list` and `auth status` outputs.
772const PROVIDER_LIST: [ProviderKind; 18] = [
773    ProviderKind::Deepseek,
774    ProviderKind::NvidiaNim,
775    ProviderKind::Openai,
776    ProviderKind::Atlascloud,
777    ProviderKind::WanjieArk,
778    ProviderKind::Volcengine,
779    ProviderKind::Openrouter,
780    ProviderKind::XiaomiMimo,
781    ProviderKind::Novita,
782    ProviderKind::Fireworks,
783    ProviderKind::Siliconflow,
784    ProviderKind::SiliconflowCN,
785    ProviderKind::Arcee,
786    ProviderKind::Moonshot,
787    ProviderKind::Sglang,
788    ProviderKind::Vllm,
789    ProviderKind::Ollama,
790    ProviderKind::Huggingface,
791];
792
793#[cfg(test)]
794fn no_keyring_secrets() -> Secrets {
795    Secrets::new(std::sync::Arc::new(
796        codewhale_secrets::InMemoryKeyringStore::new(),
797    ))
798}
799
800fn write_provider_api_key_to_config(
801    store: &mut ConfigStore,
802    provider: ProviderKind,
803    api_key: &str,
804) {
805    store.config.auth_mode = Some("api_key".to_string());
806    store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
807    if provider == ProviderKind::Deepseek {
808        store.config.api_key = Some(api_key.to_string());
809        if store.config.default_text_model.is_none() {
810            store.config.default_text_model = Some(
811                store
812                    .config
813                    .providers
814                    .deepseek
815                    .model
816                    .clone()
817                    .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
818            );
819        }
820    }
821}
822
823fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
824    store.config.providers.for_provider_mut(provider).api_key = None;
825    if provider == ProviderKind::Deepseek {
826        store.config.api_key = None;
827    }
828}
829
830fn provider_env_set(provider: ProviderKind) -> bool {
831    provider_env_value(provider).is_some()
832}
833
834fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
835    match provider {
836        ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"],
837        ProviderKind::Openrouter => &["OPENROUTER_API_KEY"],
838        ProviderKind::XiaomiMimo => &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
839        ProviderKind::Novita => &["NOVITA_API_KEY"],
840        ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
841        ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
842        ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"],
843        ProviderKind::SiliconflowCN => &["SILICONFLOW_API_KEY"],
844        ProviderKind::Arcee => &["ARCEE_API_KEY"],
845        ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
846        ProviderKind::Sglang => &["SGLANG_API_KEY"],
847        ProviderKind::Vllm => &["VLLM_API_KEY"],
848        ProviderKind::Ollama => &["OLLAMA_API_KEY"],
849        ProviderKind::Huggingface => &["HUGGINGFACE_API_KEY", "HF_TOKEN"],
850        ProviderKind::Openai => &["OPENAI_API_KEY"],
851        ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"],
852        ProviderKind::Volcengine => &[
853            "VOLCENGINE_API_KEY",
854            "VOLCENGINE_ARK_API_KEY",
855            "ARK_API_KEY",
856        ],
857        ProviderKind::WanjieArk => &[
858            "WANJIE_ARK_API_KEY",
859            "WANJIE_API_KEY",
860            "WANJIE_MAAS_API_KEY",
861        ],
862    }
863}
864
865fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
866    provider_env_vars(provider).iter().find_map(|var| {
867        std::env::var(var)
868            .ok()
869            .filter(|value| !value.trim().is_empty())
870            .map(|value| (*var, value))
871    })
872}
873
874fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
875    let slot = store
876        .config
877        .providers
878        .for_provider(provider)
879        .api_key
880        .as_deref();
881    let root = (provider == ProviderKind::Deepseek)
882        .then_some(store.config.api_key.as_deref())
883        .flatten();
884    slot.or(root).filter(|v| !v.trim().is_empty())
885}
886
887fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
888    provider_config_api_key(store, provider).is_some()
889}
890
891fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
892    secrets
893        .get(provider_slot(provider))
894        .ok()
895        .flatten()
896        .filter(|v| !v.trim().is_empty())
897}
898
899fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
900    provider_keyring_api_key(secrets, provider).is_some()
901}
902
903fn write_provider_api_key_to_keyring(
904    secrets: &Secrets,
905    provider: ProviderKind,
906    api_key: &str,
907) -> bool {
908    secrets.set(provider_slot(provider), api_key).is_ok()
909}
910
911fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
912    let _ = secrets.delete(provider_slot(provider));
913}
914
915fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
916    let active_provider = store.config.provider;
917    let mut lines = Vec::new();
918    lines.push(format!(
919        "active provider: {} (set via config or CODEWHALE_PROVIDER)",
920        active_provider.as_str()
921    ));
922    lines.push(String::new());
923    lines.push(format!(
924        "{:<14} {:<8} {:<10} {:<8} {}",
925        "provider", "config", "keyring", "env", "status"
926    ));
927    lines.push("-".repeat(70));
928
929    for provider in PROVIDER_LIST {
930        let config_key = provider_config_api_key(store, provider);
931        let keyring_key = provider_keyring_api_key(secrets, provider);
932        let env_key = provider_env_value(provider);
933
934        let config_status = config_key.map(|_| "set").unwrap_or("-");
935        let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
936        let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
937
938        let source = if config_key.is_some() {
939            "config"
940        } else if keyring_key.is_some() {
941            "keyring"
942        } else if env_key.is_some() {
943            "env"
944        } else {
945            "unset"
946        };
947
948        let active_marker = if provider == active_provider {
949            " *"
950        } else {
951            ""
952        };
953
954        lines.push(format!(
955            "{:<14} {:<8} {:<10} {:<8} {}{}",
956            provider.as_str(),
957            config_status,
958            keyring_status,
959            env_status,
960            source,
961            active_marker
962        ));
963    }
964
965    lines.push(String::new());
966    lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
967    lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
968    lines
969}
970
971fn auth_status_lines_for_provider(
972    store: &ConfigStore,
973    secrets: &Secrets,
974    provider: ProviderKind,
975) -> Vec<String> {
976    let config_key = provider_config_api_key(store, provider);
977    let keyring_key = provider_keyring_api_key(secrets, provider);
978    let env_key = provider_env_value(provider);
979
980    let active_source = if config_key.is_some() {
981        "config"
982    } else if keyring_key.is_some() {
983        "secret store"
984    } else if env_key.is_some() {
985        "env"
986    } else {
987        "missing"
988    };
989    let active_last4 = config_key
990        .map(last4_label)
991        .or_else(|| keyring_key.as_deref().map(last4_label))
992        .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));
993    let active_label = active_last4
994        .map(|last4| format!("{active_source} (last4: {last4})"))
995        .unwrap_or_else(|| active_source.to_string());
996
997    let env_var_label = env_key
998        .as_ref()
999        .map(|(name, _)| (*name).to_string())
1000        .unwrap_or_else(|| provider_env_vars(provider).join("/"));
1001    let env_status = env_key
1002        .as_ref()
1003        .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
1004        .unwrap_or_else(|| "unset".to_string());
1005
1006    let is_active = provider == store.config.provider;
1007    let active_marker = if is_active { " (active provider)" } else { "" };
1008
1009    let provider_cfg = store.config.providers.for_provider(provider);
1010    let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
1011    let model = provider_cfg.model.as_deref().unwrap_or("(default)");
1012
1013    vec![
1014        format!("provider: {}{}", provider.as_str(), active_marker),
1015        format!("route: {}", base_url),
1016        format!("model: {}", model),
1017        format!(
1018            "auth mode: {}",
1019            store.config.auth_mode.as_deref().unwrap_or("api_key")
1020        ),
1021        format!("active source: {active_label}"),
1022        "lookup order: config -> secret store -> env".to_string(),
1023        format!(
1024            "config file: {} ({})",
1025            store.path().display(),
1026            source_status(config_key, "missing")
1027        ),
1028        format!(
1029            "secret store: {} ({})",
1030            secrets.backend_name(),
1031            source_status(keyring_key.as_deref(), "missing")
1032        ),
1033        format!("env var: {env_var_label} ({env_status})"),
1034    ]
1035}
1036
1037fn source_status(value: Option<&str>, missing_label: &str) -> String {
1038    value
1039        .map(|v| format!("set, last4: {}", last4_label(v)))
1040        .unwrap_or_else(|| missing_label.to_string())
1041}
1042
1043fn last4_label(value: &str) -> String {
1044    let trimmed = value.trim();
1045    let chars: Vec<char> = trimmed.chars().collect();
1046    if chars.len() <= 4 {
1047        return "<redacted>".to_string();
1048    }
1049    let last4: String = chars[chars.len() - 4..].iter().collect();
1050    format!("...{last4}")
1051}
1052
1053fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
1054    run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
1055}
1056
1057fn run_auth_command_with_secrets(
1058    store: &mut ConfigStore,
1059    command: AuthCommand,
1060    secrets: &Secrets,
1061) -> Result<()> {
1062    match command {
1063        AuthCommand::Status { provider } => {
1064            match provider {
1065                Some(p) => {
1066                    let provider: ProviderKind = p.into();
1067                    for line in auth_status_lines_for_provider(store, secrets, provider) {
1068                        println!("{line}");
1069                    }
1070                }
1071                None => {
1072                    for line in auth_status_all_providers(store, secrets) {
1073                        println!("{line}");
1074                    }
1075                }
1076            }
1077            Ok(())
1078        }
1079        AuthCommand::Set {
1080            provider,
1081            api_key,
1082            api_key_stdin,
1083        } => {
1084            let provider: ProviderKind = provider.into();
1085            let slot = provider_slot(provider);
1086            if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
1087                let provider_cfg = store.config.providers.for_provider_mut(provider);
1088                if provider_cfg.base_url.is_none() {
1089                    provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
1090                }
1091                store.save()?;
1092                println!(
1093                    "configured {slot} provider in {} (API key optional)",
1094                    store.path().display()
1095                );
1096                return Ok(());
1097            }
1098            let api_key = match (api_key, api_key_stdin) {
1099                (Some(v), _) => v,
1100                (None, true) => read_api_key_from_stdin()?,
1101                (None, false) => prompt_api_key(slot)?,
1102            };
1103            write_provider_api_key_to_config(store, provider, &api_key);
1104            let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
1105            store.save()?;
1106            // Don't print the key. Don't echo length.
1107            if keyring_saved {
1108                println!(
1109                    "saved API key for {slot} to {} and {}",
1110                    store.path().display(),
1111                    secrets.backend_name()
1112                );
1113            } else {
1114                println!("saved API key for {slot} to {}", store.path().display());
1115            }
1116            Ok(())
1117        }
1118        AuthCommand::Get { provider } => {
1119            let provider: ProviderKind = provider.into();
1120            let slot = provider_slot(provider);
1121            let in_file = provider_config_set(store, provider);
1122            let in_keyring = !in_file && provider_keyring_set(secrets, provider);
1123            let in_env = provider_env_set(provider);
1124            // Report the highest-priority source that has it.
1125            let source = if in_file {
1126                Some("config-file")
1127            } else if in_keyring {
1128                Some("secret-store")
1129            } else if in_env {
1130                Some("env")
1131            } else {
1132                None
1133            };
1134            match source {
1135                Some(source) => println!("{slot}: set (source: {source})"),
1136                None => println!("{slot}: not set"),
1137            }
1138            Ok(())
1139        }
1140        AuthCommand::Clear { provider } => {
1141            let provider: ProviderKind = provider.into();
1142            let slot = provider_slot(provider);
1143            clear_provider_api_key_from_config(store, provider);
1144            clear_provider_api_key_from_keyring(secrets, provider);
1145            store.save()?;
1146            println!("cleared API key for {slot} from config and secret store");
1147            Ok(())
1148        }
1149        AuthCommand::List => {
1150            println!("provider     config store env  active");
1151            for provider in PROVIDER_LIST {
1152                let slot = provider_slot(provider);
1153                let file = provider_config_set(store, provider);
1154                let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
1155                let env = provider_env_set(provider);
1156                let active = if file {
1157                    "config"
1158                } else if keyring == Some(true) {
1159                    "store"
1160                } else if env {
1161                    "env"
1162                } else {
1163                    "missing"
1164                };
1165                println!(
1166                    "{slot:<12}  {}     {}      {}   {active}",
1167                    yes_no(file),
1168                    keyring_status_short(keyring),
1169                    yes_no(env)
1170                );
1171            }
1172            Ok(())
1173        }
1174        AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
1175    }
1176}
1177
1178fn yes_no(b: bool) -> &'static str {
1179    if b { "yes" } else { "no " }
1180}
1181
1182fn keyring_status_short(state: Option<bool>) -> &'static str {
1183    match state {
1184        Some(true) => "yes",
1185        Some(false) => "no ",
1186        None => "n/a",
1187    }
1188}
1189
1190fn prompt_api_key(slot: &str) -> Result<String> {
1191    use std::io::{IsTerminal, Write};
1192    eprint!("Enter API key for {slot}: ");
1193    io::stderr().flush().ok();
1194    if !io::stdin().is_terminal() {
1195        // Non-interactive: read directly without prompting twice.
1196        return read_api_key_from_stdin();
1197    }
1198    let mut buf = String::new();
1199    io::stdin()
1200        .read_line(&mut buf)
1201        .context("failed to read API key from stdin")?;
1202    let key = buf.trim().to_string();
1203    if key.is_empty() {
1204        bail!("empty API key provided");
1205    }
1206    Ok(key)
1207}
1208
1209/// Move plaintext keys from config.toml into the configured secret store.
1210/// Hidden in v0.8.8 because the normal setup path is config/env only.
1211fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
1212    let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
1213    let mut warnings: Vec<String> = Vec::new();
1214
1215    for provider in PROVIDER_LIST {
1216        let slot = provider_slot(provider);
1217        let from_provider_block = store
1218            .config
1219            .providers
1220            .for_provider(provider)
1221            .api_key
1222            .clone()
1223            .filter(|v| !v.trim().is_empty());
1224        let from_root = (provider == ProviderKind::Deepseek)
1225            .then(|| store.config.api_key.clone())
1226            .flatten()
1227            .filter(|v| !v.trim().is_empty());
1228        let value = from_provider_block.or(from_root);
1229        let Some(value) = value else { continue };
1230
1231        if let Ok(Some(existing)) = secrets.get(slot)
1232            && existing == value
1233        {
1234            // Already migrated; safe to strip the file slot.
1235        } else if dry_run {
1236            migrated.push((provider, slot));
1237            continue;
1238        } else if let Err(err) = secrets.set(slot, &value) {
1239            warnings.push(format!(
1240                "skipped {slot}: failed to write to secret store: {err}"
1241            ));
1242            continue;
1243        }
1244        if !dry_run {
1245            store.config.providers.for_provider_mut(provider).api_key = None;
1246            if provider == ProviderKind::Deepseek {
1247                store.config.api_key = None;
1248            }
1249        }
1250        migrated.push((provider, slot));
1251    }
1252
1253    if !dry_run && !migrated.is_empty() {
1254        store
1255            .save()
1256            .context("failed to write updated config.toml")?;
1257    }
1258
1259    println!("secret store backend: {}", secrets.backend_name());
1260    if migrated.is_empty() {
1261        println!("nothing to migrate (config.toml has no plaintext api_key entries)");
1262    } else {
1263        println!(
1264            "{} {} provider key(s):",
1265            if dry_run { "would migrate" } else { "migrated" },
1266            migrated.len()
1267        );
1268        for (_, slot) in &migrated {
1269            println!("  - {slot}");
1270        }
1271        if !dry_run {
1272            println!(
1273                "config.toml at {} no longer contains api_key entries for migrated providers.",
1274                store.path().display()
1275            );
1276        }
1277    }
1278    for w in warnings {
1279        eprintln!("warning: {w}");
1280    }
1281    Ok(())
1282}
1283
1284fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1285    match command {
1286        ConfigCommand::Get { key } => {
1287            if let Some(value) = store.config.get_display_value(&key) {
1288                println!("{value}");
1289                return Ok(());
1290            }
1291            bail!("key not found: {key}");
1292        }
1293        ConfigCommand::Set { key, value } => {
1294            store.config.set_value(&key, &value)?;
1295            store.save()?;
1296            println!("set {key}");
1297            Ok(())
1298        }
1299        ConfigCommand::Unset { key } => {
1300            store.config.unset_value(&key)?;
1301            store.save()?;
1302            println!("unset {key}");
1303            Ok(())
1304        }
1305        ConfigCommand::List => {
1306            for (key, value) in store.config.list_values() {
1307                println!("{key} = {value}");
1308            }
1309            Ok(())
1310        }
1311        ConfigCommand::Path => {
1312            println!("{}", store.path().display());
1313            Ok(())
1314        }
1315    }
1316}
1317
1318fn run_model_command(command: ModelCommand) -> Result<()> {
1319    let registry = ModelRegistry::default();
1320    match command {
1321        ModelCommand::List { provider } => {
1322            let filter = provider.map(ProviderKind::from);
1323            for model in registry.list().into_iter().filter(|m| match filter {
1324                Some(p) => m.provider == p,
1325                None => true,
1326            }) {
1327                println!("{} ({})", model.id, model.provider.as_str());
1328            }
1329            Ok(())
1330        }
1331        ModelCommand::Resolve { model, provider } => {
1332            let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
1333            println!("requested: {}", resolved.requested.unwrap_or_default());
1334            println!("resolved: {}", resolved.resolved.id);
1335            println!("provider: {}", resolved.resolved.provider.as_str());
1336            println!("used_fallback: {}", resolved.used_fallback);
1337            Ok(())
1338        }
1339    }
1340}
1341
1342fn run_thread_command(command: ThreadCommand) -> Result<()> {
1343    let state = StateStore::open(None)?;
1344    match command {
1345        ThreadCommand::List { all, limit } => {
1346            let threads = state.list_threads(ThreadListFilters {
1347                include_archived: all,
1348                limit,
1349            })?;
1350            for thread in threads {
1351                println!(
1352                    "{} | {} | {} | {}",
1353                    thread.id,
1354                    thread
1355                        .name
1356                        .clone()
1357                        .unwrap_or_else(|| "(unnamed)".to_string()),
1358                    thread.model_provider,
1359                    thread.cwd.display()
1360                );
1361            }
1362            Ok(())
1363        }
1364        ThreadCommand::Read { thread_id } => {
1365            let thread = state.get_thread(&thread_id)?;
1366            println!("{}", serde_json::to_string_pretty(&thread)?);
1367            Ok(())
1368        }
1369        ThreadCommand::Resume { thread_id } => {
1370            let args = vec!["resume".to_string(), thread_id];
1371            delegate_simple_tui(args)
1372        }
1373        ThreadCommand::Fork { thread_id } => {
1374            let args = vec!["fork".to_string(), thread_id];
1375            delegate_simple_tui(args)
1376        }
1377        ThreadCommand::Archive { thread_id } => {
1378            state.mark_archived(&thread_id)?;
1379            println!("archived {thread_id}");
1380            Ok(())
1381        }
1382        ThreadCommand::Unarchive { thread_id } => {
1383            state.mark_unarchived(&thread_id)?;
1384            println!("unarchived {thread_id}");
1385            Ok(())
1386        }
1387        ThreadCommand::SetName { thread_id, name } => {
1388            let mut thread = state
1389                .get_thread(&thread_id)?
1390                .with_context(|| format!("thread not found: {thread_id}"))?;
1391            thread.name = Some(name);
1392            thread.updated_at = chrono::Utc::now().timestamp();
1393            state.upsert_thread(&thread)?;
1394            println!("renamed {thread_id}");
1395            Ok(())
1396        }
1397        ThreadCommand::ClearName { thread_id } => {
1398            let mut thread = state
1399                .get_thread(&thread_id)?
1400                .with_context(|| format!("thread not found: {thread_id}"))?;
1401            thread.name = None;
1402            thread.updated_at = chrono::Utc::now().timestamp();
1403            state.upsert_thread(&thread)?;
1404            println!("cleared name for {thread_id}");
1405            Ok(())
1406        }
1407    }
1408}
1409
1410fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1411    match command {
1412        SandboxCommand::Check { command, ask } => {
1413            let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1414            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1415            let decision = engine.check(ExecPolicyContext {
1416                command: &command,
1417                cwd: &cwd.display().to_string(),
1418                tool: Some("exec_shell"),
1419                path: None,
1420                ask_for_approval: ask.into(),
1421                sandbox_mode: Some("workspace-write"),
1422            })?;
1423            println!("{}", serde_json::to_string_pretty(&decision)?);
1424            Ok(())
1425        }
1426    }
1427}
1428
1429fn run_app_server_command(args: AppServerArgs) -> Result<()> {
1430    let runtime = tokio::runtime::Builder::new_multi_thread()
1431        .enable_all()
1432        .build()
1433        .context("failed to create tokio runtime")?;
1434    if args.stdio {
1435        return runtime.block_on(run_app_server_stdio(args.config));
1436    }
1437    let listen: SocketAddr = format!("{}:{}", args.host, args.port)
1438        .parse()
1439        .with_context(|| {
1440            format!(
1441                "invalid app-server listen address {}:{}",
1442                args.host, args.port
1443            )
1444        })?;
1445    runtime.block_on(run_app_server(AppServerOptions {
1446        listen,
1447        config_path: args.config,
1448        auth_token: args.auth_token.or_else(app_server_token_from_env),
1449        insecure_no_auth: args.insecure_no_auth,
1450        cors_origins: args.cors_origin,
1451    }))
1452}
1453
1454fn app_server_token_from_env() -> Option<String> {
1455    std::env::var("CODEWHALE_APP_SERVER_TOKEN")
1456        .ok()
1457        .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
1458}
1459
1460fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1461    let persisted = load_mcp_server_definitions(store);
1462    let updated = run_stdio_server(persisted)?;
1463    persist_mcp_server_definitions(store, &updated)
1464}
1465
1466fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1467    let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1468        return Vec::new();
1469    };
1470
1471    match parse_mcp_server_definitions(&raw) {
1472        Ok(definitions) => definitions,
1473        Err(err) => {
1474            eprintln!(
1475                "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
1476            );
1477            Vec::new()
1478        }
1479    }
1480}
1481
1482fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1483    if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1484        return Ok(parsed);
1485    }
1486
1487    let unwrapped: String = serde_json::from_str(raw)
1488        .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1489    serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1490        format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1491    })
1492}
1493
1494fn persist_mcp_server_definitions(
1495    store: &mut ConfigStore,
1496    definitions: &[McpServerDefinition],
1497) -> Result<()> {
1498    let encoded =
1499        serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1500    store
1501        .config
1502        .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1503    store.save()
1504}
1505
1506fn delegate_to_tui(
1507    cli: &Cli,
1508    resolved_runtime: &ResolvedRuntimeOptions,
1509    passthrough: Vec<String>,
1510) -> Result<()> {
1511    let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1512    let tui = PathBuf::from(cmd.get_program());
1513    let status = cmd
1514        .status()
1515        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1516    exit_with_tui_status(status)
1517}
1518
1519fn run_resume_command(
1520    cli: &Cli,
1521    resolved_runtime: &ResolvedRuntimeOptions,
1522    args: TuiPassthroughArgs,
1523) -> Result<()> {
1524    let passthrough = tui_args("resume", args);
1525    if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
1526        return run_dispatcher_resume_picker(cli, resolved_runtime);
1527    }
1528    delegate_to_tui(cli, resolved_runtime, passthrough)
1529}
1530
1531fn run_dispatcher_resume_picker(
1532    cli: &Cli,
1533    resolved_runtime: &ResolvedRuntimeOptions,
1534) -> Result<()> {
1535    let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
1536    let tui = PathBuf::from(sessions_cmd.get_program());
1537    let status = sessions_cmd
1538        .status()
1539        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1540    if !status.success() {
1541        return exit_with_tui_status(status);
1542    }
1543
1544    println!();
1545    println!("Windows note: enter a session id or prefix from the list above.");
1546    println!("You can also run `codewhale resume --last` to skip this prompt.");
1547    print!("Session id/prefix (Enter to cancel): ");
1548    io::stdout().flush()?;
1549
1550    let mut input = String::new();
1551    io::stdin()
1552        .read_line(&mut input)
1553        .context("failed to read session selection")?;
1554    let session_id = input.trim();
1555    if session_id.is_empty() {
1556        bail!("No session selected.");
1557    }
1558
1559    delegate_to_tui(
1560        cli,
1561        resolved_runtime,
1562        vec!["resume".to_string(), session_id.to_string()],
1563    )
1564}
1565
1566fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
1567    is_windows && passthrough == ["resume"]
1568}
1569
1570fn build_tui_command(
1571    cli: &Cli,
1572    resolved_runtime: &ResolvedRuntimeOptions,
1573    passthrough: Vec<String>,
1574) -> Result<Command> {
1575    let tui = locate_sibling_tui_binary()?;
1576
1577    let mut cmd = Command::new(&tui);
1578    if let Some(config) = cli.config.as_ref() {
1579        cmd.arg("--config").arg(config);
1580    }
1581    if let Some(profile) = cli.profile.as_ref() {
1582        cmd.arg("--profile").arg(profile);
1583    }
1584    if let Some(workspace) = cli.workspace.as_ref() {
1585        cmd.arg("--workspace").arg(workspace);
1586    }
1587    // Accepted for older scripts, but no longer forwarded: the interactive TUI
1588    // always owns the alternate screen to avoid host scrollback hijacking.
1589    let _ = cli.no_alt_screen;
1590    if cli.mouse_capture {
1591        cmd.arg("--mouse-capture");
1592    }
1593    if cli.no_mouse_capture {
1594        cmd.arg("--no-mouse-capture");
1595    }
1596    if cli.skip_onboarding {
1597        cmd.arg("--skip-onboarding");
1598    }
1599    cmd.args(passthrough);
1600
1601    if !matches!(
1602        resolved_runtime.provider,
1603        ProviderKind::Deepseek
1604            | ProviderKind::NvidiaNim
1605            | ProviderKind::Openai
1606            | ProviderKind::Atlascloud
1607            | ProviderKind::WanjieArk
1608            | ProviderKind::Openrouter
1609            | ProviderKind::XiaomiMimo
1610            | ProviderKind::Novita
1611            | ProviderKind::Fireworks
1612            | ProviderKind::Siliconflow
1613            | ProviderKind::Arcee
1614            | ProviderKind::Moonshot
1615            | ProviderKind::Sglang
1616            | ProviderKind::Vllm
1617            | ProviderKind::Ollama
1618    ) {
1619        bail!(
1620            "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Xiaomi MiMo, Novita, Fireworks, SiliconFlow, Arcee AI, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.",
1621            resolved_runtime.provider.as_str()
1622        );
1623    }
1624
1625    if let Some(provider) = cli.provider {
1626        let provider: ProviderKind = provider.into();
1627        cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
1628    }
1629    if matches!(
1630        resolved_runtime.api_key_source,
1631        Some(RuntimeApiKeySource::Keyring)
1632    ) && let Some(api_key) = resolved_runtime.api_key.as_ref()
1633    {
1634        // TUI reloads auth_mode from config/profile, but it does not re-query the
1635        // platform keyring on normal startup. Bridge only the recovered secret;
1636        // replaying auth_mode here would turn it back into a profile override.
1637        cmd.env("DEEPSEEK_API_KEY", api_key);
1638        for var in provider_env_vars(resolved_runtime.provider) {
1639            if *var != "DEEPSEEK_API_KEY" {
1640                cmd.env(var, api_key);
1641            }
1642        }
1643        cmd.env(
1644            "DEEPSEEK_API_KEY_SOURCE",
1645            RuntimeApiKeySource::Keyring.as_env_value(),
1646        );
1647    }
1648
1649    if let Some(model) = cli.model.as_ref() {
1650        cmd.env("DEEPSEEK_MODEL", model);
1651    }
1652    if let Some(output_mode) = cli.output_mode.as_ref() {
1653        cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
1654    }
1655    if let Some(log_level) = cli.log_level.as_ref() {
1656        cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
1657    }
1658    if let Some(telemetry) = cli.telemetry {
1659        cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
1660    }
1661    if let Some(policy) = cli.approval_policy.as_ref() {
1662        cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
1663    }
1664    if let Some(mode) = cli.sandbox_mode.as_ref() {
1665        cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
1666    }
1667    if cli.yolo {
1668        cmd.env("DEEPSEEK_YOLO", "true");
1669    }
1670    if let Some(api_key) = cli.api_key.as_ref() {
1671        cmd.env("DEEPSEEK_API_KEY", api_key);
1672        if resolved_runtime.provider == ProviderKind::Openai {
1673            cmd.env("OPENAI_API_KEY", api_key);
1674        }
1675        if resolved_runtime.provider == ProviderKind::Atlascloud {
1676            cmd.env("ATLASCLOUD_API_KEY", api_key);
1677        }
1678        if resolved_runtime.provider == ProviderKind::WanjieArk {
1679            cmd.env("WANJIE_ARK_API_KEY", api_key);
1680        }
1681        if resolved_runtime.provider == ProviderKind::Volcengine {
1682            cmd.env("VOLCENGINE_API_KEY", api_key);
1683        }
1684        if resolved_runtime.provider == ProviderKind::Siliconflow {
1685            cmd.env("SILICONFLOW_API_KEY", api_key);
1686        }
1687        cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
1688    }
1689    if let Some(base_url) = cli.base_url.as_ref() {
1690        cmd.env("DEEPSEEK_BASE_URL", base_url);
1691    }
1692
1693    Ok(cmd)
1694}
1695
1696fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
1697    match status.code() {
1698        Some(code) => std::process::exit(code),
1699        None => bail!("codewhale-tui terminated by signal"),
1700    }
1701}
1702
1703fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
1704    let tui = locate_sibling_tui_binary()?;
1705    let status = Command::new(&tui)
1706        .args(args)
1707        .status()
1708        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1709    match status.code() {
1710        Some(code) => std::process::exit(code),
1711        None => bail!("codewhale-tui terminated by signal"),
1712    }
1713}
1714
1715fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
1716    format!(
1717        "failed to spawn companion TUI binary at {}: {err}\n\
1718\n\
1719The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
1720to execute it. Common fixes:\n\
1721  - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
1722  - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
1723come from the same install directory.\n\
1724  - If you downloaded release assets manually, keep both `codewhale` and \
1725`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
1726  - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \
1727binary.",
1728        tui.display()
1729    )
1730}
1731
1732/// Resolve the sibling `codewhale-tui` executable next to the running
1733/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
1734/// the npm-distributed Windows package — which ships
1735/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).
1736///
1737/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for
1738/// custom installs and CI test layouts. On Windows we additionally try
1739/// the suffix-less name as a fallback for users who already manually
1740/// renamed the file before this fix landed.
1741fn locate_sibling_tui_binary() -> Result<PathBuf> {
1742    if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
1743        let candidate = PathBuf::from(override_path);
1744        if candidate.is_file() {
1745            return Ok(candidate);
1746        }
1747        bail!(
1748            "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
1749            candidate.display()
1750        );
1751    }
1752
1753    let current = std::env::current_exe().context("failed to locate current executable path")?;
1754    if let Some(found) = sibling_tui_candidate(&current) {
1755        return Ok(found);
1756    }
1757
1758    // Build a stable error path so the user sees the platform-correct
1759    // expected name, not "codewhale-tui" on Windows.
1760    let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
1761    bail!(
1762        "Companion `codewhale-tui` binary not found at {}.\n\
1763\n\
1764The `codewhale` dispatcher delegates interactive sessions to a sibling \
1765`codewhale-tui` binary. To fix this, install one of:\n\
1766  • npm:    npm install -g codewhale                (downloads both binaries)\n\
1767  • cargo:  cargo install codewhale-cli codewhale-tui --locked\n\
1768  • GitHub Releases: download BOTH `codewhale-<platform>` AND \
1769`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
1770and place them in the same directory.\n\
1771\n\
1772Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.",
1773        expected.display()
1774    );
1775}
1776
1777/// Return the first existing sibling-binary path under any of the names
1778/// `codewhale-tui` might use on this platform. Pure function to keep
1779/// `locate_sibling_tui_binary` testable.
1780fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1781    // Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
1782    // on Windows.
1783    let primary =
1784        dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
1785    if primary.is_file() {
1786        return Some(primary);
1787    }
1788    // Windows fallback: a user who manually renamed `.exe` away (per the
1789    // workaround in #247) still launches successfully under the new code.
1790    if cfg!(windows) {
1791        let suffixless = dispatcher.with_file_name("codewhale-tui");
1792        if suffixless.is_file() {
1793            return Some(suffixless);
1794        }
1795    }
1796    None
1797}
1798
1799fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1800    let since = match args.since.as_deref() {
1801        Some(s) => {
1802            Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1803        }
1804        None => None,
1805    };
1806    metrics::run(metrics::MetricsArgs {
1807        json: args.json,
1808        since,
1809    })
1810}
1811
1812fn read_api_key_from_stdin() -> Result<String> {
1813    let mut input = String::new();
1814    io::stdin()
1815        .read_to_string(&mut input)
1816        .context("failed to read api key from stdin")?;
1817    let key = input.trim().to_string();
1818    if key.is_empty() {
1819        bail!("empty API key provided");
1820    }
1821    Ok(key)
1822}
1823
1824#[cfg(test)]
1825mod tests {
1826    use super::*;
1827    use clap::error::ErrorKind;
1828    use std::ffi::OsString;
1829    use std::sync::{Mutex, OnceLock};
1830
1831    fn parse_ok(argv: &[&str]) -> Cli {
1832        Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1833    }
1834
1835    fn help_for(argv: &[&str]) -> String {
1836        let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1837        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1838        err.to_string()
1839    }
1840
1841    fn command_env(cmd: &Command, name: &str) -> Option<String> {
1842        let name = std::ffi::OsStr::new(name);
1843        cmd.get_envs().find_map(|(key, value)| {
1844            if key == name {
1845                value.map(|v| v.to_string_lossy().into_owned())
1846            } else {
1847                None
1848            }
1849        })
1850    }
1851
1852    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1853        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1854        LOCK.get_or_init(|| Mutex::new(()))
1855            .lock()
1856            .unwrap_or_else(|p| p.into_inner())
1857    }
1858
1859    struct ScopedEnvVar {
1860        name: &'static str,
1861        previous: Option<OsString>,
1862    }
1863
1864    impl ScopedEnvVar {
1865        fn set(name: &'static str, value: &str) -> Self {
1866            let previous = std::env::var_os(name);
1867            // Safety: tests using this helper serialize with env_lock() and
1868            // restore the original value in Drop.
1869            unsafe { std::env::set_var(name, value) };
1870            Self { name, previous }
1871        }
1872    }
1873
1874    impl Drop for ScopedEnvVar {
1875        fn drop(&mut self) {
1876            // Safety: tests using this helper serialize with env_lock().
1877            unsafe {
1878                if let Some(previous) = self.previous.take() {
1879                    std::env::set_var(self.name, previous);
1880                } else {
1881                    std::env::remove_var(self.name);
1882                }
1883            }
1884        }
1885    }
1886
1887    #[test]
1888    fn clap_command_definition_is_consistent() {
1889        Cli::command().debug_assert();
1890    }
1891
1892    // Regression for #767: `run_cli` prints the full anyhow chain so users
1893    // see the underlying TOML parser error (line/column, expected token)
1894    // instead of just the top-level "failed to parse config at <path>"
1895    // wrapper. anyhow's bare `Display` impl drops the chain — pin both
1896    // pieces here so a future refactor of the printing path doesn't
1897    // silently regress.
1898    #[test]
1899    fn anyhow_chain_surfaces_toml_parse_cause() {
1900        use anyhow::Context;
1901        let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
1902        let err = Err::<(), _>(inner)
1903            .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
1904            .unwrap_err();
1905
1906        // What `eprintln!("error: {err}")` prints (top context only).
1907        assert_eq!(
1908            err.to_string(),
1909            "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
1910        );
1911
1912        // What the `for cause in err.chain().skip(1)` loop iterates over.
1913        let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
1914        assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
1915    }
1916
1917    #[test]
1918    fn parses_config_command_matrix() {
1919        let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
1920        assert!(matches!(
1921            cli.command,
1922            Some(Commands::Config(ConfigArgs {
1923                command: ConfigCommand::Get { ref key }
1924            })) if key == "provider"
1925        ));
1926
1927        let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
1928        assert!(matches!(
1929            cli.command,
1930            Some(Commands::Config(ConfigArgs {
1931                command: ConfigCommand::Set { ref key, ref value }
1932            })) if key == "model" && value == "deepseek-v4-flash"
1933        ));
1934
1935        let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
1936        assert!(matches!(
1937            cli.command,
1938            Some(Commands::Config(ConfigArgs {
1939                command: ConfigCommand::Unset { ref key }
1940            })) if key == "model"
1941        ));
1942
1943        assert!(matches!(
1944            parse_ok(&["deepseek", "config", "list"]).command,
1945            Some(Commands::Config(ConfigArgs {
1946                command: ConfigCommand::List
1947            }))
1948        ));
1949        assert!(matches!(
1950            parse_ok(&["deepseek", "config", "path"]).command,
1951            Some(Commands::Config(ConfigArgs {
1952                command: ConfigCommand::Path
1953            }))
1954        ));
1955    }
1956
1957    #[test]
1958    fn parses_update_beta_flag() {
1959        let cli = parse_ok(&["codewhale", "update"]);
1960        assert!(matches!(
1961            cli.command,
1962            Some(Commands::Update(UpdateArgs {
1963                beta: false,
1964                check: false,
1965                proxy: None
1966            }))
1967        ));
1968
1969        let cli = parse_ok(&["codewhale", "update", "--beta"]);
1970        assert!(matches!(
1971            cli.command,
1972            Some(Commands::Update(UpdateArgs {
1973                beta: true,
1974                check: false,
1975                proxy: None
1976            }))
1977        ));
1978
1979        let cli = parse_ok(&["codewhale", "update", "--check"]);
1980        assert!(matches!(
1981            cli.command,
1982            Some(Commands::Update(UpdateArgs {
1983                beta: false,
1984                check: true,
1985                proxy: None
1986            }))
1987        ));
1988
1989        let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
1990        let Some(Commands::Update(args)) = cli.command else {
1991            panic!("expected update command");
1992        };
1993        assert!(!args.beta);
1994        assert!(!args.check);
1995        assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
1996    }
1997
1998    #[test]
1999    fn parses_model_command_matrix() {
2000        let cli = parse_ok(&["deepseek", "model", "list"]);
2001        assert!(matches!(
2002            cli.command,
2003            Some(Commands::Model(ModelArgs {
2004                command: ModelCommand::List { provider: None }
2005            }))
2006        ));
2007
2008        let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
2009        assert!(matches!(
2010            cli.command,
2011            Some(Commands::Model(ModelArgs {
2012                command: ModelCommand::List {
2013                    provider: Some(ProviderArg::Openai)
2014                }
2015            }))
2016        ));
2017
2018        let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
2019        assert!(matches!(
2020            cli.command,
2021            Some(Commands::Model(ModelArgs {
2022                command: ModelCommand::Resolve {
2023                    model: Some(ref model),
2024                    provider: None
2025                }
2026            })) if model == "deepseek-v4-flash"
2027        ));
2028
2029        let cli = parse_ok(&[
2030            "deepseek",
2031            "model",
2032            "resolve",
2033            "--provider",
2034            "deepseek",
2035            "deepseek-v4-pro",
2036        ]);
2037        assert!(matches!(
2038            cli.command,
2039            Some(Commands::Model(ModelArgs {
2040                command: ModelCommand::Resolve {
2041                    model: Some(ref model),
2042                    provider: Some(ProviderArg::Deepseek)
2043                }
2044            })) if model == "deepseek-v4-pro"
2045        ));
2046    }
2047
2048    #[test]
2049    fn parses_thread_command_matrix() {
2050        let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
2051        assert!(matches!(
2052            cli.command,
2053            Some(Commands::Thread(ThreadArgs {
2054                command: ThreadCommand::List {
2055                    all: true,
2056                    limit: Some(50)
2057                }
2058            }))
2059        ));
2060
2061        let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
2062        assert!(matches!(
2063            cli.command,
2064            Some(Commands::Thread(ThreadArgs {
2065                command: ThreadCommand::Read { ref thread_id }
2066            })) if thread_id == "thread-1"
2067        ));
2068
2069        let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
2070        assert!(matches!(
2071            cli.command,
2072            Some(Commands::Thread(ThreadArgs {
2073                command: ThreadCommand::Resume { ref thread_id }
2074            })) if thread_id == "thread-2"
2075        ));
2076
2077        let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
2078        assert!(matches!(
2079            cli.command,
2080            Some(Commands::Thread(ThreadArgs {
2081                command: ThreadCommand::Fork { ref thread_id }
2082            })) if thread_id == "thread-3"
2083        ));
2084
2085        let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
2086        assert!(matches!(
2087            cli.command,
2088            Some(Commands::Thread(ThreadArgs {
2089                command: ThreadCommand::Archive { ref thread_id }
2090            })) if thread_id == "thread-4"
2091        ));
2092
2093        let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
2094        assert!(matches!(
2095            cli.command,
2096            Some(Commands::Thread(ThreadArgs {
2097                command: ThreadCommand::Unarchive { ref thread_id }
2098            })) if thread_id == "thread-5"
2099        ));
2100
2101        let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
2102        assert!(matches!(
2103            cli.command,
2104            Some(Commands::Thread(ThreadArgs {
2105                command: ThreadCommand::SetName {
2106                    ref thread_id,
2107                    ref name
2108                }
2109            })) if thread_id == "thread-6" && name == "My Thread"
2110        ));
2111
2112        let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
2113        assert!(matches!(
2114            cli.command,
2115            Some(Commands::Thread(ThreadArgs {
2116                command: ThreadCommand::ClearName { ref thread_id }
2117            })) if thread_id == "thread-7"
2118        ));
2119    }
2120
2121    #[test]
2122    fn parses_sandbox_app_server_and_completion_matrix() {
2123        let cli = parse_ok(&[
2124            "deepseek",
2125            "sandbox",
2126            "check",
2127            "echo hello",
2128            "--ask",
2129            "on-failure",
2130        ]);
2131        assert!(matches!(
2132            cli.command,
2133            Some(Commands::Sandbox(SandboxArgs {
2134                command: SandboxCommand::Check {
2135                    ref command,
2136                    ask: ApprovalModeArg::OnFailure
2137                }
2138            })) if command == "echo hello"
2139        ));
2140
2141        let cli = parse_ok(&[
2142            "deepseek",
2143            "app-server",
2144            "--host",
2145            "0.0.0.0",
2146            "--port",
2147            "9999",
2148        ]);
2149        assert!(matches!(
2150            cli.command,
2151            Some(Commands::AppServer(AppServerArgs {
2152                ref host,
2153                port: 9999,
2154                stdio: false,
2155                ..
2156            })) if host == "0.0.0.0"
2157        ));
2158
2159        let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
2160        assert!(matches!(
2161            cli.command,
2162            Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
2163        ));
2164
2165        let cli = parse_ok(&["deepseek", "completion", "bash"]);
2166        assert!(matches!(
2167            cli.command,
2168            Some(Commands::Completion { shell: Shell::Bash })
2169        ));
2170    }
2171
2172    #[test]
2173    fn parses_direct_tui_command_aliases() {
2174        let cli = parse_ok(&["deepseek", "doctor"]);
2175        assert!(matches!(
2176            cli.command,
2177            Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
2178        ));
2179
2180        let cli = parse_ok(&["deepseek", "models", "--json"]);
2181        assert!(matches!(
2182            cli.command,
2183            Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
2184        ));
2185
2186        let cli = parse_ok(&["deepseek", "resume", "abc123"]);
2187        assert!(matches!(
2188            cli.command,
2189            Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
2190        ));
2191
2192        let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
2193        assert!(matches!(
2194            cli.command,
2195            Some(Commands::Setup(TuiPassthroughArgs { ref args }))
2196                if args == &["--skills", "--local"]
2197        ));
2198    }
2199
2200    #[test]
2201    fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
2202        assert!(should_pick_resume_in_dispatcher(
2203            &["resume".to_string()],
2204            true
2205        ));
2206        assert!(!should_pick_resume_in_dispatcher(
2207            &["resume".to_string(), "--last".to_string()],
2208            true
2209        ));
2210        assert!(!should_pick_resume_in_dispatcher(
2211            &["resume".to_string(), "abc123".to_string()],
2212            true
2213        ));
2214        assert!(!should_pick_resume_in_dispatcher(
2215            &["resume".to_string()],
2216            false
2217        ));
2218    }
2219
2220    #[test]
2221    fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
2222        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2223        let path = std::env::temp_dir().join(format!(
2224            "deepseek-cli-login-test-{}-{nanos}.toml",
2225            std::process::id()
2226        ));
2227        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2228        let secrets = no_keyring_secrets();
2229
2230        run_login_command_with_secrets(
2231            &mut store,
2232            LoginArgs {
2233                provider: Some(ProviderArg::Deepseek),
2234                api_key: Some("sk-test".to_string()),
2235            },
2236            &secrets,
2237        )
2238        .expect("login should write config");
2239
2240        assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
2241        assert_eq!(
2242            store.config.providers.deepseek.api_key.as_deref(),
2243            Some("sk-test")
2244        );
2245        assert_eq!(
2246            store.config.default_text_model.as_deref(),
2247            Some("deepseek-v4-pro")
2248        );
2249        let saved = std::fs::read_to_string(&path).expect("config should be written");
2250        assert!(saved.contains("api_key = \"sk-test\""));
2251        assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
2252
2253        let _ = std::fs::remove_file(path);
2254    }
2255
2256    #[test]
2257    fn parses_auth_subcommand_matrix() {
2258        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
2259        assert!(matches!(
2260            cli.command,
2261            Some(Commands::Auth(AuthArgs {
2262                command: AuthCommand::Set {
2263                    provider: ProviderArg::Deepseek,
2264                    api_key: None,
2265                    api_key_stdin: false,
2266                }
2267            }))
2268        ));
2269
2270        let cli = parse_ok(&[
2271            "deepseek",
2272            "auth",
2273            "set",
2274            "--provider",
2275            "openrouter",
2276            "--api-key-stdin",
2277        ]);
2278        assert!(matches!(
2279            cli.command,
2280            Some(Commands::Auth(AuthArgs {
2281                command: AuthCommand::Set {
2282                    provider: ProviderArg::Openrouter,
2283                    api_key: None,
2284                    api_key_stdin: true,
2285                }
2286            }))
2287        ));
2288
2289        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
2290        assert!(matches!(
2291            cli.command,
2292            Some(Commands::Auth(AuthArgs {
2293                command: AuthCommand::Get {
2294                    provider: ProviderArg::Novita
2295                }
2296            }))
2297        ));
2298
2299        let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
2300        assert!(matches!(
2301            cli.command,
2302            Some(Commands::Auth(AuthArgs {
2303                command: AuthCommand::Clear {
2304                    provider: ProviderArg::NvidiaNim
2305                }
2306            }))
2307        ));
2308
2309        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
2310        assert!(matches!(
2311            cli.command,
2312            Some(Commands::Auth(AuthArgs {
2313                command: AuthCommand::Set {
2314                    provider: ProviderArg::Fireworks,
2315                    api_key: None,
2316                    api_key_stdin: false,
2317                }
2318            }))
2319        ));
2320
2321        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
2322        assert!(matches!(
2323            cli.command,
2324            Some(Commands::Auth(AuthArgs {
2325                command: AuthCommand::Set {
2326                    provider: ProviderArg::Siliconflow,
2327                    api_key: None,
2328                    api_key_stdin: false,
2329                }
2330            }))
2331        ));
2332
2333        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
2334        assert!(matches!(
2335            cli.command,
2336            Some(Commands::Auth(AuthArgs {
2337                command: AuthCommand::Set {
2338                    provider: ProviderArg::Arcee,
2339                    api_key: None,
2340                    api_key_stdin: false,
2341                }
2342            }))
2343        ));
2344
2345        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
2346        assert!(matches!(
2347            cli.command,
2348            Some(Commands::Auth(AuthArgs {
2349                command: AuthCommand::Set {
2350                    provider: ProviderArg::Moonshot,
2351                    api_key: None,
2352                    api_key_stdin: false,
2353                }
2354            }))
2355        ));
2356
2357        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
2358        assert!(matches!(
2359            cli.command,
2360            Some(Commands::Auth(AuthArgs {
2361                command: AuthCommand::Set {
2362                    provider: ProviderArg::WanjieArk,
2363                    api_key: None,
2364                    api_key_stdin: false,
2365                }
2366            }))
2367        ));
2368
2369        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
2370        assert!(matches!(
2371            cli.command,
2372            Some(Commands::Auth(AuthArgs {
2373                command: AuthCommand::Get {
2374                    provider: ProviderArg::Sglang
2375                }
2376            }))
2377        ));
2378
2379        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
2380        assert!(matches!(
2381            cli.command,
2382            Some(Commands::Auth(AuthArgs {
2383                command: AuthCommand::Get {
2384                    provider: ProviderArg::Vllm
2385                }
2386            }))
2387        ));
2388
2389        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
2390        assert!(matches!(
2391            cli.command,
2392            Some(Commands::Auth(AuthArgs {
2393                command: AuthCommand::Set {
2394                    provider: ProviderArg::Ollama,
2395                    api_key: None,
2396                    api_key_stdin: false,
2397                }
2398            }))
2399        ));
2400
2401        let cli = parse_ok(&["deepseek", "auth", "list"]);
2402        assert!(matches!(
2403            cli.command,
2404            Some(Commands::Auth(AuthArgs {
2405                command: AuthCommand::List
2406            }))
2407        ));
2408
2409        let cli = parse_ok(&["deepseek", "auth", "migrate"]);
2410        assert!(matches!(
2411            cli.command,
2412            Some(Commands::Auth(AuthArgs {
2413                command: AuthCommand::Migrate { dry_run: false }
2414            }))
2415        ));
2416
2417        let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
2418        assert!(matches!(
2419            cli.command,
2420            Some(Commands::Auth(AuthArgs {
2421                command: AuthCommand::Migrate { dry_run: true }
2422            }))
2423        ));
2424    }
2425
2426    #[test]
2427    fn auth_set_writes_to_shared_config_file() {
2428        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2429        use std::sync::Arc;
2430
2431        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2432        let path = std::env::temp_dir().join(format!(
2433            "deepseek-cli-auth-set-test-{}-{nanos}.toml",
2434            std::process::id()
2435        ));
2436        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2437        let inner = Arc::new(InMemoryKeyringStore::new());
2438        let secrets = Secrets::new(inner.clone());
2439
2440        run_auth_command_with_secrets(
2441            &mut store,
2442            AuthCommand::Set {
2443                provider: ProviderArg::Deepseek,
2444                api_key: Some("sk-keyring".to_string()),
2445                api_key_stdin: false,
2446            },
2447            &secrets,
2448        )
2449        .expect("set should succeed");
2450
2451        assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
2452        assert_eq!(
2453            store.config.providers.deepseek.api_key.as_deref(),
2454            Some("sk-keyring")
2455        );
2456        let saved = std::fs::read_to_string(&path).unwrap_or_default();
2457        assert!(saved.contains("api_key = \"sk-keyring\""));
2458        assert_eq!(
2459            inner.get("deepseek").unwrap().as_deref(),
2460            Some("sk-keyring")
2461        );
2462
2463        let _ = std::fs::remove_file(path);
2464    }
2465
2466    #[test]
2467    fn auth_set_provider_key_does_not_switch_active_provider() {
2468        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2469        let path = std::env::temp_dir().join(format!(
2470            "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
2471            std::process::id()
2472        ));
2473        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2474        store.config.provider = ProviderKind::Deepseek;
2475        let secrets = no_keyring_secrets();
2476
2477        run_auth_command_with_secrets(
2478            &mut store,
2479            AuthCommand::Set {
2480                provider: ProviderArg::Arcee,
2481                api_key: Some("arcee-key".to_string()),
2482                api_key_stdin: false,
2483            },
2484            &secrets,
2485        )
2486        .expect("set should succeed");
2487
2488        assert_eq!(store.config.provider, ProviderKind::Deepseek);
2489        assert_eq!(
2490            store.config.providers.arcee.api_key.as_deref(),
2491            Some("arcee-key")
2492        );
2493
2494        let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
2495        assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
2496        assert_eq!(
2497            reloaded.config.providers.arcee.api_key.as_deref(),
2498            Some("arcee-key")
2499        );
2500
2501        let _ = std::fs::remove_file(path);
2502    }
2503
2504    #[test]
2505    fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
2506        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2507        let path = std::env::temp_dir().join(format!(
2508            "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
2509            std::process::id()
2510        ));
2511        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2512        store.config.provider = ProviderKind::Deepseek;
2513        let secrets = no_keyring_secrets();
2514
2515        run_auth_command_with_secrets(
2516            &mut store,
2517            AuthCommand::Set {
2518                provider: ProviderArg::Ollama,
2519                api_key: None,
2520                api_key_stdin: false,
2521            },
2522            &secrets,
2523        )
2524        .expect("ollama auth set should not require a key");
2525
2526        assert_eq!(store.config.provider, ProviderKind::Deepseek);
2527        assert_eq!(
2528            store.config.providers.ollama.base_url.as_deref(),
2529            Some("http://localhost:11434/v1")
2530        );
2531        assert_eq!(store.config.providers.ollama.api_key, None);
2532
2533        let _ = std::fs::remove_file(path);
2534    }
2535
2536    #[test]
2537    fn auth_clear_removes_from_config() {
2538        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2539        use std::sync::Arc;
2540
2541        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2542        let path = std::env::temp_dir().join(format!(
2543            "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
2544            std::process::id()
2545        ));
2546        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2547        store.config.api_key = Some("sk-stale".to_string());
2548        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2549        store.save().unwrap();
2550
2551        let inner = Arc::new(InMemoryKeyringStore::new());
2552        inner.set("deepseek", "sk-stale").unwrap();
2553        let secrets = Secrets::new(inner.clone());
2554
2555        run_auth_command_with_secrets(
2556            &mut store,
2557            AuthCommand::Clear {
2558                provider: ProviderArg::Deepseek,
2559            },
2560            &secrets,
2561        )
2562        .expect("clear should succeed");
2563
2564        assert!(store.config.api_key.is_none());
2565        assert!(store.config.providers.deepseek.api_key.is_none());
2566        assert_eq!(inner.get("deepseek").unwrap(), None);
2567
2568        let _ = std::fs::remove_file(path);
2569    }
2570
2571    #[test]
2572    fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
2573        use codewhale_secrets::{KeyringStore, SecretsError};
2574        use std::sync::{Arc, Mutex};
2575
2576        #[derive(Default)]
2577        struct RecordingStore {
2578            gets: Mutex<Vec<String>>,
2579        }
2580
2581        impl KeyringStore for RecordingStore {
2582            fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
2583                self.gets.lock().unwrap().push(key.to_string());
2584                Ok(None)
2585            }
2586
2587            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
2588                Ok(())
2589            }
2590
2591            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
2592                Ok(())
2593            }
2594
2595            fn backend_name(&self) -> &'static str {
2596                "recording"
2597            }
2598        }
2599
2600        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2601        let path = std::env::temp_dir().join(format!(
2602            "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
2603            std::process::id()
2604        ));
2605        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2606        store.config.provider = ProviderKind::Deepseek;
2607        let inner = Arc::new(RecordingStore::default());
2608        let secrets = Secrets::new(inner.clone());
2609
2610        run_auth_command_with_secrets(
2611            &mut store,
2612            AuthCommand::Status {
2613                provider: Some(ProviderArg::Deepseek),
2614            },
2615            &secrets,
2616        )
2617        .expect("status should succeed");
2618        run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
2619            .expect("list should succeed");
2620
2621        let probed = inner.gets.lock().unwrap();
2622        // Scoped status probes only the requested provider.
2623        assert_eq!(probed[0], "deepseek");
2624        // List now probes all providers (not just active) to fix the
2625        // stale keyring-only-for-active-provider bug.
2626        assert!(probed.len() > 1, "list should probe all providers");
2627        assert!(
2628            PROVIDER_LIST
2629                .iter()
2630                .all(|p| probed.contains(&provider_slot(*p).to_string())),
2631            "every known provider should be probed by auth list: {:?}",
2632            *probed
2633        );
2634
2635        let _ = std::fs::remove_file(path);
2636    }
2637
2638    #[test]
2639    fn auth_status_reports_all_active_provider_sources_with_last4() {
2640        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2641        use std::sync::Arc;
2642
2643        let _lock = env_lock();
2644        let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
2645
2646        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2647        let path = std::env::temp_dir().join(format!(
2648            "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
2649            std::process::id()
2650        ));
2651        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2652        store.config.provider = ProviderKind::Deepseek;
2653        store.config.api_key = Some("sk-config-3333".to_string());
2654        store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
2655
2656        let inner = Arc::new(InMemoryKeyringStore::new());
2657        inner.set("deepseek", "sk-keyring-2222").unwrap();
2658        let secrets = Secrets::new(inner);
2659
2660        let output =
2661            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
2662
2663        assert!(output.contains("provider: deepseek"));
2664        assert!(output.contains("active source: config (last4: ...3333)"));
2665        assert!(output.contains("lookup order: config -> secret store -> env"));
2666        assert!(output.contains("config file: "));
2667        assert!(output.contains("set, last4: ...3333"));
2668        assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
2669        assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
2670        assert!(!output.contains("sk-config-3333"));
2671        assert!(!output.contains("sk-keyring-2222"));
2672        assert!(!output.contains("sk-env-1111"));
2673
2674        let _ = std::fs::remove_file(path);
2675    }
2676
2677    #[test]
2678    fn auth_status_all_providers_lists_every_known_provider() {
2679        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2680        use std::sync::Arc;
2681
2682        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2683        let path = std::env::temp_dir().join(format!(
2684            "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
2685            std::process::id()
2686        ));
2687        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2688        store.config.provider = ProviderKind::Deepseek;
2689        store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
2690
2691        let inner = Arc::new(InMemoryKeyringStore::new());
2692        inner.set("openrouter", "sk-or-test5678").unwrap();
2693        let secrets = Secrets::new(inner);
2694
2695        let output = auth_status_all_providers(&store, &secrets).join("\n");
2696
2697        // Should list all known providers
2698        assert!(output.contains("deepseek"));
2699        assert!(output.contains("arcee"));
2700        assert!(output.contains("openrouter"));
2701        assert!(output.contains("huggingface"));
2702        assert!(output.contains("ollama"));
2703
2704        // Active provider should be marked
2705        assert!(output.contains("deepseek") && output.contains("*"));
2706
2707        // Arcee should show config source
2708        assert!(output.contains("config"));
2709
2710        // Should NOT leak raw keys
2711        assert!(!output.contains("sk-arcee-test1234"));
2712        assert!(!output.contains("sk-or-test5678"));
2713
2714        let _ = std::fs::remove_file(path);
2715    }
2716
2717    #[test]
2718    fn auth_status_scoped_provider_shows_detailed_info() {
2719        use codewhale_secrets::InMemoryKeyringStore;
2720        use std::sync::Arc;
2721
2722        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2723        let path = std::env::temp_dir().join(format!(
2724            "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
2725            std::process::id()
2726        ));
2727        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2728        store.config.provider = ProviderKind::Deepseek;
2729        store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
2730
2731        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
2732
2733        let output =
2734            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
2735
2736        assert!(output.contains("provider: arcee"));
2737        assert!(output.contains("active source: config (last4: ...9999)"));
2738        assert!(output.contains("route:"));
2739        assert!(output.contains("model:"));
2740        assert!(!output.contains("sk-arcee-9999"));
2741
2742        let _ = std::fs::remove_file(path);
2743    }
2744
2745    #[test]
2746    fn dispatch_keyring_recovery_self_heals_into_config_file() {
2747        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2748        use std::sync::Arc;
2749
2750        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2751        let path = std::env::temp_dir().join(format!(
2752            "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
2753            std::process::id()
2754        ));
2755        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2756        let inner = Arc::new(InMemoryKeyringStore::new());
2757        inner.set("deepseek", "ring-key").unwrap();
2758        let secrets = Secrets::new(inner);
2759
2760        let resolved = resolve_runtime_for_dispatch_with_secrets(
2761            &mut store,
2762            &CliRuntimeOverrides::default(),
2763            &secrets,
2764        );
2765
2766        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
2767        assert_eq!(
2768            resolved.api_key_source,
2769            Some(RuntimeApiKeySource::ConfigFile)
2770        );
2771        assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
2772        assert_eq!(
2773            store.config.providers.deepseek.api_key.as_deref(),
2774            Some("ring-key")
2775        );
2776
2777        let saved = std::fs::read_to_string(&path).expect("config should be written");
2778        assert!(saved.contains("api_key = \"ring-key\""));
2779
2780        let resolved_again = resolve_runtime_for_dispatch_with_secrets(
2781            &mut store,
2782            &CliRuntimeOverrides::default(),
2783            &no_keyring_secrets(),
2784        );
2785        assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
2786        assert_eq!(
2787            resolved_again.api_key_source,
2788            Some(RuntimeApiKeySource::ConfigFile)
2789        );
2790
2791        let _ = std::fs::remove_file(path);
2792    }
2793
2794    #[test]
2795    fn logout_removes_plaintext_provider_keys() {
2796        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2797        let path = std::env::temp_dir().join(format!(
2798            "deepseek-cli-logout-test-{}-{nanos}.toml",
2799            std::process::id()
2800        ));
2801        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2802        store.config.api_key = Some("sk-stale".to_string());
2803        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2804        store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
2805        store.save().unwrap();
2806
2807        let secrets = no_keyring_secrets();
2808
2809        run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
2810
2811        assert!(store.config.api_key.is_none());
2812        assert!(store.config.providers.deepseek.api_key.is_none());
2813        assert!(store.config.providers.fireworks.api_key.is_none());
2814
2815        let _ = std::fs::remove_file(path);
2816    }
2817
2818    #[test]
2819    fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
2820        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2821        use std::sync::Arc;
2822
2823        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2824        let path = std::env::temp_dir().join(format!(
2825            "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
2826            std::process::id()
2827        ));
2828        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2829        store.config.api_key = Some("sk-deep".to_string());
2830        store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
2831        store.config.providers.openrouter.api_key = Some("or-key".to_string());
2832        store.config.providers.novita.api_key = Some("nv-key".to_string());
2833        store.save().unwrap();
2834
2835        let inner = Arc::new(InMemoryKeyringStore::new());
2836        let secrets = Secrets::new(inner.clone());
2837
2838        run_auth_command_with_secrets(
2839            &mut store,
2840            AuthCommand::Migrate { dry_run: false },
2841            &secrets,
2842        )
2843        .expect("migrate should succeed");
2844
2845        assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
2846        assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
2847        assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
2848
2849        // Config file must no longer contain the api keys.
2850        assert!(store.config.api_key.is_none());
2851        assert!(store.config.providers.deepseek.api_key.is_none());
2852        assert!(store.config.providers.openrouter.api_key.is_none());
2853        assert!(store.config.providers.novita.api_key.is_none());
2854
2855        let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
2856        assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
2857        assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
2858        assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
2859
2860        let _ = std::fs::remove_file(path);
2861    }
2862
2863    #[test]
2864    fn auth_migrate_dry_run_does_not_modify_anything() {
2865        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2866        use std::sync::Arc;
2867
2868        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2869        let path = std::env::temp_dir().join(format!(
2870            "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
2871            std::process::id()
2872        ));
2873        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2874        store.config.providers.openrouter.api_key = Some("or-stay".to_string());
2875        store.save().unwrap();
2876
2877        let inner = Arc::new(InMemoryKeyringStore::new());
2878        let secrets = Secrets::new(inner.clone());
2879
2880        run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
2881            .expect("dry-run should succeed");
2882
2883        assert_eq!(inner.get("openrouter").unwrap(), None);
2884        assert_eq!(
2885            store.config.providers.openrouter.api_key.as_deref(),
2886            Some("or-stay")
2887        );
2888
2889        let _ = std::fs::remove_file(path);
2890    }
2891
2892    #[test]
2893    fn parses_global_override_flags() {
2894        let cli = parse_ok(&[
2895            "deepseek",
2896            "--provider",
2897            "openai",
2898            "--config",
2899            "/tmp/deepseek.toml",
2900            "--profile",
2901            "work",
2902            "--model",
2903            "deepseek-v4-pro",
2904            "--output-mode",
2905            "json",
2906            "--log-level",
2907            "debug",
2908            "--telemetry",
2909            "true",
2910            "--approval-policy",
2911            "on-request",
2912            "--sandbox-mode",
2913            "workspace-write",
2914            "--base-url",
2915            "https://openai-compatible.example/v1",
2916            "--api-key",
2917            "sk-test",
2918            "--workspace",
2919            "/tmp/workspace",
2920            "--no-alt-screen",
2921            "--no-mouse-capture",
2922            "--skip-onboarding",
2923            "model",
2924            "resolve",
2925            "deepseek-v4-pro",
2926        ]);
2927
2928        assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
2929        assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
2930        assert_eq!(cli.profile.as_deref(), Some("work"));
2931        assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
2932        assert_eq!(cli.output_mode.as_deref(), Some("json"));
2933        assert_eq!(cli.log_level.as_deref(), Some("debug"));
2934        assert_eq!(cli.telemetry, Some(true));
2935        assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
2936        assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
2937        assert_eq!(
2938            cli.base_url.as_deref(),
2939            Some("https://openai-compatible.example/v1")
2940        );
2941        assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
2942        assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
2943        assert!(cli.no_alt_screen);
2944        assert!(cli.no_mouse_capture);
2945        assert!(!cli.mouse_capture);
2946        assert!(cli.skip_onboarding);
2947    }
2948
2949    #[test]
2950    fn build_tui_command_allows_openai_and_forwards_provider_key() {
2951        let _lock = env_lock();
2952        let dir = tempfile::TempDir::new().expect("tempdir");
2953        let custom = dir
2954            .path()
2955            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2956        std::fs::write(&custom, b"").unwrap();
2957        let custom_str = custom.to_string_lossy().into_owned();
2958        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2959
2960        let cli = parse_ok(&[
2961            "deepseek",
2962            "--provider",
2963            "openai",
2964            "--workspace",
2965            "/tmp/codewhale-workspace",
2966        ]);
2967        let resolved = ResolvedRuntimeOptions {
2968            provider: ProviderKind::Openai,
2969            model: "glm-5".to_string(),
2970            api_key: Some("resolved-openai-key".to_string()),
2971            api_key_source: Some(RuntimeApiKeySource::Keyring),
2972            base_url: "https://openai-compatible.example/v4".to_string(),
2973            auth_mode: Some("api_key".to_string()),
2974            insecure_skip_tls_verify: false,
2975            output_mode: None,
2976            log_level: None,
2977            telemetry: false,
2978            approval_policy: None,
2979            sandbox_mode: None,
2980            yolo: None,
2981            http_headers: std::collections::BTreeMap::new(),
2982        };
2983
2984        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
2985        assert_eq!(
2986            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
2987            Some("openai")
2988        );
2989        assert_eq!(
2990            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
2991            Some("resolved-openai-key")
2992        );
2993        assert_eq!(
2994            command_env(&cmd, "OPENAI_API_KEY").as_deref(),
2995            Some("resolved-openai-key")
2996        );
2997        assert_eq!(
2998            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
2999            Some("keyring")
3000        );
3001        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3002        let args: Vec<String> = cmd
3003            .get_args()
3004            .map(|arg| arg.to_string_lossy().into_owned())
3005            .collect();
3006        assert!(
3007            args.windows(2)
3008                .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
3009            "expected workspace forwarding in args: {args:?}"
3010        );
3011    }
3012
3013    #[test]
3014    fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
3015        let _lock = env_lock();
3016        let dir = tempfile::TempDir::new().expect("tempdir");
3017        let custom = dir
3018            .path()
3019            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3020        std::fs::write(&custom, b"").unwrap();
3021        let custom_str = custom.to_string_lossy().into_owned();
3022        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3023
3024        let cli = parse_ok(&["deepseek", "--profile", "google"]);
3025        let mut resolved_headers = std::collections::BTreeMap::new();
3026        resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
3027        let resolved = ResolvedRuntimeOptions {
3028            provider: ProviderKind::Deepseek,
3029            model: "deepseek-v4-pro".to_string(),
3030            api_key: Some("config-file-key".to_string()),
3031            api_key_source: Some(RuntimeApiKeySource::ConfigFile),
3032            base_url: "https://api.deepseek.com/beta".to_string(),
3033            auth_mode: Some("api_key".to_string()),
3034            insecure_skip_tls_verify: false,
3035            output_mode: None,
3036            log_level: None,
3037            telemetry: false,
3038            approval_policy: None,
3039            sandbox_mode: None,
3040            yolo: None,
3041            http_headers: resolved_headers,
3042        };
3043
3044        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3045
3046        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
3047        assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
3048        assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
3049        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
3050        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
3051        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3052        assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
3053        let args: Vec<String> = cmd
3054            .get_args()
3055            .map(|arg| arg.to_string_lossy().into_owned())
3056            .collect();
3057        assert!(
3058            args.windows(2).any(|pair| pair == ["--profile", "google"]),
3059            "expected profile forwarding in args: {args:?}"
3060        );
3061    }
3062
3063    #[test]
3064    fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
3065        let _lock = env_lock();
3066        let dir = tempfile::TempDir::new().expect("tempdir");
3067        let custom = dir
3068            .path()
3069            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3070        std::fs::write(&custom, b"").unwrap();
3071        let custom_str = custom.to_string_lossy().into_owned();
3072        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3073
3074        let cli = parse_ok(&[
3075            "codewhale",
3076            "--provider",
3077            "moonshot",
3078            "--model",
3079            "kimi-k2.6",
3080            "--workspace",
3081            "/tmp/codewhale-workspace",
3082        ]);
3083        let resolved = ResolvedRuntimeOptions {
3084            provider: ProviderKind::Moonshot,
3085            model: "kimi-k2.6".to_string(),
3086            api_key: Some("resolved-kimi-key".to_string()),
3087            api_key_source: Some(RuntimeApiKeySource::Keyring),
3088            base_url: "https://api.moonshot.ai/v1".to_string(),
3089            auth_mode: Some("api_key".to_string()),
3090            insecure_skip_tls_verify: false,
3091            output_mode: None,
3092            log_level: None,
3093            telemetry: false,
3094            approval_policy: None,
3095            sandbox_mode: None,
3096            yolo: None,
3097            http_headers: std::collections::BTreeMap::new(),
3098        };
3099
3100        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3101        assert_eq!(
3102            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3103            Some("moonshot")
3104        );
3105        assert_eq!(
3106            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
3107            Some("kimi-k2.6")
3108        );
3109        assert_eq!(
3110            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3111            Some("resolved-kimi-key")
3112        );
3113        assert_eq!(
3114            command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
3115            Some("resolved-kimi-key")
3116        );
3117        assert_eq!(
3118            command_env(&cmd, "KIMI_API_KEY").as_deref(),
3119            Some("resolved-kimi-key")
3120        );
3121        assert_eq!(
3122            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3123            Some("keyring")
3124        );
3125        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3126    }
3127
3128    #[test]
3129    fn build_tui_command_exports_explicit_provider_model_and_base_url() {
3130        let _lock = env_lock();
3131        let dir = tempfile::TempDir::new().expect("tempdir");
3132        let custom = dir
3133            .path()
3134            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3135        std::fs::write(&custom, b"").unwrap();
3136        let custom_str = custom.to_string_lossy().into_owned();
3137        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3138
3139        let cli = parse_ok(&[
3140            "deepseek",
3141            "--profile",
3142            "google",
3143            "--provider",
3144            "openai",
3145            "--model",
3146            "glm-5",
3147            "--base-url",
3148            "https://openai-compatible.example/v4",
3149        ]);
3150        let resolved = ResolvedRuntimeOptions {
3151            provider: ProviderKind::Openai,
3152            model: "glm-5".to_string(),
3153            api_key: None,
3154            api_key_source: None,
3155            base_url: "https://openai-compatible.example/v4".to_string(),
3156            auth_mode: None,
3157            insecure_skip_tls_verify: false,
3158            output_mode: None,
3159            log_level: None,
3160            telemetry: false,
3161            approval_policy: None,
3162            sandbox_mode: None,
3163            yolo: None,
3164            http_headers: std::collections::BTreeMap::new(),
3165        };
3166
3167        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3168
3169        assert_eq!(
3170            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3171            Some("openai")
3172        );
3173        assert_eq!(
3174            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
3175            Some("glm-5")
3176        );
3177        assert_eq!(
3178            command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
3179            Some("https://openai-compatible.example/v4")
3180        );
3181    }
3182
3183    #[test]
3184    fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
3185        let _lock = env_lock();
3186        let dir = tempfile::TempDir::new().expect("tempdir");
3187        let custom = dir
3188            .path()
3189            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3190        std::fs::write(&custom, b"").unwrap();
3191        let custom_str = custom.to_string_lossy().into_owned();
3192        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3193
3194        // (provider, cli flag, extra env vars that must be forwarded besides DEEPSEEK_API_KEY)
3195        let cases: &[(ProviderKind, &str, &[&str])] = &[
3196            (
3197                ProviderKind::Openrouter,
3198                "openrouter",
3199                &["OPENROUTER_API_KEY"],
3200            ),
3201            (
3202                ProviderKind::XiaomiMimo,
3203                "xiaomi-mimo",
3204                &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
3205            ),
3206            (ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]),
3207            (
3208                ProviderKind::NvidiaNim,
3209                "nvidia-nim",
3210                &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"],
3211            ),
3212            (ProviderKind::Fireworks, "fireworks", &["FIREWORKS_API_KEY"]),
3213            (
3214                ProviderKind::Siliconflow,
3215                "siliconflow",
3216                &["SILICONFLOW_API_KEY"],
3217            ),
3218            (ProviderKind::Arcee, "arcee", &["ARCEE_API_KEY"]),
3219            (ProviderKind::Sglang, "sglang", &["SGLANG_API_KEY"]),
3220            (ProviderKind::Vllm, "vllm", &["VLLM_API_KEY"]),
3221            (ProviderKind::Ollama, "ollama", &["OLLAMA_API_KEY"]),
3222            (
3223                ProviderKind::Atlascloud,
3224                "atlascloud",
3225                &["ATLASCLOUD_API_KEY"],
3226            ),
3227            (
3228                ProviderKind::WanjieArk,
3229                "wanjie-ark",
3230                &[
3231                    "WANJIE_ARK_API_KEY",
3232                    "WANJIE_API_KEY",
3233                    "WANJIE_MAAS_API_KEY",
3234                ],
3235            ),
3236        ];
3237
3238        for &(provider, flag, expected_vars) in cases {
3239            let cli = parse_ok(&[
3240                "codewhale",
3241                "--provider",
3242                flag,
3243                "--workspace",
3244                "/tmp/codewhale-workspace",
3245            ]);
3246            let resolved = ResolvedRuntimeOptions {
3247                provider,
3248                model: "test-model".to_string(),
3249                api_key: Some("test-key".to_string()),
3250                api_key_source: Some(RuntimeApiKeySource::Keyring),
3251                base_url: "http://localhost:8000/v1".to_string(),
3252                auth_mode: Some("api_key".to_string()),
3253                insecure_skip_tls_verify: false,
3254                output_mode: None,
3255                log_level: None,
3256                telemetry: false,
3257                approval_policy: None,
3258                sandbox_mode: None,
3259                yolo: None,
3260                http_headers: std::collections::BTreeMap::new(),
3261            };
3262
3263            let cmd = build_tui_command(&cli, &resolved, Vec::new())
3264                .unwrap_or_else(|e| panic!("{flag}: {e}"));
3265
3266            assert_eq!(
3267                command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3268                Some("test-key"),
3269                "{flag}: DEEPSEEK_API_KEY not forwarded"
3270            );
3271            for var in expected_vars {
3272                assert_eq!(
3273                    command_env(&cmd, var).as_deref(),
3274                    Some("test-key"),
3275                    "{flag}: {var} not forwarded"
3276                );
3277            }
3278            assert_eq!(
3279                command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3280                Some("keyring"),
3281                "{flag}: expected keyring source bridge"
3282            );
3283            assert_eq!(
3284                command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
3285                None,
3286                "{flag}: auth mode should come from config/profile, not env handoff"
3287            );
3288        }
3289    }
3290
3291    #[test]
3292    fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
3293        let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
3294
3295        assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
3296        assert!(cli.prompt.is_empty());
3297        assert_eq!(
3298            root_tui_passthrough(&cli).unwrap(),
3299            vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
3300        );
3301    }
3302
3303    #[test]
3304    fn parses_top_level_continue_for_interactive_resume() {
3305        let cli = parse_ok(&["codewhale", "--continue"]);
3306
3307        assert!(cli.continue_session);
3308        assert!(cli.prompt_flag.is_none());
3309        assert!(cli.prompt.is_empty());
3310        assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
3311    }
3312
3313    #[test]
3314    fn top_level_continue_rejects_startup_prompt() {
3315        let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
3316
3317        let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
3318        assert!(
3319            err.to_string()
3320                .contains("codewhale exec --continue <PROMPT>")
3321        );
3322    }
3323
3324    #[test]
3325    fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
3326        let cli = parse_ok(&["deepseek", "hello", "world"]);
3327
3328        assert_eq!(cli.prompt, vec!["hello", "world"]);
3329        assert!(cli.command.is_none());
3330        assert_eq!(
3331            root_tui_passthrough(&cli).unwrap(),
3332            vec!["--prompt".to_string(), "hello world".to_string()]
3333        );
3334    }
3335
3336    #[test]
3337    fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
3338        let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
3339
3340        assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
3341        assert_eq!(cli.prompt, vec!["world"]);
3342        assert_eq!(
3343            root_tui_passthrough(&cli).unwrap(),
3344            vec!["--prompt".to_string(), "hello world".to_string()]
3345        );
3346    }
3347
3348    #[test]
3349    fn known_subcommands_still_parse_before_prompt_tail() {
3350        let cli = parse_ok(&["deepseek", "doctor"]);
3351
3352        assert!(cli.prompt.is_empty());
3353        assert!(matches!(cli.command, Some(Commands::Doctor(_))));
3354    }
3355
3356    #[test]
3357    fn root_help_surface_contains_expected_subcommands_and_globals() {
3358        let rendered = help_for(&["deepseek", "--help"]);
3359
3360        for token in [
3361            "run",
3362            "doctor",
3363            "models",
3364            "sessions",
3365            "resume",
3366            "setup",
3367            "login",
3368            "logout",
3369            "auth",
3370            "mcp-server",
3371            "config",
3372            "model",
3373            "thread",
3374            "sandbox",
3375            "app-server",
3376            "completion",
3377            "metrics",
3378            "--provider",
3379            "--model",
3380            "--config",
3381            "--profile",
3382            "--output-mode",
3383            "--log-level",
3384            "--telemetry",
3385            "--base-url",
3386            "--api-key",
3387            "--approval-policy",
3388            "--sandbox-mode",
3389            "--mouse-capture",
3390            "--no-mouse-capture",
3391            "--skip-onboarding",
3392            "--continue",
3393            "--prompt",
3394        ] {
3395            assert!(
3396                rendered.contains(token),
3397                "expected help to contain token: {token}"
3398            );
3399        }
3400    }
3401
3402    #[test]
3403    fn subcommand_help_surfaces_are_stable() {
3404        let cases = [
3405            ("config", vec!["get", "set", "unset", "list", "path"]),
3406            ("model", vec!["list", "resolve"]),
3407            (
3408                "thread",
3409                vec![
3410                    "list",
3411                    "read",
3412                    "resume",
3413                    "fork",
3414                    "archive",
3415                    "unarchive",
3416                    "set-name",
3417                    "clear-name",
3418                ],
3419            ),
3420            ("sandbox", vec!["check"]),
3421            (
3422                "exec",
3423                vec![
3424                    "--auto",
3425                    "--json",
3426                    "--resume",
3427                    "--session-id",
3428                    "--continue",
3429                    "--output-format",
3430                    "stream-json",
3431                ],
3432            ),
3433            (
3434                "app-server",
3435                vec!["--host", "--port", "--config", "--stdio"],
3436            ),
3437            (
3438                "completion",
3439                vec![
3440                    "<SHELL>",
3441                    "bash",
3442                    "source <(codewhale completion bash)",
3443                    "~/.local/share/bash-completion/completions/codewhale",
3444                    "fpath=(~/.zfunc $fpath)",
3445                    "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
3446                    "codewhale completion powershell | Out-String | Invoke-Expression",
3447                ],
3448            ),
3449            ("metrics", vec!["--json", "--since"]),
3450        ];
3451
3452        for (subcommand, expected_tokens) in cases {
3453            let argv = ["deepseek", subcommand, "--help"];
3454            let rendered = help_for(&argv);
3455            for token in expected_tokens {
3456                assert!(
3457                    rendered.contains(token),
3458                    "expected help for `{subcommand}` to include `{token}`"
3459                );
3460            }
3461        }
3462    }
3463
3464    /// Regression for issue #247: on Windows the dispatcher must find the
3465    /// sibling `codewhale-tui.exe`, not bail out looking for an
3466    /// extension-less `codewhale-tui`. The candidate resolver also accepts
3467    /// the suffix-less name on Windows so users who manually renamed the
3468    /// file as a workaround keep working after the upgrade.
3469    #[test]
3470    fn sibling_tui_candidate_picks_platform_correct_name() {
3471        let dir = tempfile::TempDir::new().expect("tempdir");
3472        let dispatcher = dir
3473            .path()
3474            .join("codewhale")
3475            .with_extension(std::env::consts::EXE_EXTENSION);
3476        // Touch the dispatcher so its parent dir is the lookup root.
3477        std::fs::write(&dispatcher, b"").unwrap();
3478
3479        // No sibling yet — resolver returns None.
3480        assert!(sibling_tui_candidate(&dispatcher).is_none());
3481
3482        let target =
3483            dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
3484        std::fs::write(&target, b"").unwrap();
3485
3486        let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
3487        assert_eq!(found, target, "primary platform-correct name wins");
3488    }
3489
3490    #[test]
3491    fn dispatcher_spawn_error_names_path_and_recovery_checks() {
3492        let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
3493        let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
3494
3495        assert!(message.contains("C:/tools/codewhale-tui.exe"));
3496        assert!(message.contains("access is denied"));
3497        assert!(message.contains("where codewhale"));
3498        assert!(message.contains("DEEPSEEK_TUI_BIN"));
3499    }
3500
3501    /// Windows-only fallback: the user from #247 manually renamed the
3502    /// file to drop `.exe`. After the fix lands, that workaround must
3503    /// still resolve via the suffix-less fallback so they don't have to
3504    /// rename it back.
3505    #[cfg(windows)]
3506    #[test]
3507    fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
3508        let dir = tempfile::TempDir::new().expect("tempdir");
3509        let dispatcher = dir.path().join("codewhale.exe");
3510        std::fs::write(&dispatcher, b"").unwrap();
3511
3512        // Only the suffixless name exists — emulates the manual rename.
3513        let suffixless = dispatcher.with_file_name("codewhale-tui");
3514        std::fs::write(&suffixless, b"").unwrap();
3515
3516        let found = sibling_tui_candidate(&dispatcher)
3517            .expect("Windows fallback must locate suffixless codewhale-tui");
3518        assert_eq!(found, suffixless);
3519    }
3520
3521    /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
3522    /// custom Windows install layouts and CI test rigs.
3523    #[test]
3524    fn locate_sibling_tui_binary_honours_env_override() {
3525        let _lock = env_lock();
3526        let dir = tempfile::TempDir::new().expect("tempdir");
3527        let custom = dir
3528            .path()
3529            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3530        std::fs::write(&custom, b"").unwrap();
3531        let custom_str = custom.to_string_lossy().into_owned();
3532        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3533
3534        let resolved = locate_sibling_tui_binary().expect("override must resolve");
3535        assert_eq!(resolved, custom);
3536    }
3537}