Skip to main content

omni_dev/
cli.rs

1//! CLI interface for omni-dev.
2
3use 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// The daemon and the Snowflake client (which talks to the daemon over its
14// Unix-domain control socket) are Unix-only; running them on Windows is future
15// work (#1237).
16#[cfg(unix)]
17pub mod daemon;
18pub mod datadog;
19pub mod format;
20pub mod git;
21pub mod help;
22pub mod log;
23pub mod resources;
24#[cfg(unix)]
25pub mod sessions;
26#[cfg(unix)]
27pub mod snowflake;
28pub mod transcript;
29#[cfg(unix)]
30pub mod worktrees;
31
32// The `--ai-backend` value enum lives with the shared backend/model resolver;
33// re-exported here so `crate::cli::AiBackend` keeps working.
34pub use crate::claude::backend::AiBackend;
35
36/// Top-level clap-derived CLI struct; the library entry point for embedding
37/// omni-dev programmatically.
38///
39/// Global flags (`--ai-backend`, `--model`, `--beta-header`,
40/// `--claude-cli-allow-tools`, `--claude-cli-allow-mcp`,
41/// `--claude-cli-max-budget-usd`, `--models-yaml`) are propagated to
42/// environment variables read by downstream factories before dispatching to a
43/// [`Commands`] variant.
44#[derive(Parser)]
45#[command(name = "omni-dev")]
46#[command(
47    about = "AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.",
48    long_about = None
49)]
50#[command(version)]
51pub struct Cli {
52    /// Selects the AI backend used by commands that invoke an AI model.
53    ///
54    /// Overrides the `OMNI_DEV_AI_BACKEND` environment variable and the
55    /// legacy `USE_OPENAI`/`USE_OLLAMA`/`CLAUDE_CODE_USE_BEDROCK` variables
56    /// (`default` forces the direct Anthropic API even when they are set).
57    #[arg(long, global = true, value_enum)]
58    pub ai_backend: Option<AiBackend>,
59
60    /// AI model to use for commands that invoke an AI model.
61    ///
62    /// Highest-precedence model selector: it overrides `OMNI_DEV_MODEL` and
63    /// every per-backend model variable (`CLAUDE_MODEL`, `CLAUDE_CODE_MODEL`,
64    /// `ANTHROPIC_MODEL`, `OPENAI_MODEL`, `OLLAMA_MODEL`). Equivalent to
65    /// setting `OMNI_DEV_MODEL`.
66    #[arg(long, global = true, value_name = "MODEL")]
67    pub model: Option<String>,
68
69    /// Beta header to send with AI API requests (format: key:value).
70    ///
71    /// Only sent if the model supports it in the model registry. Equivalent
72    /// to setting `OMNI_DEV_BETA_HEADER`. Ignored when `--ai-backend` is
73    /// `claude-cli` (the CLI negotiates betas itself).
74    #[arg(long, global = true, value_name = "KEY:VALUE")]
75    pub beta_header: Option<String>,
76
77    /// Weakens the `claude-cli` sandbox by allowing the nested `claude -p`
78    /// session to use its default built-in tools (Read, Edit, Write, Bash,
79    /// Glob, Grep).
80    ///
81    /// **Only use for deliberately tool-capable use cases.** By default the
82    /// nested session runs with `--tools ""` and cannot touch the
83    /// file system. This flag removes that guard. The prompt is built from
84    /// untrusted content (diffs, commit messages, JIRA text), so well-known
85    /// secret env vars (`*_API_KEY`, `*_TOKEN`, etc.) are scrubbed from the
86    /// nested session; set `OMNI_DEV_CLAUDE_CLI_KEEP_ENV` to exempt names.
87    /// Equivalent to setting `OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS=true`.
88    /// Independent of `--claude-cli-allow-mcp`.
89    ///
90    /// Ignored when `--ai-backend` is not `claude-cli`.
91    #[arg(long, global = true)]
92    pub claude_cli_allow_tools: bool,
93
94    /// Weakens the `claude-cli` sandbox by allowing the nested `claude -p`
95    /// session to load MCP servers from `~/.claude/settings.json`.
96    ///
97    /// **Only use deliberately.** MCP servers commonly hold OAuth tokens
98    /// (Gmail, Drive, Slack) and may be arbitrary network-attached services;
99    /// enabling this exposes them to the nested session. By default the
100    /// session runs with `--strict-mcp-config` and no MCP servers load.
101    /// Equivalent to setting `OMNI_DEV_CLAUDE_CLI_ALLOW_MCP=true`.
102    /// Independent of `--claude-cli-allow-tools`.
103    ///
104    /// Ignored when `--ai-backend` is not `claude-cli`.
105    #[arg(long, global = true)]
106    pub claude_cli_allow_mcp: bool,
107
108    /// Per-invocation spending cap in USD for the `claude-cli` backend.
109    ///
110    /// Forwarded to `claude -p --max-budget-usd`. When the nested session
111    /// exceeds this budget it aborts rather than running away with cost.
112    /// Equivalent to setting `OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD`.
113    ///
114    /// Ignored when `--ai-backend` is not `claude-cli`.
115    #[arg(long, global = true, value_name = "AMOUNT")]
116    pub claude_cli_max_budget_usd: Option<f64>,
117
118    /// Path to a single user-side `models.yaml` that short-circuits the
119    /// standard `./.omni-dev/models.yaml` and `~/.omni-dev/models.yaml`
120    /// lookup. The file is still merged over the embedded catalog.
121    /// Equivalent to setting `OMNI_DEV_MODELS_YAML`.
122    #[arg(long, global = true, value_name = "PATH")]
123    pub models_yaml: Option<std::path::PathBuf>,
124
125    /// Selects a named credential/config profile from
126    /// `~/.omni-dev/settings.json` (AWS-CLI style).
127    ///
128    /// When set, the profile's `env` bundle replaces the base `env` map in the
129    /// settings-fallback chain (process env still wins); the base map is not
130    /// consulted. Overrides `OMNI_DEV_PROFILE`. An unknown name is a hard error
131    /// listing the known profiles.
132    #[arg(long, global = true, value_name = "NAME")]
133    pub profile: Option<String>,
134
135    /// Overrides the Atlassian instance URL (e.g.
136    /// `https://org.atlassian.net`) for every JIRA and Confluence command.
137    ///
138    /// Takes precedence over `ATLASSIAN_INSTANCE_URL` / settings.json (email
139    /// and API token still come from the environment/settings). Lets a
140    /// multi-site user target a specific tenant per invocation. Equivalent to
141    /// setting `OMNI_DEV_ATLASSIAN_INSTANCE`. Ignored by non-Atlassian
142    /// commands.
143    #[arg(long, global = true, value_name = "URL")]
144    pub instance: Option<String>,
145
146    /// Run as if omni-dev was started in `<PATH>` instead of the current
147    /// working directory.
148    ///
149    /// Resolved exactly once here and threaded explicitly to each command as a
150    /// parameter; deliberately **not** propagated to an environment variable
151    /// (unlike the flags above) so the repo location never becomes an ambient
152    /// global. Mirrors `git -C`.
153    #[arg(long = "repo", short = 'C', global = true, value_name = "PATH")]
154    pub repo: Option<std::path::PathBuf>,
155
156    /// The main command to execute.
157    #[command(subcommand)]
158    pub command: Commands,
159}
160
161/// Top-level subcommand dispatch enum.
162///
163/// Each variant wraps the subcommand-specific argument struct (e.g.
164/// [`ai::AiCommand`], [`git::GitCommand`], [`atlassian::AtlassianCommand`]);
165/// follow the variant's payload type for the per-command argument surface.
166#[derive(Subcommand)]
167pub enum Commands {
168    /// AI operations.
169    Ai(ai::AiCommand),
170    /// Git-related operations.
171    Git(git::GitCommand),
172    /// Command template management.
173    Commands(commands::CommandsCommand),
174    /// Configuration and model information.
175    Config(config::ConfigCommand),
176    /// Atlassian: JIRA and Confluence operations.
177    Atlassian(atlassian::AtlassianCommand),
178    /// Browser bridge: drive authenticated requests through a browser tab.
179    Browser(browser::BrowserCommand),
180    /// Daemon: host long-lived services (e.g. the browser bridge).
181    #[cfg(unix)]
182    Daemon(daemon::DaemonCommand),
183    /// Datadog: read-only API operations.
184    Datadog(datadog::DatadogCommand),
185    /// Snowflake: run arbitrary SQL through the daemon's multiplexed sessions.
186    #[cfg(unix)]
187    Snowflake(snowflake::SnowflakeCommand),
188    /// Worktrees: list the repos/worktrees open across all VS Code windows.
189    #[cfg(unix)]
190    Worktrees(worktrees::WorktreesCommand),
191    /// Sessions: track Claude Code sessions running across all terminals and windows.
192    #[cfg(unix)]
193    Sessions(sessions::SessionsCommand),
194    /// Coverage: diff/patch coverage analysis for PR comments.
195    Coverage(coverage::CoverageCommand),
196    /// Transcript and caption fetching from media platforms.
197    Transcript(transcript::TranscriptCommand),
198    /// Search the local invocation + HTTP request log.
199    Log(log::LogCommand),
200    /// Embedded reference resources (specs, etc.).
201    Resources(resources::ResourcesCommand),
202    /// Generates shell completion scripts.
203    #[command(hide = true)]
204    Completions(completions::CompletionsCommand),
205    /// Displays comprehensive help for all commands.
206    #[command(name = "help-all")]
207    HelpAll(help::HelpCommand),
208}
209
210impl Cli {
211    /// Forwards global flags to the env vars that downstream factories
212    /// read. Extracted so it can be unit-tested without invoking a real
213    /// subcommand. Setting the env vars here (rather than threading extra
214    /// arguments through every command) keeps factory signatures stable.
215    fn propagate_global_flags(&self) {
216        // Every value — including `default` — is written to the env var so
217        // the flag decisively overrides both a pre-set OMNI_DEV_AI_BACKEND
218        // and the legacy USE_* selection flags (#1118).
219        if let Some(backend) = self.ai_backend {
220            std::env::set_var(crate::claude::backend::AI_BACKEND_ENV, backend.env_value());
221        }
222
223        if let Some(model) = &self.model {
224            std::env::set_var(crate::claude::backend::MODEL_ENV, model);
225        }
226
227        if let Some(beta_header) = &self.beta_header {
228            std::env::set_var(crate::claude::backend::BETA_HEADER_ENV, beta_header);
229        }
230
231        // The escape-hatch exports are also recorded in the flag-provenance
232        // registry so the sandbox-weakened WARN can attribute them to the
233        // flag rather than to an ambient shell export (issue #1143).
234        if self.claude_cli_allow_tools {
235            std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
236            crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS");
237        }
238
239        if self.claude_cli_allow_mcp {
240            std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
241            crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP");
242        }
243
244        if let Some(budget) = self.claude_cli_max_budget_usd {
245            std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
246        }
247
248        if let Some(path) = &self.models_yaml {
249            std::env::set_var("OMNI_DEV_MODELS_YAML", path);
250        }
251
252        // The flag beats the env var: setting OMNI_DEV_PROFILE here means the
253        // settings readers (which discover the active profile from that env
254        // var) pick up the flag. When the flag is absent we leave any existing
255        // OMNI_DEV_PROFILE untouched, so the env-var path still works.
256        if let Some(profile) = &self.profile {
257            std::env::set_var(crate::utils::settings::PROFILE_ENV_VAR, profile);
258        }
259
260        // The global `--instance` flag overrides the configured Atlassian
261        // instance for every JIRA/Confluence command. Propagated to the env var
262        // that `atlassian::auth::load_credentials` reads (#1117). When absent we
263        // leave any existing value untouched so the env-var path still works.
264        if let Some(instance) = &self.instance {
265            std::env::set_var(
266                crate::atlassian::auth::ATLASSIAN_INSTANCE_OVERRIDE_ENV,
267                instance,
268            );
269        }
270    }
271
272    /// Validates the active profile (resolved from `env`) against the settings
273    /// produced by `load_settings`. The loader is invoked only when a profile is
274    /// actually active, so a no-profile invocation reads no disk. Pure over its
275    /// inputs — unit-tested with a `MapEnv` and a constructed `Settings` rather
276    /// than the process environment and `~/.omni-dev/settings.json`.
277    fn validate_active_profile<E, F>(env: &E, load_settings: F) -> Result<()>
278    where
279        E: crate::utils::env::EnvSource,
280        F: FnOnce() -> crate::utils::settings::Settings,
281    {
282        match crate::utils::settings::active_profile_from(env) {
283            Some(name) => load_settings().validate_profile(&name),
284            None => Ok(()),
285        }
286    }
287
288    /// Thin disk boundary for [`Self::validate_active_profile`]: loads
289    /// `~/.omni-dev/settings.json`, degrading to defaults when it is absent or
290    /// unreadable rather than failing (an unreadable settings file must not block
291    /// commands that use no profile). A named function so it can be unit-tested
292    /// directly instead of as an inline closure.
293    fn load_settings_or_default() -> crate::utils::settings::Settings {
294        crate::utils::settings::Settings::load().unwrap_or_default()
295    }
296
297    /// Executes the CLI command.
298    pub async fn execute(self) -> Result<()> {
299        self.propagate_global_flags();
300
301        // Validate the selected profile once, before dispatch, so a typo fails
302        // fast rather than silently falling back to base credentials. The loader
303        // runs only when a profile is active, so a no-profile invocation pays no
304        // extra disk I/O.
305        Self::validate_active_profile(
306            &crate::utils::env::SystemEnv,
307            Self::load_settings_or_default,
308        )?;
309
310        // Resolve the repo location exactly once at this boundary, then thread
311        // it explicitly into each command. Nothing deeper reads the ambient CWD.
312        let Self { repo, command, .. } = self;
313        let repo = repo.as_deref();
314
315        match command {
316            Commands::Ai(ai_cmd) => ai_cmd.execute().await,
317            Commands::Git(git_cmd) => git_cmd.execute(repo).await,
318            Commands::Commands(commands_cmd) => commands_cmd.execute(),
319            Commands::Atlassian(cmd) => cmd.execute().await,
320            Commands::Browser(cmd) => cmd.execute().await,
321            #[cfg(unix)]
322            Commands::Daemon(cmd) => cmd.execute().await,
323            Commands::Datadog(cmd) => cmd.execute().await,
324            #[cfg(unix)]
325            Commands::Snowflake(cmd) => cmd.execute().await,
326            #[cfg(unix)]
327            Commands::Worktrees(cmd) => cmd.execute().await,
328            #[cfg(unix)]
329            Commands::Sessions(cmd) => cmd.execute().await,
330            Commands::Coverage(cmd) => cmd.execute(repo).await,
331            Commands::Transcript(cmd) => cmd.execute().await,
332            Commands::Log(log_cmd) => log_cmd.execute(),
333            Commands::Config(config_cmd) => config_cmd.execute(),
334            Commands::Resources(resources_cmd) => resources_cmd.execute(),
335            Commands::Completions(completions_cmd) => completions_cmd.execute(),
336            Commands::HelpAll(help_cmd) => help_cmd.execute(),
337        }
338    }
339}
340
341#[cfg(all(target_os = "macos", feature = "menu-bar"))]
342impl Cli {
343    /// If this invocation is `daemon run` without `--no-menu`, resolves the
344    /// daemon configuration so `main` can host it with a macOS menu-bar tray on
345    /// the main thread. Returns `None` for every other invocation (which runs
346    /// normally on the async runtime).
347    pub fn menu_bar_run_config(&self) -> Option<Result<crate::daemon::DaemonRunConfig>> {
348        match &self.command {
349            Commands::Daemon(daemon::DaemonCommand {
350                command: daemon::DaemonSubcommands::Run(run),
351            }) if !run.no_menu => Some(run.clone().into_run_config()),
352            _ => None,
353        }
354    }
355}
356
357#[cfg(test)]
358#[allow(clippy::unwrap_used, clippy::expect_used)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn parses_ai_backend_claude_cli() {
364        let cli =
365            Cli::try_parse_from(["omni-dev", "--ai-backend", "claude-cli", "help-all"]).unwrap();
366        assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
367        assert!(!cli.claude_cli_allow_tools);
368    }
369
370    #[test]
371    fn parses_ai_backend_default() {
372        let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", "default", "help-all"]).unwrap();
373        assert!(matches!(cli.ai_backend, Some(AiBackend::Default)));
374    }
375
376    #[test]
377    fn parses_ai_backend_openai_ollama_bedrock() {
378        for (value, expected) in [
379            ("openai", AiBackend::OpenAi),
380            ("ollama", AiBackend::Ollama),
381            ("bedrock", AiBackend::Bedrock),
382        ] {
383            let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", value, "help-all"]).unwrap();
384            assert_eq!(cli.ai_backend, Some(expected), "value {value}");
385        }
386    }
387
388    #[test]
389    fn parses_model_before_and_after_subcommand() {
390        // Before the subcommand — the placement the docs show
391        // (`omni-dev --model … git commit message twiddle …`), broken
392        // pre-#1118 because --model was subcommand-local.
393        let before = Cli::try_parse_from([
394            "omni-dev",
395            "--model",
396            "claude-opus-4-6",
397            "git",
398            "commit",
399            "message",
400            "twiddle",
401        ])
402        .unwrap();
403        assert_eq!(before.model.as_deref(), Some("claude-opus-4-6"));
404
405        // After the subcommand — the pre-#1118 placement keeps parsing
406        // because the arg is global = true.
407        let after = Cli::try_parse_from([
408            "omni-dev",
409            "git",
410            "commit",
411            "message",
412            "twiddle",
413            "--model",
414            "claude-opus-4-6",
415        ])
416        .unwrap();
417        assert_eq!(after.model.as_deref(), Some("claude-opus-4-6"));
418    }
419
420    #[test]
421    fn parses_beta_header_before_and_after_subcommand() {
422        let before = Cli::try_parse_from([
423            "omni-dev",
424            "--beta-header",
425            "anthropic-beta:output-128k-2025-02-19",
426            "git",
427            "commit",
428            "message",
429            "check",
430        ])
431        .unwrap();
432        assert_eq!(
433            before.beta_header.as_deref(),
434            Some("anthropic-beta:output-128k-2025-02-19")
435        );
436
437        let after = Cli::try_parse_from([
438            "omni-dev",
439            "git",
440            "commit",
441            "message",
442            "check",
443            "--beta-header",
444            "anthropic-beta:output-128k-2025-02-19",
445        ])
446        .unwrap();
447        assert_eq!(
448            after.beta_header.as_deref(),
449            Some("anthropic-beta:output-128k-2025-02-19")
450        );
451    }
452
453    #[test]
454    fn parses_ai_backend_absent() {
455        let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
456        assert!(cli.ai_backend.is_none());
457        assert!(!cli.claude_cli_allow_tools);
458        assert!(!cli.claude_cli_allow_mcp);
459    }
460
461    #[test]
462    fn parses_claude_cli_allow_tools_flag() {
463        let cli =
464            Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
465        assert!(cli.claude_cli_allow_tools);
466    }
467
468    #[test]
469    fn parses_claude_cli_allow_mcp_flag() {
470        let cli = Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
471        assert!(cli.claude_cli_allow_mcp);
472        assert!(!cli.claude_cli_allow_tools);
473    }
474
475    #[test]
476    fn allow_mcp_and_allow_tools_are_independent() {
477        let only_mcp =
478            Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
479        assert!(only_mcp.claude_cli_allow_mcp);
480        assert!(!only_mcp.claude_cli_allow_tools);
481
482        let only_tools =
483            Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
484        assert!(only_tools.claude_cli_allow_tools);
485        assert!(!only_tools.claude_cli_allow_mcp);
486
487        let both = Cli::try_parse_from([
488            "omni-dev",
489            "--claude-cli-allow-tools",
490            "--claude-cli-allow-mcp",
491            "help-all",
492        ])
493        .unwrap();
494        assert!(both.claude_cli_allow_tools);
495        assert!(both.claude_cli_allow_mcp);
496    }
497
498    #[test]
499    fn global_flags_accepted_after_subcommand() {
500        // clap global = true allows the flag before or after the subcommand.
501        let cli = Cli::try_parse_from([
502            "omni-dev",
503            "help-all",
504            "--ai-backend",
505            "claude-cli",
506            "--claude-cli-allow-tools",
507        ])
508        .unwrap();
509        assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
510        assert!(cli.claude_cli_allow_tools);
511    }
512
513    #[test]
514    fn parses_max_budget_usd_flag() {
515        let cli = Cli::try_parse_from([
516            "omni-dev",
517            "--claude-cli-max-budget-usd",
518            "0.50",
519            "help-all",
520        ])
521        .unwrap();
522        assert_eq!(cli.claude_cli_max_budget_usd, Some(0.50));
523    }
524
525    #[test]
526    fn max_budget_usd_absent_is_none() {
527        let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
528        assert!(cli.claude_cli_max_budget_usd.is_none());
529    }
530
531    #[test]
532    fn max_budget_usd_rejects_non_numeric() {
533        let result = Cli::try_parse_from([
534            "omni-dev",
535            "--claude-cli-max-budget-usd",
536            "cheap",
537            "help-all",
538        ]);
539        let Err(err) = result else {
540            panic!("expected parse error for non-numeric budget");
541        };
542        assert!(err.to_string().contains("invalid"));
543    }
544
545    // ── propagate_global_flags() tests ──
546    //
547    // These tests mutate process-global env vars, so they serialise on
548    // `crate::claude::ai::claude_cli::CLI_ENV_LOCK` (shared with claude-cli's
549    // own env-mutating tests to avoid cross-module races).
550
551    const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
552    const MODEL_VAR: &str = "OMNI_DEV_MODEL";
553    const BETA_HEADER_VAR: &str = "OMNI_DEV_BETA_HEADER";
554    const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
555    const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
556    const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
557    const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
558    const PROFILE_VAR: &str = "OMNI_DEV_PROFILE";
559    const INSTANCE_VAR: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
560
561    /// Locks the shared mutex and snapshots/restores every env var
562    /// `propagate_global_flags` may touch.
563    struct GlobalFlagsEnvGuard {
564        _lock: std::sync::MutexGuard<'static, ()>,
565        saved: [(&'static str, Option<String>); 9],
566    }
567
568    impl GlobalFlagsEnvGuard {
569        fn new() -> Self {
570            let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
571                .lock()
572                .unwrap_or_else(std::sync::PoisonError::into_inner);
573            let names = [
574                BACKEND_VAR,
575                MODEL_VAR,
576                BETA_HEADER_VAR,
577                ALLOW_TOOLS_VAR,
578                ALLOW_MCP_VAR,
579                MAX_BUDGET_VAR,
580                MODELS_YAML_VAR,
581                PROFILE_VAR,
582                INSTANCE_VAR,
583            ];
584            let saved = names.map(|n| (n, std::env::var(n).ok()));
585            for (n, _) in &saved {
586                std::env::remove_var(n);
587            }
588            Self { _lock: lock, saved }
589        }
590    }
591
592    impl Drop for GlobalFlagsEnvGuard {
593        fn drop(&mut self) {
594            for (n, value) in &self.saved {
595                match value {
596                    Some(v) => std::env::set_var(n, v),
597                    None => std::env::remove_var(n),
598                }
599            }
600        }
601    }
602
603    fn cli_with_defaults() -> Cli {
604        Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
605    }
606
607    #[test]
608    fn propagate_global_flags_defaults_set_nothing() {
609        let _g = GlobalFlagsEnvGuard::new();
610        cli_with_defaults().propagate_global_flags();
611        assert!(std::env::var(BACKEND_VAR).is_err());
612        assert!(std::env::var(MODEL_VAR).is_err());
613        assert!(std::env::var(BETA_HEADER_VAR).is_err());
614        assert!(std::env::var(ALLOW_TOOLS_VAR).is_err());
615        assert!(std::env::var(ALLOW_MCP_VAR).is_err());
616        assert!(std::env::var(MAX_BUDGET_VAR).is_err());
617        assert!(std::env::var(MODELS_YAML_VAR).is_err());
618        assert!(std::env::var(PROFILE_VAR).is_err());
619        assert!(std::env::var(INSTANCE_VAR).is_err());
620    }
621
622    #[test]
623    fn propagate_global_flags_sets_instance() {
624        let _g = GlobalFlagsEnvGuard::new();
625        let mut cli = cli_with_defaults();
626        cli.instance = Some("https://org.atlassian.net".to_string());
627        cli.propagate_global_flags();
628        assert_eq!(
629            std::env::var(INSTANCE_VAR).ok().as_deref(),
630            Some("https://org.atlassian.net")
631        );
632    }
633
634    #[test]
635    fn propagate_global_flags_sets_ai_backend_claude_cli() {
636        let _g = GlobalFlagsEnvGuard::new();
637        let mut cli = cli_with_defaults();
638        cli.ai_backend = Some(AiBackend::ClaudeCli);
639        cli.propagate_global_flags();
640        assert_eq!(
641            std::env::var(BACKEND_VAR).ok().as_deref(),
642            Some("claude-cli")
643        );
644    }
645
646    #[test]
647    fn propagate_global_flags_default_backend_overrides_env_var() {
648        // `--ai-backend default` must *set* the env var (not remove it) so it
649        // decisively overrides both a pre-set backend and the legacy USE_*
650        // flags (#1118).
651        let _g = GlobalFlagsEnvGuard::new();
652        std::env::set_var(BACKEND_VAR, "claude-cli");
653        let mut cli = cli_with_defaults();
654        cli.ai_backend = Some(AiBackend::Default);
655        cli.propagate_global_flags();
656        assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some("default"));
657    }
658
659    #[test]
660    fn propagate_global_flags_sets_openai_ollama_bedrock() {
661        let _g = GlobalFlagsEnvGuard::new();
662        for (backend, expected) in [
663            (AiBackend::OpenAi, "openai"),
664            (AiBackend::Ollama, "ollama"),
665            (AiBackend::Bedrock, "bedrock"),
666        ] {
667            let mut cli = cli_with_defaults();
668            cli.ai_backend = Some(backend);
669            cli.propagate_global_flags();
670            assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some(expected));
671        }
672    }
673
674    #[test]
675    fn propagate_global_flags_sets_model() {
676        let _g = GlobalFlagsEnvGuard::new();
677        let mut cli = cli_with_defaults();
678        cli.model = Some("claude-opus-4-6".to_string());
679        cli.propagate_global_flags();
680        assert_eq!(
681            std::env::var(MODEL_VAR).ok().as_deref(),
682            Some("claude-opus-4-6")
683        );
684    }
685
686    #[test]
687    fn propagate_global_flags_sets_beta_header() {
688        let _g = GlobalFlagsEnvGuard::new();
689        let mut cli = cli_with_defaults();
690        cli.beta_header = Some("anthropic-beta:output-128k-2025-02-19".to_string());
691        cli.propagate_global_flags();
692        assert_eq!(
693            std::env::var(BETA_HEADER_VAR).ok().as_deref(),
694            Some("anthropic-beta:output-128k-2025-02-19")
695        );
696    }
697
698    #[test]
699    fn propagate_global_flags_sets_allow_tools() {
700        let _g = GlobalFlagsEnvGuard::new();
701        let mut cli = cli_with_defaults();
702        cli.claude_cli_allow_tools = true;
703        cli.propagate_global_flags();
704        assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
705        // The flag export is recorded for WARN provenance (issue #1143). The
706        // registry is additive-only, so this assertion is order-independent.
707        assert!(crate::utils::settings::exported_by_cli_flag(
708            ALLOW_TOOLS_VAR
709        ));
710    }
711
712    #[test]
713    fn propagate_global_flags_sets_allow_mcp() {
714        let _g = GlobalFlagsEnvGuard::new();
715        let mut cli = cli_with_defaults();
716        cli.claude_cli_allow_mcp = true;
717        cli.propagate_global_flags();
718        assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
719        assert!(crate::utils::settings::exported_by_cli_flag(ALLOW_MCP_VAR));
720    }
721
722    #[test]
723    fn propagate_global_flags_sets_max_budget_usd() {
724        let _g = GlobalFlagsEnvGuard::new();
725        let mut cli = cli_with_defaults();
726        cli.claude_cli_max_budget_usd = Some(1.5);
727        cli.propagate_global_flags();
728        assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
729    }
730
731    #[test]
732    fn parses_models_yaml_flag() {
733        let cli = Cli::try_parse_from([
734            "omni-dev",
735            "--models-yaml",
736            "/tmp/custom-models.yaml",
737            "help-all",
738        ])
739        .unwrap();
740        assert_eq!(
741            cli.models_yaml.as_deref(),
742            Some(std::path::Path::new("/tmp/custom-models.yaml"))
743        );
744    }
745
746    #[test]
747    fn parses_repo_flag_long_and_short() {
748        let long = Cli::try_parse_from(["omni-dev", "--repo", "/tmp/r", "help-all"]).unwrap();
749        assert_eq!(
750            long.repo.as_deref(),
751            Some(std::path::Path::new("/tmp/r")),
752            "--repo should populate cli.repo"
753        );
754        let short = Cli::try_parse_from(["omni-dev", "-C", "/tmp/r", "help-all"]).unwrap();
755        assert_eq!(
756            short.repo.as_deref(),
757            Some(std::path::Path::new("/tmp/r")),
758            "-C should populate cli.repo"
759        );
760        let absent = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
761        assert!(absent.repo.is_none());
762    }
763
764    /// RULE 3: the repo location is a parameter, never a relocated global.
765    /// `propagate_global_flags` must not export it to any environment variable.
766    #[test]
767    fn repo_flag_is_not_propagated_to_env() {
768        let _g = GlobalFlagsEnvGuard::new();
769        let mut cli = cli_with_defaults();
770        cli.repo = Some(std::path::PathBuf::from("/tmp/some-repo"));
771        cli.propagate_global_flags();
772        assert!(
773            std::env::var("OMNI_DEV_REPO").is_err(),
774            "repo must not be exported to an env var"
775        );
776    }
777
778    #[test]
779    fn propagate_global_flags_sets_models_yaml() {
780        let _g = GlobalFlagsEnvGuard::new();
781        let mut cli = cli_with_defaults();
782        cli.models_yaml = Some(std::path::PathBuf::from("/tmp/custom-models.yaml"));
783        cli.propagate_global_flags();
784        assert_eq!(
785            std::env::var(MODELS_YAML_VAR).ok().as_deref(),
786            Some("/tmp/custom-models.yaml")
787        );
788    }
789
790    #[test]
791    fn parses_profile_flag() {
792        let cli = Cli::try_parse_from(["omni-dev", "--profile", "work", "help-all"]).unwrap();
793        assert_eq!(cli.profile.as_deref(), Some("work"));
794    }
795
796    #[test]
797    fn profile_absent_is_none() {
798        let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
799        assert!(cli.profile.is_none());
800    }
801
802    #[test]
803    fn propagate_global_flags_sets_profile() {
804        let _g = GlobalFlagsEnvGuard::new();
805        let mut cli = cli_with_defaults();
806        cli.profile = Some("work".to_string());
807        cli.propagate_global_flags();
808        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
809    }
810
811    #[test]
812    fn propagate_global_flags_profile_flag_beats_env_var() {
813        let _g = GlobalFlagsEnvGuard::new();
814        std::env::set_var(PROFILE_VAR, "personal");
815        let mut cli = cli_with_defaults();
816        cli.profile = Some("work".to_string());
817        cli.propagate_global_flags();
818        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
819    }
820
821    #[test]
822    fn propagate_global_flags_absent_profile_leaves_env_var() {
823        let _g = GlobalFlagsEnvGuard::new();
824        std::env::set_var(PROFILE_VAR, "personal");
825        cli_with_defaults().propagate_global_flags();
826        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("personal"));
827    }
828
829    // ── validate_active_profile() seam (pure: MapEnv + injected settings loader,
830    // no process env, no disk) ──
831
832    #[test]
833    fn validate_active_profile_ok_and_skips_load_when_no_profile() {
834        use crate::test_support::env::MapEnv;
835        let env = MapEnv::new();
836        let result = Cli::validate_active_profile(&env, || panic!("must not load settings"));
837        assert!(result.is_ok());
838    }
839
840    #[test]
841    fn validate_active_profile_ok_for_known_profile() {
842        use crate::test_support::env::MapEnv;
843        use crate::utils::settings::{Profile, Settings};
844        let env = MapEnv::new().with(PROFILE_VAR, "work");
845        let settings = Settings {
846            profiles: std::iter::once(("work".to_string(), Profile::default())).collect(),
847            ..Default::default()
848        };
849        assert!(Cli::validate_active_profile(&env, || settings).is_ok());
850    }
851
852    #[test]
853    fn validate_active_profile_errors_for_unknown_profile() {
854        use crate::test_support::env::MapEnv;
855        use crate::utils::settings::Settings;
856        let env = MapEnv::new().with(PROFILE_VAR, "wrok");
857        let err = Cli::validate_active_profile(&env, Settings::default)
858            .unwrap_err()
859            .to_string();
860        assert!(err.contains("unknown profile 'wrok'"));
861    }
862
863    #[test]
864    fn load_settings_or_default_never_panics() {
865        // The disk boundary must degrade to defaults rather than panic when
866        // `~/.omni-dev/settings.json` is absent or unreadable. Exercises the
867        // production loader directly, no process env or fixture required.
868        let _settings = Cli::load_settings_or_default();
869    }
870
871    #[test]
872    fn propagate_global_flags_independent_flags_compose() {
873        let _g = GlobalFlagsEnvGuard::new();
874        let mut cli = cli_with_defaults();
875        cli.ai_backend = Some(AiBackend::ClaudeCli);
876        cli.claude_cli_allow_tools = true;
877        cli.claude_cli_allow_mcp = true;
878        cli.claude_cli_max_budget_usd = Some(0.25);
879        cli.propagate_global_flags();
880        assert_eq!(
881            std::env::var(BACKEND_VAR).ok().as_deref(),
882            Some("claude-cli")
883        );
884        assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
885        assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
886        assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
887    }
888}