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