1use 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#[derive(Clone, Copy, Debug, ValueEnum)]
16#[value(rename_all = "kebab-case")]
17pub enum AiBackend {
18 Default,
21 ClaudeCli,
24}
25
26#[derive(Parser)]
28#[command(name = "omni-dev")]
29#[command(about = "A comprehensive development toolkit", long_about = None)]
30#[command(version)]
31pub struct Cli {
32 #[arg(long, global = true, value_enum)]
36 pub ai_backend: Option<AiBackend>,
37
38 #[arg(long, global = true)]
50 pub claude_cli_allow_tools: bool,
51
52 #[arg(long, global = true)]
64 pub claude_cli_allow_mcp: bool,
65
66 #[arg(long, global = true, value_name = "AMOUNT")]
74 pub claude_cli_max_budget_usd: Option<f64>,
75
76 #[command(subcommand)]
78 pub command: Commands,
79}
80
81#[derive(Subcommand)]
83pub enum Commands {
84 Ai(ai::AiCommand),
86 Git(git::GitCommand),
88 Commands(commands::CommandsCommand),
90 Config(config::ConfigCommand),
92 Atlassian(atlassian::AtlassianCommand),
94 Datadog(datadog::DatadogCommand),
96 #[command(name = "help-all")]
98 HelpAll(help::HelpCommand),
99}
100
101impl Cli {
102 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 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 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 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 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}