crucible-mcp 0.4.0__py3-none-any.whl → 1.0.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.
Files changed (52) hide show
  1. crucible/cli.py +532 -12
  2. crucible/enforcement/budget.py +179 -0
  3. crucible/enforcement/bundled/error-handling.yaml +84 -0
  4. crucible/enforcement/bundled/security.yaml +123 -0
  5. crucible/enforcement/bundled/smart-contract.yaml +110 -0
  6. crucible/enforcement/compliance.py +486 -0
  7. crucible/enforcement/models.py +71 -1
  8. crucible/hooks/claudecode.py +388 -0
  9. crucible/hooks/precommit.py +117 -25
  10. crucible/knowledge/loader.py +186 -0
  11. crucible/knowledge/principles/API_DESIGN.md +176 -0
  12. crucible/knowledge/principles/COMMITS.md +127 -0
  13. crucible/knowledge/principles/DATABASE.md +138 -0
  14. crucible/knowledge/principles/DOCUMENTATION.md +201 -0
  15. crucible/knowledge/principles/ERROR_HANDLING.md +157 -0
  16. crucible/knowledge/principles/FP.md +162 -0
  17. crucible/knowledge/principles/GITIGNORE.md +218 -0
  18. crucible/knowledge/principles/OBSERVABILITY.md +147 -0
  19. crucible/knowledge/principles/PRECOMMIT.md +201 -0
  20. crucible/knowledge/principles/SECURITY.md +136 -0
  21. crucible/knowledge/principles/SMART_CONTRACT.md +153 -0
  22. crucible/knowledge/principles/SYSTEM_DESIGN.md +153 -0
  23. crucible/knowledge/principles/TESTING.md +129 -0
  24. crucible/knowledge/principles/TYPE_SAFETY.md +170 -0
  25. crucible/review/core.py +78 -7
  26. crucible/server.py +81 -14
  27. crucible/skills/accessibility-engineer/SKILL.md +71 -0
  28. crucible/skills/backend-engineer/SKILL.md +69 -0
  29. crucible/skills/customer-success/SKILL.md +69 -0
  30. crucible/skills/data-engineer/SKILL.md +70 -0
  31. crucible/skills/devops-engineer/SKILL.md +69 -0
  32. crucible/skills/fde-engineer/SKILL.md +69 -0
  33. crucible/skills/formal-verification/SKILL.md +86 -0
  34. crucible/skills/gas-optimizer/SKILL.md +89 -0
  35. crucible/skills/incident-responder/SKILL.md +91 -0
  36. crucible/skills/mev-researcher/SKILL.md +87 -0
  37. crucible/skills/mobile-engineer/SKILL.md +70 -0
  38. crucible/skills/performance-engineer/SKILL.md +68 -0
  39. crucible/skills/product-engineer/SKILL.md +68 -0
  40. crucible/skills/protocol-architect/SKILL.md +83 -0
  41. crucible/skills/security-engineer/SKILL.md +63 -0
  42. crucible/skills/tech-lead/SKILL.md +92 -0
  43. crucible/skills/uiux-engineer/SKILL.md +70 -0
  44. crucible/skills/web3-engineer/SKILL.md +79 -0
  45. crucible/tools/git.py +17 -4
  46. crucible_mcp-1.0.0.dist-info/METADATA +198 -0
  47. crucible_mcp-1.0.0.dist-info/RECORD +66 -0
  48. crucible_mcp-0.4.0.dist-info/METADATA +0 -160
  49. crucible_mcp-0.4.0.dist-info/RECORD +0 -28
  50. {crucible_mcp-0.4.0.dist-info → crucible_mcp-1.0.0.dist-info}/WHEEL +0 -0
  51. {crucible_mcp-0.4.0.dist-info → crucible_mcp-1.0.0.dist-info}/entry_points.txt +0 -0
  52. {crucible_mcp-0.4.0.dist-info → crucible_mcp-1.0.0.dist-info}/top_level.txt +0 -0
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,22 +433,269 @@ 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
+ # Run enforcement assertions
582
+ from crucible.review.core import run_enforcement
583
+
584
+ compliance_config = _build_compliance_config(
585
+ config,
586
+ cli_token_budget=getattr(args, "token_budget", None),
587
+ cli_model=getattr(args, "compliance_model", None),
588
+ cli_no_compliance=getattr(args, "no_compliance", False),
589
+ )
590
+
591
+ # Use current directory as repo root for enforcement
592
+ enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped, budget_state = (
593
+ run_enforcement(
594
+ ".",
595
+ changed_files=files_to_analyze,
596
+ repo_root=".",
597
+ compliance_config=compliance_config,
598
+ )
599
+ )
600
+ tool_errors.extend(enforcement_errors)
601
+
602
+ # Compute severity summary
603
+ severity_counts = compute_severity_counts(all_findings)
604
+
605
+ # Determine pass/fail
606
+ passed = True
607
+ if default_threshold:
608
+ threshold_idx = severity_order.index(default_threshold.value)
609
+ for sev in severity_order[: threshold_idx + 1]:
610
+ if severity_counts.get(sev, 0) > 0:
611
+ passed = False
612
+ break
613
+
614
+ # Output
615
+ if args.json:
616
+ output = {
617
+ "mode": "no-git",
618
+ "files_analyzed": len(files_to_analyze),
619
+ "domains_detected": [d.value for d in domains_detected],
620
+ "findings": [
621
+ {
622
+ "tool": f.tool,
623
+ "rule": f.rule,
624
+ "severity": f.severity.value,
625
+ "message": f.message,
626
+ "location": f.location,
627
+ "suggestion": f.suggestion,
628
+ }
629
+ for f in all_findings
630
+ ],
631
+ "enforcement": {
632
+ "findings": [
633
+ {
634
+ "assertion_id": f.assertion_id,
635
+ "severity": f.severity,
636
+ "message": f.message,
637
+ "location": f.location,
638
+ "source": f.source,
639
+ }
640
+ for f in enforcement_findings
641
+ ],
642
+ "assertions_checked": assertions_checked,
643
+ "assertions_skipped": assertions_skipped,
644
+ "tokens_used": budget_state.tokens_used if budget_state else 0,
645
+ },
646
+ "severity_counts": severity_counts,
647
+ "passed": passed,
648
+ "threshold": default_threshold.value if default_threshold else None,
649
+ "errors": tool_errors,
650
+ }
651
+ print(json_mod.dumps(output, indent=2))
652
+ else:
653
+ # Text output
654
+ if all_findings:
655
+ print(f"\nFound {len(all_findings)} static analysis issue(s):\n")
656
+ for f in all_findings:
657
+ sev_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "info": "⚪"}.get(
658
+ f.severity.value, "⚪"
659
+ )
660
+ print(f"{sev_icon} [{f.severity.value.upper()}] {f.location}")
661
+ print(f" {f.tool}/{f.rule}: {f.message}")
662
+ if f.suggestion:
663
+ print(f" 💡 {f.suggestion}")
664
+ print()
665
+
666
+ # Enforcement findings
667
+ if enforcement_findings:
668
+ print(f"\nEnforcement Assertions ({len(enforcement_findings)}):")
669
+ for f in enforcement_findings:
670
+ sev_icon = {"error": "🔴", "warning": "🟠", "info": "⚪"}.get(f.severity, "⚪")
671
+ source_tag = "[LLM]" if f.source == "llm" else "[Pattern]"
672
+ print(f" {sev_icon} [{f.severity.upper()}] {source_tag} {f.assertion_id}: {f.location}")
673
+ print(f" {f.message}")
674
+ print()
675
+
676
+ if not all_findings and not enforcement_findings:
677
+ print("\n✅ No issues found.")
678
+
679
+ # Summary
680
+ if severity_counts:
681
+ counts_str = ", ".join(f"{k}: {v}" for k, v in severity_counts.items() if v > 0)
682
+ print(f"Summary: {counts_str}")
683
+
684
+ if assertions_checked or assertions_skipped:
685
+ print(f"Assertions: {assertions_checked} checked, {assertions_skipped} skipped")
686
+ if budget_state and budget_state.tokens_used > 0:
687
+ print(f" LLM tokens used: {budget_state.tokens_used}")
688
+
689
+ if tool_errors and not args.quiet:
690
+ print(f"\n⚠️ {len(tool_errors)} tool error(s)")
691
+ for err in tool_errors[:5]:
692
+ print(f" - {err}")
693
+
694
+ return 0 if passed else 1
695
+
696
+
440
697
  def cmd_review(args: argparse.Namespace) -> int:
441
- """Run code review on git changes."""
698
+ """Run code review on git changes or a path directly."""
442
699
  import json as json_mod
443
700
 
444
701
  from crucible.models import Domain, Severity, ToolFinding
@@ -450,6 +707,14 @@ def cmd_review(args: argparse.Namespace) -> int:
450
707
  get_tools_for_domain,
451
708
  run_static_analysis,
452
709
  )
710
+
711
+ path = args.path or "."
712
+
713
+ # Handle --no-git mode: simple static analysis without git awareness
714
+ if getattr(args, "no_git", False):
715
+ return _cmd_review_no_git(args, path)
716
+
717
+ # Git-aware review mode
453
718
  from crucible.tools.git import (
454
719
  get_branch_diff,
455
720
  get_recent_commits,
@@ -459,12 +724,12 @@ def cmd_review(args: argparse.Namespace) -> int:
459
724
  is_git_repo,
460
725
  )
461
726
 
462
- path = args.path or "."
463
727
  mode = args.mode
464
728
 
465
729
  # Validate git repo
466
730
  if not is_git_repo(path):
467
- print(f"Error: {path} is not a git repository")
731
+ print(f"Error: {path} is not inside a git repository")
732
+ print("Hint: Use --no-git to review files without git awareness")
468
733
  return 1
469
734
 
470
735
  root_result = get_repo_root(path)
@@ -623,6 +888,28 @@ def cmd_review(args: argparse.Namespace) -> int:
623
888
  if result.is_ok:
624
889
  knowledge_content[filename] = result.value
625
890
 
891
+ # Run enforcement assertions (pattern + LLM)
892
+ from crucible.review.core import run_enforcement
893
+
894
+ compliance_config = _build_compliance_config(
895
+ config,
896
+ cli_token_budget=getattr(args, "token_budget", None),
897
+ cli_model=getattr(args, "compliance_model", None),
898
+ cli_no_compliance=getattr(args, "no_compliance", False),
899
+ )
900
+
901
+ enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped, budget_state = (
902
+ run_enforcement(
903
+ repo_path,
904
+ changed_files=changed_files,
905
+ repo_root=repo_path,
906
+ compliance_config=compliance_config,
907
+ )
908
+ )
909
+
910
+ # Add enforcement errors to tool errors
911
+ tool_errors.extend(enforcement_errors)
912
+
626
913
  # Compute severity summary
627
914
  severity_counts = compute_severity_counts(filtered_findings)
628
915
 
@@ -673,6 +960,22 @@ def cmd_review(args: argparse.Namespace) -> int:
673
960
  }
674
961
  for f in filtered_findings
675
962
  ],
963
+ "enforcement": {
964
+ "findings": [
965
+ {
966
+ "assertion_id": f.assertion_id,
967
+ "severity": f.severity,
968
+ "message": f.message,
969
+ "location": f.location,
970
+ "source": f.source,
971
+ "suppressed": f.suppressed,
972
+ }
973
+ for f in enforcement_findings
974
+ ],
975
+ "assertions_checked": assertions_checked,
976
+ "assertions_skipped": assertions_skipped,
977
+ "llm_tokens_used": budget_state.tokens_used if budget_state else 0,
978
+ },
676
979
  "severity_counts": severity_counts,
677
980
  "threshold": threshold_used,
678
981
  "errors": tool_errors,
@@ -815,6 +1118,28 @@ def cmd_review(args: argparse.Namespace) -> int:
815
1118
  else:
816
1119
  print("\nNo issues found in changed code.")
817
1120
 
1121
+ # Enforcement assertions
1122
+ if enforcement_findings:
1123
+ active_enforcement = [f for f in enforcement_findings if not f.suppressed]
1124
+ suppressed_enforcement = [f for f in enforcement_findings if f.suppressed]
1125
+
1126
+ if active_enforcement:
1127
+ print(f"\nEnforcement Assertions ({len(active_enforcement)}):")
1128
+ for f in active_enforcement:
1129
+ sev_upper = f.severity.upper()
1130
+ source_label = "[LLM]" if f.source == "llm" else "[PATTERN]"
1131
+ print(f" [{sev_upper}] {source_label} {f.assertion_id}: {f.location}")
1132
+ print(f" {f.message}")
1133
+ print()
1134
+
1135
+ if suppressed_enforcement:
1136
+ print(f" Suppressed: {len(suppressed_enforcement)}")
1137
+
1138
+ if assertions_checked > 0:
1139
+ print(f"\nAssertions: {assertions_checked} checked, {assertions_skipped} skipped")
1140
+ if budget_state and budget_state.tokens_used > 0:
1141
+ print(f" LLM tokens used: {budget_state.tokens_used}")
1142
+
818
1143
  if effective_threshold:
819
1144
  status = "PASSED" if passed else "FAILED"
820
1145
  print(f"\n{'='*60}")
@@ -1124,7 +1449,7 @@ def cmd_hooks_install(args: argparse.Namespace) -> int:
1124
1449
 
1125
1450
  path = args.path or "."
1126
1451
  if not is_git_repo(path):
1127
- print(f"Error: {path} is not a git repository")
1452
+ print(f"Error: {path} is not inside a git repository")
1128
1453
  return 1
1129
1454
 
1130
1455
  root_result = get_repo_root(path)
@@ -1170,7 +1495,7 @@ def cmd_hooks_uninstall(args: argparse.Namespace) -> int:
1170
1495
 
1171
1496
  path = args.path or "."
1172
1497
  if not is_git_repo(path):
1173
- print(f"Error: {path} is not a git repository")
1498
+ print(f"Error: {path} is not inside a git repository")
1174
1499
  return 1
1175
1500
 
1176
1501
  root_result = get_repo_root(path)
@@ -1203,7 +1528,7 @@ def cmd_hooks_status(args: argparse.Namespace) -> int:
1203
1528
 
1204
1529
  path = args.path or "."
1205
1530
  if not is_git_repo(path):
1206
- print(f"Error: {path} is not a git repository")
1531
+ print(f"Error: {path} is not inside a git repository")
1207
1532
  return 1
1208
1533
 
1209
1534
  root_result = get_repo_root(path)
@@ -1409,11 +1734,30 @@ include_context: false
1409
1734
  if not gitignore_path.exists():
1410
1735
  gitignore_path.write_text("# Local overrides (optional)\n*.local.md\n")
1411
1736
 
1737
+ # Create minimal CLAUDE.md if requested
1738
+ if args.with_claudemd:
1739
+ claudemd_path = project_path / "CLAUDE.md"
1740
+ if claudemd_path.exists() and not args.force:
1741
+ print(f"Warning: {claudemd_path} exists, skipping (use --force to overwrite)")
1742
+ else:
1743
+ project_name = project_path.name
1744
+ claudemd_content = f"""# {project_name}
1745
+
1746
+ Use Crucible for code review: `crucible review`
1747
+
1748
+ For full engineering principles and patterns, run:
1749
+ - `crucible knowledge list` - see available knowledge
1750
+ - `crucible skills list` - see available review personas
1751
+ """
1752
+ claudemd_path.write_text(claudemd_content)
1753
+ print(f"Created {claudemd_path}")
1754
+
1412
1755
  print(f"\nInitialized {crucible_dir}")
1413
1756
  print("\nNext steps:")
1414
1757
  print(" 1. Customize skills: crucible skills init <skill>")
1415
1758
  print(" 2. Customize knowledge: crucible knowledge init <file>")
1416
1759
  print(" 3. Install git hooks: crucible hooks install")
1760
+ print(" 4. Claude Code hooks: crucible hooks claudecode init")
1417
1761
  return 0
1418
1762
 
1419
1763
 
@@ -1468,6 +1812,104 @@ def cmd_ci_generate(args: argparse.Namespace) -> int:
1468
1812
  return 0
1469
1813
 
1470
1814
 
1815
+ # --- Config commands ---
1816
+
1817
+ CONFIG_DIR = Path.home() / ".config" / "crucible"
1818
+ SECRETS_FILE = CONFIG_DIR / "secrets.yaml"
1819
+
1820
+
1821
+ def cmd_config_set_api_key(args: argparse.Namespace) -> int:
1822
+ """Set Anthropic API key for LLM compliance assertions."""
1823
+ import getpass
1824
+
1825
+ import yaml
1826
+
1827
+ print("Set Anthropic API key for LLM compliance assertions.")
1828
+ print("This will be stored in ~/.config/crucible/secrets.yaml")
1829
+ print()
1830
+
1831
+ # Prompt for key (hidden input)
1832
+ api_key = getpass.getpass("Enter API key (input hidden): ")
1833
+
1834
+ if not api_key:
1835
+ print("No key provided, aborting.")
1836
+ return 1
1837
+
1838
+ if not api_key.startswith("sk-ant-"):
1839
+ print("Warning: Key doesn't start with 'sk-ant-', are you sure this is correct?")
1840
+ confirm = input("Continue? [y/N]: ")
1841
+ if confirm.lower() != "y":
1842
+ print("Aborted.")
1843
+ return 1
1844
+
1845
+ # Create config directory
1846
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
1847
+
1848
+ # Load existing config or create new
1849
+ existing_config: dict = {}
1850
+ if SECRETS_FILE.exists():
1851
+ try:
1852
+ with open(SECRETS_FILE) as f:
1853
+ existing_config = yaml.safe_load(f) or {}
1854
+ except Exception as e:
1855
+ print(f"Warning: Could not read existing {SECRETS_FILE}: {e}", file=sys.stderr)
1856
+
1857
+ # Update config
1858
+ existing_config["anthropic_api_key"] = api_key
1859
+
1860
+ # Write with restrictive permissions
1861
+ SECRETS_FILE.write_text(yaml.dump(existing_config, default_flow_style=False))
1862
+ SECRETS_FILE.chmod(0o600)
1863
+
1864
+ print(f"API key saved to {SECRETS_FILE}")
1865
+ print("Permissions set to 600 (owner read/write only)")
1866
+ return 0
1867
+
1868
+
1869
+ def cmd_config_show(args: argparse.Namespace) -> int:
1870
+ """Show current configuration."""
1871
+ import os
1872
+
1873
+ import yaml
1874
+
1875
+ print("Crucible Configuration")
1876
+ print("=" * 40)
1877
+
1878
+ # Check env var
1879
+ env_key = os.environ.get("ANTHROPIC_API_KEY")
1880
+ if env_key:
1881
+ print(f"ANTHROPIC_API_KEY (env): {env_key[:10]}...{env_key[-4:]}")
1882
+ else:
1883
+ print("ANTHROPIC_API_KEY (env): not set")
1884
+
1885
+ # Check config file
1886
+ if SECRETS_FILE.exists():
1887
+ try:
1888
+ with open(SECRETS_FILE) as f:
1889
+ data = yaml.safe_load(f) or {}
1890
+ file_key = data.get("anthropic_api_key")
1891
+ if file_key:
1892
+ print(f"anthropic_api_key (file): {file_key[:10]}...{file_key[-4:]}")
1893
+ else:
1894
+ print("anthropic_api_key (file): not set")
1895
+ print(f"Config file: {SECRETS_FILE}")
1896
+ except Exception as e:
1897
+ print(f"Config file error: {e}")
1898
+ else:
1899
+ print(f"Config file: {SECRETS_FILE} (not found)")
1900
+
1901
+ # Show which would be used
1902
+ print()
1903
+ if env_key:
1904
+ print("Active: environment variable (takes precedence)")
1905
+ elif SECRETS_FILE.exists():
1906
+ print("Active: config file")
1907
+ else:
1908
+ print("Active: none (LLM assertions will fail)")
1909
+
1910
+ return 0
1911
+
1912
+
1471
1913
  # --- Main ---
1472
1914
 
1473
1915
 
@@ -1579,6 +2021,28 @@ def main() -> int:
1579
2021
  "path", nargs="?", default=".", help="Repository path"
1580
2022
  )
1581
2023
 
2024
+ # hooks claudecode
2025
+ hooks_claudecode_parser = hooks_sub.add_parser(
2026
+ "claudecode",
2027
+ help="Claude Code hooks integration"
2028
+ )
2029
+ hooks_claudecode_sub = hooks_claudecode_parser.add_subparsers(dest="claudecode_command")
2030
+
2031
+ # hooks claudecode init
2032
+ hooks_cc_init_parser = hooks_claudecode_sub.add_parser(
2033
+ "init",
2034
+ help="Initialize Claude Code hooks for project"
2035
+ )
2036
+ hooks_cc_init_parser.add_argument(
2037
+ "path", nargs="?", default=".", help="Project path"
2038
+ )
2039
+
2040
+ # hooks claudecode hook (called by Claude Code)
2041
+ hooks_claudecode_sub.add_parser(
2042
+ "hook",
2043
+ help="Run hook (reads JSON from stdin)"
2044
+ )
2045
+
1582
2046
  # === review command ===
1583
2047
  review_parser = subparsers.add_parser(
1584
2048
  "review",
@@ -1618,7 +2082,24 @@ def main() -> int:
1618
2082
  help="Suppress progress messages"
1619
2083
  )
1620
2084
  review_parser.add_argument(
1621
- "path", nargs="?", default=".", help="Repository path"
2085
+ "--token-budget", type=int,
2086
+ help="Token budget for LLM compliance assertions (0 = unlimited)"
2087
+ )
2088
+ review_parser.add_argument(
2089
+ "--compliance-model",
2090
+ choices=["sonnet", "opus", "haiku"],
2091
+ help="Model for LLM compliance assertions (default: sonnet)"
2092
+ )
2093
+ review_parser.add_argument(
2094
+ "--no-compliance", action="store_true",
2095
+ help="Disable LLM compliance assertions"
2096
+ )
2097
+ review_parser.add_argument(
2098
+ "--no-git", action="store_true",
2099
+ help="Review path directly without git awareness (static analysis only)"
2100
+ )
2101
+ review_parser.add_argument(
2102
+ "path", nargs="?", default=".", help="Path to review (file or directory)"
1622
2103
  )
1623
2104
 
1624
2105
  # === pre-commit command (direct invocation) ===
@@ -1656,6 +2137,10 @@ def main() -> int:
1656
2137
  "--minimal", action="store_true",
1657
2138
  help="Create minimal config without copying skills"
1658
2139
  )
2140
+ init_proj_parser.add_argument(
2141
+ "--with-claudemd", action="store_true",
2142
+ help="Generate minimal CLAUDE.md that points to Crucible"
2143
+ )
1659
2144
  init_proj_parser.add_argument(
1660
2145
  "path", nargs="?", default=".",
1661
2146
  help="Project path (default: current directory)"
@@ -1716,6 +2201,22 @@ def main() -> int:
1716
2201
  help="Fail threshold for CI (default: high)"
1717
2202
  )
1718
2203
 
2204
+ # === config command ===
2205
+ config_parser = subparsers.add_parser("config", help="Manage crucible configuration")
2206
+ config_sub = config_parser.add_subparsers(dest="config_command")
2207
+
2208
+ # config set-api-key
2209
+ config_sub.add_parser(
2210
+ "set-api-key",
2211
+ help="Set Anthropic API key for LLM compliance assertions"
2212
+ )
2213
+
2214
+ # config show
2215
+ config_sub.add_parser(
2216
+ "show",
2217
+ help="Show current configuration"
2218
+ )
2219
+
1719
2220
  args = parser.parse_args()
1720
2221
 
1721
2222
  if args.command == "init":
@@ -1757,6 +2258,15 @@ def main() -> int:
1757
2258
  return cmd_hooks_uninstall(args)
1758
2259
  elif args.hooks_command == "status":
1759
2260
  return cmd_hooks_status(args)
2261
+ elif args.hooks_command == "claudecode":
2262
+ from crucible.hooks.claudecode import main_init, run_hook
2263
+ if args.claudecode_command == "init":
2264
+ return main_init(args.path)
2265
+ elif args.claudecode_command == "hook":
2266
+ return run_hook()
2267
+ else:
2268
+ hooks_claudecode_parser.print_help()
2269
+ return 0
1760
2270
  else:
1761
2271
  hooks_parser.print_help()
1762
2272
  return 0
@@ -1778,6 +2288,14 @@ def main() -> int:
1778
2288
  return cmd_review(args)
1779
2289
  elif args.command == "pre-commit":
1780
2290
  return cmd_precommit(args)
2291
+ elif args.command == "config":
2292
+ if args.config_command == "set-api-key":
2293
+ return cmd_config_set_api_key(args)
2294
+ elif args.config_command == "show":
2295
+ return cmd_config_show(args)
2296
+ else:
2297
+ config_parser.print_help()
2298
+ return 0
1781
2299
  else:
1782
2300
  # Default help
1783
2301
  print("crucible - Code review orchestration\n")
@@ -1799,6 +2317,7 @@ def main() -> int:
1799
2317
  print(" crucible hooks install Install pre-commit hook to .git/hooks/")
1800
2318
  print(" crucible hooks uninstall Remove pre-commit hook")
1801
2319
  print(" crucible hooks status Show hook installation status")
2320
+ print(" crucible hooks claudecode init Initialize Claude Code hooks")
1802
2321
  print()
1803
2322
  print(" crucible assertions list List assertion files from all sources")
1804
2323
  print(" crucible assertions validate Validate assertion files")
@@ -1806,9 +2325,10 @@ def main() -> int:
1806
2325
  print(" crucible assertions explain <r> Explain what a rule does")
1807
2326
  print(" crucible assertions debug Debug applicability for a rule")
1808
2327
  print()
1809
- print(" crucible review Review git changes")
2328
+ print(" crucible review Review git changes or files")
1810
2329
  print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
1811
2330
  print(" --base <ref> Base branch or commit count")
2331
+ print(" --no-git Review path without git (static analysis only)")
1812
2332
  print(" --fail-on <severity> Fail threshold (critical/high/medium/low/info)")
1813
2333
  print(" --format <format> Output format: text (default) or report (markdown)")
1814
2334
  print()