1use clap::Args;
23
24use crate::claude::backend::AiBackend;
25
26#[derive(Args, Debug, Clone, Default)]
29pub struct ModelsYamlArg {
30 #[arg(long, value_name = "PATH")]
35 pub models_yaml: Option<std::path::PathBuf>,
36}
37
38impl ModelsYamlArg {
39 pub fn apply(&self) {
42 if let Some(path) = &self.models_yaml {
43 std::env::set_var("OMNI_DEV_MODELS_YAML", path);
44 }
45 }
46}
47
48#[derive(Args, Debug, Clone, Default)]
52#[command(next_help_heading = "AI backend")]
53pub struct AiBackendArgs {
54 #[arg(long, value_enum)]
60 pub ai_backend: Option<AiBackend>,
61
62 #[arg(long, value_name = "MODEL")]
69 pub model: Option<String>,
70
71 #[arg(long, value_name = "KEY:VALUE")]
77 pub beta_header: Option<String>,
78
79 #[arg(long)]
94 pub claude_cli_allow_tools: bool,
95
96 #[arg(long)]
108 pub claude_cli_allow_mcp: bool,
109
110 #[arg(long, value_name = "AMOUNT")]
118 pub claude_cli_max_budget_usd: Option<f64>,
119
120 #[command(flatten)]
122 pub models_yaml: ModelsYamlArg,
123}
124
125impl AiBackendArgs {
126 pub fn apply(&self) {
131 if let Some(backend) = self.ai_backend {
135 std::env::set_var(crate::claude::backend::AI_BACKEND_ENV, backend.env_value());
136 }
137
138 if let Some(model) = &self.model {
139 std::env::set_var(crate::claude::backend::MODEL_ENV, model);
140 }
141
142 if let Some(beta_header) = &self.beta_header {
143 std::env::set_var(crate::claude::backend::BETA_HEADER_ENV, beta_header);
144 }
145
146 if self.claude_cli_allow_tools {
150 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
151 crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS");
152 }
153
154 if self.claude_cli_allow_mcp {
155 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
156 crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP");
157 }
158
159 if let Some(budget) = self.claude_cli_max_budget_usd {
160 std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
161 }
162
163 self.models_yaml.apply();
164 }
165}
166
167#[cfg(test)]
168#[allow(clippy::unwrap_used, clippy::expect_used)]
169mod tests {
170 use super::*;
171 use crate::cli::Cli;
172 use clap::Parser;
173
174 const TWIDDLE: [&str; 5] = ["omni-dev", "git", "commit", "message", "twiddle"];
177
178 fn parse_twiddle(extra: &[&str]) -> AiBackendArgs {
179 let argv: Vec<&str> = TWIDDLE.iter().chain(extra).copied().collect();
180 let cli = Cli::try_parse_from(argv).unwrap();
181 let crate::cli::Commands::Git(git) = cli.command else {
182 panic!("expected a git command");
183 };
184 let crate::cli::git::GitSubcommands::Commit(commit) = git.command else {
185 panic!("expected git commit");
186 };
187 let crate::cli::git::CommitSubcommands::Message(message) = commit.command;
188 let crate::cli::git::MessageSubcommands::Twiddle(twiddle) = message.command else {
189 panic!("expected twiddle");
190 };
191 twiddle.ai
192 }
193
194 #[test]
195 fn parses_every_flag_after_the_leaf() {
196 let ai = parse_twiddle(&[
197 "--ai-backend",
198 "claude-cli",
199 "--model",
200 "claude-opus-4-6",
201 "--beta-header",
202 "anthropic-beta:output-128k-2025-02-19",
203 "--claude-cli-allow-tools",
204 "--claude-cli-allow-mcp",
205 "--claude-cli-max-budget-usd",
206 "0.50",
207 "--models-yaml",
208 "/tmp/custom-models.yaml",
209 ]);
210 assert_eq!(ai.ai_backend, Some(AiBackend::ClaudeCli));
211 assert_eq!(ai.model.as_deref(), Some("claude-opus-4-6"));
212 assert_eq!(
213 ai.beta_header.as_deref(),
214 Some("anthropic-beta:output-128k-2025-02-19")
215 );
216 assert!(ai.claude_cli_allow_tools);
217 assert!(ai.claude_cli_allow_mcp);
218 assert_eq!(ai.claude_cli_max_budget_usd, Some(0.50));
219 assert_eq!(
220 ai.models_yaml.models_yaml.as_deref(),
221 Some(std::path::Path::new("/tmp/custom-models.yaml"))
222 );
223 }
224
225 #[test]
226 fn absent_flags_are_default() {
227 let ai = parse_twiddle(&[]);
228 assert!(ai.ai_backend.is_none());
229 assert!(ai.model.is_none());
230 assert!(ai.beta_header.is_none());
231 assert!(!ai.claude_cli_allow_tools);
232 assert!(!ai.claude_cli_allow_mcp);
233 assert!(ai.claude_cli_max_budget_usd.is_none());
234 assert!(ai.models_yaml.models_yaml.is_none());
235 }
236
237 #[test]
238 fn parses_each_ai_backend_value() {
239 for (value, expected) in [
240 ("default", AiBackend::Default),
241 ("claude-cli", AiBackend::ClaudeCli),
242 ("openai", AiBackend::OpenAi),
243 ("ollama", AiBackend::Ollama),
244 ("bedrock", AiBackend::Bedrock),
245 ] {
246 let ai = parse_twiddle(&["--ai-backend", value]);
247 assert_eq!(ai.ai_backend, Some(expected), "value {value}");
248 }
249 }
250
251 #[test]
252 fn max_budget_usd_rejects_non_numeric() {
253 let argv: Vec<&str> = TWIDDLE
254 .iter()
255 .chain(&["--claude-cli-max-budget-usd", "cheap"])
256 .copied()
257 .collect();
258 let Err(err) = Cli::try_parse_from(argv) else {
259 panic!("expected parse error for non-numeric budget");
260 };
261 assert!(err.to_string().contains("invalid"));
262 }
263
264 #[test]
266 fn every_ai_command_accepts_the_flags() {
267 for leaf in [
268 &["git", "commit", "message", "twiddle"][..],
269 &["git", "commit", "message", "check"],
270 &["git", "commit", "message", "staged"],
271 &["git", "branch", "create", "pr"],
272 &["ai", "chat"],
273 &["ai", "jev", "verify-decision", "#1"],
274 ] {
275 let argv: Vec<&str> = std::iter::once("omni-dev")
276 .chain(leaf.iter().copied())
277 .chain(["--ai-backend", "claude-cli", "--model", "m"])
278 .collect();
279 assert!(
280 Cli::try_parse_from(&argv).is_ok(),
281 "`{}` should accept the AI backend flags",
282 leaf.join(" ")
283 );
284 }
285 }
286
287 #[test]
290 fn flags_before_the_subcommand_are_rejected() {
291 for flag in [
292 &["--model", "claude-opus-4-6"][..],
293 &["--ai-backend", "claude-cli"],
294 &["--beta-header", "k:v"],
295 &["--claude-cli-allow-tools"],
296 &["--models-yaml", "/tmp/m.yaml"],
297 ] {
298 let argv: Vec<&str> = std::iter::once("omni-dev")
299 .chain(flag.iter().copied())
300 .chain(TWIDDLE[1..].iter().copied())
301 .collect();
302 assert!(
303 Cli::try_parse_from(&argv).is_err(),
304 "`{}` before the subcommand must be rejected",
305 flag[0]
306 );
307 }
308 }
309
310 #[test]
313 fn jev_rejects_ai_backend_flags() {
314 for flag in [
315 &["--claude-cli-allow-tools"][..],
316 &["--claude-cli-allow-mcp"],
317 &["--claude-cli-max-budget-usd", "1"],
318 &["--ai-backend", "claude-cli"],
319 &["--model", "claude-opus-4-6"],
320 &["--beta-header", "k:v"],
321 &["--models-yaml", "/tmp/m.yaml"],
322 ] {
323 let argv: Vec<&str> = [
324 "omni-dev",
325 "ai",
326 "jev",
327 "noul",
328 "--instructions",
329 "i",
330 "--true-means",
331 "t",
332 "--false-means",
333 "f",
334 ]
335 .into_iter()
336 .chain(flag.iter().copied())
337 .collect();
338 let Err(err) = Cli::try_parse_from(&argv) else {
339 panic!("`ai jev noul {}` must be rejected", flag[0]);
340 };
341 assert_eq!(
342 err.kind(),
343 clap::error::ErrorKind::UnknownArgument,
344 "`{}`: {err}",
345 flag[0]
346 );
347 }
348 }
349
350 #[test]
353 fn config_models_show_takes_only_models_yaml() {
354 assert!(Cli::try_parse_from([
355 "omni-dev",
356 "config",
357 "models",
358 "show",
359 "--models-yaml",
360 "/tmp/m.yaml",
361 ])
362 .is_ok());
363 assert!(Cli::try_parse_from([
364 "omni-dev",
365 "config",
366 "models",
367 "show",
368 "--ai-backend",
369 "claude-cli",
370 ])
371 .is_err());
372 }
373
374 #[test]
380 fn ai_client_builders_are_exactly_the_ai_backend_args_owners() {
381 use std::collections::BTreeSet;
382 use std::path::{Path, PathBuf};
383
384 fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
385 for entry in std::fs::read_dir(dir).unwrap() {
386 let path = entry.unwrap().path();
387 if path.is_dir() {
388 rust_files(&path, out);
389 } else if path.extension().is_some_and(|e| e == "rs") {
390 out.push(path);
391 }
392 }
393 }
394
395 let cli_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/cli");
396 let mut files = Vec::new();
397 rust_files(&cli_dir, &mut files);
398
399 let mut builders = BTreeSet::new();
400 let mut owners = BTreeSet::new();
401 for path in files {
402 let rel = path
403 .strip_prefix(&cli_dir)
404 .unwrap()
405 .to_string_lossy()
406 .into_owned();
407 if rel == "ai_backend_args.rs" {
408 continue;
409 }
410 let source = std::fs::read_to_string(&path).unwrap();
411 let code = source
412 .lines()
413 .map(str::trim_start)
414 .filter(|l| !l.starts_with("//"));
415 for line in code {
416 if line.contains("create_default_claude_client(") {
417 builders.insert(rel.clone());
418 }
419 if line.starts_with("pub ") && line.ends_with("AiBackendArgs,") {
422 owners.insert(rel.clone());
423 }
424 }
425 }
426
427 assert!(!builders.is_empty(), "found no AI client builders");
428 assert_eq!(
429 builders, owners,
430 "files calling `create_default_claude_client(` must be exactly the \
431 files that flatten `AiBackendArgs` (placement rule, #1778)"
432 );
433 }
434
435 const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
442 const MODEL_VAR: &str = "OMNI_DEV_MODEL";
443 const BETA_HEADER_VAR: &str = "OMNI_DEV_BETA_HEADER";
444 const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
445 const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
446 const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
447 const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
448
449 struct AiEnvGuard {
452 _lock: std::sync::MutexGuard<'static, ()>,
453 saved: [(&'static str, Option<String>); 7],
454 }
455
456 impl AiEnvGuard {
457 fn new() -> Self {
458 let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
459 .lock()
460 .unwrap_or_else(std::sync::PoisonError::into_inner);
461 let names = [
462 BACKEND_VAR,
463 MODEL_VAR,
464 BETA_HEADER_VAR,
465 ALLOW_TOOLS_VAR,
466 ALLOW_MCP_VAR,
467 MAX_BUDGET_VAR,
468 MODELS_YAML_VAR,
469 ];
470 let saved = names.map(|n| (n, std::env::var(n).ok()));
471 for (n, _) in &saved {
472 std::env::remove_var(n);
473 }
474 Self { _lock: lock, saved }
475 }
476 }
477
478 impl Drop for AiEnvGuard {
479 fn drop(&mut self) {
480 for (n, value) in &self.saved {
481 match value {
482 Some(v) => std::env::set_var(n, v),
483 None => std::env::remove_var(n),
484 }
485 }
486 }
487 }
488
489 #[test]
490 fn apply_defaults_set_nothing() {
491 let _g = AiEnvGuard::new();
492 AiBackendArgs::default().apply();
493 for var in [
494 BACKEND_VAR,
495 MODEL_VAR,
496 BETA_HEADER_VAR,
497 ALLOW_TOOLS_VAR,
498 ALLOW_MCP_VAR,
499 MAX_BUDGET_VAR,
500 MODELS_YAML_VAR,
501 ] {
502 assert!(std::env::var(var).is_err(), "{var} should be unset");
503 }
504 }
505
506 #[test]
507 fn apply_sets_ai_backend() {
508 let _g = AiEnvGuard::new();
509 for (backend, expected) in [
510 (AiBackend::ClaudeCli, "claude-cli"),
511 (AiBackend::OpenAi, "openai"),
512 (AiBackend::Ollama, "ollama"),
513 (AiBackend::Bedrock, "bedrock"),
514 ] {
515 AiBackendArgs {
516 ai_backend: Some(backend),
517 ..Default::default()
518 }
519 .apply();
520 assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some(expected));
521 }
522 }
523
524 #[test]
525 fn apply_default_backend_overrides_env_var() {
526 let _g = AiEnvGuard::new();
530 std::env::set_var(BACKEND_VAR, "claude-cli");
531 AiBackendArgs {
532 ai_backend: Some(AiBackend::Default),
533 ..Default::default()
534 }
535 .apply();
536 assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some("default"));
537 }
538
539 #[test]
540 fn apply_sets_model() {
541 let _g = AiEnvGuard::new();
542 AiBackendArgs {
543 model: Some("claude-opus-4-6".to_string()),
544 ..Default::default()
545 }
546 .apply();
547 assert_eq!(
548 std::env::var(MODEL_VAR).ok().as_deref(),
549 Some("claude-opus-4-6")
550 );
551 }
552
553 #[test]
554 fn apply_sets_beta_header() {
555 let _g = AiEnvGuard::new();
556 AiBackendArgs {
557 beta_header: Some("anthropic-beta:output-128k-2025-02-19".to_string()),
558 ..Default::default()
559 }
560 .apply();
561 assert_eq!(
562 std::env::var(BETA_HEADER_VAR).ok().as_deref(),
563 Some("anthropic-beta:output-128k-2025-02-19")
564 );
565 }
566
567 #[test]
568 fn apply_sets_allow_tools() {
569 let _g = AiEnvGuard::new();
570 AiBackendArgs {
571 claude_cli_allow_tools: true,
572 ..Default::default()
573 }
574 .apply();
575 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
576 assert!(crate::utils::settings::exported_by_cli_flag(
579 ALLOW_TOOLS_VAR
580 ));
581 }
582
583 #[test]
584 fn apply_sets_allow_mcp() {
585 let _g = AiEnvGuard::new();
586 AiBackendArgs {
587 claude_cli_allow_mcp: true,
588 ..Default::default()
589 }
590 .apply();
591 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
592 assert!(crate::utils::settings::exported_by_cli_flag(ALLOW_MCP_VAR));
593 }
594
595 #[test]
596 fn apply_sets_max_budget_usd() {
597 let _g = AiEnvGuard::new();
598 AiBackendArgs {
599 claude_cli_max_budget_usd: Some(1.5),
600 ..Default::default()
601 }
602 .apply();
603 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
604 }
605
606 #[test]
607 fn apply_sets_models_yaml() {
608 let _g = AiEnvGuard::new();
609 AiBackendArgs {
610 models_yaml: ModelsYamlArg {
611 models_yaml: Some(std::path::PathBuf::from("/tmp/custom-models.yaml")),
612 },
613 ..Default::default()
614 }
615 .apply();
616 assert_eq!(
617 std::env::var(MODELS_YAML_VAR).ok().as_deref(),
618 Some("/tmp/custom-models.yaml")
619 );
620 }
621
622 #[test]
623 fn apply_independent_flags_compose() {
624 let _g = AiEnvGuard::new();
625 AiBackendArgs {
626 ai_backend: Some(AiBackend::ClaudeCli),
627 claude_cli_allow_tools: true,
628 claude_cli_allow_mcp: true,
629 claude_cli_max_budget_usd: Some(0.25),
630 ..Default::default()
631 }
632 .apply();
633 assert_eq!(
634 std::env::var(BACKEND_VAR).ok().as_deref(),
635 Some("claude-cli")
636 );
637 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
638 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
639 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
640 }
641}