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