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 jev route",
369 "ai jev verify-decision",
370 ],
371 ),
372 (
373 "instance",
374 vec![
375 "atlassian jira",
376 "atlassian confluence",
377 "atlassian auth status",
378 ],
379 ),
380 ];
381
382 for (flag, owners) in &expected {
383 let mut matched = 0;
384 for (path, longs) in &all {
385 let accepts = longs.iter().any(|l| l == flag);
386 let should = under(path, owners);
387 assert_eq!(
388 accepts,
389 should,
390 "`{path}` {} `--{flag}`, but the placement table says it {}",
391 if accepts { "accepts" } else { "rejects" },
392 if should {
393 "reads it"
394 } else {
395 "does not read it"
396 },
397 );
398 matched += usize::from(accepts);
399 }
400 assert!(matched > 0, "no command accepts `--{flag}`");
401 }
402
403 for (path, longs) in &all {
405 assert!(
406 longs.iter().any(|l| l == "profile"),
407 "`{path}` should accept the global `--profile`"
408 );
409 }
410
411 use std::collections::BTreeSet;
415 fn declared_globals(cmd: &clap::Command, path: &str, out: &mut BTreeSet<String>) {
416 for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
417 let long = arg.get_long().unwrap_or_else(|| arg.get_id().as_str());
418 out.insert(format!("{path} --{long}"));
419 }
420 for sub in cmd.get_subcommands() {
421 declared_globals(sub, &format!("{path} {}", sub.get_name()), out);
422 }
423 }
424 let mut declared = BTreeSet::new();
425 declared_globals(&Cli::command(), "omni-dev", &mut declared);
426 let mut expected_globals: BTreeSet<String> = [
427 "omni-dev --profile",
428 "omni-dev git --repo",
429 "omni-dev coverage --repo",
430 "omni-dev config scopes --repo",
431 "omni-dev ai jev route --repo",
433 "omni-dev ai jev verify-decision --repo",
434 "omni-dev atlassian jira --instance",
435 "omni-dev atlassian confluence --instance",
436 "omni-dev atlassian auth status --instance",
437 "omni-dev gmail --account",
439 "omni-dev drive --account",
440 ]
441 .into_iter()
442 .map(str::to_string)
443 .collect();
444 if cfg!(unix) {
445 expected_globals.insert("omni-dev worktrees rebase --repo".to_string());
447 expected_globals.insert("omni-dev worktrees push --repo".to_string());
448 }
449 assert_eq!(
450 declared, expected_globals,
451 "the set of `global = true` flags changed — apply the placement rule \
452 (#1778) and update this list"
453 );
454 }
455
456 #[test]
464 fn no_subcommand_arg_shadows_a_global_arg_id() {
465 use clap::CommandFactory;
466 use std::collections::HashSet;
467
468 fn walk(
486 cmd: &clap::Command,
487 inherited_ids: &HashSet<String>,
488 inherited_longs: &HashSet<String>,
489 path: &str,
490 ) {
491 let mut ids = inherited_ids.clone();
492 let mut longs = inherited_longs.clone();
493 for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
494 ids.insert(arg.get_id().as_str().to_string());
495 if let Some(long) = arg.get_long() {
496 longs.insert(long.to_string());
497 }
498 }
499
500 for sub in cmd.get_subcommands() {
501 let sub_path = format!("{path} {}", sub.get_name());
502 for arg in sub.get_arguments() {
503 let id = arg.get_id().as_str();
504 assert!(
505 !ids.contains(id),
506 "`{sub_path}` defines an arg with id `{id}`, which is an \
507 inherited global arg id — clap propagates globals by id, \
508 so this shadows the global and panics when it is read \
509 (#1420). Rename the subcommand-local field.",
510 );
511 if let Some(long) = arg.get_long() {
512 assert!(
513 !longs.contains(long),
514 "`{sub_path}` defines `--{long}`, already an inherited \
515 global flag — clap rejects two args on the same \
516 command sharing a long flag name (issue #1500). \
517 Rename the subcommand-local flag.",
518 );
519 }
520 }
521 walk(sub, &ids, &longs, &sub_path);
522 }
523 }
524
525 let cmd = Cli::command();
526 let root_globals: HashSet<String> = cmd
527 .get_arguments()
528 .filter(|a| a.is_global_set())
529 .map(|a| a.get_id().as_str().to_string())
530 .collect();
531 assert!(
532 !root_globals.is_empty(),
533 "expected the root command to declare global args"
534 );
535 walk(&cmd, &HashSet::new(), &HashSet::new(), "omni-dev");
536 }
537
538 const PROFILE_VAR: &str = "OMNI_DEV_PROFILE";
547
548 struct ProfileEnvGuard {
551 _lock: std::sync::MutexGuard<'static, ()>,
552 saved: [(&'static str, Option<String>); 1],
553 }
554
555 impl ProfileEnvGuard {
556 fn new() -> Self {
557 let lock = crate::test_support::HOME_ENV_MUTEX
558 .lock()
559 .unwrap_or_else(std::sync::PoisonError::into_inner);
560 let names = [PROFILE_VAR];
561 let saved = names.map(|n| (n, std::env::var(n).ok()));
562 for (n, _) in &saved {
563 std::env::remove_var(n);
564 }
565 Self { _lock: lock, saved }
566 }
567 }
568
569 impl Drop for ProfileEnvGuard {
570 fn drop(&mut self) {
571 for (n, value) in &self.saved {
572 match value {
573 Some(v) => std::env::set_var(n, v),
574 None => std::env::remove_var(n),
575 }
576 }
577 }
578 }
579
580 fn cli_with_defaults() -> Cli {
581 Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
582 }
583
584 #[test]
585 fn propagate_profile_flag_defaults_set_nothing() {
586 let _g = ProfileEnvGuard::new();
587 cli_with_defaults().propagate_profile_flag();
588 assert!(std::env::var(PROFILE_VAR).is_err());
589 }
590
591 #[test]
592 fn parses_profile_flag() {
593 let cli = Cli::try_parse_from(["omni-dev", "--profile", "work", "help-all"]).unwrap();
594 assert_eq!(cli.profile.as_deref(), Some("work"));
595 }
596
597 #[test]
598 fn profile_absent_is_none() {
599 let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
600 assert!(cli.profile.is_none());
601 }
602
603 #[test]
604 fn propagate_profile_flag_sets_profile() {
605 let _g = ProfileEnvGuard::new();
606 let mut cli = cli_with_defaults();
607 cli.profile = Some("work".to_string());
608 cli.propagate_profile_flag();
609 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
610 }
611
612 #[test]
613 fn propagate_profile_flag_profile_flag_beats_env_var() {
614 let _g = ProfileEnvGuard::new();
615 std::env::set_var(PROFILE_VAR, "personal");
616 let mut cli = cli_with_defaults();
617 cli.profile = Some("work".to_string());
618 cli.propagate_profile_flag();
619 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
620 }
621
622 #[test]
623 fn propagate_profile_flag_absent_profile_leaves_env_var() {
624 let _g = ProfileEnvGuard::new();
625 std::env::set_var(PROFILE_VAR, "personal");
626 cli_with_defaults().propagate_profile_flag();
627 assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("personal"));
628 }
629
630 #[test]
634 fn validate_active_profile_ok_and_skips_load_when_no_profile() {
635 use crate::test_support::env::MapEnv;
636 let env = MapEnv::new();
637 let result = Cli::validate_active_profile(&env, || panic!("must not load settings"));
638 assert!(result.is_ok());
639 }
640
641 #[test]
642 fn validate_active_profile_ok_for_known_profile() {
643 use crate::test_support::env::MapEnv;
644 use crate::utils::settings::{Profile, Settings};
645 let env = MapEnv::new().with(PROFILE_VAR, "work");
646 let settings = Settings {
647 profiles: std::iter::once(("work".to_string(), Profile::default())).collect(),
648 ..Default::default()
649 };
650 assert!(Cli::validate_active_profile(&env, || settings).is_ok());
651 }
652
653 #[test]
654 fn validate_active_profile_errors_for_unknown_profile() {
655 use crate::test_support::env::MapEnv;
656 use crate::utils::settings::Settings;
657 let env = MapEnv::new().with(PROFILE_VAR, "wrok");
658 let err = Cli::validate_active_profile(&env, Settings::default)
659 .unwrap_err()
660 .to_string();
661 assert!(err.contains("unknown profile 'wrok'"));
662 }
663
664 #[test]
665 fn load_settings_or_default_never_panics() {
666 let _settings = Cli::load_settings_or_default();
670 }
671}