diff --git a/graphify/__main__.py b/graphify/__main__.py index 1918d44e1..92a06b6b2 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -10,6 +10,7 @@ import os import platform import re import shutil +import subprocess import sys from pathlib import Path @@ -118,6 +119,10 @@ from graphify.install import ( # noqa: E402,F401 _OPENCODE_PLUGIN_JS, _OPENCODE_PLUGIN_PATH, _OPENCODE_CONFIG_PATH, + _OPENCODE_PLUGIN_HEADER, + _drop_opencode_config_entry, + _opencode_config_names_plugin, + _opencode_plugin_is_v1_only, _PLATFORM_CONFIG, _skill_lock, ) @@ -380,6 +385,113 @@ def _refresh_one_skill(name: str, skill_dst: Path, installed: str) -> None: print(line, file=sys.stderr) +def _opencode_loads_current_plugin() -> bool: + """True when the ``opencode`` on PATH can load the plugin graphify writes now. + + That plugin is a default export, which OpenCode reads from 1.3.4 on. Older + OpenCode 1 releases call every export as a function and fail on it, while + the plugin they already have keeps working, so it must be left alone. + Unknown (no ``opencode`` on PATH, no version in its output) counts as no: + the refresh is a convenience, ``graphify opencode install`` still rewrites. + """ + exe = shutil.which("opencode") + if exe is None: + return False + try: + out = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=10).stdout + except (OSError, subprocess.SubprocessError): + return False + found = re.search(r"(\d+)\.(\d+)\.(\d+)", out) + return found is not None and tuple(int(part) for part in found.groups()) >= (1, 3, 4) + + +def _refresh_v1_opencode_plugins() -> None: + """Rewrite OpenCode plugins an older release left behind (#3554, #3732). + + Releases up to 0.9.74 wrote a plugin only OpenCode 1 can load, and an + opencode.json entry OpenCode 2 fails on. Upgrading OpenCode to 2 turns both + into a load error on every start, and upgrading graphify touches neither. + So on a CLI run, replace such a plugin with the current one and drop the + entry. + + Two directories are checked: the working directory, where the install + writes the plugin, and the home directory, where it lands when the install + was run from there (OpenCode then loads it for every project below home). + Only a plugin graphify wrote is touched. The entry is dropped beside a + current plugin too: a checkout or a failed write can bring it back alone. + + An old plugin is replaced only when the installed OpenCode can load the new + one (see _opencode_loads_current_plugin), and under the directory's lock, + without waiting: when another graphify process is already at it, this one + leaves it to them. ``GRAPHIFY_NO_AUTO_REFRESH=1`` opts out, as it does for + skills. + """ + if os.environ.get("GRAPHIFY_NO_AUTO_REFRESH", "").strip().lower() in ("1", "true", "yes"): + return + try: + directories = dict.fromkeys(d.resolve() for d in (Path.cwd(), Path.home())) + except (OSError, RuntimeError): + return + loadable: "bool | None" = None # asked once, and only when an old plugin is found + for directory in directories: + plugin_file = directory / _OPENCODE_PLUGIN_PATH + + def pending() -> "bool | None": + """True for an old plugin, False for a stale entry only, None for neither.""" + try: + body = plugin_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None # no plugin here + if not body.startswith(_OPENCODE_PLUGIN_HEADER): + return None # not graphify's + if _opencode_plugin_is_v1_only(body): + return True + return False if _opencode_config_names_plugin(directory) else None + + stale = pending() + if stale is None: + continue + if stale: + if loadable is None: + loadable = _opencode_loads_current_plugin() + if not loadable: + continue + with _skill_lock(plugin_file.parent, wait=False) as owned: + # Look again under the lock: a process that held it may have finished the job. + stale = pending() if owned else None + if stale is None: + continue + captured = io.StringIO() + try: + with contextlib.redirect_stdout(captured): + if stale: + _install_opencode_plugin(directory) + dropped = False + else: + dropped = _drop_opencode_config_entry(directory) + except Exception as exc: + # A refresh must never take the user's actual command down with it. + print(f"graphify: could not refresh {plugin_file}: {exc}", file=sys.stderr) + continue + if stale: + print( + f"graphify: refreshed {plugin_file} so OpenCode 2 can load it; " + f"set GRAPHIFY_NO_AUTO_REFRESH=1 to disable", + file=sys.stderr, + ) + # Of the install's own lines, only the one asking for a manual edit matters here. + for line in captured.getvalue().splitlines(): + if "by hand" in line: + print(line, file=sys.stderr) + elif dropped: + print( + f"graphify: removed the stale graphify entry from " + f"{directory / _OPENCODE_CONFIG_PATH}; set GRAPHIFY_NO_AUTO_REFRESH=1 to disable", + file=sys.stderr, + ) + + + @@ -724,6 +836,7 @@ def _run_cli() -> None: # refresh them first, so the check below only fires for copies the # refresh could not or must not touch (#1805). _refresh_stale_skills() + _refresh_v1_opencode_plugins() # Resolve each platform's real user-scope destination so per-platform # overrides (gemini, opencode, devin, antigravity, amp) check the dir # they actually install into, not the bare cfg['skill_dst']. diff --git a/graphify/install.py b/graphify/install.py index 36a4484c8..921463283 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -43,6 +43,7 @@ except Exception: from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT from graphify.paths import os_replace_with_fallback as _os_replace_with_fallback +from graphify.paths import write_text_atomic as _write_text_atomic def _skill_lock_path(skill_dir: Path) -> Path: @@ -1557,65 +1558,141 @@ def _uninstall_kilo_plugin(project_dir: Path) -> None: 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. +# Injects a graph reminder into shell 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. +// Injects a knowledge graph reminder before the first shell command of a +// session when the graph exists. +// +// One file for both runtimes. OpenCode 2 reads `id` and `setup()` from the +// default export; OpenCode 1 (1.3.4 and later) reads `server()` from it. Each +// ignores the other's function. Nothing is imported from an opencode package: +// a plain file in .opencode/plugins/ cannot resolve one. // // IMPORTANT: keep the reminder string free of backticks and $(...) constructs. -// The hook prepends `echo "" && ` to the user's bash command; -// backticks inside the double-quoted echo trigger bash command substitution, +// The hook prepends `echo "" ; ` to the user's shell command; +// backticks inside the double-quoted echo trigger 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; +// ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a statement +// separator, breaking the first shell command of the session (#1646). +const REMINDER = + '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." ; '; - return { - "tool.execute.before": async (input, output) => { - if (reminded) return; - if (!existsSync(join(directory, "graphify-out", "graph.json"))) return; +const hasGraph = (directory) => existsSync(join(directory, "graphify-out", "graph.json")); - 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; - } - }, - }; +// A project and a directory above it (often ~) can each hold a copy of this +// file, and OpenCode loads both. OpenCode 2 fails the second plugin with the +// same id ("Duplicate plugin ID"), so the id carries this file's location. The +// copies share one record of who was reminded, so a command gets one reminder. +const location = [...import.meta.url].reduce((hash, ch) => (Math.imul(hash, 31) + ch.charCodeAt(0)) >>> 0, 7); +const reminded = (globalThis.__graphifyReminded ??= new Set()); + +export default { + id: `graphify-${location.toString(16)}`, + + // OpenCode 2: the command tool is "shell", and one server outlives many + // sessions, so the reminder is tracked per session. + async setup(ctx) { + await ctx.tool.hook("execute.before", (event) => { + if (event.tool !== "shell" || typeof event.input?.command !== "string") return; + if (reminded.has(event.sessionID)) return; + if (!hasGraph(ctx.location.directory)) return; + + event.input = { ...event.input, command: REMINDER + event.input.command }; + reminded.add(event.sessionID); + }); + }, + + // OpenCode 1: the command tool is "bash", and the server lives as long as + // the session, so the reminder is tracked per project directory. + async server({ directory }) { + return { + "tool.execute.before": async (input, output) => { + if (input.tool !== "bash" || typeof output.args?.command !== "string") return; + if (reminded.has(directory)) return; + if (!hasGraph(directory)) return; + + output.args.command = REMINDER + output.args.command; + reminded.add(directory); + }, + }; + }, }; """ _OPENCODE_PLUGIN_PATH = Path(".opencode") / "plugins" / "graphify.js" _OPENCODE_CONFIG_PATH = Path(".opencode") / "opencode.json" +# First line of every plugin graphify has written, back to the first one (#71). +_OPENCODE_PLUGIN_HEADER = "// graphify OpenCode plugin" +def _opencode_plugin_is_v1_only(body: str) -> bool: + """True for a graphify plugin written before the OpenCode 2 entrypoint. + + Releases up to 0.9.74 exported a named ``GraphifyPlugin`` function and + nothing else. OpenCode 2 needs a default export, so it rejects the file + with "Plugin must export a default definition" (#3554, #3732). + """ + return body.startswith(_OPENCODE_PLUGIN_HEADER) and "export default" not in body +def _opencode_config_names_plugin(project_dir: Path) -> bool: + """True when opencode.json still mentions the bare plugin path.""" + try: + raw = (project_dir / _OPENCODE_CONFIG_PATH).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return False + return f'"{_OPENCODE_PLUGIN_PATH.as_posix()}"' in raw +def _drop_opencode_config_entry(project_dir: Path) -> bool: + """Remove the plugin entry older releases wrote into opencode.json. + + The entry was the bare path ``.opencode/plugins/graphify.js``. OpenCode + loads every file in ``.opencode/plugins/`` on its own, so the entry was + never needed, and OpenCode 2 reads a bare path as an npm package name and + fails to install it (#3732). Returns True when the config changed. + """ + config_file = project_dir / _OPENCODE_CONFIG_PATH + entry = _OPENCODE_PLUGIN_PATH.as_posix() + try: + raw = config_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return False + try: + config = json.loads(raw) + except ValueError: + # OpenCode accepts comments in this file, json does not. Rewriting it + # would drop them, so say what to remove instead. + if f'"{entry}"' in raw: + print( + f" {_OPENCODE_CONFIG_PATH} -> not plain JSON, left as is; " + f'remove "{entry}" from its plugin list by hand' + ) + return False + if not isinstance(config, dict): + return False + changed = False + for key in ("plugin", "plugins"): + plugins = config.get(key) + if isinstance(plugins, list) and entry in plugins: + config[key] = [p for p in plugins if p != entry] + if not config[key]: + config.pop(key) + changed = True + if changed: + # ensure_ascii=False: keep the user's non-ASCII values readable. + _write_text_atomic(config_file, json.dumps(config, indent=2, ensure_ascii=False)) + return changed def _install_opencode_plugin(project_dir: Path) -> None: - """Write graphify.js plugin and register it in opencode.json.""" + """Write graphify.js into .opencode/plugins/, where OpenCode discovers it. + + Both files are replaced atomically. The CLI refresh can run this while + OpenCode is watching them, and it must never see half a plugin. + """ 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") + _write_text_atomic(plugin_file, _OPENCODE_PLUGIN_JS) 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)") + if _drop_opencode_config_entry(project_dir): + print(f" {_OPENCODE_CONFIG_PATH} -> stale plugin entry removed") def _uninstall_opencode_plugin(project_dir: Path) -> None: """Remove graphify.js plugin and deregister from opencode.json.""" plugin_file = project_dir / _OPENCODE_PLUGIN_PATH @@ -1623,20 +1700,7 @@ def _uninstall_opencode_plugin(project_dir: Path) -> None: 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") + if _drop_opencode_config_entry(project_dir): print(f" {_OPENCODE_CONFIG_PATH} -> plugin deregistered") def _resolve_graphify_exe(project: bool = False) -> str: """Return the absolute path to the graphify executable, with forward slashes. diff --git a/tests/test_install.py b/tests/test_install.py index d8b6c418c..6ecd893f3 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -986,21 +986,22 @@ def test_opencode_agents_install_writes_plugin(tmp_path): def test_opencode_plugin_reminder_has_no_backticks(tmp_path): """The bash reminder string must not contain backticks or $(...) (regression test for #1413). - The plugin prepends `echo "" && ` to the user's bash command. - Backticks or $() inside the reminder trigger bash command substitution + The plugin prepends `echo "" ; ` to the user's shell command. + Backticks or $() inside the reminder trigger command substitution when the echo runs, which both corrupts tool output and silently executes the very graphify command we are only suggesting. """ _agents_install(tmp_path, "opencode") plugin = tmp_path / ".opencode" / "plugins" / "graphify.js" body = plugin.read_text() - # Extract the echoed reminder string literal between the double-quotes - # of the `output.args.command = 'echo "..." && ' +` line. + # Extract the echoed reminder between the double-quotes of the REMINDER + # string literal (the leading quote keeps the header comment out of it). import re - m = re.search(r'echo "([^"]*)"', body) + m = re.search(r"'echo \"([^\"]*)\"", body) assert m, "echo reminder not found in plugin body" reminder = m.group(1) + assert reminder.startswith("[graphify]") assert "`" not in reminder, f"backtick in reminder would trigger command substitution: {reminder!r}" assert "$(" not in reminder, f"$() in reminder would trigger command substitution: {reminder!r}" @@ -1012,47 +1013,246 @@ def test_opencode_plugin_uses_semicolon_not_ampersand(tmp_path): in PowerShell 5.1, Bash, and POSIX shells.""" _agents_install(tmp_path, "opencode") body = (tmp_path / ".opencode" / "plugins" / "graphify.js").read_text() - # The prepend line ends with the separator before `' +`. - assert '" ; \' +' in body or '." ; \' +' in body, "reminder should join with ';'" - assert '" && \' +' not in body, "'&&' breaks PowerShell 5.1 (#1646)" + # The REMINDER literal ends with the separator. + assert '." ; \';' in body, "reminder should join with ';'" + assert '" && ' not in body, "'&&' breaks PowerShell 5.1 (#1646)" -def test_opencode_agents_install_registers_plugin_in_config(tmp_path): - """opencode install registers the plugin in .opencode/opencode.json.""" +def test_opencode_plugin_default_export_serves_both_runtimes(tmp_path): + """OpenCode 2 rejects a plugin without a default export that has `id` and + `setup()` (#3554). OpenCode 1 reads `server()` from that same export and + throws when an export with an `id` lacks it. A plain file in + .opencode/plugins/ cannot resolve an opencode package either.""" _agents_install(tmp_path, "opencode") - config_file = tmp_path / ".opencode" / "opencode.json" - assert config_file.exists() + body = (tmp_path / ".opencode" / "plugins" / "graphify.js").read_text() + assert "export default {" in body + assert "id: `graphify-${" in body + assert "async setup(ctx)" in body + assert "async server(" in body + assert '"@opencode' not in body, "no import from an opencode package" + + +def _run_opencode_plugin_harness(tmp_path, body): + """Run `body` (JavaScript) with the installed plugin imported as `plugin`, + a second copy of the file at another path as `other`, and the helpers + `v2(plugin, directory)` / `v1(plugin, directory)` that drive a plugin the + way OpenCode 2 and OpenCode 1 do. Returns what `body` returns, as JSON.""" import json as _json + import shutil + import subprocess - config = _json.loads(config_file.read_text()) - assert any("graphify.js" in p for p in config.get("plugin", [])) + node = shutil.which("node") + if node is None: + pytest.skip("node not available") + + _agents_install(tmp_path, "opencode") + (tmp_path / "graphify-out").mkdir() + (tmp_path / "graphify-out" / "graph.json").write_text("{}") + (tmp_path / "empty").mkdir() + installed = (tmp_path / ".opencode" / "plugins" / "graphify.js").read_text() + # .mjs so node treats the copies as ES modules whatever its version. + first = tmp_path / "project.mjs" + second = tmp_path / "home.mjs" + first.write_text(installed) + second.write_text(installed) + harness = tmp_path / "harness.mjs" + harness.write_text( + f""" +import * as mod from {_json.dumps(first.as_uri())}; +import * as otherMod from {_json.dumps(second.as_uri())}; +const plugin = mod.default; +const other = otherMod.default; +const graph = {_json.dumps(str(tmp_path))}; +const empty = {_json.dumps(str(tmp_path / "empty"))}; + +const v2 = async (plugin, directory) => {{ + let hook; + const registered = []; + await plugin.setup({{ + location: {{ directory }}, + tool: {{ hook: async (name, fn) => {{ registered.push(name); hook = fn; }} }}, + }}); + const fire = (event) => {{ hook(event); return event.input.command; }}; + const run = (tool, sessionID, command) => fire({{ tool, sessionID, input: {{ command }} }}); + return {{ registered, run, fire }}; +}}; +const v1 = async (plugin, directory) => {{ + const hooks = await plugin.server({{ directory }}); + const fire = async (tool, output) => {{ await hooks["tool.execute.before"]({{ tool }}, output); return output.args.command; }}; + return Object.assign((tool, command) => fire(tool, {{ args: {{ command }} }}), {{ fire }}); +}}; + +const result = await (async () => {{ +{body} +}})(); +console.log(JSON.stringify({{ exports: Object.keys(mod), ...result }})); +""" + ) + result = subprocess.run([node, str(harness)], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + return _json.loads(result.stdout) -def test_opencode_agents_install_merges_existing_config(tmp_path): - """opencode install preserves existing .opencode/opencode.json keys.""" +def _opencode_reminded(command): + return command.startswith('echo "[graphify] ') and command.endswith('" ; ls') + + +def test_opencode_plugin_prepends_the_reminder_on_opencode_2(tmp_path): + """OpenCode 2 calls `setup(ctx)`; the command tool is "shell" and the + reminder goes out once per session.""" + seen = _run_opencode_plugin_harness(tmp_path, """ + const two = await v2(plugin, graph); + return { + registered: two.registered, + commands: [ + two.run("read", "a", "ls"), + two.run("shell", "a", "ls"), + two.run("shell", "a", "ls"), + two.run("shell", "b", "ls"), + ], + no_command: two.run("shell", "c") ?? null, + no_graph: (await v2(plugin, empty)).run("shell", "d", "ls"), + }; +""") + assert seen["exports"] == ["default"] + assert seen["registered"] == ["execute.before"] + untouched, first, again, other_session = seen["commands"] + assert untouched == "ls" and again == "ls" + assert _opencode_reminded(first) and _opencode_reminded(other_session) + assert seen["no_command"] is None + assert seen["no_graph"] == "ls" + + +def test_opencode_plugin_prepends_the_reminder_on_opencode_1(tmp_path): + """OpenCode 1 calls `server()`; the command tool is "bash" and the reminder + goes out once per project directory.""" + seen = _run_opencode_plugin_harness(tmp_path, """ + const one = await v1(plugin, graph); + const bare = { args: {} }; + await one.fire("bash", bare); + return { + no_command: "command" in bare.args, + commands: [await one("read", "ls"), await one("bash", "ls"), await one("bash", "ls")], + no_graph: await (await v1(plugin, empty))("bash", "ls"), + }; +""") + assert seen["no_command"] is False + untouched, first, again = seen["commands"] + assert untouched == "ls" and again == "ls" + assert _opencode_reminded(first) + assert seen["no_graph"] == "ls" + + +def test_opencode_plugin_copies_in_two_directories_do_not_collide(tmp_path): + """`graphify opencode install` run in ~ and again in a project leaves two + copies that OpenCode loads together. OpenCode 2 fails the second plugin + with the same id ("Duplicate plugin ID"), so each copy needs its own id, + and together they must still add the reminder only once.""" + seen = _run_opencode_plugin_harness(tmp_path, """ + const [a, b] = [await v2(plugin, graph), await v2(other, graph)]; + const event = { tool: "shell", sessionID: "s", input: { command: "ls" } }; + a.fire(event); + b.fire(event); + const [c, d] = [await v1(plugin, graph), await v1(other, graph)]; + const output = { args: { command: "ls" } }; + await c.fire("bash", output); + await d.fire("bash", output); + return { ids: [plugin.id, other.id], v2: event.input.command, v1: output.args.command }; +""") + first, second = seen["ids"] + assert first != second + assert first.startswith("graphify-") and second.startswith("graphify-") + assert _opencode_reminded(seen["v2"]) and seen["v2"].count("[graphify]") == 1 + assert _opencode_reminded(seen["v1"]) and seen["v1"].count("[graphify]") == 1 + + +def test_opencode_agents_install_does_not_register_plugin_in_config(tmp_path): + """opencode install leaves opencode.json alone. OpenCode loads every file in + .opencode/plugins/ on its own, and OpenCode 2 reads the bare path older + releases registered as an npm package name and fails to install it (#3732).""" + _agents_install(tmp_path, "opencode") + assert not (tmp_path / ".opencode" / "opencode.json").exists() + + +def test_opencode_agents_install_keeps_existing_config(tmp_path): + """opencode install preserves an existing .opencode/opencode.json.""" import json as _json config_file = tmp_path / ".opencode" / "opencode.json" config_file.parent.mkdir(parents=True, exist_ok=True) 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"]) + assert _json.loads(config_file.read_text()) == {"model": "claude-opus-4-5", "plugin": []} + + +@pytest.mark.parametrize("key", ["plugin", "plugins"]) +def test_opencode_agents_install_drops_the_entry_an_older_release_wrote(tmp_path, key): + """The bare-path entry goes, under the OpenCode 1 key or the OpenCode 2 one; + other plugins and other keys stay.""" + import json as _json + + config_file = tmp_path / ".opencode" / "opencode.json" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(_json.dumps({ + "model": "claude-opus-4-5", + key: [".opencode/plugins/graphify.js", "opencode-other-plugin"], + })) + _agents_install(tmp_path, "opencode") + assert _json.loads(config_file.read_text()) == { + "model": "claude-opus-4-5", + key: ["opencode-other-plugin"], + } + + +def test_opencode_agents_install_leaves_unreadable_config_alone(tmp_path, capsys): + """A config that is not plain JSON (OpenCode accepts comments) is not + rewritten, which would drop the comments. The user is told what to remove.""" + config_file = tmp_path / ".opencode" / "opencode.json" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text('{ // my settings\n "plugin": [".opencode/plugins/graphify.js"]\n}') + before = config_file.read_text() + _agents_install(tmp_path, "opencode") + assert config_file.read_text() == before + out = capsys.readouterr().out + assert 'remove ".opencode/plugins/graphify.js" from its plugin list by hand' in out + + +@pytest.mark.parametrize("body", ['[".opencode/plugins/graphify.js"]', '{"plugin": ".opencode/plugins/graphify.js"}']) +def test_opencode_agents_install_leaves_an_unexpected_config_shape_alone(tmp_path, body): + config_file = tmp_path / ".opencode" / "opencode.json" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(body) + _agents_install(tmp_path, "opencode") + assert config_file.read_text() == body + + +def test_opencode_config_rewrite_keeps_non_ascii_values_readable(tmp_path): + import json as _json + + config_file = tmp_path / ".opencode" / "opencode.json" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + _json.dumps({"username": "Zoë", "plugin": [".opencode/plugins/graphify.js"]}, ensure_ascii=False), + encoding="utf-8", + ) + _agents_install(tmp_path, "opencode") + text = config_file.read_text(encoding="utf-8") + assert _json.loads(text) == {"username": "Zoë"} + assert "Zoë" in text def test_opencode_agents_uninstall_removes_plugin(tmp_path): - """opencode uninstall removes the plugin file and deregisters from opencode.json.""" + """opencode uninstall removes the plugin file and the opencode.json entry + an older release registered it with.""" import json as _json _agents_install(tmp_path, "opencode") + config_file = tmp_path / ".opencode" / "opencode.json" + config_file.write_text(_json.dumps({"plugin": [".opencode/plugins/graphify.js"]})) _agents_uninstall(tmp_path, platform="opencode") plugin = tmp_path / ".opencode" / "plugins" / "graphify.js" assert not plugin.exists() - config_file = tmp_path / ".opencode" / "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", [])) + assert _json.loads(config_file.read_text()) == {} def test_kilo_agents_install_writes_agents_md(tmp_path): diff --git a/tests/test_opencode_plugin_refresh.py b/tests/test_opencode_plugin_refresh.py new file mode 100644 index 000000000..98affd3b1 --- /dev/null +++ b/tests/test_opencode_plugin_refresh.py @@ -0,0 +1,410 @@ +"""#3554 / #3732: an OpenCode upgrade must not leave a dead graphify plugin behind. + +Until 0.9.74 `graphify opencode install` wrote a plugin with only a named +export, the OpenCode 1 shape. OpenCode 2 refuses it ("Plugin must export a +default definition ...") and also fails on the opencode.json entry written next +to it. Upgrading graphify did not touch either file, so the user had to know to +run the install again in every project. The CLI now rewrites such a plugin on +its next run. +""" +from __future__ import annotations + +import contextlib +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +import graphify.__main__ as mainmod +from graphify.install import __version__, _OPENCODE_PLUGIN_JS + +# The plugin exactly as 0.9.74 wrote it. +V1_PLUGIN = '''\ +// 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 "" && ` 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; + } + }, + }; +}; +''' +ENTRY = ".opencode/plugins/graphify.js" + + +# getattr: on a checkout without the fix the tests fail one by one, not at import. +_real_version_check = getattr(mainmod, "_opencode_loads_current_plugin", None) + + +@pytest.fixture(autouse=True) +def _refresh_enabled(monkeypatch): + """Refresh on, and an OpenCode that can load the current plugin, unless a + test says otherwise. No test runs whatever `opencode` is on the real PATH.""" + monkeypatch.delenv("GRAPHIFY_NO_AUTO_REFRESH", raising=False) + monkeypatch.setattr(mainmod, "_opencode_loads_current_plugin", lambda: True, raising=False) + + +def _old_install(directory: Path, body: str = V1_PLUGIN, entry: bool = True) -> tuple[Path, Path]: + """Lay down the plugin and the config entry as an older release left them.""" + plugin = directory / ".opencode" / "plugins" / "graphify.js" + plugin.parent.mkdir(parents=True, exist_ok=True) + plugin.write_text(body, encoding="utf-8") + config = directory / ".opencode" / "opencode.json" + settings = {"model": "some/model", **({"plugin": [ENTRY]} if entry else {})} + config.write_text(json.dumps(settings), encoding="utf-8") + return plugin, config + + +def test_the_old_plugin_is_what_opencode_2_rejects(): + """Guard the fixture: the 0.9.74 file has no default export, and both + files open with the line the refresh recognises graphify's plugin by.""" + assert "export default" not in V1_PLUGIN + assert "export default" in _OPENCODE_PLUGIN_JS + assert V1_PLUGIN.startswith(mainmod._OPENCODE_PLUGIN_HEADER) + assert _OPENCODE_PLUGIN_JS.startswith(mainmod._OPENCODE_PLUGIN_HEADER) + + +def test_a_v1_plugin_in_the_working_directory_is_rewritten(tmp_path, monkeypatch, capsys): + plugin, config = _old_install(tmp_path) + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS + assert json.loads(config.read_text(encoding="utf-8")) == {"model": "some/model"} + out, err = capsys.readouterr() + assert out == "", "stdout stays clean for --json and the MCP stdio server" + assert f"graphify: refreshed {plugin.resolve()}" in err + assert "GRAPHIFY_NO_AUTO_REFRESH=1" in err + + +def test_a_v1_plugin_in_the_home_directory_is_rewritten(tmp_path, monkeypatch, capsys): + """`graphify opencode install` run from ~ puts the plugin in ~/.opencode, + and OpenCode loads that for every project below home.""" + plugin, config = _old_install(Path.home()) + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS + assert "plugin" not in json.loads(config.read_text(encoding="utf-8")) + assert "graphify: refreshed" in capsys.readouterr().err + + +def test_a_second_run_is_silent(tmp_path, monkeypatch, capsys): + _old_install(tmp_path) + monkeypatch.chdir(tmp_path) + mainmod._refresh_v1_opencode_plugins() + capsys.readouterr() + + mainmod._refresh_v1_opencode_plugins() + + assert capsys.readouterr().err == "" + + +def test_running_from_home_checks_it_once(monkeypatch, capsys): + plugin, _config = _old_install(Path.home()) + monkeypatch.chdir(Path.home()) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS + assert capsys.readouterr().err.count("graphify: refreshed") == 1 + + +def test_a_stale_entry_beside_a_current_plugin_is_dropped(tmp_path, monkeypatch, capsys): + """The entry can come back without the old plugin: a checkout that restores + opencode.json, or an earlier run that wrote the plugin and then failed.""" + plugin, config = _old_install(tmp_path, _OPENCODE_PLUGIN_JS) + stamp = plugin.stat().st_mtime_ns + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.stat().st_mtime_ns == stamp + assert json.loads(config.read_text(encoding="utf-8")) == {"model": "some/model"} + out, err = capsys.readouterr() + assert out == "" + assert f"graphify: removed the stale graphify entry from {config.resolve()}" in err + + mainmod._refresh_v1_opencode_plugins() + assert capsys.readouterr().err == "" + + +def test_a_config_with_comments_gets_the_hint_once(tmp_path, monkeypatch, capsys): + plugin, config = _old_install(tmp_path) + commented = '{ // mine\n "plugin": ["' + ENTRY + '"]\n}' + config.write_text(commented, encoding="utf-8") + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS + assert config.read_text(encoding="utf-8") == commented + out, err = capsys.readouterr() + assert out == "" + assert "graphify: refreshed" in err + assert f'remove "{ENTRY}" from its plugin list by hand' in err + + mainmod._refresh_v1_opencode_plugins() + assert capsys.readouterr().err == "", "no reminder on every later run" + + +def test_a_current_plugin_is_untouched_and_silent(tmp_path, monkeypatch, capsys): + plugin, config = _old_install(tmp_path, _OPENCODE_PLUGIN_JS, entry=False) + before = config.read_text(encoding="utf-8") + stamp = plugin.stat().st_mtime_ns + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.stat().st_mtime_ns == stamp + assert config.read_text(encoding="utf-8") == before + assert capsys.readouterr().err == "" + + +def test_a_plugin_graphify_did_not_write_is_left_alone(tmp_path, monkeypatch, capsys): + mine = "// my own plugin\nexport const Mine = async () => ({});\n" + plugin, config = _old_install(tmp_path, mine) + before = config.read_text(encoding="utf-8") + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == mine + assert config.read_text(encoding="utf-8") == before + assert capsys.readouterr().err == "" + + +def test_no_plugin_means_nothing_is_created(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() + + assert not (tmp_path / ".opencode").exists() + assert not (Path.home() / ".opencode").exists() + assert capsys.readouterr().err == "" + + +@pytest.mark.parametrize("value", ["1", "true", "YES"]) +def test_the_env_opt_out_disables_it(tmp_path, monkeypatch, capsys, value): + plugin, _config = _old_install(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GRAPHIFY_NO_AUTO_REFRESH", value) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == V1_PLUGIN + assert capsys.readouterr().err == "" + + +def test_a_failed_rewrite_never_breaks_the_command(tmp_path, monkeypatch, capsys): + plugin, _config = _old_install(tmp_path) + monkeypatch.chdir(tmp_path) + + def boom(_directory): + raise PermissionError("read-only checkout") + + monkeypatch.setattr(mainmod, "_install_opencode_plugin", boom) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == V1_PLUGIN + err = capsys.readouterr().err + assert f"graphify: could not refresh {plugin.resolve()}: read-only checkout" in err + assert "graphify: refreshed" not in err + + +def test_an_interrupted_rewrite_leaves_the_old_plugin_whole(tmp_path, monkeypatch, capsys): + """OpenCode watches the plugin file, so it must go from the old content to + the new one in a single rename, never through a half-written file.""" + import graphify.paths as pathsmod + + plugin, _config = _old_install(tmp_path) + monkeypatch.chdir(tmp_path) + + def killed(_tmp, _dst): + raise OSError("disk full") + + monkeypatch.setattr(pathsmod, "os_replace_with_fallback", killed) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == V1_PLUGIN + assert [p.name for p in plugin.parent.iterdir()] == ["graphify.js"], "no temp file left behind" + assert "graphify: could not refresh" in capsys.readouterr().err + + +def test_a_symlinked_plugin_is_rewritten_through_the_link(tmp_path, monkeypatch, requires_symlinks): + shared = tmp_path / "dotfiles" / "graphify.js" + shared.parent.mkdir() + shared.write_text(V1_PLUGIN, encoding="utf-8") + project = tmp_path / "project" + plugin = project / ".opencode" / "plugins" / "graphify.js" + plugin.parent.mkdir(parents=True) + plugin.symlink_to(shared) + monkeypatch.chdir(project) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.is_symlink() + assert shared.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS + + +def test_an_opencode_too_old_for_the_new_plugin_keeps_its_plugin(tmp_path, monkeypatch, capsys): + """OpenCode 1 before 1.3.4 calls every export as a function. The plugin it + has works there and the new one would not, so the refresh leaves it.""" + plugin, config = _old_install(tmp_path) + before = config.read_text(encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(mainmod, "_opencode_loads_current_plugin", lambda: False) + + mainmod._refresh_v1_opencode_plugins() + + assert plugin.read_text(encoding="utf-8") == V1_PLUGIN + assert config.read_text(encoding="utf-8") == before + assert capsys.readouterr().err == "" + + +@pytest.mark.parametrize("output, loads", [ + ("1.3.3\n", False), + ("1.3.4\n", True), + ("1.18.34\n", True), + ("opencode v2.0.22\n", True), + ("0.15.31\n", False), + ("", False), + ("command not understood\n", False), +]) +def test_the_installed_opencode_version_decides(monkeypatch, output, loads): + calls = [] + + def run(argv, **kwargs): + calls.append((argv, kwargs.get("timeout"))) + return subprocess.CompletedProcess(argv, 0, stdout=output, stderr="") + + monkeypatch.setattr(mainmod.shutil, "which", lambda name: f"/opt/bin/{name}") + monkeypatch.setattr(mainmod.subprocess, "run", run) + + assert _real_version_check() is loads + assert calls == [(["/opt/bin/opencode", "--version"], 10)] + + +@pytest.mark.parametrize("failure", [OSError("gone"), subprocess.TimeoutExpired("opencode", 10)]) +def test_an_opencode_that_cannot_be_asked_counts_as_too_old(monkeypatch, failure): + def run(*_a, **_k): + raise failure + + monkeypatch.setattr(mainmod.shutil, "which", lambda name: f"/opt/bin/{name}") + monkeypatch.setattr(mainmod.subprocess, "run", run) + assert _real_version_check() is False + + monkeypatch.setattr(mainmod.shutil, "which", lambda name: None) + assert _real_version_check() is False + + +def test_opencode_is_only_asked_when_an_old_plugin_is_found(tmp_path, monkeypatch, capsys): + def asked(): + raise AssertionError("no old plugin here, the version does not matter") + + monkeypatch.setattr(mainmod, "_opencode_loads_current_plugin", asked) + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() # no plugin at all + _plugin, config = _old_install(tmp_path, _OPENCODE_PLUGIN_JS) + mainmod._refresh_v1_opencode_plugins() # current plugin, stale entry + + assert json.loads(config.read_text(encoding="utf-8")) == {"model": "some/model"} + assert "removed the stale graphify entry" in capsys.readouterr().err + + +@pytest.mark.parametrize("held", [False, None]) +def test_a_directory_another_process_is_refreshing_is_skipped(tmp_path, monkeypatch, capsys, held): + """Same rule as the skill refresh: without the lock (another graphify + process has it, or locking is unavailable) leave the directory alone.""" + plugin, config = _old_install(tmp_path) + before = config.read_text(encoding="utf-8") + monkeypatch.chdir(tmp_path) + locked = [] + + @contextlib.contextmanager + def lock(directory, *, wait): + locked.append((directory, wait)) + yield held + + monkeypatch.setattr(mainmod, "_skill_lock", lock) + + mainmod._refresh_v1_opencode_plugins() + + assert locked == [(plugin.resolve().parent, False)] + assert plugin.read_text(encoding="utf-8") == V1_PLUGIN + assert config.read_text(encoding="utf-8") == before + assert capsys.readouterr().err == "" + + +def test_no_lock_file_is_made_where_there_is_nothing_to_refresh(tmp_path, monkeypatch): + _old_install(tmp_path, _OPENCODE_PLUGIN_JS, entry=False) + monkeypatch.chdir(tmp_path) + + mainmod._refresh_v1_opencode_plugins() + + assert not (Path.home() / ".cache" / "graphify").exists() + + +def test_the_first_cli_run_after_an_upgrade_rewrites_it(tmp_path, monkeypatch, capsys): + plugin, _config = _old_install(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["graphify", "--version"]) + + mainmod._run_cli() + + out, err = capsys.readouterr() + assert out.strip() == f"graphify {__version__}" + assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS + assert "graphify: refreshed" in err + + +@pytest.mark.parametrize("cmd", ["install", "uninstall", "hook-check", "hook-guard"]) +def test_install_and_hook_commands_skip_it(monkeypatch, cmd): + calls = [] + monkeypatch.setattr(mainmod, "_refresh_v1_opencode_plugins", lambda: calls.append(cmd)) + monkeypatch.setattr(mainmod, "_refresh_stale_skills", lambda: None) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda *_a, **_k: None) + monkeypatch.setattr(sys, "argv", ["graphify", cmd, "--help"]) + + class _Stop(Exception): + pass + + def stop(*_a, **_k): + raise _Stop + + monkeypatch.setattr(mainmod, "dispatch_install_cli", stop) + monkeypatch.setattr(mainmod, "dispatch_command", stop) + try: + mainmod._run_cli() + except (_Stop, SystemExit): + pass + assert calls == []