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