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 mod coverage;
13#[cfg(unix)]
17pub mod claude_wrap;
18#[cfg(unix)]
19pub mod daemon;
20pub mod datadog;
21pub mod format;
22pub mod git;
23pub mod help;
24pub mod log;
25pub mod resources;
26#[cfg(unix)]
27pub mod sessions;
28#[cfg(unix)]
29pub mod snowflake;
30pub mod transcript;
31#[cfg(unix)]
32pub mod worktrees;
33
34pub use crate::claude::backend::AiBackend;
37
38#[derive(Parser)]
47#[command(name = "omni-dev")]
48#[command(
49 about = "AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.",
50 long_about = None
51)]
52#[command(version = crate::VERSION, long_version = crate::build_info::long_version())]
55pub struct Cli {
56 #[arg(long, global = true, value_enum)]
62 pub ai_backend: Option<AiBackend>,
63
64 #[arg(long, global = true, value_name = "MODEL")]
71 pub model: Option<String>,
72
73 #[arg(long, global = true, value_name = "KEY:VALUE")]
79 pub beta_header: Option<String>,
80
81 #[arg(long, global = true)]
96 pub claude_cli_allow_tools: bool,
97
98 #[arg(long, global = true)]
110 pub claude_cli_allow_mcp: bool,
111
112 #[arg(long, global = true, value_name = "AMOUNT")]
120 pub claude_cli_max_budget_usd: Option<f64>,
121
122 #[arg(long, global = true, value_name = "PATH")]
127 pub models_yaml: Option<std::path::PathBuf>,
128
129 #[arg(long, global = true, value_name = "NAME")]
137 pub profile: Option<String>,
138
139 #[arg(long, global = true, value_name = "URL")]
148 pub instance: Option<String>,
149
150 #[arg(long = "repo", short = 'C', global = true, value_name = "PATH")]
158 pub repo: Option<std::path::PathBuf>,
159
160 #[command(subcommand)]
162 pub command: Commands,
163}
164
165#[derive(Subcommand)]
171pub enum Commands {
172 Ai(ai::AiCommand),
174 Git(git::GitCommand),
176 Commands(commands::CommandsCommand),
178 Config(config::ConfigCommand),
180 Atlassian(atlassian::AtlassianCommand),
182 Browser(browser::BrowserCommand),
184 #[cfg(unix)]
186 Daemon(daemon::DaemonCommand),
187 Datadog(datadog::DatadogCommand),
189 #[cfg(unix)]
191 Snowflake(snowflake::SnowflakeCommand),
192 #[cfg(unix)]
194 Worktrees(worktrees::WorktreesCommand),
195 #[cfg(unix)]
197 Sessions(sessions::SessionsCommand),
198 #[cfg(unix)]
200 #[command(name = "claude-wrap")]
201 ClaudeWrap(claude_wrap::ClaudeWrapCommand),
202 Coverage(coverage::CoverageCommand),
204 Transcript(transcript::TranscriptCommand),
206 Log(log::LogCommand),
208 Resources(resources::ResourcesCommand),
210 #[command(hide = true)]
212 Completions(completions::CompletionsCommand),
213 #[command(name = "help-all")]
215 HelpAll(help::HelpCommand),
216}
217
218impl Cli {
219 fn propagate_global_flags(&self) {
224 if let Some(backend) = self.ai_backend {
228 std::env::set_var(crate::claude::backend::AI_BACKEND_ENV, backend.env_value());
229 }
230
231 if let Some(model) = &self.model {
232 std::env::set_var(crate::claude::backend::MODEL_ENV, model);
233 }
234
235 if let Some(beta_header) = &self.beta_header {
236 std::env::set_var(crate::claude::backend::BETA_HEADER_ENV, beta_header);
237 }
238
239 if self.claude_cli_allow_tools {
243 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
244 crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS");
245 }
246
247 if self.claude_cli_allow_mcp {
248 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
249 crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP");
250 }
251
252 if let Some(budget) = self.claude_cli_max_budget_usd {
253 std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
254 }
255
256 if let Some(path) = &self.models_yaml {
257 std::env::set_var("OMNI_DEV_MODELS_YAML", path);
258 }
259
260 if let Some(profile) = &self.profile {
265 std::env::set_var(crate::utils::settings::PROFILE_ENV_VAR, profile);
266 }
267
268 if let Some(instance) = &self.instance {
273 std::env::set_var(
274 crate::atlassian::auth::ATLASSIAN_INSTANCE_OVERRIDE_ENV,
275 instance,
276 );
277 }
278 }
279
280 fn validate_active_profile<E, F>(env: &E, load_settings: F) -> Result<()>
286 where
287 E: crate::utils::env::EnvSource,
288 F: FnOnce() -> crate::utils::settings::Settings,
289 {
290 match crate::utils::settings::active_profile_from(env) {
291 Some(name) => load_settings().validate_profile(&name),
292 None => Ok(()),
293 }
294 }
295
296 fn load_settings_or_default() -> crate::utils::settings::Settings {
302 crate::utils::settings::Settings::load().unwrap_or_default()
303 }
304
305 pub async fn execute(self) -> Result<()> {
307 self.propagate_global_flags();
308
309 Self::validate_active_profile(
314 &crate::utils::env::SystemEnv,
315 Self::load_settings_or_default,
316 )?;
317
318 let Self { repo, command, .. } = self;
321 let repo = repo.as_deref();
322
323 match command {
324 Commands::Ai(ai_cmd) => ai_cmd.execute().await,
325 Commands::Git(git_cmd) => git_cmd.execute(repo).await,
326 Commands::Commands(commands_cmd) => commands_cmd.execute(),
327 Commands::Atlassian(cmd) => cmd.execute().await,
328 Commands::Browser(cmd) => cmd.execute().await,
329 #[cfg(unix)]
330 Commands::Daemon(cmd) => cmd.execute().await,
331 Commands::Datadog(cmd) => cmd.execute().await,
332 #[cfg(unix)]
333 Commands::Snowflake(cmd) => cmd.execute().await,
334 #[cfg(unix)]
335 Commands::Worktrees(cmd) => cmd.execute(repo).await,
336 #[cfg(unix)]
337 Commands::Sessions(cmd) => cmd.execute().await,
338 #[cfg(unix)]
339 Commands::ClaudeWrap(cmd) => cmd.execute().await,
340 Commands::Coverage(cmd) => cmd.execute(repo).await,
341 Commands::Transcript(cmd) => cmd.execute().await,
342 Commands::Log(log_cmd) => log_cmd.execute(),
343 Commands::Config(config_cmd) => config_cmd.execute(),
344 Commands::Resources(resources_cmd) => resources_cmd.execute(),
345 Commands::Completions(completions_cmd) => completions_cmd.execute(),
346 Commands::HelpAll(help_cmd) => help_cmd.execute(),
347 }
348 }
349}
350
351#[cfg(all(target_os = "macos", feature = "menu-bar"))]
352impl Cli {
353 pub fn menu_bar_run_config(&self) -> Option<Result<crate::daemon::DaemonRunConfig>> {
358 match &self.command {
359 Commands::Daemon(daemon::DaemonCommand {
360 command: daemon::DaemonSubcommands::Run(run),
361 }) if !run.no_menu => Some(run.clone().into_run_config()),
362 _ => None,
363 }
364 }
365}
366
367#[cfg(test)]
368#[allow(clippy::unwrap_used, clippy::expect_used)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn parses_ai_backend_claude_cli() {
374 let cli =
375 Cli::try_parse_from(["omni-dev", "--ai-backend", "claude-cli", "help-all"]).unwrap();
376 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
377 assert!(!cli.claude_cli_allow_tools);
378 }
379
380 #[test]
381 fn parses_ai_backend_default() {
382 let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", "default", "help-all"]).unwrap();
383 assert!(matches!(cli.ai_backend, Some(AiBackend::Default)));
384 }
385
386 #[test]
387 fn parses_ai_backend_openai_ollama_bedrock() {
388 for (value, expected) in [
389 ("openai", AiBackend::OpenAi),
390 ("ollama", AiBackend::Ollama),
391 ("bedrock", AiBackend::Bedrock),
392 ] {
393 let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", value, "help-all"]).unwrap();
394 assert_eq!(cli.ai_backend, Some(expected), "value {value}");
395 }
396 }
397
398 #[test]
399 fn parses_model_before_and_after_subcommand() {
400 let before = Cli::try_parse_from([
404 "omni-dev",
405 "--model",
406 "claude-opus-4-6",
407 "git",
408 "commit",
409 "message",
410 "twiddle",
411 ])
412 .unwrap();
413 assert_eq!(before.model.as_deref(), Some("claude-opus-4-6"));
414
415 let after = Cli::try_parse_from([
418 "omni-dev",
419 "git",
420 "commit",
421 "message",
422 "twiddle",
423 "--model",
424 "claude-opus-4-6",
425 ])
426 .unwrap();
427 assert_eq!(after.model.as_deref(), Some("claude-opus-4-6"));
428 }
429
430 #[test]
431 fn parses_beta_header_before_and_after_subcommand() {
432 let before = Cli::try_parse_from([
433 "omni-dev",
434 "--beta-header",
435 "anthropic-beta:output-128k-2025-02-19",
436 "git",
437 "commit",
438 "message",
439 "check",
440 ])
441 .unwrap();
442 assert_eq!(
443 before.beta_header.as_deref(),
444 Some("anthropic-beta:output-128k-2025-02-19")
445 );
446
447 let after = Cli::try_parse_from([
448 "omni-dev",
449 "git",
450 "commit",
451 "message",
452 "check",
453 "--beta-header",
454 "anthropic-beta:output-128k-2025-02-19",
455 ])
456 .unwrap();
457 assert_eq!(
458 after.beta_header.as_deref(),
459 Some("anthropic-beta:output-128k-2025-02-19")
460 );
461 }
462
463 #[test]
464 fn parses_ai_backend_absent() {
465 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
466 assert!(cli.ai_backend.is_none());
467 assert!(!cli.claude_cli_allow_tools);
468 assert!(!cli.claude_cli_allow_mcp);
469 }
470
471 #[test]
472 fn parses_claude_cli_allow_tools_flag() {
473 let cli =
474 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
475 assert!(cli.claude_cli_allow_tools);
476 }
477
478 #[test]
479 fn parses_claude_cli_allow_mcp_flag() {
480 let cli = Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
481 assert!(cli.claude_cli_allow_mcp);
482 assert!(!cli.claude_cli_allow_tools);
483 }
484
485 #[test]
486 fn allow_mcp_and_allow_tools_are_independent() {
487 let only_mcp =
488 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
489 assert!(only_mcp.claude_cli_allow_mcp);
490 assert!(!only_mcp.claude_cli_allow_tools);
491
492 let only_tools =
493 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
494 assert!(only_tools.claude_cli_allow_tools);
495 assert!(!only_tools.claude_cli_allow_mcp);
496
497 let both = Cli::try_parse_from([
498 "omni-dev",
499 "--claude-cli-allow-tools",
500 "--claude-cli-allow-mcp",
501 "help-all",
502 ])
503 .unwrap();
504 assert!(both.claude_cli_allow_tools);
505 assert!(both.claude_cli_allow_mcp);
506 }
507
508 #[test]
509 fn global_flags_accepted_after_subcommand() {
510 let cli = Cli::try_parse_from([
512 "omni-dev",
513 "help-all",
514 "--ai-backend",
515 "claude-cli",
516 "--claude-cli-allow-tools",
517 ])
518 .unwrap();
519 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
520 assert!(cli.claude_cli_allow_tools);
521 }
522
523 #[test]
524 fn parses_max_budget_usd_flag() {
525 let cli = Cli::try_parse_from([
526 "omni-dev",
527 "--claude-cli-max-budget-usd",
528 "0.50",
529 "help-all",
530 ])
531 .unwrap();
532 assert_eq!(cli.claude_cli_max_budget_usd, Some(0.50));
533 }
534
535 #[test]
536 fn max_budget_usd_absent_is_none() {
537 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
538 assert!(cli.claude_cli_max_budget_usd.is_none());
539 }
540
541 #[test]
542 fn max_budget_usd_rejects_non_numeric() {
543 let result = Cli::try_parse_from([
544 "omni-dev",
545 "--claude-cli-max-budget-usd",
546 "cheap",
547 "help-all",
548 ]);
549 let Err(err) = result else {
550 panic!("expected parse error for non-numeric budget");
551 };
552 assert!(err.to_string().contains("invalid"));
553 }
554
555 #[cfg(unix)]
564 #[test]
565 fn worktrees_register_repo_name_coexists_with_global_repo() {
566 let cli = Cli::try_parse_from([
567 "omni-dev",
568 "-C",
569 "/tmp/somerepo",
570 "worktrees",
571 "register",
572 "--key",
573 "k1",
574 "--repo-name",
575 "myrepo",
576 "--folder",
577 "/tmp",
578 ])
579 .unwrap();
580 assert_eq!(
581 cli.repo.as_deref(),
582 Some(std::path::Path::new("/tmp/somerepo"))
583 );
584 let Commands::Worktrees(worktrees::WorktreesCommand {
585 command: worktrees::WorktreesSubcommands::Register(register),
586 }) = cli.command
587 else {
588 panic!("expected a `worktrees register` invocation");
589 };
590 assert_eq!(register.key, "k1");
591 assert_eq!(register.repo_name.as_deref(), Some("myrepo"));
592
593 let cli = Cli::try_parse_from([
596 "omni-dev",
597 "worktrees",
598 "register",
599 "--key",
600 "k1",
601 "--repo-name",
602 "myrepo",
603 "--folder",
604 "/tmp",
605 ])
606 .unwrap();
607 assert!(cli.repo.is_none());
608 }
609
610 #[test]
618 fn no_subcommand_arg_shadows_a_global_arg_id() {
619 use clap::CommandFactory;
620 use std::collections::HashSet;
621
622 fn walk(cmd: &clap::Command, globals: &HashSet<String>, path: &str) {
623 for sub in cmd.get_subcommands() {
624 let sub_path = format!("{path} {}", sub.get_name());
625 for arg in sub.get_arguments() {
626 let id = arg.get_id().as_str();
627 assert!(
628 !globals.contains(id),
629 "`{sub_path}` defines an arg with id `{id}`, which is a \
630 global arg id — clap propagates globals by id, so this \
631 shadows the global and panics when it is read (#1420). \
632 Rename the subcommand-local field.",
633 );
634 }
635 walk(sub, globals, &sub_path);
636 }
637 }
638
639 let cmd = Cli::command();
640 let globals: HashSet<String> = cmd
641 .get_arguments()
642 .filter(|a| a.is_global_set())
643 .map(|a| a.get_id().as_str().to_string())
644 .collect();
645 assert!(
646 !globals.is_empty(),
647 "expected the root command to declare global args"
648 );
649 walk(&cmd, &globals, "omni-dev");
650 }
651
652 const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
659 const MODEL_VAR: &str = "OMNI_DEV_MODEL";
660 const BETA_HEADER_VAR: &str = "OMNI_DEV_BETA_HEADER";
661 const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
662 const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
663 const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
664 const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
665 const PROFILE_VAR: &str = "OMNI_DEV_PROFILE";
666 const INSTANCE_VAR: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
667
668 struct GlobalFlagsEnvGuard {
671 _lock: std::sync::MutexGuard<'static, ()>,
672 saved: [(&'static str, Option<String>); 9],
673 }
674
675 impl GlobalFlagsEnvGuard {
676 fn new() -> Self {
677 let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
678 .lock()
679 .unwrap_or_else(std::sync::PoisonError::into_inner);
680 let names = [
681 BACKEND_VAR,
682 MODEL_VAR,
683 BETA_HEADER_VAR,
684 ALLOW_TOOLS_VAR,
685 ALLOW_MCP_VAR,
686 MAX_BUDGET_VAR,
687 MODELS_YAML_VAR,
688 PROFILE_VAR,
689 INSTANCE_VAR,
690 ];
691 let saved = names.map(|n| (n, std::env::var(n).ok()));
692 for (n, _) in &saved {
693 std::env::remove_var(n);
694 }
695 Self { _lock: lock, saved }
696 }
697 }
698
699 impl Drop for GlobalFlagsEnvGuard {
700 fn drop(&mut self) {
701 for (n, value) in &self.saved {
702 match value {
703 Some(v) => std::env::set_var(n, v),
704 None => std::env::remove_var(n),
705 }
706 }
707 }
708 }
709
710 fn cli_with_defaults() -> Cli {
711 Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
712 }
713
714 #[test]
715 fn propagate_global_flags_defaults_set_nothing() {
716 let _g = GlobalFlagsEnvGuard::new();
717 cli_with_defaults().propagate_global_flags();
718 assert!(std::env::var(BACKEND_VAR).is_err());
719 assert!(std::env::var(MODEL_VAR).is_err());
720 assert!(std::env::var(BETA_HEADER_VAR).is_err());
721 assert!(std::env::var(ALLOW_TOOLS_VAR).is_err());
722 assert!(std::env::var(ALLOW_MCP_VAR).is_err());
723 assert!(std::env::var(MAX_BUDGET_VAR).is_err());
724 assert!(std::env::var(MODELS_YAML_VAR).is_err());
725 assert!(std::env::var(PROFILE_VAR).is_err());
726 assert!(std::env::var(INSTANCE_VAR).is_err());
727 }
728
729 #[test]
730 fn propagate_global_flags_sets_instance() {
731 let _g = GlobalFlagsEnvGuard::new();
732 let mut cli = cli_with_defaults();
733 cli.instance = Some("https://org.atlassian.net".to_string());
734 cli.propagate_global_flags();
735 assert_eq!(
736 std::env::var(INSTANCE_VAR).ok().as_deref(),
737 Some("https://org.atlassian.net")
738 );
739 }
740
741 #[test]
742 fn propagate_global_flags_sets_ai_backend_claude_cli() {
743 let _g = GlobalFlagsEnvGuard::new();
744 let mut cli = cli_with_defaults();
745 cli.ai_backend = Some(AiBackend::ClaudeCli);
746 cli.propagate_global_flags();
747 assert_eq!(
748 std::env::var(BACKEND_VAR).ok().as_deref(),
749 Some("claude-cli")
750 );
751 }
752
753 #[test]
754 fn propagate_global_flags_default_backend_overrides_env_var() {
755 let _g = GlobalFlagsEnvGuard::new();
759 std::env::set_var(BACKEND_VAR, "claude-cli");
760 let mut cli = cli_with_defaults();
761 cli.ai_backend = Some(AiBackend::Default);
762 cli.propagate_global_flags();
763 assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some("default"));
764 }
765
766 #[test]
767 fn propagate_global_flags_sets_openai_ollama_bedrock() {
768 let _g = GlobalFlagsEnvGuard::new();
769 for (backend, expected) in [
770 (AiBackend::OpenAi, "openai"),
771 (AiBackend::Ollama, "ollama"),
772 (AiBackend::Bedrock, "bedrock"),
773 ] {
774 let mut cli = cli_with_defaults();
775 cli.ai_backend = Some(backend);
776 cli.propagate_global_flags();
777 assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some(expected));
778 }
779 }
780
781 #[test]
782 fn propagate_global_flags_sets_model() {
783 let _g = GlobalFlagsEnvGuard::new();
784 let mut cli = cli_with_defaults();
785 cli.model = Some("claude-opus-4-6".to_string());
786 cli.propagate_global_flags();
787 assert_eq!(
788 std::env::var(MODEL_VAR).ok().as_deref(),
789 Some("claude-opus-4-6")
790 );
791 }
792
793 #[test]
794 fn propagate_global_flags_sets_beta_header() {
795 let _g = GlobalFlagsEnvGuard::new();
796 let mut cli = cli_with_defaults();
797 cli.beta_header = Some("anthropic-beta:output-128k-2025-02-19".to_string());
798 cli.propagate_global_flags();
799 assert_eq!(
800 std::env::var(BETA_HEADER_VAR).ok().as_deref(),
801 Some("anthropic-beta:output-128k-2025-02-19")
802 );
803 }
804
805 #[test]
806 fn propagate_global_flags_sets_allow_tools() {
807 let _g = GlobalFlagsEnvGuard::new();
808 let mut cli = cli_with_defaults();
809 cli.claude_cli_allow_tools = true;
810 cli.propagate_global_flags();
811 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
812 assert!(crate::utils::settings::exported_by_cli_flag(
815 ALLOW_TOOLS_VAR
816 ));
817 }
818
819 #[test]
820 fn propagate_global_flags_sets_allow_mcp() {
821 let _g = GlobalFlagsEnvGuard::new();
822 let mut cli = cli_with_defaults();
823 cli.claude_cli_allow_mcp = true;
824 cli.propagate_global_flags();
825 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
826 assert!(crate::utils::settings::exported_by_cli_flag(ALLOW_MCP_VAR));
827 }
828
829 #[test]
830 fn propagate_global_flags_sets_max_budget_usd() {
831 let _g = GlobalFlagsEnvGuard::new();
832 let mut cli = cli_with_defaults();
833 cli.claude_cli_max_budget_usd = Some(1.5);
834 cli.propagate_global_flags();
835 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
836 }
837
838 #[test]
839 fn parses_models_yaml_flag() {
840 let cli = Cli::try_parse_from([
841 "omni-dev",
842 "--models-yaml",
843 "/tmp/custom-models.yaml",
844 "help-all",
845 ])
846 .unwrap();
847 assert_eq!(
848 cli.models_yaml.as_deref(),
849 Some(std::path::Path::new("/tmp/custom-models.yaml"))
850 );
851 }
852
853 #[test]
854 fn parses_repo_flag_long_and_short() {
855 let long = Cli::try_parse_from(["omni-dev", "--repo", "/tmp/r", "help-all"]).unwrap();
856 assert_eq!(
857 long.repo.as_deref(),
858 Some(std::path::Path::new("/tmp/r")),
859 "--repo should populate cli.repo"
860 );
861 let short = Cli::try_parse_from(["omni-dev", "-C", "/tmp/r", "help-all"]).unwrap();
862 assert_eq!(
863 short.repo.as_deref(),
864 Some(std::path::Path::new("/tmp/r")),
865 "-C should populate cli.repo"
866 );
867 let absent = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
868 assert!(absent.repo.is_none());
869 }
870
871 #[test]
874 fn repo_flag_is_not_propagated_to_env() {
875 let _g = GlobalFlagsEnvGuard::new();
876 let mut cli = cli_with_defaults();
877 cli.repo = Some(std::path::PathBuf::from("/tmp/some-repo"));
878 cli.propagate_global_flags();
879 assert!(
880 std::env::var("OMNI_DEV_REPO").is_err(),
881 "repo must not be exported to an env var"
882 );
883 }
884
885 #[test]
886 fn propagate_global_flags_sets_models_yaml() {
887 let _g = GlobalFlagsEnvGuard::new();
888 let mut cli = cli_with_defaults();
889 cli.models_yaml = Some(std::path::PathBuf::from("/tmp/custom-models.yaml"));
890 cli.propagate_global_flags();
891 assert_eq!(
892 std::env::var(MODELS_YAML_VAR).ok().as_deref(),
893 Some("/tmp/custom-models.yaml")
894 );
895 }
896
897 #[test]
898 fn parses_profile_flag() {
899 let cli = Cli::try_parse_from(["omni-dev", "--profile", "work", "help-all"]).unwrap();
900 assert_eq!(cli.profile.as_deref(), Some("work"));
901 }
902
903 #[test]
904 fn profile_absent_is_none() {
905 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
906 assert!(cli.profile.is_none());
907 }
908
909 #[test]
910 fn propagate_global_flags_sets_profile() {
911 let _g = GlobalFlagsEnvGuard::new();
912 let mut cli = cli_with_defaults();
913 cli.profile = Some("work".to_string());
914 cli.propagate_global_flags();
915 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
916 }
917
918 #[test]
919 fn propagate_global_flags_profile_flag_beats_env_var() {
920 let _g = GlobalFlagsEnvGuard::new();
921 std::env::set_var(PROFILE_VAR, "personal");
922 let mut cli = cli_with_defaults();
923 cli.profile = Some("work".to_string());
924 cli.propagate_global_flags();
925 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
926 }
927
928 #[test]
929 fn propagate_global_flags_absent_profile_leaves_env_var() {
930 let _g = GlobalFlagsEnvGuard::new();
931 std::env::set_var(PROFILE_VAR, "personal");
932 cli_with_defaults().propagate_global_flags();
933 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("personal"));
934 }
935
936 #[test]
940 fn validate_active_profile_ok_and_skips_load_when_no_profile() {
941 use crate::test_support::env::MapEnv;
942 let env = MapEnv::new();
943 let result = Cli::validate_active_profile(&env, || panic!("must not load settings"));
944 assert!(result.is_ok());
945 }
946
947 #[test]
948 fn validate_active_profile_ok_for_known_profile() {
949 use crate::test_support::env::MapEnv;
950 use crate::utils::settings::{Profile, Settings};
951 let env = MapEnv::new().with(PROFILE_VAR, "work");
952 let settings = Settings {
953 profiles: std::iter::once(("work".to_string(), Profile::default())).collect(),
954 ..Default::default()
955 };
956 assert!(Cli::validate_active_profile(&env, || settings).is_ok());
957 }
958
959 #[test]
960 fn validate_active_profile_errors_for_unknown_profile() {
961 use crate::test_support::env::MapEnv;
962 use crate::utils::settings::Settings;
963 let env = MapEnv::new().with(PROFILE_VAR, "wrok");
964 let err = Cli::validate_active_profile(&env, Settings::default)
965 .unwrap_err()
966 .to_string();
967 assert!(err.contains("unknown profile 'wrok'"));
968 }
969
970 #[test]
971 fn load_settings_or_default_never_panics() {
972 let _settings = Cli::load_settings_or_default();
976 }
977
978 #[test]
979 fn propagate_global_flags_independent_flags_compose() {
980 let _g = GlobalFlagsEnvGuard::new();
981 let mut cli = cli_with_defaults();
982 cli.ai_backend = Some(AiBackend::ClaudeCli);
983 cli.claude_cli_allow_tools = true;
984 cli.claude_cli_allow_mcp = true;
985 cli.claude_cli_max_budget_usd = Some(0.25);
986 cli.propagate_global_flags();
987 assert_eq!(
988 std::env::var(BACKEND_VAR).ok().as_deref(),
989 Some("claude-cli")
990 );
991 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
992 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
993 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
994 }
995}