1use anyhow::Result;
4use clap::{Parser, Subcommand};
5
6pub mod ai;
7pub mod ai_backend_args;
8pub mod atlassian;
9pub mod browser;
10pub mod commands;
11pub mod completions;
12pub mod config;
13pub(crate) mod confirm;
14pub mod coverage;
15#[cfg(unix)]
19pub mod claude_wrap;
20#[cfg(unix)]
21pub mod codex_wrap;
22#[cfg(unix)]
23pub mod daemon;
24pub mod datadog;
25pub mod drive;
26pub mod format;
27pub mod git;
28pub mod gmail;
29pub mod help;
30pub mod log;
31pub mod repo_arg;
32pub mod resources;
33#[cfg(unix)]
34pub mod sessions;
35#[cfg(unix)]
36pub mod snowflake;
37pub mod transcript;
38#[cfg(unix)]
39pub mod worktrees;
40
41pub use crate::claude::backend::AiBackend;
44
45#[derive(Parser)]
53#[command(name = "omni-dev")]
54#[command(
55 about = "AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.",
56 long_about = None
57)]
58#[command(version = crate::VERSION, long_version = crate::build_info::long_version())]
61pub struct Cli {
62 #[arg(long, global = true, value_name = "NAME")]
70 pub profile: Option<String>,
71
72 #[command(subcommand)]
74 pub command: Commands,
75}
76
77#[derive(Subcommand)]
83pub enum Commands {
84 Ai(ai::AiCommand),
86 Git(git::GitCommand),
88 Commands(commands::CommandsCommand),
90 Config(config::ConfigCommand),
92 Atlassian(atlassian::AtlassianCommand),
94 Browser(browser::BrowserCommand),
96 #[cfg(unix)]
98 Daemon(daemon::DaemonCommand),
99 Datadog(datadog::DatadogCommand),
101 Drive(drive::DriveCommand),
103 Gmail(gmail::GmailCommand),
105 #[cfg(unix)]
107 Snowflake(snowflake::SnowflakeCommand),
108 #[cfg(unix)]
110 Worktrees(worktrees::WorktreesCommand),
111 #[cfg(unix)]
113 Sessions(sessions::SessionsCommand),
114 #[cfg(unix)]
116 #[command(name = "claude-wrap")]
117 ClaudeWrap(claude_wrap::ClaudeWrapCommand),
118 #[cfg(unix)]
121 #[command(name = "codex-wrap")]
122 CodexWrap(codex_wrap::CodexWrapCommand),
123 Coverage(coverage::CoverageCommand),
125 Transcript(transcript::TranscriptCommand),
127 Log(log::LogCommand),
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_profile_flag(&self) {
144 if let Some(profile) = &self.profile {
149 std::env::set_var(crate::utils::settings::PROFILE_ENV_VAR, profile);
150 }
151 }
152
153 fn validate_active_profile<E, F>(env: &E, load_settings: F) -> Result<()>
159 where
160 E: crate::utils::env::EnvSource,
161 F: FnOnce() -> crate::utils::settings::Settings,
162 {
163 match crate::utils::settings::active_profile_from(env) {
164 Some(name) => load_settings().validate_profile(&name),
165 None => Ok(()),
166 }
167 }
168
169 fn load_settings_or_default() -> crate::utils::settings::Settings {
176 crate::utils::settings::Settings::load_or_warn_default()
177 }
178
179 pub async fn execute(self) -> Result<()> {
181 self.propagate_profile_flag();
182
183 Self::validate_active_profile(
188 &crate::utils::env::SystemEnv,
189 Self::load_settings_or_default,
190 )?;
191
192 match self.command {
193 Commands::Ai(ai_cmd) => ai_cmd.execute().await,
194 Commands::Git(git_cmd) => git_cmd.execute().await,
195 Commands::Commands(commands_cmd) => commands_cmd.execute(),
196 Commands::Atlassian(cmd) => cmd.execute().await,
197 Commands::Browser(cmd) => cmd.execute().await,
198 #[cfg(unix)]
199 Commands::Daemon(cmd) => cmd.execute().await,
200 Commands::Datadog(cmd) => cmd.execute().await,
201 Commands::Drive(cmd) => cmd.execute().await,
202 Commands::Gmail(cmd) => cmd.execute().await,
203 #[cfg(unix)]
204 Commands::Snowflake(cmd) => cmd.execute().await,
205 #[cfg(unix)]
206 Commands::Worktrees(cmd) => cmd.execute().await,
207 #[cfg(unix)]
208 Commands::Sessions(cmd) => cmd.execute().await,
209 #[cfg(unix)]
210 Commands::ClaudeWrap(cmd) => cmd.execute().await,
211 #[cfg(unix)]
212 Commands::CodexWrap(cmd) => cmd.execute().await,
213 Commands::Coverage(cmd) => cmd.execute(),
214 Commands::Transcript(cmd) => cmd.execute().await,
215 Commands::Log(log_cmd) => log_cmd.execute(),
216 Commands::Config(config_cmd) => config_cmd.execute(),
217 Commands::Resources(resources_cmd) => resources_cmd.execute(),
218 Commands::Completions(completions_cmd) => completions_cmd.execute(),
219 Commands::HelpAll(help_cmd) => help_cmd.execute(),
220 }
221 }
222}
223
224#[cfg(all(target_os = "macos", feature = "menu-bar"))]
225impl Cli {
226 pub fn menu_bar_run_config(&self) -> Option<Result<crate::daemon::DaemonRunConfig>> {
231 match &self.command {
232 Commands::Daemon(daemon::DaemonCommand {
233 command: daemon::DaemonSubcommands::Run(run),
234 }) if !run.no_menu => Some(run.clone().into_run_config()),
235 _ => None,
236 }
237 }
238}
239
240#[cfg(test)]
241#[allow(clippy::unwrap_used, clippy::expect_used)]
242mod tests {
243 use super::*;
244
245 #[tokio::test]
250 async fn execute_routes_gmail_subcommand() {
251 let guard = crate::gmail::test_support::EnvGuard::take();
252 let _dir = guard.clear_credentials();
253
254 let cli = Cli::try_parse_from(["omni-dev", "gmail", "auth", "status"]).unwrap();
255 let err = cli.execute().await.unwrap_err();
256 assert!(err.to_string().contains("not configured"));
257 }
258
259 #[tokio::test]
264 async fn execute_routes_drive_subcommand() {
265 let guard = crate::drive::test_support::EnvGuard::take();
266 let _dir = guard.clear_credentials();
267
268 let cli = Cli::try_parse_from(["omni-dev", "drive", "auth", "status"]).unwrap();
269 let err = cli.execute().await.unwrap_err();
270 assert!(err.to_string().contains("not configured"));
271 }
272
273 #[cfg(unix)]
283 #[test]
284 fn worktrees_register_takes_repo_name_but_not_repo() {
285 let register = [
286 "worktrees",
287 "register",
288 "--key",
289 "k1",
290 "--repo-name",
291 "myrepo",
292 "--folder",
293 "/tmp",
294 ];
295 let cli = Cli::try_parse_from(std::iter::once("omni-dev").chain(register)).unwrap();
296 let Commands::Worktrees(worktrees::WorktreesCommand {
297 command: worktrees::WorktreesSubcommands::Register(register_cmd),
298 }) = cli.command
299 else {
300 panic!("expected a `worktrees register` invocation");
301 };
302 assert_eq!(register_cmd.key, "k1");
303 assert_eq!(register_cmd.repo_name.as_deref(), Some("myrepo"));
304
305 let with_repo = std::iter::once("omni-dev")
306 .chain(register)
307 .chain(["-C", "/tmp/somerepo"]);
308 assert!(Cli::try_parse_from(with_repo).is_err());
309 }
310
311 #[test]
316 fn scoped_flags_are_accepted_only_by_their_readers() {
317 use clap::CommandFactory;
318
319 fn leaves(cmd: &clap::Command, path: &str, out: &mut Vec<(String, Vec<String>)>) {
320 let subs: Vec<_> = cmd
321 .get_subcommands()
322 .filter(|s| s.get_name() != "help")
323 .collect();
324 if subs.is_empty() {
325 let longs = cmd
326 .get_arguments()
327 .filter_map(|a| a.get_long().map(str::to_string))
328 .collect();
329 out.push((path.to_string(), longs));
330 }
331 for sub in subs {
332 leaves(sub, &format!("{path} {}", sub.get_name()), out);
333 }
334 }
335
336 let mut cmd = Cli::command();
339 cmd.build();
340 let mut all = Vec::new();
341 leaves(&cmd, "omni-dev", &mut all);
342
343 let under = |path: &str, owners: &[&str]| {
344 owners.iter().any(|o| {
345 let o = format!("omni-dev {o}");
346 path == o || path.starts_with(&format!("{o} "))
347 })
348 };
349 const AI: &[&str] = &[
350 "git commit message twiddle",
351 "git commit message check",
352 "git commit message staged",
353 "git branch create pr",
354 "ai chat",
355 "ai jev verify-decision",
356 ];
357 let expected: [(&str, Vec<&str>); 4] = [
358 ("ai-backend", AI.to_vec()),
359 ("models-yaml", [AI, &["config models show"]].concat()),
360 (
361 "repo",
362 vec![
363 "git",
364 "coverage",
365 "config scopes",
366 "worktrees rebase",
367 "worktrees push",
368 "ai claude skills",
369 "ai jev route",
370 "ai jev exists",
371 "ai jev verify-decision",
372 ],
373 ),
374 (
375 "instance",
376 vec![
377 "atlassian jira",
378 "atlassian confluence",
379 "atlassian auth status",
380 ],
381 ),
382 ];
383
384 for (flag, owners) in &expected {
385 let mut matched = 0;
386 for (path, longs) in &all {
387 let accepts = longs.iter().any(|l| l == flag);
388 let should = under(path, owners);
389 assert_eq!(
390 accepts,
391 should,
392 "`{path}` {} `--{flag}`, but the placement table says it {}",
393 if accepts { "accepts" } else { "rejects" },
394 if should {
395 "reads it"
396 } else {
397 "does not read it"
398 },
399 );
400 matched += usize::from(accepts);
401 }
402 assert!(matched > 0, "no command accepts `--{flag}`");
403 }
404
405 for (path, longs) in &all {
407 assert!(
408 longs.iter().any(|l| l == "profile"),
409 "`{path}` should accept the global `--profile`"
410 );
411 }
412
413 use std::collections::BTreeSet;
417 fn declared_globals(cmd: &clap::Command, path: &str, out: &mut BTreeSet<String>) {
418 for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
419 let long = arg.get_long().unwrap_or_else(|| arg.get_id().as_str());
420 out.insert(format!("{path} --{long}"));
421 }
422 for sub in cmd.get_subcommands() {
423 declared_globals(sub, &format!("{path} {}", sub.get_name()), out);
424 }
425 }
426 let mut declared = BTreeSet::new();
427 declared_globals(&Cli::command(), "omni-dev", &mut declared);
428 let mut expected_globals: BTreeSet<String> = [
429 "omni-dev --profile",
430 "omni-dev git --repo",
431 "omni-dev coverage --repo",
432 "omni-dev config scopes --repo",
433 "omni-dev ai claude skills --repo",
434 "omni-dev ai jev route --repo",
436 "omni-dev ai jev exists --repo",
437 "omni-dev ai jev verify-decision --repo",
438 "omni-dev atlassian jira --instance",
439 "omni-dev atlassian confluence --instance",
440 "omni-dev atlassian auth status --instance",
441 "omni-dev gmail --account",
443 "omni-dev drive --account",
444 ]
445 .into_iter()
446 .map(str::to_string)
447 .collect();
448 if cfg!(unix) {
449 expected_globals.insert("omni-dev worktrees rebase --repo".to_string());
451 expected_globals.insert("omni-dev worktrees push --repo".to_string());
452 }
453 assert_eq!(
454 declared, expected_globals,
455 "the set of `global = true` flags changed — apply the placement rule \
456 (#1778) and update this list"
457 );
458 }
459
460 #[test]
468 fn no_subcommand_arg_shadows_a_global_arg_id() {
469 use clap::CommandFactory;
470 use std::collections::HashSet;
471
472 fn walk(
490 cmd: &clap::Command,
491 inherited_ids: &HashSet<String>,
492 inherited_longs: &HashSet<String>,
493 path: &str,
494 ) {
495 let mut ids = inherited_ids.clone();
496 let mut longs = inherited_longs.clone();
497 for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
498 ids.insert(arg.get_id().as_str().to_string());
499 if let Some(long) = arg.get_long() {
500 longs.insert(long.to_string());
501 }
502 }
503
504 for sub in cmd.get_subcommands() {
505 let sub_path = format!("{path} {}", sub.get_name());
506 for arg in sub.get_arguments() {
507 let id = arg.get_id().as_str();
508 assert!(
509 !ids.contains(id),
510 "`{sub_path}` defines an arg with id `{id}`, which is an \
511 inherited global arg id — clap propagates globals by id, \
512 so this shadows the global and panics when it is read \
513 (#1420). Rename the subcommand-local field.",
514 );
515 if let Some(long) = arg.get_long() {
516 assert!(
517 !longs.contains(long),
518 "`{sub_path}` defines `--{long}`, already an inherited \
519 global flag — clap rejects two args on the same \
520 command sharing a long flag name (issue #1500). \
521 Rename the subcommand-local flag.",
522 );
523 }
524 }
525 walk(sub, &ids, &longs, &sub_path);
526 }
527 }
528
529 let cmd = Cli::command();
530 let root_globals: HashSet<String> = cmd
531 .get_arguments()
532 .filter(|a| a.is_global_set())
533 .map(|a| a.get_id().as_str().to_string())
534 .collect();
535 assert!(
536 !root_globals.is_empty(),
537 "expected the root command to declare global args"
538 );
539 walk(&cmd, &HashSet::new(), &HashSet::new(), "omni-dev");
540 }
541
542 const PROFILE_VAR: &str = "OMNI_DEV_PROFILE";
551
552 struct ProfileEnvGuard {
555 _lock: std::sync::MutexGuard<'static, ()>,
556 saved: [(&'static str, Option<String>); 1],
557 }
558
559 impl ProfileEnvGuard {
560 fn new() -> Self {
561 let lock = crate::test_support::HOME_ENV_MUTEX
562 .lock()
563 .unwrap_or_else(std::sync::PoisonError::into_inner);
564 let names = [PROFILE_VAR];
565 let saved = names.map(|n| (n, std::env::var(n).ok()));
566 for (n, _) in &saved {
567 std::env::remove_var(n);
568 }
569 Self { _lock: lock, saved }
570 }
571 }
572
573 impl Drop for ProfileEnvGuard {
574 fn drop(&mut self) {
575 for (n, value) in &self.saved {
576 match value {
577 Some(v) => std::env::set_var(n, v),
578 None => std::env::remove_var(n),
579 }
580 }
581 }
582 }
583
584 fn cli_with_defaults() -> Cli {
585 Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
586 }
587
588 #[test]
589 fn propagate_profile_flag_defaults_set_nothing() {
590 let _g = ProfileEnvGuard::new();
591 cli_with_defaults().propagate_profile_flag();
592 assert!(std::env::var(PROFILE_VAR).is_err());
593 }
594
595 #[test]
596 fn parses_profile_flag() {
597 let cli = Cli::try_parse_from(["omni-dev", "--profile", "work", "help-all"]).unwrap();
598 assert_eq!(cli.profile.as_deref(), Some("work"));
599 }
600
601 #[test]
602 fn profile_absent_is_none() {
603 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
604 assert!(cli.profile.is_none());
605 }
606
607 #[test]
608 fn propagate_profile_flag_sets_profile() {
609 let _g = ProfileEnvGuard::new();
610 let mut cli = cli_with_defaults();
611 cli.profile = Some("work".to_string());
612 cli.propagate_profile_flag();
613 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
614 }
615
616 #[test]
617 fn propagate_profile_flag_profile_flag_beats_env_var() {
618 let _g = ProfileEnvGuard::new();
619 std::env::set_var(PROFILE_VAR, "personal");
620 let mut cli = cli_with_defaults();
621 cli.profile = Some("work".to_string());
622 cli.propagate_profile_flag();
623 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
624 }
625
626 #[test]
627 fn propagate_profile_flag_absent_profile_leaves_env_var() {
628 let _g = ProfileEnvGuard::new();
629 std::env::set_var(PROFILE_VAR, "personal");
630 cli_with_defaults().propagate_profile_flag();
631 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("personal"));
632 }
633
634 #[test]
638 fn validate_active_profile_ok_and_skips_load_when_no_profile() {
639 use crate::test_support::env::MapEnv;
640 let env = MapEnv::new();
641 let result = Cli::validate_active_profile(&env, || panic!("must not load settings"));
642 assert!(result.is_ok());
643 }
644
645 #[test]
646 fn validate_active_profile_ok_for_known_profile() {
647 use crate::test_support::env::MapEnv;
648 use crate::utils::settings::{Profile, Settings};
649 let env = MapEnv::new().with(PROFILE_VAR, "work");
650 let settings = Settings {
651 profiles: std::iter::once(("work".to_string(), Profile::default())).collect(),
652 ..Default::default()
653 };
654 assert!(Cli::validate_active_profile(&env, || settings).is_ok());
655 }
656
657 #[test]
658 fn validate_active_profile_errors_for_unknown_profile() {
659 use crate::test_support::env::MapEnv;
660 use crate::utils::settings::Settings;
661 let env = MapEnv::new().with(PROFILE_VAR, "wrok");
662 let err = Cli::validate_active_profile(&env, Settings::default)
663 .unwrap_err()
664 .to_string();
665 assert!(err.contains("unknown profile 'wrok'"));
666 }
667
668 #[test]
669 fn load_settings_or_default_never_panics() {
670 let _settings = Cli::load_settings_or_default();
674 }
675}