Files
wehub-resource-sync d88fd01084
CI / test (3.10) (push) Failing after 1s
CI / test (3.12) (push) Failing after 0s
CI / skillgen-check (push) Failing after 0s
CI / security-scan (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:09:14 +08:00

674 lines
26 KiB
Python

"""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 <name>` 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 <command>")
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> path to graph.json (default graphify-out/graph.json)")
print(" explain \"X\" plain-language explanation of a node and its neighbors")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" diagnose multigraph report same-endpoint edge collapse risk in graph.json")
print(" --graph <path> 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 <github-url> clone a GitHub repo locally and print its path for /graphify")
print(" merge-driver <base> <current> <other> git merge driver: union-merge two graph.json files (set up via hook install)")
print(" merge-graphs <g1> <g2> merge two or more graph.json files into one cross-repo graph")
print(" --out <path> output path (default: graphify-out/merged-graph.json)")
print(" --branch <branch> checkout a specific branch (default: repo default)")
print(" --out <dir> clone to a custom directory (default: ~/.graphify/repos/<owner>/<repo>)")
print(" add <url> 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 <path> target directory (default: ./raw)")
print(" watch <path> watch a folder and rebuild the graph on code changes")
print(" update <path> 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 <path> 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> path to graph.json (default <path>/graphify-out/graph.json)")
print(" --no-label keep 'Community N' placeholders (skip LLM community naming)")
print(" --backend=<name> backend to use for community naming (default: auto-detect)")
print(" --model=<name> 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 <path> (re)name communities with the configured LLM backend, regenerate report")
print(" --missing-only keep existing labels and only name missing/placeholder communities")
print(" --backend=<name> backend to use (default: auto-detect from API keys)")
print(" --model=<name> 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 \"<question>\" 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> 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> 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 <path> 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 <path> 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: <path>); writes <DIR>/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 <tag> repo tag for --global (default: target directory name)")
print(" global add <graph.json> add/update a project graph in the global graph (~/.graphify/global-graph.json)")
print(" --as <tag> repo tag (default: parent directory name)")
print(" global remove <tag> 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()