fix(opencode): load the plugin on OpenCode 2 and refresh installed copies (#3554, #3732)

`graphify opencode install` wrote a plugin with only a named export, the
OpenCode 1 shape. OpenCode 2 rejects it ("Plugin must export a default
definition with an id and an effect or setup function") and reads the bare
path registered in opencode.json as an npm package name, so it fails twice
on every start.

The plugin is now one default export with `id`, `setup()` for OpenCode 2
and `server()` for OpenCode 1 (1.3.4 and later). The installer no longer
writes the opencode.json entry, because both runtimes discover
.opencode/plugins/ on their own, and it removes the entry older releases
wrote.

OpenCode loads .opencode/plugins/ from the project and from every directory
above it, and OpenCode 2 fails a second plugin with the same id ("Duplicate
plugin ID"). An install in ~ and one in a project are both normal, so the id
carries the file's location and the copies share one record of who was
reminded: both load, and a command still gets a single reminder.

Upgrading graphify does not touch a plugin already on disk. So the CLI now
rewrites an old-shape plugin in the working directory or in the home
directory on its next run, and drops the stale entry there, next to the
stale-skill refresh from #1805. It does so only when the `opencode` on PATH
reports 1.3.4 or later, because older OpenCode 1 releases cannot load the
new plugin and the one they have still works. The rewrite takes the same
per-directory lock as the skill refresh and replaces both files atomically,
because OpenCode watches them. GRAPHIFY_NO_AUTO_REFRESH=1 turns it off.

(cherry picked from commit 0e539e1f7a)
This commit is contained in:
berce authored and safishamsi committed 2026-10-10 01:13:46 +01:00
1 parent effee34d33
commit 652020e9db
4 files changed
+865 -78

No files matched your search

+113
View File
@@ -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'].
+118 -54
View File
@@ -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 "<reminder>" && <cmd>` to the user's bash command;
// backticks inside the double-quoted echo trigger bash command substitution,
// The hook prepends `echo "<reminder>" ; <cmd>` 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.
+224 -24
View File
@@ -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 "<reminder>" && <cmd>` to the user's bash command.
Backticks or $() inside the reminder trigger bash command substitution
The plugin prepends `echo "<reminder>" ; <cmd>` 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):
+410
View File
@@ -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 "<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;
}
},
};
};
'''
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 == []