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