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