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