1use anyhow::Result;
4use clap::{Parser, Subcommand};
5
6pub mod ai;
7pub mod atlassian;
8pub mod browser;
9pub mod commands;
10pub mod completions;
11pub mod config;
12pub(crate) mod confirm;
13pub mod coverage;
14#[cfg(unix)]
18pub mod claude_wrap;
19#[cfg(unix)]
20pub mod daemon;
21pub mod datadog;
22pub mod drive;
23pub mod format;
24pub mod git;
25pub mod gmail;
26pub mod help;
27pub mod log;
28pub mod resources;
29#[cfg(unix)]
30pub mod sessions;
31#[cfg(unix)]
32pub mod snowflake;
33pub mod transcript;
34#[cfg(unix)]
35pub mod worktrees;
36
37pub use crate::claude::backend::AiBackend;
40
41#[derive(Parser)]
50#[command(name = "omni-dev")]
51#[command(
52 about = "AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.",
53 long_about = None
54)]
55#[command(version = crate::VERSION, long_version = crate::build_info::long_version())]
58pub struct Cli {
59 #[arg(long, global = true, value_enum)]
65 pub ai_backend: Option<AiBackend>,
66
67 #[arg(long, global = true, value_name = "MODEL")]
74 pub model: Option<String>,
75
76 #[arg(long, global = true, value_name = "KEY:VALUE")]
82 pub beta_header: Option<String>,
83
84 #[arg(long, global = true)]
99 pub claude_cli_allow_tools: bool,
100
101 #[arg(long, global = true)]
113 pub claude_cli_allow_mcp: bool,
114
115 #[arg(long, global = true, value_name = "AMOUNT")]
123 pub claude_cli_max_budget_usd: Option<f64>,
124
125 #[arg(long, global = true, value_name = "PATH")]
130 pub models_yaml: Option<std::path::PathBuf>,
131
132 #[arg(long, global = true, value_name = "NAME")]
140 pub profile: Option<String>,
141
142 #[arg(long, global = true, value_name = "URL")]
151 pub instance: Option<String>,
152
153 #[arg(long = "repo", short = 'C', global = true, value_name = "PATH")]
161 pub repo: Option<std::path::PathBuf>,
162
163 #[command(subcommand)]
165 pub command: Commands,
166}
167
168#[derive(Subcommand)]
174pub enum Commands {
175 Ai(ai::AiCommand),
177 Git(git::GitCommand),
179 Commands(commands::CommandsCommand),
181 Config(config::ConfigCommand),
183 Atlassian(atlassian::AtlassianCommand),
185 Browser(browser::BrowserCommand),
187 #[cfg(unix)]
189 Daemon(daemon::DaemonCommand),
190 Datadog(datadog::DatadogCommand),
192 Drive(drive::DriveCommand),
194 Gmail(gmail::GmailCommand),
196 #[cfg(unix)]
198 Snowflake(snowflake::SnowflakeCommand),
199 #[cfg(unix)]
201 Worktrees(worktrees::WorktreesCommand),
202 #[cfg(unix)]
204 Sessions(sessions::SessionsCommand),
205 #[cfg(unix)]
207 #[command(name = "claude-wrap")]
208 ClaudeWrap(claude_wrap::ClaudeWrapCommand),
209 Coverage(coverage::CoverageCommand),
211 Transcript(transcript::TranscriptCommand),
213 Log(log::LogCommand),
215 Resources(resources::ResourcesCommand),
217 #[command(hide = true)]
219 Completions(completions::CompletionsCommand),
220 #[command(name = "help-all")]
222 HelpAll(help::HelpCommand),
223}
224
225impl Cli {
226 fn propagate_global_flags(&self) {
231 if let Some(backend) = self.ai_backend {
235 std::env::set_var(crate::claude::backend::AI_BACKEND_ENV, backend.env_value());
236 }
237
238 if let Some(model) = &self.model {
239 std::env::set_var(crate::claude::backend::MODEL_ENV, model);
240 }
241
242 if let Some(beta_header) = &self.beta_header {
243 std::env::set_var(crate::claude::backend::BETA_HEADER_ENV, beta_header);
244 }
245
246 if self.claude_cli_allow_tools {
250 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
251 crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS");
252 }
253
254 if self.claude_cli_allow_mcp {
255 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
256 crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP");
257 }
258
259 if let Some(budget) = self.claude_cli_max_budget_usd {
260 std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
261 }
262
263 if let Some(path) = &self.models_yaml {
264 std::env::set_var("OMNI_DEV_MODELS_YAML", path);
265 }
266
267 if let Some(profile) = &self.profile {
272 std::env::set_var(crate::utils::settings::PROFILE_ENV_VAR, profile);
273 }
274
275 if let Some(instance) = &self.instance {
280 std::env::set_var(
281 crate::atlassian::auth::ATLASSIAN_INSTANCE_OVERRIDE_ENV,
282 instance,
283 );
284 }
285 }
286
287 fn validate_active_profile<E, F>(env: &E, load_settings: F) -> Result<()>
293 where
294 E: crate::utils::env::EnvSource,
295 F: FnOnce() -> crate::utils::settings::Settings,
296 {
297 match crate::utils::settings::active_profile_from(env) {
298 Some(name) => load_settings().validate_profile(&name),
299 None => Ok(()),
300 }
301 }
302
303 fn load_settings_or_default() -> crate::utils::settings::Settings {
309 crate::utils::settings::Settings::load().unwrap_or_default()
310 }
311
312 pub async fn execute(self) -> Result<()> {
314 self.propagate_global_flags();
315
316 Self::validate_active_profile(
321 &crate::utils::env::SystemEnv,
322 Self::load_settings_or_default,
323 )?;
324
325 let Self { repo, command, .. } = self;
328 let repo = repo.as_deref();
329
330 match command {
331 Commands::Ai(ai_cmd) => ai_cmd.execute().await,
332 Commands::Git(git_cmd) => git_cmd.execute(repo).await,
333 Commands::Commands(commands_cmd) => commands_cmd.execute(),
334 Commands::Atlassian(cmd) => cmd.execute().await,
335 Commands::Browser(cmd) => cmd.execute().await,
336 #[cfg(unix)]
337 Commands::Daemon(cmd) => cmd.execute().await,
338 Commands::Datadog(cmd) => cmd.execute().await,
339 Commands::Drive(cmd) => cmd.execute().await,
340 Commands::Gmail(cmd) => cmd.execute().await,
341 #[cfg(unix)]
342 Commands::Snowflake(cmd) => cmd.execute().await,
343 #[cfg(unix)]
344 Commands::Worktrees(cmd) => cmd.execute(repo).await,
345 #[cfg(unix)]
346 Commands::Sessions(cmd) => cmd.execute().await,
347 #[cfg(unix)]
348 Commands::ClaudeWrap(cmd) => cmd.execute().await,
349 Commands::Coverage(cmd) => cmd.execute(repo).await,
350 Commands::Transcript(cmd) => cmd.execute().await,
351 Commands::Log(log_cmd) => log_cmd.execute(),
352 Commands::Config(config_cmd) => config_cmd.execute(repo),
353 Commands::Resources(resources_cmd) => resources_cmd.execute(),
354 Commands::Completions(completions_cmd) => completions_cmd.execute(),
355 Commands::HelpAll(help_cmd) => help_cmd.execute(),
356 }
357 }
358}
359
360#[cfg(all(target_os = "macos", feature = "menu-bar"))]
361impl Cli {
362 pub fn menu_bar_run_config(&self) -> Option<Result<crate::daemon::DaemonRunConfig>> {
367 match &self.command {
368 Commands::Daemon(daemon::DaemonCommand {
369 command: daemon::DaemonSubcommands::Run(run),
370 }) if !run.no_menu => Some(run.clone().into_run_config()),
371 _ => None,
372 }
373 }
374}
375
376#[cfg(test)]
377#[allow(clippy::unwrap_used, clippy::expect_used)]
378mod tests {
379 use super::*;
380
381 #[tokio::test]
386 async fn execute_routes_gmail_subcommand() {
387 let guard = crate::gmail::test_support::EnvGuard::take();
388 let _dir = guard.clear_credentials();
389
390 let cli = Cli::try_parse_from(["omni-dev", "gmail", "auth", "status"]).unwrap();
391 let err = cli.execute().await.unwrap_err();
392 assert!(err.to_string().contains("not configured"));
393 }
394
395 #[tokio::test]
400 async fn execute_routes_drive_subcommand() {
401 let guard = crate::drive::test_support::EnvGuard::take();
402 let _dir = guard.clear_credentials();
403
404 let cli = Cli::try_parse_from(["omni-dev", "drive", "auth", "status"]).unwrap();
405 let err = cli.execute().await.unwrap_err();
406 assert!(err.to_string().contains("not configured"));
407 }
408
409 #[test]
410 fn parses_ai_backend_claude_cli() {
411 let cli =
412 Cli::try_parse_from(["omni-dev", "--ai-backend", "claude-cli", "help-all"]).unwrap();
413 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
414 assert!(!cli.claude_cli_allow_tools);
415 }
416
417 #[test]
418 fn parses_ai_backend_default() {
419 let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", "default", "help-all"]).unwrap();
420 assert!(matches!(cli.ai_backend, Some(AiBackend::Default)));
421 }
422
423 #[test]
424 fn parses_ai_backend_openai_ollama_bedrock() {
425 for (value, expected) in [
426 ("openai", AiBackend::OpenAi),
427 ("ollama", AiBackend::Ollama),
428 ("bedrock", AiBackend::Bedrock),
429 ] {
430 let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", value, "help-all"]).unwrap();
431 assert_eq!(cli.ai_backend, Some(expected), "value {value}");
432 }
433 }
434
435 #[test]
436 fn parses_model_before_and_after_subcommand() {
437 let before = Cli::try_parse_from([
441 "omni-dev",
442 "--model",
443 "claude-opus-4-6",
444 "git",
445 "commit",
446 "message",
447 "twiddle",
448 ])
449 .unwrap();
450 assert_eq!(before.model.as_deref(), Some("claude-opus-4-6"));
451
452 let after = Cli::try_parse_from([
455 "omni-dev",
456 "git",
457 "commit",
458 "message",
459 "twiddle",
460 "--model",
461 "claude-opus-4-6",
462 ])
463 .unwrap();
464 assert_eq!(after.model.as_deref(), Some("claude-opus-4-6"));
465 }
466
467 #[test]
468 fn parses_beta_header_before_and_after_subcommand() {
469 let before = Cli::try_parse_from([
470 "omni-dev",
471 "--beta-header",
472 "anthropic-beta:output-128k-2025-02-19",
473 "git",
474 "commit",
475 "message",
476 "check",
477 ])
478 .unwrap();
479 assert_eq!(
480 before.beta_header.as_deref(),
481 Some("anthropic-beta:output-128k-2025-02-19")
482 );
483
484 let after = Cli::try_parse_from([
485 "omni-dev",
486 "git",
487 "commit",
488 "message",
489 "check",
490 "--beta-header",
491 "anthropic-beta:output-128k-2025-02-19",
492 ])
493 .unwrap();
494 assert_eq!(
495 after.beta_header.as_deref(),
496 Some("anthropic-beta:output-128k-2025-02-19")
497 );
498 }
499
500 #[test]
501 fn parses_ai_backend_absent() {
502 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
503 assert!(cli.ai_backend.is_none());
504 assert!(!cli.claude_cli_allow_tools);
505 assert!(!cli.claude_cli_allow_mcp);
506 }
507
508 #[test]
509 fn parses_claude_cli_allow_tools_flag() {
510 let cli =
511 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
512 assert!(cli.claude_cli_allow_tools);
513 }
514
515 #[test]
516 fn parses_claude_cli_allow_mcp_flag() {
517 let cli = Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
518 assert!(cli.claude_cli_allow_mcp);
519 assert!(!cli.claude_cli_allow_tools);
520 }
521
522 #[test]
523 fn allow_mcp_and_allow_tools_are_independent() {
524 let only_mcp =
525 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
526 assert!(only_mcp.claude_cli_allow_mcp);
527 assert!(!only_mcp.claude_cli_allow_tools);
528
529 let only_tools =
530 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
531 assert!(only_tools.claude_cli_allow_tools);
532 assert!(!only_tools.claude_cli_allow_mcp);
533
534 let both = Cli::try_parse_from([
535 "omni-dev",
536 "--claude-cli-allow-tools",
537 "--claude-cli-allow-mcp",
538 "help-all",
539 ])
540 .unwrap();
541 assert!(both.claude_cli_allow_tools);
542 assert!(both.claude_cli_allow_mcp);
543 }
544
545 #[test]
546 fn global_flags_accepted_after_subcommand() {
547 let cli = Cli::try_parse_from([
549 "omni-dev",
550 "help-all",
551 "--ai-backend",
552 "claude-cli",
553 "--claude-cli-allow-tools",
554 ])
555 .unwrap();
556 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
557 assert!(cli.claude_cli_allow_tools);
558 }
559
560 #[test]
561 fn parses_max_budget_usd_flag() {
562 let cli = Cli::try_parse_from([
563 "omni-dev",
564 "--claude-cli-max-budget-usd",
565 "0.50",
566 "help-all",
567 ])
568 .unwrap();
569 assert_eq!(cli.claude_cli_max_budget_usd, Some(0.50));
570 }
571
572 #[test]
573 fn max_budget_usd_absent_is_none() {
574 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
575 assert!(cli.claude_cli_max_budget_usd.is_none());
576 }
577
578 #[test]
579 fn max_budget_usd_rejects_non_numeric() {
580 let result = Cli::try_parse_from([
581 "omni-dev",
582 "--claude-cli-max-budget-usd",
583 "cheap",
584 "help-all",
585 ]);
586 let Err(err) = result else {
587 panic!("expected parse error for non-numeric budget");
588 };
589 assert!(err.to_string().contains("invalid"));
590 }
591
592 #[cfg(unix)]
601 #[test]
602 fn worktrees_register_repo_name_coexists_with_global_repo() {
603 let cli = Cli::try_parse_from([
604 "omni-dev",
605 "-C",
606 "/tmp/somerepo",
607 "worktrees",
608 "register",
609 "--key",
610 "k1",
611 "--repo-name",
612 "myrepo",
613 "--folder",
614 "/tmp",
615 ])
616 .unwrap();
617 assert_eq!(
618 cli.repo.as_deref(),
619 Some(std::path::Path::new("/tmp/somerepo"))
620 );
621 let Commands::Worktrees(worktrees::WorktreesCommand {
622 command: worktrees::WorktreesSubcommands::Register(register),
623 }) = cli.command
624 else {
625 panic!("expected a `worktrees register` invocation");
626 };
627 assert_eq!(register.key, "k1");
628 assert_eq!(register.repo_name.as_deref(), Some("myrepo"));
629
630 let cli = Cli::try_parse_from([
633 "omni-dev",
634 "worktrees",
635 "register",
636 "--key",
637 "k1",
638 "--repo-name",
639 "myrepo",
640 "--folder",
641 "/tmp",
642 ])
643 .unwrap();
644 assert!(cli.repo.is_none());
645 }
646
647 #[test]
655 fn no_subcommand_arg_shadows_a_global_arg_id() {
656 use clap::CommandFactory;
657 use std::collections::HashSet;
658
659 fn walk(
677 cmd: &clap::Command,
678 inherited_ids: &HashSet<String>,
679 inherited_longs: &HashSet<String>,
680 path: &str,
681 ) {
682 let mut ids = inherited_ids.clone();
683 let mut longs = inherited_longs.clone();
684 for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
685 ids.insert(arg.get_id().as_str().to_string());
686 if let Some(long) = arg.get_long() {
687 longs.insert(long.to_string());
688 }
689 }
690
691 for sub in cmd.get_subcommands() {
692 let sub_path = format!("{path} {}", sub.get_name());
693 for arg in sub.get_arguments() {
694 let id = arg.get_id().as_str();
695 assert!(
696 !ids.contains(id),
697 "`{sub_path}` defines an arg with id `{id}`, which is an \
698 inherited global arg id — clap propagates globals by id, \
699 so this shadows the global and panics when it is read \
700 (#1420). Rename the subcommand-local field.",
701 );
702 if let Some(long) = arg.get_long() {
703 assert!(
704 !longs.contains(long),
705 "`{sub_path}` defines `--{long}`, already an inherited \
706 global flag — clap rejects two args on the same \
707 command sharing a long flag name (issue #1500). \
708 Rename the subcommand-local flag.",
709 );
710 }
711 }
712 walk(sub, &ids, &longs, &sub_path);
713 }
714 }
715
716 let cmd = Cli::command();
717 let root_globals: HashSet<String> = cmd
718 .get_arguments()
719 .filter(|a| a.is_global_set())
720 .map(|a| a.get_id().as_str().to_string())
721 .collect();
722 assert!(
723 !root_globals.is_empty(),
724 "expected the root command to declare global args"
725 );
726 walk(&cmd, &HashSet::new(), &HashSet::new(), "omni-dev");
727 }
728
729 const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
736 const MODEL_VAR: &str = "OMNI_DEV_MODEL";
737 const BETA_HEADER_VAR: &str = "OMNI_DEV_BETA_HEADER";
738 const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
739 const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
740 const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
741 const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
742 const PROFILE_VAR: &str = "OMNI_DEV_PROFILE";
743 const INSTANCE_VAR: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
744
745 struct GlobalFlagsEnvGuard {
748 _lock: std::sync::MutexGuard<'static, ()>,
749 saved: [(&'static str, Option<String>); 9],
750 }
751
752 impl GlobalFlagsEnvGuard {
753 fn new() -> Self {
754 let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
755 .lock()
756 .unwrap_or_else(std::sync::PoisonError::into_inner);
757 let names = [
758 BACKEND_VAR,
759 MODEL_VAR,
760 BETA_HEADER_VAR,
761 ALLOW_TOOLS_VAR,
762 ALLOW_MCP_VAR,
763 MAX_BUDGET_VAR,
764 MODELS_YAML_VAR,
765 PROFILE_VAR,
766 INSTANCE_VAR,
767 ];
768 let saved = names.map(|n| (n, std::env::var(n).ok()));
769 for (n, _) in &saved {
770 std::env::remove_var(n);
771 }
772 Self { _lock: lock, saved }
773 }
774 }
775
776 impl Drop for GlobalFlagsEnvGuard {
777 fn drop(&mut self) {
778 for (n, value) in &self.saved {
779 match value {
780 Some(v) => std::env::set_var(n, v),
781 None => std::env::remove_var(n),
782 }
783 }
784 }
785 }
786
787 fn cli_with_defaults() -> Cli {
788 Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
789 }
790
791 #[test]
792 fn propagate_global_flags_defaults_set_nothing() {
793 let _g = GlobalFlagsEnvGuard::new();
794 cli_with_defaults().propagate_global_flags();
795 assert!(std::env::var(BACKEND_VAR).is_err());
796 assert!(std::env::var(MODEL_VAR).is_err());
797 assert!(std::env::var(BETA_HEADER_VAR).is_err());
798 assert!(std::env::var(ALLOW_TOOLS_VAR).is_err());
799 assert!(std::env::var(ALLOW_MCP_VAR).is_err());
800 assert!(std::env::var(MAX_BUDGET_VAR).is_err());
801 assert!(std::env::var(MODELS_YAML_VAR).is_err());
802 assert!(std::env::var(PROFILE_VAR).is_err());
803 assert!(std::env::var(INSTANCE_VAR).is_err());
804 }
805
806 #[test]
807 fn propagate_global_flags_sets_instance() {
808 let _g = GlobalFlagsEnvGuard::new();
809 let mut cli = cli_with_defaults();
810 cli.instance = Some("https://org.atlassian.net".to_string());
811 cli.propagate_global_flags();
812 assert_eq!(
813 std::env::var(INSTANCE_VAR).ok().as_deref(),
814 Some("https://org.atlassian.net")
815 );
816 }
817
818 #[test]
819 fn propagate_global_flags_sets_ai_backend_claude_cli() {
820 let _g = GlobalFlagsEnvGuard::new();
821 let mut cli = cli_with_defaults();
822 cli.ai_backend = Some(AiBackend::ClaudeCli);
823 cli.propagate_global_flags();
824 assert_eq!(
825 std::env::var(BACKEND_VAR).ok().as_deref(),
826 Some("claude-cli")
827 );
828 }
829
830 #[test]
831 fn propagate_global_flags_default_backend_overrides_env_var() {
832 let _g = GlobalFlagsEnvGuard::new();
836 std::env::set_var(BACKEND_VAR, "claude-cli");
837 let mut cli = cli_with_defaults();
838 cli.ai_backend = Some(AiBackend::Default);
839 cli.propagate_global_flags();
840 assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some("default"));
841 }
842
843 #[test]
844 fn propagate_global_flags_sets_openai_ollama_bedrock() {
845 let _g = GlobalFlagsEnvGuard::new();
846 for (backend, expected) in [
847 (AiBackend::OpenAi, "openai"),
848 (AiBackend::Ollama, "ollama"),
849 (AiBackend::Bedrock, "bedrock"),
850 ] {
851 let mut cli = cli_with_defaults();
852 cli.ai_backend = Some(backend);
853 cli.propagate_global_flags();
854 assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some(expected));
855 }
856 }
857
858 #[test]
859 fn propagate_global_flags_sets_model() {
860 let _g = GlobalFlagsEnvGuard::new();
861 let mut cli = cli_with_defaults();
862 cli.model = Some("claude-opus-4-6".to_string());
863 cli.propagate_global_flags();
864 assert_eq!(
865 std::env::var(MODEL_VAR).ok().as_deref(),
866 Some("claude-opus-4-6")
867 );
868 }
869
870 #[test]
871 fn propagate_global_flags_sets_beta_header() {
872 let _g = GlobalFlagsEnvGuard::new();
873 let mut cli = cli_with_defaults();
874 cli.beta_header = Some("anthropic-beta:output-128k-2025-02-19".to_string());
875 cli.propagate_global_flags();
876 assert_eq!(
877 std::env::var(BETA_HEADER_VAR).ok().as_deref(),
878 Some("anthropic-beta:output-128k-2025-02-19")
879 );
880 }
881
882 #[test]
883 fn propagate_global_flags_sets_allow_tools() {
884 let _g = GlobalFlagsEnvGuard::new();
885 let mut cli = cli_with_defaults();
886 cli.claude_cli_allow_tools = true;
887 cli.propagate_global_flags();
888 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
889 assert!(crate::utils::settings::exported_by_cli_flag(
892 ALLOW_TOOLS_VAR
893 ));
894 }
895
896 #[test]
897 fn propagate_global_flags_sets_allow_mcp() {
898 let _g = GlobalFlagsEnvGuard::new();
899 let mut cli = cli_with_defaults();
900 cli.claude_cli_allow_mcp = true;
901 cli.propagate_global_flags();
902 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
903 assert!(crate::utils::settings::exported_by_cli_flag(ALLOW_MCP_VAR));
904 }
905
906 #[test]
907 fn propagate_global_flags_sets_max_budget_usd() {
908 let _g = GlobalFlagsEnvGuard::new();
909 let mut cli = cli_with_defaults();
910 cli.claude_cli_max_budget_usd = Some(1.5);
911 cli.propagate_global_flags();
912 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
913 }
914
915 #[test]
916 fn parses_models_yaml_flag() {
917 let cli = Cli::try_parse_from([
918 "omni-dev",
919 "--models-yaml",
920 "/tmp/custom-models.yaml",
921 "help-all",
922 ])
923 .unwrap();
924 assert_eq!(
925 cli.models_yaml.as_deref(),
926 Some(std::path::Path::new("/tmp/custom-models.yaml"))
927 );
928 }
929
930 #[test]
931 fn parses_repo_flag_long_and_short() {
932 let long = Cli::try_parse_from(["omni-dev", "--repo", "/tmp/r", "help-all"]).unwrap();
933 assert_eq!(
934 long.repo.as_deref(),
935 Some(std::path::Path::new("/tmp/r")),
936 "--repo should populate cli.repo"
937 );
938 let short = Cli::try_parse_from(["omni-dev", "-C", "/tmp/r", "help-all"]).unwrap();
939 assert_eq!(
940 short.repo.as_deref(),
941 Some(std::path::Path::new("/tmp/r")),
942 "-C should populate cli.repo"
943 );
944 let absent = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
945 assert!(absent.repo.is_none());
946 }
947
948 #[test]
951 fn repo_flag_is_not_propagated_to_env() {
952 let _g = GlobalFlagsEnvGuard::new();
953 let mut cli = cli_with_defaults();
954 cli.repo = Some(std::path::PathBuf::from("/tmp/some-repo"));
955 cli.propagate_global_flags();
956 assert!(
957 std::env::var("OMNI_DEV_REPO").is_err(),
958 "repo must not be exported to an env var"
959 );
960 }
961
962 #[test]
963 fn propagate_global_flags_sets_models_yaml() {
964 let _g = GlobalFlagsEnvGuard::new();
965 let mut cli = cli_with_defaults();
966 cli.models_yaml = Some(std::path::PathBuf::from("/tmp/custom-models.yaml"));
967 cli.propagate_global_flags();
968 assert_eq!(
969 std::env::var(MODELS_YAML_VAR).ok().as_deref(),
970 Some("/tmp/custom-models.yaml")
971 );
972 }
973
974 #[test]
975 fn parses_profile_flag() {
976 let cli = Cli::try_parse_from(["omni-dev", "--profile", "work", "help-all"]).unwrap();
977 assert_eq!(cli.profile.as_deref(), Some("work"));
978 }
979
980 #[test]
981 fn profile_absent_is_none() {
982 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
983 assert!(cli.profile.is_none());
984 }
985
986 #[test]
987 fn propagate_global_flags_sets_profile() {
988 let _g = GlobalFlagsEnvGuard::new();
989 let mut cli = cli_with_defaults();
990 cli.profile = Some("work".to_string());
991 cli.propagate_global_flags();
992 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
993 }
994
995 #[test]
996 fn propagate_global_flags_profile_flag_beats_env_var() {
997 let _g = GlobalFlagsEnvGuard::new();
998 std::env::set_var(PROFILE_VAR, "personal");
999 let mut cli = cli_with_defaults();
1000 cli.profile = Some("work".to_string());
1001 cli.propagate_global_flags();
1002 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
1003 }
1004
1005 #[test]
1006 fn propagate_global_flags_absent_profile_leaves_env_var() {
1007 let _g = GlobalFlagsEnvGuard::new();
1008 std::env::set_var(PROFILE_VAR, "personal");
1009 cli_with_defaults().propagate_global_flags();
1010 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("personal"));
1011 }
1012
1013 #[test]
1017 fn validate_active_profile_ok_and_skips_load_when_no_profile() {
1018 use crate::test_support::env::MapEnv;
1019 let env = MapEnv::new();
1020 let result = Cli::validate_active_profile(&env, || panic!("must not load settings"));
1021 assert!(result.is_ok());
1022 }
1023
1024 #[test]
1025 fn validate_active_profile_ok_for_known_profile() {
1026 use crate::test_support::env::MapEnv;
1027 use crate::utils::settings::{Profile, Settings};
1028 let env = MapEnv::new().with(PROFILE_VAR, "work");
1029 let settings = Settings {
1030 profiles: std::iter::once(("work".to_string(), Profile::default())).collect(),
1031 ..Default::default()
1032 };
1033 assert!(Cli::validate_active_profile(&env, || settings).is_ok());
1034 }
1035
1036 #[test]
1037 fn validate_active_profile_errors_for_unknown_profile() {
1038 use crate::test_support::env::MapEnv;
1039 use crate::utils::settings::Settings;
1040 let env = MapEnv::new().with(PROFILE_VAR, "wrok");
1041 let err = Cli::validate_active_profile(&env, Settings::default)
1042 .unwrap_err()
1043 .to_string();
1044 assert!(err.contains("unknown profile 'wrok'"));
1045 }
1046
1047 #[test]
1048 fn load_settings_or_default_never_panics() {
1049 let _settings = Cli::load_settings_or_default();
1053 }
1054
1055 #[test]
1056 fn propagate_global_flags_independent_flags_compose() {
1057 let _g = GlobalFlagsEnvGuard::new();
1058 let mut cli = cli_with_defaults();
1059 cli.ai_backend = Some(AiBackend::ClaudeCli);
1060 cli.claude_cli_allow_tools = true;
1061 cli.claude_cli_allow_mcp = true;
1062 cli.claude_cli_max_budget_usd = Some(0.25);
1063 cli.propagate_global_flags();
1064 assert_eq!(
1065 std::env::var(BACKEND_VAR).ok().as_deref(),
1066 Some("claude-cli")
1067 );
1068 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
1069 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
1070 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
1071 }
1072}