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