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)]
214 pub fix_hint: bool,
215
216 #[arg(long)]
218 pub compact: bool,
219
220 #[arg(short, long)]
222 pub watch: bool,
223
224 #[arg(long)]
226 pub malware_db: Option<PathBuf>,
227
228 #[arg(long)]
230 pub no_malware_scan: bool,
231
232 #[arg(long)]
234 pub cve_db: Option<PathBuf>,
235
236 #[arg(long)]
238 pub no_cve_scan: bool,
239
240 #[arg(long)]
242 pub custom_rules: Option<PathBuf>,
243
244 #[arg(long)]
246 pub baseline: bool,
247
248 #[arg(long)]
250 pub check_drift: bool,
251
252 #[arg(short, long)]
254 pub output: Option<PathBuf>,
255
256 #[arg(long, value_name = "FILE")]
258 pub save_baseline: Option<PathBuf>,
259
260 #[arg(long, value_name = "FILE")]
262 pub baseline_file: Option<PathBuf>,
263
264 #[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
266 pub compare: Option<Vec<PathBuf>>,
267
268 #[arg(long)]
270 pub fix: bool,
271
272 #[arg(long)]
274 pub fix_dry_run: bool,
275
276 #[arg(long)]
278 pub hook_mode: bool,
279
280 #[arg(long)]
282 pub pin: bool,
283
284 #[arg(long)]
286 pub pin_verify: bool,
287
288 #[arg(long)]
290 pub pin_update: bool,
291
292 #[arg(long)]
294 pub pin_force: bool,
295
296 #[arg(long)]
298 pub ignore_pin: bool,
299
300 #[arg(long)]
302 pub deep_scan: bool,
303
304 #[arg(long, value_name = "NAME")]
306 pub profile: Option<String>,
307
308 #[arg(long, value_name = "NAME")]
310 pub save_profile: Option<String>,
311
312 #[arg(long)]
314 pub report_fp: bool,
315
316 #[arg(long)]
318 pub report_fp_dry_run: bool,
319
320 #[arg(long, value_name = "URL")]
322 pub report_fp_endpoint: Option<String>,
323
324 #[arg(long)]
326 pub no_telemetry: bool,
327
328 #[arg(long)]
330 pub sbom: bool,
331
332 #[arg(long, value_name = "FORMAT")]
334 pub sbom_format: Option<String>,
335
336 #[arg(long)]
338 pub sbom_npm: bool,
339
340 #[arg(long)]
342 pub sbom_cargo: bool,
343}
344
345#[derive(Args, Debug, Clone)]
347pub struct ProxyArgs {
348 #[arg(long, default_value = "8080")]
350 pub port: u16,
351
352 #[arg(long, required = true, value_name = "HOST:PORT")]
354 pub target: String,
355
356 #[arg(long)]
358 pub tls: bool,
359
360 #[arg(long)]
362 pub block: bool,
363
364 #[arg(long, value_name = "FILE")]
366 pub log: Option<PathBuf>,
367}
368
369#[derive(Subcommand, Debug, Clone)]
371pub enum Commands {
372 Init {
374 #[arg(default_value = ".cc-audit.yaml")]
376 path: PathBuf,
377 },
378
379 Check(Box<CheckArgs>),
381
382 Hook {
384 #[command(subcommand)]
385 action: HookAction,
386 },
387
388 Serve,
390
391 Proxy(ProxyArgs),
393}
394
395#[derive(Parser, Debug, Default)]
396#[command(
397 name = "cc-audit",
398 version,
399 about = "Security auditor for Claude Code skills, hooks, and MCP servers",
400 long_about = "cc-audit scans Claude Code skills, hooks, and MCP servers for security vulnerabilities before installation."
401)]
402pub struct Cli {
403 #[command(subcommand)]
405 pub command: Option<Commands>,
406
407 #[arg(short, long, global = true)]
409 pub verbose: bool,
410}
411
412impl Default for CheckArgs {
413 fn default() -> Self {
414 Self {
415 paths: Vec::new(),
416 config: None,
417 all_clients: false,
418 client: None,
419 remote: None,
420 git_ref: "HEAD".to_string(),
421 remote_auth: None,
422 remote_list: None,
423 awesome_claude_code: false,
424 parallel_clones: 4,
425 badge: false,
426 badge_format: BadgeFormat::Markdown,
427 summary: false,
428 format: OutputFormat::Terminal,
429 strict: false,
430 warn_only: false,
431 min_severity: None,
432 min_rule_severity: None,
433 scan_type: ScanType::Skill,
434 no_recursive: false,
435 ci: false,
436 min_confidence: None,
437 skip_comments: false,
438 strict_secrets: false,
439 fix_hint: false,
440 compact: false,
441 watch: false,
442 malware_db: None,
443 no_malware_scan: false,
444 cve_db: None,
445 no_cve_scan: false,
446 custom_rules: None,
447 baseline: false,
448 check_drift: false,
449 output: None,
450 save_baseline: None,
451 baseline_file: None,
452 compare: None,
453 fix: false,
454 fix_dry_run: false,
455 hook_mode: false,
456 pin: false,
457 pin_verify: false,
458 pin_update: false,
459 pin_force: false,
460 ignore_pin: false,
461 deep_scan: false,
462 profile: None,
463 save_profile: None,
464 report_fp: false,
465 report_fp_dry_run: false,
466 report_fp_endpoint: None,
467 no_telemetry: false,
468 sbom: false,
469 sbom_format: None,
470 sbom_npm: false,
471 sbom_cargo: false,
472 }
473 }
474}
475
476impl CheckArgs {
477 pub fn for_scan(&self, paths: Vec<PathBuf>, effective: &EffectiveConfig) -> Self {
480 Self {
481 paths,
482 config: self.config.clone(),
483 remote: None,
484 git_ref: effective.git_ref.clone(),
485 remote_auth: effective.remote_auth.clone(),
486 remote_list: None,
487 awesome_claude_code: false,
488 parallel_clones: effective.parallel_clones,
489 badge: effective.badge,
490 badge_format: effective.badge_format,
491 summary: effective.summary,
492 format: effective.format,
493 strict: effective.strict,
494 warn_only: effective.warn_only,
495 min_severity: effective.min_severity,
496 min_rule_severity: effective.min_rule_severity,
497 scan_type: effective.scan_type,
498 no_recursive: false,
499 ci: effective.ci,
500 min_confidence: Some(effective.min_confidence),
501 watch: false,
502 skip_comments: effective.skip_comments,
503 strict_secrets: effective.strict_secrets,
504 fix_hint: effective.fix_hint,
505 compact: effective.compact,
506 no_malware_scan: effective.no_malware_scan,
507 cve_db: effective.cve_db.as_ref().map(PathBuf::from),
508 no_cve_scan: effective.no_cve_scan,
509 malware_db: effective.malware_db.as_ref().map(PathBuf::from),
510 custom_rules: effective.custom_rules.as_ref().map(PathBuf::from),
511 baseline: false,
512 check_drift: false,
513 output: effective.output.as_ref().map(PathBuf::from),
514 save_baseline: None,
515 baseline_file: self.baseline_file.clone(),
516 compare: None,
517 fix: false,
518 fix_dry_run: false,
519 pin: false,
520 pin_verify: false,
521 pin_update: false,
522 pin_force: false,
523 ignore_pin: false,
524 deep_scan: effective.deep_scan,
525 profile: self.profile.clone(),
526 save_profile: None,
527 all_clients: false,
528 client: None,
529 report_fp: false,
530 report_fp_dry_run: false,
531 report_fp_endpoint: None,
532 no_telemetry: self.no_telemetry,
533 sbom: false,
534 sbom_format: None,
535 sbom_npm: false,
536 sbom_cargo: false,
537 hook_mode: false,
538 }
539 }
540
541 pub fn for_batch_scan(&self, paths: Vec<PathBuf>, effective: &EffectiveConfig) -> Self {
543 let mut args = self.for_scan(paths, effective);
544 args.badge = false;
545 args.badge_format = BadgeFormat::Markdown;
546 args.summary = false;
547 args.format = OutputFormat::Terminal;
548 args.ci = false;
549 args.fix_hint = false;
550 args.output = None;
551 args.baseline_file = None;
552 args
553 }
554}
555
556impl Default for ProxyArgs {
557 fn default() -> Self {
558 Self {
559 port: 8080,
560 target: String::new(),
561 tls: false,
562 block: false,
563 log: None,
564 }
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use crate::rules::{Confidence, RuleSeverity, Severity};
572 use clap::CommandFactory;
573
574 #[test]
575 fn test_cli_valid() {
576 Cli::command().debug_assert();
577 }
578
579 #[test]
582 fn test_no_args_succeeds() {
583 let cli = Cli::try_parse_from(["cc-audit"]).unwrap();
584 assert!(cli.command.is_none());
585 }
586
587 #[test]
590 fn test_parse_init_subcommand() {
591 let cli = Cli::try_parse_from(["cc-audit", "init"]).unwrap();
592 assert!(matches!(cli.command, Some(Commands::Init { .. })));
593 }
594
595 #[test]
596 fn test_parse_init_subcommand_with_path() {
597 let cli = Cli::try_parse_from(["cc-audit", "init", "custom-config.yaml"]).unwrap();
598 if let Some(Commands::Init { path }) = cli.command {
599 assert_eq!(path.to_str().unwrap(), "custom-config.yaml");
600 } else {
601 panic!("Expected Init command");
602 }
603 }
604
605 #[test]
608 fn test_parse_check_subcommand() {
609 let cli = Cli::try_parse_from(["cc-audit", "check", "./skill/"]).unwrap();
610 if let Some(Commands::Check(args)) = cli.command {
611 assert_eq!(args.paths.len(), 1);
612 assert!(!args.strict);
613 assert!(!args.no_recursive); } else {
615 panic!("Expected Check command");
616 }
617 }
618
619 #[test]
620 fn test_parse_check_multiple_paths() {
621 let cli = Cli::try_parse_from(["cc-audit", "check", "./skill1/", "./skill2/"]).unwrap();
622 if let Some(Commands::Check(args)) = cli.command {
623 assert_eq!(args.paths.len(), 2);
624 } else {
625 panic!("Expected Check command");
626 }
627 }
628
629 #[test]
630 fn test_parse_check_format_json() {
631 let cli =
632 Cli::try_parse_from(["cc-audit", "check", "--format", "json", "./skill/"]).unwrap();
633 if let Some(Commands::Check(args)) = cli.command {
634 assert!(matches!(args.format, OutputFormat::Json));
635 } else {
636 panic!("Expected Check command");
637 }
638 }
639
640 #[test]
641 fn test_parse_check_strict_mode() {
642 let cli = Cli::try_parse_from(["cc-audit", "check", "--strict", "./skill/"]).unwrap();
643 if let Some(Commands::Check(args)) = cli.command {
644 assert!(args.strict);
645 } else {
646 panic!("Expected Check command");
647 }
648 }
649
650 #[test]
651 fn test_parse_check_no_recursive() {
652 let cli =
653 Cli::try_parse_from(["cc-audit", "check", "--no-recursive", "./skills/"]).unwrap();
654 if let Some(Commands::Check(args)) = cli.command {
655 assert!(args.no_recursive);
656 } else {
657 panic!("Expected Check command");
658 }
659 }
660
661 #[test]
662 fn test_parse_check_format_sarif() {
663 let cli =
664 Cli::try_parse_from(["cc-audit", "check", "--format", "sarif", "./skill/"]).unwrap();
665 if let Some(Commands::Check(args)) = cli.command {
666 assert!(matches!(args.format, OutputFormat::Sarif));
667 } else {
668 panic!("Expected Check command");
669 }
670 }
671
672 #[test]
673 fn test_parse_check_type_hook() {
674 let cli = Cli::try_parse_from(["cc-audit", "check", "--type", "hook", "./settings.json"])
675 .unwrap();
676 if let Some(Commands::Check(args)) = cli.command {
677 assert!(matches!(args.scan_type, ScanType::Hook));
678 } else {
679 panic!("Expected Check command");
680 }
681 }
682
683 #[test]
684 fn test_parse_check_type_mcp() {
685 let cli =
686 Cli::try_parse_from(["cc-audit", "check", "--type", "mcp", "./mcp.json"]).unwrap();
687 if let Some(Commands::Check(args)) = cli.command {
688 assert!(matches!(args.scan_type, ScanType::Mcp));
689 } else {
690 panic!("Expected Check command");
691 }
692 }
693
694 #[test]
695 fn test_parse_check_ci_mode() {
696 let cli = Cli::try_parse_from(["cc-audit", "check", "--ci", "./skill/"]).unwrap();
697 if let Some(Commands::Check(args)) = cli.command {
698 assert!(args.ci);
699 } else {
700 panic!("Expected Check command");
701 }
702 }
703
704 #[test]
705 fn test_parse_check_verbose() {
706 let cli = Cli::try_parse_from(["cc-audit", "-v", "check", "./skill/"]).unwrap();
707 assert!(cli.verbose);
708 }
709
710 #[test]
711 fn test_parse_check_all_options() {
712 let cli = Cli::try_parse_from([
713 "cc-audit", "check", "--format", "json", "--strict", "--type", "hook", "--ci",
714 "./path/",
715 ])
716 .unwrap();
717 if let Some(Commands::Check(args)) = cli.command {
718 assert!(matches!(args.format, OutputFormat::Json));
719 assert!(args.strict);
720 assert!(matches!(args.scan_type, ScanType::Hook));
721 assert!(args.ci);
722 } else {
723 panic!("Expected Check command");
724 }
725 }
726
727 #[test]
728 fn test_parse_check_default_values() {
729 let cli = Cli::try_parse_from(["cc-audit", "check", "./skill/"]).unwrap();
730 if let Some(Commands::Check(args)) = cli.command {
731 assert!(matches!(args.format, OutputFormat::Terminal));
732 assert!(matches!(args.scan_type, ScanType::Skill));
733 assert!(!args.strict);
734 assert!(!args.no_recursive);
735 assert!(!args.ci);
736 assert!(args.min_confidence.is_none());
737 } else {
738 panic!("Expected Check command");
739 }
740 }
741
742 #[test]
743 fn test_parse_check_min_confidence() {
744 let cli = Cli::try_parse_from([
745 "cc-audit",
746 "check",
747 "--min-confidence",
748 "tentative",
749 "./skill/",
750 ])
751 .unwrap();
752 if let Some(Commands::Check(args)) = cli.command {
753 assert!(matches!(args.min_confidence, Some(Confidence::Tentative)));
754 } else {
755 panic!("Expected Check command");
756 }
757 }
758
759 #[test]
760 fn test_parse_check_skip_comments() {
761 let cli =
762 Cli::try_parse_from(["cc-audit", "check", "--skip-comments", "./skill/"]).unwrap();
763 if let Some(Commands::Check(args)) = cli.command {
764 assert!(args.skip_comments);
765 } else {
766 panic!("Expected Check command");
767 }
768 }
769
770 #[test]
771 fn test_parse_check_watch() {
772 let cli = Cli::try_parse_from(["cc-audit", "check", "--watch", "./skill/"]).unwrap();
773 if let Some(Commands::Check(args)) = cli.command {
774 assert!(args.watch);
775 } else {
776 panic!("Expected Check command");
777 }
778 }
779
780 #[test]
781 fn test_parse_check_watch_short() {
782 let cli = Cli::try_parse_from(["cc-audit", "check", "-w", "./skill/"]).unwrap();
783 if let Some(Commands::Check(args)) = cli.command {
784 assert!(args.watch);
785 } else {
786 panic!("Expected Check command");
787 }
788 }
789
790 #[test]
791 fn test_parse_check_malware_db() {
792 let cli = Cli::try_parse_from([
793 "cc-audit",
794 "check",
795 "--malware-db",
796 "./custom.json",
797 "./skill/",
798 ])
799 .unwrap();
800 if let Some(Commands::Check(args)) = cli.command {
801 assert!(args.malware_db.is_some());
802 assert_eq!(args.malware_db.unwrap().to_str().unwrap(), "./custom.json");
803 } else {
804 panic!("Expected Check command");
805 }
806 }
807
808 #[test]
809 fn test_parse_check_custom_rules() {
810 let cli = Cli::try_parse_from([
811 "cc-audit",
812 "check",
813 "--custom-rules",
814 "./rules.yaml",
815 "./skill/",
816 ])
817 .unwrap();
818 if let Some(Commands::Check(args)) = cli.command {
819 assert!(args.custom_rules.is_some());
820 assert_eq!(args.custom_rules.unwrap().to_str().unwrap(), "./rules.yaml");
821 } else {
822 panic!("Expected Check command");
823 }
824 }
825
826 #[test]
827 fn test_parse_check_config_option() {
828 let cli =
829 Cli::try_parse_from(["cc-audit", "check", "-c", "custom.yaml", "./skill/"]).unwrap();
830 if let Some(Commands::Check(args)) = cli.command {
831 assert_eq!(args.config.unwrap().to_str().unwrap(), "custom.yaml");
832 } else {
833 panic!("Expected Check command");
834 }
835 }
836
837 #[test]
838 fn test_parse_check_warn_only() {
839 let cli = Cli::try_parse_from(["cc-audit", "check", "--warn-only", "./skill/"]).unwrap();
840 if let Some(Commands::Check(args)) = cli.command {
841 assert!(args.warn_only);
842 } else {
843 panic!("Expected Check command");
844 }
845 }
846
847 #[test]
848 fn test_parse_check_min_severity() {
849 let cli = Cli::try_parse_from([
850 "cc-audit",
851 "check",
852 "--min-severity",
853 "critical",
854 "./skill/",
855 ])
856 .unwrap();
857 if let Some(Commands::Check(args)) = cli.command {
858 assert_eq!(args.min_severity, Some(Severity::Critical));
859 } else {
860 panic!("Expected Check command");
861 }
862 }
863
864 #[test]
865 fn test_parse_check_min_rule_severity() {
866 let cli = Cli::try_parse_from([
867 "cc-audit",
868 "check",
869 "--min-rule-severity",
870 "error",
871 "./skill/",
872 ])
873 .unwrap();
874 if let Some(Commands::Check(args)) = cli.command {
875 assert_eq!(args.min_rule_severity, Some(RuleSeverity::Error));
876 } else {
877 panic!("Expected Check command");
878 }
879 }
880
881 #[test]
882 fn test_parse_check_all_clients() {
883 let cli = Cli::try_parse_from(["cc-audit", "check", "--all-clients"]).unwrap();
884 if let Some(Commands::Check(args)) = cli.command {
885 assert!(args.all_clients);
886 assert!(args.paths.is_empty());
887 } else {
888 panic!("Expected Check command");
889 }
890 }
891
892 #[test]
893 fn test_parse_check_client_claude() {
894 let cli = Cli::try_parse_from(["cc-audit", "check", "--client", "claude"]).unwrap();
895 if let Some(Commands::Check(args)) = cli.command {
896 assert_eq!(args.client, Some(ClientType::Claude));
897 assert!(args.paths.is_empty());
898 } else {
899 panic!("Expected Check command");
900 }
901 }
902
903 #[test]
904 fn test_check_all_clients_conflicts_with_client() {
905 let result =
906 Cli::try_parse_from(["cc-audit", "check", "--all-clients", "--client", "claude"]);
907 assert!(result.is_err());
908 }
909
910 #[test]
913 fn test_parse_hook_init() {
914 let cli = Cli::try_parse_from(["cc-audit", "hook", "init"]).unwrap();
915 if let Some(Commands::Hook { action }) = cli.command {
916 assert!(matches!(action, HookAction::Init { .. }));
917 } else {
918 panic!("Expected Hook command");
919 }
920 }
921
922 #[test]
923 fn test_parse_hook_init_with_path() {
924 let cli = Cli::try_parse_from(["cc-audit", "hook", "init", "./repo/"]).unwrap();
925 if let Some(Commands::Hook { action }) = cli.command {
926 if let HookAction::Init { path } = action {
927 assert_eq!(path.to_str().unwrap(), "./repo/");
928 } else {
929 panic!("Expected HookAction::Init");
930 }
931 } else {
932 panic!("Expected Hook command");
933 }
934 }
935
936 #[test]
937 fn test_parse_hook_remove() {
938 let cli = Cli::try_parse_from(["cc-audit", "hook", "remove"]).unwrap();
939 if let Some(Commands::Hook { action }) = cli.command {
940 assert!(matches!(action, HookAction::Remove { .. }));
941 } else {
942 panic!("Expected Hook command");
943 }
944 }
945
946 #[test]
947 fn test_parse_hook_remove_with_path() {
948 let cli = Cli::try_parse_from(["cc-audit", "hook", "remove", "./repo/"]).unwrap();
949 if let Some(Commands::Hook { action }) = cli.command {
950 if let HookAction::Remove { path } = action {
951 assert_eq!(path.to_str().unwrap(), "./repo/");
952 } else {
953 panic!("Expected HookAction::Remove");
954 }
955 } else {
956 panic!("Expected Hook command");
957 }
958 }
959
960 #[test]
963 fn test_parse_serve() {
964 let cli = Cli::try_parse_from(["cc-audit", "serve"]).unwrap();
965 assert!(matches!(cli.command, Some(Commands::Serve)));
966 }
967
968 #[test]
971 fn test_parse_proxy() {
972 let cli = Cli::try_parse_from(["cc-audit", "proxy", "--target", "localhost:9000"]).unwrap();
973 if let Some(Commands::Proxy(args)) = cli.command {
974 assert_eq!(args.target, "localhost:9000");
975 assert_eq!(args.port, 8080); assert!(!args.tls);
977 assert!(!args.block);
978 } else {
979 panic!("Expected Proxy command");
980 }
981 }
982
983 #[test]
984 fn test_parse_proxy_with_all_options() {
985 let cli = Cli::try_parse_from([
986 "cc-audit",
987 "proxy",
988 "--target",
989 "localhost:9000",
990 "--port",
991 "3000",
992 "--tls",
993 "--block",
994 "--log",
995 "proxy.log",
996 ])
997 .unwrap();
998 if let Some(Commands::Proxy(args)) = cli.command {
999 assert_eq!(args.target, "localhost:9000");
1000 assert_eq!(args.port, 3000);
1001 assert!(args.tls);
1002 assert!(args.block);
1003 assert_eq!(args.log.unwrap().to_str().unwrap(), "proxy.log");
1004 } else {
1005 panic!("Expected Proxy command");
1006 }
1007 }
1008
1009 #[test]
1010 fn test_proxy_requires_target() {
1011 let result = Cli::try_parse_from(["cc-audit", "proxy"]);
1012 assert!(result.is_err());
1013 }
1014
1015 #[test]
1018 fn test_verbose_global_flag() {
1019 let cli = Cli::try_parse_from(["cc-audit", "-v", "check", "./skill/"]).unwrap();
1020 assert!(cli.verbose);
1021
1022 let cli2 = Cli::try_parse_from(["cc-audit", "check", "-v", "./skill/"]).unwrap();
1023 assert!(cli2.verbose);
1024
1025 let cli3 = Cli::try_parse_from(["cc-audit", "check", "./skill/", "-v"]).unwrap();
1026 assert!(cli3.verbose);
1027 }
1028}