Skip to main content

cc_audit/
cli.rs

1use crate::client::ClientType;
2use crate::rules::{Confidence, ParseEnumError, RuleSeverity, Severity};
3use clap::{Args, Parser, Subcommand, ValueEnum};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum OutputFormat {
10    #[default]
11    Terminal,
12    Json,
13    Sarif,
14    Html,
15    Markdown,
16}
17
18impl std::str::FromStr for OutputFormat {
19    type Err = ParseEnumError;
20
21    fn from_str(s: &str) -> Result<Self, Self::Err> {
22        match s.to_lowercase().as_str() {
23            "terminal" | "term" => Ok(OutputFormat::Terminal),
24            "json" => Ok(OutputFormat::Json),
25            "sarif" => Ok(OutputFormat::Sarif),
26            "html" => Ok(OutputFormat::Html),
27            "markdown" | "md" => Ok(OutputFormat::Markdown),
28            _ => Err(ParseEnumError::invalid("OutputFormat", s)),
29        }
30    }
31}
32
33/// Badge output format for security badges
34#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum BadgeFormat {
37    /// shields.io URL only
38    Url,
39    /// Markdown badge with link
40    #[default]
41    Markdown,
42    /// HTML image tag
43    Html,
44}
45
46impl std::str::FromStr for BadgeFormat {
47    type Err = ParseEnumError;
48
49    fn from_str(s: &str) -> Result<Self, Self::Err> {
50        match s.to_lowercase().as_str() {
51            "url" => Ok(BadgeFormat::Url),
52            "markdown" | "md" => Ok(BadgeFormat::Markdown),
53            "html" => Ok(BadgeFormat::Html),
54            _ => Err(ParseEnumError::invalid("BadgeFormat", s)),
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum ScanType {
62    #[default]
63    Skill,
64    Hook,
65    Mcp,
66    Command,
67    Rules,
68    Docker,
69    Dependency,
70    /// Scan .claude/agents/ subagent definitions
71    Subagent,
72    /// Scan marketplace.json plugin definitions
73    Plugin,
74}
75
76impl std::str::FromStr for ScanType {
77    type Err = ParseEnumError;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        match s.to_lowercase().as_str() {
81            "skill" => Ok(ScanType::Skill),
82            "hook" => Ok(ScanType::Hook),
83            "mcp" => Ok(ScanType::Mcp),
84            "command" | "cmd" => Ok(ScanType::Command),
85            "rules" => Ok(ScanType::Rules),
86            "docker" => Ok(ScanType::Docker),
87            "dependency" | "dep" | "deps" => Ok(ScanType::Dependency),
88            "subagent" | "agent" => Ok(ScanType::Subagent),
89            "plugin" => Ok(ScanType::Plugin),
90            _ => Err(ParseEnumError::invalid("ScanType", s)),
91        }
92    }
93}
94
95/// Hook subcommand actions
96#[derive(Subcommand, Debug, Clone)]
97pub enum HookAction {
98    /// Install pre-commit hook
99    Init {
100        /// Path to git repository
101        #[arg(default_value = ".")]
102        path: PathBuf,
103    },
104    /// Remove pre-commit hook
105    Remove {
106        /// Path to git repository
107        #[arg(default_value = ".")]
108        path: PathBuf,
109    },
110}
111
112/// Arguments for the check subcommand
113#[derive(Args, Debug, Clone)]
114pub struct CheckArgs {
115    /// Paths to scan (files or directories)
116    #[arg(required_unless_present_any = ["remote", "remote_list", "awesome_claude_code", "all_clients", "client", "compare"])]
117    pub paths: Vec<PathBuf>,
118
119    /// Path to configuration file
120    #[arg(short = 'c', long = "config", value_name = "FILE")]
121    pub config: Option<PathBuf>,
122
123    /// Scan all installed AI coding clients (Claude, Cursor, Windsurf, VS Code)
124    #[arg(long, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "client"])]
125    pub all_clients: bool,
126
127    /// Scan a specific AI coding client
128    #[arg(long, value_enum, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "all_clients"])]
129    pub client: Option<ClientType>,
130
131    /// Remote repository URL to scan (e.g., `https://github.com/user/repo`)
132    #[arg(long, value_name = "URL")]
133    pub remote: Option<String>,
134
135    /// Git ref (branch, tag, or commit) for remote scan
136    #[arg(long, default_value = "HEAD")]
137    pub git_ref: String,
138
139    /// GitHub token for authentication (or use GITHUB_TOKEN env var)
140    #[arg(long, env = "GITHUB_TOKEN", value_name = "TOKEN")]
141    pub remote_auth: Option<String>,
142
143    /// File containing list of repository URLs to scan (one per line)
144    #[arg(long, conflicts_with = "remote", value_name = "FILE")]
145    pub remote_list: Option<PathBuf>,
146
147    /// Scan all repositories from awesome-claude-code
148    #[arg(long, conflicts_with_all = ["remote", "remote_list"])]
149    pub awesome_claude_code: bool,
150
151    /// Maximum number of parallel repository clones
152    #[arg(long, default_value = "4")]
153    pub parallel_clones: usize,
154
155    /// Generate security badge
156    #[arg(long)]
157    pub badge: bool,
158
159    /// Badge output format (url, markdown, html)
160    #[arg(long, value_enum, default_value_t = BadgeFormat::Markdown)]
161    pub badge_format: BadgeFormat,
162
163    /// Show summary only (for batch scans)
164    #[arg(long)]
165    pub summary: bool,
166
167    /// Output format
168    #[arg(short, long, value_enum, default_value_t = OutputFormat::Terminal)]
169    pub format: OutputFormat,
170
171    /// Strict mode: show medium/low severity findings and treat warnings as errors
172    #[arg(short = 'S', long)]
173    pub strict: bool,
174
175    /// Warn-only mode: treat all findings as warnings (exit code 0)
176    #[arg(long)]
177    pub warn_only: bool,
178
179    /// Minimum severity level to include in output (critical, high, medium, low)
180    #[arg(long, value_enum)]
181    pub min_severity: Option<Severity>,
182
183    /// Minimum rule severity to treat as errors (error, warn)
184    #[arg(long, value_enum)]
185    pub min_rule_severity: Option<RuleSeverity>,
186
187    /// Scan type
188    #[arg(short = 't', long = "type", value_enum, default_value_t = ScanType::Skill)]
189    pub scan_type: ScanType,
190
191    /// Disable recursive scanning (default: recursive enabled)
192    #[arg(long = "no-recursive")]
193    pub no_recursive: bool,
194
195    /// CI mode: non-interactive output
196    #[arg(long)]
197    pub ci: bool,
198
199    /// Minimum confidence level for findings to be reported
200    #[arg(long, value_enum)]
201    pub min_confidence: Option<Confidence>,
202
203    /// Skip comment lines when scanning (lines starting with #, //, --, etc.)
204    #[arg(long)]
205    pub skip_comments: bool,
206
207    /// Strict secrets mode: disable dummy key heuristics for test files
208    #[arg(long)]
209    pub strict_secrets: bool,
210
211    /// Show fix hints in terminal output
212    #[arg(long)]
213    pub fix_hint: bool,
214
215    /// Use compact output format (disable friendly advice)
216    #[arg(long)]
217    pub compact: bool,
218
219    /// Watch mode: continuously monitor files for changes and re-scan
220    #[arg(short, long)]
221    pub watch: bool,
222
223    /// Path to a custom malware signatures database (JSON)
224    #[arg(long)]
225    pub malware_db: Option<PathBuf>,
226
227    /// Disable malware signature scanning
228    #[arg(long)]
229    pub no_malware_scan: bool,
230
231    /// Path to a custom CVE database (JSON)
232    #[arg(long)]
233    pub cve_db: Option<PathBuf>,
234
235    /// Disable CVE vulnerability scanning
236    #[arg(long)]
237    pub no_cve_scan: bool,
238
239    /// Path to a custom rules file (YAML format)
240    #[arg(long)]
241    pub custom_rules: Option<PathBuf>,
242
243    /// Create a baseline snapshot for drift detection (rug pull prevention)
244    #[arg(long)]
245    pub baseline: bool,
246
247    /// Check for drift against saved baseline
248    #[arg(long)]
249    pub check_drift: bool,
250
251    /// Output file path (for HTML/JSON output)
252    #[arg(short, long)]
253    pub output: Option<PathBuf>,
254
255    /// Save baseline to specified file
256    #[arg(long, value_name = "FILE")]
257    pub save_baseline: Option<PathBuf>,
258
259    /// Compare against baseline file (show only new findings)
260    #[arg(long, value_name = "FILE")]
261    pub baseline_file: Option<PathBuf>,
262
263    /// Compare two paths and show differences
264    #[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
265    pub compare: Option<Vec<PathBuf>>,
266
267    /// Auto-fix issues (where possible)
268    #[arg(long)]
269    pub fix: bool,
270
271    /// Preview auto-fix changes without applying them
272    #[arg(long)]
273    pub fix_dry_run: bool,
274
275    /// Run as Claude Code Hook (reads from stdin, writes to stdout)
276    #[arg(long)]
277    pub hook_mode: bool,
278
279    /// Pin MCP tool configurations for rug-pull detection
280    #[arg(long)]
281    pub pin: bool,
282
283    /// Verify MCP tool pins against current configuration
284    #[arg(long)]
285    pub pin_verify: bool,
286
287    /// Update MCP tool pins with current configuration
288    #[arg(long)]
289    pub pin_update: bool,
290
291    /// Force overwrite existing pins
292    #[arg(long)]
293    pub pin_force: bool,
294
295    /// Skip pin verification during scan
296    #[arg(long)]
297    pub ignore_pin: bool,
298
299    /// Enable deep scan with deobfuscation
300    #[arg(long)]
301    pub deep_scan: bool,
302
303    /// Load settings from a named profile
304    #[arg(long, value_name = "NAME")]
305    pub profile: Option<String>,
306
307    /// Save current settings as a named profile
308    #[arg(long, value_name = "NAME")]
309    pub save_profile: Option<String>,
310
311    /// Report a false positive finding
312    #[arg(long)]
313    pub report_fp: bool,
314
315    /// Dry run mode for false positive reporting (print without submitting)
316    #[arg(long)]
317    pub report_fp_dry_run: bool,
318
319    /// Custom endpoint URL for false positive reporting
320    #[arg(long, value_name = "URL")]
321    pub report_fp_endpoint: Option<String>,
322
323    /// Disable telemetry and false positive reporting
324    #[arg(long)]
325    pub no_telemetry: bool,
326
327    /// Generate SBOM (Software Bill of Materials)
328    #[arg(long)]
329    pub sbom: bool,
330
331    /// SBOM output format (cyclonedx, spdx)
332    #[arg(long, value_name = "FORMAT")]
333    pub sbom_format: Option<String>,
334
335    /// Include npm dependencies in SBOM
336    #[arg(long)]
337    pub sbom_npm: bool,
338
339    /// Include Cargo dependencies in SBOM
340    #[arg(long)]
341    pub sbom_cargo: bool,
342}
343
344/// Arguments for the proxy subcommand
345#[derive(Args, Debug, Clone)]
346pub struct ProxyArgs {
347    /// Proxy listen port
348    #[arg(long, default_value = "8080")]
349    pub port: u16,
350
351    /// Target MCP server address (host:port)
352    #[arg(long, required = true, value_name = "HOST:PORT")]
353    pub target: String,
354
355    /// Enable TLS termination in proxy mode
356    #[arg(long)]
357    pub tls: bool,
358
359    /// Enable blocking mode (block messages with findings)
360    #[arg(long)]
361    pub block: bool,
362
363    /// Log file for proxy traffic (JSONL format)
364    #[arg(long, value_name = "FILE")]
365    pub log: Option<PathBuf>,
366}
367
368/// Subcommands for cc-audit
369#[derive(Subcommand, Debug, Clone)]
370pub enum Commands {
371    /// Generate a default configuration file template
372    Init {
373        /// Output path for the configuration file (default: .cc-audit.yaml)
374        #[arg(default_value = ".cc-audit.yaml")]
375        path: PathBuf,
376    },
377
378    /// Scan paths for security vulnerabilities
379    Check(Box<CheckArgs>),
380
381    /// Manage Git pre-commit hook
382    Hook {
383        #[command(subcommand)]
384        action: HookAction,
385    },
386
387    /// Run as MCP server
388    Serve,
389
390    /// Run as MCP proxy for runtime monitoring
391    Proxy(ProxyArgs),
392}
393
394#[derive(Parser, Debug, Default)]
395#[command(
396    name = "cc-audit",
397    version,
398    about = "Security auditor for Claude Code skills, hooks, and MCP servers",
399    long_about = "cc-audit scans Claude Code skills, hooks, and MCP servers for security vulnerabilities before installation."
400)]
401pub struct Cli {
402    /// Subcommand to run
403    #[command(subcommand)]
404    pub command: Option<Commands>,
405
406    /// Verbose output
407    #[arg(short, long, global = true)]
408    pub verbose: bool,
409}
410
411impl Default for CheckArgs {
412    fn default() -> Self {
413        Self {
414            paths: Vec::new(),
415            config: None,
416            all_clients: false,
417            client: None,
418            remote: None,
419            git_ref: "HEAD".to_string(),
420            remote_auth: None,
421            remote_list: None,
422            awesome_claude_code: false,
423            parallel_clones: 4,
424            badge: false,
425            badge_format: BadgeFormat::Markdown,
426            summary: false,
427            format: OutputFormat::Terminal,
428            strict: false,
429            warn_only: false,
430            min_severity: None,
431            min_rule_severity: None,
432            scan_type: ScanType::Skill,
433            no_recursive: false,
434            ci: false,
435            min_confidence: None,
436            skip_comments: false,
437            strict_secrets: false,
438            fix_hint: false,
439            compact: false,
440            watch: false,
441            malware_db: None,
442            no_malware_scan: false,
443            cve_db: None,
444            no_cve_scan: false,
445            custom_rules: None,
446            baseline: false,
447            check_drift: false,
448            output: None,
449            save_baseline: None,
450            baseline_file: None,
451            compare: None,
452            fix: false,
453            fix_dry_run: false,
454            hook_mode: false,
455            pin: false,
456            pin_verify: false,
457            pin_update: false,
458            pin_force: false,
459            ignore_pin: false,
460            deep_scan: false,
461            profile: None,
462            save_profile: None,
463            report_fp: false,
464            report_fp_dry_run: false,
465            report_fp_endpoint: None,
466            no_telemetry: false,
467            sbom: false,
468            sbom_format: None,
469            sbom_npm: false,
470            sbom_cargo: false,
471        }
472    }
473}
474
475impl Default for ProxyArgs {
476    fn default() -> Self {
477        Self {
478            port: 8080,
479            target: String::new(),
480            tls: false,
481            block: false,
482            log: None,
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::rules::{Confidence, RuleSeverity, Severity};
491    use clap::CommandFactory;
492
493    #[test]
494    fn test_cli_valid() {
495        Cli::command().debug_assert();
496    }
497
498    // ===== Test: No args shows help (command is None) =====
499
500    #[test]
501    fn test_no_args_succeeds() {
502        let cli = Cli::try_parse_from(["cc-audit"]).unwrap();
503        assert!(cli.command.is_none());
504    }
505
506    // ===== Test: init subcommand =====
507
508    #[test]
509    fn test_parse_init_subcommand() {
510        let cli = Cli::try_parse_from(["cc-audit", "init"]).unwrap();
511        assert!(matches!(cli.command, Some(Commands::Init { .. })));
512    }
513
514    #[test]
515    fn test_parse_init_subcommand_with_path() {
516        let cli = Cli::try_parse_from(["cc-audit", "init", "custom-config.yaml"]).unwrap();
517        if let Some(Commands::Init { path }) = cli.command {
518            assert_eq!(path.to_str().unwrap(), "custom-config.yaml");
519        } else {
520            panic!("Expected Init command");
521        }
522    }
523
524    // ===== Test: check subcommand =====
525
526    #[test]
527    fn test_parse_check_subcommand() {
528        let cli = Cli::try_parse_from(["cc-audit", "check", "./skill/"]).unwrap();
529        if let Some(Commands::Check(args)) = cli.command {
530            assert_eq!(args.paths.len(), 1);
531            assert!(!args.strict);
532            assert!(!args.no_recursive); // recursive is enabled by default
533        } else {
534            panic!("Expected Check command");
535        }
536    }
537
538    #[test]
539    fn test_parse_check_multiple_paths() {
540        let cli = Cli::try_parse_from(["cc-audit", "check", "./skill1/", "./skill2/"]).unwrap();
541        if let Some(Commands::Check(args)) = cli.command {
542            assert_eq!(args.paths.len(), 2);
543        } else {
544            panic!("Expected Check command");
545        }
546    }
547
548    #[test]
549    fn test_parse_check_format_json() {
550        let cli =
551            Cli::try_parse_from(["cc-audit", "check", "--format", "json", "./skill/"]).unwrap();
552        if let Some(Commands::Check(args)) = cli.command {
553            assert!(matches!(args.format, OutputFormat::Json));
554        } else {
555            panic!("Expected Check command");
556        }
557    }
558
559    #[test]
560    fn test_parse_check_strict_mode() {
561        let cli = Cli::try_parse_from(["cc-audit", "check", "--strict", "./skill/"]).unwrap();
562        if let Some(Commands::Check(args)) = cli.command {
563            assert!(args.strict);
564        } else {
565            panic!("Expected Check command");
566        }
567    }
568
569    #[test]
570    fn test_parse_check_no_recursive() {
571        let cli =
572            Cli::try_parse_from(["cc-audit", "check", "--no-recursive", "./skills/"]).unwrap();
573        if let Some(Commands::Check(args)) = cli.command {
574            assert!(args.no_recursive);
575        } else {
576            panic!("Expected Check command");
577        }
578    }
579
580    #[test]
581    fn test_parse_check_format_sarif() {
582        let cli =
583            Cli::try_parse_from(["cc-audit", "check", "--format", "sarif", "./skill/"]).unwrap();
584        if let Some(Commands::Check(args)) = cli.command {
585            assert!(matches!(args.format, OutputFormat::Sarif));
586        } else {
587            panic!("Expected Check command");
588        }
589    }
590
591    #[test]
592    fn test_parse_check_type_hook() {
593        let cli = Cli::try_parse_from(["cc-audit", "check", "--type", "hook", "./settings.json"])
594            .unwrap();
595        if let Some(Commands::Check(args)) = cli.command {
596            assert!(matches!(args.scan_type, ScanType::Hook));
597        } else {
598            panic!("Expected Check command");
599        }
600    }
601
602    #[test]
603    fn test_parse_check_type_mcp() {
604        let cli =
605            Cli::try_parse_from(["cc-audit", "check", "--type", "mcp", "./mcp.json"]).unwrap();
606        if let Some(Commands::Check(args)) = cli.command {
607            assert!(matches!(args.scan_type, ScanType::Mcp));
608        } else {
609            panic!("Expected Check command");
610        }
611    }
612
613    #[test]
614    fn test_parse_check_ci_mode() {
615        let cli = Cli::try_parse_from(["cc-audit", "check", "--ci", "./skill/"]).unwrap();
616        if let Some(Commands::Check(args)) = cli.command {
617            assert!(args.ci);
618        } else {
619            panic!("Expected Check command");
620        }
621    }
622
623    #[test]
624    fn test_parse_check_verbose() {
625        let cli = Cli::try_parse_from(["cc-audit", "-v", "check", "./skill/"]).unwrap();
626        assert!(cli.verbose);
627    }
628
629    #[test]
630    fn test_parse_check_all_options() {
631        let cli = Cli::try_parse_from([
632            "cc-audit", "check", "--format", "json", "--strict", "--type", "hook", "--ci",
633            "./path/",
634        ])
635        .unwrap();
636        if let Some(Commands::Check(args)) = cli.command {
637            assert!(matches!(args.format, OutputFormat::Json));
638            assert!(args.strict);
639            assert!(matches!(args.scan_type, ScanType::Hook));
640            assert!(args.ci);
641        } else {
642            panic!("Expected Check command");
643        }
644    }
645
646    #[test]
647    fn test_parse_check_default_values() {
648        let cli = Cli::try_parse_from(["cc-audit", "check", "./skill/"]).unwrap();
649        if let Some(Commands::Check(args)) = cli.command {
650            assert!(matches!(args.format, OutputFormat::Terminal));
651            assert!(matches!(args.scan_type, ScanType::Skill));
652            assert!(!args.strict);
653            assert!(!args.no_recursive);
654            assert!(!args.ci);
655            assert!(args.min_confidence.is_none());
656        } else {
657            panic!("Expected Check command");
658        }
659    }
660
661    #[test]
662    fn test_parse_check_min_confidence() {
663        let cli = Cli::try_parse_from([
664            "cc-audit",
665            "check",
666            "--min-confidence",
667            "tentative",
668            "./skill/",
669        ])
670        .unwrap();
671        if let Some(Commands::Check(args)) = cli.command {
672            assert!(matches!(args.min_confidence, Some(Confidence::Tentative)));
673        } else {
674            panic!("Expected Check command");
675        }
676    }
677
678    #[test]
679    fn test_parse_check_skip_comments() {
680        let cli =
681            Cli::try_parse_from(["cc-audit", "check", "--skip-comments", "./skill/"]).unwrap();
682        if let Some(Commands::Check(args)) = cli.command {
683            assert!(args.skip_comments);
684        } else {
685            panic!("Expected Check command");
686        }
687    }
688
689    #[test]
690    fn test_parse_check_watch() {
691        let cli = Cli::try_parse_from(["cc-audit", "check", "--watch", "./skill/"]).unwrap();
692        if let Some(Commands::Check(args)) = cli.command {
693            assert!(args.watch);
694        } else {
695            panic!("Expected Check command");
696        }
697    }
698
699    #[test]
700    fn test_parse_check_watch_short() {
701        let cli = Cli::try_parse_from(["cc-audit", "check", "-w", "./skill/"]).unwrap();
702        if let Some(Commands::Check(args)) = cli.command {
703            assert!(args.watch);
704        } else {
705            panic!("Expected Check command");
706        }
707    }
708
709    #[test]
710    fn test_parse_check_malware_db() {
711        let cli = Cli::try_parse_from([
712            "cc-audit",
713            "check",
714            "--malware-db",
715            "./custom.json",
716            "./skill/",
717        ])
718        .unwrap();
719        if let Some(Commands::Check(args)) = cli.command {
720            assert!(args.malware_db.is_some());
721            assert_eq!(args.malware_db.unwrap().to_str().unwrap(), "./custom.json");
722        } else {
723            panic!("Expected Check command");
724        }
725    }
726
727    #[test]
728    fn test_parse_check_custom_rules() {
729        let cli = Cli::try_parse_from([
730            "cc-audit",
731            "check",
732            "--custom-rules",
733            "./rules.yaml",
734            "./skill/",
735        ])
736        .unwrap();
737        if let Some(Commands::Check(args)) = cli.command {
738            assert!(args.custom_rules.is_some());
739            assert_eq!(args.custom_rules.unwrap().to_str().unwrap(), "./rules.yaml");
740        } else {
741            panic!("Expected Check command");
742        }
743    }
744
745    #[test]
746    fn test_parse_check_config_option() {
747        let cli =
748            Cli::try_parse_from(["cc-audit", "check", "-c", "custom.yaml", "./skill/"]).unwrap();
749        if let Some(Commands::Check(args)) = cli.command {
750            assert_eq!(args.config.unwrap().to_str().unwrap(), "custom.yaml");
751        } else {
752            panic!("Expected Check command");
753        }
754    }
755
756    #[test]
757    fn test_parse_check_warn_only() {
758        let cli = Cli::try_parse_from(["cc-audit", "check", "--warn-only", "./skill/"]).unwrap();
759        if let Some(Commands::Check(args)) = cli.command {
760            assert!(args.warn_only);
761        } else {
762            panic!("Expected Check command");
763        }
764    }
765
766    #[test]
767    fn test_parse_check_min_severity() {
768        let cli = Cli::try_parse_from([
769            "cc-audit",
770            "check",
771            "--min-severity",
772            "critical",
773            "./skill/",
774        ])
775        .unwrap();
776        if let Some(Commands::Check(args)) = cli.command {
777            assert_eq!(args.min_severity, Some(Severity::Critical));
778        } else {
779            panic!("Expected Check command");
780        }
781    }
782
783    #[test]
784    fn test_parse_check_min_rule_severity() {
785        let cli = Cli::try_parse_from([
786            "cc-audit",
787            "check",
788            "--min-rule-severity",
789            "error",
790            "./skill/",
791        ])
792        .unwrap();
793        if let Some(Commands::Check(args)) = cli.command {
794            assert_eq!(args.min_rule_severity, Some(RuleSeverity::Error));
795        } else {
796            panic!("Expected Check command");
797        }
798    }
799
800    #[test]
801    fn test_parse_check_all_clients() {
802        let cli = Cli::try_parse_from(["cc-audit", "check", "--all-clients"]).unwrap();
803        if let Some(Commands::Check(args)) = cli.command {
804            assert!(args.all_clients);
805            assert!(args.paths.is_empty());
806        } else {
807            panic!("Expected Check command");
808        }
809    }
810
811    #[test]
812    fn test_parse_check_client_claude() {
813        let cli = Cli::try_parse_from(["cc-audit", "check", "--client", "claude"]).unwrap();
814        if let Some(Commands::Check(args)) = cli.command {
815            assert_eq!(args.client, Some(ClientType::Claude));
816            assert!(args.paths.is_empty());
817        } else {
818            panic!("Expected Check command");
819        }
820    }
821
822    #[test]
823    fn test_check_all_clients_conflicts_with_client() {
824        let result =
825            Cli::try_parse_from(["cc-audit", "check", "--all-clients", "--client", "claude"]);
826        assert!(result.is_err());
827    }
828
829    // ===== Test: hook subcommand =====
830
831    #[test]
832    fn test_parse_hook_init() {
833        let cli = Cli::try_parse_from(["cc-audit", "hook", "init"]).unwrap();
834        if let Some(Commands::Hook { action }) = cli.command {
835            assert!(matches!(action, HookAction::Init { .. }));
836        } else {
837            panic!("Expected Hook command");
838        }
839    }
840
841    #[test]
842    fn test_parse_hook_init_with_path() {
843        let cli = Cli::try_parse_from(["cc-audit", "hook", "init", "./repo/"]).unwrap();
844        if let Some(Commands::Hook { action }) = cli.command {
845            if let HookAction::Init { path } = action {
846                assert_eq!(path.to_str().unwrap(), "./repo/");
847            } else {
848                panic!("Expected HookAction::Init");
849            }
850        } else {
851            panic!("Expected Hook command");
852        }
853    }
854
855    #[test]
856    fn test_parse_hook_remove() {
857        let cli = Cli::try_parse_from(["cc-audit", "hook", "remove"]).unwrap();
858        if let Some(Commands::Hook { action }) = cli.command {
859            assert!(matches!(action, HookAction::Remove { .. }));
860        } else {
861            panic!("Expected Hook command");
862        }
863    }
864
865    #[test]
866    fn test_parse_hook_remove_with_path() {
867        let cli = Cli::try_parse_from(["cc-audit", "hook", "remove", "./repo/"]).unwrap();
868        if let Some(Commands::Hook { action }) = cli.command {
869            if let HookAction::Remove { path } = action {
870                assert_eq!(path.to_str().unwrap(), "./repo/");
871            } else {
872                panic!("Expected HookAction::Remove");
873            }
874        } else {
875            panic!("Expected Hook command");
876        }
877    }
878
879    // ===== Test: serve subcommand =====
880
881    #[test]
882    fn test_parse_serve() {
883        let cli = Cli::try_parse_from(["cc-audit", "serve"]).unwrap();
884        assert!(matches!(cli.command, Some(Commands::Serve)));
885    }
886
887    // ===== Test: proxy subcommand =====
888
889    #[test]
890    fn test_parse_proxy() {
891        let cli = Cli::try_parse_from(["cc-audit", "proxy", "--target", "localhost:9000"]).unwrap();
892        if let Some(Commands::Proxy(args)) = cli.command {
893            assert_eq!(args.target, "localhost:9000");
894            assert_eq!(args.port, 8080); // default
895            assert!(!args.tls);
896            assert!(!args.block);
897        } else {
898            panic!("Expected Proxy command");
899        }
900    }
901
902    #[test]
903    fn test_parse_proxy_with_all_options() {
904        let cli = Cli::try_parse_from([
905            "cc-audit",
906            "proxy",
907            "--target",
908            "localhost:9000",
909            "--port",
910            "3000",
911            "--tls",
912            "--block",
913            "--log",
914            "proxy.log",
915        ])
916        .unwrap();
917        if let Some(Commands::Proxy(args)) = cli.command {
918            assert_eq!(args.target, "localhost:9000");
919            assert_eq!(args.port, 3000);
920            assert!(args.tls);
921            assert!(args.block);
922            assert_eq!(args.log.unwrap().to_str().unwrap(), "proxy.log");
923        } else {
924            panic!("Expected Proxy command");
925        }
926    }
927
928    #[test]
929    fn test_proxy_requires_target() {
930        let result = Cli::try_parse_from(["cc-audit", "proxy"]);
931        assert!(result.is_err());
932    }
933
934    // ===== Test: global verbose flag =====
935
936    #[test]
937    fn test_verbose_global_flag() {
938        let cli = Cli::try_parse_from(["cc-audit", "-v", "check", "./skill/"]).unwrap();
939        assert!(cli.verbose);
940
941        let cli2 = Cli::try_parse_from(["cc-audit", "check", "-v", "./skill/"]).unwrap();
942        assert!(cli2.verbose);
943
944        let cli3 = Cli::try_parse_from(["cc-audit", "check", "./skill/", "-v"]).unwrap();
945        assert!(cli3.verbose);
946    }
947}