mirror of
https://github.com/alirezarezvani/ClaudeForge.git
synced 2026-07-03 02:13:15 -04:00
feat: ClaudeForge 2.1.0 — installable plugin with 150-line cap, forked audit skills, /sync --weekly, and AGENTS.md export (#26)
This commit is contained in:
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ClaudeForge hook: validate every touched CLAUDE.md against the 150-line cap.
|
||||
|
||||
Wired into the plugin's ``hooks/hooks.json`` for both ``PostToolUse`` (after
|
||||
``Edit`` or ``Write``) and ``InstructionsLoaded`` (after any of the five
|
||||
``load_reason`` values fire). Reads the hook payload from stdin, extracts any
|
||||
referenced file path, and exits non-zero with stderr feedback when the file
|
||||
exists and exceeds the cap.
|
||||
|
||||
Exit codes follow the Claude Code hook contract:
|
||||
- ``0`` pass
|
||||
- ``2`` surface stderr to Claude as actionable feedback (do not block)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
DEFAULT_MAX_LINES = 150
|
||||
DEFAULT_EXEMPT_SUFFIX = ".local.md"
|
||||
DEFAULT_VIOLATION_RC = 2
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Merge ``hooks-config.json`` and optional ``hooks-config.local.json``.
|
||||
|
||||
Local file overrides the shared one key-by-key inside ``validateClaudeMd``.
|
||||
Missing files are silently ignored — the script falls back to defaults.
|
||||
"""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
shared = os.path.join(here, "hooks-config.json")
|
||||
local = os.path.join(here, "hooks-config.local.json")
|
||||
cfg: dict = {}
|
||||
for path in (shared, local):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
validate_block = data.get("validateClaudeMd") or {}
|
||||
cfg.update(validate_block)
|
||||
return cfg
|
||||
|
||||
|
||||
def _candidate_paths(payload: dict) -> list[str]:
|
||||
"""Extract every file path the hook payload might be referring to.
|
||||
|
||||
We accept several payload shapes so the same script works for ``PostToolUse``
|
||||
(tool_input.file_path) and ``InstructionsLoaded`` (path / file).
|
||||
"""
|
||||
paths: list[str] = []
|
||||
|
||||
tool_input = payload.get("tool_input") or {}
|
||||
for key in ("file_path", "path", "target_file"):
|
||||
value = tool_input.get(key)
|
||||
if isinstance(value, str):
|
||||
paths.append(value)
|
||||
|
||||
for key in ("path", "file", "file_path"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str):
|
||||
paths.append(value)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def _is_claude_md(path: str, exempt_suffix: str) -> bool:
|
||||
base = os.path.basename(path)
|
||||
# Personal-tier overrides (CLAUDE.local.md and any matching suffix) are
|
||||
# exempt from the cap — they live outside the chained team-shared tree.
|
||||
if base.endswith(exempt_suffix):
|
||||
return False
|
||||
return base == "CLAUDE.md" or "/.claude/rules/" in path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if sys.stdin.isatty():
|
||||
return 0
|
||||
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
return 0
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
|
||||
cfg = _load_config()
|
||||
if cfg.get("enabled") is False:
|
||||
return 0
|
||||
max_lines = int(cfg.get("maxLines", DEFAULT_MAX_LINES))
|
||||
exempt_suffix = str(cfg.get("exemptFilenameSuffix", DEFAULT_EXEMPT_SUFFIX))
|
||||
violation_rc = int(cfg.get("exitCodeOnViolation", DEFAULT_VIOLATION_RC))
|
||||
|
||||
violations: list[tuple[str, int]] = []
|
||||
for path in _candidate_paths(payload):
|
||||
if not _is_claude_md(path, exempt_suffix) or not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
line_count = sum(1 for _ in fh)
|
||||
except OSError:
|
||||
continue
|
||||
if line_count > max_lines:
|
||||
violations.append((path, line_count))
|
||||
|
||||
if not violations:
|
||||
return 0
|
||||
|
||||
for path, line_count in violations:
|
||||
print(
|
||||
f"ClaudeForge: {path} is {line_count} lines (cap is {max_lines}). "
|
||||
"Run /sync-claude-md to split into chained sub-files.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return violation_rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user