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