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