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