Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 60 additions & 15 deletions graphify/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,47 @@
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT


def _out_doc(text: str) -> str:
"""Point generated doc/plugin text at the actually configured output dir.

The packaged always-on blocks and hand-written IDE rule/plugin templates
are authored against the default ``graphify-out`` name. When ``GRAPHIFY_OUT``
overrides that (a custom name or an absolute path, #686), the generated
text must say so too — otherwise an agent reading CLAUDE.md/AGENTS.md/a
Cursor rule/etc. is told to look in a directory that was never written to
(#2571).
"""
if _GRAPHIFY_OUT == "graphify-out":
return text
return text.replace("graphify-out", _GRAPHIFY_OUT)


def _mcp_out_path_display() -> str:
"""The graph.json path to show in generated MCP config examples.

Mirrors ``graphify.paths.out_path()``: a relative ``GRAPHIFY_OUT`` is shown
under the workspace root, an absolute override is shown as-is.
"""
if os.path.isabs(_GRAPHIFY_OUT):
return f"{_GRAPHIFY_OUT}/graph.json"
return "${workspace.path}/" + _GRAPHIFY_OUT + "/graph.json"


def _js_graph_exists_expr() -> str:
"""JS ``existsSync(...)`` call for the configured graph.json location.

``GRAPHIFY_OUT`` may be a relative name (resolved under the plugin's
project ``directory``) or an absolute path (used as-is), mirroring
``graphify.paths.out_path()``. Without this, a plugin hardcoded to
``join(directory, "graphify-out", "graph.json")`` never finds the graph
under a custom output dir, so its reminder never fires (#2571).
"""
out = _GRAPHIFY_OUT.replace("\\", "\\\\").replace('"', '\\"')
if os.path.isabs(_GRAPHIFY_OUT):
return f'existsSync(join("{out}", "graph.json"))'
return f'existsSync(join(directory, "{out}", "graph.json"))'


@functools.lru_cache(maxsize=None)
def _always_on(basename: str) -> str:
"""Read a packaged always-on instruction block from graphify/always_on/.
Expand All @@ -40,13 +81,15 @@ def _always_on(basename: str) -> str:
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.
installer injects them via ``_replace_or_append_section``. The packaged
bytes must match the former triple-quoted constant exactly — the
always-on-roundtrip validator proves that — so any non-default
``GRAPHIFY_OUT`` is applied here, after the drift-guarded read, via
``_out_doc``.
"""
path = Path(__file__).parent / "always_on" / f"{basename}.md"
try:
return path.read_text(encoding="utf-8")
text = 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
Expand All @@ -55,6 +98,7 @@ def _always_on(basename: str) -> str:
f"graphify install is incomplete: missing always-on block '{basename}' "
f"at {path}. Reinstall graphifyy (e.g. `uv tool install --reinstall graphifyy`)."
) from exc
return _out_doc(text)
def _refresh_all_version_stamps() -> None:
"""After a successful install, update .graphify_version in all other known skill dirs.

Expand Down Expand Up @@ -1043,7 +1087,8 @@ def _antigravity_install(project_dir: Path) -> None:
print(' "graphify": {')
print(' "command": "uv",')
print(
' "args": ["run", "--with", "graphifyy", "--with", "mcp", "-m", "graphify.serve", "${workspace.path}/graphify-out/graph.json"]'
' "args": ["run", "--with", "graphifyy", "--with", "mcp", "-m", "graphify.serve", '
f'"{_mcp_out_path_display()}"]'
)
print(" }")
def _antigravity_uninstall(project_dir: Path, *, project: bool = False) -> None:
Expand Down Expand Up @@ -1083,7 +1128,7 @@ def _antigravity_uninstall(project_dir: Path, *, project: bool = False) -> None:
except OSError:
break
_CURSOR_RULE_PATH = Path(".cursor") / "rules" / "graphify.mdc"
_CURSOR_RULE = """\
_CURSOR_RULE = _out_doc("""\
---
description: graphify knowledge graph context
alwaysApply: true
Expand All @@ -1105,7 +1150,7 @@ def _antigravity_uninstall(project_dir: Path, *, project: bool = False) -> None:
- 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
Expand All @@ -1132,7 +1177,7 @@ def _cursor_uninstall(project_dir: Path) -> None:
# 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 = """\
_DEVIN_RULES = _out_doc("""\
## graphify

This project has a graphify knowledge graph at graphify-out/.
Expand All @@ -1142,7 +1187,7 @@ def _cursor_uninstall(project_dir: Path) -> None:
- 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
Expand All @@ -1160,7 +1205,7 @@ def _devin_rules_uninstall(project_dir: Path) -> None:
return
rules_path.unlink()
print(f" rules removed -> {rules_path}")
_KILO_PLUGIN_JS = """\
_KILO_PLUGIN_JS = _out_doc("""\
// graphify Kilo plugin
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
import { existsSync } from "fs";
Expand All @@ -1172,7 +1217,7 @@ def _devin_rules_uninstall(project_dir: Path) -> None:
return {
"tool.execute.before": async (input, output) => {
if (reminded) return;
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
if (!@@GRAPH_EXISTS_CHECK@@) return;

if (input.tool === "bash") {
// Separate with ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a
Expand All @@ -1187,7 +1232,7 @@ def _devin_rules_uninstall(project_dir: Path) -> None:
},
};
};
"""
""").replace("@@GRAPH_EXISTS_CHECK@@", _js_graph_exists_expr())
_KILO_PLUGIN_PATH = Path(".kilo") / "plugins" / "graphify.js"
_KILO_CONFIG_JSON_PATH = Path(".kilo") / "kilo.json"
_KILO_CONFIG_JSONC_PATH = Path(".kilo") / "kilo.jsonc"
Expand Down Expand Up @@ -1320,7 +1365,7 @@ def _uninstall_kilo_plugin(project_dir: Path) -> None:
)
# 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 = """\
_OPENCODE_PLUGIN_JS = _out_doc("""\
// graphify OpenCode plugin
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
//
Expand All @@ -1338,7 +1383,7 @@ def _uninstall_kilo_plugin(project_dir: Path) -> None:
return {
"tool.execute.before": async (input, output) => {
if (reminded) return;
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
if (!@@GRAPH_EXISTS_CHECK@@) return;

if (input.tool === "bash") {
// ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a statement
Expand All @@ -1351,7 +1396,7 @@ def _uninstall_kilo_plugin(project_dir: Path) -> None:
},
};
};
"""
""").replace("@@GRAPH_EXISTS_CHECK@@", _js_graph_exists_expr())
_OPENCODE_PLUGIN_PATH = Path(".opencode") / "plugins" / "graphify.js"
_OPENCODE_CONFIG_PATH = Path(".opencode") / "opencode.json"
def _install_opencode_plugin(project_dir: Path) -> None:
Expand Down