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