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