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