1#![allow(clippy::uninlined_format_args)]
2
3mod metrics;
4#[cfg(not(target_env = "ohos"))]
5mod update;
6
7use std::io::{self, Read, Write};
8use std::net::SocketAddr;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use anyhow::{Context, Result, anyhow, bail};
13use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
14use clap_complete::{Shell, generate};
15use codewhale_agent::ModelRegistry;
16use codewhale_app_server::{
17 AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
18};
19use codewhale_config::{
20 CliRuntimeOverrides, ConfigStore, ConfigToml, ProviderKind, ProviderSource,
21 ResolvedRuntimeOptions, RuntimeApiKeySource,
22};
23use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
24use codewhale_mcp::{McpServerDefinition, run_stdio_server};
25use codewhale_secrets::Secrets;
26use codewhale_state::{StateStore, ThreadListFilters};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
29enum ProviderArg {
30 Deepseek,
31 NvidiaNim,
32 Openai,
33 Atlascloud,
34 WanjieArk,
35 Volcengine,
36 Openrouter,
37 XiaomiMimo,
38 Novita,
39 Fireworks,
40 Siliconflow,
41 #[value(
42 alias = "silicon-flow-cn",
43 alias = "siliconflow-CN",
44 alias = "silicon_flow_cn",
45 alias = "siliconflow_cn",
46 alias = "siliconflow-china",
47 alias = "siliconflow_china"
48 )]
49 SiliconflowCn,
50 Arcee,
51 Moonshot,
52 Sglang,
53 Vllm,
54 Ollama,
55 Huggingface,
56 Together,
57 OpenaiCodex,
58 Anthropic,
59 #[value(alias = "open-model", alias = "open_model")]
60 Openmodel,
61 Zai,
62 Stepfun,
63 Minimax,
64 #[value(
65 alias = "minimax_anthropic",
66 alias = "mini-max-anthropic",
67 alias = "mini_max_anthropic"
68 )]
69 MinimaxAnthropic,
70 #[value(alias = "deep-infra", alias = "deep_infra")]
71 Deepinfra,
72 #[value(alias = "fugu", alias = "sakana-ai", alias = "sakana_ai")]
73 Sakana,
74 #[value(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")]
75 LongCat,
76 #[value(
77 alias = "meta-ai",
78 alias = "meta_ai",
79 alias = "meta-model-api",
80 alias = "muse",
81 alias = "muse-spark"
82 )]
83 Meta,
84 #[value(alias = "x-ai", alias = "x_ai", alias = "grok")]
85 Xai,
86}
87
88impl From<ProviderArg> for ProviderKind {
89 fn from(value: ProviderArg) -> Self {
90 match value {
91 ProviderArg::Deepseek => ProviderKind::Deepseek,
92 ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
93 ProviderArg::Openai => ProviderKind::Openai,
94 ProviderArg::Atlascloud => ProviderKind::Atlascloud,
95 ProviderArg::WanjieArk => ProviderKind::WanjieArk,
96 ProviderArg::Volcengine => ProviderKind::Volcengine,
97 ProviderArg::Openrouter => ProviderKind::Openrouter,
98 ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
99 ProviderArg::Novita => ProviderKind::Novita,
100 ProviderArg::Fireworks => ProviderKind::Fireworks,
101 ProviderArg::Siliconflow => ProviderKind::Siliconflow,
102 ProviderArg::SiliconflowCn => ProviderKind::SiliconflowCN,
103 ProviderArg::Arcee => ProviderKind::Arcee,
104 ProviderArg::Moonshot => ProviderKind::Moonshot,
105 ProviderArg::Sglang => ProviderKind::Sglang,
106 ProviderArg::Vllm => ProviderKind::Vllm,
107 ProviderArg::Ollama => ProviderKind::Ollama,
108 ProviderArg::Huggingface => ProviderKind::Huggingface,
109 ProviderArg::Together => ProviderKind::Together,
110 ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
111 ProviderArg::Anthropic => ProviderKind::Anthropic,
112 ProviderArg::Openmodel => ProviderKind::Openmodel,
113 ProviderArg::Zai => ProviderKind::Zai,
114 ProviderArg::Stepfun => ProviderKind::Stepfun,
115 ProviderArg::Minimax => ProviderKind::Minimax,
116 ProviderArg::MinimaxAnthropic => ProviderKind::MinimaxAnthropic,
117 ProviderArg::Deepinfra => ProviderKind::Deepinfra,
118 ProviderArg::Sakana => ProviderKind::Sakana,
119 ProviderArg::LongCat => ProviderKind::LongCat,
120 ProviderArg::Meta => ProviderKind::Meta,
121 ProviderArg::Xai => ProviderKind::Xai,
122 }
123 }
124}
125
126fn builtin_provider_arg(value: &str) -> Option<ProviderArg> {
127 ProviderArg::from_str(value, false).ok()
128}
129
130fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
131 if value.is_empty()
132 || value == "__custom__"
133 || !value
134 .chars()
135 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
136 {
137 return Err(
138 "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
139 .to_string(),
140 );
141 }
142 Ok(value.to_string())
143}
144
145#[derive(Debug, Parser)]
146#[command(
147 name = "codewhale",
148 version = env!("DEEPSEEK_BUILD_VERSION"),
149 bin_name = "codewhale",
150 override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
151)]
152struct Cli {
153 #[arg(long)]
154 config: Option<PathBuf>,
155 #[arg(long)]
156 profile: Option<String>,
157 #[arg(
158 long,
159 value_name = "PROVIDER",
160 value_parser = parse_provider_identifier,
161 help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
162 )]
163 provider: Option<String>,
164 #[arg(long)]
165 model: Option<String>,
166 #[arg(long = "output-mode")]
167 output_mode: Option<String>,
168 #[arg(
169 long = "verbosity",
170 value_name = "LEVEL",
171 help = "Controls transcript and output verbosity (normal, concise)"
172 )]
173 verbosity: Option<String>,
174 #[arg(long = "log-level")]
175 log_level: Option<String>,
176 #[arg(long)]
177 telemetry: Option<bool>,
178 #[arg(long)]
179 approval_policy: Option<String>,
180 #[arg(long)]
181 sandbox_mode: Option<String>,
182 #[arg(long)]
183 api_key: Option<String>,
184 #[arg(long)]
185 base_url: Option<String>,
186 #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
188 workspace: Option<PathBuf>,
189 #[arg(long = "no-alt-screen", hide = true)]
190 no_alt_screen: bool,
191 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
192 mouse_capture: bool,
193 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
194 no_mouse_capture: bool,
195 #[arg(long = "skip-onboarding")]
196 skip_onboarding: bool,
197 #[arg(long, hide = true)]
199 yolo: bool,
200 #[arg(short = 'c', long = "continue")]
202 continue_session: bool,
203 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
204 prompt_flag: Option<String>,
205 #[arg(
206 value_name = "PROMPT",
207 trailing_var_arg = true,
208 allow_hyphen_values = true
209 )]
210 prompt: Vec<String>,
211 #[command(subcommand)]
212 command: Option<Commands>,
213}
214
215#[derive(Debug, Subcommand)]
216enum Commands {
217 Run(RunArgs),
219 Doctor(TuiPassthroughArgs),
221 Models(TuiPassthroughArgs),
223 #[command(visible_alias = "tts")]
225 Speech(TuiPassthroughArgs),
226 Sessions(TuiPassthroughArgs),
228 Resume(TuiPassthroughArgs),
230 Fork(TuiPassthroughArgs),
232 Init(TuiPassthroughArgs),
234 Setup(TuiPassthroughArgs),
236 RemoteSetup(RemoteSetupArgs),
238 #[command(after_help = "\
240Examples:
241 codewhale exec \"explain this function\"
242 codewhale exec --auto \"list crates/ with ls\"
243 codewhale exec --auto --output-format stream-json \"fix the failing test\"
244
245Common forwarded flags:
246 --auto Enable tool-backed agent mode with auto-approvals
247 --json Emit summary JSON
248 --resume <SESSION_ID> Resume a previous session by ID or prefix
249 --session-id <SESSION_ID> Resume a previous session by ID or prefix
250 --continue Continue the most recent session for this workspace
251 --output-format <FORMAT> Output format: text or stream-json
252
253Plain `codewhale exec` is a one-shot model response. Use `--auto` for
254non-interactive filesystem/shell tool use, matching the supported automation
255path used by stream-json wrappers.
256")]
257 Exec(TuiPassthroughArgs),
258 Fleet(TuiPassthroughArgs),
260 #[command(name = "workflow-tool", hide = true)]
262 WorkflowTool(TuiPassthroughArgs),
263 #[command(name = "lane-log-proxy", hide = true)]
265 LaneLogProxy(LaneLogProxyArgs),
266 #[command(after_help = "\
268Examples:
269 codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
270 codewhale workflow run stopship --fleet stopship --runtime inline --verify
271
272`workflow run` validates the checked-in Workflow source and named Fleet roster,
273creates a Lane record, then dispatches the Workflow tool directly through the
274selected Runtime backend without an operator model turn.
275")]
276 Workflow(WorkflowArgs),
277 #[command(after_help = "\
279Examples:
280 codewhale lane list
281 codewhale lane status <lane-id>
282 codewhale lane attach <lane-id>
283 codewhale lane logs <lane-id>
284 codewhale lane stop <lane-id>
285 codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
286
287Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
288Runtime, not Fleet.
289")]
290 Lane(LaneArgs),
291 Review(TuiPassthroughArgs),
293 Apply(TuiPassthroughArgs),
295 Eval(TuiPassthroughArgs),
297 Mcp(TuiPassthroughArgs),
299 Features(TuiPassthroughArgs),
301 #[command(after_help = "\
303Forwarded serve options:
304 --mcp Start MCP server over stdio
305 --http Start runtime HTTP/SSE API server
306 --mobile Start runtime HTTP/SSE API server with the mobile control page
307 --qr Show a QR code for the mobile URL (requires --mobile)
308 --acp Start ACP server over stdio for editor clients
309 --host <HOST> Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
310 --port <PORT> Bind port [default: 7878]
311 --workers <WORKERS> Background task worker count (1-8)
312 --cors-origin <URL> Additional CORS origin to allow (repeatable)
313 --auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
314 --insecure Disable runtime API auth when no token is configured
315
316`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
317aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
318New integrations should prefer `codewhale app-server`.")]
319 Serve(TuiPassthroughArgs),
320 Completions(TuiPassthroughArgs),
322 Login(LoginArgs),
324 Logout,
326 Auth(AuthArgs),
328 McpServer,
330 Config(ConfigArgs),
332 Model(ModelArgs),
334 Thread(ThreadArgs),
336 Sandbox(SandboxArgs),
338 #[command(after_help = "\
340Transports:
341 codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
342 codewhale app-server --mobile Runtime API + phone control page (binds 0.0.0.0)
343 codewhale app-server --stdio JSON-RPC control transport over stdio (no listener)
344 codewhale app-server Legacy in-process app-server HTTP on 127.0.0.1:8787
345
346`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
347--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
348is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
349
350See docs/RUNTIME_API.md.")]
351 AppServer(AppServerArgs),
352 #[command(after_help = r#"Examples:
354 Bash (current shell only):
355 source <(codewhale completion bash)
356
357 Bash (persistent, Linux/bash-completion):
358 mkdir -p ~/.local/share/bash-completion/completions
359 codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
360 # Requires bash-completion to be installed and loaded by your shell.
361
362 Zsh:
363 mkdir -p ~/.zfunc
364 codewhale completion zsh > ~/.zfunc/_codewhale
365 # Add to ~/.zshrc if needed:
366 # fpath=(~/.zfunc $fpath)
367 # autoload -Uz compinit && compinit
368
369 Fish:
370 mkdir -p ~/.config/fish/completions
371 codewhale completion fish > ~/.config/fish/completions/codewhale.fish
372
373 PowerShell (current shell only):
374 codewhale completion powershell | Out-String | Invoke-Expression
375
376The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
377 Completion {
378 #[arg(value_enum)]
379 shell: Shell,
380 },
381 Metrics(MetricsArgs),
383 Update(UpdateArgs),
385}
386
387fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
388 matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
389}
390
391fn top_level_provider_override(
392 provider: Option<&str>,
393 command: Option<&Commands>,
394) -> Result<Option<ProviderKind>> {
395 let Some(provider) = provider else {
396 return Ok(None);
397 };
398 if let Some(provider) = builtin_provider_arg(provider) {
399 return Ok(Some(provider.into()));
400 }
401 if command_accepts_raw_provider(command) {
402 return Ok(None);
403 }
404
405 let expected = ProviderArg::value_variants()
406 .iter()
407 .filter_map(ValueEnum::to_possible_value)
408 .map(|value| value.get_name().to_string())
409 .collect::<Vec<_>>()
410 .join(", ");
411 bail!(
412 "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
413 )
414}
415
416fn prepare_raw_provider_tui_dispatch(
417 cli: &Cli,
418 command: Option<&Commands>,
419 runtime_overrides: &CliRuntimeOverrides,
420) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
421 let Some(provider) = cli.provider.as_deref() else {
422 return Ok(None);
423 };
424 if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
425 return Ok(None);
426 }
427
428 let passthrough = match command {
429 Some(Commands::Exec(args)) => {
430 reject_exec_global_flags(&args.args)?;
431 tui_args("exec", args.clone())
432 }
433 Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
434 _ => unreachable!("raw provider validation only permits Exec and Fleet"),
435 };
436
437 let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
441 Ok(Some((resolved_runtime, passthrough)))
442}
443
444#[derive(Debug, Args)]
445struct UpdateArgs {
446 #[arg(long)]
448 beta: bool,
449 #[arg(long)]
451 check: bool,
452 #[arg(long, value_name = "URL")]
454 proxy: Option<String>,
455}
456
457#[derive(Debug, Args)]
458struct MetricsArgs {
459 #[arg(long)]
461 json: bool,
462 #[arg(long, value_name = "DURATION")]
464 since: Option<String>,
465}
466
467#[derive(Debug, Args)]
468struct RunArgs {
469 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
470 args: Vec<String>,
471}
472
473#[derive(Debug, Args, Clone)]
474struct TuiPassthroughArgs {
475 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
476 args: Vec<String>,
477}
478
479#[derive(Debug, Args)]
480struct LaneLogProxyArgs {
481 #[arg(long, value_name = "PATH")]
482 log_path: PathBuf,
483 #[arg(long, value_name = "PATH")]
484 receipt_path: PathBuf,
485 #[arg(long, value_name = "PATH")]
486 receipt_tmp_path: PathBuf,
487 #[arg(long, value_name = "PATH")]
488 environment_path: Option<PathBuf>,
489 #[arg(long)]
490 lane_id: String,
491 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
492 command: Vec<String>,
493}
494
495#[derive(Debug, Args)]
497struct LaneArgs {
498 #[command(subcommand)]
499 command: LaneCommand,
500}
501
502#[derive(Debug, Subcommand)]
503#[allow(clippy::large_enum_variant)]
507enum LaneCommand {
508 List {
510 #[arg(long, default_value_t = false)]
512 json: bool,
513 },
514 Status {
516 lane_id: String,
518 #[arg(long, default_value_t = false)]
519 json: bool,
520 },
521 Attach {
523 lane_id: String,
524 #[arg(long, default_value_t = false)]
526 print: bool,
527 },
528 Logs {
530 lane_id: String,
531 #[arg(long, short = 'f', default_value_t = false)]
533 follow: bool,
534 #[arg(long, default_value_t = 50)]
536 tail: usize,
537 },
538 Stop { lane_id: String },
540 Start {
542 #[arg(long)]
544 workflow: Option<String>,
545 #[arg(long)]
547 fleet: Option<String>,
548 #[arg(long)]
550 issue: Option<String>,
551 #[arg(long)]
553 goal: Option<String>,
554 #[arg(long, default_value = "tmux")]
556 runtime: String,
557 #[arg(long, value_name = "DIR")]
559 worktree_repo: Option<PathBuf>,
560 #[arg(long)]
562 branch: Option<String>,
563 #[arg(long, value_name = "DIR")]
565 worktree_path: Option<PathBuf>,
566 #[arg(long)]
568 worktree_ttl_secs: Option<u64>,
569 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
571 command: Vec<String>,
572 },
573}
574
575#[derive(Debug, Args)]
577struct WorkflowArgs {
578 #[command(subcommand)]
579 command: WorkflowCommand,
580}
581
582#[derive(Debug, Subcommand)]
583enum WorkflowCommand {
584 Run {
586 workflow: String,
588 #[arg(long)]
590 fleet: String,
591 #[arg(long)]
593 issue: Option<String>,
594 #[arg(long)]
596 goal: Option<String>,
597 #[arg(long, default_value = "tmux")]
599 runtime: String,
600 #[arg(long, value_name = "PATH")]
602 source_path: Option<PathBuf>,
603 #[arg(long)]
605 token_budget: Option<u64>,
606 #[arg(long, default_value_t = false)]
608 verify: bool,
609 #[arg(long, value_name = "DIR")]
611 worktree_repo: Option<PathBuf>,
612 #[arg(long)]
614 branch: Option<String>,
615 #[arg(long, value_name = "DIR")]
617 worktree_path: Option<PathBuf>,
618 #[arg(long)]
620 worktree_ttl_secs: Option<u64>,
621 },
622}
623
624struct LaneStartRequest {
625 workflow: Option<String>,
626 fleet: Option<String>,
627 issue: Option<String>,
628 goal: Option<String>,
629 runtime: String,
630 worktree_repo: Option<PathBuf>,
631 branch: Option<String>,
632 worktree_path: Option<PathBuf>,
633 worktree_ttl_secs: Option<u64>,
634 command: Vec<String>,
635 environment: Vec<(String, String)>,
636 cwd: Option<PathBuf>,
637}
638
639fn start_lane(request: LaneStartRequest) -> Result<()> {
640 use codewhale_lane::{
641 LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
642 };
643
644 let LaneStartRequest {
645 workflow,
646 fleet,
647 issue,
648 goal,
649 runtime,
650 worktree_repo,
651 branch,
652 worktree_path,
653 worktree_ttl_secs,
654 command,
655 environment,
656 cwd,
657 } = request;
658 let kind = RuntimeBackendKind::parse(&runtime)?;
659 let reg = LaneRegistry::open_default()?;
660 let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
661 let worktree = match (worktree_repo, branch) {
662 (Some(repo_root), Some(branch_name)) => {
663 let path = worktree_path
664 .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
665 Some(WorktreeProvision {
666 repo_root,
667 branch: branch_name,
668 path,
669 base_ref: None,
670 })
671 }
672 (None, None) => None,
673 _ => bail!("--worktree-repo and --branch must be provided together"),
674 };
675 let cmd = if command.is_empty() {
676 vec![
677 "sh".into(),
678 "-c".into(),
679 format!("echo lane {} started", record.id),
680 ]
681 } else {
682 command
683 };
684 let spec = LaneStartSpec {
685 command: cmd,
686 cwd,
687 environment,
688 log_proxy: (kind == RuntimeBackendKind::Tmux)
689 .then(std::env::current_exe)
690 .transpose()
691 .context("resolve current Codewhale executable for tmux log proxy")?,
692 worktree,
693 };
694 let backend = resolve_backend(kind);
695 backend.start(®, &mut record, &spec)?;
696 println!("started {}", record.id);
697 println!("status: {}", record.status.as_str());
698 println!("runtime: {}", record.runtime.as_str());
699 println!("log: {}", record.log_path.display());
700 if let Some(attach) = backend.attach_command(&record) {
701 println!("attach: {attach}");
702 }
703 Ok(())
704}
705
706fn run_lane_command(args: LaneArgs) -> Result<()> {
707 use codewhale_lane::{LaneRegistry, backend_for};
708 use std::io::{BufRead, Seek, Write};
709 use std::process::Command;
710 use std::thread;
711 use std::time::Duration;
712
713 match args.command {
714 LaneCommand::List { json } => {
715 let reg = LaneRegistry::open_default()?;
716 let mut lanes = reg.list()?;
717 for lane in &mut lanes {
718 if let Err(err) = backend_for(lane).reconcile(®, lane) {
719 eprintln!("warning: could not reconcile lane `{}`: {err:#}", lane.id);
720 }
721 }
722 if json {
723 println!("{}", serde_json::to_string_pretty(&lanes)?);
724 } else if lanes.is_empty() {
725 println!("No lanes under {}", reg.root().display());
726 } else {
727 println!(
728 "{:<16} {:<10} {:<12} {:<16} {:<10} STARTED",
729 "ID", "STATUS", "RUNTIME", "WORKFLOW", "ISSUE"
730 );
731 for lane in lanes {
732 println!(
733 "{:<16} {:<10} {:<12} {:<16} {:<10} {}",
734 lane.id,
735 lane.status.as_str(),
736 lane.runtime.as_str(),
737 lane.workflow.as_deref().unwrap_or("-"),
738 lane.issue.as_deref().unwrap_or("-"),
739 lane.started_at,
740 );
741 }
742 }
743 Ok(())
744 }
745 LaneCommand::Status { lane_id, json } => {
746 let reg = LaneRegistry::open_default()?;
747 let mut lane = reg.load(&lane_id)?;
748 backend_for(&lane).reconcile(®, &mut lane)?;
749 if json {
750 println!("{}", serde_json::to_string_pretty(&lane)?);
751 } else {
752 println!("lane: {}", lane.id);
753 println!("status: {}", lane.status.as_str());
754 println!("runtime: {}", lane.runtime.as_str());
755 println!("workflow: {}", lane.workflow.as_deref().unwrap_or("-"));
756 println!("fleet: {}", lane.fleet.as_deref().unwrap_or("-"));
757 println!("issue: {}", lane.issue.as_deref().unwrap_or("-"));
758 println!("goal: {}", lane.goal.as_deref().unwrap_or("-"));
759 println!("started: {}", lane.started_at);
760 println!("stopped: {}", lane.stopped_at.as_deref().unwrap_or("-"));
761 println!(
762 "worktree: {}",
763 lane.worktree_path
764 .as_ref()
765 .map(|p| p.display().to_string())
766 .unwrap_or_else(|| "-".into())
767 );
768 println!("branch: {}", lane.branch.as_deref().unwrap_or("-"));
769 println!("tmux: {}", lane.tmux_session.as_deref().unwrap_or("-"));
770 println!(
771 "socket: {}",
772 lane.tmux_socket
773 .as_ref()
774 .map(|path| path.display().to_string())
775 .unwrap_or_else(|| "-".to_string())
776 );
777 println!("attach: {}", lane.attach_target.as_deref().unwrap_or("-"));
778 println!("log: {}", lane.log_path.display());
779 }
780 Ok(())
781 }
782 LaneCommand::Attach { lane_id, print } => {
783 let reg = LaneRegistry::open_default()?;
784 let mut lane = reg.load(&lane_id)?;
785 let backend = backend_for(&lane);
786 backend.reconcile(®, &mut lane)?;
787 let Some(attach) = backend.attach_command(&lane) else {
788 if !lane.status.is_active() {
789 bail!(
790 "lane `{lane_id}` is {} and has no active attach target",
791 lane.status.as_str()
792 );
793 }
794 bail!(
795 "lane `{lane_id}` runtime `{}` has no attach target",
796 lane.runtime.as_str()
797 );
798 };
799 if print {
800 println!("{attach}");
801 return Ok(());
802 }
803 if let Some(session) = lane.tmux_session.as_deref() {
804 let socket = lane
805 .tmux_socket
806 .as_deref()
807 .context("tmux lane is missing its pinned server socket")?;
808 let status = Command::new("tmux")
809 .arg("-S")
810 .arg(socket)
811 .args(["attach", "-t", session])
812 .status();
813 match status {
814 Ok(s) if s.success() => Ok(()),
815 Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
816 Err(err) => {
817 eprintln!("could not exec tmux: {err}");
818 println!("{attach}");
819 bail!("tmux attach unavailable");
820 }
821 }
822 } else {
823 println!("{attach}");
824 Ok(())
825 }
826 }
827 LaneCommand::Logs {
828 lane_id,
829 follow,
830 tail,
831 } => {
832 let reg = LaneRegistry::open_default()?;
833 let lane = reg.load(&lane_id)?;
834 let path = lane.log_path;
835 if !path.exists() {
836 bail!("log file missing: {}", path.display());
837 }
838 let content = std::fs::read(&path)?;
839 let lines: Vec<&[u8]> = content
840 .split(|byte| *byte == b'\n')
841 .filter(|line| !line.is_empty())
842 .collect();
843 let start = lines.len().saturating_sub(tail);
844 let mut stdout = std::io::stdout().lock();
845 for line in &lines[start..] {
846 stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
847 stdout.write_all(b"\n")?;
848 }
849 stdout.flush()?;
850 if !follow {
851 return Ok(());
852 }
853 let mut file = std::fs::File::open(&path)?;
854 file.seek(std::io::SeekFrom::End(0))?;
855 let mut reader = std::io::BufReader::new(file);
856 loop {
857 let mut line = Vec::new();
858 match reader.read_until(b'\n', &mut line) {
859 Ok(0) => {
860 thread::sleep(Duration::from_millis(200));
861 continue;
862 }
863 Ok(_) => {
864 let mut stdout = std::io::stdout().lock();
865 stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
866 stdout.flush()?;
867 }
868 Err(err) => return Err(err.into()),
869 }
870 }
871 }
872 LaneCommand::Stop { lane_id } => {
873 let reg = LaneRegistry::open_default()?;
874 let mut lane = reg.load(&lane_id)?;
875 let backend = backend_for(&lane);
876 backend.stop(®, &mut lane)?;
877 println!("stopped {}", lane.id);
878 Ok(())
879 }
880 LaneCommand::Start {
881 workflow,
882 fleet,
883 issue,
884 goal,
885 runtime,
886 worktree_repo,
887 branch,
888 worktree_path,
889 worktree_ttl_secs,
890 command,
891 } => start_lane(LaneStartRequest {
892 workflow,
893 fleet,
894 issue,
895 goal,
896 runtime,
897 worktree_repo,
898 branch,
899 worktree_path,
900 worktree_ttl_secs,
901 command,
902 environment: Vec::new(),
903 cwd: None,
904 }),
905 }
906}
907
908fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
909 let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
910 command: args.command,
911 log_path: args.log_path,
912 receipt_path: args.receipt_path,
913 receipt_tmp_path: args.receipt_tmp_path,
914 environment_path: args.environment_path,
915 lane_id: args.lane_id,
916 })?;
917 std::process::exit(exit_code);
918}
919
920fn run_workflow_command(
921 cli: &Cli,
922 resolved_runtime: &ResolvedRuntimeOptions,
923 config_path: &Path,
924 args: WorkflowArgs,
925) -> Result<()> {
926 match args.command {
927 WorkflowCommand::Run {
928 workflow,
929 fleet,
930 issue,
931 goal,
932 runtime,
933 source_path,
934 token_budget,
935 verify,
936 worktree_repo,
937 branch,
938 worktree_path,
939 worktree_ttl_secs,
940 } => {
941 let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
942 let source_path =
943 resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
944 validate_workflow_source_file(&source_path)?;
945
946 let source_root = if let Some(repo) = worktree_repo.as_deref() {
947 repo.canonicalize()
948 .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
949 } else {
950 workspace.clone()
951 };
952
953 let roots = named_fleet_search_roots(&workspace);
954 let named_fleet = codewhale_workflow::load_named_fleet(&fleet, &roots)
955 .with_context(|| format!("load fleet `{fleet}` from {}", display_roots(&roots)))?;
956 if workflow == "stopship" || fleet == "stopship" || fleet == "v0868-stopship" {
957 named_fleet
958 .validate_stopship_roles()
959 .with_context(|| format!("validate stopship roles in fleet `{fleet}`"))?;
960 }
961
962 let process = workflow_exec_command(WorkflowExecSpec {
963 cli,
964 resolved_runtime,
965 config_path,
966 source_root: &source_root,
967 source_path: &source_path,
968 workflow: &workflow,
969 fleet: &fleet,
970 issue: issue.as_deref(),
971 goal: goal.as_deref(),
972 token_budget,
973 verify,
974 })?;
975 start_lane(LaneStartRequest {
976 workflow: Some(workflow),
977 fleet: Some(fleet),
978 issue,
979 goal,
980 runtime,
981 worktree_repo,
982 branch,
983 worktree_path,
984 worktree_ttl_secs,
985 command: process.command,
986 environment: process.environment,
987 cwd: Some(workspace),
988 })
989 }
990 }
991}
992
993fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
994 if let Some(path) = explicit {
995 return path
996 .canonicalize()
997 .with_context(|| format!("resolve workflow workspace {}", path.display()));
998 }
999 let cwd = std::env::current_dir().context("resolve current directory")?;
1000 let output = Command::new("git")
1001 .args(["rev-parse", "--show-toplevel"])
1002 .current_dir(&cwd)
1003 .output();
1004 if let Ok(output) = output
1005 && output.status.success()
1006 {
1007 let text = String::from_utf8_lossy(&output.stdout);
1008 let root = text.trim();
1009 if !root.is_empty() {
1010 let root = PathBuf::from(root);
1011 return Ok(root.canonicalize().unwrap_or(root));
1012 }
1013 }
1014 Ok(cwd)
1015}
1016
1017fn resolve_workflow_source_path(
1018 workflow: &str,
1019 source_path: Option<&PathBuf>,
1020 workspace: &Path,
1021) -> Result<PathBuf> {
1022 let candidates = workflow_source_candidates(workflow, source_path, workspace);
1023 for candidate in &candidates {
1024 if candidate.is_file() {
1025 return Ok(candidate.clone());
1026 }
1027 }
1028 bail!(
1029 "workflow source for `{workflow}` not found; tried {}",
1030 candidates
1031 .iter()
1032 .map(|p| p.display().to_string())
1033 .collect::<Vec<_>>()
1034 .join(", ")
1035 )
1036}
1037
1038fn workflow_source_candidates(
1039 workflow: &str,
1040 source_path: Option<&PathBuf>,
1041 workspace: &Path,
1042) -> Vec<PathBuf> {
1043 let mut candidates = Vec::new();
1044 if let Some(path) = source_path {
1045 candidates.push(resolve_against_workspace(path, workspace));
1046 return candidates;
1047 }
1048
1049 let raw = workflow.trim();
1050 let workflow_path = PathBuf::from(raw);
1051 if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1052 candidates.push(resolve_against_workspace(&workflow_path, workspace));
1053 return candidates;
1054 }
1055
1056 let normalized = raw.replace('-', "_");
1057 for rel in [
1058 format!("workflows/{raw}.workflow.js"),
1059 format!("workflows/{normalized}.workflow.js"),
1060 ] {
1061 let path = workspace.join(rel);
1062 if !candidates.iter().any(|existing| existing == &path) {
1063 candidates.push(path);
1064 }
1065 }
1066 candidates
1067}
1068
1069fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1070 if path.is_absolute() {
1071 path.to_path_buf()
1072 } else {
1073 workspace.join(path)
1074 }
1075}
1076
1077fn validate_workflow_source_file(path: &Path) -> Result<()> {
1078 let source =
1079 std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1080 if source.trim_start().starts_with("export default workflow(")
1081 || source.trim_start().starts_with("workflow(")
1082 || source.contains("\nworkflow(")
1083 {
1084 let identifier = path.display().to_string();
1085 if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1086 codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1087 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1088 } else {
1089 codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1090 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1091 }
1092 }
1093 Ok(())
1094}
1095
1096fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1097 let mut roots = Vec::new();
1098 if let Ok(home) = codewhale_config::codewhale_home() {
1099 roots.push(home);
1100 }
1101 roots.push(workspace.to_path_buf());
1102 roots
1103}
1104
1105fn display_roots(roots: &[PathBuf]) -> String {
1106 roots
1107 .iter()
1108 .map(|root| root.display().to_string())
1109 .collect::<Vec<_>>()
1110 .join(", ")
1111}
1112
1113struct WorkflowExecSpec<'a> {
1114 cli: &'a Cli,
1115 resolved_runtime: &'a ResolvedRuntimeOptions,
1116 config_path: &'a Path,
1117 source_root: &'a Path,
1118 source_path: &'a Path,
1119 workflow: &'a str,
1120 fleet: &'a str,
1121 issue: Option<&'a str>,
1122 goal: Option<&'a str>,
1123 token_budget: Option<u64>,
1124 verify: bool,
1125}
1126
1127struct WorkflowProcessSpec {
1128 command: Vec<String>,
1129 environment: Vec<(String, String)>,
1130}
1131
1132fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1133 let WorkflowExecSpec {
1134 cli,
1135 resolved_runtime,
1136 config_path,
1137 source_root,
1138 source_path,
1139 workflow,
1140 fleet,
1141 issue,
1142 goal,
1143 token_budget,
1144 verify,
1145 } = spec;
1146 let source_arg = source_path
1147 .strip_prefix(source_root)
1148 .with_context(|| {
1149 format!(
1150 "workflow source {} must be inside execution root {}",
1151 source_path.display(),
1152 source_root.display()
1153 )
1154 })?
1155 .display()
1156 .to_string();
1157 let mut payload = serde_json::json!({
1158 "action": "run",
1159 "source_path": source_arg,
1160 "fleet": fleet,
1161 "args": {
1162 "workflow": workflow,
1163 "fleet": fleet,
1164 "issue": issue,
1165 "goal": goal,
1166 },
1167 "verify": verify,
1168 });
1169 if let Some(token_budget) = token_budget {
1170 payload["token_budget"] = serde_json::json!(token_budget);
1171 }
1172 let input_json = serde_json::to_string(&payload)?;
1173 let passthrough = vec![
1174 "workflow-tool".to_string(),
1175 "--approval-source".to_string(),
1176 "explicit-workflow-command".to_string(),
1177 "--input-json".to_string(),
1178 input_json,
1179 ];
1180 let command =
1181 build_tui_command_with_paths(cli, resolved_runtime, passthrough, Some(config_path), None)?;
1182 lane_process_spec_from_command(&command)
1183}
1184
1185fn valid_lane_environment_key(key: &str) -> bool {
1186 let mut chars = key.chars();
1187 chars
1188 .next()
1189 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1190 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1191}
1192
1193fn shell_owned_lane_environment(key: &str) -> bool {
1194 matches!(
1195 key,
1196 "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1197 )
1198}
1199
1200fn lane_process_spec_from_command(command: &Command) -> Result<WorkflowProcessSpec> {
1201 let mut argv = Vec::new();
1202 argv.push(command.get_program().to_string_lossy().into_owned());
1203 argv.extend(
1204 command
1205 .get_args()
1206 .map(|arg| arg.to_string_lossy().into_owned()),
1207 );
1208 let mut environment = std::collections::BTreeMap::new();
1209 for (key, value) in std::env::vars_os() {
1210 let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1211 continue;
1212 };
1213 if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1214 environment.insert(key.to_string(), value.to_string());
1215 }
1216 }
1217 for (key, value) in command.get_envs() {
1218 let key = key
1219 .to_str()
1220 .context("workflow runtime environment key is not UTF-8")?
1221 .to_string();
1222 if let Some(value) = value {
1223 environment.insert(
1224 key,
1225 value
1226 .to_str()
1227 .context("workflow runtime environment value is not UTF-8")?
1228 .to_string(),
1229 );
1230 } else {
1231 environment.remove(&key);
1232 }
1233 }
1234 Ok(WorkflowProcessSpec {
1235 command: argv,
1236 environment: environment.into_iter().collect(),
1237 })
1238}
1239
1240#[derive(Debug, Args, Clone, Default)]
1243struct RemoteSetupArgs {
1244 #[arg(long)]
1246 cloud: Option<String>,
1247 #[arg(long)]
1249 bridge: Option<String>,
1250 #[arg(long)]
1252 provider: Option<String>,
1253 #[arg(long, value_name = "DIR")]
1255 out: Option<PathBuf>,
1256 #[arg(long, default_value_t = false)]
1258 generate_only: bool,
1259 #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
1261 apply: bool,
1262 #[arg(long, default_value_t = false)]
1264 yes: bool,
1265 #[arg(long, default_value_t = false)]
1267 non_interactive: bool,
1268}
1269
1270fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1274 let mut forwarded = vec!["remote-setup".to_string()];
1275 if let Some(cloud) = args.cloud {
1276 forwarded.push("--cloud".to_string());
1277 forwarded.push(cloud);
1278 }
1279 if let Some(bridge) = args.bridge {
1280 forwarded.push("--bridge".to_string());
1281 forwarded.push(bridge);
1282 }
1283 if let Some(provider) = args.provider {
1284 forwarded.push("--provider".to_string());
1285 forwarded.push(provider);
1286 }
1287 if let Some(out) = args.out {
1288 forwarded.push("--out".to_string());
1289 forwarded.push(out.to_string_lossy().into_owned());
1290 }
1291 if args.generate_only {
1292 forwarded.push("--generate-only".to_string());
1293 }
1294 if args.apply {
1295 forwarded.push("--apply".to_string());
1296 }
1297 if args.yes {
1298 forwarded.push("--yes".to_string());
1299 }
1300 if args.non_interactive {
1301 forwarded.push("--non-interactive".to_string());
1302 }
1303 forwarded
1304}
1305
1306#[derive(Debug, Args)]
1307struct LoginArgs {
1308 #[arg(long, value_enum, hide = true)]
1309 provider: Option<ProviderArg>,
1310 #[arg(long)]
1311 api_key: Option<String>,
1312}
1313
1314#[derive(Debug, Args)]
1315struct AuthArgs {
1316 #[command(subcommand)]
1317 command: AuthCommand,
1318}
1319
1320#[derive(Debug, Subcommand)]
1321enum AuthCommand {
1322 #[command(name = "xai-device")]
1324 XaiDevice,
1325 Status {
1329 #[arg(long, value_enum)]
1331 provider: Option<ProviderArg>,
1332 },
1333 Set {
1337 #[arg(long, value_enum)]
1338 provider: ProviderArg,
1339 #[arg(long)]
1341 api_key: Option<String>,
1342 #[arg(long = "api-key-stdin", default_value_t = false)]
1344 api_key_stdin: bool,
1345 },
1346 Get {
1349 #[arg(long, value_enum)]
1350 provider: ProviderArg,
1351 },
1352 Clear {
1354 #[arg(long, value_enum)]
1355 provider: ProviderArg,
1356 },
1357 List,
1360 #[command(hide = true)]
1362 Migrate {
1363 #[arg(long, default_value_t = false)]
1365 dry_run: bool,
1366 },
1367}
1368
1369#[derive(Debug, Args)]
1370struct ConfigArgs {
1371 #[command(subcommand)]
1372 command: ConfigCommand,
1373}
1374
1375#[derive(Debug, Subcommand)]
1376enum ConfigCommand {
1377 Get { key: String },
1378 Set { key: String, value: String },
1379 Unset { key: String },
1380 List,
1381 Path,
1382}
1383
1384#[derive(Debug, Args)]
1385struct ModelArgs {
1386 #[command(subcommand)]
1387 command: ModelCommand,
1388}
1389
1390#[derive(Debug, Subcommand)]
1391enum ModelCommand {
1392 List {
1393 #[arg(long, value_enum)]
1394 provider: Option<ProviderArg>,
1395 },
1396 Resolve {
1397 model: Option<String>,
1398 #[arg(long, value_enum)]
1399 provider: Option<ProviderArg>,
1400 },
1401 Set { model: String },
1403}
1404
1405#[derive(Debug, Args)]
1406struct ThreadArgs {
1407 #[command(subcommand)]
1408 command: ThreadCommand,
1409}
1410
1411#[derive(Debug, Subcommand)]
1412enum ThreadCommand {
1413 List {
1414 #[arg(long, default_value_t = false)]
1415 all: bool,
1416 #[arg(long)]
1417 limit: Option<usize>,
1418 },
1419 Read {
1420 thread_id: String,
1421 },
1422 Resume {
1423 thread_id: String,
1424 },
1425 Fork {
1426 thread_id: String,
1427 },
1428 Archive {
1429 thread_id: String,
1430 },
1431 Unarchive {
1432 thread_id: String,
1433 },
1434 SetName {
1435 thread_id: String,
1436 name: String,
1437 },
1438 ClearName {
1441 thread_id: String,
1442 },
1443}
1444
1445#[derive(Debug, Args)]
1446struct SandboxArgs {
1447 #[command(subcommand)]
1448 command: SandboxCommand,
1449}
1450
1451#[derive(Debug, Subcommand)]
1452enum SandboxCommand {
1453 Check {
1454 command: String,
1455 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1456 ask: ApprovalModeArg,
1457 },
1458}
1459
1460#[derive(Debug, Clone, Copy, ValueEnum)]
1461enum ApprovalModeArg {
1462 UnlessTrusted,
1463 OnFailure,
1464 OnRequest,
1465 Never,
1466}
1467
1468impl From<ApprovalModeArg> for AskForApproval {
1469 fn from(value: ApprovalModeArg) -> Self {
1470 match value {
1471 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1472 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1473 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1474 ApprovalModeArg::Never => AskForApproval::Never,
1475 }
1476 }
1477}
1478
1479#[derive(Debug, Args)]
1480struct AppServerArgs {
1481 #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1485 http: bool,
1486 #[arg(long, conflicts_with = "stdio")]
1489 mobile: bool,
1490 #[arg(long, default_value_t = false)]
1493 stdio: bool,
1494 #[arg(long, requires = "mobile")]
1496 qr: bool,
1497 #[arg(long)]
1500 host: Option<String>,
1501 #[arg(long)]
1504 port: Option<u16>,
1505 #[arg(long)]
1507 workers: Option<usize>,
1508 #[arg(long)]
1509 config: Option<PathBuf>,
1510 #[arg(long = "auth-token")]
1511 auth_token: Option<String>,
1512 #[arg(long, default_value_t = false)]
1513 insecure_no_auth: bool,
1514 #[arg(long = "cors-origin")]
1515 cors_origin: Vec<String>,
1516}
1517
1518const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
1519
1520fn install_rustls_crypto_provider() {
1521 let _ = rustls::crypto::ring::default_provider().install_default();
1522}
1523
1524pub fn run_cli() -> std::process::ExitCode {
1525 install_rustls_crypto_provider();
1526
1527 match run() {
1528 Ok(()) => std::process::ExitCode::SUCCESS,
1529 Err(err) => {
1530 eprintln!("error: {err}");
1538 for cause in err.chain().skip(1) {
1539 eprintln!(" caused by: {cause}");
1540 }
1541 std::process::ExitCode::FAILURE
1542 }
1543 }
1544}
1545
1546fn split_lane_log_proxy_command(
1547 command: Option<Commands>,
1548) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
1549 match command {
1550 Some(Commands::LaneLogProxy(args)) => (Some(args), None),
1551 command => (None, command),
1552 }
1553}
1554
1555fn run() -> Result<()> {
1556 let mut cli = Cli::parse();
1557
1558 let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
1562 if let Some(args) = proxy {
1563 return run_lane_log_proxy_command(args);
1564 }
1565
1566 let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
1567 let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
1568 let runtime_overrides = CliRuntimeOverrides {
1569 provider: runtime_provider,
1570 model: cli.model.clone(),
1571 api_key: cli.api_key.clone(),
1572 base_url: cli.base_url.clone(),
1573 auth_mode: None,
1574 output_mode: cli.output_mode.clone(),
1575 log_level: cli.log_level.clone(),
1576 telemetry: cli.telemetry,
1577 approval_policy: cli.approval_policy.clone(),
1578 sandbox_mode: cli.sandbox_mode.clone(),
1579 yolo: Some(cli.yolo),
1580 verbosity: cli.verbosity.clone(),
1581 };
1582 if uses_raw_tui_provider
1583 && let Some((resolved_runtime, passthrough)) =
1584 prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
1585 {
1586 return delegate_to_tui(&cli, &resolved_runtime, passthrough);
1587 }
1588
1589 let mut store = ConfigStore::load(cli.config.clone())?;
1590 match command {
1591 Some(Commands::Run(args)) => {
1592 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1593 delegate_to_tui(&cli, &resolved_runtime, args.args)
1594 }
1595 Some(Commands::Doctor(args)) => {
1596 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1597 delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
1598 }
1599 Some(Commands::Models(args)) => {
1600 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1601 delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
1602 }
1603 Some(Commands::Speech(args)) => {
1604 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1605 delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
1606 }
1607 Some(Commands::Sessions(args)) => {
1608 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1609 delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
1610 }
1611 Some(Commands::Resume(args)) => {
1612 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1613 run_resume_command(&cli, &resolved_runtime, args)
1614 }
1615 Some(Commands::Fork(args)) => {
1616 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1617 delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
1618 }
1619 Some(Commands::Init(args)) => {
1620 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1621 delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
1622 }
1623 Some(Commands::Setup(args)) => {
1624 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1625 delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
1626 }
1627 Some(Commands::RemoteSetup(args)) => {
1628 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1629 delegate_to_tui(&cli, &resolved_runtime, remote_setup_tui_args(args))
1630 }
1631 Some(Commands::Exec(args)) => {
1632 reject_exec_global_flags(&args.args)?;
1633 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1634 delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
1635 }
1636 Some(Commands::Fleet(args)) => {
1637 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1638 delegate_to_tui(&cli, &resolved_runtime, tui_args("fleet", args))
1639 }
1640 Some(Commands::WorkflowTool(args)) => {
1641 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1642 delegate_to_tui(&cli, &resolved_runtime, tui_args("workflow-tool", args))
1643 }
1644 Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
1645 Some(Commands::Workflow(args)) => {
1646 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1647 let config_path = store.path().to_path_buf();
1648 run_workflow_command(&cli, &resolved_runtime, &config_path, args)
1649 }
1650 Some(Commands::Lane(args)) => run_lane_command(args),
1651 Some(Commands::Review(args)) => {
1652 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1653 delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
1654 }
1655 Some(Commands::Apply(args)) => {
1656 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1657 delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
1658 }
1659 Some(Commands::Eval(args)) => {
1660 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1661 delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
1662 }
1663 Some(Commands::Mcp(args)) => {
1664 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1665 delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
1666 }
1667 Some(Commands::Features(args)) => {
1668 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1669 delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
1670 }
1671 Some(Commands::Serve(args)) => {
1672 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1673 delegate_server_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
1676 }
1677 Some(Commands::Completions(args)) => {
1678 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1679 delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
1680 }
1681 Some(Commands::Login(args)) => run_login_command(&mut store, args),
1682 Some(Commands::Logout) => run_logout_command(&mut store),
1683 Some(Commands::Auth(args)) => match args.command {
1684 AuthCommand::XaiDevice => {
1685 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1686 delegate_to_tui(
1687 &cli,
1688 &resolved_runtime,
1689 vec!["auth".to_string(), "xai-device".to_string()],
1690 )
1691 }
1692 command => run_auth_command(&mut store, command),
1693 },
1694 Some(Commands::McpServer) => run_mcp_server_command(&mut store),
1695 Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
1696 Some(Commands::Model(args)) => {
1697 run_model_command(&mut store, args.command, runtime_overrides.provider)
1698 }
1699 Some(Commands::Thread(args)) => run_thread_command(args.command),
1700 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
1701 Some(Commands::AppServer(args)) => {
1702 if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
1707 cli.config = args.config.clone();
1708 store = ConfigStore::load(cli.config.clone())?;
1709 }
1710 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1711 run_app_server_command(&cli, &resolved_runtime, args)
1712 }
1713 Some(Commands::Completion { shell }) => {
1714 let mut cmd = Cli::command();
1715 generate(shell, &mut cmd, "codewhale", &mut io::stdout());
1716 Ok(())
1717 }
1718 Some(Commands::Metrics(args)) => run_metrics_command(args),
1719 Some(Commands::Update(args)) => {
1720 #[cfg(not(target_env = "ohos"))]
1721 {
1722 update::run_update(args.beta, args.check, args.proxy)
1723 }
1724 #[cfg(target_env = "ohos")]
1725 {
1726 let _ = args;
1727 bail!("self-update is not supported on HarmonyOS/OpenHarmony yet");
1728 }
1729 }
1730 None => {
1731 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1732 let forwarded = root_tui_passthrough(&cli)?;
1733 delegate_to_tui(&cli, &resolved_runtime, forwarded)
1734 }
1735 }
1736}
1737
1738fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
1739 let mut forwarded = Vec::new();
1740 if cli.continue_session {
1741 forwarded.push("--continue".to_string());
1742 }
1743
1744 let prompt =
1745 cli.prompt_flag
1746 .iter()
1747 .chain(cli.prompt.iter())
1748 .fold(String::new(), |mut acc, part| {
1749 if !acc.is_empty() {
1750 acc.push(' ');
1751 }
1752 acc.push_str(part);
1753 acc
1754 });
1755 if !prompt.is_empty() {
1756 if cli.continue_session {
1757 bail!(
1758 "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
1759 );
1760 }
1761 forwarded.push("--prompt".to_string());
1762 forwarded.push(prompt);
1763 }
1764
1765 Ok(forwarded)
1766}
1767
1768fn resolve_runtime_for_dispatch(
1769 store: &mut ConfigStore,
1770 runtime_overrides: &CliRuntimeOverrides,
1771) -> ResolvedRuntimeOptions {
1772 let runtime_secrets = Secrets::auto_detect();
1773 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
1774}
1775
1776fn resolve_runtime_for_dispatch_with_secrets(
1777 store: &mut ConfigStore,
1778 runtime_overrides: &CliRuntimeOverrides,
1779 secrets: &Secrets,
1780) -> ResolvedRuntimeOptions {
1781 store
1782 .config
1783 .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
1784}
1785
1786fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
1787 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
1788 forwarded.push(command.to_string());
1789 forwarded.extend(args.args);
1790 forwarded
1791}
1792
1793fn reject_exec_global_flags(args: &[String]) -> Result<()> {
1794 const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
1795
1796 for arg in args {
1797 if arg == "--" {
1798 break;
1799 }
1800 let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
1801 if GLOBAL_ONLY_FLAGS.contains(&flag) {
1802 bail!(
1803 "{flag} must be placed before `exec`.\n\nUse:\n codewhale {flag} <value> exec \"<prompt>\""
1804 );
1805 }
1806 }
1807
1808 Ok(())
1809}
1810
1811fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
1812 run_login_command_with_secrets(store, args, &Secrets::auto_detect())
1813}
1814
1815fn run_login_command_with_secrets(
1816 store: &mut ConfigStore,
1817 args: LoginArgs,
1818 secrets: &Secrets,
1819) -> Result<()> {
1820 let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
1821 store.config.provider = provider;
1822
1823 let api_key = match args.api_key {
1824 Some(v) => v,
1825 None => read_api_key_from_stdin()?,
1826 };
1827 let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
1828 let destination = if secret_store_saved {
1829 secrets.backend_name().to_string()
1830 } else {
1831 store.path().display().to_string()
1832 };
1833 if provider == ProviderKind::Deepseek {
1834 println!("logged in using API key mode (deepseek); saved key to {destination}");
1835 } else {
1836 println!(
1837 "logged in using API key mode ({}); saved key to {destination}",
1838 provider.as_str(),
1839 );
1840 }
1841 Ok(())
1842}
1843
1844fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
1845 run_logout_command_with_secrets(store, &Secrets::auto_detect())
1846}
1847
1848fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
1849 let active_provider = store.config.provider;
1850 store.config.api_key = None;
1851 for provider in ProviderKind::ALL {
1852 clear_provider_api_key_from_config(store, provider);
1853 }
1854 clear_provider_api_key_from_keyring(secrets, active_provider);
1855 store.config.auth_mode = None;
1856 store.save()?;
1857 println!("logged out");
1858 Ok(())
1859}
1860
1861fn provider_slot(provider: ProviderKind) -> &'static str {
1863 match provider {
1864 ProviderKind::SiliconflowCN => "siliconflow",
1866 _ => provider.provider().id(),
1867 }
1868}
1869
1870#[cfg(test)]
1871fn no_keyring_secrets() -> Secrets {
1872 Secrets::new(std::sync::Arc::new(
1873 codewhale_secrets::InMemoryKeyringStore::new(),
1874 ))
1875}
1876
1877fn write_provider_api_key_to_config(
1878 store: &mut ConfigStore,
1879 provider: ProviderKind,
1880 api_key: &str,
1881) {
1882 prepare_provider_api_key_metadata(store, provider);
1883 store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
1884 if provider == ProviderKind::Deepseek {
1885 store.config.api_key = Some(api_key.to_string());
1886 }
1887}
1888
1889fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
1890 store.config.auth_mode = Some("api_key".to_string());
1891 store.config.providers.for_provider_mut(provider).auth_mode = Some("api_key".to_string());
1892 if provider == ProviderKind::Deepseek {
1893 if store.config.default_text_model.is_none() {
1894 store.config.default_text_model = Some(
1895 store
1896 .config
1897 .providers
1898 .deepseek
1899 .model
1900 .clone()
1901 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
1902 );
1903 }
1904 }
1905}
1906
1907fn persist_provider_api_key(
1910 store: &mut ConfigStore,
1911 secrets: &Secrets,
1912 provider: ProviderKind,
1913 api_key: &str,
1914) -> Result<bool> {
1915 prepare_provider_api_key_metadata(store, provider);
1916 let secret_store_saved = match secrets.set(provider_slot(provider), api_key) {
1917 Ok(()) => {
1918 clear_provider_api_key_from_config(store, provider);
1919 true
1920 }
1921 Err(err) => {
1922 eprintln!(
1923 "warning: secret-store write failed for {}; using owner-only config fallback: {err}",
1924 provider_slot(provider)
1925 );
1926 write_provider_api_key_to_config(store, provider, api_key);
1927 false
1928 }
1929 };
1930 store.save()?;
1931 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
1932 Ok(secret_store_saved)
1933}
1934
1935fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
1936 store.config.providers.for_provider_mut(provider).api_key = None;
1937 if provider == ProviderKind::Deepseek {
1938 store.config.api_key = None;
1939 }
1940}
1941
1942fn provider_env_set(provider: ProviderKind) -> bool {
1943 provider_env_value(provider).is_some()
1944}
1945
1946fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
1947 provider.provider().env_vars()
1948}
1949
1950fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
1951 provider_env_vars(provider).iter().find_map(|var| {
1952 std::env::var(var)
1953 .ok()
1954 .filter(|value| !value.trim().is_empty())
1955 .map(|value| (*var, value))
1956 })
1957}
1958
1959fn openai_codex_auth_file_path() -> PathBuf {
1960 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
1961 let path = PathBuf::from(path);
1962 if !path.as_os_str().is_empty() {
1963 return path;
1964 }
1965 }
1966
1967 let codex_home = std::env::var("CODEX_HOME")
1968 .map(PathBuf::from)
1969 .unwrap_or_else(|_| {
1970 dirs::home_dir()
1971 .unwrap_or_else(|| PathBuf::from("."))
1972 .join(".codex")
1973 });
1974 codex_home.join("auth.json")
1975}
1976
1977fn provider_oauth_file_path(provider: ProviderKind) -> Option<PathBuf> {
1978 (provider == ProviderKind::OpenaiCodex).then(openai_codex_auth_file_path)
1979}
1980
1981fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
1982 let slot = store
1983 .config
1984 .providers
1985 .for_provider(provider)
1986 .api_key
1987 .as_deref();
1988 let root = (provider == ProviderKind::Deepseek)
1989 .then_some(store.config.api_key.as_deref())
1990 .flatten();
1991 slot.or(root).filter(|v| !v.trim().is_empty())
1992}
1993
1994fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
1995 provider_config_api_key(store, provider).is_some()
1996}
1997
1998fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
1999 secrets
2000 .get(provider_slot(provider))
2001 .ok()
2002 .flatten()
2003 .filter(|v| !v.trim().is_empty())
2004}
2005
2006fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
2007 provider_keyring_api_key(secrets, provider).is_some()
2008}
2009
2010fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
2011 let _ = secrets.delete(provider_slot(provider));
2012}
2013
2014fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
2015 let active_provider = store.config.provider;
2016 let mut lines = Vec::new();
2017 lines.push(format!(
2018 "active provider: {} (set via config or CODEWHALE_PROVIDER)",
2019 active_provider.as_str()
2020 ));
2021 lines.push(String::new());
2022 lines.push(format!(
2023 "{:<14} {:<8} {:<10} {:<8} {}",
2024 "provider", "config", "keyring", "env", "status"
2025 ));
2026 lines.push("-".repeat(70));
2027
2028 for provider in ProviderKind::ALL {
2029 let config_key = provider_config_api_key(store, provider);
2030 let keyring_key = provider_keyring_api_key(secrets, provider);
2031 let env_key = provider_env_value(provider);
2032 let oauth_file_present = provider_oauth_file_path(provider).is_some_and(|p| p.exists());
2033
2034 let config_status = config_key.map(|_| "set").unwrap_or("-");
2035 let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
2036 let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
2037
2038 let source = if provider == ProviderKind::OpenaiCodex {
2039 if env_key.is_some() {
2043 "env"
2044 } else if oauth_file_present {
2045 "oauth file"
2046 } else {
2047 "unset"
2048 }
2049 } else if config_key.is_some() {
2050 "config"
2051 } else if keyring_key.is_some() {
2052 "keyring"
2053 } else if env_key.is_some() {
2054 "env"
2055 } else if oauth_file_present {
2056 "oauth file"
2057 } else {
2058 "unset"
2059 };
2060
2061 let active_marker = if provider == active_provider {
2062 " *"
2063 } else {
2064 ""
2065 };
2066
2067 lines.push(format!(
2068 "{:<14} {:<8} {:<10} {:<8} {}{}",
2069 provider.as_str(),
2070 config_status,
2071 keyring_status,
2072 env_status,
2073 source,
2074 active_marker
2075 ));
2076 }
2077
2078 lines.push(String::new());
2079 lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
2080 lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
2081 lines
2082}
2083
2084fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
2085 let mut lines = Vec::new();
2086 lines.push("provider config store env active".to_string());
2087 for provider in ProviderKind::ALL {
2088 let slot = provider_slot(provider);
2089 let file = provider_config_set(store, provider);
2090 let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
2091 let env = provider_env_set(provider);
2092 let oauth_file = provider_oauth_file_path(provider).is_some_and(|p| p.exists());
2093 let active = if provider == ProviderKind::OpenaiCodex {
2094 if env {
2095 "env"
2096 } else if oauth_file {
2097 "oauth"
2098 } else {
2099 "missing"
2100 }
2101 } else if file {
2102 "config"
2103 } else if keyring == Some(true) {
2104 "store"
2105 } else if env {
2106 "env"
2107 } else {
2108 "missing"
2109 };
2110 lines.push(format!(
2111 "{slot:<12} {} {} {} {active}",
2112 yes_no(file),
2113 keyring_status_short(keyring),
2114 yes_no(env)
2115 ));
2116 }
2117 lines
2118}
2119
2120fn auth_status_lines_for_provider(
2121 store: &ConfigStore,
2122 secrets: &Secrets,
2123 provider: ProviderKind,
2124) -> Vec<String> {
2125 let config_key = provider_config_api_key(store, provider);
2126 let keyring_key = provider_keyring_api_key(secrets, provider);
2127 let env_key = provider_env_value(provider);
2128 let oauth_file = provider_oauth_file_path(provider);
2129 let oauth_file_present = oauth_file.as_ref().is_some_and(|path| path.exists());
2130
2131 let active_source = if provider == ProviderKind::OpenaiCodex {
2132 if env_key.is_some() {
2133 "env"
2134 } else if oauth_file_present {
2135 "Codex OAuth file"
2136 } else {
2137 "missing"
2138 }
2139 } else if config_key.is_some() {
2140 "config"
2141 } else if keyring_key.is_some() {
2142 "secret store"
2143 } else if env_key.is_some() {
2144 "env"
2145 } else {
2146 "missing"
2147 };
2148 let active_last4 = if provider == ProviderKind::OpenaiCodex {
2149 env_key.as_ref().map(|(_, value)| last4_label(value))
2150 } else {
2151 config_key
2152 .map(last4_label)
2153 .or_else(|| keyring_key.as_deref().map(last4_label))
2154 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
2155 };
2156 let active_label = active_last4
2157 .map(|last4| format!("{active_source} (last4: {last4})"))
2158 .unwrap_or_else(|| active_source.to_string());
2159
2160 let env_var_label = env_key
2161 .as_ref()
2162 .map(|(name, _)| (*name).to_string())
2163 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
2164 let env_status = env_key
2165 .as_ref()
2166 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
2167 .unwrap_or_else(|| "unset".to_string());
2168
2169 let is_active = provider == store.config.provider;
2170 let active_marker = if is_active { " (active provider)" } else { "" };
2171
2172 let provider_cfg = store.config.providers.for_provider(provider);
2173 let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
2174 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
2175
2176 let lookup_order = if provider == ProviderKind::OpenaiCodex {
2177 "lookup order: env -> Codex OAuth file".to_string()
2178 } else {
2179 "lookup order: config -> secret store -> env".to_string()
2180 };
2181 let auth_mode = if provider == ProviderKind::OpenaiCodex {
2182 "codex_oauth"
2183 } else {
2184 store.config.auth_mode.as_deref().unwrap_or("api_key")
2185 };
2186
2187 let mut lines = vec![
2188 format!("provider: {}{}", provider.as_str(), active_marker),
2189 format!("route: {}", base_url),
2190 format!("model: {}", model),
2191 format!("auth mode: {auth_mode}"),
2192 format!("active source: {active_label}"),
2193 lookup_order,
2194 format!(
2195 "config file: {} ({})",
2196 store.path().display(),
2197 source_status(config_key, "missing")
2198 ),
2199 format!(
2200 "secret store: {} ({})",
2201 secrets.backend_name(),
2202 source_status(keyring_key.as_deref(), "missing")
2203 ),
2204 format!("env var: {env_var_label} ({env_status})"),
2205 ];
2206 if let Some(path) = oauth_file {
2207 let status = if path.exists() { "present" } else { "missing" };
2208 lines.push(format!("Codex OAuth file: {} ({status})", path.display()));
2209 }
2210 lines
2211}
2212
2213fn source_status(value: Option<&str>, missing_label: &str) -> String {
2214 value
2215 .map(|v| format!("set, last4: {}", last4_label(v)))
2216 .unwrap_or_else(|| missing_label.to_string())
2217}
2218
2219fn last4_label(value: &str) -> String {
2220 let trimmed = value.trim();
2221 let chars: Vec<char> = trimmed.chars().collect();
2222 if chars.len() <= 4 {
2223 return "<redacted>".to_string();
2224 }
2225 let last4: String = chars[chars.len() - 4..].iter().collect();
2226 format!("...{last4}")
2227}
2228
2229fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
2230 run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
2231}
2232
2233fn run_auth_command_with_secrets(
2234 store: &mut ConfigStore,
2235 command: AuthCommand,
2236 secrets: &Secrets,
2237) -> Result<()> {
2238 match command {
2239 AuthCommand::XaiDevice => {
2240 bail!("xAI device authentication must be delegated to codewhale-tui")
2241 }
2242 AuthCommand::Status { provider } => {
2243 match provider {
2244 Some(p) => {
2245 let provider: ProviderKind = p.into();
2246 for line in auth_status_lines_for_provider(store, secrets, provider) {
2247 println!("{line}");
2248 }
2249 }
2250 None => {
2251 for line in auth_status_all_providers(store, secrets) {
2252 println!("{line}");
2253 }
2254 }
2255 }
2256 Ok(())
2257 }
2258 AuthCommand::Set {
2259 provider,
2260 api_key,
2261 api_key_stdin,
2262 } => {
2263 let provider: ProviderKind = provider.into();
2264 let slot = provider_slot(provider);
2265 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
2266 let provider_cfg = store.config.providers.for_provider_mut(provider);
2267 if provider_cfg.base_url.is_none() {
2268 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
2269 }
2270 store.save()?;
2271 println!(
2272 "configured {slot} provider in {} (API key optional)",
2273 store.path().display()
2274 );
2275 return Ok(());
2276 }
2277 let api_key = match (api_key, api_key_stdin) {
2278 (Some(v), _) => v,
2279 (None, true) => read_api_key_from_stdin()?,
2280 (None, false) => prompt_api_key(slot)?,
2281 };
2282 let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
2283 if secret_store_saved {
2285 println!(
2286 "saved API key for {slot} to {} (config contains metadata only)",
2287 secrets.backend_name(),
2288 );
2289 } else {
2290 println!("saved API key for {slot} to {}", store.path().display());
2291 }
2292 Ok(())
2293 }
2294 AuthCommand::Get { provider } => {
2295 let provider: ProviderKind = provider.into();
2296 let slot = provider_slot(provider);
2297 let in_file = provider_config_set(store, provider);
2298 let in_keyring = !in_file && provider_keyring_set(secrets, provider);
2299 let in_env = provider_env_set(provider);
2300 let source = if in_file {
2302 Some("config-file")
2303 } else if in_keyring {
2304 Some("secret-store")
2305 } else if in_env {
2306 Some("env")
2307 } else {
2308 None
2309 };
2310 match source {
2311 Some(source) => println!("{slot}: set (source: {source})"),
2312 None => println!("{slot}: not set"),
2313 }
2314 Ok(())
2315 }
2316 AuthCommand::Clear { provider } => {
2317 let provider: ProviderKind = provider.into();
2318 let slot = provider_slot(provider);
2319 clear_provider_api_key_from_config(store, provider);
2320 clear_provider_api_key_from_keyring(secrets, provider);
2321 store.save()?;
2322 println!("cleared API key for {slot} from config and secret store");
2323 Ok(())
2324 }
2325 AuthCommand::List => {
2326 for line in auth_list_lines(store, secrets) {
2327 println!("{line}");
2328 }
2329 Ok(())
2330 }
2331 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
2332 }
2333}
2334
2335fn yes_no(b: bool) -> &'static str {
2336 if b { "yes" } else { "no " }
2337}
2338
2339fn keyring_status_short(state: Option<bool>) -> &'static str {
2340 match state {
2341 Some(true) => "yes",
2342 Some(false) => "no ",
2343 None => "n/a",
2344 }
2345}
2346
2347fn prompt_api_key(slot: &str) -> Result<String> {
2348 use std::io::{IsTerminal, Write};
2349 eprint!("Enter API key for {slot}: ");
2350 io::stderr().flush().ok();
2351 if !io::stdin().is_terminal() {
2352 return read_api_key_from_stdin();
2354 }
2355 let mut buf = String::new();
2356 io::stdin()
2357 .read_line(&mut buf)
2358 .context("failed to read API key from stdin")?;
2359 let key = buf.trim().to_string();
2360 if key.is_empty() {
2361 bail!("empty API key provided");
2362 }
2363 Ok(key)
2364}
2365
2366fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
2369 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
2370 let mut warnings: Vec<String> = Vec::new();
2371
2372 for provider in ProviderKind::ALL {
2373 let slot = provider_slot(provider);
2374 let from_provider_block = store
2375 .config
2376 .providers
2377 .for_provider(provider)
2378 .api_key
2379 .clone()
2380 .filter(|v| !v.trim().is_empty());
2381 let from_root = (provider == ProviderKind::Deepseek)
2382 .then(|| store.config.api_key.clone())
2383 .flatten()
2384 .filter(|v| !v.trim().is_empty());
2385 let value = from_provider_block.or(from_root);
2386 let Some(value) = value else { continue };
2387
2388 if let Ok(Some(existing)) = secrets.get(slot)
2389 && existing == value
2390 {
2391 } else if dry_run {
2393 migrated.push((provider, slot));
2394 continue;
2395 } else if let Err(err) = secrets.set(slot, &value) {
2396 warnings.push(format!(
2397 "skipped {slot}: failed to write to secret store: {err}"
2398 ));
2399 continue;
2400 }
2401 if !dry_run {
2402 store.config.providers.for_provider_mut(provider).api_key = None;
2403 if provider == ProviderKind::Deepseek {
2404 store.config.api_key = None;
2405 }
2406 }
2407 migrated.push((provider, slot));
2408 }
2409
2410 if !dry_run && !migrated.is_empty() {
2411 store
2412 .save()
2413 .context("failed to write updated config.toml")?;
2414 }
2415 if !dry_run {
2416 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
2417 .context("failed to remove plaintext API keys from config backup")?;
2418 }
2419
2420 println!("secret store backend: {}", secrets.backend_name());
2421 if migrated.is_empty() {
2422 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
2423 } else {
2424 println!(
2425 "{} {} provider key(s):",
2426 if dry_run { "would migrate" } else { "migrated" },
2427 migrated.len()
2428 );
2429 for (_, slot) in &migrated {
2430 println!(" - {slot}");
2431 }
2432 if !dry_run {
2433 println!(
2434 "config.toml at {} no longer contains api_key entries for migrated providers.",
2435 store.path().display()
2436 );
2437 }
2438 }
2439 for w in warnings {
2440 eprintln!("warning: {w}");
2441 }
2442 Ok(())
2443}
2444
2445fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
2446 match command {
2447 ConfigCommand::Get { key } => {
2448 if let Some(value) = store.config.get_display_value(&key) {
2449 println!("{value}");
2450 return Ok(());
2451 }
2452 bail!("key not found: {key}");
2453 }
2454 ConfigCommand::Set { key, value } => {
2455 store.config.set_value(&key, &value)?;
2456 store.save()?;
2457 println!("set {key}");
2458 Ok(())
2459 }
2460 ConfigCommand::Unset { key } => {
2461 store.config.unset_value(&key)?;
2462 store.save()?;
2463 println!("unset {key}");
2464 Ok(())
2465 }
2466 ConfigCommand::List => {
2467 for (key, value) in store.config.list_values() {
2468 println!("{key} = {value}");
2469 }
2470 Ok(())
2471 }
2472 ConfigCommand::Path => {
2473 println!("{}", store.path().display());
2474 Ok(())
2475 }
2476 }
2477}
2478
2479fn model_command_provider_hint(
2480 command_provider: Option<ProviderArg>,
2481 top_level_provider: Option<ProviderKind>,
2482) -> Option<ProviderKind> {
2483 command_provider
2484 .map(ProviderKind::from)
2485 .or(top_level_provider)
2486}
2487
2488fn run_model_command(
2489 store: &mut ConfigStore,
2490 command: ModelCommand,
2491 top_level_provider: Option<ProviderKind>,
2492) -> Result<()> {
2493 let registry = ModelRegistry::default();
2494 match command {
2495 ModelCommand::List { provider } => {
2496 let filter = model_command_provider_hint(provider, top_level_provider);
2497 for model in registry.list().into_iter().filter(|m| match filter {
2498 Some(p) => m.provider == p,
2499 None => true,
2500 }) {
2501 println!("{} ({})", model.id, model.provider.as_str());
2502 }
2503 Ok(())
2504 }
2505 ModelCommand::Resolve { model, provider } => {
2506 let provider = model_command_provider_hint(provider, top_level_provider);
2507 let resolved = registry.resolve(model.as_deref(), provider);
2508 println!("requested: {}", resolved.requested.unwrap_or_default());
2509 println!("resolved: {}", resolved.resolved.id);
2510 println!("provider: {}", resolved.resolved.provider.as_str());
2511 println!("used_fallback: {}", resolved.used_fallback);
2512 Ok(())
2513 }
2514 ModelCommand::Set { model } => {
2515 let trimmed = model.trim();
2516 if trimmed.is_empty() {
2517 bail!("Model name cannot be empty");
2518 }
2519 let canonical = match trimmed.to_ascii_lowercase().as_str() {
2520 "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
2521 "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
2522 _ => trimmed,
2523 };
2524 store.config.default_text_model = Some(canonical.to_string());
2525 store.save()?;
2526 println!("Default model set to '{canonical}'");
2527 Ok(())
2528 }
2529 }
2530}
2531
2532fn run_thread_command(command: ThreadCommand) -> Result<()> {
2533 let state = StateStore::open(None)?;
2534 match command {
2535 ThreadCommand::List { all, limit } => {
2536 let threads = state.list_threads(ThreadListFilters {
2537 include_archived: all,
2538 limit,
2539 })?;
2540 for thread in threads {
2541 println!(
2542 "{} | {} | {} | {}",
2543 thread.id,
2544 thread
2545 .name
2546 .clone()
2547 .unwrap_or_else(|| "(unnamed)".to_string()),
2548 thread.model_provider,
2549 thread.cwd.display()
2550 );
2551 }
2552 Ok(())
2553 }
2554 ThreadCommand::Read { thread_id } => {
2555 let thread = state.get_thread(&thread_id)?;
2556 println!("{}", serde_json::to_string_pretty(&thread)?);
2557 Ok(())
2558 }
2559 ThreadCommand::Resume { thread_id } => {
2560 let args = vec!["resume".to_string(), thread_id];
2561 delegate_simple_tui(args)
2562 }
2563 ThreadCommand::Fork { thread_id } => {
2564 let args = vec!["fork".to_string(), thread_id];
2565 delegate_simple_tui(args)
2566 }
2567 ThreadCommand::Archive { thread_id } => {
2568 state.mark_archived(&thread_id)?;
2569 println!("archived {thread_id}");
2570 Ok(())
2571 }
2572 ThreadCommand::Unarchive { thread_id } => {
2573 state.mark_unarchived(&thread_id)?;
2574 println!("unarchived {thread_id}");
2575 Ok(())
2576 }
2577 ThreadCommand::SetName { thread_id, name } => {
2578 let mut thread = state
2579 .get_thread(&thread_id)?
2580 .with_context(|| format!("thread not found: {thread_id}"))?;
2581 thread.name = Some(name);
2582 thread.updated_at = chrono::Utc::now().timestamp();
2583 state.upsert_thread(&thread)?;
2584 println!("renamed {thread_id}");
2585 Ok(())
2586 }
2587 ThreadCommand::ClearName { thread_id } => {
2588 let mut thread = state
2589 .get_thread(&thread_id)?
2590 .with_context(|| format!("thread not found: {thread_id}"))?;
2591 thread.name = None;
2592 thread.updated_at = chrono::Utc::now().timestamp();
2593 state.upsert_thread(&thread)?;
2594 println!("cleared name for {thread_id}");
2595 Ok(())
2596 }
2597 }
2598}
2599
2600fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
2601 match command {
2602 SandboxCommand::Check { command, ask } => {
2603 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
2604 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2605 let decision = engine.check(ExecPolicyContext {
2606 command: &command,
2607 cwd: &cwd.display().to_string(),
2608 tool: Some("exec_shell"),
2609 path: None,
2610 ask_for_approval: ask.into(),
2611 sandbox_mode: Some("workspace-write"),
2612 })?;
2613 println!("{}", serde_json::to_string_pretty(&decision)?);
2614 Ok(())
2615 }
2616 }
2617}
2618
2619fn run_app_server_command(
2620 cli: &Cli,
2621 resolved_runtime: &ResolvedRuntimeOptions,
2622 args: AppServerArgs,
2623) -> Result<()> {
2624 if args.http || args.mobile {
2629 return delegate_server_to_tui(cli, resolved_runtime, app_server_serve_passthrough(&args));
2632 }
2633
2634 let runtime = tokio::runtime::Builder::new_multi_thread()
2635 .enable_all()
2636 .build()
2637 .context("failed to create tokio runtime")?;
2638 if args.stdio {
2639 return runtime.block_on(run_app_server_stdio(args.config));
2640 }
2641 let host = args.host.as_deref().unwrap_or("127.0.0.1");
2645 let port = args.port.unwrap_or(8787);
2646 let listen: SocketAddr = format!("{host}:{port}")
2647 .parse()
2648 .with_context(|| format!("invalid app-server listen address {host}:{port}"))?;
2649 runtime.block_on(run_app_server(AppServerOptions {
2650 listen,
2651 config_path: args.config,
2652 auth_token: args.auth_token.or_else(app_server_token_from_env),
2653 insecure_no_auth: args.insecure_no_auth,
2654 cors_origins: args.cors_origin,
2655 }))
2656}
2657
2658fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
2666 let mut forwarded = vec!["serve".to_string()];
2667 forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
2668 if let Some(host) = args.host.as_ref() {
2669 forwarded.push("--host".to_string());
2670 forwarded.push(host.clone());
2671 }
2672 if let Some(port) = args.port {
2673 forwarded.push("--port".to_string());
2674 forwarded.push(port.to_string());
2675 }
2676 if let Some(workers) = args.workers {
2677 forwarded.push("--workers".to_string());
2678 forwarded.push(workers.to_string());
2679 }
2680 for origin in &args.cors_origin {
2681 forwarded.push("--cors-origin".to_string());
2682 forwarded.push(origin.clone());
2683 }
2684 if let Some(token) = args.auth_token.as_ref() {
2685 forwarded.push("--auth-token".to_string());
2686 forwarded.push(token.clone());
2687 }
2688 if args.insecure_no_auth {
2689 forwarded.push("--insecure".to_string());
2690 }
2691 if args.qr {
2692 forwarded.push("--qr".to_string());
2693 }
2694 forwarded
2695}
2696
2697fn app_server_token_from_env() -> Option<String> {
2698 std::env::var("CODEWHALE_APP_SERVER_TOKEN")
2699 .ok()
2700 .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
2701}
2702
2703fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
2704 let persisted = load_mcp_server_definitions(store);
2705 let updated = run_stdio_server(persisted)?;
2706 persist_mcp_server_definitions(store, &updated)
2707}
2708
2709fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
2710 let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
2711 return Vec::new();
2712 };
2713
2714 match parse_mcp_server_definitions(&raw) {
2715 Ok(definitions) => definitions,
2716 Err(err) => {
2717 eprintln!(
2718 "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
2719 );
2720 Vec::new()
2721 }
2722 }
2723}
2724
2725fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
2726 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
2727 return Ok(parsed);
2728 }
2729
2730 let unwrapped: String = serde_json::from_str(raw)
2731 .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
2732 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
2733 format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
2734 })
2735}
2736
2737fn persist_mcp_server_definitions(
2738 store: &mut ConfigStore,
2739 definitions: &[McpServerDefinition],
2740) -> Result<()> {
2741 let encoded =
2742 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
2743 store
2744 .config
2745 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
2746 store.save()
2747}
2748
2749fn delegate_to_tui(
2750 cli: &Cli,
2751 resolved_runtime: &ResolvedRuntimeOptions,
2752 passthrough: Vec<String>,
2753) -> Result<()> {
2754 let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
2755 let tui = PathBuf::from(cmd.get_program());
2756 let status = cmd
2757 .status()
2758 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
2759 exit_with_tui_status(status)
2760}
2761
2762fn delegate_server_to_tui(
2782 cli: &Cli,
2783 resolved_runtime: &ResolvedRuntimeOptions,
2784 passthrough: Vec<String>,
2785) -> Result<()> {
2786 let mut std_cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
2787 install_server_parent_death_signal(&mut std_cmd);
2788 let tui = PathBuf::from(std_cmd.get_program());
2789 let runtime = tokio::runtime::Builder::new_current_thread()
2790 .enable_all()
2791 .build()
2792 .context("failed to create server-teardown runtime")?;
2793 runtime.block_on(async move {
2794 let mut cmd = tokio::process::Command::from(std_cmd);
2795 cmd.kill_on_drop(true);
2796 let mut child = cmd
2797 .spawn()
2798 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
2799 #[cfg(windows)]
2804 let _child_job = attach_server_child_job(&child);
2805 match supervise_server_child(&mut child, server_shutdown_signal()).await? {
2806 ServerTeardown::Exited(status) => exit_with_tui_status(status),
2807 ServerTeardown::Signaled(code) => std::process::exit(code),
2810 }
2811 })
2812}
2813
2814#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
2818fn install_server_parent_death_signal(cmd: &mut Command) {
2819 use std::os::unix::process::CommandExt;
2820 unsafe {
2824 cmd.pre_exec(|| {
2825 let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
2826 if result == -1 {
2827 let _ = std::io::Error::last_os_error();
2829 }
2830 Ok(())
2831 });
2832 }
2833}
2834
2835#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
2836fn install_server_parent_death_signal(_cmd: &mut Command) {}
2837
2838#[derive(Debug)]
2840enum ServerTeardown {
2841 Exited(std::process::ExitStatus),
2843 Signaled(i32),
2846}
2847
2848async fn supervise_server_child<F>(
2851 child: &mut tokio::process::Child,
2852 shutdown: F,
2853) -> io::Result<ServerTeardown>
2854where
2855 F: std::future::Future<Output = i32>,
2856{
2857 tokio::select! {
2858 status = child.wait() => Ok(ServerTeardown::Exited(status?)),
2859 code = shutdown => {
2860 let _ = child.start_kill();
2863 let _ = child.wait().await;
2864 Ok(ServerTeardown::Signaled(code))
2865 }
2866 }
2867}
2868
2869#[cfg(unix)]
2873async fn server_shutdown_signal() -> i32 {
2874 use tokio::signal::unix::{SignalKind, signal};
2875 let mut terminate = signal(SignalKind::terminate()).ok();
2876 let mut hangup = signal(SignalKind::hangup()).ok();
2877 let term = async {
2878 match terminate.as_mut() {
2879 Some(s) => {
2880 s.recv().await;
2881 }
2882 None => std::future::pending::<()>().await,
2883 }
2884 };
2885 let hup = async {
2886 match hangup.as_mut() {
2887 Some(s) => {
2888 s.recv().await;
2889 }
2890 None => std::future::pending::<()>().await,
2891 }
2892 };
2893 tokio::select! {
2894 _ = tokio::signal::ctrl_c() => 130,
2895 _ = term => 143,
2896 _ = hup => 129,
2897 }
2898}
2899
2900#[cfg(not(unix))]
2901async fn server_shutdown_signal() -> i32 {
2902 let _ = tokio::signal::ctrl_c().await;
2903 130
2904}
2905
2906#[cfg(windows)]
2913fn attach_server_child_job(child: &tokio::process::Child) -> Option<ServerChildJob> {
2914 let Some(child_handle) = child.raw_handle() else {
2915 tracing::warn!("delegated server child exited before a job object could be attached");
2916 return None;
2917 };
2918
2919 match ServerChildJob::attach(child_handle) {
2920 Ok(job) => Some(job),
2921 Err(err) => {
2922 tracing::warn!("failed to place delegated server child in a job object: {err}");
2923 None
2924 }
2925 }
2926}
2927
2928#[cfg(windows)]
2929struct ServerChildJob {
2930 handle: windows::Win32::Foundation::HANDLE,
2931}
2932
2933#[cfg(windows)]
2936unsafe impl Send for ServerChildJob {}
2937
2938#[cfg(windows)]
2939impl ServerChildJob {
2940 fn attach(child_handle: std::os::windows::io::RawHandle) -> std::io::Result<Self> {
2941 use windows::Win32::Foundation::HANDLE;
2942 use windows::Win32::System::JobObjects::{
2943 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
2944 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
2945 SetInformationJobObject,
2946 };
2947 use windows::core::PCWSTR;
2948
2949 let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(win_io_error)?;
2952 let job = Self { handle };
2953
2954 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
2955 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
2956 unsafe {
2957 SetInformationJobObject(
2958 job.handle,
2959 JobObjectExtendedLimitInformation,
2960 &limits as *const _ as *const core::ffi::c_void,
2961 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
2962 )
2963 .map_err(win_io_error)?;
2964 AssignProcessToJobObject(job.handle, HANDLE(child_handle)).map_err(win_io_error)?;
2965 }
2966 Ok(job)
2967 }
2968}
2969
2970#[cfg(windows)]
2971impl Drop for ServerChildJob {
2972 fn drop(&mut self) {
2973 unsafe {
2977 let _ = windows::Win32::Foundation::CloseHandle(self.handle);
2978 }
2979 }
2980}
2981
2982#[cfg(windows)]
2983fn win_io_error(err: windows::core::Error) -> std::io::Error {
2984 std::io::Error::other(err)
2985}
2986
2987#[cfg(all(test, unix))]
2988mod server_teardown_tests {
2989 use super::*;
2990
2991 #[tokio::test]
2992 async fn supervisor_propagates_child_exit_when_no_shutdown() {
2993 let mut child = tokio::process::Command::new("true")
2996 .kill_on_drop(true)
2997 .spawn()
2998 .expect("spawn true");
2999 let outcome = supervise_server_child(&mut child, std::future::pending::<i32>())
3000 .await
3001 .expect("supervise");
3002 match outcome {
3003 ServerTeardown::Exited(status) => assert!(status.success()),
3004 other => panic!("expected Exited, got {other:?}"),
3005 }
3006 }
3007
3008 #[tokio::test]
3009 async fn shutdown_signal_kills_and_reaps_long_running_child() {
3010 let mut child = tokio::process::Command::new("sleep")
3013 .arg("30")
3014 .kill_on_drop(true)
3015 .spawn()
3016 .expect("spawn sleep");
3017 assert!(
3018 child.id().is_some(),
3019 "child should be running before shutdown"
3020 );
3021 let outcome = supervise_server_child(&mut child, async { 143 })
3024 .await
3025 .expect("supervise");
3026 assert!(matches!(outcome, ServerTeardown::Signaled(143)));
3027 assert!(
3030 child.id().is_none(),
3031 "delegated child must be reaped after dispatcher teardown"
3032 );
3033 }
3034
3035 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
3036 #[test]
3037 fn parent_death_signal_hook_does_not_break_spawn() {
3038 let mut cmd = Command::new("true");
3039 install_server_parent_death_signal(&mut cmd);
3040 let status = cmd.status().expect("spawn true with parent-death hook");
3041 assert!(status.success());
3042 }
3043}
3044
3045fn run_resume_command(
3046 cli: &Cli,
3047 resolved_runtime: &ResolvedRuntimeOptions,
3048 args: TuiPassthroughArgs,
3049) -> Result<()> {
3050 let passthrough = tui_args("resume", args);
3051 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
3052 return run_dispatcher_resume_picker(cli, resolved_runtime);
3053 }
3054 delegate_to_tui(cli, resolved_runtime, passthrough)
3055}
3056
3057fn run_dispatcher_resume_picker(
3058 cli: &Cli,
3059 resolved_runtime: &ResolvedRuntimeOptions,
3060) -> Result<()> {
3061 let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
3062 let tui = PathBuf::from(sessions_cmd.get_program());
3063 let status = sessions_cmd
3064 .status()
3065 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
3066 if !status.success() {
3067 return exit_with_tui_status(status);
3068 }
3069
3070 println!();
3071 println!("Windows note: enter a session id or prefix from the list above.");
3072 println!("You can also run `codewhale resume --last` to skip this prompt.");
3073 print!("Session id/prefix (Enter to cancel): ");
3074 io::stdout().flush()?;
3075
3076 let mut input = String::new();
3077 io::stdin()
3078 .read_line(&mut input)
3079 .context("failed to read session selection")?;
3080 let session_id = input.trim();
3081 if session_id.is_empty() {
3082 bail!("No session selected.");
3083 }
3084
3085 delegate_to_tui(
3086 cli,
3087 resolved_runtime,
3088 vec!["resume".to_string(), session_id.to_string()],
3089 )
3090}
3091
3092fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
3093 is_windows && passthrough == ["resume"]
3094}
3095
3096fn build_tui_command(
3097 cli: &Cli,
3098 resolved_runtime: &ResolvedRuntimeOptions,
3099 passthrough: Vec<String>,
3100) -> Result<Command> {
3101 build_tui_command_with_paths(
3102 cli,
3103 resolved_runtime,
3104 passthrough,
3105 cli.config.as_deref(),
3106 cli.workspace.as_deref(),
3107 )
3108}
3109
3110fn build_tui_command_with_paths(
3111 cli: &Cli,
3112 resolved_runtime: &ResolvedRuntimeOptions,
3113 passthrough: Vec<String>,
3114 config_path: Option<&Path>,
3115 workspace_path: Option<&Path>,
3116) -> Result<Command> {
3117 let tui = locate_sibling_tui_binary()?;
3118 let mut verbosity = if cli.profile.is_some() {
3119 cli.verbosity.clone()
3120 } else {
3121 resolved_runtime.verbosity.clone()
3122 };
3123 if verbosity.is_none()
3124 && passthrough
3125 .iter()
3126 .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
3127 {
3128 verbosity = Some("concise".to_string());
3129 }
3130
3131 let mut cmd = Command::new(&tui);
3132 if let Some(config) = config_path {
3133 cmd.arg("--config").arg(config);
3134 }
3135 if let Some(profile) = cli.profile.as_ref() {
3136 cmd.arg("--profile").arg(profile);
3137 }
3138 if let Some(workspace) = workspace_path {
3139 cmd.arg("--workspace").arg(workspace);
3140 }
3141 let _ = cli.no_alt_screen;
3144 if cli.mouse_capture {
3145 cmd.arg("--mouse-capture");
3146 }
3147 if cli.no_mouse_capture {
3148 cmd.arg("--no-mouse-capture");
3149 }
3150 if cli.skip_onboarding {
3151 cmd.arg("--skip-onboarding");
3152 }
3153 cmd.args(passthrough);
3154
3155 let uses_raw_tui_provider = cli
3156 .provider
3157 .as_deref()
3158 .is_some_and(|provider| builtin_provider_arg(provider).is_none());
3159 let keyring_bridge_provider = resolved_runtime.provider;
3160 let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
3161 let keyring_bridge_source = resolved_runtime.api_key_source;
3162
3163 if let Some(provider) = cli.provider.as_deref() {
3164 let provider = builtin_provider_arg(provider)
3165 .map(ProviderKind::from)
3166 .map_or_else(
3167 || provider.to_string(),
3168 |provider| provider.as_str().to_string(),
3169 );
3170 cmd.env("CODEWHALE_PROVIDER", &provider);
3173 cmd.env("DEEPSEEK_PROVIDER", provider);
3174 }
3175 if !(uses_raw_tui_provider
3176 || (cli.profile.is_some()
3177 && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
3178 && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
3179 && let Some(api_key) = keyring_bridge_api_key
3180 {
3181 cmd.env("DEEPSEEK_API_KEY", api_key);
3185 for var in provider_env_vars(keyring_bridge_provider) {
3186 if *var != "DEEPSEEK_API_KEY" {
3187 cmd.env(var, api_key);
3188 }
3189 }
3190 cmd.env(
3191 "DEEPSEEK_API_KEY_SOURCE",
3192 RuntimeApiKeySource::Keyring.as_env_value(),
3193 );
3194 }
3195
3196 if let Some(model) = cli.model.as_ref() {
3197 cmd.env("DEEPSEEK_MODEL", model);
3198 }
3199 if let Some(output_mode) = cli.output_mode.as_ref() {
3200 cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
3201 }
3202 if let Some(v) = verbosity.as_ref() {
3203 cmd.env("CODEWHALE_VERBOSITY", v);
3204 cmd.env("DEEPSEEK_VERBOSITY", v);
3205 }
3206 if let Some(log_level) = cli.log_level.as_ref() {
3207 cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
3208 }
3209 if let Some(telemetry) = cli.telemetry {
3210 cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
3211 }
3212 if let Some(policy) = cli.approval_policy.as_ref() {
3213 cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
3214 }
3215 if let Some(mode) = cli.sandbox_mode.as_ref() {
3216 cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
3217 }
3218 if cli.yolo {
3219 cmd.env("DEEPSEEK_YOLO", "true");
3220 }
3221 if let Some(api_key) = cli.api_key.as_ref() {
3222 cmd.env("CODEWHALE_CLI_API_KEY", api_key);
3229 if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
3230 cmd.env("DEEPSEEK_API_KEY", api_key);
3231 for var in provider_env_vars(resolved_runtime.provider) {
3232 if *var != "DEEPSEEK_API_KEY" {
3233 cmd.env(var, api_key);
3234 }
3235 }
3236 }
3237 cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
3238 }
3239 if let Some(base_url) = cli.base_url.as_ref() {
3240 cmd.env("DEEPSEEK_BASE_URL", base_url);
3241 }
3242
3243 Ok(cmd)
3244}
3245
3246fn tui_child_exit_code(status: std::process::ExitStatus) -> Option<i32> {
3247 if let Some(code) = status.code() {
3248 return Some(code);
3249 }
3250
3251 #[cfg(unix)]
3252 {
3253 use std::os::unix::process::ExitStatusExt;
3254
3255 status.signal().map(|signal| 128 + signal)
3256 }
3257
3258 #[cfg(not(unix))]
3259 {
3260 None
3261 }
3262}
3263
3264fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
3265 if let Some(code) = tui_child_exit_code(status) {
3266 std::process::exit(code);
3267 }
3268 bail!("codewhale-tui terminated without an exit code")
3269}
3270
3271fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
3272 let tui = locate_sibling_tui_binary()?;
3273 let status = Command::new(&tui)
3274 .args(args)
3275 .status()
3276 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
3277 exit_with_tui_status(status)
3278}
3279
3280fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
3281 format!(
3282 "failed to spawn companion TUI binary at {}: {err}\n\
3283\n\
3284The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
3285to execute it. Common fixes:\n\
3286 - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
3287 - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
3288come from the same install directory.\n\
3289 - If you downloaded release assets manually, keep both `codewhale` and \
3290`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
3291 - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \
3292binary.",
3293 tui.display()
3294 )
3295}
3296
3297fn locate_sibling_tui_binary() -> Result<PathBuf> {
3307 if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
3308 let candidate = PathBuf::from(override_path);
3309 if candidate.is_file() {
3310 return Ok(candidate);
3311 }
3312 bail!(
3313 "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
3314 candidate.display()
3315 );
3316 }
3317
3318 let current = std::env::current_exe().context("failed to locate current executable path")?;
3319 if let Some(found) = sibling_tui_candidate(¤t) {
3320 return Ok(found);
3321 }
3322
3323 let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
3326 bail!(
3327 "Companion `codewhale-tui` binary not found at {}.\n\
3328\n\
3329The `codewhale` dispatcher delegates interactive sessions to a sibling \
3330`codewhale-tui` binary. To fix this, install one of:\n\
3331 • npm: npm install -g codewhale (downloads both binaries)\n\
3332 • cargo: cargo install codewhale-cli codewhale-tui --locked\n\
3333 • GitHub Releases: download BOTH `codewhale-<platform>` AND \
3334`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
3335and place them in the same directory.\n\
3336\n\
3337Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.",
3338 expected.display()
3339 );
3340}
3341
3342fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
3346 let primary =
3349 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
3350 if primary.is_file() {
3351 return Some(primary);
3352 }
3353 if cfg!(windows) {
3356 let suffixless = dispatcher.with_file_name("codewhale-tui");
3357 if suffixless.is_file() {
3358 return Some(suffixless);
3359 }
3360 }
3361 None
3362}
3363
3364fn run_metrics_command(args: MetricsArgs) -> Result<()> {
3365 let since = match args.since.as_deref() {
3366 Some(s) => {
3367 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
3368 }
3369 None => None,
3370 };
3371 metrics::run(metrics::MetricsArgs {
3372 json: args.json,
3373 since,
3374 })
3375}
3376
3377fn read_api_key_from_stdin() -> Result<String> {
3378 let mut input = String::new();
3379 io::stdin()
3380 .read_to_string(&mut input)
3381 .context("failed to read api key from stdin")?;
3382 let key = input.trim().to_string();
3383 if key.is_empty() {
3384 bail!("empty API key provided");
3385 }
3386 Ok(key)
3387}
3388
3389#[cfg(test)]
3390mod tests {
3391 use super::*;
3392 use clap::error::ErrorKind;
3393 use codewhale_config::ProviderSource;
3394 use std::ffi::OsString;
3395 use std::sync::{Mutex, OnceLock};
3396
3397 fn parse_ok(argv: &[&str]) -> Cli {
3398 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
3399 }
3400
3401 fn help_for(argv: &[&str]) -> String {
3402 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
3403 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
3404 err.to_string()
3405 }
3406
3407 fn command_env(cmd: &Command, name: &str) -> Option<String> {
3408 let name = std::ffi::OsStr::new(name);
3409 cmd.get_envs().find_map(|(key, value)| {
3410 if key == name {
3411 value.map(|v| v.to_string_lossy().into_owned())
3412 } else {
3413 None
3414 }
3415 })
3416 }
3417
3418 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
3419 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3420 LOCK.get_or_init(|| Mutex::new(()))
3421 .lock()
3422 .unwrap_or_else(|p| p.into_inner())
3423 }
3424
3425 struct ScopedEnvVar {
3426 name: &'static str,
3427 previous: Option<OsString>,
3428 }
3429
3430 impl ScopedEnvVar {
3431 fn set(name: &'static str, value: &str) -> Self {
3432 let previous = std::env::var_os(name);
3433 unsafe { std::env::set_var(name, value) };
3436 Self { name, previous }
3437 }
3438
3439 fn remove(name: &'static str) -> Self {
3440 let previous = std::env::var_os(name);
3441 unsafe { std::env::remove_var(name) };
3444 Self { name, previous }
3445 }
3446 }
3447
3448 impl Drop for ScopedEnvVar {
3449 fn drop(&mut self) {
3450 unsafe {
3452 if let Some(previous) = self.previous.take() {
3453 std::env::set_var(self.name, previous);
3454 } else {
3455 std::env::remove_var(self.name);
3456 }
3457 }
3458 }
3459 }
3460
3461 fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
3462 let dir = tempfile::TempDir::new().expect("tempdir");
3463 let custom = dir
3464 .path()
3465 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3466 std::fs::write(&custom, b"").unwrap();
3467 let custom_str = custom.to_string_lossy().into_owned();
3468 let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3469 (dir, bin)
3470 }
3471
3472 fn resolved_runtime_for_test(
3473 provider: ProviderKind,
3474 provider_source: ProviderSource,
3475 ) -> ResolvedRuntimeOptions {
3476 ResolvedRuntimeOptions {
3477 provider,
3478 provider_source,
3479 model: "test-model".to_string(),
3480 api_key: None,
3481 api_key_source: None,
3482 base_url: "http://localhost:8000/v1".to_string(),
3483 auth_mode: None,
3484 insecure_skip_tls_verify: false,
3485 output_mode: None,
3486 log_level: None,
3487 telemetry: false,
3488 approval_policy: None,
3489 sandbox_mode: None,
3490 yolo: None,
3491 verbosity: None,
3492 http_headers: std::collections::BTreeMap::new(),
3493 }
3494 }
3495
3496 #[test]
3497 fn clap_command_definition_is_consistent() {
3498 Cli::command().debug_assert();
3499 }
3500
3501 #[test]
3508 fn anyhow_chain_surfaces_toml_parse_cause() {
3509 use anyhow::Context;
3510 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
3511 let err = Err::<(), _>(inner)
3512 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
3513 .unwrap_err();
3514
3515 assert_eq!(
3517 err.to_string(),
3518 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
3519 );
3520
3521 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
3523 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
3524 }
3525
3526 #[test]
3527 fn parses_config_command_matrix() {
3528 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
3529 assert!(matches!(
3530 cli.command,
3531 Some(Commands::Config(ConfigArgs {
3532 command: ConfigCommand::Get { ref key }
3533 })) if key == "provider"
3534 ));
3535
3536 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
3537 assert!(matches!(
3538 cli.command,
3539 Some(Commands::Config(ConfigArgs {
3540 command: ConfigCommand::Set { ref key, ref value }
3541 })) if key == "model" && value == "deepseek-v4-flash"
3542 ));
3543
3544 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
3545 assert!(matches!(
3546 cli.command,
3547 Some(Commands::Config(ConfigArgs {
3548 command: ConfigCommand::Unset { ref key }
3549 })) if key == "model"
3550 ));
3551
3552 assert!(matches!(
3553 parse_ok(&["deepseek", "config", "list"]).command,
3554 Some(Commands::Config(ConfigArgs {
3555 command: ConfigCommand::List
3556 }))
3557 ));
3558 assert!(matches!(
3559 parse_ok(&["deepseek", "config", "path"]).command,
3560 Some(Commands::Config(ConfigArgs {
3561 command: ConfigCommand::Path
3562 }))
3563 ));
3564 }
3565
3566 #[test]
3567 fn parses_update_beta_flag() {
3568 let cli = parse_ok(&["codewhale", "update"]);
3569 assert!(matches!(
3570 cli.command,
3571 Some(Commands::Update(UpdateArgs {
3572 beta: false,
3573 check: false,
3574 proxy: None
3575 }))
3576 ));
3577
3578 let cli = parse_ok(&["codewhale", "update", "--beta"]);
3579 assert!(matches!(
3580 cli.command,
3581 Some(Commands::Update(UpdateArgs {
3582 beta: true,
3583 check: false,
3584 proxy: None
3585 }))
3586 ));
3587
3588 let cli = parse_ok(&["codewhale", "update", "--check"]);
3589 assert!(matches!(
3590 cli.command,
3591 Some(Commands::Update(UpdateArgs {
3592 beta: false,
3593 check: true,
3594 proxy: None
3595 }))
3596 ));
3597
3598 let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
3599 let Some(Commands::Update(args)) = cli.command else {
3600 panic!("expected update command");
3601 };
3602 assert!(!args.beta);
3603 assert!(!args.check);
3604 assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
3605 }
3606
3607 #[test]
3608 fn parses_model_command_matrix() {
3609 let cli = parse_ok(&["deepseek", "model", "list"]);
3610 assert!(matches!(
3611 cli.command,
3612 Some(Commands::Model(ModelArgs {
3613 command: ModelCommand::List { provider: None }
3614 }))
3615 ));
3616
3617 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
3618 assert!(matches!(
3619 cli.command,
3620 Some(Commands::Model(ModelArgs {
3621 command: ModelCommand::List {
3622 provider: Some(ProviderArg::Openai)
3623 }
3624 }))
3625 ));
3626
3627 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
3628 assert!(matches!(
3629 cli.command,
3630 Some(Commands::Model(ModelArgs {
3631 command: ModelCommand::Resolve {
3632 model: Some(ref model),
3633 provider: None
3634 }
3635 })) if model == "deepseek-v4-flash"
3636 ));
3637
3638 let cli = parse_ok(&[
3639 "deepseek",
3640 "model",
3641 "resolve",
3642 "--provider",
3643 "deepseek",
3644 "deepseek-v4-pro",
3645 ]);
3646 assert!(matches!(
3647 cli.command,
3648 Some(Commands::Model(ModelArgs {
3649 command: ModelCommand::Resolve {
3650 model: Some(ref model),
3651 provider: Some(ProviderArg::Deepseek)
3652 }
3653 })) if model == "deepseek-v4-pro"
3654 ));
3655
3656 let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
3657 assert!(matches!(
3658 cli.command,
3659 Some(Commands::Model(ModelArgs {
3660 command: ModelCommand::Set { ref model }
3661 })) if model == "pro"
3662 ));
3663 }
3664
3665 #[test]
3666 fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
3667 assert_eq!(
3668 model_command_provider_hint(None, Some(ProviderKind::Zai)),
3669 Some(ProviderKind::Zai)
3670 );
3671 assert_eq!(
3672 model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
3673 Some(ProviderKind::Minimax)
3674 );
3675 assert_eq!(model_command_provider_hint(None, None), None);
3676
3677 let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
3678 assert_eq!(cli.provider.as_deref(), Some("zai"));
3679 assert!(matches!(
3680 cli.command,
3681 Some(Commands::Model(ModelArgs {
3682 command: ModelCommand::List { provider: None }
3683 }))
3684 ));
3685 }
3686
3687 #[test]
3688 fn parses_thread_command_matrix() {
3689 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
3690 assert!(matches!(
3691 cli.command,
3692 Some(Commands::Thread(ThreadArgs {
3693 command: ThreadCommand::List {
3694 all: true,
3695 limit: Some(50)
3696 }
3697 }))
3698 ));
3699
3700 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
3701 assert!(matches!(
3702 cli.command,
3703 Some(Commands::Thread(ThreadArgs {
3704 command: ThreadCommand::Read { ref thread_id }
3705 })) if thread_id == "thread-1"
3706 ));
3707
3708 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
3709 assert!(matches!(
3710 cli.command,
3711 Some(Commands::Thread(ThreadArgs {
3712 command: ThreadCommand::Resume { ref thread_id }
3713 })) if thread_id == "thread-2"
3714 ));
3715
3716 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
3717 assert!(matches!(
3718 cli.command,
3719 Some(Commands::Thread(ThreadArgs {
3720 command: ThreadCommand::Fork { ref thread_id }
3721 })) if thread_id == "thread-3"
3722 ));
3723
3724 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
3725 assert!(matches!(
3726 cli.command,
3727 Some(Commands::Thread(ThreadArgs {
3728 command: ThreadCommand::Archive { ref thread_id }
3729 })) if thread_id == "thread-4"
3730 ));
3731
3732 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
3733 assert!(matches!(
3734 cli.command,
3735 Some(Commands::Thread(ThreadArgs {
3736 command: ThreadCommand::Unarchive { ref thread_id }
3737 })) if thread_id == "thread-5"
3738 ));
3739
3740 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
3741 assert!(matches!(
3742 cli.command,
3743 Some(Commands::Thread(ThreadArgs {
3744 command: ThreadCommand::SetName {
3745 ref thread_id,
3746 ref name
3747 }
3748 })) if thread_id == "thread-6" && name == "My Thread"
3749 ));
3750
3751 let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
3752 assert!(matches!(
3753 cli.command,
3754 Some(Commands::Thread(ThreadArgs {
3755 command: ThreadCommand::ClearName { ref thread_id }
3756 })) if thread_id == "thread-7"
3757 ));
3758 }
3759
3760 #[test]
3761 fn parses_sandbox_app_server_and_completion_matrix() {
3762 let cli = parse_ok(&[
3763 "deepseek",
3764 "sandbox",
3765 "check",
3766 "echo hello",
3767 "--ask",
3768 "on-failure",
3769 ]);
3770 assert!(matches!(
3771 cli.command,
3772 Some(Commands::Sandbox(SandboxArgs {
3773 command: SandboxCommand::Check {
3774 ref command,
3775 ask: ApprovalModeArg::OnFailure
3776 }
3777 })) if command == "echo hello"
3778 ));
3779
3780 let cli = parse_ok(&[
3781 "deepseek",
3782 "app-server",
3783 "--host",
3784 "0.0.0.0",
3785 "--port",
3786 "9999",
3787 ]);
3788 assert!(matches!(
3789 cli.command,
3790 Some(Commands::AppServer(AppServerArgs {
3791 host: Some(ref host),
3792 port: Some(9999),
3793 stdio: false,
3794 http: false,
3795 mobile: false,
3796 ..
3797 })) if host == "0.0.0.0"
3798 ));
3799
3800 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
3801 assert!(matches!(
3802 cli.command,
3803 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
3804 ));
3805
3806 let cli = parse_ok(&["deepseek", "completion", "bash"]);
3807 assert!(matches!(
3808 cli.command,
3809 Some(Commands::Completion { shell: Shell::Bash })
3810 ));
3811 }
3812
3813 #[test]
3814 fn app_server_transports_are_mutually_exclusive() {
3815 assert!(matches!(
3816 parse_ok(&["deepseek", "app-server", "--http"]).command,
3817 Some(Commands::AppServer(AppServerArgs {
3818 http: true,
3819 mobile: false,
3820 stdio: false,
3821 ..
3822 }))
3823 ));
3824 assert!(matches!(
3825 parse_ok(&["deepseek", "app-server", "--mobile"]).command,
3826 Some(Commands::AppServer(AppServerArgs {
3827 mobile: true,
3828 http: false,
3829 stdio: false,
3830 ..
3831 }))
3832 ));
3833
3834 for argv in [
3835 ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
3836 ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
3837 ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
3838 ] {
3839 let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
3840 assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
3841 }
3842 }
3843
3844 #[test]
3845 fn app_server_qr_requires_mobile() {
3846 let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
3847 .expect_err("--qr without --mobile must fail");
3848 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
3849 assert!(matches!(
3850 parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
3851 Some(Commands::AppServer(AppServerArgs {
3852 mobile: true,
3853 qr: true,
3854 ..
3855 }))
3856 ));
3857 }
3858
3859 #[test]
3860 fn app_server_serve_passthrough_maps_flags_to_serve() {
3861 let args = AppServerArgs {
3862 http: true,
3863 mobile: false,
3864 stdio: false,
3865 qr: false,
3866 host: Some("127.0.0.1".to_string()),
3867 port: Some(9000),
3868 workers: Some(4),
3869 config: None,
3870 auth_token: Some("tok".to_string()),
3871 insecure_no_auth: true,
3872 cors_origin: vec!["http://localhost:5173".to_string()],
3873 };
3874 let argv = app_server_serve_passthrough(&args);
3875 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
3876 assert_eq!(
3878 as_str,
3879 vec![
3880 "serve",
3881 "--http",
3882 "--host",
3883 "127.0.0.1",
3884 "--port",
3885 "9000",
3886 "--workers",
3887 "4",
3888 "--cors-origin",
3889 "http://localhost:5173",
3890 "--auth-token",
3891 "tok",
3892 "--insecure",
3893 ]
3894 );
3895 }
3896
3897 #[test]
3898 fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
3899 let args = AppServerArgs {
3900 http: false,
3901 mobile: true,
3902 stdio: false,
3903 qr: true,
3904 host: None,
3905 port: None,
3906 workers: None,
3907 config: None,
3908 auth_token: None,
3909 insecure_no_auth: false,
3910 cors_origin: vec![],
3911 };
3912 let argv = app_server_serve_passthrough(&args);
3913 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
3914 assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
3917 }
3918
3919 #[test]
3920 fn serve_help_documents_forwarded_runtime_modes() {
3921 let help = help_for(&["codewhale", "serve", "--help"]);
3922 for flag in ["--http", "--mobile", "--mcp", "--acp"] {
3923 assert!(
3924 help.contains(flag),
3925 "serve help should document forwarded flag {flag}; help was:\n{help}"
3926 );
3927 }
3928 assert!(help.contains("compatibility"));
3929 }
3930
3931 #[test]
3932 fn parses_direct_tui_command_aliases() {
3933 let cli = parse_ok(&["deepseek", "doctor"]);
3934 assert!(matches!(
3935 cli.command,
3936 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
3937 ));
3938
3939 let cli = parse_ok(&["deepseek", "models", "--json"]);
3940 assert!(matches!(
3941 cli.command,
3942 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
3943 ));
3944
3945 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
3946 assert!(matches!(
3947 cli.command,
3948 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
3949 ));
3950
3951 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
3952 assert!(matches!(
3953 cli.command,
3954 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
3955 if args == &["--skills", "--local"]
3956 ));
3957
3958 let cli = parse_ok(&["codewhale", "fleet", "init"]);
3959 assert!(cli.prompt.is_empty());
3960 assert!(matches!(
3961 cli.command,
3962 Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
3963 ));
3964
3965 let cli = parse_ok(&[
3966 "codewhale",
3967 "fleet",
3968 "run",
3969 "tasks.json",
3970 "--max-workers",
3971 "2",
3972 ]);
3973 assert!(cli.prompt.is_empty());
3974 assert!(matches!(
3975 cli.command,
3976 Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
3977 if args == &["run", "tasks.json", "--max-workers", "2"]
3978 ));
3979
3980 let cli = parse_ok(&[
3981 "codewhale",
3982 "workflow",
3983 "run",
3984 "stopship",
3985 "--fleet",
3986 "stopship",
3987 "--runtime",
3988 "tmux",
3989 "--issue",
3990 "4375",
3991 ]);
3992 assert!(matches!(
3993 cli.command,
3994 Some(Commands::Workflow(WorkflowArgs {
3995 command: WorkflowCommand::Run {
3996 ref workflow,
3997 ref fleet,
3998 ref runtime,
3999 ref issue,
4000 ..
4001 }
4002 })) if workflow == "stopship"
4003 && fleet == "stopship"
4004 && runtime == "tmux"
4005 && issue.as_deref() == Some("4375")
4006 ));
4007 }
4008
4009 #[test]
4010 fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
4011 let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
4012 assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
4013 assert_eq!(
4014 top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
4015 .expect("built-in Exec provider"),
4016 Some(ProviderKind::Openrouter)
4017 );
4018
4019 for (provider, command) in [
4020 ("qianfan", vec!["exec", "Reply OK"]),
4021 ("lm-studio", vec!["exec", "Reply OK"]),
4022 ("lm-studio", vec!["fleet", "status"]),
4023 ] {
4024 let argv = std::iter::once("codewhale")
4025 .chain(["--provider", provider])
4026 .chain(command.iter().copied())
4027 .collect::<Vec<_>>();
4028 let cli = parse_ok(&argv);
4029 assert_eq!(cli.provider.as_deref(), Some(provider));
4030 assert_eq!(
4031 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
4032 .expect("raw TUI provider"),
4033 None,
4034 "{argv:?} should defer the raw provider id to the TUI"
4035 );
4036 }
4037 }
4038
4039 #[test]
4040 fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
4041 let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
4042 let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
4043 .expect_err("model registry commands still require a built-in provider");
4044 assert!(
4045 err.to_string()
4046 .contains("configured custom providers are accepted only by exec and fleet")
4047 );
4048
4049 let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
4050 .expect_err("auth keeps enum-only provider validation");
4051 assert_eq!(err.kind(), ErrorKind::InvalidValue);
4052
4053 let err = Cli::try_parse_from([
4054 "codewhale",
4055 "--provider",
4056 "../../lm-studio",
4057 "exec",
4058 "Reply OK",
4059 ])
4060 .expect_err("provider ids must stay simple tokens");
4061 assert!(
4062 err.to_string()
4063 .contains("provider must be a simple identifier")
4064 );
4065 }
4066
4067 #[test]
4068 fn raw_provider_dispatch_defers_dynamic_config_to_the_tui() {
4069 let dir = tempfile::TempDir::new().expect("tempdir");
4070 let config_path = dir.path().join("config.toml");
4071 std::fs::write(
4072 &config_path,
4073 r#"provider = "lm-studio"
4074
4075[providers.lm-studio]
4076kind = "openai-compatible"
4077base_url = "http://127.0.0.1:1234/v1"
4078model = "qwen-2.5-7b"
4079"#,
4080 )
4081 .expect("custom provider config fixture");
4082 assert!(
4083 ConfigStore::load(Some(config_path.clone())).is_err(),
4084 "the enum-backed dispatcher store must not be the owner of dynamic provider config"
4085 );
4086
4087 let config = config_path.to_string_lossy().into_owned();
4088 let cli = parse_ok(&[
4089 "codewhale",
4090 "--config",
4091 &config,
4092 "--provider",
4093 "lm-studio",
4094 "exec",
4095 "Reply OK",
4096 ]);
4097 let prepared = prepare_raw_provider_tui_dispatch(
4098 &cli,
4099 cli.command.as_ref(),
4100 &CliRuntimeOverrides::default(),
4101 )
4102 .expect("prepare raw provider dispatch")
4103 .expect("Exec with a raw provider should bypass dispatcher config resolution");
4104 assert_eq!(prepared.1, ["exec", "Reply OK"].map(str::to_string));
4105 }
4106
4107 #[test]
4108 fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
4109 let cli = parse_ok(&[
4110 "codewhale",
4111 "lane-log-proxy",
4112 "--log-path",
4113 "/tmp/lane.ndjson",
4114 "--receipt-path",
4115 "/tmp/lane.exit.json",
4116 "--receipt-tmp-path",
4117 "/tmp/lane.exit.json.tmp",
4118 "--environment-path",
4119 "/tmp/lane.env.json",
4120 "--lane-id",
4121 "lane-proof",
4122 "--",
4123 "/bin/echo",
4124 "--child-flag",
4125 "hello",
4126 ]);
4127 let (proxy, command) = split_lane_log_proxy_command(cli.command);
4128 assert!(command.is_none());
4129 let proxy = proxy.expect("proxy args");
4130 assert_eq!(proxy.lane_id, "lane-proof");
4131 assert_eq!(
4132 proxy.command,
4133 ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
4134 );
4135
4136 let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
4137 let (proxy, command) = split_lane_log_proxy_command(cli.command);
4138 assert!(proxy.is_none());
4139 assert!(matches!(
4140 command,
4141 Some(Commands::Lane(LaneArgs {
4142 command: LaneCommand::List { json: true }
4143 }))
4144 ));
4145 }
4146
4147 #[test]
4148 fn short_workflow_names_do_not_resolve_historical_v0868_files() {
4149 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4150 .join("..")
4151 .join("..");
4152 let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
4153 assert!(candidates.iter().all(|path| {
4154 !path
4155 .file_name()
4156 .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
4157 }));
4158 assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
4159
4160 let historical = resolve_workflow_source_path(
4161 "workflows/v0868_issue_sweep.workflow.js",
4162 None,
4163 &workspace,
4164 )
4165 .expect("explicit historical workflow path");
4166 assert!(historical.ends_with("workflows/v0868_issue_sweep.workflow.js"));
4167 }
4168
4169 #[test]
4170 fn workflow_run_resolves_stopship_alias_and_payload() {
4171 let _lock = env_lock();
4172 let (_dir, _tui) = install_fake_tui_binary();
4173 let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
4174 let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
4175 let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
4176 let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
4177 let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
4178 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4179 .join("..")
4180 .join("..");
4181 let cli = parse_ok(&[
4182 "codewhale",
4183 "--profile",
4184 "workflow-profile",
4185 "--model",
4186 "explicit-workflow-model",
4187 "--api-key",
4188 "explicit-profile-key",
4189 "--workspace",
4190 workspace.to_str().expect("workspace UTF-8"),
4191 ]);
4192 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
4193 let source = resolve_workflow_source_path("stopship", None, &workspace)
4194 .expect("stopship workflow source");
4195 assert!(source.ends_with("workflows/stopship.workflow.js"));
4196
4197 let process = workflow_exec_command(WorkflowExecSpec {
4198 cli: &cli,
4199 resolved_runtime: &resolved,
4200 config_path: &workspace.join("config.toml"),
4201 source_root: &workspace,
4202 source_path: &source,
4203 workflow: "stopship",
4204 fleet: "stopship",
4205 issue: Some("4375"),
4206 goal: Some("fix stopship"),
4207 token_budget: Some(25_000),
4208 verify: true,
4209 })
4210 .expect("command");
4211 let joined = process.command.join("\n");
4212 assert!(joined.contains("workflow-tool"));
4213 assert!(joined.contains("explicit-workflow-command"));
4214 assert!(joined.contains("--input-json"));
4215 assert!(!process.command.iter().any(|arg| arg == "exec"));
4216 assert!(!process.command.iter().any(|arg| arg == "--workspace"));
4217 assert!(
4218 process
4219 .command
4220 .windows(2)
4221 .any(|pair| pair == ["--profile", "workflow-profile"])
4222 );
4223 assert!(!joined.contains("Run the CodeWhale"));
4224 assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
4225 assert!(joined.contains("\"fleet\":\"stopship\""));
4226 assert!(joined.contains("\"issue\":\"4375\""));
4227 assert!(joined.contains("\"token_budget\":25000"));
4228 assert!(joined.contains("\"verify\":true"));
4229 assert!(
4230 process.environment.iter().any(|(key, value)| {
4231 key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
4232 })
4233 );
4234 assert!(
4235 !process
4236 .environment
4237 .iter()
4238 .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
4239 );
4240 assert!(
4241 !process
4242 .environment
4243 .iter()
4244 .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
4245 );
4246 assert!(
4247 !process
4248 .environment
4249 .iter()
4250 .any(|(key, _)| key == "DEEPSEEK_API_KEY")
4251 );
4252 assert!(process.environment.iter().any(|(key, value)| {
4253 key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
4254 }));
4255 assert!(
4256 !process
4257 .command
4258 .iter()
4259 .any(|argument| argument.contains("explicit-profile-key"))
4260 );
4261 assert!(
4262 process
4263 .environment
4264 .iter()
4265 .all(|(_, value)| value != "test-model")
4266 );
4267 }
4268
4269 #[test]
4270 fn exec_keeps_global_looking_flags_as_passthrough_args() {
4271 let cli = parse_ok(&[
4272 "codewhale",
4273 "exec",
4274 "--provider",
4275 "definitely-not-a-provider",
4276 "Reply OK",
4277 ]);
4278
4279 let Some(Commands::Exec(args)) = cli.command else {
4280 panic!("expected exec command");
4281 };
4282
4283 assert_eq!(
4284 args.args,
4285 vec![
4286 "--provider".to_string(),
4287 "definitely-not-a-provider".to_string(),
4288 "Reply OK".to_string(),
4289 ]
4290 );
4291 }
4292
4293 #[test]
4294 fn exec_rejects_provider_after_subcommand() {
4295 let args = vec![
4296 "--provider".to_string(),
4297 "definitely-not-a-provider".to_string(),
4298 "Reply OK".to_string(),
4299 ];
4300
4301 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
4302
4303 assert!(
4304 err.to_string()
4305 .contains("--provider must be placed before `exec`")
4306 );
4307 }
4308
4309 #[test]
4310 fn exec_rejects_equals_form_provider_after_subcommand() {
4311 let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
4312
4313 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
4314
4315 assert!(
4316 err.to_string()
4317 .contains("--provider must be placed before `exec`")
4318 );
4319 }
4320
4321 #[test]
4322 fn exec_allows_documented_forwarded_flags() {
4323 let args = vec![
4324 "--auto".to_string(),
4325 "--output-format".to_string(),
4326 "stream-json".to_string(),
4327 "fix tests".to_string(),
4328 ];
4329
4330 reject_exec_global_flags(&args).expect("documented exec flags should pass");
4331 }
4332
4333 #[test]
4334 fn exec_allows_literal_prompt_flags_after_separator() {
4335 let args = vec![
4336 "--".to_string(),
4337 "--provider".to_string(),
4338 "is literal prompt text".to_string(),
4339 ];
4340
4341 reject_exec_global_flags(&args).expect("separator should stop global flag validation");
4342 }
4343
4344 #[test]
4345 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
4346 assert!(should_pick_resume_in_dispatcher(
4347 &["resume".to_string()],
4348 true
4349 ));
4350 assert!(!should_pick_resume_in_dispatcher(
4351 &["resume".to_string(), "--last".to_string()],
4352 true
4353 ));
4354 assert!(!should_pick_resume_in_dispatcher(
4355 &["resume".to_string(), "abc123".to_string()],
4356 true
4357 ));
4358 assert!(!should_pick_resume_in_dispatcher(
4359 &["resume".to_string()],
4360 false
4361 ));
4362 }
4363
4364 #[test]
4365 fn deepseek_login_uses_isolated_file_store_and_preserves_tui_defaults() {
4366 let _lock = env_lock();
4367 let dir = tempfile::TempDir::new().expect("tempdir");
4368 let codewhale_home = dir.path().join("codewhale-home");
4369 let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
4370 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
4371 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
4372 let path = codewhale_home.join("config.toml");
4373 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4374 let secrets = Secrets::auto_detect();
4375
4376 run_login_command_with_secrets(
4377 &mut store,
4378 LoginArgs {
4379 provider: Some(ProviderArg::Deepseek),
4380 api_key: Some("sk-test".to_string()),
4381 },
4382 &secrets,
4383 )
4384 .expect("login should persist credential");
4385
4386 assert!(store.config.api_key.is_none());
4387 assert!(store.config.providers.deepseek.api_key.is_none());
4388 assert_eq!(
4389 store.config.default_text_model.as_deref(),
4390 Some("deepseek-v4-pro")
4391 );
4392 let saved = std::fs::read_to_string(&path).expect("config should be written");
4393 assert!(!saved.contains("sk-test"), "{saved}");
4394 assert!(
4395 !saved
4396 .lines()
4397 .any(|line| line.trim_start().starts_with("api_key ="))
4398 );
4399 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
4400 assert_eq!(
4401 secrets.get("deepseek").expect("read secret").as_deref(),
4402 Some("sk-test")
4403 );
4404 }
4405
4406 #[test]
4407 fn parses_auth_subcommand_matrix() {
4408 let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
4409 assert!(matches!(
4410 cli.command,
4411 Some(Commands::Auth(AuthArgs {
4412 command: AuthCommand::XaiDevice
4413 }))
4414 ));
4415
4416 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
4417 assert!(matches!(
4418 cli.command,
4419 Some(Commands::Auth(AuthArgs {
4420 command: AuthCommand::Set {
4421 provider: ProviderArg::Deepseek,
4422 api_key: None,
4423 api_key_stdin: false,
4424 }
4425 }))
4426 ));
4427
4428 let cli = parse_ok(&[
4429 "deepseek",
4430 "auth",
4431 "set",
4432 "--provider",
4433 "openrouter",
4434 "--api-key-stdin",
4435 ]);
4436 assert!(matches!(
4437 cli.command,
4438 Some(Commands::Auth(AuthArgs {
4439 command: AuthCommand::Set {
4440 provider: ProviderArg::Openrouter,
4441 api_key: None,
4442 api_key_stdin: true,
4443 }
4444 }))
4445 ));
4446
4447 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
4448 assert!(matches!(
4449 cli.command,
4450 Some(Commands::Auth(AuthArgs {
4451 command: AuthCommand::Get {
4452 provider: ProviderArg::Novita
4453 }
4454 }))
4455 ));
4456
4457 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
4458 assert!(matches!(
4459 cli.command,
4460 Some(Commands::Auth(AuthArgs {
4461 command: AuthCommand::Clear {
4462 provider: ProviderArg::NvidiaNim
4463 }
4464 }))
4465 ));
4466
4467 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
4468 assert!(matches!(
4469 cli.command,
4470 Some(Commands::Auth(AuthArgs {
4471 command: AuthCommand::Set {
4472 provider: ProviderArg::Fireworks,
4473 api_key: None,
4474 api_key_stdin: false,
4475 }
4476 }))
4477 ));
4478
4479 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
4480 assert!(matches!(
4481 cli.command,
4482 Some(Commands::Auth(AuthArgs {
4483 command: AuthCommand::Set {
4484 provider: ProviderArg::Siliconflow,
4485 api_key: None,
4486 api_key_stdin: false,
4487 }
4488 }))
4489 ));
4490
4491 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
4492 assert!(matches!(
4493 cli.command,
4494 Some(Commands::Auth(AuthArgs {
4495 command: AuthCommand::Set {
4496 provider: ProviderArg::Arcee,
4497 api_key: None,
4498 api_key_stdin: false,
4499 }
4500 }))
4501 ));
4502
4503 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
4504 assert!(matches!(
4505 cli.command,
4506 Some(Commands::Auth(AuthArgs {
4507 command: AuthCommand::Set {
4508 provider: ProviderArg::Moonshot,
4509 api_key: None,
4510 api_key_stdin: false,
4511 }
4512 }))
4513 ));
4514
4515 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
4516 assert!(matches!(
4517 cli.command,
4518 Some(Commands::Auth(AuthArgs {
4519 command: AuthCommand::Set {
4520 provider: ProviderArg::WanjieArk,
4521 api_key: None,
4522 api_key_stdin: false,
4523 }
4524 }))
4525 ));
4526
4527 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
4528 assert!(matches!(
4529 cli.command,
4530 Some(Commands::Auth(AuthArgs {
4531 command: AuthCommand::Get {
4532 provider: ProviderArg::Sglang
4533 }
4534 }))
4535 ));
4536
4537 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
4538 assert!(matches!(
4539 cli.command,
4540 Some(Commands::Auth(AuthArgs {
4541 command: AuthCommand::Get {
4542 provider: ProviderArg::Vllm
4543 }
4544 }))
4545 ));
4546
4547 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
4548 assert!(matches!(
4549 cli.command,
4550 Some(Commands::Auth(AuthArgs {
4551 command: AuthCommand::Set {
4552 provider: ProviderArg::Ollama,
4553 api_key: None,
4554 api_key_stdin: false,
4555 }
4556 }))
4557 ));
4558
4559 let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
4560 assert!(matches!(
4561 cli.command,
4562 Some(Commands::Auth(AuthArgs {
4563 command: AuthCommand::Status {
4564 provider: Some(ProviderArg::OpenaiCodex)
4565 }
4566 }))
4567 ));
4568
4569 for (provider, expected) in [
4570 ("anthropic", ProviderArg::Anthropic),
4571 ("openmodel", ProviderArg::Openmodel),
4572 ("open-model", ProviderArg::Openmodel),
4573 ("zai", ProviderArg::Zai),
4574 ("stepfun", ProviderArg::Stepfun),
4575 ("minimax", ProviderArg::Minimax),
4576 ("minimax-anthropic", ProviderArg::MinimaxAnthropic),
4577 ("minimax_anthropic", ProviderArg::MinimaxAnthropic),
4578 ("deepinfra", ProviderArg::Deepinfra),
4579 ("deep-infra", ProviderArg::Deepinfra),
4580 ("siliconflow-cn", ProviderArg::SiliconflowCn),
4581 ("siliconflow-CN", ProviderArg::SiliconflowCn),
4582 ("siliconflow_china", ProviderArg::SiliconflowCn),
4583 ] {
4584 let cli = parse_ok(&[
4585 "deepseek",
4586 "auth",
4587 "set",
4588 "--provider",
4589 provider,
4590 "--api-key-stdin",
4591 ]);
4592 assert!(matches!(
4593 cli.command,
4594 Some(Commands::Auth(AuthArgs {
4595 command: AuthCommand::Set {
4596 provider,
4597 api_key: None,
4598 api_key_stdin: true,
4599 }
4600 })) if provider == expected
4601 ));
4602 }
4603
4604 let cli = parse_ok(&["deepseek", "auth", "list"]);
4605 assert!(matches!(
4606 cli.command,
4607 Some(Commands::Auth(AuthArgs {
4608 command: AuthCommand::List
4609 }))
4610 ));
4611
4612 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
4613 assert!(matches!(
4614 cli.command,
4615 Some(Commands::Auth(AuthArgs {
4616 command: AuthCommand::Migrate { dry_run: false }
4617 }))
4618 ));
4619
4620 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
4621 assert!(matches!(
4622 cli.command,
4623 Some(Commands::Auth(AuthArgs {
4624 command: AuthCommand::Migrate { dry_run: true }
4625 }))
4626 ));
4627 }
4628
4629 #[test]
4630 fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
4631 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
4632 use std::sync::Arc;
4633
4634 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4635 let path = std::env::temp_dir().join(format!(
4636 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
4637 std::process::id()
4638 ));
4639 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4640 let inner = Arc::new(InMemoryKeyringStore::new());
4641 let secrets = Secrets::new(inner.clone());
4642
4643 run_auth_command_with_secrets(
4644 &mut store,
4645 AuthCommand::Set {
4646 provider: ProviderArg::Deepseek,
4647 api_key: Some("sk-keyring".to_string()),
4648 api_key_stdin: false,
4649 },
4650 &secrets,
4651 )
4652 .expect("set should succeed");
4653
4654 assert!(store.config.api_key.is_none());
4655 assert!(store.config.providers.deepseek.api_key.is_none());
4656 let saved = std::fs::read_to_string(&path).unwrap_or_default();
4657 assert!(!saved.contains("sk-keyring"), "{saved}");
4658 assert!(
4659 !saved
4660 .lines()
4661 .any(|line| line.trim_start().starts_with("api_key ="))
4662 );
4663 assert_eq!(
4664 inner.get("deepseek").unwrap().as_deref(),
4665 Some("sk-keyring")
4666 );
4667
4668 let _ = std::fs::remove_file(path);
4669 }
4670
4671 #[test]
4672 fn auth_set_uses_plaintext_config_only_when_secret_store_write_fails() {
4673 use codewhale_secrets::{KeyringStore, SecretsError};
4674 use std::sync::Arc;
4675
4676 struct FailingStore;
4677
4678 impl KeyringStore for FailingStore {
4679 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
4680 Ok(None)
4681 }
4682
4683 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
4684 Err(SecretsError::Keyring("test write failure".to_string()))
4685 }
4686
4687 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
4688 Ok(())
4689 }
4690
4691 fn backend_name(&self) -> &'static str {
4692 "failing test store"
4693 }
4694 }
4695
4696 let dir = tempfile::TempDir::new().expect("tempdir");
4697 let path = dir.path().join("config.toml");
4698 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
4699 let secrets = Secrets::new(Arc::new(FailingStore));
4700
4701 run_auth_command_with_secrets(
4702 &mut store,
4703 AuthCommand::Set {
4704 provider: ProviderArg::Openrouter,
4705 api_key: Some("fallback-test-credential".to_string()),
4706 api_key_stdin: false,
4707 },
4708 &secrets,
4709 )
4710 .expect("config fallback");
4711
4712 assert_eq!(
4713 store.config.providers.openrouter.api_key.as_deref(),
4714 Some("fallback-test-credential")
4715 );
4716 let saved = std::fs::read_to_string(path).expect("config fallback file");
4717 assert!(saved.contains("fallback-test-credential"));
4718 }
4719
4720 #[test]
4721 fn auth_set_provider_key_does_not_switch_active_provider() {
4722 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4723 let path = std::env::temp_dir().join(format!(
4724 "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
4725 std::process::id()
4726 ));
4727 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4728 store.config.provider = ProviderKind::Deepseek;
4729 let secrets = no_keyring_secrets();
4730
4731 run_auth_command_with_secrets(
4732 &mut store,
4733 AuthCommand::Set {
4734 provider: ProviderArg::Arcee,
4735 api_key: Some("arcee-key".to_string()),
4736 api_key_stdin: false,
4737 },
4738 &secrets,
4739 )
4740 .expect("set should succeed");
4741
4742 assert_eq!(store.config.provider, ProviderKind::Deepseek);
4743 assert!(store.config.providers.arcee.api_key.is_none());
4744 assert_eq!(
4745 store.config.providers.arcee.auth_mode.as_deref(),
4746 Some("api_key")
4747 );
4748
4749 let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
4750 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
4751 assert!(reloaded.config.providers.arcee.api_key.is_none());
4752 assert_eq!(
4753 reloaded.config.providers.arcee.auth_mode.as_deref(),
4754 Some("api_key")
4755 );
4756
4757 let _ = std::fs::remove_file(path);
4758 }
4759
4760 #[test]
4761 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
4762 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4763 let path = std::env::temp_dir().join(format!(
4764 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
4765 std::process::id()
4766 ));
4767 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4768 store.config.provider = ProviderKind::Deepseek;
4769 let secrets = no_keyring_secrets();
4770
4771 run_auth_command_with_secrets(
4772 &mut store,
4773 AuthCommand::Set {
4774 provider: ProviderArg::Ollama,
4775 api_key: None,
4776 api_key_stdin: false,
4777 },
4778 &secrets,
4779 )
4780 .expect("ollama auth set should not require a key");
4781
4782 assert_eq!(store.config.provider, ProviderKind::Deepseek);
4783 assert_eq!(
4784 store.config.providers.ollama.base_url.as_deref(),
4785 Some("http://localhost:11434/v1")
4786 );
4787 assert_eq!(store.config.providers.ollama.api_key, None);
4788
4789 let _ = std::fs::remove_file(path);
4790 }
4791
4792 #[test]
4793 fn auth_clear_removes_from_config() {
4794 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
4795 use std::sync::Arc;
4796
4797 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4798 let path = std::env::temp_dir().join(format!(
4799 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
4800 std::process::id()
4801 ));
4802 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4803 store.config.api_key = Some("sk-stale".to_string());
4804 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
4805 store.save().unwrap();
4806
4807 let inner = Arc::new(InMemoryKeyringStore::new());
4808 inner.set("deepseek", "sk-stale").unwrap();
4809 let secrets = Secrets::new(inner.clone());
4810
4811 run_auth_command_with_secrets(
4812 &mut store,
4813 AuthCommand::Clear {
4814 provider: ProviderArg::Deepseek,
4815 },
4816 &secrets,
4817 )
4818 .expect("clear should succeed");
4819
4820 assert!(store.config.api_key.is_none());
4821 assert!(store.config.providers.deepseek.api_key.is_none());
4822 assert_eq!(inner.get("deepseek").unwrap(), None);
4823
4824 let _ = std::fs::remove_file(path);
4825 }
4826
4827 #[test]
4828 fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
4829 use codewhale_secrets::{KeyringStore, SecretsError};
4830 use std::sync::{Arc, Mutex};
4831
4832 #[derive(Default)]
4833 struct RecordingStore {
4834 gets: Mutex<Vec<String>>,
4835 }
4836
4837 impl KeyringStore for RecordingStore {
4838 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
4839 self.gets.lock().unwrap().push(key.to_string());
4840 Ok(None)
4841 }
4842
4843 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
4844 Ok(())
4845 }
4846
4847 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
4848 Ok(())
4849 }
4850
4851 fn backend_name(&self) -> &'static str {
4852 "recording"
4853 }
4854 }
4855
4856 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4857 let path = std::env::temp_dir().join(format!(
4858 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
4859 std::process::id()
4860 ));
4861 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4862 store.config.provider = ProviderKind::Deepseek;
4863 let inner = Arc::new(RecordingStore::default());
4864 let secrets = Secrets::new(inner.clone());
4865
4866 run_auth_command_with_secrets(
4867 &mut store,
4868 AuthCommand::Status {
4869 provider: Some(ProviderArg::Deepseek),
4870 },
4871 &secrets,
4872 )
4873 .expect("status should succeed");
4874 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
4875 .expect("list should succeed");
4876
4877 let probed = inner.gets.lock().unwrap();
4878 assert_eq!(probed[0], "deepseek");
4880 assert!(probed.len() > 1, "list should probe all providers");
4883 assert!(
4884 ProviderKind::ALL
4885 .iter()
4886 .all(|p| probed.contains(&provider_slot(*p).to_string())),
4887 "every known provider should be probed by auth list: {:?}",
4888 *probed
4889 );
4890
4891 let _ = std::fs::remove_file(path);
4892 }
4893
4894 #[test]
4895 fn auth_status_reports_all_active_provider_sources_with_last4() {
4896 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
4897 use std::sync::Arc;
4898
4899 let _lock = env_lock();
4900 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
4901
4902 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4903 let path = std::env::temp_dir().join(format!(
4904 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
4905 std::process::id()
4906 ));
4907 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4908 store.config.provider = ProviderKind::Deepseek;
4909 store.config.api_key = Some("sk-config-3333".to_string());
4910 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
4911
4912 let inner = Arc::new(InMemoryKeyringStore::new());
4913 inner.set("deepseek", "sk-keyring-2222").unwrap();
4914 let secrets = Secrets::new(inner);
4915
4916 let output =
4917 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
4918
4919 assert!(output.contains("provider: deepseek"));
4920 assert!(output.contains("active source: config (last4: ...3333)"));
4921 assert!(output.contains("lookup order: config -> secret store -> env"));
4922 assert!(output.contains("config file: "));
4923 assert!(output.contains("set, last4: ...3333"));
4924 assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
4925 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
4926 assert!(!output.contains("sk-config-3333"));
4927 assert!(!output.contains("sk-keyring-2222"));
4928 assert!(!output.contains("sk-env-1111"));
4929
4930 let _ = std::fs::remove_file(path);
4931 }
4932
4933 #[test]
4934 fn auth_status_all_providers_lists_every_known_provider() {
4935 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
4936 use std::sync::Arc;
4937
4938 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
4939 let path = std::env::temp_dir().join(format!(
4940 "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
4941 std::process::id()
4942 ));
4943 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
4944 store.config.provider = ProviderKind::Deepseek;
4945 store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
4946
4947 let inner = Arc::new(InMemoryKeyringStore::new());
4948 inner.set("openrouter", "sk-or-test5678").unwrap();
4949 let secrets = Secrets::new(inner);
4950
4951 let output = auth_status_all_providers(&store, &secrets).join("\n");
4952
4953 assert!(output.contains("deepseek"));
4955 assert!(output.contains("arcee"));
4956 assert!(output.contains("openrouter"));
4957 assert!(output.contains("huggingface"));
4958 assert!(output.contains("ollama"));
4959
4960 assert!(output.contains("deepseek") && output.contains("*"));
4962
4963 assert!(output.contains("config"));
4965
4966 assert!(!output.contains("sk-arcee-test1234"));
4968 assert!(!output.contains("sk-or-test5678"));
4969
4970 let _ = std::fs::remove_file(path);
4971 }
4972
4973 #[test]
4974 fn auth_status_openai_codex_reports_codex_oauth_file() {
4975 use codewhale_secrets::InMemoryKeyringStore;
4976 use std::sync::Arc;
4977
4978 let _lock = env_lock();
4979 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
4980 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
4981
4982 let dir = tempfile::TempDir::new().expect("tempdir");
4983 let config_path = dir.path().join("config.toml");
4984 let auth_path = dir.path().join("auth.json");
4985 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
4986 .expect("write auth file");
4987 let auth_path_str = auth_path.to_string_lossy().into_owned();
4988 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
4989
4990 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
4991 store.config.provider = ProviderKind::OpenaiCodex;
4992 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
4993
4994 let output =
4995 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
4996
4997 assert!(output.contains("provider: openai-codex"));
4998 assert!(output.contains("auth mode: codex_oauth"));
4999 assert!(output.contains("active source: Codex OAuth file"));
5000 assert!(output.contains("lookup order: env -> Codex OAuth file"));
5001 assert!(output.contains(&format!(
5002 "Codex OAuth file: {} (present)",
5003 auth_path.display()
5004 )));
5005 assert!(!output.contains("secret-token"));
5006 }
5007
5008 #[test]
5009 fn auth_list_treats_openai_codex_oauth_file_as_active() {
5010 use codewhale_secrets::InMemoryKeyringStore;
5011 use std::sync::Arc;
5012
5013 let _lock = env_lock();
5014 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
5015 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
5016
5017 let dir = tempfile::TempDir::new().expect("tempdir");
5018 let config_path = dir.path().join("config.toml");
5019 let auth_path = dir.path().join("auth.json");
5020 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
5021 .expect("write auth file");
5022 let auth_path_str = auth_path.to_string_lossy().into_owned();
5023 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
5024
5025 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
5026 store.config.provider = ProviderKind::OpenaiCodex;
5027 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
5028
5029 let output = auth_list_lines(&store, &secrets).join("\n");
5030 let row = output
5031 .lines()
5032 .find(|line| line.starts_with("openai-codex"))
5033 .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
5034 assert!(row.ends_with("oauth"), "{row}");
5035 assert!(!output.contains("secret-token"));
5036 }
5037
5038 #[test]
5039 fn auth_status_scoped_provider_shows_detailed_info() {
5040 use codewhale_secrets::InMemoryKeyringStore;
5041 use std::sync::Arc;
5042
5043 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5044 let path = std::env::temp_dir().join(format!(
5045 "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
5046 std::process::id()
5047 ));
5048 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5049 store.config.provider = ProviderKind::Deepseek;
5050 store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
5051
5052 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
5053
5054 let output =
5055 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
5056
5057 assert!(output.contains("provider: arcee"));
5058 assert!(output.contains("active source: config (last4: ...9999)"));
5059 assert!(output.contains("route:"));
5060 assert!(output.contains("model:"));
5061 assert!(!output.contains("sk-arcee-9999"));
5062
5063 let _ = std::fs::remove_file(path);
5064 }
5065
5066 #[test]
5067 fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
5068 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
5069 use std::sync::Arc;
5070
5071 let _lock = env_lock();
5075 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5076 let path = std::env::temp_dir().join(format!(
5077 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
5078 std::process::id()
5079 ));
5080 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5081 let inner = Arc::new(InMemoryKeyringStore::new());
5082 inner.set("deepseek", "ring-key").unwrap();
5083 let secrets = Secrets::new(inner);
5084
5085 let resolved = resolve_runtime_for_dispatch_with_secrets(
5086 &mut store,
5087 &CliRuntimeOverrides::default(),
5088 &secrets,
5089 );
5090
5091 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
5092 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
5093 assert!(store.config.api_key.is_none());
5094 assert!(store.config.providers.deepseek.api_key.is_none());
5095 assert!(
5096 !path.exists(),
5097 "dispatch must not create config from a stored key"
5098 );
5099
5100 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
5101 &mut store,
5102 &CliRuntimeOverrides::default(),
5103 &secrets,
5104 );
5105 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
5106 assert_eq!(
5107 resolved_again.api_key_source,
5108 Some(RuntimeApiKeySource::Keyring)
5109 );
5110 assert!(
5111 !path.exists(),
5112 "repeat dispatch must remain credential-file free"
5113 );
5114
5115 let _ = std::fs::remove_file(path);
5116 }
5117
5118 #[test]
5119 fn logout_removes_plaintext_provider_keys() {
5120 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5121 let path = std::env::temp_dir().join(format!(
5122 "deepseek-cli-logout-test-{}-{nanos}.toml",
5123 std::process::id()
5124 ));
5125 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5126 store.config.api_key = Some("sk-stale".to_string());
5127 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
5128 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
5129 store.save().unwrap();
5130
5131 let secrets = no_keyring_secrets();
5132
5133 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
5134
5135 assert!(store.config.api_key.is_none());
5136 assert!(store.config.providers.deepseek.api_key.is_none());
5137 assert!(store.config.providers.fireworks.api_key.is_none());
5138
5139 let _ = std::fs::remove_file(path);
5140 }
5141
5142 #[test]
5143 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
5144 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
5145 use std::sync::Arc;
5146
5147 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5148 let path = std::env::temp_dir().join(format!(
5149 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
5150 std::process::id()
5151 ));
5152 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5153 store.config.api_key = Some("sk-deep".to_string());
5154 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
5155 store.config.providers.openrouter.api_key = Some("or-key".to_string());
5156 store.config.providers.novita.api_key = Some("nv-key".to_string());
5157 store.save().unwrap();
5158
5159 let inner = Arc::new(InMemoryKeyringStore::new());
5160 let secrets = Secrets::new(inner.clone());
5161
5162 run_auth_command_with_secrets(
5163 &mut store,
5164 AuthCommand::Migrate { dry_run: false },
5165 &secrets,
5166 )
5167 .expect("migrate should succeed");
5168
5169 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
5170 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
5171 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
5172
5173 assert!(store.config.api_key.is_none());
5175 assert!(store.config.providers.deepseek.api_key.is_none());
5176 assert!(store.config.providers.openrouter.api_key.is_none());
5177 assert!(store.config.providers.novita.api_key.is_none());
5178
5179 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
5180 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
5181 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
5182 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
5183
5184 let backup_path = path.with_file_name(format!(
5185 "{}.bak",
5186 path.file_name().unwrap_or_default().to_string_lossy()
5187 ));
5188 let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
5189 assert!(
5190 !backup.contains("sk-deep"),
5191 "plaintext leaked in backup: {backup}"
5192 );
5193 assert!(
5194 !backup.contains("or-key"),
5195 "plaintext leaked in backup: {backup}"
5196 );
5197 assert!(
5198 !backup.contains("nv-key"),
5199 "plaintext leaked in backup: {backup}"
5200 );
5201
5202 let resolved = resolve_runtime_for_dispatch_with_secrets(
5203 &mut store,
5204 &CliRuntimeOverrides::default(),
5205 &secrets,
5206 );
5207 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
5208 let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
5209 assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
5210 assert!(
5211 !after_dispatch
5212 .lines()
5213 .any(|line| line.trim_start().starts_with("api_key ="))
5214 );
5215
5216 let _ = std::fs::remove_file(path);
5217 }
5218
5219 #[test]
5220 fn auth_migrate_dry_run_does_not_modify_anything() {
5221 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
5222 use std::sync::Arc;
5223
5224 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
5225 let path = std::env::temp_dir().join(format!(
5226 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
5227 std::process::id()
5228 ));
5229 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5230 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
5231 store.save().unwrap();
5232
5233 let inner = Arc::new(InMemoryKeyringStore::new());
5234 let secrets = Secrets::new(inner.clone());
5235
5236 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
5237 .expect("dry-run should succeed");
5238
5239 assert_eq!(inner.get("openrouter").unwrap(), None);
5240 assert_eq!(
5241 store.config.providers.openrouter.api_key.as_deref(),
5242 Some("or-stay")
5243 );
5244
5245 let _ = std::fs::remove_file(path);
5246 }
5247
5248 #[test]
5249 fn parses_global_override_flags() {
5250 let cli = parse_ok(&[
5251 "deepseek",
5252 "--provider",
5253 "openai",
5254 "--config",
5255 "/tmp/deepseek.toml",
5256 "--profile",
5257 "work",
5258 "--model",
5259 "deepseek-v4-pro",
5260 "--output-mode",
5261 "json",
5262 "--verbosity",
5263 "concise",
5264 "--log-level",
5265 "debug",
5266 "--telemetry",
5267 "true",
5268 "--approval-policy",
5269 "on-request",
5270 "--sandbox-mode",
5271 "workspace-write",
5272 "--base-url",
5273 "https://openai-compatible.example/v1",
5274 "--api-key",
5275 "sk-test",
5276 "--workspace",
5277 "/tmp/workspace",
5278 "--no-alt-screen",
5279 "--no-mouse-capture",
5280 "--skip-onboarding",
5281 "model",
5282 "resolve",
5283 "deepseek-v4-pro",
5284 ]);
5285
5286 assert_eq!(cli.provider.as_deref(), Some("openai"));
5287 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
5288 assert_eq!(cli.profile.as_deref(), Some("work"));
5289 assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
5290 assert_eq!(cli.output_mode.as_deref(), Some("json"));
5291 assert_eq!(cli.verbosity.as_deref(), Some("concise"));
5292 assert_eq!(cli.log_level.as_deref(), Some("debug"));
5293 assert_eq!(cli.telemetry, Some(true));
5294 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
5295 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
5296 assert_eq!(
5297 cli.base_url.as_deref(),
5298 Some("https://openai-compatible.example/v1")
5299 );
5300 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
5301 assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
5302 assert!(cli.no_alt_screen);
5303 assert!(cli.no_mouse_capture);
5304 assert!(!cli.mouse_capture);
5305 assert!(cli.skip_onboarding);
5306 }
5307
5308 #[test]
5309 fn cli_provider_helpers_follow_config_metadata() {
5310 let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
5311 .iter()
5312 .map(|provider| provider.kind())
5313 .collect();
5314 assert_eq!(registry_kinds, ProviderKind::ALL);
5315
5316 for provider in ProviderKind::ALL {
5317 assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
5318 if provider == ProviderKind::SiliconflowCN {
5319 assert_eq!(
5320 provider_slot(provider),
5321 provider_slot(ProviderKind::Siliconflow)
5322 );
5323 } else {
5324 assert_eq!(provider_slot(provider), provider.provider().id());
5325 }
5326 }
5327 }
5328
5329 #[test]
5330 fn build_tui_command_forwards_raw_exec_and_fleet_provider_without_secret_bridge() {
5331 let _lock = env_lock();
5332 let (_dir, _bin) = install_fake_tui_binary();
5333 let _ambient_provider = ScopedEnvVar::set("CODEWHALE_PROVIDER", "openrouter");
5334
5335 let cases = [
5336 (
5337 parse_ok(&["codewhale", "--provider", "lm-studio", "exec", "Reply OK"]),
5338 vec!["exec".to_string(), "Reply OK".to_string()],
5339 ),
5340 (
5341 parse_ok(&["codewhale", "--provider", "lm-studio", "fleet", "status"]),
5342 vec!["fleet".to_string(), "status".to_string()],
5343 ),
5344 ];
5345
5346 for (cli, passthrough) in cases {
5347 let mut resolved =
5348 resolved_runtime_for_test(ProviderKind::Openrouter, ProviderSource::Config);
5349 resolved.api_key = Some("unrelated-keyring-secret".to_string());
5350 resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
5351
5352 let cmd = build_tui_command(&cli, &resolved, passthrough.clone())
5353 .expect("raw provider should dispatch to the TUI");
5354 assert_eq!(
5355 command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
5356 Some("lm-studio")
5357 );
5358 assert_eq!(
5359 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5360 Some("lm-studio")
5361 );
5362 for secret_var in [
5363 "CODEWHALE_CLI_API_KEY",
5364 "DEEPSEEK_API_KEY",
5365 "OPENROUTER_API_KEY",
5366 "DEEPSEEK_API_KEY_SOURCE",
5367 ] {
5368 assert_eq!(
5369 command_env(&cmd, secret_var),
5370 None,
5371 "raw provider dispatch must not bridge {secret_var}"
5372 );
5373 }
5374 assert_eq!(
5375 cmd.get_args()
5376 .map(|arg| arg.to_string_lossy().into_owned())
5377 .collect::<Vec<_>>(),
5378 passthrough
5379 );
5380 }
5381 }
5382
5383 #[test]
5384 fn build_tui_command_allows_openai_and_forwards_provider_key() {
5385 let _lock = env_lock();
5386 let dir = tempfile::TempDir::new().expect("tempdir");
5387 let custom = dir
5388 .path()
5389 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5390 std::fs::write(&custom, b"").unwrap();
5391 let custom_str = custom.to_string_lossy().into_owned();
5392 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5393
5394 let cli = parse_ok(&[
5395 "deepseek",
5396 "--provider",
5397 "openai",
5398 "--workspace",
5399 "/tmp/codewhale-workspace",
5400 ]);
5401 let resolved = ResolvedRuntimeOptions {
5402 provider: ProviderKind::Openai,
5403 provider_source: ProviderSource::Cli,
5404 model: "glm-5".to_string(),
5405 api_key: Some("resolved-openai-key".to_string()),
5406 api_key_source: Some(RuntimeApiKeySource::Keyring),
5407 base_url: "https://openai-compatible.example/v4".to_string(),
5408 auth_mode: Some("api_key".to_string()),
5409 insecure_skip_tls_verify: false,
5410 output_mode: None,
5411 log_level: None,
5412 telemetry: false,
5413 approval_policy: None,
5414 sandbox_mode: None,
5415 yolo: None,
5416 verbosity: None,
5417 http_headers: std::collections::BTreeMap::new(),
5418 };
5419
5420 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5421 assert_eq!(
5422 command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
5423 Some("openai")
5424 );
5425 assert_eq!(
5426 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5427 Some("openai")
5428 );
5429 assert_eq!(
5430 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5431 Some("resolved-openai-key")
5432 );
5433 assert_eq!(
5434 command_env(&cmd, "OPENAI_API_KEY").as_deref(),
5435 Some("resolved-openai-key")
5436 );
5437 assert_eq!(
5438 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
5439 Some("keyring")
5440 );
5441 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
5442 let args: Vec<String> = cmd
5443 .get_args()
5444 .map(|arg| arg.to_string_lossy().into_owned())
5445 .collect();
5446 assert!(
5447 args.windows(2)
5448 .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
5449 "expected workspace forwarding in args: {args:?}"
5450 );
5451 }
5452
5453 #[test]
5454 fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
5455 let _lock = env_lock();
5456 let dir = tempfile::TempDir::new().expect("tempdir");
5457 let custom = dir
5458 .path()
5459 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5460 std::fs::write(&custom, b"").unwrap();
5461 let custom_str = custom.to_string_lossy().into_owned();
5462 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5463
5464 let cli = parse_ok(&["codewhale", "doctor"]);
5465 let resolved = ResolvedRuntimeOptions {
5466 provider: ProviderKind::OpenaiCodex,
5467 provider_source: ProviderSource::Config,
5468 model: "gpt-5.5".to_string(),
5469 api_key: None,
5470 api_key_source: None,
5471 base_url: "https://chatgpt.com/backend-api".to_string(),
5472 auth_mode: Some("oauth".to_string()),
5473 insecure_skip_tls_verify: false,
5474 output_mode: None,
5475 log_level: None,
5476 telemetry: false,
5477 approval_policy: None,
5478 sandbox_mode: None,
5479 yolo: None,
5480 verbosity: None,
5481 http_headers: std::collections::BTreeMap::new(),
5482 };
5483
5484 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5485 .expect("openai-codex should be accepted by the facade");
5486 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
5487 let args: Vec<String> = cmd
5488 .get_args()
5489 .map(|arg| arg.to_string_lossy().into_owned())
5490 .collect();
5491 assert_eq!(args, vec!["doctor"]);
5492 }
5493
5494 #[test]
5495 fn build_tui_command_forwards_explicit_openai_codex_provider() {
5496 let _lock = env_lock();
5497 let dir = tempfile::TempDir::new().expect("tempdir");
5498 let custom = dir
5499 .path()
5500 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5501 std::fs::write(&custom, b"").unwrap();
5502 let custom_str = custom.to_string_lossy().into_owned();
5503 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5504
5505 let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
5506 let resolved = ResolvedRuntimeOptions {
5507 provider: ProviderKind::OpenaiCodex,
5508 provider_source: ProviderSource::Cli,
5509 model: "gpt-5.5".to_string(),
5510 api_key: None,
5511 api_key_source: None,
5512 base_url: "https://chatgpt.com/backend-api".to_string(),
5513 auth_mode: Some("oauth".to_string()),
5514 insecure_skip_tls_verify: false,
5515 output_mode: None,
5516 log_level: None,
5517 telemetry: false,
5518 approval_policy: None,
5519 sandbox_mode: None,
5520 yolo: None,
5521 verbosity: None,
5522 http_headers: std::collections::BTreeMap::new(),
5523 };
5524
5525 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5526 .expect("openai-codex should be accepted by the facade");
5527 assert_eq!(
5528 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5529 Some("openai-codex")
5530 );
5531 }
5532
5533 #[test]
5534 fn build_tui_command_allows_anthropic_cli_provider() {
5535 let _lock = env_lock();
5536 let (_dir, _bin) = install_fake_tui_binary();
5537
5538 let cli = parse_ok(&["codewhale", "--provider", "anthropic", "doctor"]);
5539 let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
5540
5541 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5542 .expect("anthropic should be accepted by the facade");
5543 assert_eq!(
5544 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5545 Some("anthropic")
5546 );
5547 }
5548
5549 #[test]
5550 fn build_tui_command_allows_anthropic_env_provider() {
5551 let _lock = env_lock();
5552 let (_dir, _bin) = install_fake_tui_binary();
5553
5554 let cli = parse_ok(&["codewhale", "doctor"]);
5555 let resolved = resolved_runtime_for_test(
5556 ProviderKind::Anthropic,
5557 ProviderSource::Env("DEEPSEEK_PROVIDER"),
5558 );
5559
5560 build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5561 .expect("anthropic from provider env should be accepted by the facade");
5562 }
5563
5564 #[test]
5565 fn build_tui_command_bridges_anthropic_keyring_secret() {
5566 let _lock = env_lock();
5567 let (_dir, _bin) = install_fake_tui_binary();
5568
5569 let cli = parse_ok(&["codewhale", "doctor"]);
5570 let mut resolved =
5571 resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
5572 resolved.api_key = Some("anthropic-keyring-secret".to_string());
5573 resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
5574
5575 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
5576 .expect("config-sourced anthropic provider should be accepted");
5577
5578 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
5579 assert_eq!(
5580 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5581 Some("anthropic-keyring-secret")
5582 );
5583 assert_eq!(
5584 command_env(&cmd, "ANTHROPIC_API_KEY").as_deref(),
5585 Some("anthropic-keyring-secret")
5586 );
5587 assert_eq!(
5588 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
5589 Some("keyring")
5590 );
5591 }
5592
5593 #[test]
5594 fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
5595 let _lock = env_lock();
5596 let dir = tempfile::TempDir::new().expect("tempdir");
5597 let custom = dir
5598 .path()
5599 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5600 std::fs::write(&custom, b"").unwrap();
5601 let custom_str = custom.to_string_lossy().into_owned();
5602 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5603
5604 let cli = parse_ok(&["deepseek", "--profile", "google"]);
5605 let mut resolved_headers = std::collections::BTreeMap::new();
5606 resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
5607 let resolved = ResolvedRuntimeOptions {
5608 provider: ProviderKind::Deepseek,
5609 provider_source: ProviderSource::Config,
5610 model: "deepseek-v4-pro".to_string(),
5611 api_key: Some("config-file-key".to_string()),
5612 api_key_source: Some(RuntimeApiKeySource::ConfigFile),
5613 base_url: "https://api.deepseek.com/beta".to_string(),
5614 auth_mode: Some("api_key".to_string()),
5615 insecure_skip_tls_verify: false,
5616 output_mode: None,
5617 log_level: None,
5618 telemetry: false,
5619 approval_policy: None,
5620 sandbox_mode: None,
5621 yolo: None,
5622 verbosity: Some("normal".to_string()),
5623 http_headers: resolved_headers,
5624 };
5625
5626 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5627
5628 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
5629 assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
5630 assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
5631 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
5632 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
5633 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
5634 assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
5635 assert_eq!(command_env(&cmd, "CODEWHALE_VERBOSITY"), None);
5636 assert_eq!(command_env(&cmd, "DEEPSEEK_VERBOSITY"), None);
5637 let args: Vec<String> = cmd
5638 .get_args()
5639 .map(|arg| arg.to_string_lossy().into_owned())
5640 .collect();
5641 assert!(
5642 args.windows(2).any(|pair| pair == ["--profile", "google"]),
5643 "expected profile forwarding in args: {args:?}"
5644 );
5645 }
5646
5647 #[test]
5648 fn build_tui_command_defaults_noninteractive_to_concise_verbosity() {
5649 let _lock = env_lock();
5650 let (_dir, _bin) = install_fake_tui_binary();
5651
5652 let cli = parse_ok(&["codewhale"]);
5653 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
5654
5655 let cmd = build_tui_command(
5656 &cli,
5657 &resolved,
5658 vec!["exec".to_string(), "summarize".to_string()],
5659 )
5660 .expect("command");
5661
5662 assert_eq!(
5663 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
5664 Some("concise")
5665 );
5666 assert_eq!(
5667 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
5668 Some("concise")
5669 );
5670 }
5671
5672 #[test]
5673 fn build_tui_command_respects_resolved_verbosity_override() {
5674 let _lock = env_lock();
5675 let (_dir, _bin) = install_fake_tui_binary();
5676
5677 let cli = parse_ok(&["codewhale"]);
5678 let mut resolved =
5679 resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
5680 resolved.verbosity = Some("normal".to_string());
5681
5682 let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string()]).expect("command");
5683
5684 assert_eq!(
5685 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
5686 Some("normal")
5687 );
5688 assert_eq!(
5689 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
5690 Some("normal")
5691 );
5692 }
5693
5694 #[test]
5695 fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
5696 let _lock = env_lock();
5697 let dir = tempfile::TempDir::new().expect("tempdir");
5698 let custom = dir
5699 .path()
5700 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5701 std::fs::write(&custom, b"").unwrap();
5702 let custom_str = custom.to_string_lossy().into_owned();
5703 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5704
5705 let cli = parse_ok(&[
5706 "codewhale",
5707 "--provider",
5708 "moonshot",
5709 "--model",
5710 "kimi-k2.7-code",
5711 "--workspace",
5712 "/tmp/codewhale-workspace",
5713 ]);
5714 let resolved = ResolvedRuntimeOptions {
5715 provider: ProviderKind::Moonshot,
5716 provider_source: ProviderSource::Cli,
5717 model: "kimi-k2.7-code".to_string(),
5718 api_key: Some("resolved-kimi-key".to_string()),
5719 api_key_source: Some(RuntimeApiKeySource::Keyring),
5720 base_url: "https://api.moonshot.ai/v1".to_string(),
5721 auth_mode: Some("api_key".to_string()),
5722 insecure_skip_tls_verify: false,
5723 output_mode: None,
5724 log_level: None,
5725 telemetry: false,
5726 approval_policy: None,
5727 sandbox_mode: None,
5728 yolo: None,
5729 verbosity: None,
5730 http_headers: std::collections::BTreeMap::new(),
5731 };
5732
5733 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5734 assert_eq!(
5735 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5736 Some("moonshot")
5737 );
5738 assert_eq!(
5739 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
5740 Some("kimi-k2.7-code")
5741 );
5742 assert_eq!(
5743 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5744 Some("resolved-kimi-key")
5745 );
5746 assert_eq!(
5747 command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
5748 Some("resolved-kimi-key")
5749 );
5750 assert_eq!(
5751 command_env(&cmd, "KIMI_API_KEY").as_deref(),
5752 Some("resolved-kimi-key")
5753 );
5754 assert_eq!(
5755 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
5756 Some("keyring")
5757 );
5758 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
5759 }
5760
5761 #[test]
5762 fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
5763 let _lock = env_lock();
5764 let dir = tempfile::TempDir::new().expect("tempdir");
5765 let custom = dir
5766 .path()
5767 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5768 std::fs::write(&custom, b"").unwrap();
5769 let custom_str = custom.to_string_lossy().into_owned();
5770 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5771
5772 let cli = parse_ok(&[
5773 "codewhale",
5774 "--provider",
5775 "volcengine",
5776 "--model",
5777 "DeepSeek-V4-Pro",
5778 "--workspace",
5779 "/tmp/codewhale-workspace",
5780 ]);
5781 let resolved = ResolvedRuntimeOptions {
5782 provider: ProviderKind::Volcengine,
5783 provider_source: ProviderSource::Cli,
5784 model: "DeepSeek-V4-Pro".to_string(),
5785 api_key: Some("resolved-ark-key".to_string()),
5786 api_key_source: Some(RuntimeApiKeySource::Keyring),
5787 base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
5788 auth_mode: Some("api_key".to_string()),
5789 insecure_skip_tls_verify: false,
5790 output_mode: None,
5791 log_level: None,
5792 telemetry: false,
5793 approval_policy: None,
5794 sandbox_mode: None,
5795 yolo: None,
5796 verbosity: None,
5797 http_headers: std::collections::BTreeMap::new(),
5798 };
5799
5800 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5801 assert_eq!(
5802 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5803 Some("volcengine")
5804 );
5805 assert_eq!(
5806 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
5807 Some("DeepSeek-V4-Pro")
5808 );
5809 assert_eq!(
5810 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5811 Some("resolved-ark-key")
5812 );
5813 assert_eq!(
5814 command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
5815 Some("resolved-ark-key")
5816 );
5817 assert_eq!(
5818 command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
5819 Some("resolved-ark-key")
5820 );
5821 assert_eq!(
5822 command_env(&cmd, "ARK_API_KEY").as_deref(),
5823 Some("resolved-ark-key")
5824 );
5825 }
5826
5827 #[test]
5828 fn build_tui_command_exports_explicit_provider_model_and_base_url() {
5829 let _lock = env_lock();
5830 let dir = tempfile::TempDir::new().expect("tempdir");
5831 let custom = dir
5832 .path()
5833 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5834 std::fs::write(&custom, b"").unwrap();
5835 let custom_str = custom.to_string_lossy().into_owned();
5836 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5837
5838 let cli = parse_ok(&[
5839 "deepseek",
5840 "--profile",
5841 "google",
5842 "--provider",
5843 "openai",
5844 "--model",
5845 "glm-5",
5846 "--base-url",
5847 "https://openai-compatible.example/v4",
5848 ]);
5849 let resolved = ResolvedRuntimeOptions {
5850 provider: ProviderKind::Openai,
5851 provider_source: ProviderSource::Cli,
5852 model: "glm-5".to_string(),
5853 api_key: None,
5854 api_key_source: None,
5855 base_url: "https://openai-compatible.example/v4".to_string(),
5856 auth_mode: None,
5857 insecure_skip_tls_verify: false,
5858 output_mode: None,
5859 log_level: None,
5860 telemetry: false,
5861 approval_policy: None,
5862 sandbox_mode: None,
5863 yolo: None,
5864 verbosity: None,
5865 http_headers: std::collections::BTreeMap::new(),
5866 };
5867
5868 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
5869
5870 assert_eq!(
5871 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
5872 Some("openai")
5873 );
5874 assert_eq!(
5875 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
5876 Some("glm-5")
5877 );
5878 assert_eq!(
5879 command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
5880 Some("https://openai-compatible.example/v4")
5881 );
5882 }
5883
5884 #[test]
5885 fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
5886 let _lock = env_lock();
5887 let dir = tempfile::TempDir::new().expect("tempdir");
5888 let custom = dir
5889 .path()
5890 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5891 std::fs::write(&custom, b"").unwrap();
5892 let custom_str = custom.to_string_lossy().into_owned();
5893 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5894
5895 for provider in ProviderKind::ALL {
5896 let cli = parse_ok(&["codewhale", "--workspace", "/tmp/codewhale-workspace"]);
5897 let resolved = ResolvedRuntimeOptions {
5898 provider,
5899 provider_source: ProviderSource::Config,
5900 model: "test-model".to_string(),
5901 api_key: Some("test-key".to_string()),
5902 api_key_source: Some(RuntimeApiKeySource::Keyring),
5903 base_url: "http://localhost:8000/v1".to_string(),
5904 auth_mode: Some("api_key".to_string()),
5905 insecure_skip_tls_verify: false,
5906 output_mode: None,
5907 log_level: None,
5908 telemetry: false,
5909 approval_policy: None,
5910 sandbox_mode: None,
5911 yolo: None,
5912 verbosity: None,
5913 http_headers: std::collections::BTreeMap::new(),
5914 };
5915
5916 let cmd = build_tui_command(&cli, &resolved, Vec::new())
5917 .unwrap_or_else(|e| panic!("{}: {e}", provider.as_str()));
5918
5919 assert_eq!(
5920 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
5921 Some("test-key"),
5922 "{}: DEEPSEEK_API_KEY not forwarded",
5923 provider.as_str()
5924 );
5925 for var in provider_env_vars(provider)
5926 .iter()
5927 .filter(|var| **var != "DEEPSEEK_API_KEY")
5928 {
5929 assert_eq!(
5930 command_env(&cmd, var).as_deref(),
5931 Some("test-key"),
5932 "{}: {var} not forwarded",
5933 provider.as_str()
5934 );
5935 }
5936 assert_eq!(
5937 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
5938 Some("keyring"),
5939 "{}: expected keyring source bridge",
5940 provider.as_str()
5941 );
5942 assert_eq!(
5943 command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
5944 None,
5945 "{}: auth mode should come from config/profile, not env handoff",
5946 provider.as_str()
5947 );
5948 }
5949 }
5950
5951 #[test]
5952 fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
5953 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
5954
5955 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
5956 assert!(cli.prompt.is_empty());
5957 assert_eq!(
5958 root_tui_passthrough(&cli).unwrap(),
5959 vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
5960 );
5961 }
5962
5963 #[test]
5964 fn parses_top_level_continue_for_interactive_resume() {
5965 let cli = parse_ok(&["codewhale", "--continue"]);
5966
5967 assert!(cli.continue_session);
5968 assert!(cli.prompt_flag.is_none());
5969 assert!(cli.prompt.is_empty());
5970 assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
5971 }
5972
5973 #[test]
5974 fn top_level_continue_rejects_startup_prompt() {
5975 let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
5976
5977 let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
5978 assert!(
5979 err.to_string()
5980 .contains("codewhale exec --continue <PROMPT>")
5981 );
5982 }
5983
5984 #[test]
5985 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
5986 let cli = parse_ok(&["deepseek", "hello", "world"]);
5987
5988 assert_eq!(cli.prompt, vec!["hello", "world"]);
5989 assert!(cli.command.is_none());
5990 assert_eq!(
5991 root_tui_passthrough(&cli).unwrap(),
5992 vec!["--prompt".to_string(), "hello world".to_string()]
5993 );
5994 }
5995
5996 #[test]
5997 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
5998 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
5999
6000 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
6001 assert_eq!(cli.prompt, vec!["world"]);
6002 assert_eq!(
6003 root_tui_passthrough(&cli).unwrap(),
6004 vec!["--prompt".to_string(), "hello world".to_string()]
6005 );
6006 }
6007
6008 #[test]
6009 fn known_subcommands_still_parse_before_prompt_tail() {
6010 let cli = parse_ok(&["deepseek", "doctor"]);
6011
6012 assert!(cli.prompt.is_empty());
6013 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
6014 }
6015
6016 #[test]
6017 fn root_help_surface_contains_expected_subcommands_and_globals() {
6018 let rendered = help_for(&["deepseek", "--help"]);
6019
6020 for token in [
6021 "run",
6022 "doctor",
6023 "models",
6024 "sessions",
6025 "resume",
6026 "setup",
6027 "login",
6028 "logout",
6029 "auth",
6030 "mcp-server",
6031 "config",
6032 "model",
6033 "thread",
6034 "sandbox",
6035 "app-server",
6036 "completion",
6037 "metrics",
6038 "--provider",
6039 "--model",
6040 "--config",
6041 "--profile",
6042 "--output-mode",
6043 "--log-level",
6044 "--telemetry",
6045 "--base-url",
6046 "--api-key",
6047 "--approval-policy",
6048 "--sandbox-mode",
6049 "--mouse-capture",
6050 "--no-mouse-capture",
6051 "--skip-onboarding",
6052 "--continue",
6053 "--prompt",
6054 ] {
6055 assert!(
6056 rendered.contains(token),
6057 "expected help to contain token: {token}"
6058 );
6059 }
6060 }
6061
6062 #[test]
6063 fn subcommand_help_surfaces_are_stable() {
6064 let cases = [
6065 ("config", vec!["get", "set", "unset", "list", "path"]),
6066 ("model", vec!["list", "resolve"]),
6067 (
6068 "thread",
6069 vec![
6070 "list",
6071 "read",
6072 "resume",
6073 "fork",
6074 "archive",
6075 "unarchive",
6076 "set-name",
6077 "clear-name",
6078 ],
6079 ),
6080 ("sandbox", vec!["check"]),
6081 (
6082 "exec",
6083 vec![
6084 "--auto",
6085 "--json",
6086 "--resume",
6087 "--session-id",
6088 "--continue",
6089 "--output-format",
6090 "stream-json",
6091 ],
6092 ),
6093 (
6094 "app-server",
6095 vec!["--host", "--port", "--config", "--stdio"],
6096 ),
6097 (
6098 "completion",
6099 vec![
6100 "<SHELL>",
6101 "bash",
6102 "source <(codewhale completion bash)",
6103 "~/.local/share/bash-completion/completions/codewhale",
6104 "fpath=(~/.zfunc $fpath)",
6105 "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
6106 "codewhale completion powershell | Out-String | Invoke-Expression",
6107 ],
6108 ),
6109 ("metrics", vec!["--json", "--since"]),
6110 ];
6111
6112 for (subcommand, expected_tokens) in cases {
6113 let argv = ["deepseek", subcommand, "--help"];
6114 let rendered = help_for(&argv);
6115 for token in expected_tokens {
6116 assert!(
6117 rendered.contains(token),
6118 "expected help for `{subcommand}` to include `{token}`"
6119 );
6120 }
6121 }
6122 }
6123
6124 #[test]
6130 fn sibling_tui_candidate_picks_platform_correct_name() {
6131 let dir = tempfile::TempDir::new().expect("tempdir");
6132 let dispatcher = dir
6133 .path()
6134 .join("codewhale")
6135 .with_extension(std::env::consts::EXE_EXTENSION);
6136 std::fs::write(&dispatcher, b"").unwrap();
6138
6139 assert!(sibling_tui_candidate(&dispatcher).is_none());
6141
6142 let target =
6143 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
6144 std::fs::write(&target, b"").unwrap();
6145
6146 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
6147 assert_eq!(found, target, "primary platform-correct name wins");
6148 }
6149
6150 #[test]
6151 fn dispatcher_spawn_error_names_path_and_recovery_checks() {
6152 let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
6153 let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
6154
6155 assert!(message.contains("C:/tools/codewhale-tui.exe"));
6156 assert!(message.contains("access is denied"));
6157 assert!(message.contains("where codewhale"));
6158 assert!(message.contains("DEEPSEEK_TUI_BIN"));
6159 }
6160
6161 #[cfg(unix)]
6162 #[test]
6163 fn tui_child_exit_code_maps_unix_signal_to_shell_status() {
6164 use std::os::unix::process::ExitStatusExt;
6165
6166 let status = std::process::ExitStatus::from_raw(libc::SIGPIPE);
6167
6168 assert_eq!(tui_child_exit_code(status), Some(141));
6169 }
6170
6171 #[cfg(windows)]
6176 #[test]
6177 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
6178 let dir = tempfile::TempDir::new().expect("tempdir");
6179 let dispatcher = dir.path().join("codewhale.exe");
6180 std::fs::write(&dispatcher, b"").unwrap();
6181
6182 let suffixless = dispatcher.with_file_name("codewhale-tui");
6184 std::fs::write(&suffixless, b"").unwrap();
6185
6186 let found = sibling_tui_candidate(&dispatcher)
6187 .expect("Windows fallback must locate suffixless codewhale-tui");
6188 assert_eq!(found, suffixless);
6189 }
6190
6191 #[test]
6194 fn locate_sibling_tui_binary_honours_env_override() {
6195 let _lock = env_lock();
6196 let dir = tempfile::TempDir::new().expect("tempdir");
6197 let custom = dir
6198 .path()
6199 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
6200 std::fs::write(&custom, b"").unwrap();
6201 let custom_str = custom.to_string_lossy().into_owned();
6202 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
6203
6204 let resolved = locate_sibling_tui_binary().expect("override must resolve");
6205 assert_eq!(resolved, custom);
6206 }
6207}