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