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