diff --git a/graphify/install.py b/graphify/install.py index fafa8d208..fb6abb1e6 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -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/. @@ -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 @@ -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. @@ -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: @@ -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 @@ -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 @@ -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/. @@ -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 @@ -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"; @@ -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 @@ -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" @@ -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. // @@ -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 @@ -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: