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