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