"""graphify CLI - `graphify install` sets up the Claude Code skill.""" from __future__ import annotations import functools import json import os import platform import re import shutil import sys from pathlib import Path try: from importlib.metadata import version as _pkg_version __version__ = _pkg_version("graphifyy") except Exception: __version__ = "unknown" # Output directory — override with GRAPHIFY_OUT env var for worktrees or shared-output setups. # Accepts a relative name ("graphify-out-feature") or an absolute path ("/shared/graphify-out"). # Defined once in graphify.paths so the security/callflow path guards honour the # same override (#1423). from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT # Install/uninstall subsystem moved to graphify/install.py; re-exported here so # `from graphify.__main__ import ` keeps working unchanged. from graphify.install import ( # noqa: E402,F401 dispatch_install_cli, _agents_install, _agents_platform_install, _agents_platform_uninstall, _agents_uninstall, _always_on, _amp_install, _amp_legacy_cleanup, _amp_uninstall, _antigravity_finalize, _antigravity_install, _antigravity_uninstall, _canonical_platform, _claude_pretooluse_hooks, _copy_skill_file, _cursor_install, _cursor_uninstall, _devin_rules_install, _devin_rules_uninstall, _gemini_hook, _install_claude_hook, _install_codebuddy_hook, _install_codex_hook, _install_gemini_hook, _install_kilo_plugin, _install_opencode_plugin, _install_skill_references, _kilo_config_path, _kilo_config_write_path, _kilo_install, _kilo_uninstall, _kilo_uninstall_global, _kiro_install, _kiro_uninstall, _load_json_like, _packaged_skill_refs_dir, _platform_skill_destination, _print_banner, _print_install_usage, _print_project_git_add_hint, _project_install, _project_scope_root, _project_uninstall, _project_uninstall_all, _refresh_all_version_stamps, _remove_claude_skill_registration, _remove_skill_file, _replace_or_append_section, _resolve_graphify_exe, _skill_registration, _strip_graphify_hook, _strip_graphify_md_section, _strip_json_comments, _uninstall_claude_hook, _uninstall_codebuddy_hook, _uninstall_codex_hook, _uninstall_gemini_hook, _uninstall_kilo_plugin, _uninstall_opencode_plugin, claude_install, claude_uninstall, codebuddy_install, codebuddy_uninstall, gemini_install, gemini_uninstall, install, uninstall_all, vscode_install, vscode_uninstall, _PLATFORM_ALIASES, _CLAUDE_MD_MARKER, _CODEBUDDY_MD_MARKER, _AGENTS_MD_MARKER, _GEMINI_MD_MARKER, _VSCODE_INSTRUCTIONS_MARKER, _ANTIGRAVITY_RULES_PATH, _ANTIGRAVITY_WORKFLOW_PATH, _ANTIGRAVITY_WORKFLOW, _CURSOR_RULE_PATH, _CURSOR_RULE, _DEVIN_RULES_PATH, _DEVIN_RULES, _KILO_PLUGIN_JS, _KILO_PLUGIN_PATH, _KILO_CONFIG_JSON_PATH, _KILO_CONFIG_JSONC_PATH, _OPENCODE_PLUGIN_JS, _OPENCODE_PLUGIN_PATH, _OPENCODE_CONFIG_PATH, _PLATFORM_CONFIG, ) from graphify.cli import ( # noqa: E402,F401 dispatch_command, _StageTimer, _clone_repo, _default_graph_path, _enforce_graph_size_cap_or_exit, _run_hook_guard, _SEARCH_NUDGE, _READ_NUDGE, _HOOK_SOURCE_EXTS, _GEMINI_NUDGE_TEXT, ) _ALWAYS_ON_ALIASES = { "_CLAUDE_MD_SECTION": "claude-md", "_AGENTS_MD_SECTION": "agents-md", "_GEMINI_MD_SECTION": "gemini-md", "_VSCODE_INSTRUCTIONS_SECTION": "vscode-instructions", "_ANTIGRAVITY_RULES": "antigravity-rules", "_KIRO_STEERING": "kiro-steering", } def __getattr__(name: str) -> str: # PEP 562: lazily resolve the legacy always-on section constants for external # importers (e.g. the install-string tests). In-module code calls _always_on() # directly; nothing is read at import time, so a missing block can no longer # brick the CLI on `import graphify.__main__` (#1121 follow-up). base = _ALWAYS_ON_ALIASES.get(name) if base is not None: return _always_on(base) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def _check_skill_version(skill_dst: Path) -> None: """Warn if the installed skill is from an older graphify version.""" version_file = skill_dst.parent / ".graphify_version" try: if not version_file.exists(): return except OSError: return try: skill_exists = skill_dst.exists() except OSError: return if not skill_exists: print(" warning: skill dir exists but SKILL.md is missing. Run 'graphify install' to repair.") return # A progressive SKILL.md links to its references/ sidecar. If the body points # at references/ but the dir is gone (manual delete, partial upgrade), the # on-demand fragments won't load — flag it for repair. try: body = skill_dst.read_text(encoding="utf-8") except OSError: body = "" if "references/" in body and not (skill_dst.parent / "references").exists(): print(" warning: skill references/ sidecar is missing. Run 'graphify install' to repair.", file=sys.stderr) try: installed = version_file.read_text(encoding="utf-8").strip() except OSError: return if installed != __version__: if _version_tuple(installed) > _version_tuple(__version__): # The skill on disk is NEWER than the running package. `graphify install` # writes the package's OWN (older) bundled skill and re-stamps the version, # so following the old "run install" advice would silently DOWNGRADE the # skill. The real fix is to upgrade the package (#1568). Common for a stale # `uv tool` CLI, or a contributor whose dev checkout stamped a newer skill. print( f" warning: skill is from graphify {installed}, but the package is " f"{__version__} (older). Upgrade the package " f"(e.g. 'uv tool upgrade graphifyy' or 'pip install -U graphifyy'); " f"running 'graphify install' would downgrade the skill.", file=sys.stderr, ) else: print(f" warning: skill is from graphify {installed}, package is {__version__}. Run 'graphify install' to update.", file=sys.stderr) def _version_tuple(version: str) -> tuple[int, ...]: """Parse a version string into a comparable integer tuple (``0.9.2`` -> ``(0, 9, 2)``). Reads the leading digits of each dot-segment, so pre/post-release suffixes (``1.0.0rc1``) compare by their numeric core. A non-numeric or empty segment becomes 0, so a malformed stamp degrades to a conservative comparison rather than raising. """ parts: list[int] = [] for segment in str(version).split("."): digits = "" for ch in segment: if ch.isdigit(): digits += ch else: break parts.append(int(digits) if digits else 0) return tuple(parts) # PreToolUse nudge payloads, emitted verbatim by the shell-agnostic # `graphify hook-guard` subcommand (see _run_hook_guard). The previous hooks # inlined POSIX bash (case/esac, [ -f ], single-quoted echo) which Windows # cmd.exe/PowerShell cannot parse, so on Windows the hook failed and the nudge # silently vanished — users had to invoke /graphify by hand (#522). Moving the # logic into a Python subcommand invoked via an absolute exe path makes the hook # parse identically under sh, cmd.exe and PowerShell. Claude Code accepts # additionalContext on PreToolUse (Codex Desktop does not — that path stays a # no-op via `hook-check`). Compact separators keep the payload byte-for-byte the # same JSON the old `echo` emitted. # Source/doc extensions the Read|Glob guard nudges on (verbatim from the old hook). # The trailing-extension test (real final path segment, then its last '.') means # '.json' never false-matches '.js', and framework files like '.astro' are kept. # The always-on instruction blocks are packaged markdown under graphify/always_on/, # generated by tools/skillgen and guarded by `skillgen --check`. Reading them at # load keeps the install-string / issue-#580 contract byte-for-byte while letting # a human edit one fragment instead of a triple-quoted literal here. # AGENTS.md section for Codex, OpenCode, and OpenClaw. # All three platforms read AGENTS.md in the project root for persistent instructions. # Gemini CLI BeforeTool hook nudge text. The hook always returns # {"decision":"allow"} (never blocks a tool) and appends this as additionalContext # when a graph exists. Emitted by `graphify hook-guard gemini`. The old hook was a # `python -c "..."` one-liner that depended on a bare `python` on PATH (often # `python`/`py` or absent on Windows) and embedded backticks + escaped quotes that # Windows PowerShell mangles (#522 follow-up); the subcommand form has no such # dependency and parses under every shell. _KIRO_STEERING_MARKER = "graphify: A knowledge graph of this project" _CODEX_HOOK = { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", # Use the graphify CLI itself so the hook is shell-agnostic: # no [ -f ] bash syntax, no python3 vs python Conda issue, # no JSON escaping inside PowerShell strings. Works on # Windows (PowerShell/cmd.exe), macOS, and Linux. "command": "graphify hook-check", } ], } ] } } def main() -> None: for _stream in (sys.stdout, sys.stderr): if _stream is not None and hasattr(_stream, "reconfigure"): try: _stream.reconfigure(encoding="utf-8", errors="replace") except Exception: pass # Check all known skill install locations for a stale version stamp. # Skip during install/uninstall (hook writes trigger a fresh check anyway). # Skip during hook-check — it runs on every editor tool use and must be silent. # Deduplicate paths so platforms sharing the same install dir don't warn twice. _silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"} if not any(arg in _silent_cmds for arg in sys.argv): # Resolve each platform's real user-scope destination so per-platform # overrides (gemini, opencode, devin, antigravity, amp) check the dir # they actually install into, not the bare cfg['skill_dst']. for skill_dst in {_platform_skill_destination(name) for name in _PLATFORM_CONFIG}: _check_skill_version(skill_dst) if len(sys.argv) >= 2 and sys.argv[1] in ("-v", "--version", "version"): print(f"graphify {__version__}") return if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "-?"): print("Usage: graphify ") print() print("Commands:") print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)") print(" uninstall remove graphify from all detected platforms in one shot") print(" --purge also delete graphify-out/ directory") print(" path \"A\" \"B\" shortest path between two nodes in graph.json") print(" --graph path to graph.json (default graphify-out/graph.json)") print(" explain \"X\" plain-language explanation of a node and its neighbors") print(" --graph path to graph.json (default graphify-out/graph.json)") print(" diagnose multigraph report same-endpoint edge collapse risk in graph.json") print(" --graph path to graph/extraction JSON") print(" (default graphify-out/graph.json)") print(" --json emit machine-readable JSON") print(" --max-examples N max same-endpoint examples to print (default 5)") print(" --directed force directed post-build simulation") print(" --undirected force undirected post-build simulation") print(" (default follows JSON directed flag;") print(" raw extraction with no flag defaults directed)") print(" --extract-path PATH extractor source for suppression scan") print(" clone clone a GitHub repo locally and print its path for /graphify") print(" merge-driver git merge driver: union-merge two graph.json files (set up via hook install)") print(" merge-graphs merge two or more graph.json files into one cross-repo graph") print(" --out output path (default: graphify-out/merged-graph.json)") print(" --branch checkout a specific branch (default: repo default)") print(" --out clone to a custom directory (default: ~/.graphify/repos//)") print(" add fetch a URL and save it to ./raw, then update the graph") print(" --author \"Name\" tag the author of the content") print(" --contributor \"Name\" tag who added it to the corpus") print(" --dir target directory (default: ./raw)") print(" watch watch a folder and rebuild the graph on code changes") print(" update re-extract code files and update the graph (no LLM needed)") print(" --force overwrite graph.json even if the rebuild has fewer nodes") print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)") print(" --no-cluster skip clustering, write raw extraction only") print(" cluster-only rerun clustering on an existing graph.json and regenerate report") print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)") print(" --graph path to graph.json (default /graphify-out/graph.json)") print(" --no-label keep 'Community N' placeholders (skip LLM community naming)") print(" --backend= backend to use for community naming (default: auto-detect)") print(" --model= model to use for community naming") print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)") print(" --batch-size=N communities per labeling LLM call (default 100)") print(" label (re)name communities with the configured LLM backend, regenerate report") print(" --missing-only keep existing labels and only name missing/placeholder communities") print(" --backend= backend to use (default: auto-detect from API keys)") print(" --model= model to use for community naming") print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)") print(" --batch-size=N communities per labeling LLM call (default 100)") print(" query \"\" BFS traversal of graph.json for a question") print(" --dfs use depth-first instead of breadth-first") print(" --context C explicit edge-context filter (repeatable)") print(" --budget N cap output at N tokens (default 2000)") print(" --graph path to graph.json (default graphify-out/graph.json)") print(" affected \"X\" reverse traversal to find nodes impacted by X") print(" --relation R edge relation to traverse in reverse (repeatable)") print(" --depth N reverse traversal depth (default 2)") print(" --graph path to graph.json (default graphify-out/graph.json)") print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop") print(" --question Q the question asked") print(" --answer A the answer to save") print( " --type T query type: query|path_query|explain (default: query)" ) print(" --nodes N1 N2 ... source node labels cited in the answer") print(" --outcome O work-memory signal: useful|dead_end|corrected") print(" --correction TEXT what the right answer was (pairs with --outcome corrected)") print(" --memory-dir DIR memory directory (default: graphify-out/memory)") print(" reflect aggregate graphify-out/memory/ outcomes into a deterministic lessons doc") print(" --memory-dir DIR memory directory (default: graphify-out/memory)") print(" --out FILE output path (default: graphify-out/reflections/LESSONS.md)") print(" --graph PATH graph.json, for community grouping + dropping stale nodes (optional)") print(" --analysis PATH .graphify_analysis.json (optional, auto-detected next to --graph)") print(" --labels PATH .graphify_labels.json (optional, auto-detected next to --graph)") print(" --half-life-days N signal weight halves every N days (default 30)") print(" --min-corroboration N distinct useful results to prefer a node (default 2)") print(" check-update check needs_update flag and notify if semantic re-extraction is pending (cron-safe)") print(" tree emit a D3 v7 collapsible-tree HTML for graph.json") print(" --graph PATH path to graph.json (default graphify-out/graph.json)") print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)") print(" --root PATH filesystem root for the hierarchy") print(" --max-children N cap children per node (default 200)") print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)") print(" --label NAME project label in header") print(" extract headless full extraction (AST + semantic LLM) for CI/scripts") print(" --backend B gemini|kimi|claude|openai|deepseek|ollama (default: whichever API key is set)") print(" openai also reaches self-hosted OpenAI-compatible servers (llama.cpp,") print(" vLLM, LM Studio): set OPENAI_BASE_URL (e.g. http://localhost:8080/v1)") print(" and OPENAI_MODEL to the model name your server serves") print(" claude also reaches custom Anthropic-compatible endpoints (LiteLLM") print(" proxy, gateways): set ANTHROPIC_BASE_URL and ANTHROPIC_MODEL") print(" --model M override backend default model") print(" --mode deep aggressive INFERRED-edge semantic extraction") print(" --max-workers N AST extraction subprocess count (default: cpu_count)") print(" --token-budget N per-chunk token cap for semantic extraction (default: 60000)") print(" --max-concurrency N parallel semantic chunks in flight (default: 4; set 1 for local LLMs)") print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)") print(" --out DIR output dir (default: ); writes /graphify-out/") print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction") print(" --no-cluster skip clustering, write raw extraction only") print(" --code-only index code (local AST, no API key) and skip doc/paper/image files") print(" --postgres DSN extract schema from a live PostgreSQL database") print(" maps tables, views, functions + FK relationships;") print(" column-level detail is not represented in the graph") print(" --cargo extract crate→crate deps from Cargo.toml") print(" --global also merge the resulting graph into the global graph") print(" --as repo tag for --global (default: target directory name)") print(" global add add/update a project graph in the global graph (~/.graphify/global-graph.json)") print(" --as repo tag (default: parent directory name)") print(" global remove remove a repo's nodes from the global graph") print(" global list list repos in the global graph") print(" global path print path to the global graph file") print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach") print(" export callflow-html emit Mermaid-based architecture/call-flow HTML") print(" hook install install post-commit/post-checkout git hooks (all platforms)") print(" hook uninstall remove git hooks") print(" hook status check if git hooks are installed") print( " gemini install write GEMINI.md section + BeforeTool hook (Gemini CLI)" ) print(" gemini uninstall remove GEMINI.md section + BeforeTool hook") print(" cursor install write .cursor/rules/graphify.mdc (Cursor)") print(" cursor uninstall remove .cursor/rules/graphify.mdc") print(" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)") print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook") print(" codebuddy install write graphify section to CODEBUDDY.md + PreToolUse hook (CodeBuddy)") print(" codebuddy uninstall remove graphify section from CODEBUDDY.md + PreToolUse hook") print(" codex install write graphify section to AGENTS.md (Codex)") print(" codex uninstall remove graphify section from AGENTS.md") print( " opencode install write graphify section to AGENTS.md + tool.execute.before plugin (OpenCode)" ) print( " opencode uninstall remove graphify section from AGENTS.md + plugin" ) print( " kilo install install native Kilo skill + command + AGENTS.md + .kilo plugin" ) print( " kilo uninstall remove native Kilo skill + command + AGENTS.md + .kilo plugin" ) print(" aider install write graphify section to AGENTS.md (Aider)") print(" aider uninstall remove graphify section from AGENTS.md") print( " copilot install copy graphify skill to ~/.copilot/skills (GitHub Copilot CLI)" ) print(" copilot uninstall remove graphify skill from ~/.copilot/skills") print( " vscode install configure VS Code Copilot Chat (skill + .github/copilot-instructions.md)" ) print(" vscode uninstall remove VS Code Copilot Chat configuration") print( " claw install write graphify section to AGENTS.md (OpenClaw)" ) print(" claw uninstall remove graphify section from AGENTS.md") print( " droid install write graphify section to AGENTS.md (Factory Droid)" ) print(" droid uninstall remove graphify section from AGENTS.md") print(" trae install write graphify section to AGENTS.md (Trae)") print(" trae uninstall remove graphify section from AGENTS.md") print(" trae-cn install write graphify section to AGENTS.md (Trae CN)") print(" trae-cn uninstall remove graphify section from AGENTS.md") print( " antigravity install write .agents/rules + .agents/workflows + skill (Google Antigravity)" ) print( " antigravity uninstall remove .agents/rules, .agents/workflows, and skill" ) print( " hermes install write skill to ~/.hermes/skills/graphify/ (Hermes)" ) print(" hermes uninstall remove skill from ~/.hermes/skills/graphify/") print( " kiro install write skill to .kiro/skills/graphify/ + steering file (Kiro IDE/CLI)" ) print(" kiro uninstall remove skill + steering file") print(" pi install write skill to ~/.pi/agent/skills/graphify/ (Pi coding agent)") print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/") print(" devin install write skill to ~/.config/devin/skills/graphify/ (Devin CLI)") print(" devin uninstall remove skill from ~/.config/devin/skills/graphify/") print() return cmd = sys.argv[1] # Universal help guard: -h/--help/-? anywhere after the command shows help # and stops — prevents flags from silently triggering destructive subcommands # (e.g. "cursor install --help" was silently installing into Cursor, #821). # Exempt: free-text commands (user string may contain these tokens), and # "install"/"uninstall" which have their own per-subcommand help handlers. _FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"} if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]): print(f"Run 'graphify --help' for full usage.") return if dispatch_install_cli(cmd): return dispatch_command(cmd) if __name__ == "__main__": main()