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