1use crate::client::ClientType;
2use crate::rules::{Confidence, ParseEnumError, RuleSeverity, Severity};
3use crate::run::EffectiveConfig;
4use clap::{Args, Parser, Subcommand, ValueEnum};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum OutputFormat {
11 #[default]
12 Terminal,
13 Json,
14 Sarif,
15 Html,
16 Markdown,
17}
18
19impl std::str::FromStr for OutputFormat {
20 type Err = ParseEnumError;
21
22 fn from_str(s: &str) -> Result<Self, Self::Err> {
23 match s.to_lowercase().as_str() {
24 "terminal" | "term" => Ok(OutputFormat::Terminal),
25 "json" => Ok(OutputFormat::Json),
26 "sarif" => Ok(OutputFormat::Sarif),
27 "html" => Ok(OutputFormat::Html),
28 "markdown" | "md" => Ok(OutputFormat::Markdown),
29 _ => Err(ParseEnumError::invalid("OutputFormat", s)),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum BadgeFormat {
38 Url,
40 #[default]
42 Markdown,
43 Html,
45}
46
47impl std::str::FromStr for BadgeFormat {
48 type Err = ParseEnumError;
49
50 fn from_str(s: &str) -> Result<Self, Self::Err> {
51 match s.to_lowercase().as_str() {
52 "url" => Ok(BadgeFormat::Url),
53 "markdown" | "md" => Ok(BadgeFormat::Markdown),
54 "html" => Ok(BadgeFormat::Html),
55 _ => Err(ParseEnumError::invalid("BadgeFormat", s)),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum ScanType {
63 #[default]
64 Skill,
65 Hook,
66 Mcp,
67 Command,
68 Rules,
69 Docker,
70 Dependency,
71 Subagent,
73 Plugin,
75}
76
77impl std::str::FromStr for ScanType {
78 type Err = ParseEnumError;
79
80 fn from_str(s: &str) -> Result<Self, Self::Err> {
81 match s.to_lowercase().as_str() {
82 "skill" => Ok(ScanType::Skill),
83 "hook" => Ok(ScanType::Hook),
84 "mcp" => Ok(ScanType::Mcp),
85 "command" | "cmd" => Ok(ScanType::Command),
86 "rules" => Ok(ScanType::Rules),
87 "docker" => Ok(ScanType::Docker),
88 "dependency" | "dep" | "deps" => Ok(ScanType::Dependency),
89 "subagent" | "agent" => Ok(ScanType::Subagent),
90 "plugin" => Ok(ScanType::Plugin),
91 _ => Err(ParseEnumError::invalid("ScanType", s)),
92 }
93 }
94}
95
96#[derive(Subcommand, Debug, Clone)]
98pub enum HookAction {
99 Init {
101 #[arg(default_value = ".")]
103 path: PathBuf,
104 },
105 Remove {
107 #[arg(default_value = ".")]
109 path: PathBuf,
110 },
111}
112
113#[derive(Args, Debug, Clone)]
115pub struct CheckArgs {
116 #[arg(required_unless_present_any = ["remote", "remote_list", "awesome_claude_code", "all_clients", "client", "compare"])]
118 pub paths: Vec<PathBuf>,
119
120 #[arg(short = 'c', long = "config", value_name = "FILE")]
122 pub config: Option<PathBuf>,
123
124 #[arg(long, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "client"])]
126 pub all_clients: bool,
127
128 #[arg(long, value_enum, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "all_clients"])]
130 pub client: Option<ClientType>,
131
132 #[arg(long, value_name = "URL")]
134 pub remote: Option<String>,
135
136 #[arg(long, default_value = "HEAD")]
138 pub git_ref: String,
139
140 #[arg(long, env = "GITHUB_TOKEN", value_name = "TOKEN")]
142 pub remote_auth: Option<String>,
143
144 #[arg(long, conflicts_with = "remote", value_name = "FILE")]
146 pub remote_list: Option<PathBuf>,
147
148 #[arg(long, conflicts_with_all = ["remote", "remote_list"])]
150 pub awesome_claude_code: bool,
151
152 #[arg(long, default_value = "4")]
154 pub parallel_clones: usize,
155
156 #[arg(long)]
158 pub badge: bool,
159
160 #[arg(long, value_enum, default_value_t = BadgeFormat::Markdown)]
162 pub badge_format: BadgeFormat,
163
164 #[arg(long)]
166 pub summary: bool,
167
168 #[arg(short, long, value_enum, default_value_t = OutputFormat::Terminal)]
170 pub format: OutputFormat,
171
172 #[arg(short = 'S', long)]
174 pub strict: bool,
175
176 #[arg(long)]
178 pub warn_only: bool,
179
180 #[arg(long, value_enum)]
182 pub min_severity: Option<Severity>,
183
184 #[arg(long, value_enum)]
186 pub min_rule_severity: Option<RuleSeverity>,
187
188 #[arg(short = 't', long = "type", value_enum, default_value_t = ScanType::Skill)]
190 pub scan_type: ScanType,
191
192 #[arg(long = "no-recursive")]
194 pub no_recursive: bool,
195
196 #[arg(long)]
198 pub ci: bool,
199
200 #[arg(long, value_enum)]
202 pub min_confidence: Option<Confidence>,
203
204 #[arg(long)]
206 pub skip_comments: bool,
207
208 #[arg(long)]
210 pub strict_secrets: bool,
211
212 #[arg(long)]
216 pub allow_inline_suppression: bool,
217
218 #[arg(long)]
220 pub fix_hint: bool,
221
222 #[arg(long)]
224 pub compact: bool,
225
226 #[arg(short, long)]
228 pub watch: bool,
229
230 #[arg(long)]
232 pub malware_db: Option<PathBuf>,
233
234 #[arg(long)]
236 pub no_malware_scan: bool,
237
238 #[arg(long)]
240 pub cve_db: Option<PathBuf>,
241
242 #[arg(long)]
244 pub no_cve_scan: bool,
245
246 #[arg(long)]
248 pub custom_rules: Option<PathBuf>,
249
250 #[arg(long)]
252 pub baseline: bool,
253
254 #[arg(long)]
256 pub check_drift: bool,
257
258 #[arg(short, long)]
260 pub output: Option<PathBuf>,
261
262 #[arg(long, value_name = "FILE")]
264 pub save_baseline: Option<PathBuf>,
265
266 #[arg(long, value_name = "FILE")]
268 pub baseline_file: Option<PathBuf>,
269
270 #[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
272 pub compare: Option<Vec<PathBuf>>,
273
274 #[arg(long)]
276 pub fix: bool,
277
278 #[arg(long)]
280 pub fix_dry_run: bool,
281
282 #[arg(long)]
284 pub hook_mode: bool,
285
286 #[arg(long)]
288 pub pin: bool,
289
290 #[arg(long)]
292 pub pin_verify: bool,
293
294 #[arg(long)]
296 pub pin_update: bool,
297
298 #[arg(long)]
300 pub pin_force: bool,
301
302 #[arg(long)]
304 pub ignore_pin: bool,
305
306 #[arg(long)]
308 pub deep_scan: bool,
309
310 #[arg(long, value_name = "NAME")]
312 pub profile: Option<String>,
313
314 #[arg(long, value_name = "NAME")]
316 pub save_profile: Option<String>,
317
318 #[arg(long)]
320 pub report_fp: bool,
321
322 #[arg(long)]
324 pub report_fp_dry_run: bool,
325
326 #[arg(long, value_name = "URL")]
328 pub report_fp_endpoint: Option<String>,
329
330 #[arg(long)]
332 pub no_telemetry: bool,
333
334 #[arg(long)]
336 pub sbom: bool,
337
338 #[arg(long, value_name = "FORMAT")]
340 pub sbom_format: Option<String>,
341
342 #[arg(long)]
344 pub sbom_npm: bool,
345
346 #[arg(long)]
348 pub sbom_cargo: bool,
349}
350
351#[derive(Args, Debug, Clone)]
353pub struct ProxyArgs {
354 #[arg(long, default_value = "8080")]
356 pub port: u16,
357
358 #[arg(long, required = true, value_name = "HOST:PORT")]
360 pub target: String,
361
362 #[arg(long)]
364 pub tls: bool,
365
366 #[arg(long)]
368 pub block: bool,
369
370 #[arg(long, value_name = "FILE")]
372 pub log: Option<PathBuf>,
373}
374
375#[derive(Subcommand, Debug, Clone)]
377pub enum Commands {
378 Init {
380 #[arg(default_value = ".cc-audit.yaml")]
382 path: PathBuf,
383 },
384
385 Check(Box<CheckArgs>),
387
388 Hook {
390 #[command(subcommand)]
391 action: HookAction,
392 },
393
394 Serve,
396
397 Proxy(ProxyArgs),
399}
400
401#[derive(Parser, Debug, Default)]
402#[command(
403 name = "cc-audit",
404 version,
405 about = "Security auditor for Claude Code skills, hooks, and MCP servers",
406 long_about = "cc-audit scans Claude Code skills, hooks, and MCP servers for security vulnerabilities before installation."
407)]
408pub struct Cli {
409 #[command(subcommand)]
411 pub command: Option<Commands>,
412
413 #[arg(short, long, global = true)]
415 pub verbose: bool,
416}
417
418impl Default for CheckArgs {
419 fn default() -> Self {
420 Self {
421 paths: Vec::new(),
422 config: None,
423 all_clients: false,
424 client: None,
425 remote: None,
426 git_ref: "HEAD".to_string(),
427 remote_auth: None,
428 remote_list: None,
429 awesome_claude_code: false,
430 parallel_clones: 4,
431 badge: false,
432 badge_format: BadgeFormat::Markdown,
433 summary: false,
434 format: OutputFormat::Terminal,
435 strict: false,
436 warn_only: false,
437 min_severity: None,
438 min_rule_severity: None,
439 scan_type: ScanType::Skill,
440 no_recursive: false,
441 ci: false,
442 min_confidence: None,
443 skip_comments: false,
444 strict_secrets: false,
445 allow_inline_suppression: false,
446 fix_hint: false,
447 compact: false,
448 watch: false,
449 malware_db: None,
450 no_malware_scan: false,
451 cve_db: None,
452 no_cve_scan: false,
453 custom_rules: None,
454 baseline: false,
455 check_drift: false,
456 output: None,
457 save_baseline: None,
458 baseline_file: None,
459 compare: None,
460 fix: false,
461 fix_dry_run: false,
462 hook_mode: false,
463 pin: false,
464 pin_verify: false,
465 pin_update: false,
466 pin_force: false,
467 ignore_pin: false,
468 deep_scan: false,
469 profile: None,
470 save_profile: None,
471 report_fp: false,
472 report_fp_dry_run: false,
473 report_fp_endpoint: None,
474 no_telemetry: false,
475 sbom: false,
476 sbom_format: None,
477 sbom_npm: false,
478 sbom_cargo: false,
479 }
480 }
481}
482
483impl CheckArgs {
484 pub fn for_scan(&self, paths: Vec<PathBuf>, effective: &EffectiveConfig) -> Self {
487 Self {
488 paths,
489 config: self.config.clone(),
490 remote: None,
491 git_ref: effective.git_ref.clone(),
492 remote_auth: effective.remote_auth.clone(),
493 remote_list: None,
494 awesome_claude_code: false,
495 parallel_clones: effective.parallel_clones,
496 badge: effective.badge,
497 badge_format: effective.badge_format,
498 summary: effective.summary,
499 format: effective.format,
500 strict: effective.strict,
501 warn_only: effective.warn_only,
502 min_severity: effective.min_severity,
503 min_rule_severity: effective.min_rule_severity,
504 scan_type: effective.scan_type,
505 no_recursive: false,
506 ci: effective.ci,
507 min_confidence: Some(effective.min_confidence),
508 watch: false,
509 skip_comments: effective.skip_comments,
510 strict_secrets: effective.strict_secrets,
511 allow_inline_suppression: effective.allow_inline_suppression,
512 fix_hint: effective.fix_hint,
513 compact: effective.compact,
514 no_malware_scan: effective.no_malware_scan,
515 cve_db: effective.cve_db.as_ref().map(PathBuf::from),
516 no_cve_scan: effective.no_cve_scan,
517 malware_db: effective.malware_db.as_ref().map(PathBuf::from),
518 custom_rules: effective.custom_rules.as_ref().map(PathBuf::from),
519 baseline: false,
520 check_drift: false,
521 output: effective.output.as_ref().map(PathBuf::from),
522 save_baseline: None,
523 baseline_file: self.baseline_file.clone(),
524 compare: None,
525 fix: false,
526 fix_dry_run: false,
527 pin: false,
528 pin_verify: false,
529 pin_update: false,
530 pin_force: false,
531 ignore_pin: false,
532 deep_scan: effective.deep_scan,
533 profile: self.profile.clone(),
534 save_profile: None,
535 all_clients: false,
536 client: None,
537 report_fp: false,
538 report_fp_dry_run: false,
539 report_fp_endpoint: None,
540 no_telemetry: self.no_telemetry,
541 sbom: false,
542 sbom_format: None,
543 sbom_npm: false,
544 sbom_cargo: false,
545 hook_mode: false,
546 }
547 }
548
549 pub fn for_batch_scan(&self, paths: Vec<PathBuf>, effective: &EffectiveConfig) -> Self {
551 let mut args = self.for_scan(paths, effective);
552 args.badge = false;
553 args.badge_format = BadgeFormat::Markdown;
554 args.summary = false;
555 args.format = OutputFormat::Terminal;
556 args.ci = false;
557 args.fix_hint = false;
558 args.output = None;
559 args.baseline_file = None;
560 args
561 }
562}
563
564impl Default for ProxyArgs {
565 fn default() -> Self {
566 Self {
567 port: 8080,
568 target: String::new(),
569 tls: false,
570 block: false,
571 log: None,
572 }
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::rules::{Confidence, RuleSeverity, Severity};
580 use clap::CommandFactory;
581
582 #[test]
583 fn test_cli_valid() {
584 Cli::command().debug_assert();
585 }
586
587 #[test]
590 fn test_no_args_succeeds() {
591 let cli = Cli::try_parse_from(["cc-audit"]).unwrap();
592 assert!(cli.command.is_none());
593 }
594
595 #[test]
598 fn test_parse_init_subcommand() {
599 let cli = Cli::try_parse_from(["cc-audit", "init"]).unwrap();
600 assert!(matches!(cli.command, Some(Commands::Init { .. })));
601 }
602
603 #[test]
604 fn test_parse_init_subcommand_with_path() {
605 let cli = Cli::try_parse_from(["cc-audit", "init", "custom-config.yaml"]).unwrap();
606 if let Some(Commands::Init { path }) = cli.command {
607 assert_eq!(path.to_str().unwrap(), "custom-config.yaml");
608 } else {
609 panic!("Expected Init command");
610 }
611 }
612
613 #[test]
616 fn test_parse_check_subcommand() {
617 let cli = Cli::try_parse_from(["cc-audit", "check", "./skill/"]).unwrap();
618 if let Some(Commands::Check(args)) = cli.command {
619 assert_eq!(args.paths.len(), 1);
620 assert!(!args.strict);
621 assert!(!args.no_recursive); } else {
623 panic!("Expected Check command");
624 }
625 }
626
627 #[test]
628 fn test_parse_check_multiple_paths() {
629 let cli = Cli::try_parse_from(["cc-audit", "check", "./skill1/", "./skill2/"]).unwrap();
630 if let Some(Commands::Check(args)) = cli.command {
631 assert_eq!(args.paths.len(), 2);
632 } else {
633 panic!("Expected Check command");
634 }
635 }
636
637 #[test]
638 fn test_parse_check_format_json() {
639 let cli =
640 Cli::try_parse_from(["cc-audit", "check", "--format", "json", "./skill/"]).unwrap();
641 if let Some(Commands::Check(args)) = cli.command {
642 assert!(matches!(args.format, OutputFormat::Json));
643 } else {
644 panic!("Expected Check command");
645 }
646 }
647
648 #[test]
649 fn test_parse_check_strict_mode() {
650 let cli = Cli::try_parse_from(["cc-audit", "check", "--strict", "./skill/"]).unwrap();
651 if let Some(Commands::Check(args)) = cli.command {
652 assert!(args.strict);
653 } else {
654 panic!("Expected Check command");
655 }
656 }
657
658 #[test]
659 fn test_parse_check_no_recursive() {
660 let cli =
661 Cli::try_parse_from(["cc-audit", "check", "--no-recursive", "./skills/"]).unwrap();
662 if let Some(Commands::Check(args)) = cli.command {
663 assert!(args.no_recursive);
664 } else {
665 panic!("Expected Check command");
666 }
667 }
668
669 #[test]
670 fn test_parse_check_format_sarif() {
671 let cli =
672 Cli::try_parse_from(["cc-audit", "check", "--format", "sarif", "./skill/"]).unwrap();
673 if let Some(Commands::Check(args)) = cli.command {
674 assert!(matches!(args.format, OutputFormat::Sarif));
675 } else {
676 panic!("Expected Check command");
677 }
678 }
679
680 #[test]
681 fn test_parse_check_type_hook() {
682 let cli = Cli::try_parse_from(["cc-audit", "check", "--type", "hook", "./settings.json"])
683 .unwrap();
684 if let Some(Commands::Check(args)) = cli.command {
685 assert!(matches!(args.scan_type, ScanType::Hook));
686 } else {
687 panic!("Expected Check command");
688 }
689 }
690
691 #[test]
692 fn test_parse_check_type_mcp() {
693 let cli =
694 Cli::try_parse_from(["cc-audit", "check", "--type", "mcp", "./mcp.json"]).unwrap();
695 if let Some(Commands::Check(args)) = cli.command {
696 assert!(matches!(args.scan_type, ScanType::Mcp));
697 } else {
698 panic!("Expected Check command");
699 }
700 }
701
702 #[test]
703 fn test_parse_check_ci_mode() {
704 let cli = Cli::try_parse_from(["cc-audit", "check", "--ci", "./skill/"]).unwrap();
705 if let Some(Commands::Check(args)) = cli.command {
706 assert!(args.ci);
707 } else {
708 panic!("Expected Check command");
709 }
710 }
711
712 #[test]
713 fn test_parse_check_verbose() {
714 let cli = Cli::try_parse_from(["cc-audit", "-v", "check", "./skill/"]).unwrap();
715 assert!(cli.verbose);
716 }
717
718 #[test]
719 fn test_parse_check_all_options() {
720 let cli = Cli::try_parse_from([
721 "cc-audit", "check", "--format", "json", "--strict", "--type", "hook", "--ci",
722 "./path/",
723 ])
724 .unwrap();
725 if let Some(Commands::Check(args)) = cli.command {
726 assert!(matches!(args.format, OutputFormat::Json));
727 assert!(args.strict);
728 assert!(matches!(args.scan_type, ScanType::Hook));
729 assert!(args.ci);
730 } else {
731 panic!("Expected Check command");
732 }
733 }
734
735 #[test]
736 fn test_parse_check_default_values() {
737 let cli = Cli::try_parse_from(["cc-audit", "check", "./skill/"]).unwrap();
738 if let Some(Commands::Check(args)) = cli.command {
739 assert!(matches!(args.format, OutputFormat::Terminal));
740 assert!(matches!(args.scan_type, ScanType::Skill));
741 assert!(!args.strict);
742 assert!(!args.no_recursive);
743 assert!(!args.ci);
744 assert!(args.min_confidence.is_none());
745 } else {
746 panic!("Expected Check command");
747 }
748 }
749
750 #[test]
751 fn test_parse_check_min_confidence() {
752 let cli = Cli::try_parse_from([
753 "cc-audit",
754 "check",
755 "--min-confidence",
756 "tentative",
757 "./skill/",
758 ])
759 .unwrap();
760 if let Some(Commands::Check(args)) = cli.command {
761 assert!(matches!(args.min_confidence, Some(Confidence::Tentative)));
762 } else {
763 panic!("Expected Check command");
764 }
765 }
766
767 #[test]
768 fn test_parse_check_skip_comments() {
769 let cli =
770 Cli::try_parse_from(["cc-audit", "check", "--skip-comments", "./skill/"]).unwrap();
771 if let Some(Commands::Check(args)) = cli.command {
772 assert!(args.skip_comments);
773 } else {
774 panic!("Expected Check command");
775 }
776 }
777
778 #[test]
779 fn test_parse_check_watch() {
780 let cli = Cli::try_parse_from(["cc-audit", "check", "--watch", "./skill/"]).unwrap();
781 if let Some(Commands::Check(args)) = cli.command {
782 assert!(args.watch);
783 } else {
784 panic!("Expected Check command");
785 }
786 }
787
788 #[test]
789 fn test_parse_check_watch_short() {
790 let cli = Cli::try_parse_from(["cc-audit", "check", "-w", "./skill/"]).unwrap();
791 if let Some(Commands::Check(args)) = cli.command {
792 assert!(args.watch);
793 } else {
794 panic!("Expected Check command");
795 }
796 }
797
798 #[test]
799 fn test_parse_check_malware_db() {
800 let cli = Cli::try_parse_from([
801 "cc-audit",
802 "check",
803 "--malware-db",
804 "./custom.json",
805 "./skill/",
806 ])
807 .unwrap();
808 if let Some(Commands::Check(args)) = cli.command {
809 assert!(args.malware_db.is_some());
810 assert_eq!(args.malware_db.unwrap().to_str().unwrap(), "./custom.json");
811 } else {
812 panic!("Expected Check command");
813 }
814 }
815
816 #[test]
817 fn test_parse_check_custom_rules() {
818 let cli = Cli::try_parse_from([
819 "cc-audit",
820 "check",
821 "--custom-rules",
822 "./rules.yaml",
823 "./skill/",
824 ])
825 .unwrap();
826 if let Some(Commands::Check(args)) = cli.command {
827 assert!(args.custom_rules.is_some());
828 assert_eq!(args.custom_rules.unwrap().to_str().unwrap(), "./rules.yaml");
829 } else {
830 panic!("Expected Check command");
831 }
832 }
833
834 #[test]
835 fn test_parse_check_config_option() {
836 let cli =
837 Cli::try_parse_from(["cc-audit", "check", "-c", "custom.yaml", "./skill/"]).unwrap();
838 if let Some(Commands::Check(args)) = cli.command {
839 assert_eq!(args.config.unwrap().to_str().unwrap(), "custom.yaml");
840 } else {
841 panic!("Expected Check command");
842 }
843 }
844
845 #[test]
846 fn test_parse_check_warn_only() {
847 let cli = Cli::try_parse_from(["cc-audit", "check", "--warn-only", "./skill/"]).unwrap();
848 if let Some(Commands::Check(args)) = cli.command {
849 assert!(args.warn_only);
850 } else {
851 panic!("Expected Check command");
852 }
853 }
854
855 #[test]
856 fn test_parse_check_min_severity() {
857 let cli = Cli::try_parse_from([
858 "cc-audit",
859 "check",
860 "--min-severity",
861 "critical",
862 "./skill/",
863 ])
864 .unwrap();
865 if let Some(Commands::Check(args)) = cli.command {
866 assert_eq!(args.min_severity, Some(Severity::Critical));
867 } else {
868 panic!("Expected Check command");
869 }
870 }
871
872 #[test]
873 fn test_parse_check_min_rule_severity() {
874 let cli = Cli::try_parse_from([
875 "cc-audit",
876 "check",
877 "--min-rule-severity",
878 "error",
879 "./skill/",
880 ])
881 .unwrap();
882 if let Some(Commands::Check(args)) = cli.command {
883 assert_eq!(args.min_rule_severity, Some(RuleSeverity::Error));
884 } else {
885 panic!("Expected Check command");
886 }
887 }
888
889 #[test]
890 fn test_parse_check_all_clients() {
891 let cli = Cli::try_parse_from(["cc-audit", "check", "--all-clients"]).unwrap();
892 if let Some(Commands::Check(args)) = cli.command {
893 assert!(args.all_clients);
894 assert!(args.paths.is_empty());
895 } else {
896 panic!("Expected Check command");
897 }
898 }
899
900 #[test]
901 fn test_parse_check_client_claude() {
902 let cli = Cli::try_parse_from(["cc-audit", "check", "--client", "claude"]).unwrap();
903 if let Some(Commands::Check(args)) = cli.command {
904 assert_eq!(args.client, Some(ClientType::Claude));
905 assert!(args.paths.is_empty());
906 } else {
907 panic!("Expected Check command");
908 }
909 }
910
911 #[test]
912 fn test_check_all_clients_conflicts_with_client() {
913 let result =
914 Cli::try_parse_from(["cc-audit", "check", "--all-clients", "--client", "claude"]);
915 assert!(result.is_err());
916 }
917
918 #[test]
921 fn test_parse_hook_init() {
922 let cli = Cli::try_parse_from(["cc-audit", "hook", "init"]).unwrap();
923 if let Some(Commands::Hook { action }) = cli.command {
924 assert!(matches!(action, HookAction::Init { .. }));
925 } else {
926 panic!("Expected Hook command");
927 }
928 }
929
930 #[test]
931 fn test_parse_hook_init_with_path() {
932 let cli = Cli::try_parse_from(["cc-audit", "hook", "init", "./repo/"]).unwrap();
933 if let Some(Commands::Hook { action }) = cli.command {
934 if let HookAction::Init { path } = action {
935 assert_eq!(path.to_str().unwrap(), "./repo/");
936 } else {
937 panic!("Expected HookAction::Init");
938 }
939 } else {
940 panic!("Expected Hook command");
941 }
942 }
943
944 #[test]
945 fn test_parse_hook_remove() {
946 let cli = Cli::try_parse_from(["cc-audit", "hook", "remove"]).unwrap();
947 if let Some(Commands::Hook { action }) = cli.command {
948 assert!(matches!(action, HookAction::Remove { .. }));
949 } else {
950 panic!("Expected Hook command");
951 }
952 }
953
954 #[test]
955 fn test_parse_hook_remove_with_path() {
956 let cli = Cli::try_parse_from(["cc-audit", "hook", "remove", "./repo/"]).unwrap();
957 if let Some(Commands::Hook { action }) = cli.command {
958 if let HookAction::Remove { path } = action {
959 assert_eq!(path.to_str().unwrap(), "./repo/");
960 } else {
961 panic!("Expected HookAction::Remove");
962 }
963 } else {
964 panic!("Expected Hook command");
965 }
966 }
967
968 #[test]
971 fn test_parse_serve() {
972 let cli = Cli::try_parse_from(["cc-audit", "serve"]).unwrap();
973 assert!(matches!(cli.command, Some(Commands::Serve)));
974 }
975
976 #[test]
979 fn test_parse_proxy() {
980 let cli = Cli::try_parse_from(["cc-audit", "proxy", "--target", "localhost:9000"]).unwrap();
981 if let Some(Commands::Proxy(args)) = cli.command {
982 assert_eq!(args.target, "localhost:9000");
983 assert_eq!(args.port, 8080); assert!(!args.tls);
985 assert!(!args.block);
986 } else {
987 panic!("Expected Proxy command");
988 }
989 }
990
991 #[test]
992 fn test_parse_proxy_with_all_options() {
993 let cli = Cli::try_parse_from([
994 "cc-audit",
995 "proxy",
996 "--target",
997 "localhost:9000",
998 "--port",
999 "3000",
1000 "--tls",
1001 "--block",
1002 "--log",
1003 "proxy.log",
1004 ])
1005 .unwrap();
1006 if let Some(Commands::Proxy(args)) = cli.command {
1007 assert_eq!(args.target, "localhost:9000");
1008 assert_eq!(args.port, 3000);
1009 assert!(args.tls);
1010 assert!(args.block);
1011 assert_eq!(args.log.unwrap().to_str().unwrap(), "proxy.log");
1012 } else {
1013 panic!("Expected Proxy command");
1014 }
1015 }
1016
1017 #[test]
1018 fn test_proxy_requires_target() {
1019 let result = Cli::try_parse_from(["cc-audit", "proxy"]);
1020 assert!(result.is_err());
1021 }
1022
1023 #[test]
1026 fn test_verbose_global_flag() {
1027 let cli = Cli::try_parse_from(["cc-audit", "-v", "check", "./skill/"]).unwrap();
1028 assert!(cli.verbose);
1029
1030 let cli2 = Cli::try_parse_from(["cc-audit", "check", "-v", "./skill/"]).unwrap();
1031 assert!(cli2.verbose);
1032
1033 let cli3 = Cli::try_parse_from(["cc-audit", "check", "./skill/", "-v"]).unwrap();
1034 assert!(cli3.verbose);
1035 }
1036}