use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
pub mod ai;
pub mod atlassian;
pub mod commands;
pub mod config;
pub mod datadog;
pub mod git;
pub mod help;
#[derive(Clone, Copy, Debug, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum AiBackend {
Default,
ClaudeCli,
}
#[derive(Parser)]
#[command(name = "omni-dev")]
#[command(about = "A comprehensive development toolkit", long_about = None)]
#[command(version)]
pub struct Cli {
#[arg(long, global = true, value_enum)]
pub ai_backend: Option<AiBackend>,
#[arg(long, global = true)]
pub claude_cli_allow_tools: bool,
#[arg(long, global = true)]
pub claude_cli_allow_mcp: bool,
#[arg(long, global = true, value_name = "AMOUNT")]
pub claude_cli_max_budget_usd: Option<f64>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
Ai(ai::AiCommand),
Git(git::GitCommand),
Commands(commands::CommandsCommand),
Config(config::ConfigCommand),
Atlassian(atlassian::AtlassianCommand),
Datadog(datadog::DatadogCommand),
#[command(name = "help-all")]
HelpAll(help::HelpCommand),
}
impl Cli {
fn propagate_global_flags(&self) {
if let Some(backend) = self.ai_backend {
match backend {
AiBackend::Default => std::env::remove_var("OMNI_DEV_AI_BACKEND"),
AiBackend::ClaudeCli => std::env::set_var("OMNI_DEV_AI_BACKEND", "claude-cli"),
}
}
if self.claude_cli_allow_tools {
std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
}
if self.claude_cli_allow_mcp {
std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
}
if let Some(budget) = self.claude_cli_max_budget_usd {
std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
}
}
pub async fn execute(self) -> Result<()> {
self.propagate_global_flags();
match self.command {
Commands::Ai(ai_cmd) => ai_cmd.execute().await,
Commands::Git(git_cmd) => git_cmd.execute().await,
Commands::Commands(commands_cmd) => commands_cmd.execute(),
Commands::Atlassian(cmd) => cmd.execute().await,
Commands::Datadog(cmd) => cmd.execute().await,
Commands::Config(config_cmd) => config_cmd.execute(),
Commands::HelpAll(help_cmd) => help_cmd.execute(),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn parses_ai_backend_claude_cli() {
let cli =
Cli::try_parse_from(["omni-dev", "--ai-backend", "claude-cli", "help-all"]).unwrap();
assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
assert!(!cli.claude_cli_allow_tools);
}
#[test]
fn parses_ai_backend_default() {
let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", "default", "help-all"]).unwrap();
assert!(matches!(cli.ai_backend, Some(AiBackend::Default)));
}
#[test]
fn parses_ai_backend_absent() {
let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
assert!(cli.ai_backend.is_none());
assert!(!cli.claude_cli_allow_tools);
assert!(!cli.claude_cli_allow_mcp);
}
#[test]
fn parses_claude_cli_allow_tools_flag() {
let cli =
Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
assert!(cli.claude_cli_allow_tools);
}
#[test]
fn parses_claude_cli_allow_mcp_flag() {
let cli = Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
assert!(cli.claude_cli_allow_mcp);
assert!(!cli.claude_cli_allow_tools);
}
#[test]
fn allow_mcp_and_allow_tools_are_independent() {
let only_mcp =
Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
assert!(only_mcp.claude_cli_allow_mcp);
assert!(!only_mcp.claude_cli_allow_tools);
let only_tools =
Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
assert!(only_tools.claude_cli_allow_tools);
assert!(!only_tools.claude_cli_allow_mcp);
let both = Cli::try_parse_from([
"omni-dev",
"--claude-cli-allow-tools",
"--claude-cli-allow-mcp",
"help-all",
])
.unwrap();
assert!(both.claude_cli_allow_tools);
assert!(both.claude_cli_allow_mcp);
}
#[test]
fn global_flags_accepted_after_subcommand() {
let cli = Cli::try_parse_from([
"omni-dev",
"help-all",
"--ai-backend",
"claude-cli",
"--claude-cli-allow-tools",
])
.unwrap();
assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
assert!(cli.claude_cli_allow_tools);
}
#[test]
fn parses_max_budget_usd_flag() {
let cli = Cli::try_parse_from([
"omni-dev",
"--claude-cli-max-budget-usd",
"0.50",
"help-all",
])
.unwrap();
assert_eq!(cli.claude_cli_max_budget_usd, Some(0.50));
}
#[test]
fn max_budget_usd_absent_is_none() {
let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
assert!(cli.claude_cli_max_budget_usd.is_none());
}
#[test]
fn max_budget_usd_rejects_non_numeric() {
let result = Cli::try_parse_from([
"omni-dev",
"--claude-cli-max-budget-usd",
"cheap",
"help-all",
]);
let Err(err) = result else {
panic!("expected parse error for non-numeric budget");
};
assert!(err.to_string().contains("invalid"));
}
const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
struct GlobalFlagsEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
saved: [(&'static str, Option<String>); 4],
}
impl GlobalFlagsEnvGuard {
fn new() -> Self {
let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let names = [BACKEND_VAR, ALLOW_TOOLS_VAR, ALLOW_MCP_VAR, MAX_BUDGET_VAR];
let saved = names.map(|n| (n, std::env::var(n).ok()));
for (n, _) in &saved {
std::env::remove_var(n);
}
Self { _lock: lock, saved }
}
}
impl Drop for GlobalFlagsEnvGuard {
fn drop(&mut self) {
for (n, value) in &self.saved {
match value {
Some(v) => std::env::set_var(n, v),
None => std::env::remove_var(n),
}
}
}
}
fn cli_with_defaults() -> Cli {
Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
}
#[test]
fn propagate_global_flags_defaults_set_nothing() {
let _g = GlobalFlagsEnvGuard::new();
cli_with_defaults().propagate_global_flags();
assert!(std::env::var(BACKEND_VAR).is_err());
assert!(std::env::var(ALLOW_TOOLS_VAR).is_err());
assert!(std::env::var(ALLOW_MCP_VAR).is_err());
assert!(std::env::var(MAX_BUDGET_VAR).is_err());
}
#[test]
fn propagate_global_flags_sets_ai_backend_claude_cli() {
let _g = GlobalFlagsEnvGuard::new();
let mut cli = cli_with_defaults();
cli.ai_backend = Some(AiBackend::ClaudeCli);
cli.propagate_global_flags();
assert_eq!(
std::env::var(BACKEND_VAR).ok().as_deref(),
Some("claude-cli")
);
}
#[test]
fn propagate_global_flags_default_backend_removes_env_var() {
let _g = GlobalFlagsEnvGuard::new();
std::env::set_var(BACKEND_VAR, "claude-cli");
let mut cli = cli_with_defaults();
cli.ai_backend = Some(AiBackend::Default);
cli.propagate_global_flags();
assert!(std::env::var(BACKEND_VAR).is_err());
}
#[test]
fn propagate_global_flags_sets_allow_tools() {
let _g = GlobalFlagsEnvGuard::new();
let mut cli = cli_with_defaults();
cli.claude_cli_allow_tools = true;
cli.propagate_global_flags();
assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
}
#[test]
fn propagate_global_flags_sets_allow_mcp() {
let _g = GlobalFlagsEnvGuard::new();
let mut cli = cli_with_defaults();
cli.claude_cli_allow_mcp = true;
cli.propagate_global_flags();
assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
}
#[test]
fn propagate_global_flags_sets_max_budget_usd() {
let _g = GlobalFlagsEnvGuard::new();
let mut cli = cli_with_defaults();
cli.claude_cli_max_budget_usd = Some(1.5);
cli.propagate_global_flags();
assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
}
#[test]
fn propagate_global_flags_independent_flags_compose() {
let _g = GlobalFlagsEnvGuard::new();
let mut cli = cli_with_defaults();
cli.ai_backend = Some(AiBackend::ClaudeCli);
cli.claude_cli_allow_tools = true;
cli.claude_cli_allow_mcp = true;
cli.claude_cli_max_budget_usd = Some(0.25);
cli.propagate_global_flags();
assert_eq!(
std::env::var(BACKEND_VAR).ok().as_deref(),
Some("claude-cli")
);
assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
}
}