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