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