mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 00:19:33 +00:00
`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:
1 parent
effee34d33
commit
652020e9db
4 files changed
+865
-78
No files matched your search
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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 == []
|
||||
Reference in new issue
Block a user