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;
13#[cfg(unix)]
17pub mod daemon;
18pub mod datadog;
19pub mod git;
20pub mod help;
21pub mod log;
22pub mod resources;
23#[cfg(unix)]
24pub mod snowflake;
25pub mod transcript;
26
27#[derive(Clone, Copy, Debug, ValueEnum)]
34#[value(rename_all = "kebab-case")]
35pub enum AiBackend {
36 Default,
39 ClaudeCli,
42}
43
44#[derive(Parser)]
52#[command(name = "omni-dev")]
53#[command(
54 about = "AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.",
55 long_about = None
56)]
57#[command(version)]
58pub struct Cli {
59 #[arg(long, global = true, value_enum)]
63 pub ai_backend: Option<AiBackend>,
64
65 #[arg(long, global = true)]
77 pub claude_cli_allow_tools: bool,
78
79 #[arg(long, global = true)]
91 pub claude_cli_allow_mcp: bool,
92
93 #[arg(long, global = true, value_name = "AMOUNT")]
101 pub claude_cli_max_budget_usd: Option<f64>,
102
103 #[arg(long, global = true, value_name = "PATH")]
108 pub models_yaml: Option<std::path::PathBuf>,
109
110 #[arg(long = "repo", short = 'C', global = true, value_name = "PATH")]
118 pub repo: Option<std::path::PathBuf>,
119
120 #[command(subcommand)]
122 pub command: Commands,
123}
124
125#[derive(Subcommand)]
131pub enum Commands {
132 Ai(ai::AiCommand),
134 Git(git::GitCommand),
136 Commands(commands::CommandsCommand),
138 Config(config::ConfigCommand),
140 Atlassian(atlassian::AtlassianCommand),
142 Browser(browser::BrowserCommand),
144 #[cfg(unix)]
146 Daemon(daemon::DaemonCommand),
147 Datadog(datadog::DatadogCommand),
149 #[cfg(unix)]
151 Snowflake(snowflake::SnowflakeCommand),
152 Coverage(coverage::CoverageCommand),
154 Transcript(transcript::TranscriptCommand),
156 Log(log::LogCommand),
158 Resources(resources::ResourcesCommand),
160 #[command(hide = true)]
162 Completions(completions::CompletionsCommand),
163 #[command(name = "help-all")]
165 HelpAll(help::HelpCommand),
166}
167
168impl Cli {
169 fn propagate_global_flags(&self) {
174 if let Some(backend) = self.ai_backend {
175 match backend {
176 AiBackend::Default => std::env::remove_var("OMNI_DEV_AI_BACKEND"),
177 AiBackend::ClaudeCli => std::env::set_var("OMNI_DEV_AI_BACKEND", "claude-cli"),
178 }
179 }
180
181 if self.claude_cli_allow_tools {
182 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
183 }
184
185 if self.claude_cli_allow_mcp {
186 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
187 }
188
189 if let Some(budget) = self.claude_cli_max_budget_usd {
190 std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
191 }
192
193 if let Some(path) = &self.models_yaml {
194 std::env::set_var("OMNI_DEV_MODELS_YAML", path);
195 }
196 }
197
198 pub async fn execute(self) -> Result<()> {
200 self.propagate_global_flags();
201
202 let Self { repo, command, .. } = self;
205 let repo = repo.as_deref();
206
207 match command {
208 Commands::Ai(ai_cmd) => ai_cmd.execute().await,
209 Commands::Git(git_cmd) => git_cmd.execute(repo).await,
210 Commands::Commands(commands_cmd) => commands_cmd.execute(),
211 Commands::Atlassian(cmd) => cmd.execute().await,
212 Commands::Browser(cmd) => cmd.execute().await,
213 #[cfg(unix)]
214 Commands::Daemon(cmd) => cmd.execute().await,
215 Commands::Datadog(cmd) => cmd.execute().await,
216 #[cfg(unix)]
217 Commands::Snowflake(cmd) => cmd.execute().await,
218 Commands::Coverage(cmd) => cmd.execute(repo).await,
219 Commands::Transcript(cmd) => cmd.execute().await,
220 Commands::Log(log_cmd) => log_cmd.execute(),
221 Commands::Config(config_cmd) => config_cmd.execute(),
222 Commands::Resources(resources_cmd) => resources_cmd.execute(),
223 Commands::Completions(completions_cmd) => completions_cmd.execute(),
224 Commands::HelpAll(help_cmd) => help_cmd.execute(),
225 }
226 }
227}
228
229#[cfg(all(target_os = "macos", feature = "menu-bar"))]
230impl Cli {
231 pub fn menu_bar_run_config(&self) -> Option<Result<crate::daemon::DaemonRunConfig>> {
236 match &self.command {
237 Commands::Daemon(daemon::DaemonCommand {
238 command: daemon::DaemonSubcommands::Run(run),
239 }) if !run.no_menu => Some(run.clone().into_run_config()),
240 _ => None,
241 }
242 }
243}
244
245#[cfg(test)]
246#[allow(clippy::unwrap_used, clippy::expect_used)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn parses_ai_backend_claude_cli() {
252 let cli =
253 Cli::try_parse_from(["omni-dev", "--ai-backend", "claude-cli", "help-all"]).unwrap();
254 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
255 assert!(!cli.claude_cli_allow_tools);
256 }
257
258 #[test]
259 fn parses_ai_backend_default() {
260 let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", "default", "help-all"]).unwrap();
261 assert!(matches!(cli.ai_backend, Some(AiBackend::Default)));
262 }
263
264 #[test]
265 fn parses_ai_backend_absent() {
266 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
267 assert!(cli.ai_backend.is_none());
268 assert!(!cli.claude_cli_allow_tools);
269 assert!(!cli.claude_cli_allow_mcp);
270 }
271
272 #[test]
273 fn parses_claude_cli_allow_tools_flag() {
274 let cli =
275 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
276 assert!(cli.claude_cli_allow_tools);
277 }
278
279 #[test]
280 fn parses_claude_cli_allow_mcp_flag() {
281 let cli = Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
282 assert!(cli.claude_cli_allow_mcp);
283 assert!(!cli.claude_cli_allow_tools);
284 }
285
286 #[test]
287 fn allow_mcp_and_allow_tools_are_independent() {
288 let only_mcp =
289 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
290 assert!(only_mcp.claude_cli_allow_mcp);
291 assert!(!only_mcp.claude_cli_allow_tools);
292
293 let only_tools =
294 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
295 assert!(only_tools.claude_cli_allow_tools);
296 assert!(!only_tools.claude_cli_allow_mcp);
297
298 let both = Cli::try_parse_from([
299 "omni-dev",
300 "--claude-cli-allow-tools",
301 "--claude-cli-allow-mcp",
302 "help-all",
303 ])
304 .unwrap();
305 assert!(both.claude_cli_allow_tools);
306 assert!(both.claude_cli_allow_mcp);
307 }
308
309 #[test]
310 fn global_flags_accepted_after_subcommand() {
311 let cli = Cli::try_parse_from([
313 "omni-dev",
314 "help-all",
315 "--ai-backend",
316 "claude-cli",
317 "--claude-cli-allow-tools",
318 ])
319 .unwrap();
320 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
321 assert!(cli.claude_cli_allow_tools);
322 }
323
324 #[test]
325 fn parses_max_budget_usd_flag() {
326 let cli = Cli::try_parse_from([
327 "omni-dev",
328 "--claude-cli-max-budget-usd",
329 "0.50",
330 "help-all",
331 ])
332 .unwrap();
333 assert_eq!(cli.claude_cli_max_budget_usd, Some(0.50));
334 }
335
336 #[test]
337 fn max_budget_usd_absent_is_none() {
338 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
339 assert!(cli.claude_cli_max_budget_usd.is_none());
340 }
341
342 #[test]
343 fn max_budget_usd_rejects_non_numeric() {
344 let result = Cli::try_parse_from([
345 "omni-dev",
346 "--claude-cli-max-budget-usd",
347 "cheap",
348 "help-all",
349 ]);
350 let Err(err) = result else {
351 panic!("expected parse error for non-numeric budget");
352 };
353 assert!(err.to_string().contains("invalid"));
354 }
355
356 const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
363 const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
364 const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
365 const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
366 const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
367
368 struct GlobalFlagsEnvGuard {
371 _lock: std::sync::MutexGuard<'static, ()>,
372 saved: [(&'static str, Option<String>); 5],
373 }
374
375 impl GlobalFlagsEnvGuard {
376 fn new() -> Self {
377 let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
378 .lock()
379 .unwrap_or_else(std::sync::PoisonError::into_inner);
380 let names = [
381 BACKEND_VAR,
382 ALLOW_TOOLS_VAR,
383 ALLOW_MCP_VAR,
384 MAX_BUDGET_VAR,
385 MODELS_YAML_VAR,
386 ];
387 let saved = names.map(|n| (n, std::env::var(n).ok()));
388 for (n, _) in &saved {
389 std::env::remove_var(n);
390 }
391 Self { _lock: lock, saved }
392 }
393 }
394
395 impl Drop for GlobalFlagsEnvGuard {
396 fn drop(&mut self) {
397 for (n, value) in &self.saved {
398 match value {
399 Some(v) => std::env::set_var(n, v),
400 None => std::env::remove_var(n),
401 }
402 }
403 }
404 }
405
406 fn cli_with_defaults() -> Cli {
407 Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
408 }
409
410 #[test]
411 fn propagate_global_flags_defaults_set_nothing() {
412 let _g = GlobalFlagsEnvGuard::new();
413 cli_with_defaults().propagate_global_flags();
414 assert!(std::env::var(BACKEND_VAR).is_err());
415 assert!(std::env::var(ALLOW_TOOLS_VAR).is_err());
416 assert!(std::env::var(ALLOW_MCP_VAR).is_err());
417 assert!(std::env::var(MAX_BUDGET_VAR).is_err());
418 assert!(std::env::var(MODELS_YAML_VAR).is_err());
419 }
420
421 #[test]
422 fn propagate_global_flags_sets_ai_backend_claude_cli() {
423 let _g = GlobalFlagsEnvGuard::new();
424 let mut cli = cli_with_defaults();
425 cli.ai_backend = Some(AiBackend::ClaudeCli);
426 cli.propagate_global_flags();
427 assert_eq!(
428 std::env::var(BACKEND_VAR).ok().as_deref(),
429 Some("claude-cli")
430 );
431 }
432
433 #[test]
434 fn propagate_global_flags_default_backend_removes_env_var() {
435 let _g = GlobalFlagsEnvGuard::new();
436 std::env::set_var(BACKEND_VAR, "claude-cli");
437 let mut cli = cli_with_defaults();
438 cli.ai_backend = Some(AiBackend::Default);
439 cli.propagate_global_flags();
440 assert!(std::env::var(BACKEND_VAR).is_err());
441 }
442
443 #[test]
444 fn propagate_global_flags_sets_allow_tools() {
445 let _g = GlobalFlagsEnvGuard::new();
446 let mut cli = cli_with_defaults();
447 cli.claude_cli_allow_tools = true;
448 cli.propagate_global_flags();
449 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
450 }
451
452 #[test]
453 fn propagate_global_flags_sets_allow_mcp() {
454 let _g = GlobalFlagsEnvGuard::new();
455 let mut cli = cli_with_defaults();
456 cli.claude_cli_allow_mcp = true;
457 cli.propagate_global_flags();
458 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
459 }
460
461 #[test]
462 fn propagate_global_flags_sets_max_budget_usd() {
463 let _g = GlobalFlagsEnvGuard::new();
464 let mut cli = cli_with_defaults();
465 cli.claude_cli_max_budget_usd = Some(1.5);
466 cli.propagate_global_flags();
467 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
468 }
469
470 #[test]
471 fn parses_models_yaml_flag() {
472 let cli = Cli::try_parse_from([
473 "omni-dev",
474 "--models-yaml",
475 "/tmp/custom-models.yaml",
476 "help-all",
477 ])
478 .unwrap();
479 assert_eq!(
480 cli.models_yaml.as_deref(),
481 Some(std::path::Path::new("/tmp/custom-models.yaml"))
482 );
483 }
484
485 #[test]
486 fn parses_repo_flag_long_and_short() {
487 let long = Cli::try_parse_from(["omni-dev", "--repo", "/tmp/r", "help-all"]).unwrap();
488 assert_eq!(
489 long.repo.as_deref(),
490 Some(std::path::Path::new("/tmp/r")),
491 "--repo should populate cli.repo"
492 );
493 let short = Cli::try_parse_from(["omni-dev", "-C", "/tmp/r", "help-all"]).unwrap();
494 assert_eq!(
495 short.repo.as_deref(),
496 Some(std::path::Path::new("/tmp/r")),
497 "-C should populate cli.repo"
498 );
499 let absent = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
500 assert!(absent.repo.is_none());
501 }
502
503 #[test]
506 fn repo_flag_is_not_propagated_to_env() {
507 let _g = GlobalFlagsEnvGuard::new();
508 let mut cli = cli_with_defaults();
509 cli.repo = Some(std::path::PathBuf::from("/tmp/some-repo"));
510 cli.propagate_global_flags();
511 assert!(
512 std::env::var("OMNI_DEV_REPO").is_err(),
513 "repo must not be exported to an env var"
514 );
515 }
516
517 #[test]
518 fn propagate_global_flags_sets_models_yaml() {
519 let _g = GlobalFlagsEnvGuard::new();
520 let mut cli = cli_with_defaults();
521 cli.models_yaml = Some(std::path::PathBuf::from("/tmp/custom-models.yaml"));
522 cli.propagate_global_flags();
523 assert_eq!(
524 std::env::var(MODELS_YAML_VAR).ok().as_deref(),
525 Some("/tmp/custom-models.yaml")
526 );
527 }
528
529 #[test]
530 fn propagate_global_flags_independent_flags_compose() {
531 let _g = GlobalFlagsEnvGuard::new();
532 let mut cli = cli_with_defaults();
533 cli.ai_backend = Some(AiBackend::ClaudeCli);
534 cli.claude_cli_allow_tools = true;
535 cli.claude_cli_allow_mcp = true;
536 cli.claude_cli_max_budget_usd = Some(0.25);
537 cli.propagate_global_flags();
538 assert_eq!(
539 std::env::var(BACKEND_VAR).ok().as_deref(),
540 Some("claude-cli")
541 );
542 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
543 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
544 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
545 }
546}