Skip to main content

omni_dev/
cli.rs

1//! CLI interface for omni-dev.
2
3use anyhow::Result;
4use clap::{Parser, Subcommand, ValueEnum};
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 git;
20pub mod help;
21pub mod log;
22pub mod resources;
23#[cfg(unix)]
24pub mod snowflake;
25pub mod transcript;
26
27/// CLI-side selector for the AI backend dispatched by
28/// [`create_default_claude_client`][crate::claude::client::create_default_claude_client].
29///
30/// `None` (flag omitted) preserves env-var dispatch; an explicit value
31/// overrides `OMNI_DEV_AI_BACKEND`. Propagation to the env var happens
32/// in `Cli::propagate_global_flags`.
33#[derive(Clone, Copy, Debug, ValueEnum)]
34#[value(rename_all = "kebab-case")]
35pub enum AiBackend {
36    /// Default backend dispatch (HTTP to Anthropic/Bedrock/OpenAI/Ollama via
37    /// the existing `USE_*` env vars).
38    Default,
39    /// Shell out to the `claude -p` CLI (reuses an existing Claude Code auth
40    /// session). Equivalent to setting `OMNI_DEV_AI_BACKEND=claude-cli`.
41    ClaudeCli,
42}
43
44/// Top-level clap-derived CLI struct; the library entry point for embedding
45/// omni-dev programmatically.
46///
47/// Global flags (`--ai-backend`, `--claude-cli-allow-tools`,
48/// `--claude-cli-allow-mcp`, `--claude-cli-max-budget-usd`, `--models-yaml`)
49/// are propagated to environment variables read by downstream factories
50/// before dispatching to a [`Commands`] variant.
51#[derive(Parser)]
52#[command(name = "omni-dev")]
53#[command(
54    about = "AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.",
55    long_about = None
56)]
57#[command(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.
62    #[arg(long, global = true, value_enum)]
63    pub ai_backend: Option<AiBackend>,
64
65    /// Weakens the `claude-cli` sandbox by allowing the nested `claude -p`
66    /// session to use its default built-in tools (Read, Edit, Write, Bash,
67    /// Glob, Grep).
68    ///
69    /// **Only use for deliberately tool-capable use cases.** By default the
70    /// nested session runs with `--tools ""` and cannot touch the
71    /// file system. This flag removes that guard. Equivalent to setting
72    /// `OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS=true`. Independent of
73    /// `--claude-cli-allow-mcp`.
74    ///
75    /// Ignored when `--ai-backend` is not `claude-cli`.
76    #[arg(long, global = true)]
77    pub claude_cli_allow_tools: bool,
78
79    /// Weakens the `claude-cli` sandbox by allowing the nested `claude -p`
80    /// session to load MCP servers from `~/.claude/settings.json`.
81    ///
82    /// **Only use deliberately.** MCP servers commonly hold OAuth tokens
83    /// (Gmail, Drive, Slack) and may be arbitrary network-attached services;
84    /// enabling this exposes them to the nested session. By default the
85    /// session runs with `--strict-mcp-config` and no MCP servers load.
86    /// Equivalent to setting `OMNI_DEV_CLAUDE_CLI_ALLOW_MCP=true`.
87    /// Independent of `--claude-cli-allow-tools`.
88    ///
89    /// Ignored when `--ai-backend` is not `claude-cli`.
90    #[arg(long, global = true)]
91    pub claude_cli_allow_mcp: bool,
92
93    /// Per-invocation spending cap in USD for the `claude-cli` backend.
94    ///
95    /// Forwarded to `claude -p --max-budget-usd`. When the nested session
96    /// exceeds this budget it aborts rather than running away with cost.
97    /// Equivalent to setting `OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD`.
98    ///
99    /// Ignored when `--ai-backend` is not `claude-cli`.
100    #[arg(long, global = true, value_name = "AMOUNT")]
101    pub claude_cli_max_budget_usd: Option<f64>,
102
103    /// Path to a single user-side `models.yaml` that short-circuits the
104    /// standard `./.omni-dev/models.yaml` and `~/.omni-dev/models.yaml`
105    /// lookup. The file is still merged over the embedded catalog.
106    /// Equivalent to setting `OMNI_DEV_MODELS_YAML`.
107    #[arg(long, global = true, value_name = "PATH")]
108    pub models_yaml: Option<std::path::PathBuf>,
109
110    /// Run as if omni-dev was started in `<PATH>` instead of the current
111    /// working directory.
112    ///
113    /// Resolved exactly once here and threaded explicitly to each command as a
114    /// parameter; deliberately **not** propagated to an environment variable
115    /// (unlike the flags above) so the repo location never becomes an ambient
116    /// global. Mirrors `git -C`.
117    #[arg(long = "repo", short = 'C', global = true, value_name = "PATH")]
118    pub repo: Option<std::path::PathBuf>,
119
120    /// The main command to execute.
121    #[command(subcommand)]
122    pub command: Commands,
123}
124
125/// Top-level subcommand dispatch enum.
126///
127/// Each variant wraps the subcommand-specific argument struct (e.g.
128/// [`ai::AiCommand`], [`git::GitCommand`], [`atlassian::AtlassianCommand`]);
129/// follow the variant's payload type for the per-command argument surface.
130#[derive(Subcommand)]
131pub enum Commands {
132    /// AI operations.
133    Ai(ai::AiCommand),
134    /// Git-related operations.
135    Git(git::GitCommand),
136    /// Command template management.
137    Commands(commands::CommandsCommand),
138    /// Configuration and model information.
139    Config(config::ConfigCommand),
140    /// Atlassian: JIRA and Confluence operations.
141    Atlassian(atlassian::AtlassianCommand),
142    /// Browser bridge: drive authenticated requests through a browser tab.
143    Browser(browser::BrowserCommand),
144    /// Daemon: host long-lived services (e.g. the browser bridge).
145    #[cfg(unix)]
146    Daemon(daemon::DaemonCommand),
147    /// Datadog: read-only API operations.
148    Datadog(datadog::DatadogCommand),
149    /// Snowflake: run arbitrary SQL through the daemon's multiplexed sessions.
150    #[cfg(unix)]
151    Snowflake(snowflake::SnowflakeCommand),
152    /// Coverage: diff/patch coverage analysis for PR comments.
153    Coverage(coverage::CoverageCommand),
154    /// Transcript and caption fetching from media platforms.
155    Transcript(transcript::TranscriptCommand),
156    /// Search the local invocation + HTTP request log.
157    Log(log::LogCommand),
158    /// Embedded reference resources (specs, etc.).
159    Resources(resources::ResourcesCommand),
160    /// Generates shell completion scripts.
161    #[command(hide = true)]
162    Completions(completions::CompletionsCommand),
163    /// Displays comprehensive help for all commands.
164    #[command(name = "help-all")]
165    HelpAll(help::HelpCommand),
166}
167
168impl Cli {
169    /// Forwards global flags to the env vars that downstream factories
170    /// read. Extracted so it can be unit-tested without invoking a real
171    /// subcommand. Setting the env vars here (rather than threading extra
172    /// arguments through every command) keeps factory signatures stable.
173    fn propagate_global_flags(&self) {
174        if let Some(backend) = self.ai_backend {
175            match backend {
176                AiBackend::Default => std::env::remove_var("OMNI_DEV_AI_BACKEND"),
177                AiBackend::ClaudeCli => std::env::set_var("OMNI_DEV_AI_BACKEND", "claude-cli"),
178            }
179        }
180
181        if self.claude_cli_allow_tools {
182            std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
183        }
184
185        if self.claude_cli_allow_mcp {
186            std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
187        }
188
189        if let Some(budget) = self.claude_cli_max_budget_usd {
190            std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
191        }
192
193        if let Some(path) = &self.models_yaml {
194            std::env::set_var("OMNI_DEV_MODELS_YAML", path);
195        }
196    }
197
198    /// Executes the CLI command.
199    pub async fn execute(self) -> Result<()> {
200        self.propagate_global_flags();
201
202        // Resolve the repo location exactly once at this boundary, then thread
203        // it explicitly into each command. Nothing deeper reads the ambient CWD.
204        let Self { repo, command, .. } = self;
205        let repo = repo.as_deref();
206
207        match command {
208            Commands::Ai(ai_cmd) => ai_cmd.execute().await,
209            Commands::Git(git_cmd) => git_cmd.execute(repo).await,
210            Commands::Commands(commands_cmd) => commands_cmd.execute(),
211            Commands::Atlassian(cmd) => cmd.execute().await,
212            Commands::Browser(cmd) => cmd.execute().await,
213            #[cfg(unix)]
214            Commands::Daemon(cmd) => cmd.execute().await,
215            Commands::Datadog(cmd) => cmd.execute().await,
216            #[cfg(unix)]
217            Commands::Snowflake(cmd) => cmd.execute().await,
218            Commands::Coverage(cmd) => cmd.execute(repo).await,
219            Commands::Transcript(cmd) => cmd.execute().await,
220            Commands::Log(log_cmd) => log_cmd.execute(),
221            Commands::Config(config_cmd) => config_cmd.execute(),
222            Commands::Resources(resources_cmd) => resources_cmd.execute(),
223            Commands::Completions(completions_cmd) => completions_cmd.execute(),
224            Commands::HelpAll(help_cmd) => help_cmd.execute(),
225        }
226    }
227}
228
229#[cfg(all(target_os = "macos", feature = "menu-bar"))]
230impl Cli {
231    /// If this invocation is `daemon run` without `--no-menu`, resolves the
232    /// daemon configuration so `main` can host it with a macOS menu-bar tray on
233    /// the main thread. Returns `None` for every other invocation (which runs
234    /// normally on the async runtime).
235    pub fn menu_bar_run_config(&self) -> Option<Result<crate::daemon::DaemonRunConfig>> {
236        match &self.command {
237            Commands::Daemon(daemon::DaemonCommand {
238                command: daemon::DaemonSubcommands::Run(run),
239            }) if !run.no_menu => Some(run.clone().into_run_config()),
240            _ => None,
241        }
242    }
243}
244
245#[cfg(test)]
246#[allow(clippy::unwrap_used, clippy::expect_used)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn parses_ai_backend_claude_cli() {
252        let cli =
253            Cli::try_parse_from(["omni-dev", "--ai-backend", "claude-cli", "help-all"]).unwrap();
254        assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
255        assert!(!cli.claude_cli_allow_tools);
256    }
257
258    #[test]
259    fn parses_ai_backend_default() {
260        let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", "default", "help-all"]).unwrap();
261        assert!(matches!(cli.ai_backend, Some(AiBackend::Default)));
262    }
263
264    #[test]
265    fn parses_ai_backend_absent() {
266        let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
267        assert!(cli.ai_backend.is_none());
268        assert!(!cli.claude_cli_allow_tools);
269        assert!(!cli.claude_cli_allow_mcp);
270    }
271
272    #[test]
273    fn parses_claude_cli_allow_tools_flag() {
274        let cli =
275            Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
276        assert!(cli.claude_cli_allow_tools);
277    }
278
279    #[test]
280    fn parses_claude_cli_allow_mcp_flag() {
281        let cli = Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
282        assert!(cli.claude_cli_allow_mcp);
283        assert!(!cli.claude_cli_allow_tools);
284    }
285
286    #[test]
287    fn allow_mcp_and_allow_tools_are_independent() {
288        let only_mcp =
289            Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
290        assert!(only_mcp.claude_cli_allow_mcp);
291        assert!(!only_mcp.claude_cli_allow_tools);
292
293        let only_tools =
294            Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
295        assert!(only_tools.claude_cli_allow_tools);
296        assert!(!only_tools.claude_cli_allow_mcp);
297
298        let both = Cli::try_parse_from([
299            "omni-dev",
300            "--claude-cli-allow-tools",
301            "--claude-cli-allow-mcp",
302            "help-all",
303        ])
304        .unwrap();
305        assert!(both.claude_cli_allow_tools);
306        assert!(both.claude_cli_allow_mcp);
307    }
308
309    #[test]
310    fn global_flags_accepted_after_subcommand() {
311        // clap global = true allows the flag before or after the subcommand.
312        let cli = Cli::try_parse_from([
313            "omni-dev",
314            "help-all",
315            "--ai-backend",
316            "claude-cli",
317            "--claude-cli-allow-tools",
318        ])
319        .unwrap();
320        assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
321        assert!(cli.claude_cli_allow_tools);
322    }
323
324    #[test]
325    fn parses_max_budget_usd_flag() {
326        let cli = Cli::try_parse_from([
327            "omni-dev",
328            "--claude-cli-max-budget-usd",
329            "0.50",
330            "help-all",
331        ])
332        .unwrap();
333        assert_eq!(cli.claude_cli_max_budget_usd, Some(0.50));
334    }
335
336    #[test]
337    fn max_budget_usd_absent_is_none() {
338        let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
339        assert!(cli.claude_cli_max_budget_usd.is_none());
340    }
341
342    #[test]
343    fn max_budget_usd_rejects_non_numeric() {
344        let result = Cli::try_parse_from([
345            "omni-dev",
346            "--claude-cli-max-budget-usd",
347            "cheap",
348            "help-all",
349        ]);
350        let Err(err) = result else {
351            panic!("expected parse error for non-numeric budget");
352        };
353        assert!(err.to_string().contains("invalid"));
354    }
355
356    // ── propagate_global_flags() tests ──
357    //
358    // These tests mutate process-global env vars, so they serialise on
359    // `crate::claude::ai::claude_cli::CLI_ENV_LOCK` (shared with claude-cli's
360    // own env-mutating tests to avoid cross-module races).
361
362    const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
363    const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
364    const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
365    const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
366    const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
367
368    /// Locks the shared mutex and snapshots/restores every env var
369    /// `propagate_global_flags` may touch.
370    struct GlobalFlagsEnvGuard {
371        _lock: std::sync::MutexGuard<'static, ()>,
372        saved: [(&'static str, Option<String>); 5],
373    }
374
375    impl GlobalFlagsEnvGuard {
376        fn new() -> Self {
377            let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
378                .lock()
379                .unwrap_or_else(std::sync::PoisonError::into_inner);
380            let names = [
381                BACKEND_VAR,
382                ALLOW_TOOLS_VAR,
383                ALLOW_MCP_VAR,
384                MAX_BUDGET_VAR,
385                MODELS_YAML_VAR,
386            ];
387            let saved = names.map(|n| (n, std::env::var(n).ok()));
388            for (n, _) in &saved {
389                std::env::remove_var(n);
390            }
391            Self { _lock: lock, saved }
392        }
393    }
394
395    impl Drop for GlobalFlagsEnvGuard {
396        fn drop(&mut self) {
397            for (n, value) in &self.saved {
398                match value {
399                    Some(v) => std::env::set_var(n, v),
400                    None => std::env::remove_var(n),
401                }
402            }
403        }
404    }
405
406    fn cli_with_defaults() -> Cli {
407        Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
408    }
409
410    #[test]
411    fn propagate_global_flags_defaults_set_nothing() {
412        let _g = GlobalFlagsEnvGuard::new();
413        cli_with_defaults().propagate_global_flags();
414        assert!(std::env::var(BACKEND_VAR).is_err());
415        assert!(std::env::var(ALLOW_TOOLS_VAR).is_err());
416        assert!(std::env::var(ALLOW_MCP_VAR).is_err());
417        assert!(std::env::var(MAX_BUDGET_VAR).is_err());
418        assert!(std::env::var(MODELS_YAML_VAR).is_err());
419    }
420
421    #[test]
422    fn propagate_global_flags_sets_ai_backend_claude_cli() {
423        let _g = GlobalFlagsEnvGuard::new();
424        let mut cli = cli_with_defaults();
425        cli.ai_backend = Some(AiBackend::ClaudeCli);
426        cli.propagate_global_flags();
427        assert_eq!(
428            std::env::var(BACKEND_VAR).ok().as_deref(),
429            Some("claude-cli")
430        );
431    }
432
433    #[test]
434    fn propagate_global_flags_default_backend_removes_env_var() {
435        let _g = GlobalFlagsEnvGuard::new();
436        std::env::set_var(BACKEND_VAR, "claude-cli");
437        let mut cli = cli_with_defaults();
438        cli.ai_backend = Some(AiBackend::Default);
439        cli.propagate_global_flags();
440        assert!(std::env::var(BACKEND_VAR).is_err());
441    }
442
443    #[test]
444    fn propagate_global_flags_sets_allow_tools() {
445        let _g = GlobalFlagsEnvGuard::new();
446        let mut cli = cli_with_defaults();
447        cli.claude_cli_allow_tools = true;
448        cli.propagate_global_flags();
449        assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
450    }
451
452    #[test]
453    fn propagate_global_flags_sets_allow_mcp() {
454        let _g = GlobalFlagsEnvGuard::new();
455        let mut cli = cli_with_defaults();
456        cli.claude_cli_allow_mcp = true;
457        cli.propagate_global_flags();
458        assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
459    }
460
461    #[test]
462    fn propagate_global_flags_sets_max_budget_usd() {
463        let _g = GlobalFlagsEnvGuard::new();
464        let mut cli = cli_with_defaults();
465        cli.claude_cli_max_budget_usd = Some(1.5);
466        cli.propagate_global_flags();
467        assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
468    }
469
470    #[test]
471    fn parses_models_yaml_flag() {
472        let cli = Cli::try_parse_from([
473            "omni-dev",
474            "--models-yaml",
475            "/tmp/custom-models.yaml",
476            "help-all",
477        ])
478        .unwrap();
479        assert_eq!(
480            cli.models_yaml.as_deref(),
481            Some(std::path::Path::new("/tmp/custom-models.yaml"))
482        );
483    }
484
485    #[test]
486    fn parses_repo_flag_long_and_short() {
487        let long = Cli::try_parse_from(["omni-dev", "--repo", "/tmp/r", "help-all"]).unwrap();
488        assert_eq!(
489            long.repo.as_deref(),
490            Some(std::path::Path::new("/tmp/r")),
491            "--repo should populate cli.repo"
492        );
493        let short = Cli::try_parse_from(["omni-dev", "-C", "/tmp/r", "help-all"]).unwrap();
494        assert_eq!(
495            short.repo.as_deref(),
496            Some(std::path::Path::new("/tmp/r")),
497            "-C should populate cli.repo"
498        );
499        let absent = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
500        assert!(absent.repo.is_none());
501    }
502
503    /// RULE 3: the repo location is a parameter, never a relocated global.
504    /// `propagate_global_flags` must not export it to any environment variable.
505    #[test]
506    fn repo_flag_is_not_propagated_to_env() {
507        let _g = GlobalFlagsEnvGuard::new();
508        let mut cli = cli_with_defaults();
509        cli.repo = Some(std::path::PathBuf::from("/tmp/some-repo"));
510        cli.propagate_global_flags();
511        assert!(
512            std::env::var("OMNI_DEV_REPO").is_err(),
513            "repo must not be exported to an env var"
514        );
515    }
516
517    #[test]
518    fn propagate_global_flags_sets_models_yaml() {
519        let _g = GlobalFlagsEnvGuard::new();
520        let mut cli = cli_with_defaults();
521        cli.models_yaml = Some(std::path::PathBuf::from("/tmp/custom-models.yaml"));
522        cli.propagate_global_flags();
523        assert_eq!(
524            std::env::var(MODELS_YAML_VAR).ok().as_deref(),
525            Some("/tmp/custom-models.yaml")
526        );
527    }
528
529    #[test]
530    fn propagate_global_flags_independent_flags_compose() {
531        let _g = GlobalFlagsEnvGuard::new();
532        let mut cli = cli_with_defaults();
533        cli.ai_backend = Some(AiBackend::ClaudeCli);
534        cli.claude_cli_allow_tools = true;
535        cli.claude_cli_allow_mcp = true;
536        cli.claude_cli_max_budget_usd = Some(0.25);
537        cli.propagate_global_flags();
538        assert_eq!(
539            std::env::var(BACKEND_VAR).ok().as_deref(),
540            Some("claude-cli")
541        );
542        assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
543        assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
544        assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
545    }
546}