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