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