crucible-mcp 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
crucible/cli.py CHANGED
@@ -393,6 +393,881 @@ def cmd_knowledge_install(args: argparse.Namespace) -> int:
393
393
  return 0
394
394
 
395
395
 
396
+ # --- Review command ---
397
+
398
+
399
+ def _load_review_config(repo_path: str | None = None) -> dict:
400
+ """Load review config with cascade priority.
401
+
402
+ Config file: .crucible/review.yaml or ~/.claude/crucible/review.yaml
403
+
404
+ Example config:
405
+ fail_on: high # default threshold
406
+ fail_on_domain: # per-domain overrides
407
+ smart_contract: critical # stricter for smart contracts
408
+ backend: high
409
+ include_context: false
410
+ skip_tools: []
411
+ """
412
+ import yaml
413
+
414
+ config_data: dict = {}
415
+ config_project = Path(".crucible/review.yaml")
416
+ config_user = Path.home() / ".claude" / "crucible" / "review.yaml"
417
+
418
+ if repo_path:
419
+ config_project = Path(repo_path) / ".crucible" / "review.yaml"
420
+
421
+ # Try project-level first
422
+ if config_project.exists():
423
+ try:
424
+ with open(config_project) as f:
425
+ config_data = yaml.safe_load(f) or {}
426
+ except Exception:
427
+ pass
428
+
429
+ # Fall back to user-level
430
+ if not config_data and config_user.exists():
431
+ try:
432
+ with open(config_user) as f:
433
+ config_data = yaml.safe_load(f) or {}
434
+ except Exception:
435
+ pass
436
+
437
+ return config_data
438
+
439
+
440
+ def cmd_review(args: argparse.Namespace) -> int:
441
+ """Run code review on git changes."""
442
+ import json as json_mod
443
+
444
+ from crucible.models import Domain, Severity, ToolFinding
445
+ from crucible.tools.delegation import (
446
+ delegate_bandit,
447
+ delegate_ruff,
448
+ delegate_semgrep,
449
+ delegate_slither,
450
+ )
451
+ from crucible.tools.git import (
452
+ get_branch_diff,
453
+ get_recent_commits,
454
+ get_repo_root,
455
+ get_staged_changes,
456
+ get_unstaged_changes,
457
+ is_git_repo,
458
+ )
459
+
460
+ # Helper functions (inline to avoid circular imports)
461
+ def get_semgrep_config(domain: Domain) -> str:
462
+ if domain == Domain.SMART_CONTRACT:
463
+ return "p/smart-contracts"
464
+ elif domain == Domain.BACKEND:
465
+ return "p/python"
466
+ elif domain == Domain.FRONTEND:
467
+ return "p/typescript"
468
+ return "auto"
469
+
470
+ def detect_domain(filename: str) -> tuple[Domain, list[str]]:
471
+ """Detect domain from file extension."""
472
+ ext = Path(filename).suffix.lower()
473
+ if ext == ".sol":
474
+ return Domain.SMART_CONTRACT, ["solidity", "smart_contract", "web3"]
475
+ elif ext in (".py", ".pyw"):
476
+ return Domain.BACKEND, ["python", "backend"]
477
+ elif ext in (".ts", ".tsx", ".js", ".jsx"):
478
+ return Domain.FRONTEND, ["typescript", "frontend", "javascript"]
479
+ elif ext == ".rs":
480
+ return Domain.BACKEND, ["rust", "backend"]
481
+ elif ext == ".go":
482
+ return Domain.BACKEND, ["go", "backend"]
483
+ return Domain.UNKNOWN, []
484
+
485
+ def get_changed_files(changes: list) -> list[str]:
486
+ """Get list of changed files (excluding deleted)."""
487
+ return [c.path for c in changes if c.status != "D"]
488
+
489
+ def parse_location_line(location: str) -> int | None:
490
+ """Extract line number from location string like 'file.py:10'."""
491
+ if ":" in location:
492
+ try:
493
+ return int(location.split(":")[1].split(":")[0])
494
+ except (ValueError, IndexError):
495
+ return None
496
+ return None
497
+
498
+ def filter_findings_to_changes(
499
+ findings: list[ToolFinding],
500
+ changes: list,
501
+ include_context: bool = False,
502
+ ) -> list[ToolFinding]:
503
+ """Filter findings to only those in changed lines."""
504
+ # Build a lookup of file -> changed line ranges
505
+ changed_ranges: dict[str, list[tuple[int, int]]] = {}
506
+ for change in changes:
507
+ if change.status == "D":
508
+ continue # Skip deleted files
509
+ ranges = [(r.start, r.end) for r in change.added_lines]
510
+ changed_ranges[change.path] = ranges
511
+
512
+ context_lines = 5 if include_context else 0
513
+ filtered: list[ToolFinding] = []
514
+
515
+ for finding in findings:
516
+ # Parse location: "path:line" or "path:line:col"
517
+ parts = finding.location.split(":")
518
+ if len(parts) < 2:
519
+ continue
520
+
521
+ file_path = parts[0]
522
+ try:
523
+ line_num = int(parts[1])
524
+ except ValueError:
525
+ continue
526
+
527
+ # Check if file is in changes
528
+ # Handle both absolute and relative paths
529
+ matching_file = None
530
+ for changed_file in changed_ranges:
531
+ if file_path.endswith(changed_file) or changed_file.endswith(file_path):
532
+ matching_file = changed_file
533
+ break
534
+
535
+ if not matching_file:
536
+ continue
537
+
538
+ # Check if line is in changed ranges (with context)
539
+ for start, end in changed_ranges[matching_file]:
540
+ if start - context_lines <= line_num <= end + context_lines:
541
+ filtered.append(finding)
542
+ break
543
+
544
+ return filtered
545
+
546
+ path = args.path or "."
547
+ mode = args.mode
548
+
549
+ # Validate git repo
550
+ if not is_git_repo(path):
551
+ print(f"Error: {path} is not a git repository")
552
+ return 1
553
+
554
+ root_result = get_repo_root(path)
555
+ if root_result.is_err:
556
+ print(f"Error: {root_result.error}")
557
+ return 1
558
+
559
+ repo_path = root_result.value
560
+
561
+ # Load config
562
+ config = _load_review_config(repo_path)
563
+
564
+ # Parse severity threshold (CLI overrides config)
565
+ severity_order = ["critical", "high", "medium", "low", "info"]
566
+ severity_map = {
567
+ "critical": Severity.CRITICAL,
568
+ "high": Severity.HIGH,
569
+ "medium": Severity.MEDIUM,
570
+ "low": Severity.LOW,
571
+ "info": Severity.INFO,
572
+ }
573
+
574
+ # Default threshold from CLI or config
575
+ default_threshold_str = args.fail_on or config.get("fail_on")
576
+ default_threshold: Severity | None = None
577
+ if default_threshold_str:
578
+ default_threshold = severity_map.get(default_threshold_str.lower())
579
+
580
+ # Per-domain thresholds from config
581
+ domain_thresholds: dict[Domain, Severity] = {}
582
+ for domain_str, sev_str in config.get("fail_on_domain", {}).items():
583
+ try:
584
+ domain = Domain(domain_str)
585
+ except ValueError:
586
+ # Try common aliases
587
+ domain_aliases = {
588
+ "solidity": Domain.SMART_CONTRACT,
589
+ "python": Domain.BACKEND,
590
+ "typescript": Domain.FRONTEND,
591
+ "javascript": Domain.FRONTEND,
592
+ }
593
+ domain = domain_aliases.get(domain_str.lower())
594
+ if domain and sev_str:
595
+ sev = severity_map.get(sev_str.lower())
596
+ if sev:
597
+ domain_thresholds[domain] = sev
598
+
599
+ # Include context from config if not specified on CLI
600
+ if not args.include_context and config.get("include_context"):
601
+ args.include_context = True
602
+
603
+ # Skip tools from config
604
+ skip_tools = set(config.get("skip_tools", []))
605
+
606
+ # Get changes based on mode
607
+ if mode == "staged":
608
+ context_result = get_staged_changes(repo_path)
609
+ elif mode == "unstaged":
610
+ context_result = get_unstaged_changes(repo_path)
611
+ elif mode == "branch":
612
+ base_branch = args.base if args.base else "main"
613
+ context_result = get_branch_diff(repo_path, base_branch)
614
+ elif mode == "commits":
615
+ try:
616
+ count = int(args.base) if args.base else 1
617
+ except ValueError:
618
+ print(f"Error: Invalid commit count '{args.base}'")
619
+ return 1
620
+ context_result = get_recent_commits(repo_path, count)
621
+ else:
622
+ print(f"Error: Unknown mode '{mode}'")
623
+ return 1
624
+
625
+ if context_result.is_err:
626
+ print(f"Error: {context_result.error}")
627
+ return 1
628
+
629
+ context = context_result.value
630
+
631
+ # Check for changes
632
+ if not context.changes:
633
+ if mode == "staged":
634
+ print("No changes to review. Stage files with `git add` first.")
635
+ elif mode == "unstaged":
636
+ print("No unstaged changes to review.")
637
+ else:
638
+ print("No changes found.")
639
+ return 0
640
+
641
+ changed_files = get_changed_files(context.changes)
642
+ if not changed_files:
643
+ print("No files to analyze (only deletions).")
644
+ return 0
645
+
646
+ # Run analysis
647
+ all_findings: list[ToolFinding] = []
648
+ tool_errors: list[str] = []
649
+
650
+ if not args.quiet and not args.json:
651
+ print(f"Reviewing {len(changed_files)} file(s)...")
652
+
653
+ # Track domains detected for per-domain threshold checking
654
+ domains_detected: set[Domain] = set()
655
+
656
+ for file_path in changed_files:
657
+ full_path = f"{repo_path}/{file_path}"
658
+ domain, domain_tags = detect_domain(file_path)
659
+ domains_detected.add(domain)
660
+
661
+ # Select tools based on domain
662
+ if domain == Domain.SMART_CONTRACT:
663
+ tools = ["slither", "semgrep"]
664
+ elif domain == Domain.BACKEND and "python" in domain_tags:
665
+ tools = ["ruff", "bandit", "semgrep"]
666
+ elif domain == Domain.FRONTEND:
667
+ tools = ["semgrep"]
668
+ else:
669
+ tools = ["semgrep"]
670
+
671
+ # Apply skip_tools from config
672
+ tools = [t for t in tools if t not in skip_tools]
673
+
674
+ # Run tools
675
+ if "semgrep" in tools:
676
+ semgrep_config = get_semgrep_config(domain)
677
+ result = delegate_semgrep(full_path, semgrep_config)
678
+ if result.is_ok:
679
+ all_findings.extend(result.value)
680
+ elif result.is_err:
681
+ tool_errors.append(f"semgrep ({file_path}): {result.error}")
682
+
683
+ if "ruff" in tools:
684
+ result = delegate_ruff(full_path)
685
+ if result.is_ok:
686
+ all_findings.extend(result.value)
687
+ elif result.is_err:
688
+ tool_errors.append(f"ruff ({file_path}): {result.error}")
689
+
690
+ if "slither" in tools:
691
+ result = delegate_slither(full_path)
692
+ if result.is_ok:
693
+ all_findings.extend(result.value)
694
+ elif result.is_err:
695
+ tool_errors.append(f"slither ({file_path}): {result.error}")
696
+
697
+ if "bandit" in tools:
698
+ result = delegate_bandit(full_path)
699
+ if result.is_ok:
700
+ all_findings.extend(result.value)
701
+ elif result.is_err:
702
+ tool_errors.append(f"bandit ({file_path}): {result.error}")
703
+
704
+ # Filter findings to changed lines
705
+ filtered_findings = filter_findings_to_changes(
706
+ all_findings, context.changes, args.include_context
707
+ )
708
+
709
+ # Deduplicate findings
710
+ def deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
711
+ """Deduplicate findings by location and message."""
712
+ seen: dict[tuple[str, str], ToolFinding] = {}
713
+ severity_order = [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO]
714
+
715
+ for f in findings:
716
+ norm_msg = f.message.lower().strip()
717
+ key = (f.location, norm_msg)
718
+
719
+ if key not in seen:
720
+ seen[key] = f
721
+ else:
722
+ existing = seen[key]
723
+ if severity_order.index(f.severity) < severity_order.index(existing.severity):
724
+ seen[key] = f
725
+
726
+ return list(seen.values())
727
+
728
+ filtered_findings = deduplicate_findings(filtered_findings)
729
+
730
+ # Compute severity summary
731
+ severity_counts: dict[str, int] = {}
732
+ for f in filtered_findings:
733
+ sev = f.severity.value
734
+ severity_counts[sev] = severity_counts.get(sev, 0) + 1
735
+
736
+ # Determine pass/fail using per-domain thresholds
737
+ # Use the strictest applicable threshold
738
+ passed = True
739
+ effective_threshold: Severity | None = default_threshold
740
+
741
+ # Check for per-domain thresholds (use strictest)
742
+ for domain in domains_detected:
743
+ if domain in domain_thresholds:
744
+ domain_thresh = domain_thresholds[domain]
745
+ if effective_threshold is None:
746
+ effective_threshold = domain_thresh
747
+ else:
748
+ # Use the stricter threshold (higher in severity_order = stricter)
749
+ if severity_order.index(domain_thresh.value) < severity_order.index(
750
+ effective_threshold.value
751
+ ):
752
+ effective_threshold = domain_thresh
753
+
754
+ if effective_threshold:
755
+ threshold_idx = severity_order.index(effective_threshold.value)
756
+ for sev in severity_order[: threshold_idx + 1]:
757
+ if severity_counts.get(sev, 0) > 0:
758
+ passed = False
759
+ break
760
+
761
+ # Track which threshold was used for output
762
+ threshold_used = effective_threshold.value if effective_threshold else None
763
+
764
+ # Output
765
+ if args.json:
766
+ output = {
767
+ "mode": mode,
768
+ "files_changed": len(changed_files),
769
+ "domains_detected": [d.value for d in domains_detected],
770
+ "findings": [
771
+ {
772
+ "tool": f.tool,
773
+ "rule": f.rule,
774
+ "severity": f.severity.value,
775
+ "message": f.message,
776
+ "location": f.location,
777
+ "suggestion": f.suggestion,
778
+ }
779
+ for f in filtered_findings
780
+ ],
781
+ "severity_counts": severity_counts,
782
+ "threshold": threshold_used,
783
+ "errors": tool_errors,
784
+ "passed": passed,
785
+ }
786
+ print(json_mod.dumps(output, indent=2))
787
+ elif getattr(args, "format", "text") == "report":
788
+ # Markdown report format
789
+ from datetime import datetime
790
+
791
+ print("# Code Review Report\n")
792
+ print(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M')}")
793
+ print(f"**Mode:** {mode} changes")
794
+ print(f"**Files reviewed:** {len(changed_files)}")
795
+ print(f"**Domains detected:** {', '.join(d.value for d in domains_detected)}")
796
+ if threshold_used:
797
+ print(f"**Threshold:** {threshold_used}")
798
+ print()
799
+
800
+ # Summary
801
+ print("## Summary\n")
802
+ if filtered_findings:
803
+ total = len(filtered_findings)
804
+ print(f"**{total} finding(s)** detected:\n")
805
+ for sev in ["critical", "high", "medium", "low", "info"]:
806
+ count = severity_counts.get(sev, 0)
807
+ if count > 0:
808
+ print(f"- {sev.upper()}: {count}")
809
+ print()
810
+ else:
811
+ print("No issues found in changed code.\n")
812
+
813
+ # Files changed
814
+ print("## Files Changed\n")
815
+ for c in context.changes:
816
+ status_char = {"A": "Added", "M": "Modified", "D": "Deleted", "R": "Renamed"}.get(c.status, "?")
817
+ print(f"- `{c.path}` ({status_char})")
818
+ print()
819
+
820
+ # Findings by severity
821
+ if filtered_findings:
822
+ print("## Findings\n")
823
+
824
+ # Group by severity
825
+ by_severity: dict[str, list] = {}
826
+ for f in filtered_findings:
827
+ sev = f.severity.value
828
+ if sev not in by_severity:
829
+ by_severity[sev] = []
830
+ by_severity[sev].append(f)
831
+
832
+ for sev in ["critical", "high", "medium", "low", "info"]:
833
+ if sev not in by_severity:
834
+ continue
835
+ print(f"### {sev.upper()}\n")
836
+ for f in by_severity[sev]:
837
+ print(f"#### `{f.location}`\n")
838
+ print(f"**Tool:** {f.tool} ")
839
+ print(f"**Rule:** {f.rule} ")
840
+ print(f"**Message:** {f.message}")
841
+ if f.suggestion:
842
+ print(f"\n**Suggestion:** {f.suggestion}")
843
+ print()
844
+
845
+ # Tool errors
846
+ if tool_errors:
847
+ print("## Tool Errors\n")
848
+ for error in tool_errors:
849
+ print(f"- {error}")
850
+ print()
851
+
852
+ # Result
853
+ print("## Result\n")
854
+ if effective_threshold:
855
+ status = "PASSED" if passed else "FAILED"
856
+ status_emoji = "PASS" if passed else "FAIL"
857
+ print(f"**Status:** {status_emoji} ({threshold_used} threshold)")
858
+ else:
859
+ print("**Status:** Complete (no threshold set)")
860
+ print()
861
+ print("---")
862
+ print("*Generated by Crucible*")
863
+ else:
864
+ # Text output
865
+ print(f"\n{'='*60}")
866
+ print(f"Review: {mode} changes")
867
+ print(f"{'='*60}")
868
+
869
+ print(f"\nFiles changed: {len(changed_files)}")
870
+ for c in context.changes:
871
+ status_char = {"A": "+", "M": "~", "D": "-", "R": "R"}.get(c.status, "?")
872
+ print(f" [{status_char}] {c.path}")
873
+
874
+ if tool_errors:
875
+ print(f"\nTool Errors ({len(tool_errors)}):")
876
+ for error in tool_errors:
877
+ print(f" - {error}")
878
+
879
+ if filtered_findings:
880
+ print(f"\nFindings ({len(filtered_findings)}):")
881
+ print(f" Summary: {severity_counts}")
882
+ print()
883
+ for f in filtered_findings:
884
+ sev_upper = f.severity.value.upper()
885
+ print(f" [{sev_upper}] {f.location}")
886
+ print(f" {f.tool}/{f.rule}: {f.message}")
887
+ if f.suggestion:
888
+ print(f" Suggestion: {f.suggestion}")
889
+ print()
890
+ else:
891
+ print("\nNo issues found in changed code.")
892
+
893
+ if effective_threshold:
894
+ status = "PASSED" if passed else "FAILED"
895
+ print(f"\n{'='*60}")
896
+ print(f"Result: {status} (threshold: {threshold_used})")
897
+ print(f"{'='*60}")
898
+
899
+ return 0 if passed else 1
900
+
901
+
902
+ # --- Hooks commands ---
903
+
904
+ PRECOMMIT_HOOK_SCRIPT = """\
905
+ #!/bin/sh
906
+ # Crucible pre-commit hook
907
+ # Checks for secrets/sensitive files and runs static analysis
908
+
909
+ crucible pre-commit "$@"
910
+ exit_code=$?
911
+
912
+ if [ $exit_code -ne 0 ]; then
913
+ echo ""
914
+ echo "Pre-commit check failed. Fix issues or use --no-verify to skip."
915
+ fi
916
+
917
+ exit $exit_code
918
+ """
919
+
920
+
921
+ def cmd_hooks_install(args: argparse.Namespace) -> int:
922
+ """Install git hooks to .git/hooks/."""
923
+ from crucible.tools.git import get_repo_root, is_git_repo
924
+
925
+ path = args.path or "."
926
+ if not is_git_repo(path):
927
+ print(f"Error: {path} is not a git repository")
928
+ return 1
929
+
930
+ root_result = get_repo_root(path)
931
+ if root_result.is_err:
932
+ print(f"Error: {root_result.error}")
933
+ return 1
934
+
935
+ repo_root = Path(root_result.value)
936
+ hooks_dir = repo_root / ".git" / "hooks"
937
+
938
+ if not hooks_dir.exists():
939
+ print(f"Error: hooks directory not found at {hooks_dir}")
940
+ return 1
941
+
942
+ hook_path = hooks_dir / "pre-commit"
943
+
944
+ if hook_path.exists():
945
+ content = hook_path.read_text()
946
+ is_crucible = "crucible" in content.lower()
947
+
948
+ if is_crucible and not args.force:
949
+ print("Crucible pre-commit hook is already installed")
950
+ return 0
951
+ elif not is_crucible and not args.force:
952
+ print(f"Error: {hook_path} already exists")
953
+ print(" Use --force to replace it")
954
+ return 1
955
+
956
+ hook_path.write_text(PRECOMMIT_HOOK_SCRIPT)
957
+ hook_path.chmod(0o755)
958
+
959
+ print(f"Installed pre-commit hook to {hook_path}")
960
+ print("\nThe hook checks for:")
961
+ print(" - Sensitive files (env, keys, credentials)")
962
+ print(" - Static analysis issues (semgrep, ruff, bandit, slither)")
963
+ print("\nUse 'git commit --no-verify' to skip if needed.")
964
+ return 0
965
+
966
+
967
+ def cmd_hooks_uninstall(args: argparse.Namespace) -> int:
968
+ """Uninstall crucible git hooks."""
969
+ from crucible.tools.git import get_repo_root, is_git_repo
970
+
971
+ path = args.path or "."
972
+ if not is_git_repo(path):
973
+ print(f"Error: {path} is not a git repository")
974
+ return 1
975
+
976
+ root_result = get_repo_root(path)
977
+ if root_result.is_err:
978
+ print(f"Error: {root_result.error}")
979
+ return 1
980
+
981
+ repo_root = Path(root_result.value)
982
+ hook_path = repo_root / ".git" / "hooks" / "pre-commit"
983
+
984
+ if not hook_path.exists():
985
+ print("No pre-commit hook installed")
986
+ return 0
987
+
988
+ # Check if it's our hook
989
+ content = hook_path.read_text()
990
+ if "crucible" not in content.lower():
991
+ print("Error: pre-commit hook exists but wasn't installed by crucible")
992
+ print(" Remove manually if you want to replace it")
993
+ return 1
994
+
995
+ hook_path.unlink()
996
+ print(f"Removed pre-commit hook from {hook_path}")
997
+ return 0
998
+
999
+
1000
+ def cmd_hooks_status(args: argparse.Namespace) -> int:
1001
+ """Show status of installed hooks."""
1002
+ from crucible.tools.git import get_repo_root, is_git_repo
1003
+
1004
+ path = args.path or "."
1005
+ if not is_git_repo(path):
1006
+ print(f"Error: {path} is not a git repository")
1007
+ return 1
1008
+
1009
+ root_result = get_repo_root(path)
1010
+ if root_result.is_err:
1011
+ print(f"Error: {root_result.error}")
1012
+ return 1
1013
+
1014
+ repo_root = Path(root_result.value)
1015
+ hook_path = repo_root / ".git" / "hooks" / "pre-commit"
1016
+
1017
+ print(f"Repository: {repo_root}")
1018
+ print()
1019
+
1020
+ if hook_path.exists():
1021
+ content = hook_path.read_text()
1022
+ if "crucible" in content.lower():
1023
+ print("pre-commit: INSTALLED (crucible)")
1024
+ else:
1025
+ print("pre-commit: EXISTS (not crucible)")
1026
+ else:
1027
+ print("pre-commit: NOT INSTALLED")
1028
+
1029
+ # Check for config file
1030
+ config_project = repo_root / ".crucible" / "precommit.yaml"
1031
+ config_user = Path.home() / ".claude" / "crucible" / "precommit.yaml"
1032
+
1033
+ print()
1034
+ if config_project.exists():
1035
+ print(f"Config: {config_project} (project)")
1036
+ elif config_user.exists():
1037
+ print(f"Config: {config_user} (user)")
1038
+ else:
1039
+ print("Config: using defaults")
1040
+
1041
+ return 0
1042
+
1043
+
1044
+ def cmd_precommit(args: argparse.Namespace) -> int:
1045
+ """Run pre-commit checks (can be called directly or from hook)."""
1046
+ from crucible.hooks.precommit import (
1047
+ EXIT_ERROR,
1048
+ EXIT_FAIL,
1049
+ EXIT_PASS,
1050
+ PrecommitConfig,
1051
+ _parse_severity,
1052
+ format_precommit_output,
1053
+ load_precommit_config,
1054
+ run_precommit,
1055
+ )
1056
+
1057
+ path = args.path or "."
1058
+ config = load_precommit_config(path)
1059
+
1060
+ # Apply CLI overrides
1061
+ if args.fail_on or args.verbose:
1062
+ config = PrecommitConfig(
1063
+ fail_on=_parse_severity(args.fail_on) if args.fail_on else config.fail_on,
1064
+ timeout=config.timeout,
1065
+ exclude=config.exclude,
1066
+ include_context=config.include_context,
1067
+ tools=config.tools,
1068
+ skip_tools=config.skip_tools,
1069
+ verbose=args.verbose or config.verbose,
1070
+ secrets_tool=config.secrets_tool,
1071
+ )
1072
+
1073
+ result = run_precommit(path, config)
1074
+
1075
+ if args.json:
1076
+ import json
1077
+ output = {
1078
+ "passed": result.passed,
1079
+ "findings": [
1080
+ {
1081
+ "tool": f.tool,
1082
+ "rule": f.rule,
1083
+ "severity": f.severity.value,
1084
+ "message": f.message,
1085
+ "location": f.location,
1086
+ "suggestion": f.suggestion,
1087
+ }
1088
+ for f in result.findings
1089
+ ],
1090
+ "severity_counts": result.severity_counts,
1091
+ "files_checked": result.files_checked,
1092
+ "error": result.error,
1093
+ }
1094
+ print(json.dumps(output, indent=2))
1095
+ else:
1096
+ print(format_precommit_output(result, args.verbose or config.verbose))
1097
+
1098
+ if result.error:
1099
+ return EXIT_ERROR
1100
+ return EXIT_PASS if result.passed else EXIT_FAIL
1101
+
1102
+
1103
+ # --- Init command ---
1104
+
1105
+
1106
+ def _detect_project_stack(path: Path) -> list[str]:
1107
+ """Detect the project's tech stack based on files present."""
1108
+ stack: list[str] = []
1109
+
1110
+ indicators = {
1111
+ "python": ["pyproject.toml", "setup.py", "requirements.txt", "Pipfile"],
1112
+ "typescript": ["tsconfig.json", "package.json"],
1113
+ "javascript": ["package.json"],
1114
+ "solidity": ["foundry.toml", "hardhat.config.js", "hardhat.config.ts", "truffle-config.js"],
1115
+ "rust": ["Cargo.toml"],
1116
+ "go": ["go.mod"],
1117
+ }
1118
+
1119
+ for tech, files in indicators.items():
1120
+ for file in files:
1121
+ if (path / file).exists():
1122
+ if tech not in stack:
1123
+ stack.append(tech)
1124
+ break
1125
+
1126
+ # Check for .sol files directly
1127
+ if not any(t in stack for t in ["solidity"]):
1128
+ sol_files = list(path.glob("**/*.sol"))
1129
+ if sol_files and len(sol_files) < 1000: # Sanity limit
1130
+ stack.append("solidity")
1131
+
1132
+ return stack
1133
+
1134
+
1135
+ def _get_recommended_skills(stack: list[str]) -> list[str]:
1136
+ """Get recommended skills based on detected stack."""
1137
+ skills: list[str] = ["security-engineer"] # Always recommend
1138
+
1139
+ stack_skills = {
1140
+ "python": ["backend-engineer"],
1141
+ "typescript": ["backend-engineer", "uiux-engineer"],
1142
+ "javascript": ["backend-engineer", "uiux-engineer"],
1143
+ "solidity": ["web3-engineer", "gas-optimizer", "protocol-architect"],
1144
+ "rust": ["backend-engineer", "performance-engineer"],
1145
+ "go": ["backend-engineer", "performance-engineer"],
1146
+ }
1147
+
1148
+ for tech in stack:
1149
+ if tech in stack_skills:
1150
+ for skill in stack_skills[tech]:
1151
+ if skill not in skills:
1152
+ skills.append(skill)
1153
+
1154
+ return skills
1155
+
1156
+
1157
+ def cmd_init(args: argparse.Namespace) -> int:
1158
+ """Initialize .crucible/ directory for project customization."""
1159
+ project_path = Path(args.path).resolve()
1160
+ crucible_dir = project_path / ".crucible"
1161
+
1162
+ if crucible_dir.exists() and not args.force:
1163
+ print(f"Error: {crucible_dir} already exists. Use --force to overwrite.")
1164
+ return 1
1165
+
1166
+ # Detect stack
1167
+ stack = _detect_project_stack(project_path)
1168
+ if stack:
1169
+ print(f"Detected stack: {', '.join(stack)}")
1170
+ else:
1171
+ print("No specific stack detected")
1172
+
1173
+ # Create directory structure
1174
+ (crucible_dir / "skills").mkdir(parents=True, exist_ok=True)
1175
+ (crucible_dir / "knowledge").mkdir(parents=True, exist_ok=True)
1176
+
1177
+ # Create default review.yaml
1178
+ review_config = """# Crucible review configuration
1179
+ # See: https://github.com/be-nvy/crucible
1180
+
1181
+ # Fail on findings at or above this severity
1182
+ fail_on: high
1183
+
1184
+ # Per-domain overrides (uncomment to customize)
1185
+ # fail_on_domain:
1186
+ # smart_contract: critical
1187
+ # backend: high
1188
+
1189
+ # Skip specific tools (uncomment to customize)
1190
+ # skip_tools:
1191
+ # - slither
1192
+
1193
+ # Include findings near (within 5 lines of) changes
1194
+ include_context: false
1195
+ """
1196
+ (crucible_dir / "review.yaml").write_text(review_config)
1197
+ print(f"Created {crucible_dir / 'review.yaml'}")
1198
+
1199
+ if not args.minimal:
1200
+ # Get recommended skills
1201
+ recommended = _get_recommended_skills(stack)
1202
+ print(f"\nRecommended skills for your stack: {', '.join(recommended)}")
1203
+ print("\nTo customize a skill, run:")
1204
+ for skill in recommended:
1205
+ print(f" crucible skills init {skill}")
1206
+
1207
+ # Create .gitignore if not exists
1208
+ gitignore_path = crucible_dir / ".gitignore"
1209
+ if not gitignore_path.exists():
1210
+ gitignore_path.write_text("# Local overrides (optional)\n*.local.md\n")
1211
+
1212
+ print(f"\nInitialized {crucible_dir}")
1213
+ print("\nNext steps:")
1214
+ print(" 1. Customize skills: crucible skills init <skill>")
1215
+ print(" 2. Customize knowledge: crucible knowledge init <file>")
1216
+ print(" 3. Install git hooks: crucible hooks install")
1217
+ return 0
1218
+
1219
+
1220
+ # --- CI command ---
1221
+
1222
+
1223
+ GITHUB_WORKFLOW_TEMPLATE = '''name: Crucible Code Review
1224
+
1225
+ on:
1226
+ pull_request:
1227
+ branches: [main, master]
1228
+ push:
1229
+ branches: [main, master]
1230
+
1231
+ jobs:
1232
+ review:
1233
+ runs-on: ubuntu-latest
1234
+ steps:
1235
+ - uses: actions/checkout@v4
1236
+ with:
1237
+ fetch-depth: 0 # Full history for branch comparison
1238
+
1239
+ - uses: actions/setup-python@v5
1240
+ with:
1241
+ python-version: "3.11"
1242
+
1243
+ - name: Install crucible
1244
+ run: pip install crucible-mcp
1245
+
1246
+ - name: Review changes
1247
+ run: |
1248
+ if [ "${{{{ github.event_name }}}}" = "pull_request" ]; then
1249
+ crucible review --mode branch --base ${{{{ github.base_ref }}}} --fail-on {fail_on}
1250
+ else
1251
+ crucible review --mode commits --base 1 --fail-on {fail_on}
1252
+ fi
1253
+ '''
1254
+
1255
+
1256
+ def cmd_ci_generate(args: argparse.Namespace) -> int:
1257
+ """Generate GitHub Actions workflow for crucible."""
1258
+ workflow = GITHUB_WORKFLOW_TEMPLATE.format(fail_on=args.fail_on)
1259
+
1260
+ if args.output:
1261
+ output_path = Path(args.output)
1262
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1263
+ output_path.write_text(workflow)
1264
+ print(f"Generated {output_path}")
1265
+ else:
1266
+ print(workflow)
1267
+
1268
+ return 0
1269
+
1270
+
396
1271
  # --- Main ---
397
1272
 
398
1273
 
@@ -470,9 +1345,156 @@ def main() -> int:
470
1345
  )
471
1346
  knowledge_show_parser.add_argument("file", help="Name of the file to show")
472
1347
 
1348
+ # === hooks command ===
1349
+ hooks_parser = subparsers.add_parser("hooks", help="Manage git hooks")
1350
+ hooks_sub = hooks_parser.add_subparsers(dest="hooks_command")
1351
+
1352
+ # hooks install
1353
+ hooks_install_parser = hooks_sub.add_parser(
1354
+ "install",
1355
+ help="Install crucible pre-commit hook"
1356
+ )
1357
+ hooks_install_parser.add_argument(
1358
+ "--force", "-f", action="store_true", help="Overwrite existing hook"
1359
+ )
1360
+ hooks_install_parser.add_argument(
1361
+ "path", nargs="?", default=".", help="Repository path"
1362
+ )
1363
+
1364
+ # hooks uninstall
1365
+ hooks_uninstall_parser = hooks_sub.add_parser(
1366
+ "uninstall",
1367
+ help="Remove crucible pre-commit hook"
1368
+ )
1369
+ hooks_uninstall_parser.add_argument(
1370
+ "path", nargs="?", default=".", help="Repository path"
1371
+ )
1372
+
1373
+ # hooks status
1374
+ hooks_status_parser = hooks_sub.add_parser(
1375
+ "status",
1376
+ help="Show hook installation status"
1377
+ )
1378
+ hooks_status_parser.add_argument(
1379
+ "path", nargs="?", default=".", help="Repository path"
1380
+ )
1381
+
1382
+ # === review command ===
1383
+ review_parser = subparsers.add_parser(
1384
+ "review",
1385
+ help="Review git changes (staged, unstaged, branch, commits)"
1386
+ )
1387
+ review_parser.add_argument(
1388
+ "--mode", "-m",
1389
+ choices=["staged", "unstaged", "branch", "commits"],
1390
+ default="staged",
1391
+ help="What changes to review (default: staged)"
1392
+ )
1393
+ review_parser.add_argument(
1394
+ "--base", "-b",
1395
+ help="Base branch for 'branch' mode or commit count for 'commits' mode"
1396
+ )
1397
+ review_parser.add_argument(
1398
+ "--fail-on",
1399
+ choices=["critical", "high", "medium", "low", "info"],
1400
+ help="Fail on findings at or above this severity"
1401
+ )
1402
+ review_parser.add_argument(
1403
+ "--include-context", "-c", action="store_true",
1404
+ help="Include findings near changed lines (within 5 lines)"
1405
+ )
1406
+ review_parser.add_argument(
1407
+ "--json", action="store_true",
1408
+ help="Output as JSON"
1409
+ )
1410
+ review_parser.add_argument(
1411
+ "--format", "-f",
1412
+ choices=["text", "report"],
1413
+ default="text",
1414
+ help="Output format: text (default) or report (markdown audit report)"
1415
+ )
1416
+ review_parser.add_argument(
1417
+ "--quiet", "-q", action="store_true",
1418
+ help="Suppress progress messages"
1419
+ )
1420
+ review_parser.add_argument(
1421
+ "path", nargs="?", default=".", help="Repository path"
1422
+ )
1423
+
1424
+ # === pre-commit command (direct invocation) ===
1425
+ precommit_parser = subparsers.add_parser(
1426
+ "pre-commit",
1427
+ help="Run pre-commit checks on staged changes"
1428
+ )
1429
+ precommit_parser.add_argument(
1430
+ "--fail-on",
1431
+ choices=["critical", "high", "medium", "low", "info"],
1432
+ help="Fail on findings at or above this severity"
1433
+ )
1434
+ precommit_parser.add_argument(
1435
+ "--verbose", "-v", action="store_true",
1436
+ help="Show all findings, not just high+"
1437
+ )
1438
+ precommit_parser.add_argument(
1439
+ "--json", action="store_true",
1440
+ help="Output as JSON"
1441
+ )
1442
+ precommit_parser.add_argument(
1443
+ "path", nargs="?", default=".", help="Repository path"
1444
+ )
1445
+
1446
+ # === init command ===
1447
+ init_proj_parser = subparsers.add_parser(
1448
+ "init",
1449
+ help="Initialize .crucible/ directory for project customization"
1450
+ )
1451
+ init_proj_parser.add_argument(
1452
+ "--force", "-f", action="store_true",
1453
+ help="Overwrite existing .crucible/ directory"
1454
+ )
1455
+ init_proj_parser.add_argument(
1456
+ "--minimal", action="store_true",
1457
+ help="Create minimal config without copying skills"
1458
+ )
1459
+ init_proj_parser.add_argument(
1460
+ "path", nargs="?", default=".",
1461
+ help="Project path (default: current directory)"
1462
+ )
1463
+
1464
+ # === ci command ===
1465
+ ci_parser = subparsers.add_parser(
1466
+ "ci",
1467
+ help="Generate CI configuration"
1468
+ )
1469
+ ci_sub = ci_parser.add_subparsers(dest="ci_command")
1470
+
1471
+ # ci generate
1472
+ ci_generate_parser = ci_sub.add_parser(
1473
+ "generate",
1474
+ help="Generate GitHub Actions workflow"
1475
+ )
1476
+ ci_generate_parser.add_argument(
1477
+ "--output", "-o",
1478
+ help="Output file path (default: stdout)"
1479
+ )
1480
+ ci_generate_parser.add_argument(
1481
+ "--fail-on",
1482
+ choices=["critical", "high", "medium", "low"],
1483
+ default="high",
1484
+ help="Fail threshold for CI (default: high)"
1485
+ )
1486
+
473
1487
  args = parser.parse_args()
474
1488
 
475
- if args.command == "skills":
1489
+ if args.command == "init":
1490
+ return cmd_init(args)
1491
+ elif args.command == "ci":
1492
+ if args.ci_command == "generate":
1493
+ return cmd_ci_generate(args)
1494
+ else:
1495
+ ci_parser.print_help()
1496
+ return 0
1497
+ elif args.command == "skills":
476
1498
  if args.skills_command == "install":
477
1499
  return cmd_skills_install(args)
478
1500
  elif args.skills_command == "list":
@@ -496,9 +1518,27 @@ def main() -> int:
496
1518
  else:
497
1519
  knowledge_parser.print_help()
498
1520
  return 0
1521
+ elif args.command == "hooks":
1522
+ if args.hooks_command == "install":
1523
+ return cmd_hooks_install(args)
1524
+ elif args.hooks_command == "uninstall":
1525
+ return cmd_hooks_uninstall(args)
1526
+ elif args.hooks_command == "status":
1527
+ return cmd_hooks_status(args)
1528
+ else:
1529
+ hooks_parser.print_help()
1530
+ return 0
1531
+ elif args.command == "review":
1532
+ return cmd_review(args)
1533
+ elif args.command == "pre-commit":
1534
+ return cmd_precommit(args)
499
1535
  else:
500
1536
  # Default help
501
1537
  print("crucible - Code review orchestration\n")
1538
+ print("Getting Started:")
1539
+ print(" crucible init Initialize .crucible/ for project customization")
1540
+ print(" crucible ci generate Generate GitHub Actions workflow")
1541
+ print()
502
1542
  print("Commands:")
503
1543
  print(" crucible skills list List skills from all sources")
504
1544
  print(" crucible skills install Install skills to ~/.claude/crucible/")
@@ -510,12 +1550,19 @@ def main() -> int:
510
1550
  print(" crucible knowledge init <file> Copy knowledge for project customization")
511
1551
  print(" crucible knowledge show <file> Show knowledge resolution")
512
1552
  print()
513
- print(" crucible-mcp Run as MCP server\n")
514
- print("MCP Tools:")
515
- print(" quick_review Run static analysis, returns findings + domains")
516
- print(" get_principles Load engineering checklists")
517
- print(" delegate_* Direct tool access (semgrep, ruff, slither, bandit)")
518
- print(" check_tools Show installed analysis tools")
1553
+ print(" crucible hooks install Install pre-commit hook to .git/hooks/")
1554
+ print(" crucible hooks uninstall Remove pre-commit hook")
1555
+ print(" crucible hooks status Show hook installation status")
1556
+ print()
1557
+ print(" crucible review Review git changes")
1558
+ print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
1559
+ print(" --base <ref> Base branch or commit count")
1560
+ print(" --fail-on <severity> Fail threshold (critical/high/medium/low/info)")
1561
+ print(" --format <format> Output format: text (default) or report (markdown)")
1562
+ print()
1563
+ print(" crucible pre-commit Run pre-commit checks on staged changes")
1564
+ print()
1565
+ print(" crucible-mcp Run as MCP server")
519
1566
  return 0
520
1567
 
521
1568