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 +1054 -7
- crucible/hooks/__init__.py +15 -0
- crucible/hooks/precommit.py +660 -0
- crucible/knowledge/loader.py +70 -1
- crucible/models.py +15 -0
- crucible/server.py +599 -4
- crucible/skills/__init__.py +23 -0
- crucible/skills/loader.py +281 -0
- crucible/tools/delegation.py +96 -10
- crucible/tools/git.py +317 -0
- crucible_mcp-0.2.0.dist-info/METADATA +140 -0
- crucible_mcp-0.2.0.dist-info/RECORD +22 -0
- {crucible_mcp-0.1.0.dist-info → crucible_mcp-0.2.0.dist-info}/WHEEL +1 -1
- crucible_mcp-0.1.0.dist-info/METADATA +0 -158
- crucible_mcp-0.1.0.dist-info/RECORD +0 -17
- {crucible_mcp-0.1.0.dist-info → crucible_mcp-0.2.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-0.1.0.dist-info → crucible_mcp-0.2.0.dist-info}/top_level.txt +0 -0
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 == "
|
|
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-
|
|
514
|
-
print("
|
|
515
|
-
print("
|
|
516
|
-
print(
|
|
517
|
-
print("
|
|
518
|
-
print("
|
|
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
|
|