diff --git a/graphify/__main__.py b/graphify/__main__.py index 093539d3..6d8fcc5d 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -160,6 +160,87 @@ Rules: _AGENTS_MD_MARKER = "## graphify" +# 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. +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") { + 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; + } + }, + }; +}; +""" + +_OPENCODE_PLUGIN_PATH = Path(".opencode") / "plugins" / "graphify.js" +_OPENCODE_CONFIG_PATH = Path("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 = str(_OPENCODE_PLUGIN_PATH) + 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 = str(_OPENCODE_PLUGIN_PATH) + 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") + + _CODEX_HOOK = { "hooks": { "PreToolUse": [ @@ -238,11 +319,13 @@ def _agents_install(project_dir: Path, platform: str) -> None: if platform == "codex": _install_codex_hook(project_dir or Path(".")) + elif platform == "opencode": + _install_opencode_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 != "codex": + if platform not in ("codex", "opencode"): 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.") @@ -274,6 +357,8 @@ def _agents_uninstall(project_dir: Path) -> None: target.unlink() print(f"AGENTS.md was empty after removal - deleted {target.resolve()}") + _uninstall_opencode_plugin(project_dir or Path(".")) + def claude_install(project_dir: Path | None = None) -> None: """Write the graphify section to the local CLAUDE.md.""" @@ -402,8 +487,8 @@ def main() -> None: print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook") print(" codex install write graphify section to AGENTS.md (Codex)") print(" codex uninstall remove graphify section from AGENTS.md") - print(" opencode install write graphify section to AGENTS.md (OpenCode)") - print(" opencode uninstall remove graphify section from AGENTS.md") + print(" opencode install write graphify section to AGENTS.md + tool.execute.before plugin (OpenCode)") + print(" opencode uninstall remove graphify section from AGENTS.md + plugin") print(" claw install write graphify section to AGENTS.md (OpenClaw)") print(" claw uninstall remove graphify section from AGENTS.md") print(" droid install write graphify section to AGENTS.md (Factory Droid)") diff --git a/tests/test_install.py b/tests/test_install.py index 7715b4db..04c2cda1 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -181,3 +181,47 @@ def test_agents_uninstall_no_op_when_not_installed(tmp_path, capsys): _agents_uninstall(tmp_path) out = capsys.readouterr().out assert "nothing to do" in out + + +# --- OpenCode plugin tests --- + +def test_opencode_agents_install_writes_plugin(tmp_path): + """opencode install writes .opencode/plugins/graphify.js.""" + _agents_install(tmp_path, "opencode") + plugin = tmp_path / ".opencode" / "plugins" / "graphify.js" + assert plugin.exists() + assert "tool.execute.before" in plugin.read_text() + + +def test_opencode_agents_install_registers_plugin_in_config(tmp_path): + """opencode install registers the plugin in opencode.json.""" + _agents_install(tmp_path, "opencode") + config_file = tmp_path / "opencode.json" + assert config_file.exists() + import json as _json + config = _json.loads(config_file.read_text()) + assert any("graphify.js" in p for p in config.get("plugin", [])) + + +def test_opencode_agents_install_merges_existing_config(tmp_path): + """opencode install preserves existing opencode.json keys.""" + import json as _json + config_file = tmp_path / "opencode.json" + config_file.write_text(_json.dumps({"model": "claude-opus-4-5", "plugin": []})) + _agents_install(tmp_path, "opencode") + config = _json.loads(config_file.read_text()) + assert config["model"] == "claude-opus-4-5" + assert any("graphify.js" in p for p in config["plugin"]) + + +def test_opencode_agents_uninstall_removes_plugin(tmp_path): + """opencode uninstall removes the plugin file and deregisters from opencode.json.""" + import json as _json + _agents_install(tmp_path, "opencode") + _agents_uninstall(tmp_path) + plugin = tmp_path / ".opencode" / "plugins" / "graphify.js" + assert not plugin.exists() + config_file = tmp_path / "opencode.json" + if config_file.exists(): + config = _json.loads(config_file.read_text()) + assert not any("graphify.js" in p for p in config.get("plugin", []))