Skip to main content

cc_audit/
cli.rs

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/// Badge output format for security badges
35#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum BadgeFormat {
38    /// shields.io URL only
39    Url,
40    /// Markdown badge with link
41    #[default]
42    Markdown,
43    /// HTML image tag
44    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    /// Auto: run every applicable specialized scanner (the default for a plain
64    /// `check` with no `--type`). Passing an explicit `--type` narrows the scan
65    /// to that single scanner (#155).
66    #[default]
67    Auto,
68    Skill,
69    Hook,
70    Mcp,
71    Command,
72    Rules,
73    Docker,
74    Dependency,
75    /// Scan .claude/agents/ subagent definitions
76    Subagent,
77    /// Scan marketplace.json plugin definitions
78    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/// Hook subcommand actions
102#[derive(Subcommand, Debug, Clone)]
103pub enum HookAction {
104    /// Install pre-commit hook
105    Init {
106        /// Path to git repository
107        #[arg(default_value = ".")]
108        path: PathBuf,
109    },
110    /// Remove pre-commit hook
111    Remove {
112        /// Path to git repository
113        #[arg(default_value = ".")]
114        path: PathBuf,
115    },
116}
117
118/// Arguments for the check subcommand
119#[derive(Args, Debug, Clone)]
120pub struct CheckArgs {
121    /// Paths to scan (files or directories)
122    #[arg(required_unless_present_any = ["remote", "remote_list", "awesome_claude_code", "all_clients", "client", "compare"])]
123    pub paths: Vec<PathBuf>,
124
125    /// Path to configuration file
126    #[arg(short = 'c', long = "config", value_name = "FILE")]
127    pub config: Option<PathBuf>,
128
129    /// Scan all installed AI coding clients (Claude, Cursor, Windsurf, VS Code)
130    #[arg(long, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "client"])]
131    pub all_clients: bool,
132
133    /// Scan a specific AI coding client
134    #[arg(long, value_enum, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "all_clients"])]
135    pub client: Option<ClientType>,
136
137    /// Remote repository URL to scan (e.g., `https://github.com/user/repo`)
138    #[arg(long, value_name = "URL")]
139    pub remote: Option<String>,
140
141    /// Git ref (branch, tag, or commit) for remote scan
142    #[arg(long, default_value = "HEAD")]
143    pub git_ref: String,
144
145    /// GitHub token for authentication (or use GITHUB_TOKEN env var)
146    #[arg(long, env = "GITHUB_TOKEN", value_name = "TOKEN")]
147    pub remote_auth: Option<String>,
148
149    /// File containing list of repository URLs to scan (one per line)
150    #[arg(long, conflicts_with = "remote", value_name = "FILE")]
151    pub remote_list: Option<PathBuf>,
152
153    /// Scan all repositories from awesome-claude-code
154    #[arg(long, conflicts_with_all = ["remote", "remote_list"])]
155    pub awesome_claude_code: bool,
156
157    /// Maximum number of parallel repository clones
158    #[arg(long, default_value = "4")]
159    pub parallel_clones: usize,
160
161    /// Generate security badge
162    #[arg(long)]
163    pub badge: bool,
164
165    /// Badge output format (url, markdown, html)
166    #[arg(long, value_enum, default_value_t = BadgeFormat::Markdown)]
167    pub badge_format: BadgeFormat,
168
169    /// Show summary only (for batch scans)
170    #[arg(long)]
171    pub summary: bool,
172
173    /// Output format
174    #[arg(short, long, value_enum, default_value_t = OutputFormat::Terminal)]
175    pub format: OutputFormat,
176
177    /// Strict mode: show medium/low severity findings and treat warnings as errors
178    #[arg(short = 'S', long)]
179    pub strict: bool,
180
181    /// Warn-only mode: treat all findings as warnings (exit code 0)
182    #[arg(long)]
183    pub warn_only: bool,
184
185    /// Minimum severity level to include in output (critical, high, medium, low)
186    #[arg(long, value_enum)]
187    pub min_severity: Option<Severity>,
188
189    /// Minimum rule severity to treat as errors (error, warn)
190    #[arg(long, value_enum)]
191    pub min_rule_severity: Option<RuleSeverity>,
192
193    /// Scan type
194    #[arg(short = 't', long = "type", value_enum, default_value_t = ScanType::Auto)]
195    pub scan_type: ScanType,
196
197    /// Disable recursive scanning (default: recursive enabled)
198    #[arg(long = "no-recursive")]
199    pub no_recursive: bool,
200
201    /// CI mode: non-interactive output
202    #[arg(long)]
203    pub ci: bool,
204
205    /// Minimum confidence level for findings to be reported
206    #[arg(long, value_enum)]
207    pub min_confidence: Option<Confidence>,
208
209    /// Skip comment lines when scanning (lines starting with #, //, --, etc.)
210    #[arg(long)]
211    pub skip_comments: bool,
212
213    /// Strict secrets mode: disable dummy key heuristics for test files
214    #[arg(long)]
215    pub strict_secrets: bool,
216
217    /// Honor in-band suppression directives (cc-audit-disable / cc-audit-ignore)
218    /// read from the scanned content. Off by default so untrusted artifacts
219    /// cannot disable rules on themselves; enable only for your own trusted code.
220    #[arg(long)]
221    pub allow_inline_suppression: bool,
222
223    /// Show fix hints in terminal output
224    #[arg(long)]
225    pub fix_hint: bool,
226
227    /// Use compact output format (disable friendly advice)
228    #[arg(long)]
229    pub compact: bool,
230
231    /// Watch mode: continuously monitor files for changes and re-scan
232    #[arg(short, long)]
233    pub watch: bool,
234
235    /// Path to a custom malware signatures database (JSON)
236    #[arg(long)]
237    pub malware_db: Option<PathBuf>,
238
239    /// Disable malware signature scanning
240    #[arg(long)]
241    pub no_malware_scan: bool,
242
243    /// Path to a custom CVE database (JSON)
244    #[arg(long)]
245    pub cve_db: Option<PathBuf>,
246
247    /// Disable CVE vulnerability scanning
248    #[arg(long)]
249    pub no_cve_scan: bool,
250
251    /// Path to a custom rules file (YAML format)
252    #[arg(long)]
253    pub custom_rules: Option<PathBuf>,
254
255    /// Create a baseline snapshot for drift detection (rug pull prevention)
256    #[arg(long)]
257    pub baseline: bool,
258
259    /// Check for drift against saved baseline
260    #[arg(long)]
261    pub check_drift: bool,
262
263    /// Output file path (for HTML/JSON output)
264    #[arg(short, long)]
265    pub output: Option<PathBuf>,
266
267    /// Save baseline to specified file
268    #[arg(long, value_name = "FILE")]
269    pub save_baseline: Option<PathBuf>,
270
271    /// Compare against baseline file (show only new findings)
272    #[arg(long, value_name = "FILE")]
273    pub baseline_file: Option<PathBuf>,
274
275    /// Compare two paths and show differences
276    #[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
277    pub compare: Option<Vec<PathBuf>>,
278
279    /// Auto-fix issues (where possible)
280    #[arg(long)]
281    pub fix: bool,
282
283    /// Preview auto-fix changes without applying them
284    #[arg(long)]
285    pub fix_dry_run: bool,
286
287    /// Run as Claude Code Hook (reads from stdin, writes to stdout)
288    #[arg(long)]
289    pub hook_mode: bool,
290
291    /// Pin MCP tool configurations for rug-pull detection
292    #[arg(long)]
293    pub pin: bool,
294
295    /// Verify MCP tool pins against current configuration
296    #[arg(long)]
297    pub pin_verify: bool,
298
299    /// Update MCP tool pins with current configuration
300    #[arg(long)]
301    pub pin_update: bool,
302
303    /// Force overwrite existing pins
304    #[arg(long)]
305    pub pin_force: bool,
306
307    /// Skip pin verification during scan
308    #[arg(long)]
309    pub ignore_pin: bool,
310
311    /// Enable deep scan with deobfuscation
312    #[arg(long)]
313    pub deep_scan: bool,
314
315    /// Load settings from a named profile
316    #[arg(long, value_name = "NAME")]
317    pub profile: Option<String>,
318
319    /// Save current settings as a named profile
320    #[arg(long, value_name = "NAME")]
321    pub save_profile: Option<String>,
322
323    /// Report a false positive finding
324    #[arg(long)]
325    pub report_fp: bool,
326
327    /// Dry run mode for false positive reporting (print without submitting)
328    #[arg(long)]
329    pub report_fp_dry_run: bool,
330
331    /// Custom endpoint URL for false positive reporting
332    #[arg(long, value_name = "URL")]
333    pub report_fp_endpoint: Option<String>,
334
335    /// Disable telemetry and false positive reporting
336    #[arg(long)]
337    pub no_telemetry: bool,
338
339    /// Generate SBOM (Software Bill of Materials)
340    #[arg(long)]
341    pub sbom: bool,
342
343    /// SBOM output format (cyclonedx, spdx)
344    #[arg(long, value_name = "FORMAT")]
345    pub sbom_format: Option<String>,
346
347    /// Include npm dependencies in SBOM
348    #[arg(long)]
349    pub sbom_npm: bool,
350
351    /// Include Cargo dependencies in SBOM
352    #[arg(long)]
353    pub sbom_cargo: bool,
354}
355
356/// Arguments for the proxy subcommand
357#[derive(Args, Debug, Clone)]
358pub struct ProxyArgs {
359    /// Proxy listen port
360    #[arg(long, default_value = "8080")]
361    pub port: u16,
362
363    /// Target MCP server address (host:port)
364    #[arg(long, required = true, value_name = "HOST:PORT")]
365    pub target: String,
366
367    /// Enable TLS termination in proxy mode
368    #[arg(long)]
369    pub tls: bool,
370
371    /// Enable blocking mode (block messages with findings)
372    #[arg(long)]
373    pub block: bool,
374
375    /// Log file for proxy traffic (JSONL format)
376    #[arg(long, value_name = "FILE")]
377    pub log: Option<PathBuf>,
378}
379
380/// Subcommands for cc-audit
381#[derive(Subcommand, Debug, Clone)]
382pub enum Commands {
383    /// Generate a default configuration file template
384    Init {
385        /// Output path for the configuration file (default: .cc-audit.yaml)
386        #[arg(default_value = ".cc-audit.yaml")]
387        path: PathBuf,
388    },
389
390    /// Scan paths for security vulnerabilities
391    Check(Box<CheckArgs>),
392
393    /// Manage Git pre-commit hook
394    Hook {
395        #[command(subcommand)]
396        action: HookAction,
397    },
398
399    /// Run as MCP server
400    Serve,
401
402    /// Run as MCP proxy for runtime monitoring
403    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    /// Subcommand to run
415    #[command(subcommand)]
416    pub command: Option<Commands>,
417
418    /// Verbose output
419    #[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    /// サブスキャン用の CheckArgs を作成。self と EffectiveConfig から設定を継承する。
490    /// remote/compare/baseline 等のサブスキャン不要なフィールドはリセットされる。
491    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    /// バッチスキャン用の CheckArgs を作成。badge/summary を無効化し、Terminal 形式にする。
555    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: No args shows help (command is None) =====
593
594    #[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: init subcommand =====
601
602    #[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: check subcommand =====
619
620    #[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); // recursive is enabled by default
627        } 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            // Default with no --type is Auto: fan out across all scanners (#155).
746            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: hook subcommand =====
925
926    #[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: serve subcommand =====
975
976    #[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: proxy subcommand =====
983
984    #[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); // default
990            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: global verbose flag =====
1030
1031    #[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}