1use 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 coverage;
13pub mod datadog;
14pub mod git;
15pub mod help;
16pub mod resources;
17pub mod transcript;
18pub mod voice;
19
20#[derive(Clone, Copy, Debug, ValueEnum)]
27#[value(rename_all = "kebab-case")]
28pub enum AiBackend {
29 Default,
32 ClaudeCli,
35}
36
37#[derive(Parser)]
45#[command(name = "omni-dev")]
46#[command(about = "A comprehensive development toolkit", long_about = None)]
47#[command(version)]
48pub struct Cli {
49 #[arg(long, global = true, value_enum)]
53 pub ai_backend: Option<AiBackend>,
54
55 #[arg(long, global = true)]
67 pub claude_cli_allow_tools: bool,
68
69 #[arg(long, global = true)]
81 pub claude_cli_allow_mcp: bool,
82
83 #[arg(long, global = true, value_name = "AMOUNT")]
91 pub claude_cli_max_budget_usd: Option<f64>,
92
93 #[arg(long, global = true, value_name = "PATH")]
98 pub models_yaml: Option<std::path::PathBuf>,
99
100 #[arg(long = "repo", short = 'C', global = true, value_name = "PATH")]
108 pub repo: Option<std::path::PathBuf>,
109
110 #[command(subcommand)]
112 pub command: Commands,
113}
114
115#[derive(Subcommand)]
121pub enum Commands {
122 Ai(ai::AiCommand),
124 Git(git::GitCommand),
126 Commands(commands::CommandsCommand),
128 Config(config::ConfigCommand),
130 Atlassian(atlassian::AtlassianCommand),
132 Browser(browser::BrowserCommand),
134 Datadog(datadog::DatadogCommand),
136 Coverage(coverage::CoverageCommand),
138 Transcript(transcript::TranscriptCommand),
140 Voice(voice::VoiceCommand),
142 Resources(resources::ResourcesCommand),
144 #[command(hide = true)]
146 Completions(completions::CompletionsCommand),
147 #[command(name = "help-all")]
149 HelpAll(help::HelpCommand),
150}
151
152impl Cli {
153 fn propagate_global_flags(&self) {
158 if let Some(backend) = self.ai_backend {
159 match backend {
160 AiBackend::Default => std::env::remove_var("OMNI_DEV_AI_BACKEND"),
161 AiBackend::ClaudeCli => std::env::set_var("OMNI_DEV_AI_BACKEND", "claude-cli"),
162 }
163 }
164
165 if self.claude_cli_allow_tools {
166 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
167 }
168
169 if self.claude_cli_allow_mcp {
170 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
171 }
172
173 if let Some(budget) = self.claude_cli_max_budget_usd {
174 std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
175 }
176
177 if let Some(path) = &self.models_yaml {
178 std::env::set_var("OMNI_DEV_MODELS_YAML", path);
179 }
180 }
181
182 pub async fn execute(self) -> Result<()> {
184 self.propagate_global_flags();
185
186 let Self { repo, command, .. } = self;
189 let repo = repo.as_deref();
190
191 match command {
192 Commands::Ai(ai_cmd) => ai_cmd.execute().await,
193 Commands::Git(git_cmd) => git_cmd.execute(repo).await,
194 Commands::Commands(commands_cmd) => commands_cmd.execute(),
195 Commands::Atlassian(cmd) => cmd.execute().await,
196 Commands::Browser(cmd) => cmd.execute().await,
197 Commands::Datadog(cmd) => cmd.execute().await,
198 Commands::Coverage(cmd) => cmd.execute(repo).await,
199 Commands::Transcript(cmd) => cmd.execute().await,
200 Commands::Voice(cmd) => cmd.execute().await,
201 Commands::Config(config_cmd) => config_cmd.execute(),
202 Commands::Resources(resources_cmd) => resources_cmd.execute(),
203 Commands::Completions(completions_cmd) => completions_cmd.execute(),
204 Commands::HelpAll(help_cmd) => help_cmd.execute(),
205 }
206 }
207}
208
209#[cfg(test)]
210#[allow(clippy::unwrap_used, clippy::expect_used)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn parses_ai_backend_claude_cli() {
216 let cli =
217 Cli::try_parse_from(["omni-dev", "--ai-backend", "claude-cli", "help-all"]).unwrap();
218 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
219 assert!(!cli.claude_cli_allow_tools);
220 }
221
222 #[test]
223 fn parses_ai_backend_default() {
224 let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", "default", "help-all"]).unwrap();
225 assert!(matches!(cli.ai_backend, Some(AiBackend::Default)));
226 }
227
228 #[test]
229 fn parses_ai_backend_absent() {
230 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
231 assert!(cli.ai_backend.is_none());
232 assert!(!cli.claude_cli_allow_tools);
233 assert!(!cli.claude_cli_allow_mcp);
234 }
235
236 #[test]
237 fn parses_claude_cli_allow_tools_flag() {
238 let cli =
239 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
240 assert!(cli.claude_cli_allow_tools);
241 }
242
243 #[test]
244 fn parses_claude_cli_allow_mcp_flag() {
245 let cli = Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
246 assert!(cli.claude_cli_allow_mcp);
247 assert!(!cli.claude_cli_allow_tools);
248 }
249
250 #[test]
251 fn allow_mcp_and_allow_tools_are_independent() {
252 let only_mcp =
253 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
254 assert!(only_mcp.claude_cli_allow_mcp);
255 assert!(!only_mcp.claude_cli_allow_tools);
256
257 let only_tools =
258 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
259 assert!(only_tools.claude_cli_allow_tools);
260 assert!(!only_tools.claude_cli_allow_mcp);
261
262 let both = Cli::try_parse_from([
263 "omni-dev",
264 "--claude-cli-allow-tools",
265 "--claude-cli-allow-mcp",
266 "help-all",
267 ])
268 .unwrap();
269 assert!(both.claude_cli_allow_tools);
270 assert!(both.claude_cli_allow_mcp);
271 }
272
273 #[test]
274 fn global_flags_accepted_after_subcommand() {
275 let cli = Cli::try_parse_from([
277 "omni-dev",
278 "help-all",
279 "--ai-backend",
280 "claude-cli",
281 "--claude-cli-allow-tools",
282 ])
283 .unwrap();
284 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
285 assert!(cli.claude_cli_allow_tools);
286 }
287
288 #[test]
289 fn parses_max_budget_usd_flag() {
290 let cli = Cli::try_parse_from([
291 "omni-dev",
292 "--claude-cli-max-budget-usd",
293 "0.50",
294 "help-all",
295 ])
296 .unwrap();
297 assert_eq!(cli.claude_cli_max_budget_usd, Some(0.50));
298 }
299
300 #[test]
301 fn max_budget_usd_absent_is_none() {
302 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
303 assert!(cli.claude_cli_max_budget_usd.is_none());
304 }
305
306 #[test]
307 fn max_budget_usd_rejects_non_numeric() {
308 let result = Cli::try_parse_from([
309 "omni-dev",
310 "--claude-cli-max-budget-usd",
311 "cheap",
312 "help-all",
313 ]);
314 let Err(err) = result else {
315 panic!("expected parse error for non-numeric budget");
316 };
317 assert!(err.to_string().contains("invalid"));
318 }
319
320 const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
327 const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
328 const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
329 const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
330 const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
331
332 struct GlobalFlagsEnvGuard {
335 _lock: std::sync::MutexGuard<'static, ()>,
336 saved: [(&'static str, Option<String>); 5],
337 }
338
339 impl GlobalFlagsEnvGuard {
340 fn new() -> Self {
341 let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
342 .lock()
343 .unwrap_or_else(std::sync::PoisonError::into_inner);
344 let names = [
345 BACKEND_VAR,
346 ALLOW_TOOLS_VAR,
347 ALLOW_MCP_VAR,
348 MAX_BUDGET_VAR,
349 MODELS_YAML_VAR,
350 ];
351 let saved = names.map(|n| (n, std::env::var(n).ok()));
352 for (n, _) in &saved {
353 std::env::remove_var(n);
354 }
355 Self { _lock: lock, saved }
356 }
357 }
358
359 impl Drop for GlobalFlagsEnvGuard {
360 fn drop(&mut self) {
361 for (n, value) in &self.saved {
362 match value {
363 Some(v) => std::env::set_var(n, v),
364 None => std::env::remove_var(n),
365 }
366 }
367 }
368 }
369
370 fn cli_with_defaults() -> Cli {
371 Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
372 }
373
374 #[test]
375 fn propagate_global_flags_defaults_set_nothing() {
376 let _g = GlobalFlagsEnvGuard::new();
377 cli_with_defaults().propagate_global_flags();
378 assert!(std::env::var(BACKEND_VAR).is_err());
379 assert!(std::env::var(ALLOW_TOOLS_VAR).is_err());
380 assert!(std::env::var(ALLOW_MCP_VAR).is_err());
381 assert!(std::env::var(MAX_BUDGET_VAR).is_err());
382 assert!(std::env::var(MODELS_YAML_VAR).is_err());
383 }
384
385 #[test]
386 fn propagate_global_flags_sets_ai_backend_claude_cli() {
387 let _g = GlobalFlagsEnvGuard::new();
388 let mut cli = cli_with_defaults();
389 cli.ai_backend = Some(AiBackend::ClaudeCli);
390 cli.propagate_global_flags();
391 assert_eq!(
392 std::env::var(BACKEND_VAR).ok().as_deref(),
393 Some("claude-cli")
394 );
395 }
396
397 #[test]
398 fn propagate_global_flags_default_backend_removes_env_var() {
399 let _g = GlobalFlagsEnvGuard::new();
400 std::env::set_var(BACKEND_VAR, "claude-cli");
401 let mut cli = cli_with_defaults();
402 cli.ai_backend = Some(AiBackend::Default);
403 cli.propagate_global_flags();
404 assert!(std::env::var(BACKEND_VAR).is_err());
405 }
406
407 #[test]
408 fn propagate_global_flags_sets_allow_tools() {
409 let _g = GlobalFlagsEnvGuard::new();
410 let mut cli = cli_with_defaults();
411 cli.claude_cli_allow_tools = true;
412 cli.propagate_global_flags();
413 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
414 }
415
416 #[test]
417 fn propagate_global_flags_sets_allow_mcp() {
418 let _g = GlobalFlagsEnvGuard::new();
419 let mut cli = cli_with_defaults();
420 cli.claude_cli_allow_mcp = true;
421 cli.propagate_global_flags();
422 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
423 }
424
425 #[test]
426 fn propagate_global_flags_sets_max_budget_usd() {
427 let _g = GlobalFlagsEnvGuard::new();
428 let mut cli = cli_with_defaults();
429 cli.claude_cli_max_budget_usd = Some(1.5);
430 cli.propagate_global_flags();
431 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
432 }
433
434 #[test]
435 fn parses_models_yaml_flag() {
436 let cli = Cli::try_parse_from([
437 "omni-dev",
438 "--models-yaml",
439 "/tmp/custom-models.yaml",
440 "help-all",
441 ])
442 .unwrap();
443 assert_eq!(
444 cli.models_yaml.as_deref(),
445 Some(std::path::Path::new("/tmp/custom-models.yaml"))
446 );
447 }
448
449 #[test]
450 fn parses_repo_flag_long_and_short() {
451 let long = Cli::try_parse_from(["omni-dev", "--repo", "/tmp/r", "help-all"]).unwrap();
452 assert_eq!(
453 long.repo.as_deref(),
454 Some(std::path::Path::new("/tmp/r")),
455 "--repo should populate cli.repo"
456 );
457 let short = Cli::try_parse_from(["omni-dev", "-C", "/tmp/r", "help-all"]).unwrap();
458 assert_eq!(
459 short.repo.as_deref(),
460 Some(std::path::Path::new("/tmp/r")),
461 "-C should populate cli.repo"
462 );
463 let absent = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
464 assert!(absent.repo.is_none());
465 }
466
467 #[test]
470 fn repo_flag_is_not_propagated_to_env() {
471 let _g = GlobalFlagsEnvGuard::new();
472 let mut cli = cli_with_defaults();
473 cli.repo = Some(std::path::PathBuf::from("/tmp/some-repo"));
474 cli.propagate_global_flags();
475 assert!(
476 std::env::var("OMNI_DEV_REPO").is_err(),
477 "repo must not be exported to an env var"
478 );
479 }
480
481 #[test]
482 fn propagate_global_flags_sets_models_yaml() {
483 let _g = GlobalFlagsEnvGuard::new();
484 let mut cli = cli_with_defaults();
485 cli.models_yaml = Some(std::path::PathBuf::from("/tmp/custom-models.yaml"));
486 cli.propagate_global_flags();
487 assert_eq!(
488 std::env::var(MODELS_YAML_VAR).ok().as_deref(),
489 Some("/tmp/custom-models.yaml")
490 );
491 }
492
493 #[test]
494 fn propagate_global_flags_independent_flags_compose() {
495 let _g = GlobalFlagsEnvGuard::new();
496 let mut cli = cli_with_defaults();
497 cli.ai_backend = Some(AiBackend::ClaudeCli);
498 cli.claude_cli_allow_tools = true;
499 cli.claude_cli_allow_mcp = true;
500 cli.claude_cli_max_budget_usd = Some(0.25);
501 cli.propagate_global_flags();
502 assert_eq!(
503 std::env::var(BACKEND_VAR).ok().as_deref(),
504 Some("claude-cli")
505 );
506 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
507 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
508 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
509 }
510}