crucible-mcp 0.4.0__tar.gz → 0.5.0__tar.gz

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 (49) hide show
  1. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/PKG-INFO +2 -1
  2. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/pyproject.toml +2 -1
  3. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/cli.py +425 -12
  4. crucible_mcp-0.5.0/src/crucible/enforcement/budget.py +179 -0
  5. crucible_mcp-0.5.0/src/crucible/enforcement/compliance.py +486 -0
  6. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/enforcement/models.py +71 -1
  7. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/review/core.py +78 -7
  8. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/server.py +81 -14
  9. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/tools/git.py +17 -4
  10. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/PKG-INFO +2 -1
  11. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/SOURCES.txt +3 -0
  12. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/requires.txt +1 -0
  13. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_cli.py +1 -1
  14. crucible_mcp-0.5.0/tests/test_compliance.py +617 -0
  15. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_git.py +20 -0
  16. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_hooks_cli.py +1 -1
  17. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/README.md +0 -0
  18. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/setup.cfg +0 -0
  19. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/__init__.py +0 -0
  20. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/domain/__init__.py +0 -0
  21. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/domain/detection.py +0 -0
  22. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/enforcement/__init__.py +0 -0
  23. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/enforcement/assertions.py +0 -0
  24. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/enforcement/patterns.py +0 -0
  25. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/errors.py +0 -0
  26. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/hooks/__init__.py +0 -0
  27. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/hooks/precommit.py +0 -0
  28. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/knowledge/__init__.py +0 -0
  29. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/knowledge/loader.py +0 -0
  30. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/models.py +0 -0
  31. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/review/__init__.py +0 -0
  32. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/skills/__init__.py +0 -0
  33. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/skills/loader.py +0 -0
  34. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/synthesis/__init__.py +0 -0
  35. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/tools/__init__.py +0 -0
  36. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible/tools/delegation.py +0 -0
  37. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/dependency_links.txt +0 -0
  38. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/entry_points.txt +0 -0
  39. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/src/crucible_mcp.egg-info/top_level.txt +0 -0
  40. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_detection.py +0 -0
  41. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_enforcement.py +0 -0
  42. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_full_review.py +0 -0
  43. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_integration.py +0 -0
  44. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_knowledge.py +0 -0
  45. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_precommit.py +0 -0
  46. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_server.py +0 -0
  47. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_skills.py +0 -0
  48. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_skills_loader.py +0 -0
  49. {crucible_mcp-0.4.0 → crucible_mcp-0.5.0}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crucible-mcp
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
5
5
  Author: be.nvy
6
6
  License-Expression: MIT
@@ -9,6 +9,7 @@ Requires-Python: >=3.11
9
9
  Description-Content-Type: text/markdown
10
10
  Requires-Dist: mcp>=1.0.0
11
11
  Requires-Dist: pyyaml>=6.0
12
+ Requires-Dist: anthropic>=0.40.0
12
13
  Provides-Extra: dev
13
14
  Requires-Dist: pytest>=8.0; extra == "dev"
14
15
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crucible-mcp"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Code review MCP server for Claude. Not affiliated with Atlassian."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -11,6 +11,7 @@ keywords = ["mcp", "code-review", "static-analysis", "claude"]
11
11
  dependencies = [
12
12
  "mcp>=1.0.0",
13
13
  "pyyaml>=6.0",
14
+ "anthropic>=0.40.0",
14
15
  ]
15
16
 
16
17
  [project.optional-dependencies]
@@ -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,217 @@ 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
@@ -450,6 +655,14 @@ def cmd_review(args: argparse.Namespace) -> int:
450
655
  get_tools_for_domain,
451
656
  run_static_analysis,
452
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
453
666
  from crucible.tools.git import (
454
667
  get_branch_diff,
455
668
  get_recent_commits,
@@ -459,12 +672,12 @@ def cmd_review(args: argparse.Namespace) -> int:
459
672
  is_git_repo,
460
673
  )
461
674
 
462
- path = args.path or "."
463
675
  mode = args.mode
464
676
 
465
677
  # Validate git repo
466
678
  if not is_git_repo(path):
467
- 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")
468
681
  return 1
469
682
 
470
683
  root_result = get_repo_root(path)
@@ -623,6 +836,28 @@ def cmd_review(args: argparse.Namespace) -> int:
623
836
  if result.is_ok:
624
837
  knowledge_content[filename] = result.value
625
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
+
626
861
  # Compute severity summary
627
862
  severity_counts = compute_severity_counts(filtered_findings)
628
863
 
@@ -673,6 +908,22 @@ def cmd_review(args: argparse.Namespace) -> int:
673
908
  }
674
909
  for f in filtered_findings
675
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
+ },
676
927
  "severity_counts": severity_counts,
677
928
  "threshold": threshold_used,
678
929
  "errors": tool_errors,
@@ -815,6 +1066,28 @@ def cmd_review(args: argparse.Namespace) -> int:
815
1066
  else:
816
1067
  print("\nNo issues found in changed code.")
817
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
+
818
1091
  if effective_threshold:
819
1092
  status = "PASSED" if passed else "FAILED"
820
1093
  print(f"\n{'='*60}")
@@ -1124,7 +1397,7 @@ def cmd_hooks_install(args: argparse.Namespace) -> int:
1124
1397
 
1125
1398
  path = args.path or "."
1126
1399
  if not is_git_repo(path):
1127
- print(f"Error: {path} is not a git repository")
1400
+ print(f"Error: {path} is not inside a git repository")
1128
1401
  return 1
1129
1402
 
1130
1403
  root_result = get_repo_root(path)
@@ -1170,7 +1443,7 @@ def cmd_hooks_uninstall(args: argparse.Namespace) -> int:
1170
1443
 
1171
1444
  path = args.path or "."
1172
1445
  if not is_git_repo(path):
1173
- print(f"Error: {path} is not a git repository")
1446
+ print(f"Error: {path} is not inside a git repository")
1174
1447
  return 1
1175
1448
 
1176
1449
  root_result = get_repo_root(path)
@@ -1203,7 +1476,7 @@ def cmd_hooks_status(args: argparse.Namespace) -> int:
1203
1476
 
1204
1477
  path = args.path or "."
1205
1478
  if not is_git_repo(path):
1206
- print(f"Error: {path} is not a git repository")
1479
+ print(f"Error: {path} is not inside a git repository")
1207
1480
  return 1
1208
1481
 
1209
1482
  root_result = get_repo_root(path)
@@ -1468,6 +1741,104 @@ def cmd_ci_generate(args: argparse.Namespace) -> int:
1468
1741
  return 0
1469
1742
 
1470
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
+
1471
1842
  # --- Main ---
1472
1843
 
1473
1844
 
@@ -1618,7 +1989,24 @@ def main() -> int:
1618
1989
  help="Suppress progress messages"
1619
1990
  )
1620
1991
  review_parser.add_argument(
1621
- "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)"
1622
2010
  )
1623
2011
 
1624
2012
  # === pre-commit command (direct invocation) ===
@@ -1716,6 +2104,22 @@ def main() -> int:
1716
2104
  help="Fail threshold for CI (default: high)"
1717
2105
  )
1718
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
+
1719
2123
  args = parser.parse_args()
1720
2124
 
1721
2125
  if args.command == "init":
@@ -1778,6 +2182,14 @@ def main() -> int:
1778
2182
  return cmd_review(args)
1779
2183
  elif args.command == "pre-commit":
1780
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
1781
2193
  else:
1782
2194
  # Default help
1783
2195
  print("crucible - Code review orchestration\n")
@@ -1806,9 +2218,10 @@ def main() -> int:
1806
2218
  print(" crucible assertions explain <r> Explain what a rule does")
1807
2219
  print(" crucible assertions debug Debug applicability for a rule")
1808
2220
  print()
1809
- print(" crucible review Review git changes")
2221
+ print(" crucible review Review git changes or files")
1810
2222
  print(" --mode <mode> staged/unstaged/branch/commits (default: staged)")
1811
2223
  print(" --base <ref> Base branch or commit count")
2224
+ print(" --no-git Review path without git (static analysis only)")
1812
2225
  print(" --fail-on <severity> Fail threshold (critical/high/medium/low/info)")
1813
2226
  print(" --format <format> Output format: text (default) or report (markdown)")
1814
2227
  print()