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