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.
- crucible/cli.py +532 -12
- crucible/enforcement/budget.py +179 -0
- crucible/enforcement/bundled/error-handling.yaml +84 -0
- crucible/enforcement/bundled/security.yaml +123 -0
- crucible/enforcement/bundled/smart-contract.yaml +110 -0
- crucible/enforcement/compliance.py +486 -0
- crucible/enforcement/models.py +71 -1
- crucible/hooks/claudecode.py +388 -0
- crucible/hooks/precommit.py +117 -25
- crucible/knowledge/loader.py +186 -0
- crucible/knowledge/principles/API_DESIGN.md +176 -0
- crucible/knowledge/principles/COMMITS.md +127 -0
- crucible/knowledge/principles/DATABASE.md +138 -0
- crucible/knowledge/principles/DOCUMENTATION.md +201 -0
- crucible/knowledge/principles/ERROR_HANDLING.md +157 -0
- crucible/knowledge/principles/FP.md +162 -0
- crucible/knowledge/principles/GITIGNORE.md +218 -0
- crucible/knowledge/principles/OBSERVABILITY.md +147 -0
- crucible/knowledge/principles/PRECOMMIT.md +201 -0
- crucible/knowledge/principles/SECURITY.md +136 -0
- crucible/knowledge/principles/SMART_CONTRACT.md +153 -0
- crucible/knowledge/principles/SYSTEM_DESIGN.md +153 -0
- crucible/knowledge/principles/TESTING.md +129 -0
- crucible/knowledge/principles/TYPE_SAFETY.md +170 -0
- crucible/review/core.py +78 -7
- crucible/server.py +81 -14
- crucible/skills/accessibility-engineer/SKILL.md +71 -0
- crucible/skills/backend-engineer/SKILL.md +69 -0
- crucible/skills/customer-success/SKILL.md +69 -0
- crucible/skills/data-engineer/SKILL.md +70 -0
- crucible/skills/devops-engineer/SKILL.md +69 -0
- crucible/skills/fde-engineer/SKILL.md +69 -0
- crucible/skills/formal-verification/SKILL.md +86 -0
- crucible/skills/gas-optimizer/SKILL.md +89 -0
- crucible/skills/incident-responder/SKILL.md +91 -0
- crucible/skills/mev-researcher/SKILL.md +87 -0
- crucible/skills/mobile-engineer/SKILL.md +70 -0
- crucible/skills/performance-engineer/SKILL.md +68 -0
- crucible/skills/product-engineer/SKILL.md +68 -0
- crucible/skills/protocol-architect/SKILL.md +83 -0
- crucible/skills/security-engineer/SKILL.md +63 -0
- crucible/skills/tech-lead/SKILL.md +92 -0
- crucible/skills/uiux-engineer/SKILL.md +70 -0
- crucible/skills/web3-engineer/SKILL.md +79 -0
- crucible/tools/git.py +17 -4
- crucible_mcp-1.0.0.dist-info/METADATA +198 -0
- crucible_mcp-1.0.0.dist-info/RECORD +66 -0
- crucible_mcp-0.4.0.dist-info/METADATA +0 -160
- crucible_mcp-0.4.0.dist-info/RECORD +0 -28
- {crucible_mcp-0.4.0.dist-info → crucible_mcp-1.0.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-0.4.0.dist-info → crucible_mcp-1.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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()
|