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