mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 18:37:12 +00:00
97a289c531
main() carried a 256-line if/elif chain routing every install-family command (install, uninstall, and the 22 per-platform targets: claude, codebuddy, gemini, cursor, vscode, copilot, kilo, kiro, devin, pi, amp, agents/skills, aider/codex/opencode/claw/droid/trae/trae-cn/hermes, antigravity). That block only ever reads sys.argv and calls functions that already live in install.py, so move it there verbatim as a guarded dispatch_install_cli(cmd) -> bool; main() now does `if dispatch_install_cli(cmd): return`. The command set is derived from the block's own conditions (so none is missed), and the branch's early `return` becomes `return True` so the help path still short-circuits. Verified end-to-end: `graphify claude install/uninstall`, unknown command, and the install test suite all behave identically. __main__.py 3,641 -> 3,388 LOC (5,368 at branch start, -37%). Full suite unchanged: 3036 passed, 29 skipped; skillgen --check OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2149 lines
91 KiB
Python
2149 lines
91 KiB
Python
"""graphify install/uninstall subsystem.
|
|
|
|
The per-platform skill/hook installers and uninstallers, extracted verbatim from
|
|
graphify/__main__.py so the CLI dispatcher (`main`) stays readable. Import path is
|
|
unchanged: `graphify.__main__` re-exports every name defined here, so
|
|
`from graphify.__main__ import claude_install` (etc.) keeps working.
|
|
|
|
Lives at graphify/install.py (same package dir as __main__) on purpose: the
|
|
installers resolve packaged assets via `Path(__file__).parent / "always_on"` and
|
|
`/ "skills"`, which only works from within the graphify/ directory.
|
|
"""
|
|
|
|
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"
|
|
|
|
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
|
|
|
|
|
|
@functools.lru_cache(maxsize=None)
|
|
def _always_on(basename: str) -> str:
|
|
"""Read a packaged always-on instruction block from graphify/always_on/.
|
|
|
|
The six always-on blocks (CLAUDE.md / AGENTS.md / GEMINI.md / VS Code
|
|
Copilot instructions / Antigravity rules / Kiro steering) live as committed
|
|
markdown next to this module, generated by tools/skillgen from a single
|
|
human-edited fragment and guarded against drift by ``skillgen --check``. The
|
|
installer injects them verbatim via ``_replace_or_append_section``, so the
|
|
bytes here must match the former triple-quoted constant exactly — the
|
|
always-on-roundtrip validator proves that.
|
|
"""
|
|
path = Path(__file__).parent / "always_on" / f"{basename}.md"
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
# Defer to use-time so a missing/corrupt packaged block can't crash module
|
|
# import (which would brick every CLI command, not just install). Reached
|
|
# only by an install/integration path that actually needs this block.
|
|
raise RuntimeError(
|
|
f"graphify install is incomplete: missing always-on block '{basename}' "
|
|
f"at {path}. Reinstall graphifyy (e.g. `uv tool install --reinstall graphifyy`)."
|
|
) from exc
|
|
def _refresh_all_version_stamps() -> None:
|
|
"""After a successful install, update .graphify_version in all other known skill dirs.
|
|
|
|
Prevents stale-version warnings from platforms that were installed previously
|
|
but not explicitly re-installed during this upgrade.
|
|
"""
|
|
for name in _PLATFORM_CONFIG:
|
|
skill_dst = _platform_skill_destination(name)
|
|
vf = skill_dst.parent / ".graphify_version"
|
|
if skill_dst.exists():
|
|
vf.write_text(__version__, encoding="utf-8")
|
|
def _platform_skill_destination(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
|
|
"""Return the skill destination for a platform and scope."""
|
|
if platform_name == "gemini":
|
|
if project:
|
|
return (project_dir or Path(".")) / ".gemini" / "skills" / "graphify" / "SKILL.md"
|
|
if platform.system() == "Windows":
|
|
return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
|
|
return Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md"
|
|
|
|
if platform_name == "opencode":
|
|
if project:
|
|
return (project_dir or Path(".")) / ".opencode" / "skills" / "graphify" / "SKILL.md"
|
|
return Path.home() / ".config" / "opencode" / "skills" / "graphify" / "SKILL.md"
|
|
|
|
if platform_name == "hermes":
|
|
if project:
|
|
return (project_dir or Path(".")) / ".hermes" / "skills" / "graphify" / "SKILL.md"
|
|
# On Windows, Hermes scans %LOCALAPPDATA%\hermes\skills, not ~/.hermes (#1403).
|
|
if platform.system() == "Windows":
|
|
local_appdata = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
|
|
return local_appdata / "hermes" / "skills" / "graphify" / "SKILL.md"
|
|
return Path.home() / ".hermes" / "skills" / "graphify" / "SKILL.md"
|
|
|
|
if platform_name == "devin":
|
|
if project:
|
|
return (project_dir or Path(".")) / ".devin" / "skills" / "graphify" / "SKILL.md"
|
|
return Path.home() / ".config" / "devin" / "skills" / "graphify" / "SKILL.md"
|
|
|
|
if platform_name == "amp":
|
|
if project:
|
|
return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
|
|
return Path.home() / ".config" / "agents" / "skills" / "graphify" / "SKILL.md"
|
|
|
|
if platform_name == "agents":
|
|
# The generic Agent-Skills target: project ./.agents/skills, global the
|
|
# spec's user-global ~/.agents/skills (read by `npx skills` and compliant
|
|
# frameworks), NOT amp's ~/.config/agents/skills.
|
|
if project:
|
|
return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
|
|
return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
|
|
|
|
if platform_name in ("antigravity", "antigravity-windows"):
|
|
if project:
|
|
return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
|
|
# Global Antigravity skill dir (all workspaces): ~/.gemini/config/skills/
|
|
return Path.home() / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
|
|
|
|
cfg = _PLATFORM_CONFIG[platform_name]
|
|
if project:
|
|
return (project_dir or Path(".")) / cfg["skill_dst"]
|
|
|
|
if platform_name in ("claude", "windows") and os.environ.get("CLAUDE_CONFIG_DIR"):
|
|
return Path(os.environ["CLAUDE_CONFIG_DIR"]) / "skills" / "graphify" / "SKILL.md"
|
|
return Path.home() / cfg["skill_dst"]
|
|
def _packaged_skill_refs_dir(platform_name: str) -> Path | None:
|
|
"""Return the packaged references source dir for a progressive platform, else None.
|
|
|
|
A platform opts into progressive disclosure by setting ``skill_refs`` in its
|
|
``_PLATFORM_CONFIG`` entry. The value names a bundle under
|
|
``graphify/skills/<bundle>/references/``. Reuse keys (e.g. trae-cn) point at
|
|
their twin's bundle.
|
|
|
|
``gemini`` has no ``_PLATFORM_CONFIG`` entry: it installs claude's
|
|
``skill.md`` body verbatim (see ``_copy_skill_file``). Since that body is the
|
|
lean progressive core that links to ``references/``, gemini needs claude's
|
|
references/ sidecar too, or its SKILL.md ships with dead pointers. So gemini
|
|
resolves to the claude bundle rather than opting out.
|
|
|
|
Bundles ship one platform-group at a time. A host whose bundle directory
|
|
``graphify/skills/<bundle>/`` is not in this build has not gone progressive
|
|
yet, so this returns None and the host installs today's monolithic SKILL.md
|
|
with no references/ sidecar. Only when the bundle directory IS present does
|
|
this return the references path; if that directory then lacks its
|
|
``references/`` subdir, ``_copy_skill_file`` hard-fails (a malformed bundle,
|
|
the empty-sidecar regression the wheel-content test also guards).
|
|
"""
|
|
if platform_name == "gemini":
|
|
bundle = "claude"
|
|
else:
|
|
bundle = _PLATFORM_CONFIG[platform_name].get("skill_refs")
|
|
if not bundle:
|
|
return None
|
|
bundle_dir = Path(__file__).parent / "skills" / bundle
|
|
if not bundle_dir.is_dir():
|
|
return None
|
|
return bundle_dir / "references"
|
|
def _install_skill_references(skill_dst: Path, refs_src: Path) -> None:
|
|
"""Atomically install a packaged references/ sidecar next to SKILL.md.
|
|
|
|
Stages the packaged dir into ``references.tmp`` (copytree), drops any stale
|
|
``references/`` already on disk, then ``os.replace``-renames the staged dir
|
|
into place. The rename is atomic on the same filesystem, so an interrupted
|
|
install never leaves a half-written references/ visible to the agent.
|
|
"""
|
|
refs_dst = skill_dst.parent / "references"
|
|
refs_staged = skill_dst.parent / "references.tmp"
|
|
if refs_staged.exists():
|
|
shutil.rmtree(refs_staged)
|
|
try:
|
|
shutil.copytree(refs_src, refs_staged)
|
|
if refs_dst.exists():
|
|
shutil.rmtree(refs_dst)
|
|
os.replace(refs_staged, refs_dst)
|
|
except Exception:
|
|
if refs_staged.exists():
|
|
shutil.rmtree(refs_staged, ignore_errors=True)
|
|
raise
|
|
def _copy_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
|
|
"""Copy a packaged skill file and write its version stamp.
|
|
|
|
For progressive platforms (those with ``skill_refs`` set), the packaged
|
|
``references/`` sidecar is installed alongside SKILL.md and the single
|
|
``.graphify_version`` stamp covers both. For monolith platforms (no
|
|
``skill_refs``), any orphan ``references/`` left by a prior progressive
|
|
install is removed so the on-disk layout matches the package.
|
|
"""
|
|
skill_file = "skill.md" if platform_name == "gemini" else _PLATFORM_CONFIG[platform_name]["skill_file"]
|
|
skill_src = Path(__file__).parent / skill_file
|
|
if not skill_src.exists():
|
|
print(f"error: {skill_file} not found in package - reinstall graphify", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
refs_src = _packaged_skill_refs_dir(platform_name)
|
|
if refs_src is not None and not refs_src.exists():
|
|
# Progressive platform declared a references bundle that is missing from
|
|
# the package. Fail loud rather than silently shipping an empty sidecar.
|
|
print(
|
|
f"error: references for '{platform_name}' not found in package "
|
|
f"({refs_src}) - reinstall graphify",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
|
|
skill_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Install the references/ sidecar (or clear an orphan one) BEFORE writing
|
|
# SKILL.md, so SKILL.md is the last artifact laid down. An install that is
|
|
# interrupted partway then leaves no SKILL.md rather than a SKILL.md that
|
|
# points at an absent references/ dir.
|
|
if refs_src is not None:
|
|
_install_skill_references(skill_dst, refs_src)
|
|
print(f" references -> {skill_dst.parent / 'references'}")
|
|
else:
|
|
# Monolith (or progressive-with-no-refs): clear any orphan references/.
|
|
orphan_refs = skill_dst.parent / "references"
|
|
if orphan_refs.exists():
|
|
shutil.rmtree(orphan_refs)
|
|
|
|
# SKILL.md last (crash-safety), via an atomic temp + rename.
|
|
tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
|
|
try:
|
|
shutil.copy(skill_src, tmp_dst)
|
|
os.replace(tmp_dst, skill_dst)
|
|
except Exception:
|
|
try:
|
|
tmp_dst.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
(skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
|
|
print(f" skill installed -> {skill_dst}")
|
|
return skill_dst
|
|
def _remove_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> bool:
|
|
"""Remove a platform skill file and its version stamp without touching other scopes."""
|
|
skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
|
|
removed = False
|
|
if skill_dst.exists():
|
|
skill_dst.unlink()
|
|
print(f" skill removed -> {skill_dst}")
|
|
removed = True
|
|
version_file = skill_dst.parent / ".graphify_version"
|
|
if version_file.exists():
|
|
version_file.unlink()
|
|
removed = True
|
|
refs_dir = skill_dst.parent / "references"
|
|
if refs_dir.exists():
|
|
shutil.rmtree(refs_dir)
|
|
removed = True
|
|
for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
|
|
try:
|
|
d.rmdir()
|
|
except OSError:
|
|
break
|
|
return removed
|
|
def _project_scope_root(path: Path, project_dir: Path) -> Path:
|
|
"""Return the top-level project artifact for a project-scoped skill path."""
|
|
try:
|
|
rel = path.relative_to(project_dir)
|
|
except ValueError:
|
|
return path
|
|
return project_dir / rel.parts[0] if rel.parts else path
|
|
def _remove_claude_skill_registration(project_dir: Path) -> None:
|
|
"""Remove the project-scoped Claude skill registration file/section."""
|
|
claude_md = project_dir / ".claude" / "CLAUDE.md"
|
|
if not claude_md.exists():
|
|
return
|
|
content = claude_md.read_text(encoding="utf-8")
|
|
if "# graphify" not in content:
|
|
return
|
|
cleaned = re.sub(r"\n*# graphify\n.*?(?=\n# |\Z)", "", content, flags=re.DOTALL).rstrip()
|
|
if cleaned:
|
|
claude_md.write_text(cleaned + "\n", encoding="utf-8")
|
|
print(f" CLAUDE.md -> graphify skill registration removed from {claude_md}")
|
|
else:
|
|
claude_md.unlink()
|
|
print(f" CLAUDE.md -> deleted {claude_md}")
|
|
def _print_project_git_add_hint(paths: list[Path]) -> None:
|
|
unique: list[str] = []
|
|
for path in paths:
|
|
text = path.as_posix().rstrip("/")
|
|
if path.exists() and path.is_dir():
|
|
text += "/"
|
|
if text not in unique:
|
|
unique.append(text)
|
|
if not unique:
|
|
return
|
|
print()
|
|
print("Project-scoped install. Add to version control:")
|
|
print(f" git add {' '.join(unique)}")
|
|
def _claude_pretooluse_hooks() -> "list[dict]":
|
|
"""graphify's Claude/Codebuddy PreToolUse hooks, resolved at install time.
|
|
|
|
The command invokes `graphify hook-guard <search|read>` via the absolute exe
|
|
path (`_resolve_graphify_exe`), so it parses under sh, cmd.exe and PowerShell
|
|
alike — this is the #522 fix, and mirrors the codex hook. Matchers stay "Bash"
|
|
and "Read|Glob" and the command always contains "graphify", so the existing
|
|
install/uninstall filters find and replace both old bash hooks and these.
|
|
"""
|
|
exe = _resolve_graphify_exe()
|
|
if " " in exe and not exe.startswith('"'):
|
|
exe = f'"{exe}"'
|
|
return [
|
|
{"matcher": "Bash",
|
|
"hooks": [{"type": "command", "command": f"{exe} hook-guard search"}]},
|
|
{"matcher": "Read|Glob",
|
|
"hooks": [{"type": "command", "command": f"{exe} hook-guard read"}]},
|
|
]
|
|
def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str:
|
|
return (
|
|
"\n# graphify\n"
|
|
f"- **graphify** (`{skill_path}`) "
|
|
"- any input to knowledge graph. Trigger: `/graphify`\n"
|
|
"When the user types `/graphify`, use the installed graphify skill "
|
|
"or instructions 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,
|
|
"skill_refs": "claude",
|
|
},
|
|
"codex": {
|
|
"skill_file": "skill-codex.md",
|
|
"skill_dst": Path(".codex") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "codex",
|
|
},
|
|
"opencode": {
|
|
"skill_file": "skill-opencode.md",
|
|
"skill_dst": Path(".config") / "opencode" / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "opencode",
|
|
},
|
|
"kilo": {
|
|
"skill_file": "skill-kilo.md",
|
|
"skill_dst": Path(".config") / "kilo" / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "kilo",
|
|
},
|
|
"aider": {
|
|
# Monolith: aider ships the full SKILL.md inline, no references/ sidecar.
|
|
"skill_file": "skill-aider.md",
|
|
"skill_dst": Path(".aider") / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
},
|
|
"copilot": {
|
|
"skill_file": "skill-copilot.md",
|
|
"skill_dst": Path(".copilot") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "copilot",
|
|
},
|
|
"claw": {
|
|
"skill_file": "skill-claw.md",
|
|
"skill_dst": Path(".openclaw") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "claw",
|
|
},
|
|
"droid": {
|
|
"skill_file": "skill-droid.md",
|
|
"skill_dst": Path(".factory") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "droid",
|
|
},
|
|
"trae": {
|
|
"skill_file": "skill-trae.md",
|
|
"skill_dst": Path(".trae") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "trae",
|
|
},
|
|
"trae-cn": {
|
|
# Reuses trae's split bundle (same skill body + references).
|
|
"skill_file": "skill-trae.md",
|
|
"skill_dst": Path(".trae-cn") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "trae",
|
|
},
|
|
"hermes": {
|
|
# Reuses claw's split bundle.
|
|
"skill_file": "skill-claw.md",
|
|
"skill_dst": Path(".hermes") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "claw",
|
|
},
|
|
"kiro": {
|
|
"skill_file": "skill-kiro.md",
|
|
"skill_dst": Path(".kiro") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "kiro",
|
|
},
|
|
"pi": {
|
|
"skill_file": "skill-pi.md",
|
|
"skill_dst": Path(".pi") / "agent" / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "pi",
|
|
},
|
|
"codebuddy": {
|
|
# Reuses claude's split bundle (shares skill.md).
|
|
"skill_file": "skill.md",
|
|
"skill_dst": Path(".codebuddy") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "claude",
|
|
},
|
|
"antigravity": {
|
|
# Rides claude's split bundle (shares skill.md).
|
|
"skill_file": "skill.md",
|
|
"skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "claude",
|
|
},
|
|
"antigravity-windows": {
|
|
# Rides windows' split bundle.
|
|
"skill_file": "skill-windows.md",
|
|
"skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "windows",
|
|
},
|
|
"windows": {
|
|
"skill_file": "skill-windows.md",
|
|
"skill_dst": Path(".claude") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": True,
|
|
"skill_refs": "windows",
|
|
},
|
|
"kimi": {
|
|
# Reuses claude's split bundle (shares skill.md).
|
|
"skill_file": "skill.md",
|
|
"skill_dst": Path(".kimi") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "claude",
|
|
},
|
|
"amp": {
|
|
# Amp searches .agents/skills (project) and ~/.config/agents/skills (user),
|
|
# not .amp/skills. The user-scope path is set in _platform_skill_destination.
|
|
"skill_file": "skill-amp.md",
|
|
"skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "amp",
|
|
},
|
|
"agents": {
|
|
# The generic cross-framework Agent-Skills target. Global: ~/.agents/skills
|
|
# (the spec's user-global location, read by `npx skills` and compliant
|
|
# frameworks); project: ./.agents/skills. The CLI accepts `skills` as an
|
|
# alias (see _canonical_platform). Ships its own rendered bundle.
|
|
"skill_file": "skill-agents.md",
|
|
"skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
"skill_refs": "agents",
|
|
},
|
|
"devin": {
|
|
# Monolith: devin ships the full SKILL.md inline, no references/ sidecar.
|
|
"skill_file": "skill-devin.md",
|
|
# User scope: ~/.config/devin/skills/graphify/SKILL.md
|
|
# Project scope: .devin/skills/graphify/SKILL.md (overridden in _platform_skill_destination)
|
|
"skill_dst": Path(".config") / "devin" / "skills" / "graphify" / "SKILL.md",
|
|
"claude_md": False,
|
|
},
|
|
}
|
|
# CLI-only platform aliases, resolved to a real _PLATFORM_CONFIG key before
|
|
# dispatch. `skills` is the friendly alias for the generic `agents` platform
|
|
# (the Agent-Skills ecosystem calls them "skills").
|
|
_PLATFORM_ALIASES: dict[str, str] = {"skills": "agents"}
|
|
def _canonical_platform(platform_name: str) -> str:
|
|
"""Resolve a CLI platform alias to its real _PLATFORM_CONFIG key."""
|
|
return _PLATFORM_ALIASES.get(platform_name, platform_name)
|
|
def _replace_or_append_section(content: str, marker: str, new_section: str) -> str:
|
|
"""Idempotently update or append a graphify-owned section in shared files.
|
|
|
|
If no line is exactly ``marker`` (the heading, at column 0), append
|
|
``new_section`` to the end (with a blank-line separator if there's existing
|
|
content).
|
|
|
|
If a real ``marker`` heading exists, replace the existing section in place.
|
|
The section runs from that heading to the line before the next H2 heading
|
|
(``## `` at line start), or to EOF if no later H2 exists. This lets older
|
|
installs receive the updated copy without users having to uninstall and
|
|
reinstall (issue #580).
|
|
|
|
The heading is matched only when a line *is* exactly ``marker`` (after
|
|
stripping surrounding whitespace), never as a substring. Matching ``##
|
|
graphify`` inside a bullet or an inline reference used to anchor the replace
|
|
on that mention and delete every line from there to the next heading,
|
|
silently destroying hand-curated content (#1688). When several exact
|
|
headings exist, the last one is used, since graphify's section is appended.
|
|
"""
|
|
lines = content.split("\n")
|
|
starts = [i for i, line in enumerate(lines) if line.strip() == marker]
|
|
if not starts:
|
|
if content.strip():
|
|
return content.rstrip() + "\n\n" + new_section.lstrip()
|
|
return new_section.lstrip()
|
|
|
|
start = starts[-1]
|
|
end = len(lines)
|
|
for j in range(start + 1, len(lines)):
|
|
if lines[j].startswith("## "):
|
|
end = j
|
|
break
|
|
|
|
head = "\n".join(lines[:start]).rstrip()
|
|
tail = "\n".join(lines[end:]).lstrip()
|
|
section = new_section.strip()
|
|
|
|
parts: list[str] = []
|
|
if head:
|
|
parts.append(head)
|
|
parts.append(section)
|
|
if tail:
|
|
parts.append(tail)
|
|
out = "\n\n".join(parts)
|
|
if not out.endswith("\n"):
|
|
out += "\n"
|
|
return out
|
|
def _print_banner() -> None:
|
|
"""Amber brain banner on graphify install. TTY-only, never raises."""
|
|
if not sys.stdout.isatty():
|
|
return
|
|
try:
|
|
if sys.platform == "win32":
|
|
import ctypes
|
|
ctypes.windll.kernel32.SetConsoleMode(
|
|
ctypes.windll.kernel32.GetStdHandle(-11), 7
|
|
)
|
|
A = "\033[38;5;214m"
|
|
D = "\033[38;5;130m"
|
|
R = "\033[0m"
|
|
print(f"""{A}
|
|
╭──◉──╮ ╭──◉──╮
|
|
╱ ◉ ◉ ╲ ╱ ◉ ◉ ╲
|
|
│ ◉─◉─◉ ◉ ◉─◉─◉ │
|
|
│ ◉ ◉ │ ◉ ◉ │
|
|
│ ◉─◉─◉ ◉ ◉─◉─◉ │
|
|
╲ ◉ ◉ ╱ ╲ ◉ ◉ ╱
|
|
╰──◉──╯ ╰──◉──╯
|
|
◉
|
|
|
|
█▀▀ █▀█ ▄▀█ █▀█ █ █ █ █▀▀ █▄█
|
|
█▄█ █▀▄ █▀█ █▀▀ █▀█ █ █▀ █{D} {__version__}{R}
|
|
""")
|
|
except Exception:
|
|
pass
|
|
def install(platform: str = "claude", *, project: bool = False, project_dir: Path | None = None) -> None:
|
|
_print_banner()
|
|
platform = _canonical_platform(platform)
|
|
if platform == "gemini":
|
|
gemini_install(project_dir=project_dir, project=project)
|
|
return
|
|
if platform == "cursor":
|
|
_cursor_install(Path("."))
|
|
return
|
|
# On Windows, antigravity needs the PowerShell skill, not the bash one
|
|
if platform == "antigravity" and sys.platform == "win32":
|
|
platform = "antigravity-windows"
|
|
if platform not in _PLATFORM_CONFIG:
|
|
print(
|
|
f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}, gemini, cursor",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
cfg = _PLATFORM_CONFIG[platform]
|
|
project_dir = project_dir or Path(".")
|
|
skill_dst = _copy_skill_file(platform, project=project, project_dir=project_dir)
|
|
|
|
if platform == "kilo":
|
|
# Kilo Code also supports a native /graphify command file.
|
|
command_src = Path(__file__).parent / "command-kilo.md"
|
|
if not command_src.exists():
|
|
print(
|
|
f"error: command-kilo.md not found in package - reinstall graphify",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
command_dst = Path.home() / ".config" / "kilo" / "command" / "graphify.md"
|
|
command_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy(command_src, command_dst)
|
|
print(f" command installed -> {command_dst}")
|
|
|
|
if cfg["claude_md"]:
|
|
# Register in the matching Claude Code scope.
|
|
claude_md = (project_dir / ".claude" / "CLAUDE.md") if project else Path.home() / ".claude" / "CLAUDE.md"
|
|
registration = _skill_registration(".claude/skills/graphify/SKILL.md" if project else "~/.claude/skills/graphify/SKILL.md")
|
|
if claude_md.exists():
|
|
content = claude_md.read_text(encoding="utf-8")
|
|
if "graphify" in content:
|
|
print(f" CLAUDE.md -> already registered (no change)")
|
|
else:
|
|
claude_md.write_text(content.rstrip() + registration, encoding="utf-8")
|
|
print(f" CLAUDE.md -> skill registered in {claude_md}")
|
|
else:
|
|
claude_md.parent.mkdir(parents=True, exist_ok=True)
|
|
claude_md.write_text(registration.lstrip(), encoding="utf-8")
|
|
print(f" CLAUDE.md -> created at {claude_md}")
|
|
|
|
if platform == "codebuddy":
|
|
# Register in ~/.codebuddy/CODEBUDDY.md (CodeBuddy only)
|
|
codebuddy_md = Path.home() / ".codebuddy" / "CODEBUDDY.md"
|
|
registration = _skill_registration("~/.codebuddy/skills/graphify/SKILL.md")
|
|
if codebuddy_md.exists():
|
|
content = codebuddy_md.read_text(encoding="utf-8")
|
|
if "graphify" in content:
|
|
print(f" CODEBUDDY.md -> already registered (no change)")
|
|
else:
|
|
codebuddy_md.write_text(content.rstrip() + registration, encoding="utf-8")
|
|
print(f" CODEBUDDY.md -> skill registered in {codebuddy_md}")
|
|
else:
|
|
codebuddy_md.parent.mkdir(parents=True, exist_ok=True)
|
|
codebuddy_md.write_text(registration.lstrip(), encoding="utf-8")
|
|
print(f" CODEBUDDY.md -> created at {codebuddy_md}")
|
|
|
|
if platform == "opencode":
|
|
_install_opencode_plugin(project_dir if project else Path("."))
|
|
|
|
# Refresh version stamps in all other previously-installed skill dirs so
|
|
# stale-version warnings don't fire for platforms not explicitly re-installed.
|
|
if project:
|
|
_print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)])
|
|
else:
|
|
_refresh_all_version_stamps()
|
|
|
|
print()
|
|
print("Done. Open your AI coding assistant and type:")
|
|
print()
|
|
print(" /graphify .")
|
|
print()
|
|
def _print_install_usage() -> None:
|
|
platforms = ", ".join([*_PLATFORM_CONFIG, "gemini", "cursor"])
|
|
print("Usage: graphify install [--project] [--platform P|P]")
|
|
print(f"Platforms: {platforms}")
|
|
_CLAUDE_MD_MARKER = "## graphify"
|
|
_CODEBUDDY_MD_MARKER = "## graphify"
|
|
_AGENTS_MD_MARKER = "## graphify"
|
|
_GEMINI_MD_MARKER = "## graphify"
|
|
def _gemini_hook() -> dict:
|
|
"""Gemini CLI BeforeTool hook, resolved to a shell-agnostic `graphify` call."""
|
|
exe = _resolve_graphify_exe()
|
|
if " " in exe and not exe.startswith('"'):
|
|
exe = f'"{exe}"'
|
|
return {
|
|
"matcher": "read_file|list_directory",
|
|
"hooks": [{"type": "command", "command": f"{exe} hook-guard gemini"}],
|
|
}
|
|
def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> None:
|
|
"""Copy skill file, write GEMINI.md section, and install BeforeTool hook."""
|
|
project_dir = project_dir or Path(".")
|
|
skill_dst = _copy_skill_file("gemini", project=project, project_dir=project_dir)
|
|
|
|
target = project_dir / "GEMINI.md"
|
|
|
|
if target.exists():
|
|
content = target.read_text(encoding="utf-8")
|
|
new_content = _replace_or_append_section(
|
|
content, _GEMINI_MD_MARKER, _always_on("gemini-md")
|
|
)
|
|
else:
|
|
new_content = _always_on("gemini-md")
|
|
|
|
if target.exists() and new_content == target.read_text(encoding="utf-8"):
|
|
print(f"graphify already configured in {target.resolve()} (no change)")
|
|
else:
|
|
target.write_text(new_content, encoding="utf-8")
|
|
print(f"graphify section written to {target.resolve()}")
|
|
|
|
# Always re-install the Gemini hook so an older payload (e.g. pre-issue-#580
|
|
# wording) is replaced on upgrade.
|
|
_install_gemini_hook(project_dir)
|
|
if project:
|
|
_print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / "GEMINI.md", project_dir / ".gemini"])
|
|
print()
|
|
print("Gemini CLI will now check the knowledge graph before answering")
|
|
print("codebase questions and rebuild it after code changes.")
|
|
def _install_gemini_hook(project_dir: Path) -> None:
|
|
settings_path = project_dir / ".gemini" / "settings.json"
|
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
settings = (
|
|
json.loads(settings_path.read_text(encoding="utf-8"))
|
|
if settings_path.exists()
|
|
else {}
|
|
)
|
|
except json.JSONDecodeError:
|
|
settings = {}
|
|
before_tool = settings.setdefault("hooks", {}).setdefault("BeforeTool", [])
|
|
settings["hooks"]["BeforeTool"] = [
|
|
h for h in before_tool if "graphify" not in str(h)
|
|
]
|
|
settings["hooks"]["BeforeTool"].append(_gemini_hook())
|
|
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
|
print(" .gemini/settings.json -> BeforeTool hook registered")
|
|
def _uninstall_gemini_hook(project_dir: Path) -> None:
|
|
settings_path = project_dir / ".gemini" / "settings.json"
|
|
if not settings_path.exists():
|
|
return
|
|
try:
|
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return
|
|
before_tool = settings.get("hooks", {}).get("BeforeTool", [])
|
|
filtered = [h for h in before_tool if "graphify" not in str(h)]
|
|
if len(filtered) == len(before_tool):
|
|
return
|
|
settings["hooks"]["BeforeTool"] = filtered
|
|
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
|
print(" .gemini/settings.json -> BeforeTool hook removed")
|
|
def gemini_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
|
|
"""Remove the graphify section from GEMINI.md, uninstall hook, and remove skill file."""
|
|
project_dir = project_dir or Path(".")
|
|
_remove_skill_file("gemini", project=project, project_dir=project_dir)
|
|
|
|
target = project_dir / "GEMINI.md"
|
|
if not target.exists():
|
|
print("No GEMINI.md found in current directory - nothing to do")
|
|
return
|
|
content = target.read_text(encoding="utf-8")
|
|
if _GEMINI_MD_MARKER not in content:
|
|
print("graphify section not found in GEMINI.md - nothing to do")
|
|
return
|
|
cleaned = re.sub(
|
|
r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL
|
|
).rstrip()
|
|
if cleaned:
|
|
target.write_text(cleaned + "\n", encoding="utf-8")
|
|
print(f"graphify section removed from {target.resolve()}")
|
|
else:
|
|
target.unlink()
|
|
print(f"GEMINI.md was empty after removal - deleted {target.resolve()}")
|
|
_uninstall_gemini_hook(project_dir)
|
|
_VSCODE_INSTRUCTIONS_MARKER = "## graphify"
|
|
def vscode_install(project_dir: Path | None = None) -> None:
|
|
"""Install graphify skill for VS Code Copilot Chat + write .github/copilot-instructions.md."""
|
|
skill_src = Path(__file__).parent / "skill-vscode.md"
|
|
refs_bundle = "vscode"
|
|
if not skill_src.exists():
|
|
skill_src = Path(__file__).parent / "skill-copilot.md"
|
|
refs_bundle = "copilot"
|
|
skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md"
|
|
skill_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
|
|
try:
|
|
shutil.copy(skill_src, tmp_dst)
|
|
os.replace(tmp_dst, skill_dst)
|
|
except Exception:
|
|
try:
|
|
tmp_dst.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
# Progressive-capable: install the packaged references/ sidecar when present.
|
|
refs_src = Path(__file__).parent / "skills" / refs_bundle / "references"
|
|
if refs_src.exists():
|
|
_install_skill_references(skill_dst, refs_src)
|
|
print(f" references -> {skill_dst.parent / 'references'}")
|
|
else:
|
|
orphan_refs = skill_dst.parent / "references"
|
|
if orphan_refs.exists():
|
|
shutil.rmtree(orphan_refs)
|
|
(skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
|
|
print(f" skill installed -> {skill_dst}")
|
|
|
|
instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md"
|
|
instructions.parent.mkdir(parents=True, exist_ok=True)
|
|
if instructions.exists():
|
|
content = instructions.read_text(encoding="utf-8")
|
|
new_content = _replace_or_append_section(
|
|
content, _VSCODE_INSTRUCTIONS_MARKER, _always_on("vscode-instructions")
|
|
)
|
|
if new_content == content:
|
|
print(f" {instructions} -> already configured (no change)")
|
|
else:
|
|
instructions.write_text(new_content, encoding="utf-8")
|
|
print(f" {instructions} -> graphify section {'updated' if _VSCODE_INSTRUCTIONS_MARKER in content else 'added'}")
|
|
else:
|
|
instructions.write_text(_always_on("vscode-instructions"), encoding="utf-8")
|
|
print(f" {instructions} -> created")
|
|
|
|
print()
|
|
print(
|
|
"VS Code Copilot Chat configured. Type /graphify in the chat panel to build the graph."
|
|
)
|
|
print("Note: for GitHub Copilot CLI (terminal), use: graphify copilot install")
|
|
def vscode_uninstall(project_dir: Path | None = None) -> None:
|
|
"""Remove graphify VS Code Copilot Chat skill and .github/copilot-instructions.md section."""
|
|
skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md"
|
|
if skill_dst.exists():
|
|
skill_dst.unlink()
|
|
print(f" skill removed -> {skill_dst}")
|
|
version_file = skill_dst.parent / ".graphify_version"
|
|
if version_file.exists():
|
|
version_file.unlink()
|
|
refs_dir = skill_dst.parent / "references"
|
|
if refs_dir.exists():
|
|
shutil.rmtree(refs_dir)
|
|
for d in (
|
|
skill_dst.parent,
|
|
skill_dst.parent.parent,
|
|
skill_dst.parent.parent.parent,
|
|
):
|
|
try:
|
|
d.rmdir()
|
|
except OSError:
|
|
break
|
|
|
|
instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md"
|
|
if not instructions.exists():
|
|
return
|
|
content = instructions.read_text(encoding="utf-8")
|
|
if _VSCODE_INSTRUCTIONS_MARKER not in content:
|
|
return
|
|
cleaned = re.sub(
|
|
r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL
|
|
).rstrip()
|
|
if cleaned:
|
|
instructions.write_text(cleaned + "\n", encoding="utf-8")
|
|
print(f" graphify section removed from {instructions}")
|
|
else:
|
|
instructions.unlink()
|
|
print(f" {instructions} -> deleted (was empty after removal)")
|
|
_ANTIGRAVITY_RULES_PATH = Path(".agents") / "rules" / "graphify.md"
|
|
_ANTIGRAVITY_WORKFLOW_PATH = Path(".agents") / "workflows" / "graphify.md"
|
|
_ANTIGRAVITY_WORKFLOW = """\
|
|
---
|
|
name: graphify
|
|
description: Turn any folder of files into a navigable knowledge graph
|
|
---
|
|
|
|
# Workflow: graphify
|
|
|
|
Follow the graphify skill installed at ~/.gemini/config/skills/graphify/SKILL.md to run the full pipeline.
|
|
|
|
If no path argument is given, use `.` (current directory).
|
|
"""
|
|
def _kiro_install(project_dir: Path) -> None:
|
|
"""Write graphify skill + steering file for Kiro IDE/CLI."""
|
|
project_dir = project_dir or Path(".")
|
|
|
|
# Skill file + references/ sidecar + .graphify_version stamp via the shared
|
|
# progressive-disclosure helper. Previously this used a bare write_text that
|
|
# bypassed _copy_skill_file, so the references/ dir and version stamp were
|
|
# never written even though kiro declares skill_refs: "kiro" (#1142).
|
|
_copy_skill_file("kiro", project=True, project_dir=project_dir)
|
|
|
|
# Steering file → .kiro/steering/graphify.md (always-on)
|
|
steering_dir = project_dir / ".kiro" / "steering"
|
|
steering_dir.mkdir(parents=True, exist_ok=True)
|
|
steering_dst = steering_dir / "graphify.md"
|
|
if steering_dst.exists() and steering_dst.read_text(encoding="utf-8") == _always_on("kiro-steering"):
|
|
print(f" .kiro/steering/graphify.md -> already configured (no change)")
|
|
else:
|
|
# File is wholly graphify-owned. Overwrite on upgrade so older
|
|
# report-first wording does not silently linger (issue #580).
|
|
action = "updated" if steering_dst.exists() else "written"
|
|
steering_dst.write_text(_always_on("kiro-steering"), encoding="utf-8")
|
|
print(f" .kiro/steering/graphify.md -> always-on steering {action}")
|
|
|
|
print()
|
|
print("Kiro will now read the knowledge graph before every conversation.")
|
|
print("Use /graphify to build or update the graph.")
|
|
def _kiro_uninstall(project_dir: Path) -> None:
|
|
"""Remove graphify skill + steering file for Kiro."""
|
|
project_dir = project_dir or Path(".")
|
|
removed = []
|
|
|
|
# Skill + .graphify_version + references/ sidecar + empty-dir walk.
|
|
skill_dst = _platform_skill_destination("kiro", project=True, project_dir=project_dir)
|
|
if _remove_skill_file("kiro", project=True, project_dir=project_dir):
|
|
removed.append(str(skill_dst.relative_to(project_dir)))
|
|
|
|
steering_dst = project_dir / ".kiro" / "steering" / "graphify.md"
|
|
if steering_dst.exists():
|
|
steering_dst.unlink()
|
|
removed.append(str(steering_dst.relative_to(project_dir)))
|
|
|
|
print("Removed: " + (", ".join(removed) if removed else "nothing to remove"))
|
|
def _antigravity_finalize(skill_dst: Path, project_dir: Path) -> None:
|
|
"""Write Antigravity's always-on layer next to an installed skill.
|
|
|
|
Injects the native tool-discovery YAML frontmatter into *skill_dst*, then
|
|
writes ``.agents/rules/graphify.md`` and ``.agents/workflows/graphify.md``
|
|
under *project_dir*. Shared by the global ``antigravity install`` and the
|
|
project-scoped ``install --project --platform antigravity`` paths, so both lay
|
|
down the rules/workflows that the uninstall path already expects to remove.
|
|
"""
|
|
# Inject YAML frontmatter for native Antigravity tool discovery.
|
|
if skill_dst.exists():
|
|
content = skill_dst.read_text(encoding="utf-8")
|
|
if not content.startswith("---\n"):
|
|
frontmatter = "---\nname: graphify-manager\ndescription: Rebuild the code graph or perform manual CLI queries when MCP server is offline.\n---\n\n"
|
|
skill_dst.write_text(frontmatter + content, encoding="utf-8")
|
|
|
|
# .agents/rules/graphify.md
|
|
rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
|
|
rules_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if rules_path.exists():
|
|
existing = rules_path.read_text(encoding="utf-8")
|
|
if _always_on("antigravity-rules").strip() != existing.strip():
|
|
rules_path.write_text(_always_on("antigravity-rules"), encoding="utf-8")
|
|
print(f"graphify rule updated at {rules_path.resolve()}")
|
|
else:
|
|
print(f"graphify rule already configured at {rules_path.resolve()} (no change)")
|
|
else:
|
|
rules_path.write_text(_always_on("antigravity-rules"), encoding="utf-8")
|
|
print(f"graphify rule written to {rules_path.resolve()}")
|
|
|
|
# .agents/workflows/graphify.md
|
|
wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH
|
|
wf_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if wf_path.exists():
|
|
existing = wf_path.read_text(encoding="utf-8")
|
|
if _ANTIGRAVITY_WORKFLOW.strip() != existing.strip():
|
|
wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8")
|
|
print(f"graphify workflow updated at {wf_path.resolve()}")
|
|
else:
|
|
print(f"graphify workflow already configured at {wf_path.resolve()} (no change)")
|
|
else:
|
|
wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8")
|
|
print(f"graphify workflow written to {wf_path.resolve()}")
|
|
def _antigravity_install(project_dir: Path) -> None:
|
|
"""Install graphify for Google Antigravity (global skill + .agents/rules + .agents/workflows)."""
|
|
# Copy the skill to ~/.gemini/config/skills/graphify/SKILL.md (global), then
|
|
# lay down the always-on rules/workflows under the project dir.
|
|
install(platform="antigravity")
|
|
_antigravity_finalize(_platform_skill_destination("antigravity"), project_dir)
|
|
|
|
print()
|
|
print("Antigravity will now check the knowledge graph before answering")
|
|
print("codebase questions. Run /graphify first to build the graph.")
|
|
print()
|
|
print(
|
|
"To enable full MCP architecture navigation, add this to ~/.gemini/antigravity/mcp_config.json:"
|
|
)
|
|
print(' "graphify": {')
|
|
print(' "command": "uv",')
|
|
print(
|
|
' "args": ["run", "--with", "graphifyy", "--with", "mcp", "-m", "graphify.serve", "${workspace.path}/graphify-out/graph.json"]'
|
|
)
|
|
print(" }")
|
|
def _antigravity_uninstall(project_dir: Path, *, project: bool = False) -> None:
|
|
"""Remove graphify Antigravity rules, workflow, and skill files."""
|
|
# Remove rules file
|
|
rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
|
|
if rules_path.exists():
|
|
rules_path.unlink()
|
|
print(f"graphify rule removed from {rules_path.resolve()}")
|
|
else:
|
|
print("No graphify Antigravity rule found - nothing to do")
|
|
|
|
# Remove workflow file
|
|
wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH
|
|
if wf_path.exists():
|
|
wf_path.unlink()
|
|
print(f"graphify workflow removed from {wf_path.resolve()}")
|
|
|
|
# Remove skill file
|
|
skill_dst = _platform_skill_destination("antigravity", project=project, project_dir=project_dir)
|
|
if skill_dst.exists():
|
|
skill_dst.unlink()
|
|
print(f"graphify skill removed from {skill_dst}")
|
|
version_file = skill_dst.parent / ".graphify_version"
|
|
if version_file.exists():
|
|
version_file.unlink()
|
|
refs_dir = skill_dst.parent / "references"
|
|
if refs_dir.exists():
|
|
shutil.rmtree(refs_dir)
|
|
for d in (
|
|
skill_dst.parent,
|
|
skill_dst.parent.parent,
|
|
skill_dst.parent.parent.parent,
|
|
):
|
|
try:
|
|
d.rmdir()
|
|
except OSError:
|
|
break
|
|
_CURSOR_RULE_PATH = Path(".cursor") / "rules" / "graphify.mdc"
|
|
_CURSOR_RULE = """\
|
|
---
|
|
description: graphify knowledge graph context
|
|
alwaysApply: true
|
|
---
|
|
|
|
This project has a graphify knowledge graph at graphify-out/.
|
|
|
|
**MANDATORY: Before using Read, Grep, Glob, or Bash to explore the codebase, you MUST run graphify first:**
|
|
- `graphify query "<question>"` — scoped subgraph for any codebase or architecture question
|
|
- `graphify path "<A>" "<B>"` — dependency path between two symbols
|
|
- `graphify explain "<concept>"` — all nodes related to a concept
|
|
|
|
This applies to YOU and to every subagent you spawn. Include this rule explicitly in every subagent prompt that involves code exploration. Do not skip graphify because files are "already known" or because you are executing a plan — the graph surfaces cross-file dependencies and INFERRED edges that grep and Read cannot find.
|
|
|
|
Only use Read/Grep/Glob directly when:
|
|
1. graphify has already oriented you and you need to modify or debug specific lines
|
|
2. `graphify-out/graph.json` does not exist yet
|
|
|
|
- If `graphify-out/wiki/index.md` exists, navigate it instead of reading raw files
|
|
- Read `graphify-out/GRAPH_REPORT.md` only for broad architecture review when query/path/explain do not surface enough context
|
|
- After modifying code files, run `graphify update .` to keep the graph current (AST-only, no API cost)
|
|
"""
|
|
def _cursor_install(project_dir: Path) -> None:
|
|
"""Write .cursor/rules/graphify.mdc with alwaysApply: true."""
|
|
rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH
|
|
rule_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if rule_path.exists() and rule_path.read_text(encoding="utf-8") == _CURSOR_RULE:
|
|
print(f"graphify rule at {rule_path} already configured (no change)")
|
|
return
|
|
# File is wholly graphify-owned. Overwrite on upgrade so older
|
|
# report-first wording does not silently linger (issue #580).
|
|
action = "updated" if rule_path.exists() else "written"
|
|
rule_path.write_text(_CURSOR_RULE, encoding="utf-8")
|
|
print(f"graphify rule {action} at {rule_path.resolve()}")
|
|
print()
|
|
print("Cursor will now always include the knowledge graph context.")
|
|
print("Run /graphify . first to build the graph if you haven't already.")
|
|
def _cursor_uninstall(project_dir: Path) -> None:
|
|
"""Remove .cursor/rules/graphify.mdc."""
|
|
rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH
|
|
if not rule_path.exists():
|
|
print("No graphify Cursor rule found - nothing to do")
|
|
return
|
|
rule_path.unlink()
|
|
print(f"graphify Cursor rule removed from {rule_path.resolve()}")
|
|
# Devin CLI — .windsurf/rules/graphify.md (always-on context)
|
|
# Devin reads .windsurf/rules/*.md files the same way Windsurf IDE does.
|
|
_DEVIN_RULES_PATH = Path(".windsurf") / "rules" / "graphify.md"
|
|
_DEVIN_RULES = """\
|
|
## graphify
|
|
|
|
This project has a graphify knowledge graph at graphify-out/.
|
|
|
|
Rules:
|
|
- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (or `graphify path "<A>" "<B>"` / `graphify explain "<concept>"`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output.
|
|
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
|
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context
|
|
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
|
|
"""
|
|
def _devin_rules_install(project_dir: Path) -> None:
|
|
"""Write .windsurf/rules/graphify.md for always-on Devin context."""
|
|
rules_path = (project_dir or Path(".")) / _DEVIN_RULES_PATH
|
|
rules_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if rules_path.exists() and rules_path.read_text(encoding="utf-8") == _DEVIN_RULES:
|
|
print(f" {rules_path} -> already configured (no change)")
|
|
return
|
|
action = "updated" if rules_path.exists() else "written"
|
|
rules_path.write_text(_DEVIN_RULES, encoding="utf-8")
|
|
print(f" rules {action} -> {rules_path}")
|
|
def _devin_rules_uninstall(project_dir: Path) -> None:
|
|
"""Remove .windsurf/rules/graphify.md."""
|
|
rules_path = (project_dir or Path(".")) / _DEVIN_RULES_PATH
|
|
if not rules_path.exists():
|
|
return
|
|
rules_path.unlink()
|
|
print(f" rules removed -> {rules_path}")
|
|
_KILO_PLUGIN_JS = """\
|
|
// graphify Kilo plugin
|
|
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
|
|
import { existsSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
export const GraphifyPlugin = async ({ directory }) => {
|
|
let reminded = false;
|
|
|
|
return {
|
|
"tool.execute.before": async (input, output) => {
|
|
if (reminded) return;
|
|
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
|
|
|
|
if (input.tool === "bash") {
|
|
// Separate with ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a
|
|
// statement separator ("not a valid statement separator"), which broke
|
|
// the first bash command in every OpenCode session on Windows (#1646).
|
|
// ';' works in PowerShell 5.1, Bash, and POSIX shells alike.
|
|
output.args.command =
|
|
'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." ; ' +
|
|
output.args.command;
|
|
reminded = true;
|
|
}
|
|
},
|
|
};
|
|
};
|
|
"""
|
|
_KILO_PLUGIN_PATH = Path(".kilo") / "plugins" / "graphify.js"
|
|
_KILO_CONFIG_JSON_PATH = Path(".kilo") / "kilo.json"
|
|
_KILO_CONFIG_JSONC_PATH = Path(".kilo") / "kilo.jsonc"
|
|
def _strip_json_comments(raw: str) -> str:
|
|
"""Remove JSONC-style comments while leaving string content intact."""
|
|
result: list[str] = []
|
|
in_string = False
|
|
escaped = False
|
|
line_comment = False
|
|
block_comment = False
|
|
i = 0
|
|
|
|
while i < len(raw):
|
|
ch = raw[i]
|
|
nxt = raw[i + 1] if i + 1 < len(raw) else ""
|
|
|
|
if line_comment:
|
|
if ch == "\n":
|
|
line_comment = False
|
|
result.append(ch)
|
|
i += 1
|
|
continue
|
|
|
|
if block_comment:
|
|
if ch == "*" and nxt == "/":
|
|
block_comment = False
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
continue
|
|
|
|
if in_string:
|
|
result.append(ch)
|
|
if escaped:
|
|
escaped = False
|
|
elif ch == "\\":
|
|
escaped = True
|
|
elif ch == '"':
|
|
in_string = False
|
|
i += 1
|
|
continue
|
|
|
|
if ch == "/" and nxt == "/":
|
|
line_comment = True
|
|
i += 2
|
|
continue
|
|
if ch == "/" and nxt == "*":
|
|
block_comment = True
|
|
i += 2
|
|
continue
|
|
|
|
result.append(ch)
|
|
if ch == '"':
|
|
in_string = True
|
|
i += 1
|
|
|
|
return re.sub(r",(\s*[}\]])", r"\1", "".join(result))
|
|
def _load_json_like(config_file: Path) -> dict:
|
|
if not config_file.exists():
|
|
return {}
|
|
try:
|
|
raw = config_file.read_text(encoding="utf-8")
|
|
if config_file.suffix == ".jsonc":
|
|
raw = _strip_json_comments(raw)
|
|
loaded = json.loads(raw)
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
return loaded if isinstance(loaded, dict) else {}
|
|
def _kilo_config_path(project_dir: Path) -> Path:
|
|
kilo_dir = (project_dir or Path(".")) / ".kilo"
|
|
json_path = kilo_dir / _KILO_CONFIG_JSON_PATH.name
|
|
if json_path.exists():
|
|
return json_path
|
|
jsonc_path = kilo_dir / _KILO_CONFIG_JSONC_PATH.name
|
|
if jsonc_path.exists():
|
|
return jsonc_path
|
|
return json_path
|
|
def _kilo_config_write_path(project_dir: Path) -> Path:
|
|
"""Write automated Kilo edits to kilo.json so existing JSONC stays untouched."""
|
|
kilo_dir = (project_dir or Path(".")) / ".kilo"
|
|
return kilo_dir / _KILO_CONFIG_JSON_PATH.name
|
|
def _install_kilo_plugin(project_dir: Path) -> None:
|
|
"""Write graphify.js plugin and register it without rewriting user JSONC."""
|
|
plugin_file = project_dir / _KILO_PLUGIN_PATH
|
|
plugin_file.parent.mkdir(parents=True, exist_ok=True)
|
|
plugin_file.write_text(_KILO_PLUGIN_JS, encoding="utf-8")
|
|
print(f" {_KILO_PLUGIN_PATH} -> tool.execute.before hook written")
|
|
|
|
config_file = _kilo_config_path(project_dir)
|
|
write_config_file = _kilo_config_write_path(project_dir)
|
|
write_config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config = _load_json_like(config_file)
|
|
plugins = config.get("plugin")
|
|
if not isinstance(plugins, list):
|
|
plugins = []
|
|
config["plugin"] = plugins
|
|
entry = plugin_file.resolve().as_uri()
|
|
if entry not in plugins:
|
|
plugins.append(entry)
|
|
write_config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
print(f" {write_config_file.relative_to(project_dir)} -> plugin registered")
|
|
else:
|
|
print(
|
|
f" {config_file.relative_to(project_dir)} -> plugin already registered (no change)"
|
|
)
|
|
def _uninstall_kilo_plugin(project_dir: Path) -> None:
|
|
"""Remove graphify.js plugin and deregister it without rewriting user JSONC."""
|
|
plugin_file = project_dir / _KILO_PLUGIN_PATH
|
|
if plugin_file.exists():
|
|
plugin_file.unlink()
|
|
print(f" {_KILO_PLUGIN_PATH} -> removed")
|
|
|
|
config_file = _kilo_config_path(project_dir)
|
|
if not config_file.exists():
|
|
return
|
|
write_config_file = _kilo_config_write_path(project_dir)
|
|
config = _load_json_like(config_file)
|
|
plugins = config.get("plugin", [])
|
|
if not isinstance(plugins, list):
|
|
plugins = []
|
|
entry = plugin_file.resolve().as_uri()
|
|
if entry in plugins:
|
|
config["plugin"] = [plugin for plugin in plugins if plugin != entry]
|
|
if not config["plugin"]:
|
|
config.pop("plugin")
|
|
write_config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
write_config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
print(
|
|
f" {write_config_file.relative_to(project_dir)} -> plugin deregistered"
|
|
)
|
|
# OpenCode tool.execute.before plugin — fires before every tool call.
|
|
# Injects a graph reminder into bash command output when graph.json exists.
|
|
_OPENCODE_PLUGIN_JS = """\
|
|
// graphify OpenCode plugin
|
|
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
|
|
//
|
|
// IMPORTANT: keep the reminder string free of backticks and $(...) constructs.
|
|
// The hook prepends `echo "<reminder>" && <cmd>` to the user's bash command;
|
|
// backticks inside the double-quoted echo trigger bash command substitution,
|
|
// which both corrupts tool output and silently executes the very graphify
|
|
// command we are only suggesting. Plain words render fine in opencode's TUI.
|
|
import { existsSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
export const GraphifyPlugin = async ({ directory }) => {
|
|
let reminded = false;
|
|
|
|
return {
|
|
"tool.execute.before": async (input, output) => {
|
|
if (reminded) return;
|
|
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
|
|
|
|
if (input.tool === "bash") {
|
|
// ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a statement
|
|
// separator, breaking the first bash command of the session (#1646).
|
|
output.args.command =
|
|
'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run graphify query with your question (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." ; ' +
|
|
output.args.command;
|
|
reminded = true;
|
|
}
|
|
},
|
|
};
|
|
};
|
|
"""
|
|
_OPENCODE_PLUGIN_PATH = Path(".opencode") / "plugins" / "graphify.js"
|
|
_OPENCODE_CONFIG_PATH = Path(".opencode") / "opencode.json"
|
|
def _install_opencode_plugin(project_dir: Path) -> None:
|
|
"""Write graphify.js plugin and register it in opencode.json."""
|
|
plugin_file = project_dir / _OPENCODE_PLUGIN_PATH
|
|
plugin_file.parent.mkdir(parents=True, exist_ok=True)
|
|
plugin_file.write_text(_OPENCODE_PLUGIN_JS, encoding="utf-8")
|
|
print(f" {_OPENCODE_PLUGIN_PATH} -> tool.execute.before hook written")
|
|
|
|
config_file = project_dir / _OPENCODE_CONFIG_PATH
|
|
if config_file.exists():
|
|
try:
|
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
config = {}
|
|
else:
|
|
config = {}
|
|
|
|
plugins = config.setdefault("plugin", [])
|
|
entry = _OPENCODE_PLUGIN_PATH.as_posix()
|
|
if entry not in plugins:
|
|
plugins.append(entry)
|
|
config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
print(f" {_OPENCODE_CONFIG_PATH} -> plugin registered")
|
|
else:
|
|
print(f" {_OPENCODE_CONFIG_PATH} -> plugin already registered (no change)")
|
|
def _uninstall_opencode_plugin(project_dir: Path) -> None:
|
|
"""Remove graphify.js plugin and deregister from opencode.json."""
|
|
plugin_file = project_dir / _OPENCODE_PLUGIN_PATH
|
|
if plugin_file.exists():
|
|
plugin_file.unlink()
|
|
print(f" {_OPENCODE_PLUGIN_PATH} -> removed")
|
|
|
|
config_file = project_dir / _OPENCODE_CONFIG_PATH
|
|
if not config_file.exists():
|
|
return
|
|
try:
|
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return
|
|
plugins = config.get("plugin", [])
|
|
entry = _OPENCODE_PLUGIN_PATH.as_posix()
|
|
if entry in plugins:
|
|
plugins.remove(entry)
|
|
if not plugins:
|
|
config.pop("plugin")
|
|
config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
print(f" {_OPENCODE_CONFIG_PATH} -> plugin deregistered")
|
|
def _resolve_graphify_exe() -> str:
|
|
"""Return the absolute path to the graphify executable.
|
|
|
|
Falls back to bare 'graphify' if resolution fails. Using an absolute path
|
|
ensures the hook works in environments where the venv Scripts/ directory is
|
|
not on PATH (e.g. VS Code Codex extension on Windows).
|
|
"""
|
|
import shutil
|
|
found = shutil.which("graphify")
|
|
if found:
|
|
return found
|
|
# Derive from sys.executable: same Scripts/ (Windows) or bin/ (Unix) dir
|
|
scripts_dir = Path(sys.executable).parent
|
|
for name in ("graphify.exe", "graphify"):
|
|
candidate = scripts_dir / name
|
|
if candidate.exists():
|
|
return str(candidate)
|
|
return "graphify"
|
|
def _install_codex_hook(project_dir: Path) -> None:
|
|
"""Add graphify PreToolUse hook to .codex/hooks.json."""
|
|
hooks_path = project_dir / ".codex" / "hooks.json"
|
|
hooks_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if hooks_path.exists():
|
|
try:
|
|
existing = json.loads(hooks_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
existing = {}
|
|
else:
|
|
existing = {}
|
|
|
|
graphify_exe = _resolve_graphify_exe()
|
|
hook_entry = {
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "Bash",
|
|
"hooks": [{"type": "command", "command": f"{graphify_exe} hook-check"}],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
pre_tool = existing.setdefault("hooks", {}).setdefault("PreToolUse", [])
|
|
existing["hooks"]["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)]
|
|
existing["hooks"]["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"])
|
|
hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
print(f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check)")
|
|
def _uninstall_codex_hook(project_dir: Path) -> None:
|
|
"""Remove graphify PreToolUse hook from .codex/hooks.json."""
|
|
hooks_path = project_dir / ".codex" / "hooks.json"
|
|
if not hooks_path.exists():
|
|
return
|
|
try:
|
|
existing = json.loads(hooks_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return
|
|
pre_tool = existing.get("hooks", {}).get("PreToolUse", [])
|
|
filtered = [h for h in pre_tool if "graphify" not in str(h)]
|
|
existing["hooks"]["PreToolUse"] = filtered
|
|
hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
print(f" .codex/hooks.json -> PreToolUse hook removed")
|
|
def _agents_install(project_dir: Path, platform: str) -> None:
|
|
"""Write the graphify section to the local AGENTS.md for always-on platforms."""
|
|
target = (project_dir or Path(".")) / "AGENTS.md"
|
|
|
|
if target.exists():
|
|
content = target.read_text(encoding="utf-8")
|
|
new_content = _replace_or_append_section(
|
|
content, _AGENTS_MD_MARKER, _always_on("agents-md")
|
|
)
|
|
else:
|
|
new_content = _always_on("agents-md")
|
|
|
|
if target.exists() and new_content == target.read_text(encoding="utf-8"):
|
|
print(f"graphify already configured in {target.resolve()} (no change)")
|
|
else:
|
|
target.write_text(new_content, encoding="utf-8")
|
|
print(f"graphify section written to {target.resolve()}")
|
|
|
|
if platform == "codex":
|
|
_install_codex_hook(project_dir or Path("."))
|
|
elif platform == "opencode":
|
|
_install_opencode_plugin(project_dir or Path("."))
|
|
elif platform == "kilo":
|
|
_install_kilo_plugin(project_dir or Path("."))
|
|
|
|
print()
|
|
print(
|
|
f"{platform.capitalize()} will now check the knowledge graph before answering"
|
|
)
|
|
print("codebase questions and rebuild it after code changes.")
|
|
if platform not in ("codex", "opencode", "kilo"):
|
|
print()
|
|
print("Note: unlike Claude Code, there is no PreToolUse hook equivalent for")
|
|
print(
|
|
f"{platform.capitalize()} — the AGENTS.md rules are the always-on mechanism."
|
|
)
|
|
def _amp_legacy_cleanup() -> None:
|
|
"""Best-effort removal of the pre-fix ~/.amp/skills/graphify install dir.
|
|
|
|
Older graphify versions wrote the Amp skill to ~/.amp/skills, which Amp does
|
|
not search. Clean it up on install so a stale, never-loaded copy does not
|
|
linger. Failures are ignored (the new path is what matters).
|
|
"""
|
|
legacy = Path.home() / ".amp" / "skills" / "graphify"
|
|
if legacy.exists():
|
|
shutil.rmtree(legacy, ignore_errors=True)
|
|
if not legacy.exists():
|
|
print(f" legacy removed -> {legacy}")
|
|
def _amp_install(project_dir: Path | None = None) -> None:
|
|
"""User-scope Amp install: skill into ~/.config/agents/skills + AGENTS.md."""
|
|
_amp_legacy_cleanup()
|
|
_copy_skill_file("amp")
|
|
_agents_install(project_dir or Path("."), "amp")
|
|
def _amp_uninstall(project_dir: Path | None = None) -> None:
|
|
"""User-scope Amp uninstall: remove the skill and the AGENTS.md section."""
|
|
removed = _remove_skill_file("amp")
|
|
if removed:
|
|
print("skill removed")
|
|
_agents_uninstall(project_dir or Path("."), platform="amp")
|
|
def _agents_platform_install(project_dir: Path | None = None) -> None:
|
|
"""`graphify agents install`: skill into ~/.agents/skills + AGENTS.md.
|
|
|
|
The amp-twin of the generic Agent-Skills target. Mirrors _amp_install but
|
|
lands the skill at the spec's user-global ~/.agents/skills (set in
|
|
_platform_skill_destination). Wiring AGENTS.md keeps it honest with the
|
|
rendered hooks reference, which points at `graphify agents install`. The bare
|
|
`graphify install --platform agents` path stays skill-only (via install()),
|
|
exactly as amp's `--platform amp` does.
|
|
"""
|
|
_copy_skill_file("agents")
|
|
_agents_install(project_dir or Path("."), "agents")
|
|
def _agents_platform_uninstall(project_dir: Path | None = None) -> None:
|
|
"""`graphify agents uninstall`: remove the skill and the AGENTS.md section."""
|
|
removed = _remove_skill_file("agents")
|
|
if removed:
|
|
print("skill removed")
|
|
_agents_uninstall(project_dir or Path("."), platform="agents")
|
|
def _project_install(platform_name: str, project_dir: Path | None = None) -> None:
|
|
"""Install platform skill/config files in the current project."""
|
|
project_dir = project_dir or Path(".")
|
|
platform_name = _canonical_platform(platform_name)
|
|
if platform_name in ("claude", "windows"):
|
|
install(platform=platform_name, project=True, project_dir=project_dir)
|
|
claude_install(project_dir)
|
|
_print_project_git_add_hint([project_dir / ".claude", project_dir / "CLAUDE.md"])
|
|
elif platform_name == "gemini":
|
|
gemini_install(project_dir, project=True)
|
|
elif platform_name == "cursor":
|
|
_cursor_install(project_dir)
|
|
_print_project_git_add_hint([project_dir / ".cursor"])
|
|
elif platform_name == "kiro":
|
|
_kiro_install(project_dir)
|
|
_print_project_git_add_hint([project_dir / ".kiro"])
|
|
elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
|
|
skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir)
|
|
_agents_install(project_dir, platform_name)
|
|
hint_paths = [_project_scope_root(skill_dst, project_dir), project_dir / "AGENTS.md"]
|
|
if platform_name == "opencode":
|
|
hint_paths.append(project_dir / ".opencode")
|
|
elif platform_name == "codex":
|
|
hint_paths.append(project_dir / ".codex")
|
|
_print_project_git_add_hint(hint_paths)
|
|
elif platform_name == "devin":
|
|
skill_dst = _copy_skill_file("devin", project=True, project_dir=project_dir)
|
|
_devin_rules_install(project_dir)
|
|
_print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".windsurf"])
|
|
elif platform_name == "antigravity":
|
|
# Project-scoped: skill in .agents/skills/ PLUS the .agents/rules +
|
|
# .agents/workflows always-on layer (previously this path wrote only the
|
|
# skill, leaving the rules/workflows the uninstall path removes unset).
|
|
skill_dst = _copy_skill_file("antigravity", project=True, project_dir=project_dir)
|
|
_antigravity_finalize(skill_dst, project_dir)
|
|
_print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".agents"])
|
|
elif platform_name in ("copilot", "pi", "kimi", "agents"):
|
|
# Skill-only project install: drop SKILL.md (+ references) at the scope
|
|
# root. `agents` -> ./.agents/skills/graphify/SKILL.md.
|
|
skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir)
|
|
_print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)])
|
|
else:
|
|
install(platform=platform_name, project=True, project_dir=project_dir)
|
|
def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> None:
|
|
"""Remove project-scoped platform skill/config files only."""
|
|
project_dir = project_dir or Path(".")
|
|
platform_name = _canonical_platform(platform_name)
|
|
if platform_name in ("claude", "windows"):
|
|
_remove_skill_file(platform_name, project=True, project_dir=project_dir)
|
|
_remove_claude_skill_registration(project_dir)
|
|
claude_uninstall(project_dir, project=True)
|
|
elif platform_name == "gemini":
|
|
gemini_uninstall(project_dir, project=True)
|
|
elif platform_name == "cursor":
|
|
_cursor_uninstall(project_dir)
|
|
elif platform_name == "kiro":
|
|
_kiro_uninstall(project_dir)
|
|
elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
|
|
_remove_skill_file(platform_name, project=True, project_dir=project_dir)
|
|
_agents_uninstall(project_dir, platform=platform_name)
|
|
if platform_name == "codex":
|
|
_uninstall_codex_hook(project_dir)
|
|
elif platform_name == "antigravity":
|
|
_antigravity_uninstall(project_dir, project=True)
|
|
elif platform_name == "devin":
|
|
removed = _remove_skill_file("devin", project=True, project_dir=project_dir)
|
|
_devin_rules_uninstall(project_dir)
|
|
if not removed:
|
|
print("nothing to remove")
|
|
elif platform_name in ("copilot", "pi", "kimi", "agents"):
|
|
removed = _remove_skill_file(platform_name, project=True, project_dir=project_dir)
|
|
if not removed:
|
|
print("nothing to remove")
|
|
elif platform_name == "codebuddy":
|
|
codebuddy_uninstall(project_dir)
|
|
else:
|
|
_remove_skill_file(platform_name, project=True, project_dir=project_dir)
|
|
def _project_uninstall_all(project_dir: Path | None = None) -> None:
|
|
"""Remove project-scoped install files without touching user-scope installs."""
|
|
project_dir = project_dir or Path(".")
|
|
print("Uninstalling project-scoped graphify files...\n")
|
|
for platform_name in _PLATFORM_CONFIG:
|
|
_project_uninstall(platform_name, project_dir)
|
|
for platform_name in ("gemini", "cursor"):
|
|
_project_uninstall(platform_name, project_dir)
|
|
print("\nDone.")
|
|
def _agents_uninstall(project_dir: Path, platform: str = "") -> None:
|
|
"""Remove the graphify section from the local AGENTS.md."""
|
|
target = (project_dir or Path(".")) / "AGENTS.md"
|
|
|
|
if not target.exists():
|
|
print("No AGENTS.md found in current directory - nothing to do")
|
|
if platform == "opencode":
|
|
_uninstall_opencode_plugin(project_dir or Path("."))
|
|
elif platform == "kilo":
|
|
_uninstall_kilo_plugin(project_dir or Path("."))
|
|
return
|
|
|
|
content = target.read_text(encoding="utf-8")
|
|
if _AGENTS_MD_MARKER not in content:
|
|
print("graphify section not found in AGENTS.md - nothing to do")
|
|
if platform == "opencode":
|
|
_uninstall_opencode_plugin(project_dir or Path("."))
|
|
elif platform == "kilo":
|
|
_uninstall_kilo_plugin(project_dir or Path("."))
|
|
return
|
|
|
|
cleaned = re.sub(
|
|
r"\n*## graphify\n.*?(?=\n## |\Z)",
|
|
"",
|
|
content,
|
|
flags=re.DOTALL,
|
|
).rstrip()
|
|
if cleaned:
|
|
target.write_text(cleaned + "\n", encoding="utf-8")
|
|
print(f"graphify section removed from {target.resolve()}")
|
|
else:
|
|
target.unlink()
|
|
print(f"AGENTS.md was empty after removal - deleted {target.resolve()}")
|
|
|
|
if platform == "opencode":
|
|
_uninstall_opencode_plugin(project_dir or Path("."))
|
|
elif platform == "kilo":
|
|
_uninstall_kilo_plugin(project_dir or Path("."))
|
|
def _kilo_uninstall_global() -> list[str]:
|
|
removed = []
|
|
command_dst = Path.home() / ".config" / "kilo" / "command" / "graphify.md"
|
|
if command_dst.exists():
|
|
command_dst.unlink()
|
|
removed.append(f"command removed: {command_dst}")
|
|
try:
|
|
command_dst.parent.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
skill_dst = Path.home() / _PLATFORM_CONFIG["kilo"]["skill_dst"]
|
|
if skill_dst.exists():
|
|
skill_dst.unlink()
|
|
removed.append(f"skill removed: {skill_dst}")
|
|
version_file = skill_dst.parent / ".graphify_version"
|
|
if version_file.exists():
|
|
version_file.unlink()
|
|
for d in (
|
|
skill_dst.parent,
|
|
skill_dst.parent.parent,
|
|
skill_dst.parent.parent.parent,
|
|
):
|
|
try:
|
|
d.rmdir()
|
|
except OSError:
|
|
break
|
|
|
|
return removed
|
|
def _kilo_install(project_dir: Path) -> None:
|
|
"""Install native Kilo skill + command globally and always-on project wiring locally."""
|
|
install(platform="kilo")
|
|
_agents_install(project_dir or Path("."), "kilo")
|
|
def _kilo_uninstall(project_dir: Path) -> None:
|
|
"""Remove Kilo always-on project wiring and global skill/command files."""
|
|
_agents_uninstall(project_dir or Path("."), platform="kilo")
|
|
removed = _kilo_uninstall_global()
|
|
print("; ".join(removed) if removed else "nothing to remove")
|
|
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(encoding="utf-8")
|
|
new_content = _replace_or_append_section(
|
|
content, _CLAUDE_MD_MARKER, _always_on("claude-md")
|
|
)
|
|
else:
|
|
new_content = _always_on("claude-md")
|
|
|
|
if target.exists() and new_content == target.read_text(encoding="utf-8"):
|
|
print(f"graphify already configured in {target.resolve()} (no change)")
|
|
else:
|
|
target.write_text(new_content, encoding="utf-8")
|
|
print(f"graphify section written to {target.resolve()}")
|
|
|
|
# Always re-install the Claude Code PreToolUse hook so an old hook
|
|
# payload (e.g. pre-issue-#580 wording) is replaced on upgrade.
|
|
_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(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
settings = {}
|
|
else:
|
|
settings = {}
|
|
|
|
hooks = settings.setdefault("hooks", {})
|
|
pre_tool = hooks.setdefault("PreToolUse", [])
|
|
|
|
hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
|
|
hooks["PreToolUse"].extend(_claude_pretooluse_hooks())
|
|
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
|
print(f" .claude/settings.json -> PreToolUse hooks registered (Bash search + Read/Glob)")
|
|
def _uninstall_claude_hook(project_dir: Path) -> None:
|
|
"""Remove the graphify PreToolUse hook from .claude/settings.json and its
|
|
local-only sibling .claude/settings.local.json.
|
|
|
|
A user may relocate the hook into settings.local.json so it is not committed
|
|
to a shared repo, so uninstall has to clean whichever file holds it (#1731).
|
|
"""
|
|
claude_dir = project_dir / ".claude"
|
|
for name in ("settings.json", "settings.local.json"):
|
|
_strip_graphify_hook(claude_dir / name)
|
|
def _strip_graphify_hook(settings_path: Path) -> None:
|
|
"""Drop graphify PreToolUse hooks from a single Claude settings file, if present."""
|
|
if not settings_path.exists():
|
|
return
|
|
try:
|
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return
|
|
pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
|
|
filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") 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), encoding="utf-8")
|
|
print(f" .claude/{settings_path.name} -> PreToolUse hook removed")
|
|
def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
|
|
"""Remove graphify from every platform detected in the current project."""
|
|
pd = project_dir or Path(".")
|
|
print("Uninstalling graphify from all detected platforms...\n")
|
|
|
|
# Skill-file / config-section uninstallers
|
|
claude_uninstall(pd)
|
|
codebuddy_uninstall(pd)
|
|
gemini_uninstall(pd)
|
|
vscode_uninstall(pd)
|
|
_cursor_uninstall(pd)
|
|
_kiro_uninstall(pd)
|
|
_antigravity_uninstall(pd)
|
|
# AGENTS.md covers: codex, aider, opencode, claw, droid, trae, trae-cn, hermes, copilot
|
|
_agents_uninstall(pd)
|
|
# Amp also drops a user-scope skill at ~/.config/agents/skills, which the
|
|
# AGENTS.md cleanup above does not touch.
|
|
_remove_skill_file("amp")
|
|
# The generic agents platform's user-scope skill lives at ~/.agents/skills,
|
|
# which neither the AGENTS.md cleanup nor amp's removal reaches.
|
|
_remove_skill_file("agents")
|
|
_uninstall_opencode_plugin(pd)
|
|
_uninstall_codex_hook(pd)
|
|
|
|
# Git hook
|
|
try:
|
|
from graphify.hooks import uninstall as hook_uninstall
|
|
result = hook_uninstall(pd)
|
|
if result:
|
|
print(result)
|
|
except Exception:
|
|
pass
|
|
|
|
if purge:
|
|
import shutil as _shutil
|
|
out = pd / _GRAPHIFY_OUT
|
|
if out.exists():
|
|
_shutil.rmtree(out)
|
|
print(f"\n {_GRAPHIFY_OUT}/ -> deleted (--purge)")
|
|
else:
|
|
print(f"\n {_GRAPHIFY_OUT}/ -> not found (nothing to purge)")
|
|
|
|
print("\nDone. Run 'pip uninstall graphifyy' to remove the package itself.")
|
|
def claude_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
|
|
"""Remove the graphify skill tree (SKILL.md + references/) and the graphify
|
|
section from CLAUDE.md and its local-only variants, plus the PreToolUse hook.
|
|
|
|
Mirrors gemini_uninstall: the bare `graphify uninstall` and `graphify claude
|
|
uninstall` must remove the installed skill, not just strip CLAUDE.md, or the
|
|
progressive-disclosure tree (SKILL.md + references/) is orphaned (#1121).
|
|
|
|
A user may relocate the section/hook into the local-only files Claude Code
|
|
supports so they are not committed to a shared repo, so uninstall also cleans
|
|
CLAUDE.local.md, .claude/CLAUDE.local.md and .claude/settings.local.json (#1731).
|
|
"""
|
|
project_dir = project_dir or Path(".")
|
|
_remove_skill_file("claude", project=project, project_dir=project_dir)
|
|
|
|
md_targets = [
|
|
project_dir / "CLAUDE.md",
|
|
project_dir / "CLAUDE.local.md",
|
|
project_dir / ".claude" / "CLAUDE.local.md",
|
|
]
|
|
existing = [t for t in md_targets if t.exists()]
|
|
removed_any = False
|
|
for target in existing:
|
|
# Not short-circuited: every present file must be cleaned, not just the first.
|
|
if _strip_graphify_md_section(target):
|
|
removed_any = True
|
|
|
|
if not existing:
|
|
print("No CLAUDE.md found in current directory - nothing to do")
|
|
elif not removed_any:
|
|
print("graphify section not found in CLAUDE.md - nothing to do")
|
|
|
|
_uninstall_claude_hook(project_dir)
|
|
def _strip_graphify_md_section(target: Path) -> bool:
|
|
"""Strip the ## graphify section from one CLAUDE.md-style file.
|
|
|
|
Returns True if a section was removed. Deletes the file if nothing else
|
|
remains after removal.
|
|
"""
|
|
try:
|
|
content = target.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
# An unreadable/undecodable CLAUDE.md-style file (e.g. non-UTF-8, or a
|
|
# directory of that name) must not abort uninstall - nothing to strip.
|
|
return False
|
|
if _CLAUDE_MD_MARKER not in content:
|
|
return False
|
|
# 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", encoding="utf-8")
|
|
print(f"graphify section removed from {target.resolve()}")
|
|
else:
|
|
target.unlink()
|
|
print(f"{target.name} was empty after removal - deleted {target.resolve()}")
|
|
return True
|
|
def codebuddy_install(project_dir: Path | None = None) -> None:
|
|
"""Install the graphify skill and CODEBUDDY.md section for CodeBuddy."""
|
|
_copy_skill_file("codebuddy", project=bool(project_dir), project_dir=project_dir)
|
|
target = (project_dir or Path(".")) / "CODEBUDDY.md"
|
|
|
|
if target.exists():
|
|
content = target.read_text(encoding="utf-8")
|
|
new_content = _replace_or_append_section(
|
|
content, _CODEBUDDY_MD_MARKER, _always_on("claude-md")
|
|
)
|
|
else:
|
|
new_content = _always_on("claude-md")
|
|
|
|
if target.exists() and new_content == target.read_text(encoding="utf-8"):
|
|
print(f"graphify already configured in {target.resolve()} (no change)")
|
|
else:
|
|
target.write_text(new_content, encoding="utf-8")
|
|
print(f"graphify section written to {target.resolve()}")
|
|
|
|
# Also write CodeBuddy PreToolUse hook to .codebuddy/settings.json
|
|
_install_codebuddy_hook(project_dir or Path("."))
|
|
|
|
print()
|
|
print("CodeBuddy will now check the knowledge graph before answering")
|
|
print("codebase questions and rebuild it after code changes.")
|
|
def _install_codebuddy_hook(project_dir: Path) -> None:
|
|
"""Add graphify PreToolUse hook to .codebuddy/settings.json."""
|
|
settings_path = project_dir / ".codebuddy" / "settings.json"
|
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if settings_path.exists():
|
|
try:
|
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
settings = {}
|
|
else:
|
|
settings = {}
|
|
|
|
hooks = settings.setdefault("hooks", {})
|
|
pre_tool = hooks.setdefault("PreToolUse", [])
|
|
|
|
hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
|
|
hooks["PreToolUse"].extend(_claude_pretooluse_hooks())
|
|
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
|
print(f" .codebuddy/settings.json -> PreToolUse hooks registered")
|
|
def _uninstall_codebuddy_hook(project_dir: Path) -> None:
|
|
"""Remove graphify PreToolUse hook from .codebuddy/settings.json."""
|
|
settings_path = project_dir / ".codebuddy" / "settings.json"
|
|
if not settings_path.exists():
|
|
return
|
|
try:
|
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return
|
|
pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
|
|
filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") 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), encoding="utf-8")
|
|
print(f" .codebuddy/settings.json -> PreToolUse hook removed")
|
|
def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
|
|
"""Remove the graphify skill tree (SKILL.md + references/) and the CODEBUDDY.md section."""
|
|
project_dir = project_dir or Path(".")
|
|
_remove_skill_file("codebuddy", project=project, project_dir=project_dir)
|
|
target = project_dir / "CODEBUDDY.md"
|
|
|
|
if not target.exists():
|
|
print("No CODEBUDDY.md found in current directory - nothing to do")
|
|
return
|
|
|
|
content = target.read_text(encoding="utf-8")
|
|
if _CODEBUDDY_MD_MARKER not in content:
|
|
print("graphify section not found in CODEBUDDY.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", encoding="utf-8")
|
|
print(f"graphify section removed from {target.resolve()}")
|
|
else:
|
|
target.unlink()
|
|
print(f"CODEBUDDY.md was empty after removal - deleted {target.resolve()}")
|
|
|
|
_uninstall_codebuddy_hook(project_dir or Path("."))
|
|
|
|
|
|
_CLI_INSTALL_COMMANDS = frozenset({
|
|
"agents",
|
|
"aider",
|
|
"amp",
|
|
"antigravity",
|
|
"claude",
|
|
"claw",
|
|
"codebuddy",
|
|
"codex",
|
|
"copilot",
|
|
"cursor",
|
|
"devin",
|
|
"droid",
|
|
"gemini",
|
|
"hermes",
|
|
"install",
|
|
"kilo",
|
|
"kiro",
|
|
"opencode",
|
|
"pi",
|
|
"skills",
|
|
"trae",
|
|
"trae-cn",
|
|
"uninstall",
|
|
"vscode",
|
|
})
|
|
|
|
|
|
def dispatch_install_cli(cmd: str) -> bool:
|
|
"""Handle `graphify <install-command>` dispatch (install/uninstall + every
|
|
per-platform install target). Returns True if cmd was an install command and
|
|
was handled, False otherwise so the caller can continue its own dispatch.
|
|
Moved verbatim from __main__.main().
|
|
"""
|
|
if cmd not in _CLI_INSTALL_COMMANDS:
|
|
return False
|
|
if cmd == "install":
|
|
# Default to windows platform on Windows, claude elsewhere
|
|
default_platform = "windows" if platform.system() == "Windows" else "claude"
|
|
selected_platform: str | None = None
|
|
project_scope = False
|
|
args = sys.argv[2:]
|
|
i = 0
|
|
while i < len(args):
|
|
arg = args[i]
|
|
if arg in ("-h", "--help"):
|
|
_print_install_usage()
|
|
return True
|
|
if arg == "--project":
|
|
project_scope = True
|
|
i += 1
|
|
elif arg.startswith("--platform="):
|
|
candidate = arg.split("=", 1)[1]
|
|
if selected_platform and selected_platform != candidate:
|
|
print("error: specify install platform only once", file=sys.stderr)
|
|
sys.exit(1)
|
|
selected_platform = candidate
|
|
i += 1
|
|
elif arg == "--platform":
|
|
if i + 1 >= len(args):
|
|
print("error: --platform requires a value", file=sys.stderr)
|
|
sys.exit(1)
|
|
candidate = args[i + 1]
|
|
if selected_platform and selected_platform != candidate:
|
|
print("error: specify install platform only once", file=sys.stderr)
|
|
sys.exit(1)
|
|
selected_platform = candidate
|
|
i += 2
|
|
elif arg.startswith("-"):
|
|
print(f"error: unknown install option '{arg}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
if selected_platform and selected_platform != arg:
|
|
print("error: specify install platform only once", file=sys.stderr)
|
|
sys.exit(1)
|
|
selected_platform = arg
|
|
i += 1
|
|
chosen_platform = selected_platform or default_platform
|
|
if project_scope:
|
|
_project_install(chosen_platform, Path("."))
|
|
else:
|
|
install(platform=chosen_platform)
|
|
elif cmd == "uninstall":
|
|
args = sys.argv[2:]
|
|
purge = "--purge" in args
|
|
project_scope = "--project" in args
|
|
selected_platform = None
|
|
i = 0
|
|
while i < len(args):
|
|
arg = args[i]
|
|
if arg in ("--purge", "--project"):
|
|
i += 1
|
|
elif arg.startswith("--platform="):
|
|
selected_platform = arg.split("=", 1)[1]
|
|
i += 1
|
|
elif arg == "--platform":
|
|
if i + 1 >= len(args):
|
|
print("error: --platform requires a value", file=sys.stderr)
|
|
sys.exit(1)
|
|
selected_platform = args[i + 1]
|
|
i += 2
|
|
elif arg.startswith("-"):
|
|
print(f"error: unknown uninstall option '{arg}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
selected_platform = arg
|
|
i += 1
|
|
if project_scope:
|
|
if selected_platform:
|
|
_project_uninstall(selected_platform, Path("."))
|
|
else:
|
|
_project_uninstall_all(Path("."))
|
|
else:
|
|
uninstall_all(purge=purge)
|
|
elif cmd == "claude":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_install("claude", Path("."))
|
|
else:
|
|
claude_install()
|
|
elif subcmd == "uninstall":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_uninstall("claude", Path("."))
|
|
else:
|
|
claude_uninstall()
|
|
else:
|
|
print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "codebuddy":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
codebuddy_install()
|
|
elif subcmd == "uninstall":
|
|
codebuddy_uninstall()
|
|
else:
|
|
print("Usage: graphify codebuddy [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "gemini":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
gemini_install(project=("--project" in sys.argv[3:]))
|
|
elif subcmd == "uninstall":
|
|
gemini_uninstall(project=("--project" in sys.argv[3:]))
|
|
else:
|
|
print("Usage: graphify gemini [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "cursor":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
_cursor_install(Path("."))
|
|
elif subcmd == "uninstall":
|
|
_cursor_uninstall(Path("."))
|
|
else:
|
|
print("Usage: graphify cursor [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "vscode":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
vscode_install()
|
|
elif subcmd == "uninstall":
|
|
vscode_uninstall()
|
|
else:
|
|
print("Usage: graphify vscode [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "copilot":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_install("copilot", Path("."))
|
|
else:
|
|
install(platform="copilot")
|
|
elif subcmd == "uninstall":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_uninstall("copilot", Path("."))
|
|
else:
|
|
removed = _remove_skill_file("copilot")
|
|
print("skill removed" if removed else "nothing to remove")
|
|
else:
|
|
print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "kilo":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
_kilo_install(Path("."))
|
|
elif subcmd == "uninstall":
|
|
_kilo_uninstall(Path("."))
|
|
else:
|
|
print("Usage: graphify kilo [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "kiro":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
_kiro_install(Path("."))
|
|
elif subcmd == "uninstall":
|
|
_kiro_uninstall(Path("."))
|
|
else:
|
|
print("Usage: graphify kiro [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "devin":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_install("devin", Path("."))
|
|
else:
|
|
install(platform="devin")
|
|
elif subcmd == "uninstall":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_uninstall("devin", Path("."))
|
|
else:
|
|
removed = _remove_skill_file("devin")
|
|
print("skill removed" if removed else "nothing to remove")
|
|
else:
|
|
print("Usage: graphify devin [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "pi":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_install("pi", Path("."))
|
|
else:
|
|
install("pi")
|
|
elif subcmd == "uninstall":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_uninstall("pi", Path("."))
|
|
else:
|
|
_remove_skill_file("pi")
|
|
else:
|
|
print("Usage: graphify pi [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "amp":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_install("amp", Path("."))
|
|
else:
|
|
_amp_install(Path("."))
|
|
elif subcmd == "uninstall":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_uninstall("amp", Path("."))
|
|
else:
|
|
_amp_uninstall(Path("."))
|
|
else:
|
|
print("Usage: graphify amp [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd in ("agents", "skills"):
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_install("agents", Path("."))
|
|
else:
|
|
_agents_platform_install(Path("."))
|
|
elif subcmd == "uninstall":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_uninstall("agents", Path("."))
|
|
else:
|
|
_agents_platform_uninstall(Path("."))
|
|
else:
|
|
print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_install(cmd, Path("."))
|
|
else:
|
|
_agents_install(Path("."), cmd)
|
|
elif subcmd == "uninstall":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_uninstall(cmd, Path("."))
|
|
else:
|
|
_agents_uninstall(Path("."), platform=cmd)
|
|
if cmd == "codex":
|
|
_uninstall_codex_hook(Path("."))
|
|
else:
|
|
print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif cmd == "antigravity":
|
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
if subcmd == "install":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_install("antigravity", Path("."))
|
|
else:
|
|
_antigravity_install(Path("."))
|
|
elif subcmd == "uninstall":
|
|
if "--project" in sys.argv[3:]:
|
|
_project_uninstall("antigravity", Path("."))
|
|
else:
|
|
_antigravity_uninstall(Path("."))
|
|
else:
|
|
print("Usage: graphify antigravity [install|uninstall]", file=sys.stderr)
|
|
sys.exit(1)
|
|
return True
|