Files
graphify/graphify/__main__.py
T

282 lines
10 KiB
Python

"""graphify CLI - `graphify install` sets up the Claude Code skill."""
from __future__ import annotations
import json
import re
import shutil
import sys
from pathlib import Path
_SETTINGS_HOOK = {
"matcher": "Glob|Grep",
"hooks": [
{
"type": "command",
"command": (
"[ -f graphify-out/graph.json ] && "
"echo 'graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md "
"for god nodes and community structure before searching raw files.' || true"
),
}
],
}
_SKILL_REGISTRATION = (
"\n# graphify\n"
"- **graphify** (`~/.claude/skills/graphify/SKILL.md`) "
"- any input to knowledge graph. Trigger: `/graphify`\n"
"When the user types `/graphify`, invoke the Skill tool "
"with `skill: \"graphify\"` before doing anything else.\n"
)
_PLATFORM_CONFIG: dict[str, dict] = {
"claude": {
"skill_file": "skill.md",
"skill_dst": Path(".claude") / "skills" / "graphify" / "SKILL.md",
"claude_md": True,
},
"codex": {
"skill_file": "skill-codex.md",
"skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
"opencode": {
"skill_file": "skill-opencode.md",
"skill_dst": Path(".config") / "opencode" / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
"claw": {
"skill_file": "skill-claw.md",
"skill_dst": Path(".claw") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
}
def install(platform: str = "claude") -> None:
if platform not in _PLATFORM_CONFIG:
print(
f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}",
file=sys.stderr,
)
sys.exit(1)
cfg = _PLATFORM_CONFIG[platform]
skill_src = Path(__file__).parent / cfg["skill_file"]
if not skill_src.exists():
print(f"error: {cfg['skill_file']} not found in package - reinstall graphify", file=sys.stderr)
sys.exit(1)
skill_dst = Path.home() / cfg["skill_dst"]
skill_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(skill_src, skill_dst)
print(f" skill installed → {skill_dst}")
if cfg["claude_md"]:
# Register in ~/.claude/CLAUDE.md (Claude Code only)
claude_md = Path.home() / ".claude" / "CLAUDE.md"
if claude_md.exists():
content = claude_md.read_text()
if "graphify" in content:
print(f" CLAUDE.md → already registered (no change)")
else:
claude_md.write_text(content.rstrip() + _SKILL_REGISTRATION)
print(f" CLAUDE.md → skill registered in {claude_md}")
else:
claude_md.parent.mkdir(parents=True, exist_ok=True)
claude_md.write_text(_SKILL_REGISTRATION.lstrip())
print(f" CLAUDE.md → created at {claude_md}")
print()
print("Done. Open your AI coding assistant and type:")
print()
print(" /graphify .")
print()
_CLAUDE_MD_SECTION = """\
## graphify
This project has a graphify knowledge graph at graphify-out/.
Rules:
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
"""
_CLAUDE_MD_MARKER = "## graphify"
def claude_install(project_dir: Path | None = None) -> None:
"""Write the graphify section to the local CLAUDE.md."""
target = (project_dir or Path(".")) / "CLAUDE.md"
if target.exists():
content = target.read_text()
if _CLAUDE_MD_MARKER in content:
print("graphify already configured in CLAUDE.md")
return
new_content = content.rstrip() + "\n\n" + _CLAUDE_MD_SECTION
else:
new_content = _CLAUDE_MD_SECTION
target.write_text(new_content)
print(f"graphify section written to {target.resolve()}")
# Also write Claude Code PreToolUse hook to .claude/settings.json
_install_claude_hook(project_dir or Path("."))
print()
print("Claude Code will now check the knowledge graph before answering")
print("codebase questions and rebuild it after code changes.")
def _install_claude_hook(project_dir: Path) -> None:
"""Add graphify PreToolUse hook to .claude/settings.json."""
settings_path = project_dir / ".claude" / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
if settings_path.exists():
try:
settings = json.loads(settings_path.read_text())
except json.JSONDecodeError:
settings = {}
else:
settings = {}
hooks = settings.setdefault("hooks", {})
pre_tool = hooks.setdefault("PreToolUse", [])
# Check if already installed
if any(h.get("matcher") == "Glob|Grep" and "graphify" in str(h) for h in pre_tool):
print(f" .claude/settings.json → hook already registered (no change)")
return
pre_tool.append(_SETTINGS_HOOK)
settings_path.write_text(json.dumps(settings, indent=2))
print(f" .claude/settings.json → PreToolUse hook registered")
def _uninstall_claude_hook(project_dir: Path) -> None:
"""Remove graphify PreToolUse hook from .claude/settings.json."""
settings_path = project_dir / ".claude" / "settings.json"
if not settings_path.exists():
return
try:
settings = json.loads(settings_path.read_text())
except json.JSONDecodeError:
return
pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
filtered = [h for h in pre_tool if not (h.get("matcher") == "Glob|Grep" and "graphify" in str(h))]
if len(filtered) == len(pre_tool):
return
settings["hooks"]["PreToolUse"] = filtered
settings_path.write_text(json.dumps(settings, indent=2))
print(f" .claude/settings.json → PreToolUse hook removed")
def claude_uninstall(project_dir: Path | None = None) -> None:
"""Remove the graphify section from the local CLAUDE.md."""
target = (project_dir or Path(".")) / "CLAUDE.md"
if not target.exists():
print("No CLAUDE.md found in current directory - nothing to do")
return
content = target.read_text()
if _CLAUDE_MD_MARKER not in content:
print("graphify section not found in CLAUDE.md - nothing to do")
return
# Remove the ## graphify section: from the marker to the next ## heading or EOF
cleaned = re.sub(
r"\n*## graphify\n.*?(?=\n## |\Z)",
"",
content,
flags=re.DOTALL,
).rstrip()
if cleaned:
target.write_text(cleaned + "\n")
print(f"graphify section removed from {target.resolve()}")
else:
target.unlink()
print(f"CLAUDE.md was empty after removal - deleted {target.resolve()}")
_uninstall_claude_hook(project_dir or Path("."))
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
print("Usage: graphify <command>")
print()
print("Commands:")
print(" install [--platform P] copy skill to platform config dir (claude|codex|opencode|claw)")
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
print(" hook install install post-commit git hook (auto-rebuilds graph on commit)")
print(" hook uninstall remove post-commit git hook")
print(" hook status check if hook is installed")
print(" claude install write graphify section to local CLAUDE.md")
print(" claude uninstall remove graphify section from local CLAUDE.md")
print()
return
cmd = sys.argv[1]
if cmd == "install":
platform = "claude"
args = sys.argv[2:]
i = 0
while i < len(args):
if args[i].startswith("--platform="):
platform = args[i].split("=", 1)[1]
i += 1
elif args[i] == "--platform" and i + 1 < len(args):
platform = args[i + 1]
i += 2
else:
i += 1
install(platform=platform)
elif cmd == "claude":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
claude_install()
elif subcmd == "uninstall":
claude_uninstall()
else:
print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "hook":
from graphify.hooks import install as hook_install, uninstall as hook_uninstall, status as hook_status
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
print(hook_install(Path(".")))
elif subcmd == "uninstall":
print(hook_uninstall(Path(".")))
elif subcmd == "status":
print(hook_status(Path(".")))
else:
print("Usage: graphify hook [install|uninstall|status]", file=sys.stderr)
sys.exit(1)
elif cmd == "benchmark":
from graphify.benchmark import run_benchmark, print_benchmark
graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json"
# Try to load corpus_words from detect output
corpus_words = None
detect_path = Path(".graphify_detect.json")
if detect_path.exists():
try:
detect_data = json.loads(detect_path.read_text())
corpus_words = detect_data.get("total_words")
except Exception:
pass
result = run_benchmark(graph_path, corpus_words=corpus_words)
print_benchmark(result)
else:
print(f"error: unknown command '{cmd}'", file=sys.stderr)
print("Run 'graphify --help' for usage.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()