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