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