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;
13pub mod transcript;
14
15#[derive(Clone, Copy, Debug, ValueEnum)]
17#[value(rename_all = "kebab-case")]
18pub enum AiBackend {
19 Default,
22 ClaudeCli,
25}
26
27#[derive(Parser)]
29#[command(name = "omni-dev")]
30#[command(about = "A comprehensive development toolkit", long_about = None)]
31#[command(version)]
32pub struct Cli {
33 #[arg(long, global = true, value_enum)]
37 pub ai_backend: Option<AiBackend>,
38
39 #[arg(long, global = true)]
51 pub claude_cli_allow_tools: bool,
52
53 #[arg(long, global = true)]
65 pub claude_cli_allow_mcp: bool,
66
67 #[arg(long, global = true, value_name = "AMOUNT")]
75 pub claude_cli_max_budget_usd: Option<f64>,
76
77 #[arg(long, global = true, value_name = "PATH")]
82 pub models_yaml: Option<std::path::PathBuf>,
83
84 #[command(subcommand)]
86 pub command: Commands,
87}
88
89#[derive(Subcommand)]
91pub enum Commands {
92 Ai(ai::AiCommand),
94 Git(git::GitCommand),
96 Commands(commands::CommandsCommand),
98 Config(config::ConfigCommand),
100 Atlassian(atlassian::AtlassianCommand),
102 Datadog(datadog::DatadogCommand),
104 Transcript(transcript::TranscriptCommand),
106 #[command(name = "help-all")]
108 HelpAll(help::HelpCommand),
109}
110
111impl Cli {
112 fn propagate_global_flags(&self) {
117 if let Some(backend) = self.ai_backend {
118 match backend {
119 AiBackend::Default => std::env::remove_var("OMNI_DEV_AI_BACKEND"),
120 AiBackend::ClaudeCli => std::env::set_var("OMNI_DEV_AI_BACKEND", "claude-cli"),
121 }
122 }
123
124 if self.claude_cli_allow_tools {
125 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
126 }
127
128 if self.claude_cli_allow_mcp {
129 std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
130 }
131
132 if let Some(budget) = self.claude_cli_max_budget_usd {
133 std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
134 }
135
136 if let Some(path) = &self.models_yaml {
137 std::env::set_var("OMNI_DEV_MODELS_YAML", path);
138 }
139 }
140
141 pub async fn execute(self) -> Result<()> {
143 self.propagate_global_flags();
144
145 match self.command {
146 Commands::Ai(ai_cmd) => ai_cmd.execute().await,
147 Commands::Git(git_cmd) => git_cmd.execute().await,
148 Commands::Commands(commands_cmd) => commands_cmd.execute(),
149 Commands::Atlassian(cmd) => cmd.execute().await,
150 Commands::Datadog(cmd) => cmd.execute().await,
151 Commands::Transcript(cmd) => cmd.execute().await,
152 Commands::Config(config_cmd) => config_cmd.execute(),
153 Commands::HelpAll(help_cmd) => help_cmd.execute(),
154 }
155 }
156}
157
158#[cfg(test)]
159#[allow(clippy::unwrap_used, clippy::expect_used)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn parses_ai_backend_claude_cli() {
165 let cli =
166 Cli::try_parse_from(["omni-dev", "--ai-backend", "claude-cli", "help-all"]).unwrap();
167 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
168 assert!(!cli.claude_cli_allow_tools);
169 }
170
171 #[test]
172 fn parses_ai_backend_default() {
173 let cli = Cli::try_parse_from(["omni-dev", "--ai-backend", "default", "help-all"]).unwrap();
174 assert!(matches!(cli.ai_backend, Some(AiBackend::Default)));
175 }
176
177 #[test]
178 fn parses_ai_backend_absent() {
179 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
180 assert!(cli.ai_backend.is_none());
181 assert!(!cli.claude_cli_allow_tools);
182 assert!(!cli.claude_cli_allow_mcp);
183 }
184
185 #[test]
186 fn parses_claude_cli_allow_tools_flag() {
187 let cli =
188 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
189 assert!(cli.claude_cli_allow_tools);
190 }
191
192 #[test]
193 fn parses_claude_cli_allow_mcp_flag() {
194 let cli = Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
195 assert!(cli.claude_cli_allow_mcp);
196 assert!(!cli.claude_cli_allow_tools);
197 }
198
199 #[test]
200 fn allow_mcp_and_allow_tools_are_independent() {
201 let only_mcp =
202 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-mcp", "help-all"]).unwrap();
203 assert!(only_mcp.claude_cli_allow_mcp);
204 assert!(!only_mcp.claude_cli_allow_tools);
205
206 let only_tools =
207 Cli::try_parse_from(["omni-dev", "--claude-cli-allow-tools", "help-all"]).unwrap();
208 assert!(only_tools.claude_cli_allow_tools);
209 assert!(!only_tools.claude_cli_allow_mcp);
210
211 let both = Cli::try_parse_from([
212 "omni-dev",
213 "--claude-cli-allow-tools",
214 "--claude-cli-allow-mcp",
215 "help-all",
216 ])
217 .unwrap();
218 assert!(both.claude_cli_allow_tools);
219 assert!(both.claude_cli_allow_mcp);
220 }
221
222 #[test]
223 fn global_flags_accepted_after_subcommand() {
224 let cli = Cli::try_parse_from([
226 "omni-dev",
227 "help-all",
228 "--ai-backend",
229 "claude-cli",
230 "--claude-cli-allow-tools",
231 ])
232 .unwrap();
233 assert!(matches!(cli.ai_backend, Some(AiBackend::ClaudeCli)));
234 assert!(cli.claude_cli_allow_tools);
235 }
236
237 #[test]
238 fn parses_max_budget_usd_flag() {
239 let cli = Cli::try_parse_from([
240 "omni-dev",
241 "--claude-cli-max-budget-usd",
242 "0.50",
243 "help-all",
244 ])
245 .unwrap();
246 assert_eq!(cli.claude_cli_max_budget_usd, Some(0.50));
247 }
248
249 #[test]
250 fn max_budget_usd_absent_is_none() {
251 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
252 assert!(cli.claude_cli_max_budget_usd.is_none());
253 }
254
255 #[test]
256 fn max_budget_usd_rejects_non_numeric() {
257 let result = Cli::try_parse_from([
258 "omni-dev",
259 "--claude-cli-max-budget-usd",
260 "cheap",
261 "help-all",
262 ]);
263 let Err(err) = result else {
264 panic!("expected parse error for non-numeric budget");
265 };
266 assert!(err.to_string().contains("invalid"));
267 }
268
269 const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
276 const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
277 const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
278 const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
279 const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
280
281 struct GlobalFlagsEnvGuard {
284 _lock: std::sync::MutexGuard<'static, ()>,
285 saved: [(&'static str, Option<String>); 5],
286 }
287
288 impl GlobalFlagsEnvGuard {
289 fn new() -> Self {
290 let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
291 .lock()
292 .unwrap_or_else(std::sync::PoisonError::into_inner);
293 let names = [
294 BACKEND_VAR,
295 ALLOW_TOOLS_VAR,
296 ALLOW_MCP_VAR,
297 MAX_BUDGET_VAR,
298 MODELS_YAML_VAR,
299 ];
300 let saved = names.map(|n| (n, std::env::var(n).ok()));
301 for (n, _) in &saved {
302 std::env::remove_var(n);
303 }
304 Self { _lock: lock, saved }
305 }
306 }
307
308 impl Drop for GlobalFlagsEnvGuard {
309 fn drop(&mut self) {
310 for (n, value) in &self.saved {
311 match value {
312 Some(v) => std::env::set_var(n, v),
313 None => std::env::remove_var(n),
314 }
315 }
316 }
317 }
318
319 fn cli_with_defaults() -> Cli {
320 Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
321 }
322
323 #[test]
324 fn propagate_global_flags_defaults_set_nothing() {
325 let _g = GlobalFlagsEnvGuard::new();
326 cli_with_defaults().propagate_global_flags();
327 assert!(std::env::var(BACKEND_VAR).is_err());
328 assert!(std::env::var(ALLOW_TOOLS_VAR).is_err());
329 assert!(std::env::var(ALLOW_MCP_VAR).is_err());
330 assert!(std::env::var(MAX_BUDGET_VAR).is_err());
331 assert!(std::env::var(MODELS_YAML_VAR).is_err());
332 }
333
334 #[test]
335 fn propagate_global_flags_sets_ai_backend_claude_cli() {
336 let _g = GlobalFlagsEnvGuard::new();
337 let mut cli = cli_with_defaults();
338 cli.ai_backend = Some(AiBackend::ClaudeCli);
339 cli.propagate_global_flags();
340 assert_eq!(
341 std::env::var(BACKEND_VAR).ok().as_deref(),
342 Some("claude-cli")
343 );
344 }
345
346 #[test]
347 fn propagate_global_flags_default_backend_removes_env_var() {
348 let _g = GlobalFlagsEnvGuard::new();
349 std::env::set_var(BACKEND_VAR, "claude-cli");
350 let mut cli = cli_with_defaults();
351 cli.ai_backend = Some(AiBackend::Default);
352 cli.propagate_global_flags();
353 assert!(std::env::var(BACKEND_VAR).is_err());
354 }
355
356 #[test]
357 fn propagate_global_flags_sets_allow_tools() {
358 let _g = GlobalFlagsEnvGuard::new();
359 let mut cli = cli_with_defaults();
360 cli.claude_cli_allow_tools = true;
361 cli.propagate_global_flags();
362 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
363 }
364
365 #[test]
366 fn propagate_global_flags_sets_allow_mcp() {
367 let _g = GlobalFlagsEnvGuard::new();
368 let mut cli = cli_with_defaults();
369 cli.claude_cli_allow_mcp = true;
370 cli.propagate_global_flags();
371 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
372 }
373
374 #[test]
375 fn propagate_global_flags_sets_max_budget_usd() {
376 let _g = GlobalFlagsEnvGuard::new();
377 let mut cli = cli_with_defaults();
378 cli.claude_cli_max_budget_usd = Some(1.5);
379 cli.propagate_global_flags();
380 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
381 }
382
383 #[test]
384 fn parses_models_yaml_flag() {
385 let cli = Cli::try_parse_from([
386 "omni-dev",
387 "--models-yaml",
388 "/tmp/custom-models.yaml",
389 "help-all",
390 ])
391 .unwrap();
392 assert_eq!(
393 cli.models_yaml.as_deref(),
394 Some(std::path::Path::new("/tmp/custom-models.yaml"))
395 );
396 }
397
398 #[test]
399 fn propagate_global_flags_sets_models_yaml() {
400 let _g = GlobalFlagsEnvGuard::new();
401 let mut cli = cli_with_defaults();
402 cli.models_yaml = Some(std::path::PathBuf::from("/tmp/custom-models.yaml"));
403 cli.propagate_global_flags();
404 assert_eq!(
405 std::env::var(MODELS_YAML_VAR).ok().as_deref(),
406 Some("/tmp/custom-models.yaml")
407 );
408 }
409
410 #[test]
411 fn propagate_global_flags_independent_flags_compose() {
412 let _g = GlobalFlagsEnvGuard::new();
413 let mut cli = cli_with_defaults();
414 cli.ai_backend = Some(AiBackend::ClaudeCli);
415 cli.claude_cli_allow_tools = true;
416 cli.claude_cli_allow_mcp = true;
417 cli.claude_cli_max_budget_usd = Some(0.25);
418 cli.propagate_global_flags();
419 assert_eq!(
420 std::env::var(BACKEND_VAR).ok().as_deref(),
421 Some("claude-cli")
422 );
423 assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
424 assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
425 assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
426 }
427}