crucible-mcp 0.3.0__py3-none-any.whl → 0.5.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
@@ -1,10 +1,14 @@
1
1
  """crucible CLI."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import argparse
4
6
  import shutil
5
7
  import sys
6
8
  from pathlib import Path
7
9
 
10
+ from crucible.enforcement.models import ComplianceConfig
11
+
8
12
  # Skills directories
9
13
  SKILLS_BUNDLED = Path(__file__).parent / "skills"
10
14
  SKILLS_USER = Path.home() / ".claude" / "crucible" / "skills"
@@ -408,6 +412,12 @@ def _load_review_config(repo_path: str | None = None) -> dict:
408
412
  backend: high
409
413
  include_context: false
410
414
  skip_tools: []
415
+ enforcement:
416
+ compliance:
417
+ enabled: true
418
+ model: sonnet
419
+ token_budget: 10000
420
+ overflow_behavior: warn
411
421
  """
412
422
  import yaml
413
423
 
@@ -423,31 +433,236 @@ def _load_review_config(repo_path: str | None = None) -> dict:
423
433
  try:
424
434
  with open(config_project) as f:
425
435
  config_data = yaml.safe_load(f) or {}
426
- except Exception:
427
- pass
436
+ except Exception as e:
437
+ print(f"Warning: Could not load {config_project}: {e}", file=sys.stderr)
428
438
 
429
439
  # Fall back to user-level
430
440
  if not config_data and config_user.exists():
431
441
  try:
432
442
  with open(config_user) as f:
433
443
  config_data = yaml.safe_load(f) or {}
434
- except Exception:
435
- pass
444
+ except Exception as e:
445
+ print(f"Warning: Could not load {config_user}: {e}", file=sys.stderr)
436
446
 
437
447
  return config_data
438
448
 
439
449
 
450
+ def _build_compliance_config(
451
+ config: dict,
452
+ cli_token_budget: int | None = None,
453
+ cli_model: str | None = None,
454
+ cli_no_compliance: bool = False,
455
+ ) -> ComplianceConfig:
456
+ """Build compliance config from config file and CLI overrides.
457
+
458
+ Args:
459
+ config: Loaded config dict
460
+ cli_token_budget: CLI --token-budget override
461
+ cli_model: CLI --compliance-model override
462
+ cli_no_compliance: CLI --no-compliance flag
463
+
464
+ Returns:
465
+ ComplianceConfig instance
466
+ """
467
+ from crucible.enforcement.models import ComplianceConfig, OverflowBehavior
468
+
469
+ # Get enforcement.compliance section from config
470
+ enforcement_config = config.get("enforcement", {})
471
+ compliance_section = enforcement_config.get("compliance", {})
472
+
473
+ # Build config with defaults
474
+ enabled = not cli_no_compliance and compliance_section.get("enabled", True)
475
+ model = cli_model or compliance_section.get("model", "sonnet")
476
+ token_budget = cli_token_budget if cli_token_budget is not None else compliance_section.get("token_budget", 10000)
477
+
478
+ # Parse overflow behavior
479
+ overflow_str = compliance_section.get("overflow_behavior", "warn")
480
+ try:
481
+ overflow_behavior = OverflowBehavior(overflow_str.lower())
482
+ except ValueError:
483
+ overflow_behavior = OverflowBehavior.WARN
484
+
485
+ # Parse priority order
486
+ priority_order = compliance_section.get("priority_order", ["critical", "high", "medium", "low"])
487
+ if isinstance(priority_order, list):
488
+ priority_order = tuple(priority_order)
489
+ else:
490
+ priority_order = ("critical", "high", "medium", "low")
491
+
492
+ return ComplianceConfig(
493
+ enabled=enabled,
494
+ model=model,
495
+ token_budget=token_budget,
496
+ priority_order=priority_order,
497
+ overflow_behavior=overflow_behavior,
498
+ )
499
+
500
+
501
+ def _cmd_review_no_git(args: argparse.Namespace, path: str) -> int:
502
+ """Run static analysis on a path without git awareness."""
503
+ import json as json_mod
504
+ from pathlib import Path
505
+
506
+ from crucible.models import Domain, Severity, ToolFinding
507
+ from crucible.review.core import (
508
+ compute_severity_counts,
509
+ deduplicate_findings,
510
+ detect_domain_for_file,
511
+ get_tools_for_domain,
512
+ run_static_analysis,
513
+ )
514
+
515
+ path_obj = Path(path)
516
+ if not path_obj.exists():
517
+ print(f"Error: {path} does not exist")
518
+ return 1
519
+
520
+ # Load config from current directory or user level
521
+ config = _load_review_config(".")
522
+
523
+ # Parse severity threshold
524
+ severity_order = ["critical", "high", "medium", "low", "info"]
525
+ severity_map = {
526
+ "critical": Severity.CRITICAL,
527
+ "high": Severity.HIGH,
528
+ "medium": Severity.MEDIUM,
529
+ "low": Severity.LOW,
530
+ "info": Severity.INFO,
531
+ }
532
+ default_threshold_str = args.fail_on or config.get("fail_on")
533
+ default_threshold: Severity | None = None
534
+ if default_threshold_str:
535
+ default_threshold = severity_map.get(default_threshold_str.lower())
536
+
537
+ skip_tools = set(config.get("skip_tools", []))
538
+
539
+ # Collect files to analyze
540
+ files_to_analyze: list[str] = []
541
+ if path_obj.is_file():
542
+ files_to_analyze = [str(path_obj)]
543
+ else:
544
+ # Recursively find files, respecting common ignores
545
+ ignore_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "build", "dist"}
546
+ for file_path in path_obj.rglob("*"):
547
+ if file_path.is_file():
548
+ # Skip ignored directories
549
+ if any(ignored in file_path.parts for ignored in ignore_dirs):
550
+ continue
551
+ files_to_analyze.append(str(file_path))
552
+
553
+ if not files_to_analyze:
554
+ print("No files to analyze.")
555
+ return 0
556
+
557
+ if not args.quiet and not args.json:
558
+ print(f"Reviewing {len(files_to_analyze)} file(s) (no git)...")
559
+
560
+ # Run analysis
561
+ all_findings: list[ToolFinding] = []
562
+ tool_errors: list[str] = []
563
+ domains_detected: set[Domain] = set()
564
+ all_domain_tags: set[str] = set()
565
+
566
+ for file_path in files_to_analyze:
567
+ domain, domain_tags = detect_domain_for_file(file_path)
568
+ domains_detected.add(domain)
569
+ all_domain_tags.update(domain_tags)
570
+
571
+ tools = get_tools_for_domain(domain, domain_tags)
572
+ tools = [t for t in tools if t not in skip_tools]
573
+
574
+ findings, errors = run_static_analysis(file_path, domain, domain_tags, tools)
575
+ all_findings.extend(findings)
576
+ tool_errors.extend(errors)
577
+
578
+ # Deduplicate
579
+ all_findings = deduplicate_findings(all_findings)
580
+
581
+ # Compute severity summary
582
+ severity_counts = compute_severity_counts(all_findings)
583
+
584
+ # Determine pass/fail
585
+ passed = True
586
+ if default_threshold:
587
+ threshold_idx = severity_order.index(default_threshold.value)
588
+ for sev in severity_order[: threshold_idx + 1]:
589
+ if severity_counts.get(sev, 0) > 0:
590
+ passed = False
591
+ break
592
+
593
+ # Output
594
+ if args.json:
595
+ output = {
596
+ "mode": "no-git",
597
+ "files_analyzed": len(files_to_analyze),
598
+ "domains_detected": [d.value for d in domains_detected],
599
+ "findings": [
600
+ {
601
+ "tool": f.tool,
602
+ "rule": f.rule,
603
+ "severity": f.severity.value,
604
+ "message": f.message,
605
+ "location": f.location,
606
+ "suggestion": f.suggestion,
607
+ }
608
+ for f in all_findings
609
+ ],
610
+ "severity_counts": severity_counts,
611
+ "passed": passed,
612
+ "threshold": default_threshold.value if default_threshold else None,
613
+ "errors": tool_errors,
614
+ }
615
+ print(json_mod.dumps(output, indent=2))
616
+ else:
617
+ # Text output
618
+ if all_findings:
619
+ print(f"\nFound {len(all_findings)} issue(s):\n")
620
+ for f in all_findings:
621
+ sev_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "info": "⚪"}.get(
622
+ f.severity.value, "⚪"
623
+ )
624
+ print(f"{sev_icon} [{f.severity.value.upper()}] {f.location}")
625
+ print(f" {f.tool}/{f.rule}: {f.message}")
626
+ if f.suggestion:
627
+ print(f" 💡 {f.suggestion}")
628
+ print()
629
+ else:
630
+ print("\n✅ No issues found.")
631
+
632
+ # Summary
633
+ if severity_counts:
634
+ counts_str = ", ".join(f"{k}: {v}" for k, v in severity_counts.items() if v > 0)
635
+ print(f"Summary: {counts_str}")
636
+
637
+ if tool_errors and not args.quiet:
638
+ print(f"\n⚠️ {len(tool_errors)} tool error(s)")
639
+ for err in tool_errors[:5]:
640
+ print(f" - {err}")
641
+
642
+ return 0 if passed else 1
643
+
644
+
440
645
  def cmd_review(args: argparse.Namespace) -> int:
441
- """Run code review on git changes."""
646
+ """Run code review on git changes or a path directly."""
442
647
  import json as json_mod
443
648
 
444
649
  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,
650
+ from crucible.review.core import (
651
+ compute_severity_counts,
652
+ deduplicate_findings,
653
+ detect_domain_for_file,
654
+ filter_findings_to_changes,
655
+ get_tools_for_domain,
656
+ run_static_analysis,
450
657
  )
658
+
659
+ path = args.path or "."
660
+
661
+ # Handle --no-git mode: simple static analysis without git awareness
662
+ if getattr(args, "no_git", False):
663
+ return _cmd_review_no_git(args, path)
664
+
665
+ # Git-aware review mode
451
666
  from crucible.tools.git import (
452
667
  get_branch_diff,
453
668
  get_recent_commits,
@@ -457,98 +672,12 @@ def cmd_review(args: argparse.Namespace) -> int:
457
672
  is_git_repo,
458
673
  )
459
674
 
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
675
  mode = args.mode
548
676
 
549
677
  # Validate git repo
550
678
  if not is_git_repo(path):
551
- print(f"Error: {path} is not a git repository")
679
+ print(f"Error: {path} is not inside a git repository")
680
+ print("Hint: Use --no-git to review files without git awareness")
552
681
  return 1
553
682
 
554
683
  root_result = get_repo_root(path)
@@ -638,7 +767,7 @@ def cmd_review(args: argparse.Namespace) -> int:
638
767
  print("No changes found.")
639
768
  return 0
640
769
 
641
- changed_files = get_changed_files(context.changes)
770
+ changed_files = [c.path for c in context.changes if c.status != "D"]
642
771
  if not changed_files:
643
772
  print("No files to analyze (only deletions).")
644
773
  return 0
@@ -656,77 +785,24 @@ def cmd_review(args: argparse.Namespace) -> int:
656
785
 
657
786
  for file_path in changed_files:
658
787
  full_path = f"{repo_path}/{file_path}"
659
- domain, domain_tags = detect_domain(file_path)
788
+ domain, domain_tags = detect_domain_for_file(file_path)
660
789
  domains_detected.add(domain)
661
790
  all_domain_tags.update(domain_tags)
662
791
 
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
792
+ # Get tools for this domain, applying skip_tools from config
793
+ tools = get_tools_for_domain(domain, domain_tags)
674
794
  tools = [t for t in tools if t not in skip_tools]
675
795
 
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
796
+ # Run static analysis
797
+ findings, errors = run_static_analysis(full_path, domain, domain_tags, tools)
798
+ all_findings.extend(findings)
799
+ for err in errors:
800
+ tool_errors.append(f"{err} ({file_path})")
801
+
802
+ # Filter findings to changed lines and deduplicate
707
803
  filtered_findings = filter_findings_to_changes(
708
- all_findings, context.changes, args.include_context
804
+ all_findings, context, args.include_context
709
805
  )
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
806
  filtered_findings = deduplicate_findings(filtered_findings)
731
807
 
732
808
  # Match skills and load knowledge based on detected domains
@@ -760,11 +836,30 @@ def cmd_review(args: argparse.Namespace) -> int:
760
836
  if result.is_ok:
761
837
  knowledge_content[filename] = result.value
762
838
 
839
+ # Run enforcement assertions (pattern + LLM)
840
+ from crucible.review.core import run_enforcement
841
+
842
+ compliance_config = _build_compliance_config(
843
+ config,
844
+ cli_token_budget=getattr(args, "token_budget", None),
845
+ cli_model=getattr(args, "compliance_model", None),
846
+ cli_no_compliance=getattr(args, "no_compliance", False),
847
+ )
848
+
849
+ enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped, budget_state = (
850
+ run_enforcement(
851
+ repo_path,
852
+ changed_files=changed_files,
853
+ repo_root=repo_path,
854
+ compliance_config=compliance_config,
855
+ )
856
+ )
857
+
858
+ # Add enforcement errors to tool errors
859
+ tool_errors.extend(enforcement_errors)
860
+
763
861
  # 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
862
+ severity_counts = compute_severity_counts(filtered_findings)
768
863
 
769
864
  # Determine pass/fail using per-domain thresholds
770
865
  # Use the strictest applicable threshold
@@ -813,6 +908,22 @@ def cmd_review(args: argparse.Namespace) -> int:
813
908
  }
814
909
  for f in filtered_findings
815
910
  ],
911
+ "enforcement": {
912
+ "findings": [
913
+ {
914
+ "assertion_id": f.assertion_id,
915
+ "severity": f.severity,
916
+ "message": f.message,
917
+ "location": f.location,
918
+ "source": f.source,
919
+ "suppressed": f.suppressed,
920
+ }
921
+ for f in enforcement_findings
922
+ ],
923
+ "assertions_checked": assertions_checked,
924
+ "assertions_skipped": assertions_skipped,
925
+ "llm_tokens_used": budget_state.tokens_used if budget_state else 0,
926
+ },
816
927
  "severity_counts": severity_counts,
817
928
  "threshold": threshold_used,
818
929
  "errors": tool_errors,
@@ -955,6 +1066,28 @@ def cmd_review(args: argparse.Namespace) -> int:
955
1066
  else:
956
1067
  print("\nNo issues found in changed code.")
957
1068
 
1069
+ # Enforcement assertions
1070
+ if enforcement_findings:
1071
+ active_enforcement = [f for f in enforcement_findings if not f.suppressed]
1072
+ suppressed_enforcement = [f for f in enforcement_findings if f.suppressed]
1073
+
1074
+ if active_enforcement:
1075
+ print(f"\nEnforcement Assertions ({len(active_enforcement)}):")
1076
+ for f in active_enforcement:
1077
+ sev_upper = f.severity.upper()
1078
+ source_label = "[LLM]" if f.source == "llm" else "[PATTERN]"
1079
+ print(f" [{sev_upper}] {source_label} {f.assertion_id}: {f.location}")
1080
+ print(f" {f.message}")
1081
+ print()
1082
+
1083
+ if suppressed_enforcement:
1084
+ print(f" Suppressed: {len(suppressed_enforcement)}")
1085
+
1086
+ if assertions_checked > 0:
1087
+ print(f"\nAssertions: {assertions_checked} checked, {assertions_skipped} skipped")
1088
+ if budget_state and budget_state.tokens_used > 0:
1089
+ print(f" LLM tokens used: {budget_state.tokens_used}")
1090
+
958
1091
  if effective_threshold:
959
1092
  status = "PASSED" if passed else "FAILED"
960
1093
  print(f"\n{'='*60}")
@@ -964,6 +1097,281 @@ def cmd_review(args: argparse.Namespace) -> int:
964
1097
  return 0 if passed else 1
965
1098
 
966
1099
 
1100
+ # --- Assertions commands ---
1101
+
1102
+ # Assertions directories
1103
+ ASSERTIONS_BUNDLED = Path(__file__).parent / "enforcement" / "bundled"
1104
+ ASSERTIONS_USER = Path.home() / ".claude" / "crucible" / "assertions"
1105
+ ASSERTIONS_PROJECT = Path(".crucible") / "assertions"
1106
+
1107
+
1108
+ def cmd_assertions_validate(args: argparse.Namespace) -> int:
1109
+ """Validate assertion files."""
1110
+ from crucible.enforcement.assertions import (
1111
+ clear_assertion_cache,
1112
+ get_all_assertion_files,
1113
+ load_assertion_file,
1114
+ )
1115
+
1116
+ clear_assertion_cache()
1117
+
1118
+ files = get_all_assertion_files()
1119
+ if not files:
1120
+ print("No assertion files found.")
1121
+ print("\nCreate assertion files in:")
1122
+ print(f" Project: {ASSERTIONS_PROJECT}/")
1123
+ print(f" User: {ASSERTIONS_USER}/")
1124
+ return 0
1125
+
1126
+ valid_count = 0
1127
+ error_count = 0
1128
+
1129
+ for filename in sorted(files):
1130
+ result = load_assertion_file(filename)
1131
+ if result.is_ok:
1132
+ assertion_count = len(result.value.assertions)
1133
+ print(f" ✓ {filename}: {assertion_count} assertion(s) valid")
1134
+ valid_count += 1
1135
+ else:
1136
+ print(f" ✗ {filename}: {result.error}")
1137
+ error_count += 1
1138
+
1139
+ print()
1140
+ if error_count == 0:
1141
+ print(f"All {valid_count} file(s) valid.")
1142
+ return 0
1143
+ else:
1144
+ print(f"{error_count} file(s) with errors, {valid_count} valid.")
1145
+ return 1
1146
+
1147
+
1148
+ def cmd_assertions_list(args: argparse.Namespace) -> int:
1149
+ """List available assertion files."""
1150
+ print("Bundled assertions:")
1151
+ if ASSERTIONS_BUNDLED.exists():
1152
+ found = False
1153
+ for file_path in sorted(ASSERTIONS_BUNDLED.iterdir()):
1154
+ if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
1155
+ print(f" - {file_path.name}")
1156
+ found = True
1157
+ if not found:
1158
+ print(" (none)")
1159
+ else:
1160
+ print(" (none)")
1161
+
1162
+ print("\nUser assertions (~/.claude/crucible/assertions/):")
1163
+ if ASSERTIONS_USER.exists():
1164
+ found = False
1165
+ for file_path in sorted(ASSERTIONS_USER.iterdir()):
1166
+ if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
1167
+ print(f" - {file_path.name}")
1168
+ found = True
1169
+ if not found:
1170
+ print(" (none)")
1171
+ else:
1172
+ print(" (none)")
1173
+
1174
+ print("\nProject assertions (.crucible/assertions/):")
1175
+ if ASSERTIONS_PROJECT.exists():
1176
+ found = False
1177
+ for file_path in sorted(ASSERTIONS_PROJECT.iterdir()):
1178
+ if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
1179
+ print(f" - {file_path.name}")
1180
+ found = True
1181
+ if not found:
1182
+ print(" (none)")
1183
+ else:
1184
+ print(" (none)")
1185
+
1186
+ return 0
1187
+
1188
+
1189
+ def cmd_assertions_test(args: argparse.Namespace) -> int:
1190
+ """Test assertions against a file or directory."""
1191
+ import os
1192
+
1193
+ from crucible.enforcement.assertions import load_assertions
1194
+ from crucible.enforcement.patterns import run_pattern_assertions
1195
+
1196
+ target_path = Path(args.file)
1197
+ if not target_path.exists():
1198
+ print(f"Error: Path '{target_path}' not found")
1199
+ return 1
1200
+
1201
+ assertions, errors = load_assertions()
1202
+
1203
+ if errors:
1204
+ print("Assertion loading errors:")
1205
+ for error in errors:
1206
+ print(f" - {error}")
1207
+ print()
1208
+
1209
+ if not assertions:
1210
+ print("No assertions loaded.")
1211
+ return 0
1212
+
1213
+ # Collect files to test
1214
+ files_to_test: list[tuple[str, str]] = [] # (display_path, content)
1215
+
1216
+ if target_path.is_file():
1217
+ try:
1218
+ content = target_path.read_text()
1219
+ files_to_test.append((str(target_path), content))
1220
+ except UnicodeDecodeError:
1221
+ print(f"Error: Cannot read '{target_path}' (binary file?)")
1222
+ return 1
1223
+ else:
1224
+ # Directory mode
1225
+ for root, _, files in os.walk(target_path):
1226
+ for fname in files:
1227
+ fpath = Path(root) / fname
1228
+ rel_path = fpath.relative_to(target_path)
1229
+ try:
1230
+ content = fpath.read_text()
1231
+ files_to_test.append((str(rel_path), content))
1232
+ except (UnicodeDecodeError, OSError):
1233
+ pass # Skip binary/unreadable files
1234
+
1235
+ # Run assertions on all files
1236
+ all_findings = []
1237
+ checked = 0
1238
+ skipped = 0
1239
+
1240
+ for display_path, content in files_to_test:
1241
+ findings, c, s = run_pattern_assertions(display_path, content, assertions)
1242
+ all_findings.extend(findings)
1243
+ checked = max(checked, c)
1244
+ skipped = max(skipped, s)
1245
+
1246
+ # Separate suppressed and active findings
1247
+ active = [f for f in all_findings if not f.suppressed]
1248
+ suppressed = [f for f in all_findings if f.suppressed]
1249
+
1250
+ print(f"Testing {target_path}")
1251
+ print(f" Files scanned: {len(files_to_test)}")
1252
+ print(f" Assertions checked: {checked}")
1253
+ print(f" Assertions skipped (LLM): {skipped}")
1254
+ print()
1255
+
1256
+ if active:
1257
+ print(f"Findings ({len(active)}):")
1258
+ for f in active:
1259
+ sev = f.severity.upper()
1260
+ print(f" [{sev}] {f.assertion_id}: {f.location}")
1261
+ print(f" {f.message}")
1262
+ if f.match_text:
1263
+ print(f" Matched: {f.match_text!r}")
1264
+ print()
1265
+
1266
+ if suppressed:
1267
+ print(f"Suppressed ({len(suppressed)}):")
1268
+ for f in suppressed:
1269
+ reason = f" -- {f.suppression_reason}" if f.suppression_reason else ""
1270
+ print(f" {f.assertion_id}: {f.location}{reason}")
1271
+
1272
+ if not active and not suppressed:
1273
+ print("No matches found.")
1274
+
1275
+ return 0
1276
+
1277
+
1278
+ def cmd_assertions_explain(args: argparse.Namespace) -> int:
1279
+ """Explain what a rule does."""
1280
+ from crucible.enforcement.assertions import load_assertions
1281
+
1282
+ rule_id = args.rule.lower()
1283
+ assertions, errors = load_assertions()
1284
+
1285
+ for assertion in assertions:
1286
+ if assertion.id.lower() == rule_id:
1287
+ print(f"Rule: {assertion.id}")
1288
+ print(f"Type: {assertion.type.value}")
1289
+ if assertion.pattern:
1290
+ print(f"Pattern: {assertion.pattern}")
1291
+ print(f"Message: {assertion.message}")
1292
+ print(f"Severity: {assertion.severity}")
1293
+ print(f"Priority: {assertion.priority.value}")
1294
+ if assertion.languages:
1295
+ print(f"Languages: {', '.join(assertion.languages)}")
1296
+ if assertion.applicability:
1297
+ if assertion.applicability.glob:
1298
+ print(f"Applies to: {assertion.applicability.glob}")
1299
+ if assertion.applicability.exclude:
1300
+ print(f"Excludes: {', '.join(assertion.applicability.exclude)}")
1301
+ if assertion.compliance:
1302
+ print(f"Compliance: {assertion.compliance}")
1303
+ return 0
1304
+
1305
+ print(f"Rule '{rule_id}' not found.")
1306
+ print("\nAvailable rules:")
1307
+ for a in assertions[:10]:
1308
+ print(f" - {a.id}")
1309
+ if len(assertions) > 10:
1310
+ print(f" ... and {len(assertions) - 10} more")
1311
+ return 1
1312
+
1313
+
1314
+ def cmd_assertions_debug(args: argparse.Namespace) -> int:
1315
+ """Debug applicability for a rule and file."""
1316
+ from crucible.enforcement.assertions import load_assertions
1317
+ from crucible.enforcement.patterns import matches_glob, matches_language
1318
+
1319
+ rule_id = args.rule.lower()
1320
+ file_path = args.file
1321
+
1322
+ assertions, errors = load_assertions()
1323
+
1324
+ for assertion in assertions:
1325
+ if assertion.id.lower() == rule_id:
1326
+ print(f"Applicability check for '{assertion.id}':")
1327
+ print()
1328
+
1329
+ # Language check
1330
+ if assertion.languages:
1331
+ lang_match = matches_language(file_path, assertion.languages)
1332
+ lang_status = "MATCH" if lang_match else "NO MATCH"
1333
+ print(f" Languages: {', '.join(assertion.languages)}")
1334
+ print(f" File: {file_path} → {lang_status}")
1335
+ else:
1336
+ print(" Languages: (any)")
1337
+
1338
+ # Glob check
1339
+ if assertion.applicability:
1340
+ if assertion.applicability.glob:
1341
+ glob_match = matches_glob(
1342
+ file_path,
1343
+ assertion.applicability.glob,
1344
+ assertion.applicability.exclude,
1345
+ )
1346
+ glob_status = "MATCH" if glob_match else "NO MATCH"
1347
+ print(f" Glob: {assertion.applicability.glob}")
1348
+ if assertion.applicability.exclude:
1349
+ print(f" Exclude: {', '.join(assertion.applicability.exclude)}")
1350
+ print(f" File: {file_path} → {glob_status}")
1351
+ else:
1352
+ print(" Glob: (any)")
1353
+ else:
1354
+ print(" Applicability: (none)")
1355
+
1356
+ # Overall result
1357
+ print()
1358
+ lang_ok = matches_language(file_path, assertion.languages)
1359
+ glob_ok = True
1360
+ if assertion.applicability and assertion.applicability.glob:
1361
+ glob_ok = matches_glob(
1362
+ file_path,
1363
+ assertion.applicability.glob,
1364
+ assertion.applicability.exclude,
1365
+ )
1366
+ overall = lang_ok and glob_ok
1367
+ result = "APPLICABLE" if overall else "NOT APPLICABLE"
1368
+ print(f" Result: {result}")
1369
+ return 0
1370
+
1371
+ print(f"Rule '{rule_id}' not found.")
1372
+ return 1
1373
+
1374
+
967
1375
  # --- Hooks commands ---
968
1376
 
969
1377
  PRECOMMIT_HOOK_SCRIPT = """\
@@ -989,7 +1397,7 @@ def cmd_hooks_install(args: argparse.Namespace) -> int:
989
1397
 
990
1398
  path = args.path or "."
991
1399
  if not is_git_repo(path):
992
- print(f"Error: {path} is not a git repository")
1400
+ print(f"Error: {path} is not inside a git repository")
993
1401
  return 1
994
1402
 
995
1403
  root_result = get_repo_root(path)
@@ -1035,7 +1443,7 @@ def cmd_hooks_uninstall(args: argparse.Namespace) -> int:
1035
1443
 
1036
1444
  path = args.path or "."
1037
1445
  if not is_git_repo(path):
1038
- print(f"Error: {path} is not a git repository")
1446
+ print(f"Error: {path} is not inside a git repository")
1039
1447
  return 1
1040
1448
 
1041
1449
  root_result = get_repo_root(path)
@@ -1068,7 +1476,7 @@ def cmd_hooks_status(args: argparse.Namespace) -> int:
1068
1476
 
1069
1477
  path = args.path or "."
1070
1478
  if not is_git_repo(path):
1071
- print(f"Error: {path} is not a git repository")
1479
+ print(f"Error: {path} is not inside a git repository")
1072
1480
  return 1
1073
1481
 
1074
1482
  root_result = get_repo_root(path)
@@ -1333,6 +1741,104 @@ def cmd_ci_generate(args: argparse.Namespace) -> int:
1333
1741
  return 0
1334
1742
 
1335
1743
 
1744
+ # --- Config commands ---
1745
+
1746
+ CONFIG_DIR = Path.home() / ".config" / "crucible"
1747
+ SECRETS_FILE = CONFIG_DIR / "secrets.yaml"
1748
+
1749
+
1750
+ def cmd_config_set_api_key(args: argparse.Namespace) -> int:
1751
+ """Set Anthropic API key for LLM compliance assertions."""
1752
+ import getpass
1753
+
1754
+ import yaml
1755
+
1756
+ print("Set Anthropic API key for LLM compliance assertions.")
1757
+ print("This will be stored in ~/.config/crucible/secrets.yaml")
1758
+ print()
1759
+
1760
+ # Prompt for key (hidden input)
1761
+ api_key = getpass.getpass("Enter API key (input hidden): ")
1762
+
1763
+ if not api_key:
1764
+ print("No key provided, aborting.")
1765
+ return 1
1766
+
1767
+ if not api_key.startswith("sk-ant-"):
1768
+ print("Warning: Key doesn't start with 'sk-ant-', are you sure this is correct?")
1769
+ confirm = input("Continue? [y/N]: ")
1770
+ if confirm.lower() != "y":
1771
+ print("Aborted.")
1772
+ return 1
1773
+
1774
+ # Create config directory
1775
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
1776
+
1777
+ # Load existing config or create new
1778
+ existing_config: dict = {}
1779
+ if SECRETS_FILE.exists():
1780
+ try:
1781
+ with open(SECRETS_FILE) as f:
1782
+ existing_config = yaml.safe_load(f) or {}
1783
+ except Exception as e:
1784
+ print(f"Warning: Could not read existing {SECRETS_FILE}: {e}", file=sys.stderr)
1785
+
1786
+ # Update config
1787
+ existing_config["anthropic_api_key"] = api_key
1788
+
1789
+ # Write with restrictive permissions
1790
+ SECRETS_FILE.write_text(yaml.dump(existing_config, default_flow_style=False))
1791
+ SECRETS_FILE.chmod(0o600)
1792
+
1793
+ print(f"API key saved to {SECRETS_FILE}")
1794
+ print("Permissions set to 600 (owner read/write only)")
1795
+ return 0
1796
+
1797
+
1798
+ def cmd_config_show(args: argparse.Namespace) -> int:
1799
+ """Show current configuration."""
1800
+ import os
1801
+
1802
+ import yaml
1803
+
1804
+ print("Crucible Configuration")
1805
+ print("=" * 40)
1806
+
1807
+ # Check env var
1808
+ env_key = os.environ.get("ANTHROPIC_API_KEY")
1809
+ if env_key:
1810
+ print(f"ANTHROPIC_API_KEY (env): {env_key[:10]}...{env_key[-4:]}")
1811
+ else:
1812
+ print("ANTHROPIC_API_KEY (env): not set")
1813
+
1814
+ # Check config file
1815
+ if SECRETS_FILE.exists():
1816
+ try:
1817
+ with open(SECRETS_FILE) as f:
1818
+ data = yaml.safe_load(f) or {}
1819
+ file_key = data.get("anthropic_api_key")
1820
+ if file_key:
1821
+ print(f"anthropic_api_key (file): {file_key[:10]}...{file_key[-4:]}")
1822
+ else:
1823
+ print("anthropic_api_key (file): not set")
1824
+ print(f"Config file: {SECRETS_FILE}")
1825
+ except Exception as e:
1826
+ print(f"Config file error: {e}")
1827
+ else:
1828
+ print(f"Config file: {SECRETS_FILE} (not found)")
1829
+
1830
+ # Show which would be used
1831
+ print()
1832
+ if env_key:
1833
+ print("Active: environment variable (takes precedence)")
1834
+ elif SECRETS_FILE.exists():
1835
+ print("Active: config file")
1836
+ else:
1837
+ print("Active: none (LLM assertions will fail)")
1838
+
1839
+ return 0
1840
+
1841
+
1336
1842
  # --- Main ---
1337
1843
 
1338
1844
 
@@ -1483,7 +1989,24 @@ def main() -> int:
1483
1989
  help="Suppress progress messages"
1484
1990
  )
1485
1991
  review_parser.add_argument(
1486
- "path", nargs="?", default=".", help="Repository path"
1992
+ "--token-budget", type=int,
1993
+ help="Token budget for LLM compliance assertions (0 = unlimited)"
1994
+ )
1995
+ review_parser.add_argument(
1996
+ "--compliance-model",
1997
+ choices=["sonnet", "opus", "haiku"],
1998
+ help="Model for LLM compliance assertions (default: sonnet)"
1999
+ )
2000
+ review_parser.add_argument(
2001
+ "--no-compliance", action="store_true",
2002
+ help="Disable LLM compliance assertions"
2003
+ )
2004
+ review_parser.add_argument(
2005
+ "--no-git", action="store_true",
2006
+ help="Review path directly without git awareness (static analysis only)"
2007
+ )
2008
+ review_parser.add_argument(
2009
+ "path", nargs="?", default=".", help="Path to review (file or directory)"
1487
2010
  )
1488
2011
 
1489
2012
  # === pre-commit command (direct invocation) ===
@@ -1526,6 +2049,38 @@ def main() -> int:
1526
2049
  help="Project path (default: current directory)"
1527
2050
  )
1528
2051
 
2052
+ # === assertions command ===
2053
+ assertions_parser = subparsers.add_parser("assertions", help="Manage pattern assertions")
2054
+ assertions_sub = assertions_parser.add_subparsers(dest="assertions_command")
2055
+
2056
+ # assertions validate
2057
+ assertions_sub.add_parser("validate", help="Validate assertion files")
2058
+
2059
+ # assertions list
2060
+ assertions_sub.add_parser("list", help="List assertion files from all sources")
2061
+
2062
+ # assertions test
2063
+ assertions_test_parser = assertions_sub.add_parser(
2064
+ "test",
2065
+ help="Test assertions against a file"
2066
+ )
2067
+ assertions_test_parser.add_argument("file", help="File to test")
2068
+
2069
+ # assertions explain
2070
+ assertions_explain_parser = assertions_sub.add_parser(
2071
+ "explain",
2072
+ help="Explain what a rule does"
2073
+ )
2074
+ assertions_explain_parser.add_argument("rule", help="Rule ID to explain")
2075
+
2076
+ # assertions debug
2077
+ assertions_debug_parser = assertions_sub.add_parser(
2078
+ "debug",
2079
+ help="Debug applicability for a rule and file"
2080
+ )
2081
+ assertions_debug_parser.add_argument("--rule", "-r", required=True, help="Rule ID")
2082
+ assertions_debug_parser.add_argument("--file", "-f", required=True, help="File to check")
2083
+
1529
2084
  # === ci command ===
1530
2085
  ci_parser = subparsers.add_parser(
1531
2086
  "ci",
@@ -1549,6 +2104,22 @@ def main() -> int:
1549
2104
  help="Fail threshold for CI (default: high)"
1550
2105
  )
1551
2106
 
2107
+ # === config command ===
2108
+ config_parser = subparsers.add_parser("config", help="Manage crucible configuration")
2109
+ config_sub = config_parser.add_subparsers(dest="config_command")
2110
+
2111
+ # config set-api-key
2112
+ config_sub.add_parser(
2113
+ "set-api-key",
2114
+ help="Set Anthropic API key for LLM compliance assertions"
2115
+ )
2116
+
2117
+ # config show
2118
+ config_sub.add_parser(
2119
+ "show",
2120
+ help="Show current configuration"
2121
+ )
2122
+
1552
2123
  args = parser.parse_args()
1553
2124
 
1554
2125
  if args.command == "init":
@@ -1593,10 +2164,32 @@ def main() -> int:
1593
2164
  else:
1594
2165
  hooks_parser.print_help()
1595
2166
  return 0
2167
+ elif args.command == "assertions":
2168
+ if args.assertions_command == "validate":
2169
+ return cmd_assertions_validate(args)
2170
+ elif args.assertions_command == "list":
2171
+ return cmd_assertions_list(args)
2172
+ elif args.assertions_command == "test":
2173
+ return cmd_assertions_test(args)
2174
+ elif args.assertions_command == "explain":
2175
+ return cmd_assertions_explain(args)
2176
+ elif args.assertions_command == "debug":
2177
+ return cmd_assertions_debug(args)
2178
+ else:
2179
+ assertions_parser.print_help()
2180
+ return 0
1596
2181
  elif args.command == "review":
1597
2182
  return cmd_review(args)
1598
2183
  elif args.command == "pre-commit":
1599
2184
  return cmd_precommit(args)
2185
+ elif args.command == "config":
2186
+ if args.config_command == "set-api-key":
2187
+ return cmd_config_set_api_key(args)
2188
+ elif args.config_command == "show":
2189
+ return cmd_config_show(args)
2190
+ else:
2191
+ config_parser.print_help()
2192
+ return 0
1600
2193
  else:
1601
2194
  # Default help
1602
2195
  print("crucible - Code review orchestration\n")
@@ -1619,9 +2212,16 @@ def main() -> int:
1619
2212
  print(" crucible hooks uninstall Remove pre-commit hook")
1620
2213
  print(" crucible hooks status Show hook installation status")
1621
2214
  print()
1622
- print(" crucible review Review git changes")
2215
+ print(" crucible assertions list List assertion files from all sources")
2216
+ print(" crucible assertions validate Validate assertion files")
2217
+ print(" crucible assertions test <file> Test assertions against a file")
2218
+ print(" crucible assertions explain <r> Explain what a rule does")
2219
+ print(" crucible assertions debug Debug applicability for a rule")
2220
+ print()
2221
+ print(" crucible review Review git changes or files")
1623
2222
  print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
1624
2223
  print(" --base <ref> Base branch or commit count")
2224
+ print(" --no-git Review path without git (static analysis only)")
1625
2225
  print(" --fail-on <severity> Fail threshold (critical/high/medium/low/info)")
1626
2226
  print(" --format <format> Output format: text (default) or report (markdown)")
1627
2227
  print()