crucible-mcp 0.1.0__py3-none-any.whl → 0.3.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,946 @@ 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
+ all_domain_tags: set[str] = set()
656
+
657
+ for file_path in changed_files:
658
+ full_path = f"{repo_path}/{file_path}"
659
+ domain, domain_tags = detect_domain(file_path)
660
+ domains_detected.add(domain)
661
+ all_domain_tags.update(domain_tags)
662
+
663
+ # Select tools based on domain
664
+ if domain == Domain.SMART_CONTRACT:
665
+ tools = ["slither", "semgrep"]
666
+ elif domain == Domain.BACKEND and "python" in domain_tags:
667
+ tools = ["ruff", "bandit", "semgrep"]
668
+ elif domain == Domain.FRONTEND:
669
+ tools = ["semgrep"]
670
+ else:
671
+ tools = ["semgrep"]
672
+
673
+ # Apply skip_tools from config
674
+ tools = [t for t in tools if t not in skip_tools]
675
+
676
+ # Run tools
677
+ if "semgrep" in tools:
678
+ semgrep_config = get_semgrep_config(domain)
679
+ result = delegate_semgrep(full_path, semgrep_config)
680
+ if result.is_ok:
681
+ all_findings.extend(result.value)
682
+ elif result.is_err:
683
+ tool_errors.append(f"semgrep ({file_path}): {result.error}")
684
+
685
+ if "ruff" in tools:
686
+ result = delegate_ruff(full_path)
687
+ if result.is_ok:
688
+ all_findings.extend(result.value)
689
+ elif result.is_err:
690
+ tool_errors.append(f"ruff ({file_path}): {result.error}")
691
+
692
+ if "slither" in tools:
693
+ result = delegate_slither(full_path)
694
+ if result.is_ok:
695
+ all_findings.extend(result.value)
696
+ elif result.is_err:
697
+ tool_errors.append(f"slither ({file_path}): {result.error}")
698
+
699
+ if "bandit" in tools:
700
+ result = delegate_bandit(full_path)
701
+ if result.is_ok:
702
+ all_findings.extend(result.value)
703
+ elif result.is_err:
704
+ tool_errors.append(f"bandit ({file_path}): {result.error}")
705
+
706
+ # Filter findings to changed lines
707
+ filtered_findings = filter_findings_to_changes(
708
+ all_findings, context.changes, args.include_context
709
+ )
710
+
711
+ # Deduplicate findings
712
+ def deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
713
+ """Deduplicate findings by location and message."""
714
+ seen: dict[tuple[str, str], ToolFinding] = {}
715
+ severity_order = [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO]
716
+
717
+ for f in findings:
718
+ norm_msg = f.message.lower().strip()
719
+ key = (f.location, norm_msg)
720
+
721
+ if key not in seen:
722
+ seen[key] = f
723
+ else:
724
+ existing = seen[key]
725
+ if severity_order.index(f.severity) < severity_order.index(existing.severity):
726
+ seen[key] = f
727
+
728
+ return list(seen.values())
729
+
730
+ filtered_findings = deduplicate_findings(filtered_findings)
731
+
732
+ # Match skills and load knowledge based on detected domains
733
+ from crucible.knowledge.loader import load_knowledge_file
734
+ from crucible.skills.loader import (
735
+ get_knowledge_for_skills,
736
+ load_skill,
737
+ match_skills_for_domain,
738
+ )
739
+
740
+ # Use first domain for primary matching (most files determine this)
741
+ primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
742
+ matched_skills = match_skills_for_domain(
743
+ primary_domain, list(all_domain_tags), override=None
744
+ )
745
+
746
+ # Load skill content
747
+ skill_names = [name for name, _ in matched_skills]
748
+ skill_content: dict[str, str] = {}
749
+ for skill_name, _triggers in matched_skills:
750
+ result = load_skill(skill_name)
751
+ if result.is_ok:
752
+ _, content = result.value
753
+ skill_content[skill_name] = content
754
+
755
+ # Load linked knowledge
756
+ knowledge_files = get_knowledge_for_skills(skill_names)
757
+ knowledge_content: dict[str, str] = {}
758
+ for filename in knowledge_files:
759
+ result = load_knowledge_file(filename)
760
+ if result.is_ok:
761
+ knowledge_content[filename] = result.value
762
+
763
+ # Compute severity summary
764
+ severity_counts: dict[str, int] = {}
765
+ for f in filtered_findings:
766
+ sev = f.severity.value
767
+ severity_counts[sev] = severity_counts.get(sev, 0) + 1
768
+
769
+ # Determine pass/fail using per-domain thresholds
770
+ # Use the strictest applicable threshold
771
+ passed = True
772
+ effective_threshold: Severity | None = default_threshold
773
+
774
+ # Check for per-domain thresholds (use strictest)
775
+ for domain in domains_detected:
776
+ if domain in domain_thresholds:
777
+ domain_thresh = domain_thresholds[domain]
778
+ if effective_threshold is None:
779
+ effective_threshold = domain_thresh
780
+ else:
781
+ # Use the stricter threshold (higher in severity_order = stricter)
782
+ if severity_order.index(domain_thresh.value) < severity_order.index(
783
+ effective_threshold.value
784
+ ):
785
+ effective_threshold = domain_thresh
786
+
787
+ if effective_threshold:
788
+ threshold_idx = severity_order.index(effective_threshold.value)
789
+ for sev in severity_order[: threshold_idx + 1]:
790
+ if severity_counts.get(sev, 0) > 0:
791
+ passed = False
792
+ break
793
+
794
+ # Track which threshold was used for output
795
+ threshold_used = effective_threshold.value if effective_threshold else None
796
+
797
+ # Output
798
+ if args.json:
799
+ output = {
800
+ "mode": mode,
801
+ "files_changed": len(changed_files),
802
+ "domains_detected": [d.value for d in domains_detected],
803
+ "skills_matched": dict(matched_skills),
804
+ "knowledge_loaded": list(knowledge_files),
805
+ "findings": [
806
+ {
807
+ "tool": f.tool,
808
+ "rule": f.rule,
809
+ "severity": f.severity.value,
810
+ "message": f.message,
811
+ "location": f.location,
812
+ "suggestion": f.suggestion,
813
+ }
814
+ for f in filtered_findings
815
+ ],
816
+ "severity_counts": severity_counts,
817
+ "threshold": threshold_used,
818
+ "errors": tool_errors,
819
+ "passed": passed,
820
+ }
821
+ print(json_mod.dumps(output, indent=2))
822
+ elif getattr(args, "format", "text") == "report":
823
+ # Markdown report format
824
+ from datetime import datetime
825
+
826
+ print("# Code Review Report\n")
827
+ print(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M')}")
828
+ print(f"**Mode:** {mode} changes")
829
+ print(f"**Files reviewed:** {len(changed_files)}")
830
+ print(f"**Domains detected:** {', '.join(d.value for d in domains_detected)}")
831
+ if threshold_used:
832
+ print(f"**Threshold:** {threshold_used}")
833
+ print()
834
+
835
+ # Summary
836
+ print("## Summary\n")
837
+ if filtered_findings:
838
+ total = len(filtered_findings)
839
+ print(f"**{total} finding(s)** detected:\n")
840
+ for sev in ["critical", "high", "medium", "low", "info"]:
841
+ count = severity_counts.get(sev, 0)
842
+ if count > 0:
843
+ print(f"- {sev.upper()}: {count}")
844
+ print()
845
+ else:
846
+ print("No issues found in changed code.\n")
847
+
848
+ # Files changed
849
+ print("## Files Changed\n")
850
+ for c in context.changes:
851
+ status_char = {"A": "Added", "M": "Modified", "D": "Deleted", "R": "Renamed"}.get(c.status, "?")
852
+ print(f"- `{c.path}` ({status_char})")
853
+ print()
854
+
855
+ # Skills matched
856
+ if matched_skills:
857
+ print("## Applicable Skills\n")
858
+ for skill_name, triggers in matched_skills:
859
+ print(f"- **{skill_name}**: matched on {', '.join(triggers)}")
860
+ print()
861
+
862
+ # Knowledge loaded
863
+ if knowledge_files:
864
+ print("## Knowledge Loaded\n")
865
+ print(f"Files: {', '.join(sorted(knowledge_files))}")
866
+ print()
867
+
868
+ # Findings by severity
869
+ if filtered_findings:
870
+ print("## Findings\n")
871
+
872
+ # Group by severity
873
+ by_severity: dict[str, list] = {}
874
+ for f in filtered_findings:
875
+ sev = f.severity.value
876
+ if sev not in by_severity:
877
+ by_severity[sev] = []
878
+ by_severity[sev].append(f)
879
+
880
+ for sev in ["critical", "high", "medium", "low", "info"]:
881
+ if sev not in by_severity:
882
+ continue
883
+ print(f"### {sev.upper()}\n")
884
+ for f in by_severity[sev]:
885
+ print(f"#### `{f.location}`\n")
886
+ print(f"**Tool:** {f.tool} ")
887
+ print(f"**Rule:** {f.rule} ")
888
+ print(f"**Message:** {f.message}")
889
+ if f.suggestion:
890
+ print(f"\n**Suggestion:** {f.suggestion}")
891
+ print()
892
+
893
+ # Tool errors
894
+ if tool_errors:
895
+ print("## Tool Errors\n")
896
+ for error in tool_errors:
897
+ print(f"- {error}")
898
+ print()
899
+
900
+ # Review checklists from skills
901
+ if skill_content:
902
+ print("## Review Checklists\n")
903
+ for skill_name, content in skill_content.items():
904
+ print(f"### {skill_name}\n")
905
+ # Print the skill content (already markdown)
906
+ print(content)
907
+ print()
908
+
909
+ # Knowledge reference
910
+ if knowledge_content:
911
+ print("## Principles Reference\n")
912
+ for filename, content in sorted(knowledge_content.items()):
913
+ print(f"### {filename}\n")
914
+ print(content)
915
+ print()
916
+
917
+ # Result
918
+ print("## Result\n")
919
+ if effective_threshold:
920
+ status = "PASSED" if passed else "FAILED"
921
+ status_emoji = "PASS" if passed else "FAIL"
922
+ print(f"**Status:** {status_emoji} ({threshold_used} threshold)")
923
+ else:
924
+ print("**Status:** Complete (no threshold set)")
925
+ print()
926
+ print("---")
927
+ print("*Generated by Crucible*")
928
+ else:
929
+ # Text output
930
+ print(f"\n{'='*60}")
931
+ print(f"Review: {mode} changes")
932
+ print(f"{'='*60}")
933
+
934
+ print(f"\nFiles changed: {len(changed_files)}")
935
+ for c in context.changes:
936
+ status_char = {"A": "+", "M": "~", "D": "-", "R": "R"}.get(c.status, "?")
937
+ print(f" [{status_char}] {c.path}")
938
+
939
+ if tool_errors:
940
+ print(f"\nTool Errors ({len(tool_errors)}):")
941
+ for error in tool_errors:
942
+ print(f" - {error}")
943
+
944
+ if filtered_findings:
945
+ print(f"\nFindings ({len(filtered_findings)}):")
946
+ print(f" Summary: {severity_counts}")
947
+ print()
948
+ for f in filtered_findings:
949
+ sev_upper = f.severity.value.upper()
950
+ print(f" [{sev_upper}] {f.location}")
951
+ print(f" {f.tool}/{f.rule}: {f.message}")
952
+ if f.suggestion:
953
+ print(f" Suggestion: {f.suggestion}")
954
+ print()
955
+ else:
956
+ print("\nNo issues found in changed code.")
957
+
958
+ if effective_threshold:
959
+ status = "PASSED" if passed else "FAILED"
960
+ print(f"\n{'='*60}")
961
+ print(f"Result: {status} (threshold: {threshold_used})")
962
+ print(f"{'='*60}")
963
+
964
+ return 0 if passed else 1
965
+
966
+
967
+ # --- Hooks commands ---
968
+
969
+ PRECOMMIT_HOOK_SCRIPT = """\
970
+ #!/bin/sh
971
+ # Crucible pre-commit hook
972
+ # Checks for secrets/sensitive files and runs static analysis
973
+
974
+ crucible pre-commit "$@"
975
+ exit_code=$?
976
+
977
+ if [ $exit_code -ne 0 ]; then
978
+ echo ""
979
+ echo "Pre-commit check failed. Fix issues or use --no-verify to skip."
980
+ fi
981
+
982
+ exit $exit_code
983
+ """
984
+
985
+
986
+ def cmd_hooks_install(args: argparse.Namespace) -> int:
987
+ """Install git hooks to .git/hooks/."""
988
+ from crucible.tools.git import get_repo_root, is_git_repo
989
+
990
+ path = args.path or "."
991
+ if not is_git_repo(path):
992
+ print(f"Error: {path} is not a git repository")
993
+ return 1
994
+
995
+ root_result = get_repo_root(path)
996
+ if root_result.is_err:
997
+ print(f"Error: {root_result.error}")
998
+ return 1
999
+
1000
+ repo_root = Path(root_result.value)
1001
+ hooks_dir = repo_root / ".git" / "hooks"
1002
+
1003
+ if not hooks_dir.exists():
1004
+ print(f"Error: hooks directory not found at {hooks_dir}")
1005
+ return 1
1006
+
1007
+ hook_path = hooks_dir / "pre-commit"
1008
+
1009
+ if hook_path.exists():
1010
+ content = hook_path.read_text()
1011
+ is_crucible = "crucible" in content.lower()
1012
+
1013
+ if is_crucible and not args.force:
1014
+ print("Crucible pre-commit hook is already installed")
1015
+ return 0
1016
+ elif not is_crucible and not args.force:
1017
+ print(f"Error: {hook_path} already exists")
1018
+ print(" Use --force to replace it")
1019
+ return 1
1020
+
1021
+ hook_path.write_text(PRECOMMIT_HOOK_SCRIPT)
1022
+ hook_path.chmod(0o755)
1023
+
1024
+ print(f"Installed pre-commit hook to {hook_path}")
1025
+ print("\nThe hook checks for:")
1026
+ print(" - Sensitive files (env, keys, credentials)")
1027
+ print(" - Static analysis issues (semgrep, ruff, bandit, slither)")
1028
+ print("\nUse 'git commit --no-verify' to skip if needed.")
1029
+ return 0
1030
+
1031
+
1032
+ def cmd_hooks_uninstall(args: argparse.Namespace) -> int:
1033
+ """Uninstall crucible git hooks."""
1034
+ from crucible.tools.git import get_repo_root, is_git_repo
1035
+
1036
+ path = args.path or "."
1037
+ if not is_git_repo(path):
1038
+ print(f"Error: {path} is not a git repository")
1039
+ return 1
1040
+
1041
+ root_result = get_repo_root(path)
1042
+ if root_result.is_err:
1043
+ print(f"Error: {root_result.error}")
1044
+ return 1
1045
+
1046
+ repo_root = Path(root_result.value)
1047
+ hook_path = repo_root / ".git" / "hooks" / "pre-commit"
1048
+
1049
+ if not hook_path.exists():
1050
+ print("No pre-commit hook installed")
1051
+ return 0
1052
+
1053
+ # Check if it's our hook
1054
+ content = hook_path.read_text()
1055
+ if "crucible" not in content.lower():
1056
+ print("Error: pre-commit hook exists but wasn't installed by crucible")
1057
+ print(" Remove manually if you want to replace it")
1058
+ return 1
1059
+
1060
+ hook_path.unlink()
1061
+ print(f"Removed pre-commit hook from {hook_path}")
1062
+ return 0
1063
+
1064
+
1065
+ def cmd_hooks_status(args: argparse.Namespace) -> int:
1066
+ """Show status of installed hooks."""
1067
+ from crucible.tools.git import get_repo_root, is_git_repo
1068
+
1069
+ path = args.path or "."
1070
+ if not is_git_repo(path):
1071
+ print(f"Error: {path} is not a git repository")
1072
+ return 1
1073
+
1074
+ root_result = get_repo_root(path)
1075
+ if root_result.is_err:
1076
+ print(f"Error: {root_result.error}")
1077
+ return 1
1078
+
1079
+ repo_root = Path(root_result.value)
1080
+ hook_path = repo_root / ".git" / "hooks" / "pre-commit"
1081
+
1082
+ print(f"Repository: {repo_root}")
1083
+ print()
1084
+
1085
+ if hook_path.exists():
1086
+ content = hook_path.read_text()
1087
+ if "crucible" in content.lower():
1088
+ print("pre-commit: INSTALLED (crucible)")
1089
+ else:
1090
+ print("pre-commit: EXISTS (not crucible)")
1091
+ else:
1092
+ print("pre-commit: NOT INSTALLED")
1093
+
1094
+ # Check for config file
1095
+ config_project = repo_root / ".crucible" / "precommit.yaml"
1096
+ config_user = Path.home() / ".claude" / "crucible" / "precommit.yaml"
1097
+
1098
+ print()
1099
+ if config_project.exists():
1100
+ print(f"Config: {config_project} (project)")
1101
+ elif config_user.exists():
1102
+ print(f"Config: {config_user} (user)")
1103
+ else:
1104
+ print("Config: using defaults")
1105
+
1106
+ return 0
1107
+
1108
+
1109
+ def cmd_precommit(args: argparse.Namespace) -> int:
1110
+ """Run pre-commit checks (can be called directly or from hook)."""
1111
+ from crucible.hooks.precommit import (
1112
+ EXIT_ERROR,
1113
+ EXIT_FAIL,
1114
+ EXIT_PASS,
1115
+ PrecommitConfig,
1116
+ _parse_severity,
1117
+ format_precommit_output,
1118
+ load_precommit_config,
1119
+ run_precommit,
1120
+ )
1121
+
1122
+ path = args.path or "."
1123
+ config = load_precommit_config(path)
1124
+
1125
+ # Apply CLI overrides
1126
+ if args.fail_on or args.verbose:
1127
+ config = PrecommitConfig(
1128
+ fail_on=_parse_severity(args.fail_on) if args.fail_on else config.fail_on,
1129
+ timeout=config.timeout,
1130
+ exclude=config.exclude,
1131
+ include_context=config.include_context,
1132
+ tools=config.tools,
1133
+ skip_tools=config.skip_tools,
1134
+ verbose=args.verbose or config.verbose,
1135
+ secrets_tool=config.secrets_tool,
1136
+ )
1137
+
1138
+ result = run_precommit(path, config)
1139
+
1140
+ if args.json:
1141
+ import json
1142
+ output = {
1143
+ "passed": result.passed,
1144
+ "findings": [
1145
+ {
1146
+ "tool": f.tool,
1147
+ "rule": f.rule,
1148
+ "severity": f.severity.value,
1149
+ "message": f.message,
1150
+ "location": f.location,
1151
+ "suggestion": f.suggestion,
1152
+ }
1153
+ for f in result.findings
1154
+ ],
1155
+ "severity_counts": result.severity_counts,
1156
+ "files_checked": result.files_checked,
1157
+ "error": result.error,
1158
+ }
1159
+ print(json.dumps(output, indent=2))
1160
+ else:
1161
+ print(format_precommit_output(result, args.verbose or config.verbose))
1162
+
1163
+ if result.error:
1164
+ return EXIT_ERROR
1165
+ return EXIT_PASS if result.passed else EXIT_FAIL
1166
+
1167
+
1168
+ # --- Init command ---
1169
+
1170
+
1171
+ def _detect_project_stack(path: Path) -> list[str]:
1172
+ """Detect the project's tech stack based on files present."""
1173
+ stack: list[str] = []
1174
+
1175
+ indicators = {
1176
+ "python": ["pyproject.toml", "setup.py", "requirements.txt", "Pipfile"],
1177
+ "typescript": ["tsconfig.json", "package.json"],
1178
+ "javascript": ["package.json"],
1179
+ "solidity": ["foundry.toml", "hardhat.config.js", "hardhat.config.ts", "truffle-config.js"],
1180
+ "rust": ["Cargo.toml"],
1181
+ "go": ["go.mod"],
1182
+ }
1183
+
1184
+ for tech, files in indicators.items():
1185
+ for file in files:
1186
+ if (path / file).exists():
1187
+ if tech not in stack:
1188
+ stack.append(tech)
1189
+ break
1190
+
1191
+ # Check for .sol files directly
1192
+ if not any(t in stack for t in ["solidity"]):
1193
+ sol_files = list(path.glob("**/*.sol"))
1194
+ if sol_files and len(sol_files) < 1000: # Sanity limit
1195
+ stack.append("solidity")
1196
+
1197
+ return stack
1198
+
1199
+
1200
+ def _get_recommended_skills(stack: list[str]) -> list[str]:
1201
+ """Get recommended skills based on detected stack."""
1202
+ skills: list[str] = ["security-engineer"] # Always recommend
1203
+
1204
+ stack_skills = {
1205
+ "python": ["backend-engineer"],
1206
+ "typescript": ["backend-engineer", "uiux-engineer"],
1207
+ "javascript": ["backend-engineer", "uiux-engineer"],
1208
+ "solidity": ["web3-engineer", "gas-optimizer", "protocol-architect"],
1209
+ "rust": ["backend-engineer", "performance-engineer"],
1210
+ "go": ["backend-engineer", "performance-engineer"],
1211
+ }
1212
+
1213
+ for tech in stack:
1214
+ if tech in stack_skills:
1215
+ for skill in stack_skills[tech]:
1216
+ if skill not in skills:
1217
+ skills.append(skill)
1218
+
1219
+ return skills
1220
+
1221
+
1222
+ def cmd_init(args: argparse.Namespace) -> int:
1223
+ """Initialize .crucible/ directory for project customization."""
1224
+ project_path = Path(args.path).resolve()
1225
+ crucible_dir = project_path / ".crucible"
1226
+
1227
+ if crucible_dir.exists() and not args.force:
1228
+ print(f"Error: {crucible_dir} already exists. Use --force to overwrite.")
1229
+ return 1
1230
+
1231
+ # Detect stack
1232
+ stack = _detect_project_stack(project_path)
1233
+ if stack:
1234
+ print(f"Detected stack: {', '.join(stack)}")
1235
+ else:
1236
+ print("No specific stack detected")
1237
+
1238
+ # Create directory structure
1239
+ (crucible_dir / "skills").mkdir(parents=True, exist_ok=True)
1240
+ (crucible_dir / "knowledge").mkdir(parents=True, exist_ok=True)
1241
+
1242
+ # Create default review.yaml
1243
+ review_config = """# Crucible review configuration
1244
+ # See: https://github.com/be-nvy/crucible
1245
+
1246
+ # Fail on findings at or above this severity
1247
+ fail_on: high
1248
+
1249
+ # Per-domain overrides (uncomment to customize)
1250
+ # fail_on_domain:
1251
+ # smart_contract: critical
1252
+ # backend: high
1253
+
1254
+ # Skip specific tools (uncomment to customize)
1255
+ # skip_tools:
1256
+ # - slither
1257
+
1258
+ # Include findings near (within 5 lines of) changes
1259
+ include_context: false
1260
+ """
1261
+ (crucible_dir / "review.yaml").write_text(review_config)
1262
+ print(f"Created {crucible_dir / 'review.yaml'}")
1263
+
1264
+ if not args.minimal:
1265
+ # Get recommended skills
1266
+ recommended = _get_recommended_skills(stack)
1267
+ print(f"\nRecommended skills for your stack: {', '.join(recommended)}")
1268
+ print("\nTo customize a skill, run:")
1269
+ for skill in recommended:
1270
+ print(f" crucible skills init {skill}")
1271
+
1272
+ # Create .gitignore if not exists
1273
+ gitignore_path = crucible_dir / ".gitignore"
1274
+ if not gitignore_path.exists():
1275
+ gitignore_path.write_text("# Local overrides (optional)\n*.local.md\n")
1276
+
1277
+ print(f"\nInitialized {crucible_dir}")
1278
+ print("\nNext steps:")
1279
+ print(" 1. Customize skills: crucible skills init <skill>")
1280
+ print(" 2. Customize knowledge: crucible knowledge init <file>")
1281
+ print(" 3. Install git hooks: crucible hooks install")
1282
+ return 0
1283
+
1284
+
1285
+ # --- CI command ---
1286
+
1287
+
1288
+ GITHUB_WORKFLOW_TEMPLATE = '''name: Crucible Code Review
1289
+
1290
+ on:
1291
+ pull_request:
1292
+ branches: [main, master]
1293
+ push:
1294
+ branches: [main, master]
1295
+
1296
+ jobs:
1297
+ review:
1298
+ runs-on: ubuntu-latest
1299
+ steps:
1300
+ - uses: actions/checkout@v4
1301
+ with:
1302
+ fetch-depth: 0 # Full history for branch comparison
1303
+
1304
+ - uses: actions/setup-python@v5
1305
+ with:
1306
+ python-version: "3.11"
1307
+
1308
+ - name: Install crucible
1309
+ run: pip install crucible-mcp
1310
+
1311
+ - name: Review changes
1312
+ run: |
1313
+ if [ "${{{{ github.event_name }}}}" = "pull_request" ]; then
1314
+ crucible review --mode branch --base ${{{{ github.base_ref }}}} --fail-on {fail_on}
1315
+ else
1316
+ crucible review --mode commits --base 1 --fail-on {fail_on}
1317
+ fi
1318
+ '''
1319
+
1320
+
1321
+ def cmd_ci_generate(args: argparse.Namespace) -> int:
1322
+ """Generate GitHub Actions workflow for crucible."""
1323
+ workflow = GITHUB_WORKFLOW_TEMPLATE.format(fail_on=args.fail_on)
1324
+
1325
+ if args.output:
1326
+ output_path = Path(args.output)
1327
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1328
+ output_path.write_text(workflow)
1329
+ print(f"Generated {output_path}")
1330
+ else:
1331
+ print(workflow)
1332
+
1333
+ return 0
1334
+
1335
+
396
1336
  # --- Main ---
397
1337
 
398
1338
 
@@ -470,9 +1410,156 @@ def main() -> int:
470
1410
  )
471
1411
  knowledge_show_parser.add_argument("file", help="Name of the file to show")
472
1412
 
1413
+ # === hooks command ===
1414
+ hooks_parser = subparsers.add_parser("hooks", help="Manage git hooks")
1415
+ hooks_sub = hooks_parser.add_subparsers(dest="hooks_command")
1416
+
1417
+ # hooks install
1418
+ hooks_install_parser = hooks_sub.add_parser(
1419
+ "install",
1420
+ help="Install crucible pre-commit hook"
1421
+ )
1422
+ hooks_install_parser.add_argument(
1423
+ "--force", "-f", action="store_true", help="Overwrite existing hook"
1424
+ )
1425
+ hooks_install_parser.add_argument(
1426
+ "path", nargs="?", default=".", help="Repository path"
1427
+ )
1428
+
1429
+ # hooks uninstall
1430
+ hooks_uninstall_parser = hooks_sub.add_parser(
1431
+ "uninstall",
1432
+ help="Remove crucible pre-commit hook"
1433
+ )
1434
+ hooks_uninstall_parser.add_argument(
1435
+ "path", nargs="?", default=".", help="Repository path"
1436
+ )
1437
+
1438
+ # hooks status
1439
+ hooks_status_parser = hooks_sub.add_parser(
1440
+ "status",
1441
+ help="Show hook installation status"
1442
+ )
1443
+ hooks_status_parser.add_argument(
1444
+ "path", nargs="?", default=".", help="Repository path"
1445
+ )
1446
+
1447
+ # === review command ===
1448
+ review_parser = subparsers.add_parser(
1449
+ "review",
1450
+ help="Review git changes (staged, unstaged, branch, commits)"
1451
+ )
1452
+ review_parser.add_argument(
1453
+ "--mode", "-m",
1454
+ choices=["staged", "unstaged", "branch", "commits"],
1455
+ default="staged",
1456
+ help="What changes to review (default: staged)"
1457
+ )
1458
+ review_parser.add_argument(
1459
+ "--base", "-b",
1460
+ help="Base branch for 'branch' mode or commit count for 'commits' mode"
1461
+ )
1462
+ review_parser.add_argument(
1463
+ "--fail-on",
1464
+ choices=["critical", "high", "medium", "low", "info"],
1465
+ help="Fail on findings at or above this severity"
1466
+ )
1467
+ review_parser.add_argument(
1468
+ "--include-context", "-c", action="store_true",
1469
+ help="Include findings near changed lines (within 5 lines)"
1470
+ )
1471
+ review_parser.add_argument(
1472
+ "--json", action="store_true",
1473
+ help="Output as JSON"
1474
+ )
1475
+ review_parser.add_argument(
1476
+ "--format", "-f",
1477
+ choices=["text", "report"],
1478
+ default="text",
1479
+ help="Output format: text (default) or report (markdown audit report)"
1480
+ )
1481
+ review_parser.add_argument(
1482
+ "--quiet", "-q", action="store_true",
1483
+ help="Suppress progress messages"
1484
+ )
1485
+ review_parser.add_argument(
1486
+ "path", nargs="?", default=".", help="Repository path"
1487
+ )
1488
+
1489
+ # === pre-commit command (direct invocation) ===
1490
+ precommit_parser = subparsers.add_parser(
1491
+ "pre-commit",
1492
+ help="Run pre-commit checks on staged changes"
1493
+ )
1494
+ precommit_parser.add_argument(
1495
+ "--fail-on",
1496
+ choices=["critical", "high", "medium", "low", "info"],
1497
+ help="Fail on findings at or above this severity"
1498
+ )
1499
+ precommit_parser.add_argument(
1500
+ "--verbose", "-v", action="store_true",
1501
+ help="Show all findings, not just high+"
1502
+ )
1503
+ precommit_parser.add_argument(
1504
+ "--json", action="store_true",
1505
+ help="Output as JSON"
1506
+ )
1507
+ precommit_parser.add_argument(
1508
+ "path", nargs="?", default=".", help="Repository path"
1509
+ )
1510
+
1511
+ # === init command ===
1512
+ init_proj_parser = subparsers.add_parser(
1513
+ "init",
1514
+ help="Initialize .crucible/ directory for project customization"
1515
+ )
1516
+ init_proj_parser.add_argument(
1517
+ "--force", "-f", action="store_true",
1518
+ help="Overwrite existing .crucible/ directory"
1519
+ )
1520
+ init_proj_parser.add_argument(
1521
+ "--minimal", action="store_true",
1522
+ help="Create minimal config without copying skills"
1523
+ )
1524
+ init_proj_parser.add_argument(
1525
+ "path", nargs="?", default=".",
1526
+ help="Project path (default: current directory)"
1527
+ )
1528
+
1529
+ # === ci command ===
1530
+ ci_parser = subparsers.add_parser(
1531
+ "ci",
1532
+ help="Generate CI configuration"
1533
+ )
1534
+ ci_sub = ci_parser.add_subparsers(dest="ci_command")
1535
+
1536
+ # ci generate
1537
+ ci_generate_parser = ci_sub.add_parser(
1538
+ "generate",
1539
+ help="Generate GitHub Actions workflow"
1540
+ )
1541
+ ci_generate_parser.add_argument(
1542
+ "--output", "-o",
1543
+ help="Output file path (default: stdout)"
1544
+ )
1545
+ ci_generate_parser.add_argument(
1546
+ "--fail-on",
1547
+ choices=["critical", "high", "medium", "low"],
1548
+ default="high",
1549
+ help="Fail threshold for CI (default: high)"
1550
+ )
1551
+
473
1552
  args = parser.parse_args()
474
1553
 
475
- if args.command == "skills":
1554
+ if args.command == "init":
1555
+ return cmd_init(args)
1556
+ elif args.command == "ci":
1557
+ if args.ci_command == "generate":
1558
+ return cmd_ci_generate(args)
1559
+ else:
1560
+ ci_parser.print_help()
1561
+ return 0
1562
+ elif args.command == "skills":
476
1563
  if args.skills_command == "install":
477
1564
  return cmd_skills_install(args)
478
1565
  elif args.skills_command == "list":
@@ -496,9 +1583,27 @@ def main() -> int:
496
1583
  else:
497
1584
  knowledge_parser.print_help()
498
1585
  return 0
1586
+ elif args.command == "hooks":
1587
+ if args.hooks_command == "install":
1588
+ return cmd_hooks_install(args)
1589
+ elif args.hooks_command == "uninstall":
1590
+ return cmd_hooks_uninstall(args)
1591
+ elif args.hooks_command == "status":
1592
+ return cmd_hooks_status(args)
1593
+ else:
1594
+ hooks_parser.print_help()
1595
+ return 0
1596
+ elif args.command == "review":
1597
+ return cmd_review(args)
1598
+ elif args.command == "pre-commit":
1599
+ return cmd_precommit(args)
499
1600
  else:
500
1601
  # Default help
501
1602
  print("crucible - Code review orchestration\n")
1603
+ print("Getting Started:")
1604
+ print(" crucible init Initialize .crucible/ for project customization")
1605
+ print(" crucible ci generate Generate GitHub Actions workflow")
1606
+ print()
502
1607
  print("Commands:")
503
1608
  print(" crucible skills list List skills from all sources")
504
1609
  print(" crucible skills install Install skills to ~/.claude/crucible/")
@@ -510,12 +1615,19 @@ def main() -> int:
510
1615
  print(" crucible knowledge init <file> Copy knowledge for project customization")
511
1616
  print(" crucible knowledge show <file> Show knowledge resolution")
512
1617
  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")
1618
+ print(" crucible hooks install Install pre-commit hook to .git/hooks/")
1619
+ print(" crucible hooks uninstall Remove pre-commit hook")
1620
+ print(" crucible hooks status Show hook installation status")
1621
+ print()
1622
+ print(" crucible review Review git changes")
1623
+ print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
1624
+ print(" --base <ref> Base branch or commit count")
1625
+ print(" --fail-on <severity> Fail threshold (critical/high/medium/low/info)")
1626
+ print(" --format <format> Output format: text (default) or report (markdown)")
1627
+ print()
1628
+ print(" crucible pre-commit Run pre-commit checks on staged changes")
1629
+ print()
1630
+ print(" crucible-mcp Run as MCP server")
519
1631
  return 0
520
1632
 
521
1633