diff --git a/.gitignore b/.gitignore
index 864cf32..16bcee0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ venv/
/env/
build/
dist/
+!/plugin/opencode/dist/
+!/plugin/opencode/dist/**
# Node
node_modules/
@@ -51,4 +53,4 @@ benchmarks/memory_comparison/results
# Local design docs / brainstorm specs (kept out of public repo)
docs/superpowers/
-marketing/
\ No newline at end of file
+marketing/
diff --git a/Makefile b/Makefile
index 517ac30..9a9713f 100644
--- a/Makefile
+++ b/Makefile
@@ -23,7 +23,7 @@
VERSION_FILES := package.json plugin/pyproject.toml \
plugin/.claude-plugin/plugin.json plugin/.codex-plugin/plugin.json \
.claude-plugin/marketplace.json README.md
-LOCK_FILES := plugin/uv.lock reflexio.lock.json
+LOCK_FILES := package-lock.json plugin/uv.lock reflexio.lock.json
PYPROJECT := plugin/pyproject.toml
help:
@@ -93,6 +93,7 @@ bump: check-version unskip-worktree ## Rewrite version in all release manifests
package.json plugin/.claude-plugin/plugin.json plugin/.codex-plugin/plugin.json \
.claude-plugin/marketplace.json
@sed -i.bak -E 's/^version = "[^"]+"/version = "$(VERSION)"/' plugin/pyproject.toml
+ @python3 -c 'import json, pathlib; p=pathlib.Path("package-lock.json"); data=json.loads(p.read_text()); data["version"]="$(VERSION)"; data["packages"][""]["version"]="$(VERSION)"; p.write_text(json.dumps(data, indent=2) + "\n")'
@sed -i.bak -E 's|badge/version-[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?-green\.svg|badge/version-$(VERSION)-green.svg|' README.md
@rm -f package.json.bak plugin/pyproject.toml.bak \
plugin/.claude-plugin/plugin.json.bak plugin/.codex-plugin/plugin.json.bak \
@@ -116,6 +117,7 @@ bump: check-version unskip-worktree ## Rewrite version in all release manifests
publish-npm: check-vendor-reflexio check-locked-project-version ## Publish the current version to npm
@echo "→ npm publish"
+ npm run build:opencode
npm publish --access public
publish-pypi: check-pypi-compatible-reflexio unskip-worktree ## Build and publish the current version to PyPI
@@ -126,6 +128,7 @@ publish-pypi: check-pypi-compatible-reflexio unskip-worktree ## Build and publis
publish-dry: unskip-worktree check-vendor-reflexio check-locked-project-version ## Show what would be published without uploading
@echo "→ npm publish --dry-run"
+ @npm run build:opencode
@npm publish --dry-run
@echo ""
@echo "→ uv build (dry: inspect plugin/dist/ manually)"
@@ -135,6 +138,7 @@ publish-dry: unskip-worktree check-vendor-reflexio check-locked-project-version
package: check-vendor-reflexio check-locked-project-version ## Build the npm tarball locally without publishing
@echo "→ npm pack"
+ @npm run build:opencode
@tarball=$$(npm pack 2>/dev/null | tail -1); \
abs=$$(cd "$$(dirname "$$tarball")" && pwd)/$$(basename "$$tarball"); \
echo ""; \
@@ -143,8 +147,10 @@ package: check-vendor-reflexio check-locked-project-version ## Build the npm tar
echo "Install locally with one of:"; \
echo " npm install -g $$abs && claude-smart install"; \
echo " npm install -g $$abs && claude-smart install --host codex"; \
+ echo " npm install -g $$abs && claude-smart install --host opencode"; \
echo " npx --package=$$abs -- claude-smart install"; \
- echo " npx --package=$$abs -- claude-smart install --host codex"
+ echo " npx --package=$$abs -- claude-smart install --host codex"; \
+ echo " npx --package=$$abs -- claude-smart install --host opencode"
publish: check-pypi-compatible-reflexio publish-npm publish-pypi ## Publish to both npm and PyPI
diff --git a/README.md b/README.md
index 96519e0..b4a41f8 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
claude-smart
-
A self-improvement plugin for Claude Code and Codex that turns interactions into durable skills they follow in future sessions.
+A self-improvement plugin for Claude Code, Codex, and OpenCode that turns interactions into durable skills they follow in future sessions.
@@ -22,7 +22,7 @@
-
+
@@ -98,6 +98,24 @@ npx claude-smart uninstall --host codex
Restart Codex after uninstalling. The uninstaller stops local claude-smart services and removes plugin/cache/config state; learned data under `~/.reflexio/` and `~/.claude-smart/` is preserved and shared with Claude Code, so you can switch between hosts without losing skills or preferences.
+### OpenCode
+
+```bash
+npx claude-smart install --host opencode
+```
+
+Then restart OpenCode in your project so it loads the plugin from `opencode.json`, the documented project config file. If that project does not already have a root config but does have `.opencode/opencode.json` or `.opencode/opencode.jsonc`, the installer updates that existing file instead of creating a second config. If both locations exist, the root config wins. Use `--global` to install into `~/.config/opencode/opencode.json` for all OpenCode projects on this machine. When run through `npx`, long-lived local services are prepared from OpenCode's installed plugin package on the next OpenCode launch instead of from npm's temporary `npx` cache.
+
+OpenCode support is new and uses OpenCode's npm plugin loader to inject relevant learned context before each model request. Learning extraction runs `opencode run --pure` from an isolated temp project, so it uses OpenCode's default model unless you set `CLAUDE_SMART_OPENCODE_MODEL=provider/model`. Set that env var if your normal project config pins a different provider or model.
+
+To uninstall:
+
+```bash
+npx claude-smart uninstall --host opencode
+```
+
+Restart OpenCode after uninstalling. The uninstaller removes only the `claude-smart` entry from OpenCode's plugin list; learned data under `~/.reflexio/` and `~/.claude-smart/` is preserved and shared across hosts.
+
Developing the plugin itself? See [DEVELOPER.md](./DEVELOPER.md#developing-locally) for what the installer does, manual toggles via `/plugins`, and clone-based development.
> **Not supported:** Claude Code Cowork, claude.ai/code web, or remote Codex environments without local plugin hooks — they run outside your local machine, so the local backend/dashboard and `~/.reflexio/` aren't reachable.
@@ -163,7 +181,7 @@ A web UI for browsing session histories, inspecting preferences, and editing pro
## How It Works
-claude-smart builds three artifacts as you work and injects the relevant ones into Claude Code or Codex:
+claude-smart builds three artifacts as you work and injects the relevant ones into Claude Code, Codex, or OpenCode:
- **Preferences** (project-scoped) — how you work in this specific repo (stack, role, small quirks). *e.g.* "uses pnpm, not npm"; "prefers terse answers"; "backend engineer — explain frontend with backend analogues."
- **Project-specific skills** — durable rules with triggers and rationales learned from corrections in a project. *e.g.* "always pass `--run` to `npm test` — watch mode hangs CI."
@@ -216,6 +234,7 @@ Advanced users can tune claude-smart via environment variables — see [DEVELOPE
| `.claude/settings.local.json` or `~/.claude/settings.json` | Claude Code hook environment, such as `CLAUDE_SMART_ENABLE_OPTIMIZER`; use project-local settings for one repo or user settings for all projects. |
| `~/.codex/config.toml` | Codex plugin state, hook feature flags, and per-hook trust entries after `claude-smart install --host codex`. |
| `~/.codex/plugins/cache/reflexioai/claude-smart//` | Codex's cached install of the `claude-smart` plugin from the `ReflexioAI` marketplace. |
+| `opencode.json` / `opencode.jsonc`, or existing `.opencode/opencode.json*` | OpenCode local plugin config patched by `claude-smart install --host opencode`; the installer updates only the claude-smart entry in OpenCode's plugin list. |
| `~/.reflexio/plugin-root` | Self-healed symlink to the active plugin dir (managed by `ensure-plugin-root.sh` — written on install, refreshed each `SessionStart`). Claude Code slash commands and Codex shell-command helpers resolve through it, so don't delete it; if you do, the next session will recreate it. |
| `~/.claude-smart/sessions/{session_id}.jsonl` | Per-session buffer. User turns, assistant turns, tool invocations, `{"published_up_to": N}` watermarks. Safe to inspect and safe to delete — everything past the latest watermark has already been written to reflexio's DB. |
| `~/.claude-smart/node/current/` | Private Node.js/npm runtime used by hooks and the dashboard after install. |
@@ -229,8 +248,6 @@ For troubleshooting, see [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).
claude-smart is powered by [**Reflexio**](https://reflexio.ai) — the self-improving engine that turns interactions into durable, reusable skills.
-> **Building your own agent?** Want the same self-improvement loop — learning from corrections, optimizing proven paths, and growing a skill library — inside *your* product or agent? Get in touch at [**reflexio.ai**](https://reflexio.ai).
-
---
## License
@@ -248,4 +265,4 @@ See the [LICENSE](LICENSE) file for details.
---
-**Powered by** [Reflexio](https://reflexio.ai) · **Runs on** [Claude Code](https://claude.com/claude-code) and Codex · **Written in** Python 3.12+
+**Powered by** [Reflexio](https://reflexio.ai) · **Runs on** [Claude Code](https://claude.com/claude-code), Codex, and OpenCode · **Written in** Python 3.12+
diff --git a/bin/claude-smart.js b/bin/claude-smart.js
index 3e8ba62..af16d33 100755
--- a/bin/claude-smart.js
+++ b/bin/claude-smart.js
@@ -17,8 +17,10 @@ const { execSync, spawn, spawnSync } = require("child_process");
const crypto = require("crypto");
const {
chmodSync,
+ constants,
cpSync,
existsSync,
+ accessSync,
lstatSync,
mkdirSync,
readFileSync,
@@ -37,6 +39,8 @@ const PLUGIN_SPEC = "claude-smart@reflexioai";
const CODEX_MARKETPLACE_NAME = "reflexioai";
const CODEX_MARKETPLACE_DISPLAY_NAME = "ReflexioAI";
const CODEX_PLUGIN_ID = `claude-smart@${CODEX_MARKETPLACE_NAME}`;
+const OPENCODE_PLUGIN_SPEC = "claude-smart";
+const OPENCODE_CONFIG_NAMES = ["opencode.json", "opencode.jsonc"];
const REFLEXIO_ENV_PATH = join(homedir(), ".reflexio", ".env");
const MANAGED_REFLEXIO_URL = "https://www.reflexio.ai/";
const MANAGED_SETUP_ENV = "CLAUDE_SMART_MANAGED_SETUP";
@@ -79,6 +83,9 @@ const CODEX_REQUIRED_FILES = [
"plugin/scripts/codex-claude-compat",
"plugin/scripts/codex-claude-compat.cmd",
"plugin/scripts/codex-claude-compat.js",
+ "plugin/scripts/opencode-claude-compat",
+ "plugin/scripts/opencode-claude-compat.cmd",
+ "plugin/scripts/opencode-claude-compat.js",
"plugin/scripts/codex-hook.js",
"plugin/scripts/_codex_env.sh",
];
@@ -342,6 +349,170 @@ function configureReflexioSetup() {
return loadReflexioSetupEnv();
}
+function stripJsonc(text) {
+ let out = "";
+ let inString = false;
+ let escaped = false;
+ const skipTrivia = (index) => {
+ while (index < text.length) {
+ while (index < text.length && /\s/.test(text[index])) index += 1;
+ if (text[index] === "/" && text[index + 1] === "/") {
+ index += 2;
+ while (index < text.length && !"\r\n".includes(text[index])) index += 1;
+ continue;
+ }
+ if (text[index] === "/" && text[index + 1] === "*") {
+ index += 2;
+ while (index + 1 < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1;
+ index = Math.min(index + 2, text.length);
+ continue;
+ }
+ break;
+ }
+ return index;
+ };
+ for (let i = 0; i < text.length; i += 1) {
+ const ch = text[i];
+ const next = text[i + 1] || "";
+ if (inString) {
+ out += ch;
+ if (escaped) escaped = false;
+ else if (ch === "\\") escaped = true;
+ else if (ch === '"') inString = false;
+ continue;
+ }
+ if (ch === '"') {
+ inString = true;
+ out += ch;
+ continue;
+ }
+ if (ch === "/" && next === "/") {
+ while (i < text.length && !"\r\n".includes(text[i])) i += 1;
+ i -= 1;
+ continue;
+ }
+ if (ch === "/" && next === "*") {
+ i += 2;
+ while (i + 1 < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
+ i += 1;
+ continue;
+ }
+ if (ch === ",") {
+ const j = skipTrivia(i + 1);
+ if (text[j] === "}" || text[j] === "]") continue;
+ }
+ out += ch;
+ }
+ return out;
+}
+
+function readJsoncObject(path) {
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "{}";
+ const parsed = JSON.parse(stripJsonc(text || "{}"));
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new Error(`OpenCode config ${path} must be a JSON object`);
+ }
+ return parsed;
+}
+
+function parseGlobalFlag(args) {
+ return args.includes("--global");
+}
+
+function opencodeGlobalConfigDir() {
+ const xdg = (process.env.XDG_CONFIG_HOME || "").trim();
+ const base = xdg ? xdg : join(homedir(), ".config");
+ return join(base, "opencode");
+}
+
+function opencodeConfigPath(args = [], cwd = process.cwd()) {
+ if (parseGlobalFlag(args)) {
+ const configDir = opencodeGlobalConfigDir();
+ for (const name of OPENCODE_CONFIG_NAMES) {
+ const candidate = join(configDir, name);
+ if (existsSync(candidate)) return candidate;
+ }
+ return join(configDir, "opencode.json");
+ }
+ const candidates = [
+ ...OPENCODE_CONFIG_NAMES.map((name) => join(cwd, name)),
+ ...OPENCODE_CONFIG_NAMES.map((name) => join(cwd, ".opencode", name)),
+ ];
+ for (const candidate of candidates) {
+ if (existsSync(candidate)) return candidate;
+ }
+ return join(cwd, "opencode.json");
+}
+
+function opencodePluginSpec(entry) {
+ if (typeof entry === "string") return entry;
+ if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
+ return null;
+}
+
+function patchOpenCodePluginConfig(configPath, { install }) {
+ const data = readJsoncObject(configPath);
+ for (const field of ["plugins", "plugin"]) {
+ if (data[field] !== undefined && !Array.isArray(data[field])) {
+ throw new Error(`OpenCode config ${configPath} field "${field}" must be a JSON array`);
+ }
+ }
+ const current = [
+ ...(Array.isArray(data.plugin) ? data.plugin : []),
+ ...(Array.isArray(data.plugins) ? data.plugins : []),
+ ];
+ const kept = current.filter((entry) => opencodePluginSpec(entry) !== OPENCODE_PLUGIN_SPEC);
+ const next = install ? [...kept, OPENCODE_PLUGIN_SPEC] : kept;
+ const changed =
+ data.plugins !== undefined ||
+ (Array.isArray(data.plugin)
+ ? next.length !== data.plugin.length || next.some((entry, index) => entry !== data.plugin[index])
+ : install && next.length > 0);
+ if (!changed) return { changed: false, configPath };
+ let backupPath = null;
+ if (existsSync(configPath)) {
+ const original = readFileSync(configPath, "utf8");
+ if (original.trim() && original !== stripJsonc(original)) {
+ backupPath = `${configPath}.bak`;
+ writeFileSync(backupPath, original);
+ }
+ }
+ data.plugin = next;
+ delete data.plugins;
+ mkdirSync(dirname(configPath), { recursive: true });
+ writeFileSync(configPath, JSON.stringify(data, null, 2) + "\n");
+ return { changed: true, configPath, backupPath };
+}
+
+function isNpxPackageRoot(path) {
+ return path.split(/[\\/]+/).includes("_npx");
+}
+
+function hasExtractionProvider() {
+ if ((process.env.REFLEXIO_API_KEY || "").trim()) return true;
+ const cliPath = (process.env.CLAUDE_SMART_CLI_PATH || "").trim();
+ if (cliPath && isExecutableFile(cliPath)) return true;
+ return hasCli("claude") || hasCli("codex") || hasCli("opencode");
+}
+
+function isExecutableFile(path) {
+ try {
+ if (!statSync(path).isFile()) return false;
+ accessSync(path, constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function extractionProviderError() {
+ return (
+ "error: OpenCode support needs a working learning/extraction provider.\n" +
+ "Run `npx claude-smart setup` to configure Reflexio, or install " +
+ "OpenCode, Claude Code, or Codex so local extraction can use a supported CLI.\n"
+ );
+}
+
function findClaudeCodePluginRoot() {
const cacheRoot = join(homedir(), ".claude", "plugins", "cache", CODEX_MARKETPLACE_NAME, "claude-smart");
const candidates = [];
@@ -925,7 +1096,9 @@ async function bootstrapPluginRuntime(pluginRoot, options = {}) {
assertSupportedRuntimePlatform();
process.stdout.write("Preparing claude-smart runtime for hooks...\n");
const nodeRuntime = await ensurePrivateNode();
- patchCodexHooksForNode(pluginRoot, nodeRuntime.node);
+ if (options.patchCodexHooks !== false) {
+ patchCodexHooksForNode(pluginRoot, nodeRuntime.node);
+ }
if (options.readOnly) prunePublishHooksForReadOnly(pluginRoot);
ensurePluginRoot(pluginRoot);
const uv = await ensureUv();
@@ -970,13 +1143,15 @@ async function bootstrapPluginRuntime(pluginRoot, options = {}) {
function printHelp() {
process.stdout.write(
[
- "claude-smart — install helper for Claude Code and Codex",
+ "claude-smart — install helper for Claude Code, Codex, and OpenCode",
"",
"Usage:",
" npx claude-smart install Install the plugin into Claude Code",
" npx claude-smart install --host codex Register the plugin marketplace for Codex",
+ " npx claude-smart install --host opencode Add claude-smart to OpenCode config",
" npx claude-smart setup Configure managed/read-only/global setup",
" npx claude-smart uninstall --host codex Remove the Codex marketplace registration",
+ " npx claude-smart uninstall --host opencode Remove claude-smart from OpenCode config",
" npx claude-smart --help Show this help",
"",
"Claude Code install:",
@@ -993,9 +1168,15 @@ function printHelp() {
" 6. Trusts and enables claude-smart hook entries in ~/.codex/config.toml",
" 7. Restart Codex.",
"",
+ "OpenCode install:",
+ " 1. Adds \"claude-smart\" to OpenCode's plugin list in opencode.json",
+ " 2. Prepares local services now for stable installs; npx prepares on next OpenCode launch",
+ " 3. Restart OpenCode.",
+ "",
"Update:",
" npx claude-smart update Reinstall Claude Code support from this package",
" npx claude-smart update --host codex Reinstall Codex support from this package",
+ " npx claude-smart update --host opencode Reinstall OpenCode support from this package",
" npx claude-smart setup Configure managed/read-only/global setup",
"",
"Uninstall:",
@@ -1010,11 +1191,11 @@ function parseHost(args) {
if (idx === -1) return "claude-code";
const value = args[idx + 1];
if (!value) {
- process.stderr.write("error: --host requires a value: claude-code or codex\n");
+ process.stderr.write("error: --host requires a value: claude-code, codex, or opencode\n");
process.exit(1);
}
- if (value !== "claude-code" && value !== "codex") {
- process.stderr.write("error: --host must be claude-code or codex\n");
+ if (value !== "claude-code" && value !== "codex" && value !== "opencode") {
+ process.stderr.write("error: --host must be claude-code, codex, or opencode\n");
process.exit(1);
}
return value;
@@ -1433,6 +1614,10 @@ async function runUpdate(args) {
await runUpdateCodex(args);
return;
}
+ if (parseHost(args) === "opencode") {
+ await runUpdateOpenCode(args);
+ return;
+ }
const pluginRoot = findClaudeCodePluginRoot();
if (pluginRoot) {
@@ -1449,11 +1634,22 @@ async function runUpdateCodex(args) {
await runInstallCodex(args);
}
+async function runUpdateOpenCode(args) {
+ const pluginRoot = join(PACKAGE_ROOT, "plugin");
+ stopClaudeSmartServices(pluginRoot);
+ process.stdout.write("Updating claude-smart OpenCode support by reinstalling from this package...\n");
+ await runInstallOpenCode(args);
+}
+
async function runUninstall(args) {
if (parseHost(args) === "codex") {
await runUninstallCodex();
return;
}
+ if (parseHost(args) === "opencode") {
+ await runUninstallOpenCode(args);
+ return;
+ }
if (!hasClaudeCli()) {
process.stderr.write(
@@ -1504,6 +1700,10 @@ async function runInstall(args, options = {}) {
await runInstallCodex(args);
return;
}
+ if (parseHost(args) === "opencode") {
+ await runInstallOpenCode(args);
+ return;
+ }
if (!hasClaudeCli()) {
process.stderr.write(
@@ -1686,6 +1886,66 @@ async function runInstallCodex(args) {
);
}
+async function runInstallOpenCode(args) {
+ const setup = configureReflexioSetup();
+ const readOnly = setup.readOnly;
+ if (!hasExtractionProvider()) {
+ process.stderr.write(extractionProviderError());
+ process.exit(1);
+ }
+
+ const pluginRoot = join(PACKAGE_ROOT, "plugin");
+ let result;
+ if (isNpxPackageRoot(PACKAGE_ROOT)) {
+ try {
+ result = patchOpenCodePluginConfig(opencodeConfigPath(args), { install: true });
+ } catch (err) {
+ process.stderr.write(`error: could not update OpenCode config: ${err && err.message ? err.message : err}\n`);
+ process.exit(1);
+ }
+ process.stdout.write(
+ "Skipped starting claude-smart services from npx's temporary package cache; OpenCode will prepare the runtime from its installed plugin package on next launch.\n",
+ );
+ } else {
+ try {
+ await bootstrapPluginRuntime(pluginRoot, { readOnly, patchCodexHooks: false });
+ } catch (err) {
+ process.stderr.write(
+ `error: claude-smart OpenCode setup failed during dependency bootstrap: ${err && err.message ? err.message : err}\n`,
+ );
+ process.exit(1);
+ }
+ try {
+ result = patchOpenCodePluginConfig(opencodeConfigPath(args), { install: true });
+ } catch (err) {
+ process.stderr.write(`error: could not update OpenCode config: ${err && err.message ? err.message : err}\n`);
+ stopClaudeSmartServices(pluginRoot);
+ process.exit(1);
+ }
+ if (readOnly) {
+ process.stdout.write("Installed read-only hook manifest; publish interactions hooks are disabled.\n");
+ }
+ if (startBackendService(pluginRoot, "opencode")) {
+ process.stdout.write("Started claude-smart backend service.\n");
+ }
+ if (refreshDashboardService(pluginRoot)) {
+ process.stdout.write("Refreshed claude-smart dashboard service.\n");
+ }
+ }
+ if (result.backupPath) {
+ process.stdout.write(`Saved a comment-preserving backup of your previous config at ${result.backupPath}.\n`);
+ }
+ process.stdout.write(
+ [
+ "",
+ `${result.changed ? "Updated" : "OpenCode config already includes"} "${OPENCODE_PLUGIN_SPEC}" in ${result.configPath}.`,
+ "claude-smart OpenCode support is installed.",
+ "Restart OpenCode in your project so it loads the plugin.",
+ "",
+ ].join("\n"),
+ );
+}
+
async function runUninstallCodex() {
stopClaudeSmartServices(join(PACKAGE_ROOT, "plugin"));
if (!hasCli("codex")) {
@@ -1713,6 +1973,31 @@ async function runUninstallCodex() {
);
}
+async function runUninstallOpenCode(args) {
+ stopClaudeSmartServices(join(PACKAGE_ROOT, "plugin"));
+ let result;
+ try {
+ result = patchOpenCodePluginConfig(opencodeConfigPath(args), { install: false });
+ } catch (err) {
+ process.stderr.write(`error: could not update OpenCode config: ${err && err.message ? err.message : err}\n`);
+ process.exit(1);
+ }
+ if (result.backupPath) {
+ process.stdout.write(`Saved a comment-preserving backup of your previous config at ${result.backupPath}.\n`);
+ }
+ process.stdout.write(
+ [
+ "",
+ result.changed
+ ? `Removed "${OPENCODE_PLUGIN_SPEC}" from ${result.configPath}.`
+ : `OpenCode config did not include "${OPENCODE_PLUGIN_SPEC}".`,
+ "Restart OpenCode to apply.",
+ ...LOCAL_DATA_NOTICE,
+ "",
+ ].join("\n"),
+ );
+}
+
async function main() {
const args = process.argv.slice(2);
const cmd = args[0] || "install";
@@ -1764,7 +2049,12 @@ module.exports = {
ensureUv,
configureReflexioSetup,
patchCodexHooksForNode,
+ opencodeConfigPath,
+ isNpxPackageRoot,
+ patchOpenCodePluginConfig,
+ hasExtractionProvider,
platformSupportError,
prunePublishHooksForReadOnly,
restorePublishHooksFromSource,
+ stripJsonc,
};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..cab66e0
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,55 @@
+{
+ "name": "claude-smart",
+ "version": "0.2.45",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "claude-smart",
+ "version": "0.2.45",
+ "license": "Apache-2.0",
+ "bin": {
+ "claude-smart": "bin/claude-smart.js"
+ },
+ "devDependencies": {
+ "@types/node": "^20.19.0",
+ "typescript": "^5.8.3"
+ },
+ "engines": {
+ "node": ">=20.9.0",
+ "opencode": ">=1.17.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
index 4181438..0f81239 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,15 @@
{
"name": "claude-smart",
"version": "0.2.45",
- "description": "Self-improving Claude Code and Codex plugin that turns corrections into Preferences, Project-specific skills, and Shared skills",
+ "description": "Self-improving Claude Code, Codex, and OpenCode plugin that turns corrections into Preferences, Project-specific skills, and Shared skills",
"keywords": [
"claude",
"claude-code",
"claude-code-plugin",
"codex",
"codex-plugin",
+ "opencode",
+ "opencode-plugin",
"memory",
"agent-memory",
"plugin",
@@ -28,6 +30,18 @@
"license": "Apache-2.0",
"author": "Yi Lu",
"type": "commonjs",
+ "oc-plugin": [
+ "server"
+ ],
+ "exports": {
+ "./server": {
+ "import": "./plugin/opencode/dist/server.mjs"
+ }
+ },
+ "scripts": {
+ "build:opencode": "tsc -p plugin/opencode/tsconfig.json",
+ "prepack": "npm run build:opencode"
+ },
"bin": {
"claude-smart": "bin/claude-smart.js"
},
@@ -49,6 +63,11 @@
"LICENSE"
],
"engines": {
- "node": ">=20.9.0"
+ "node": ">=20.9.0",
+ "opencode": ">=1.17.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.19.0",
+ "typescript": "^5.8.3"
}
}
diff --git a/plugin/opencode/assistant-buffer.ts b/plugin/opencode/assistant-buffer.ts
new file mode 100644
index 0000000..29f1353
--- /dev/null
+++ b/plugin/opencode/assistant-buffer.ts
@@ -0,0 +1,108 @@
+import { eventProperties, type EventLike, sessionIDFrom } from "./internal.js"
+
+type PartLike = {
+ id?: string
+ type?: string
+ text?: string
+ messageID?: string
+}
+
+type SessionText = {
+ messageID?: string
+ parts: Map
+ textPartIDs: Set
+ ignoredPartIDs: Set
+}
+
+function textFromPart(part: unknown): { id: string; text: string; messageID?: string } | undefined {
+ if (!part || typeof part !== "object") return undefined
+ const item = part as PartLike
+ if (item.type !== "text") return undefined
+ if (typeof item.text !== "string") return undefined
+ return {
+ id: item.id || `${item.messageID || "message"}:${item.type}`,
+ text: item.text,
+ messageID: item.messageID,
+ }
+}
+
+function partIDFrom(part: unknown): string | undefined {
+ if (!part || typeof part !== "object") return undefined
+ const item = part as PartLike
+ return item.id || (item.messageID && item.type ? `${item.messageID}:${item.type}` : undefined)
+}
+
+function emptySession(messageID?: string): SessionText {
+ return { messageID, parts: new Map(), textPartIDs: new Set(), ignoredPartIDs: new Set() }
+}
+
+export class AssistantBuffer {
+ private readonly sessions = new Map()
+
+ update(event: EventLike): void {
+ const type = event.type
+ const properties = eventProperties(event)
+ const sessionID = sessionIDFrom(properties)
+ if (!sessionID || !type) return
+
+ if (type === "message.updated") {
+ const info = properties.info
+ if (!info || typeof info !== "object") return
+ const message = info as { id?: string; role?: string }
+ if (message.role !== "assistant") return
+ const current = this.sessions.get(sessionID)
+ if (!current || current.messageID !== message.id) {
+ this.sessions.set(sessionID, emptySession(message.id))
+ }
+ return
+ }
+
+ if (type === "message.part.updated") {
+ const hit = textFromPart(properties.part)
+ if (!hit) {
+ const ignoredID = partIDFrom(properties.part)
+ if (!ignoredID) return
+ const current: SessionText = this.sessions.get(sessionID) ?? emptySession()
+ current.ignoredPartIDs.add(ignoredID)
+ current.textPartIDs.delete(ignoredID)
+ current.parts.delete(ignoredID)
+ this.sessions.set(sessionID, current)
+ return
+ }
+ const current: SessionText = this.sessions.get(sessionID) ?? emptySession()
+ if (hit.messageID && current.messageID && hit.messageID !== current.messageID) {
+ current.messageID = hit.messageID
+ current.parts.clear()
+ current.textPartIDs.clear()
+ current.ignoredPartIDs.clear()
+ } else if (hit.messageID) {
+ current.messageID = hit.messageID
+ }
+ current.ignoredPartIDs.delete(hit.id)
+ current.textPartIDs.add(hit.id)
+ current.parts.set(hit.id, hit.text)
+ this.sessions.set(sessionID, current)
+ return
+ }
+
+ if (type === "message.part.delta") {
+ const partID = typeof properties.partID === "string" ? properties.partID : undefined
+ const delta = typeof properties.delta === "string" ? properties.delta : undefined
+ if (!partID || !delta) return
+ const current: SessionText = this.sessions.get(sessionID) ?? emptySession()
+ if (current.ignoredPartIDs.has(partID) || !current.textPartIDs.has(partID)) return
+ current.parts.set(partID, `${current.parts.get(partID) || ""}${delta}`)
+ this.sessions.set(sessionID, current)
+ }
+ }
+
+ text(sessionID: string): string {
+ const current = this.sessions.get(sessionID)
+ if (!current) return ""
+ return Array.from(current.parts.values()).filter(Boolean).join("\n\n")
+ }
+
+ clear(sessionID: string): void {
+ this.sessions.delete(sessionID)
+ }
+}
diff --git a/plugin/opencode/dist/assistant-buffer.js b/plugin/opencode/dist/assistant-buffer.js
new file mode 100644
index 0000000..a4da35f
--- /dev/null
+++ b/plugin/opencode/dist/assistant-buffer.js
@@ -0,0 +1,96 @@
+import { eventProperties, sessionIDFrom } from "./internal.js";
+function textFromPart(part) {
+ if (!part || typeof part !== "object")
+ return undefined;
+ const item = part;
+ if (item.type !== "text")
+ return undefined;
+ if (typeof item.text !== "string")
+ return undefined;
+ return {
+ id: item.id || `${item.messageID || "message"}:${item.type}`,
+ text: item.text,
+ messageID: item.messageID,
+ };
+}
+function partIDFrom(part) {
+ if (!part || typeof part !== "object")
+ return undefined;
+ const item = part;
+ return item.id || (item.messageID && item.type ? `${item.messageID}:${item.type}` : undefined);
+}
+function emptySession(messageID) {
+ return { messageID, parts: new Map(), textPartIDs: new Set(), ignoredPartIDs: new Set() };
+}
+export class AssistantBuffer {
+ sessions = new Map();
+ update(event) {
+ const type = event.type;
+ const properties = eventProperties(event);
+ const sessionID = sessionIDFrom(properties);
+ if (!sessionID || !type)
+ return;
+ if (type === "message.updated") {
+ const info = properties.info;
+ if (!info || typeof info !== "object")
+ return;
+ const message = info;
+ if (message.role !== "assistant")
+ return;
+ const current = this.sessions.get(sessionID);
+ if (!current || current.messageID !== message.id) {
+ this.sessions.set(sessionID, emptySession(message.id));
+ }
+ return;
+ }
+ if (type === "message.part.updated") {
+ const hit = textFromPart(properties.part);
+ if (!hit) {
+ const ignoredID = partIDFrom(properties.part);
+ if (!ignoredID)
+ return;
+ const current = this.sessions.get(sessionID) ?? emptySession();
+ current.ignoredPartIDs.add(ignoredID);
+ current.textPartIDs.delete(ignoredID);
+ current.parts.delete(ignoredID);
+ this.sessions.set(sessionID, current);
+ return;
+ }
+ const current = this.sessions.get(sessionID) ?? emptySession();
+ if (hit.messageID && current.messageID && hit.messageID !== current.messageID) {
+ current.messageID = hit.messageID;
+ current.parts.clear();
+ current.textPartIDs.clear();
+ current.ignoredPartIDs.clear();
+ }
+ else if (hit.messageID) {
+ current.messageID = hit.messageID;
+ }
+ current.ignoredPartIDs.delete(hit.id);
+ current.textPartIDs.add(hit.id);
+ current.parts.set(hit.id, hit.text);
+ this.sessions.set(sessionID, current);
+ return;
+ }
+ if (type === "message.part.delta") {
+ const partID = typeof properties.partID === "string" ? properties.partID : undefined;
+ const delta = typeof properties.delta === "string" ? properties.delta : undefined;
+ if (!partID || !delta)
+ return;
+ const current = this.sessions.get(sessionID) ?? emptySession();
+ if (current.ignoredPartIDs.has(partID) || !current.textPartIDs.has(partID))
+ return;
+ current.parts.set(partID, `${current.parts.get(partID) || ""}${delta}`);
+ this.sessions.set(sessionID, current);
+ }
+ }
+ text(sessionID) {
+ const current = this.sessions.get(sessionID);
+ if (!current)
+ return "";
+ return Array.from(current.parts.values()).filter(Boolean).join("\n\n");
+ }
+ clear(sessionID) {
+ this.sessions.delete(sessionID);
+ }
+}
diff --git a/plugin/opencode/dist/internal.js b/plugin/opencode/dist/internal.js
new file mode 100644
index 0000000..a9b811b
--- /dev/null
+++ b/plugin/opencode/dist/internal.js
@@ -0,0 +1,12 @@
+export function isRecord(value) {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+export function eventProperties(event) {
+ return isRecord(event.properties) ? event.properties : event;
+}
+export function sessionIDFrom(value) {
+ if (!isRecord(value))
+ return "";
+ const raw = value.sessionID ?? value.session_id;
+ return typeof raw === "string" ? raw : "";
+}
diff --git a/plugin/opencode/dist/payload.js b/plugin/opencode/dist/payload.js
new file mode 100644
index 0000000..f9dee12
--- /dev/null
+++ b/plugin/opencode/dist/payload.js
@@ -0,0 +1,85 @@
+import { eventProperties, isRecord, sessionIDFrom } from "./internal.js";
+function textFromParts(parts) {
+ if (!Array.isArray(parts))
+ return "";
+ return parts
+ .map((part) => (part && part.type === "text" && typeof part.text === "string" ? part.text : ""))
+ .filter(Boolean)
+ .join("\n\n");
+}
+export function eventPayload(event, cwd) {
+ const properties = eventProperties(event);
+ const info = isRecord(properties.info) ? properties.info : {};
+ return {
+ session_id: sessionIDFrom(properties) || sessionIDFrom(info),
+ cwd: typeof info.directory === "string" ? info.directory : cwd,
+ };
+}
+export function chatMessagePayload(input, output, cwd) {
+ const message = isRecord(output.message) ? output.message : {};
+ const prompt = textFromParts(output.parts) ||
+ (typeof message.content === "string" ? message.content : "") ||
+ textFromParts(message.parts);
+ return {
+ session_id: sessionIDFrom(input),
+ cwd,
+ prompt,
+ };
+}
+export function normalizeToolName(tool) {
+ const lowered = tool.toLowerCase();
+ if (lowered === "edit")
+ return "Edit";
+ if (lowered === "write")
+ return "Write";
+ if (lowered === "apply_patch")
+ return "apply_patch";
+ if (["bash", "shell", "terminal", "exec", "command"].includes(lowered))
+ return "Bash";
+ return tool;
+}
+export function normalizeToolInput(tool, args) {
+ if (!isRecord(args))
+ return {};
+ const out = { ...args };
+ const copy = (from, to) => {
+ if (from in args && !(to in out))
+ out[to] = args[from];
+ };
+ copy("filePath", "file_path");
+ copy("oldString", "old_string");
+ copy("newString", "new_string");
+ copy("patchText", "command");
+ if (normalizeToolName(tool) === "Bash") {
+ copy("cmd", "command");
+ copy("script", "command");
+ }
+ return out;
+}
+export function toolAfterPayload(input, output, cwd) {
+ const tool = typeof input.tool === "string" ? input.tool : "";
+ const text = typeof output.output === "string" ? output.output : "";
+ const response = {
+ output: text,
+ stdout: text,
+ };
+ if (typeof output.title === "string")
+ response.title = output.title;
+ if (isRecord(output.metadata))
+ response.metadata = output.metadata;
+ if (isRecord(output.metadata) && output.metadata.error)
+ response.error = output.metadata.error;
+ return {
+ session_id: sessionIDFrom(input),
+ cwd,
+ tool_name: normalizeToolName(tool),
+ tool_input: normalizeToolInput(tool, input.args),
+ tool_response: response,
+ };
+}
+export function stopPayload(event, cwd, lastAssistantMessage) {
+ return {
+ ...eventPayload(event, cwd),
+ last_assistant_message: lastAssistantMessage,
+ };
+}
diff --git a/plugin/opencode/dist/server.mjs b/plugin/opencode/dist/server.mjs
new file mode 100644
index 0000000..8688f4e
--- /dev/null
+++ b/plugin/opencode/dist/server.mjs
@@ -0,0 +1,158 @@
+import { spawn } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { AssistantBuffer } from "./assistant-buffer.js";
+import { sessionIDFrom } from "./internal.js";
+import { chatMessagePayload, eventPayload, stopPayload, toolAfterPayload } from "./payload.js";
+const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
+const PLUGIN_ROOT = resolve(MODULE_DIR, "../..");
+const SCRIPTS_DIR = resolve(PLUGIN_ROOT, "scripts");
+const HOOK_ENTRY = resolve(SCRIPTS_DIR, "hook_entry.sh");
+const BACKEND_SERVICE = resolve(SCRIPTS_DIR, "backend-service.sh");
+const DASHBOARD_SERVICE = resolve(SCRIPTS_DIR, "dashboard-service.sh");
+function contextFrom(result) {
+ const hookOutput = result.hookSpecificOutput;
+ if (!hookOutput || typeof hookOutput !== "object")
+ return "";
+ const additional = hookOutput.additionalContext;
+ return typeof additional === "string" ? additional : "";
+}
+function parseFirstJsonObject(text) {
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("{"))
+ continue;
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (parsed && typeof parsed === "object")
+ return parsed;
+ }
+ catch {
+ continue;
+ }
+ }
+ return {};
+}
+function runScript(script, args, payload) {
+ return new Promise((resolvePromise) => {
+ const child = spawn("bash", [script, ...args], {
+ cwd: PLUGIN_ROOT,
+ env: {
+ ...process.env,
+ CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
+ CLAUDE_SMART_HOST: "opencode",
+ },
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ let stdout = "";
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk.toString();
+ });
+ child.stderr.on("data", (chunk) => {
+ process.stderr.write(chunk);
+ });
+ child.stdin.on("error", () => {
+ // Hooks can exit before reading stdin on marker-gated setup failures.
+ });
+ child.on("error", () => resolvePromise({}));
+ child.on("close", () => resolvePromise(parseFirstJsonObject(stdout)));
+ try {
+ if (payload)
+ child.stdin.write(JSON.stringify(payload));
+ child.stdin.end();
+ }
+ catch {
+ resolvePromise({});
+ }
+ });
+}
+function runService(script, subcommand) {
+ return runScript(script, [subcommand]).then(() => undefined);
+}
+function cacheContext(cache, sessionID, result) {
+ const context = contextFrom(result);
+ if (!sessionID || !context)
+ return;
+ const pending = cache.get(sessionID) ?? [];
+ pending.push(context);
+ cache.set(sessionID, pending);
+}
+async function server(input) {
+ const pendingContext = new Map();
+ const activeSessions = new Set();
+ const completedAssistantText = new Map();
+ const assistant = new AssistantBuffer();
+ const cwd = input.directory;
+ async function flushStop(sessionID) {
+ if (!sessionID || !activeSessions.has(sessionID))
+ return;
+ activeSessions.delete(sessionID);
+ const text = assistant.text(sessionID) || completedAssistantText.get(sessionID) || "";
+ completedAssistantText.delete(sessionID);
+ await runScript(HOOK_ENTRY, ["opencode", "stop"], stopPayload({ properties: { sessionID, info: { directory: cwd } } }, cwd, text));
+ assistant.clear(sessionID);
+ }
+ return {
+ event: async ({ event }) => {
+ const type = event.type;
+ assistant.update(event);
+ if (type === "session.created") {
+ const payload = eventPayload(event, cwd);
+ const sessionID = String(payload.session_id || "");
+ if (!sessionID)
+ return;
+ activeSessions.add(sessionID);
+ await runService(BACKEND_SERVICE, "start");
+ await runService(DASHBOARD_SERVICE, "start");
+ const result = await runScript(HOOK_ENTRY, ["opencode", "session-start"], payload);
+ cacheContext(pendingContext, sessionID, result);
+ return;
+ }
+ if (type === "session.idle") {
+ const payload = eventPayload(event, cwd);
+ const sessionID = String(payload.session_id || "");
+ if (!sessionID)
+ return;
+ await flushStop(sessionID);
+ }
+ },
+ "chat.message": async (hookInput, output) => {
+ const payload = chatMessagePayload(hookInput, output, cwd);
+ if (!payload.session_id || !payload.prompt)
+ return;
+ activeSessions.add(String(payload.session_id || ""));
+ const result = await runScript(HOOK_ENTRY, ["opencode", "user-prompt"], payload);
+ cacheContext(pendingContext, String(payload.session_id || ""), result);
+ },
+ "experimental.chat.system.transform": async (hookInput, output) => {
+ const sessionID = sessionIDFrom(hookInput);
+ const pending = pendingContext.get(sessionID);
+ if (!pending?.length)
+ return;
+ output.system.push(...pending);
+ pendingContext.delete(sessionID);
+ },
+ "tool.execute.after": async (hookInput, output) => {
+ const payload = toolAfterPayload(hookInput, output, cwd);
+ if (!payload.session_id || !payload.tool_name)
+ return;
+ await runScript(HOOK_ENTRY, ["opencode", "post-tool"], payload);
+ },
+ "experimental.text.complete": async (hookInput, output) => {
+ const sessionID = typeof hookInput.sessionID === "string" ? hookInput.sessionID : "";
+ if (!sessionID)
+ return;
+ if (typeof output.text === "string")
+ completedAssistantText.set(sessionID, output.text);
+ },
+ dispose: async () => {
+ await Promise.all([...activeSessions].map((sessionID) => flushStop(sessionID)));
+ await runService(DASHBOARD_SERVICE, "session-end");
+ await runService(BACKEND_SERVICE, "session-end");
+ },
+ };
+}
+export default {
+ id: "claude-smart",
+ server,
+};
diff --git a/plugin/opencode/internal.ts b/plugin/opencode/internal.ts
new file mode 100644
index 0000000..a812805
--- /dev/null
+++ b/plugin/opencode/internal.ts
@@ -0,0 +1,18 @@
+export type EventLike = {
+ type?: string
+ properties?: Record
+}
+
+export function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value)
+}
+
+export function eventProperties(event: EventLike): Record {
+ return isRecord(event.properties) ? event.properties : (event as Record)
+}
+
+export function sessionIDFrom(value: unknown): string {
+ if (!isRecord(value)) return ""
+ const raw = value.sessionID ?? value.session_id
+ return typeof raw === "string" ? raw : ""
+}
diff --git a/plugin/opencode/package.json b/plugin/opencode/package.json
new file mode 100644
index 0000000..3dbc1ca
--- /dev/null
+++ b/plugin/opencode/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/plugin/opencode/payload.ts b/plugin/opencode/payload.ts
new file mode 100644
index 0000000..2066bab
--- /dev/null
+++ b/plugin/opencode/payload.ts
@@ -0,0 +1,90 @@
+import { eventProperties, type EventLike, isRecord, sessionIDFrom } from "./internal.js"
+
+type PartLike = {
+ type?: string
+ text?: string
+}
+
+export type PythonPayload = Record
+
+function textFromParts(parts: unknown): string {
+ if (!Array.isArray(parts)) return ""
+ return parts
+ .map((part: PartLike) => (part && part.type === "text" && typeof part.text === "string" ? part.text : ""))
+ .filter(Boolean)
+ .join("\n\n")
+}
+
+export function eventPayload(event: EventLike, cwd: string): PythonPayload {
+ const properties = eventProperties(event)
+ const info = isRecord(properties.info) ? properties.info : {}
+ return {
+ session_id: sessionIDFrom(properties) || sessionIDFrom(info),
+ cwd: typeof info.directory === "string" ? info.directory : cwd,
+ }
+}
+
+export function chatMessagePayload(input: Record, output: Record, cwd: string): PythonPayload {
+ const message = isRecord(output.message) ? output.message : {}
+ const prompt =
+ textFromParts(output.parts) ||
+ (typeof message.content === "string" ? message.content : "") ||
+ textFromParts(message.parts)
+ return {
+ session_id: sessionIDFrom(input),
+ cwd,
+ prompt,
+ }
+}
+
+export function normalizeToolName(tool: string): string {
+ const lowered = tool.toLowerCase()
+ if (lowered === "edit") return "Edit"
+ if (lowered === "write") return "Write"
+ if (lowered === "apply_patch") return "apply_patch"
+ if (["bash", "shell", "terminal", "exec", "command"].includes(lowered)) return "Bash"
+ return tool
+}
+
+export function normalizeToolInput(tool: string, args: unknown): Record {
+ if (!isRecord(args)) return {}
+ const out: Record = { ...args }
+ const copy = (from: string, to: string) => {
+ if (from in args && !(to in out)) out[to] = args[from]
+ }
+ copy("filePath", "file_path")
+ copy("oldString", "old_string")
+ copy("newString", "new_string")
+ copy("patchText", "command")
+ if (normalizeToolName(tool) === "Bash") {
+ copy("cmd", "command")
+ copy("script", "command")
+ }
+ return out
+}
+
+export function toolAfterPayload(input: Record, output: Record, cwd: string): PythonPayload {
+ const tool = typeof input.tool === "string" ? input.tool : ""
+ const text = typeof output.output === "string" ? output.output : ""
+ const response: Record = {
+ output: text,
+ stdout: text,
+ }
+ if (typeof output.title === "string") response.title = output.title
+ if (isRecord(output.metadata)) response.metadata = output.metadata
+ if (isRecord(output.metadata) && output.metadata.error) response.error = output.metadata.error
+ return {
+ session_id: sessionIDFrom(input),
+ cwd,
+ tool_name: normalizeToolName(tool),
+ tool_input: normalizeToolInput(tool, input.args),
+ tool_response: response,
+ }
+}
+
+export function stopPayload(event: EventLike, cwd: string, lastAssistantMessage: string): PythonPayload {
+ return {
+ ...eventPayload(event, cwd),
+ last_assistant_message: lastAssistantMessage,
+ }
+}
diff --git a/plugin/opencode/server.mts b/plugin/opencode/server.mts
new file mode 100644
index 0000000..326b288
--- /dev/null
+++ b/plugin/opencode/server.mts
@@ -0,0 +1,174 @@
+import { spawn } from "node:child_process"
+import { dirname, resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+
+import { AssistantBuffer } from "./assistant-buffer.js"
+import { sessionIDFrom } from "./internal.js"
+import { chatMessagePayload, eventPayload, stopPayload, toolAfterPayload } from "./payload.js"
+
+type HookResult = Record
+
+type PluginInput = {
+ directory: string
+}
+
+type EventInput = {
+ event: {
+ type?: string
+ properties?: Record
+ }
+}
+
+const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
+const PLUGIN_ROOT = resolve(MODULE_DIR, "../..")
+const SCRIPTS_DIR = resolve(PLUGIN_ROOT, "scripts")
+const HOOK_ENTRY = resolve(SCRIPTS_DIR, "hook_entry.sh")
+const BACKEND_SERVICE = resolve(SCRIPTS_DIR, "backend-service.sh")
+const DASHBOARD_SERVICE = resolve(SCRIPTS_DIR, "dashboard-service.sh")
+
+function contextFrom(result: HookResult): string {
+ const hookOutput = result.hookSpecificOutput
+ if (!hookOutput || typeof hookOutput !== "object") return ""
+ const additional = (hookOutput as Record).additionalContext
+ return typeof additional === "string" ? additional : ""
+}
+
+function parseFirstJsonObject(text: string): HookResult {
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim()
+ if (!trimmed.startsWith("{")) continue
+ try {
+ const parsed = JSON.parse(trimmed)
+ if (parsed && typeof parsed === "object") return parsed as HookResult
+ } catch {
+ continue
+ }
+ }
+ return {}
+}
+
+function runScript(script: string, args: string[], payload?: Record): Promise {
+ return new Promise((resolvePromise) => {
+ const child = spawn("bash", [script, ...args], {
+ cwd: PLUGIN_ROOT,
+ env: {
+ ...process.env,
+ CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
+ CLAUDE_SMART_HOST: "opencode",
+ },
+ stdio: ["pipe", "pipe", "pipe"],
+ })
+ let stdout = ""
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk.toString()
+ })
+ child.stderr.on("data", (chunk) => {
+ process.stderr.write(chunk)
+ })
+ child.stdin.on("error", () => {
+ // Hooks can exit before reading stdin on marker-gated setup failures.
+ })
+ child.on("error", () => resolvePromise({}))
+ child.on("close", () => resolvePromise(parseFirstJsonObject(stdout)))
+ try {
+ if (payload) child.stdin.write(JSON.stringify(payload))
+ child.stdin.end()
+ } catch {
+ resolvePromise({})
+ }
+ })
+}
+
+function runService(script: string, subcommand: string): Promise {
+ return runScript(script, [subcommand]).then(() => undefined)
+}
+
+function cacheContext(cache: Map, sessionID: string, result: HookResult): void {
+ const context = contextFrom(result)
+ if (!sessionID || !context) return
+ const pending = cache.get(sessionID) ?? []
+ pending.push(context)
+ cache.set(sessionID, pending)
+}
+
+async function server(input: PluginInput) {
+ const pendingContext = new Map()
+ const activeSessions = new Set()
+ const completedAssistantText = new Map()
+ const assistant = new AssistantBuffer()
+ const cwd = input.directory
+
+ async function flushStop(sessionID: string): Promise {
+ if (!sessionID || !activeSessions.has(sessionID)) return
+ activeSessions.delete(sessionID)
+ const text = assistant.text(sessionID) || completedAssistantText.get(sessionID) || ""
+ completedAssistantText.delete(sessionID)
+ await runScript(
+ HOOK_ENTRY,
+ ["opencode", "stop"],
+ stopPayload({ properties: { sessionID, info: { directory: cwd } } }, cwd, text),
+ )
+ assistant.clear(sessionID)
+ }
+
+ return {
+ event: async ({ event }: EventInput) => {
+ const type = event.type
+ assistant.update(event)
+ if (type === "session.created") {
+ const payload = eventPayload(event, cwd)
+ const sessionID = String(payload.session_id || "")
+ if (!sessionID) return
+ activeSessions.add(sessionID)
+ await runService(BACKEND_SERVICE, "start")
+ await runService(DASHBOARD_SERVICE, "start")
+ const result = await runScript(HOOK_ENTRY, ["opencode", "session-start"], payload)
+ cacheContext(pendingContext, sessionID, result)
+ return
+ }
+ if (type === "session.idle") {
+ const payload = eventPayload(event, cwd)
+ const sessionID = String(payload.session_id || "")
+ if (!sessionID) return
+ await flushStop(sessionID)
+ }
+ },
+ "chat.message": async (hookInput: Record, output: Record) => {
+ const payload = chatMessagePayload(hookInput, output, cwd)
+ if (!payload.session_id || !payload.prompt) return
+ activeSessions.add(String(payload.session_id || ""))
+ const result = await runScript(HOOK_ENTRY, ["opencode", "user-prompt"], payload)
+ cacheContext(pendingContext, String(payload.session_id || ""), result)
+ },
+ "experimental.chat.system.transform": async (hookInput: Record, output: { system: string[] }) => {
+ const sessionID = sessionIDFrom(hookInput)
+ const pending = pendingContext.get(sessionID)
+ if (!pending?.length) return
+ output.system.push(...pending)
+ pendingContext.delete(sessionID)
+ },
+ "tool.execute.after": async (hookInput: Record, output: Record) => {
+ const payload = toolAfterPayload(hookInput, output, cwd)
+ if (!payload.session_id || !payload.tool_name) return
+ await runScript(HOOK_ENTRY, ["opencode", "post-tool"], payload)
+ },
+ "experimental.text.complete": async (
+ hookInput: { sessionID?: string },
+ output: { text?: string },
+ ) => {
+ const sessionID = typeof hookInput.sessionID === "string" ? hookInput.sessionID : ""
+ if (!sessionID) return
+ if (typeof output.text === "string") completedAssistantText.set(sessionID, output.text)
+ },
+ dispose: async () => {
+ await Promise.all([...activeSessions].map((sessionID) => flushStop(sessionID)))
+ await runService(DASHBOARD_SERVICE, "session-end")
+ await runService(BACKEND_SERVICE, "session-end")
+ },
+ }
+}
+
+export default {
+ id: "claude-smart",
+ server,
+}
diff --git a/plugin/opencode/tsconfig.json b/plugin/opencode/tsconfig.json
new file mode 100644
index 0000000..dd962dd
--- /dev/null
+++ b/plugin/opencode/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "rootDir": ".",
+ "outDir": "dist"
+ },
+ "include": ["*.ts", "*.mts"]
+}
diff --git a/plugin/pyproject.toml b/plugin/pyproject.toml
index 2961e73..aaf6889 100644
--- a/plugin/pyproject.toml
+++ b/plugin/pyproject.toml
@@ -1,13 +1,15 @@
[project]
name = "claude-smart"
version = "0.2.45"
-description = "Self-improving Claude Code and Codex plugin that turns corrections into Preferences, Project-specific skills, and Shared skills"
+description = "Self-improving Claude Code, Codex, and OpenCode plugin that turns corrections into Preferences, Project-specific skills, and Shared skills"
keywords = [
"claude",
"claude-code",
"claude-code-plugin",
"codex",
"codex-plugin",
+ "opencode",
+ "opencode-plugin",
"memory",
"agent-memory",
"plugin",
diff --git a/plugin/scripts/backend-service.sh b/plugin/scripts/backend-service.sh
index f2212bd..117ee60 100755
--- a/plugin/scripts/backend-service.sh
+++ b/plugin/scripts/backend-service.sh
@@ -50,7 +50,12 @@ PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "backend-service.sh" "$@"
if [ -z "${CLAUDE_SMART_CLI_PATH:-}" ]; then
- if [ "${CLAUDE_SMART_HOST:-claude-code}" = "codex" ]; then
+ if [ "${CLAUDE_SMART_HOST:-claude-code}" = "opencode" ] && command -v opencode >/dev/null 2>&1; then
+ # Preserve Reflexio's Claude CLI provider contract while routing
+ # generation through the user's authenticated OpenCode setup.
+ claude_smart_prepend_node_bins
+ export CLAUDE_SMART_CLI_PATH="$PLUGIN_ROOT/scripts/opencode-claude-compat"
+ elif [ "${CLAUDE_SMART_HOST:-claude-code}" = "codex" ]; then
# Reflexio's provider still calls CLAUDE_SMART_CLI_PATH with Claude CLI
# flags. Use a small compatibility executable that translates that narrow
# contract to `codex exec`.
@@ -286,7 +291,7 @@ case "$CMD" in
# Keep plugin runtime data in ~/.reflexio even when the backend imports
# Reflexio from an editable checkout inside a larger repo with its own
# .env. python-dotenv respects pre-existing env vars, so this prevents a
- # parent REFLEXIO_LOG_DIR from sending claude-smart to enterprise configs.
+ # parent REFLEXIO_LOG_DIR from sending claude-smart to unrelated configs.
export REFLEXIO_LOG_DIR="${REFLEXIO_LOG_DIR:-$HOME}"
# Force sqlite: the plugin venv ships only the open-source reflexio
diff --git a/plugin/scripts/hook_entry.sh b/plugin/scripts/hook_entry.sh
index 210e40a..7fb6dc7 100755
--- a/plugin/scripts/hook_entry.sh
+++ b/plugin/scripts/hook_entry.sh
@@ -15,7 +15,7 @@ set -eu
HOST="claude-code"
EVENT="${1:-}"
case "$EVENT" in
- claude-code|codex)
+ claude-code|codex|opencode)
HOST="$EVENT"
EVENT="${2:-}"
;;
diff --git a/plugin/scripts/opencode-claude-compat b/plugin/scripts/opencode-claude-compat
new file mode 100755
index 0000000..8d7a3d1
--- /dev/null
+++ b/plugin/scripts/opencode-claude-compat
@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+set -eu
+SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/"
+exec node "$SCRIPT_DIR/opencode-claude-compat.js" "$@"
diff --git a/plugin/scripts/opencode-claude-compat.cmd b/plugin/scripts/opencode-claude-compat.cmd
new file mode 100644
index 0000000..b6f6ddf
--- /dev/null
+++ b/plugin/scripts/opencode-claude-compat.cmd
@@ -0,0 +1,3 @@
+@echo off
+set SCRIPT_DIR=%~dp0
+node "%SCRIPT_DIR%opencode-claude-compat.js" %*
diff --git a/plugin/scripts/opencode-claude-compat.js b/plugin/scripts/opencode-claude-compat.js
new file mode 100755
index 0000000..7c009d0
--- /dev/null
+++ b/plugin/scripts/opencode-claude-compat.js
@@ -0,0 +1,223 @@
+#!/usr/bin/env node
+"use strict";
+
+/*
+ * Translate Reflexio's Claude CLI provider contract to `opencode run`.
+ *
+ * Reflexio shells out to CLAUDE_SMART_CLI_PATH as if it were Claude Code:
+ *
+ * -p --output-format stream-json --model ...
+ *
+ * Under OpenCode, this bridge preserves that executable contract while routing
+ * generation through the user's authenticated OpenCode CLI/provider setup.
+ */
+
+const { spawnSync } = require("node:child_process");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const DEFAULT_TIMEOUT_MS = 120_000;
+const AGENT_NAME = "claude-smart-extractor";
+
+function main(argv) {
+ let workDir = null;
+ try {
+ const { outputFormat, systemPrompt, model } = parseSupportedArgs(argv);
+ const prompt = fs.readFileSync(0, "utf8");
+ workDir = prepareWorkDir();
+ const content = runOpenCode({
+ prompt: combinedPrompt({ prompt, systemPrompt }),
+ model,
+ workDir,
+ });
+ const payload =
+ outputFormat === "stream-json"
+ ? { type: "result", subtype: "success", result: content }
+ : { result: content };
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
+ return 0;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ process.stderr.write(`opencode-claude-compat: ${message}\n`);
+ return 1;
+ } finally {
+ if (workDir) {
+ try {
+ fs.rmSync(workDir, { recursive: true, force: true });
+ } catch {
+ // Best effort cleanup only.
+ }
+ }
+ }
+}
+
+function parseSupportedArgs(argv) {
+ let outputFormat = "json";
+ let systemPrompt = "";
+ let model = "";
+ let idx = 0;
+ while (idx < argv.length) {
+ const arg = argv[idx];
+ if (arg === "-p") {
+ idx += 1;
+ } else if (arg === "--output-format") {
+ outputFormat = requireValue(argv, idx, arg);
+ idx += 2;
+ } else if (arg === "--model") {
+ model = requireValue(argv, idx, arg);
+ idx += 2;
+ } else if (arg === "--verbose" || arg === "--include-partial-messages") {
+ idx += 1;
+ } else if (arg === "--append-system-prompt") {
+ systemPrompt = requireValue(argv, idx, arg);
+ idx += 2;
+ } else {
+ throw new Error(`unsupported Claude CLI argument: ${arg}`);
+ }
+ }
+ if (outputFormat !== "json" && outputFormat !== "stream-json") {
+ throw new Error(`unsupported --output-format: ${outputFormat}`);
+ }
+ return { outputFormat, systemPrompt, model };
+}
+
+function requireValue(argv, idx, name) {
+ if (idx + 1 >= argv.length) {
+ throw new Error(`${name} requires a value`);
+ }
+ return argv[idx + 1];
+}
+
+function prepareWorkDir() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-smart-opencode-"));
+ const config = {
+ agent: {
+ [AGENT_NAME]: {
+ description: "Internal claude-smart learning extraction",
+ prompt:
+ "You are running as an internal claude-smart extraction subprocess. " +
+ "Return only the requested text or JSON. Do not use tools, edit files, " +
+ "search the workspace, or ask follow-up questions.",
+ steps: 2,
+ permission: "deny",
+ },
+ },
+ };
+ fs.writeFileSync(path.join(dir, "opencode.json"), `${JSON.stringify(config, null, 2)}\n`);
+ return dir;
+}
+
+function runOpenCode({ prompt, model, workDir }) {
+ const opencodePath = process.env.CLAUDE_SMART_OPENCODE_PATH || commandPath(opencodeNames());
+ if (!opencodePath) {
+ throw new Error("opencode CLI not found on PATH");
+ }
+
+ const args = [
+ "run",
+ "--pure",
+ "--format",
+ "json",
+ "--agent",
+ AGENT_NAME,
+ "--dir",
+ workDir,
+ ];
+ const selectedModel = opencodeModel(model);
+ if (selectedModel) {
+ args.push("--model", selectedModel);
+ }
+ const variant = (process.env.CLAUDE_SMART_OPENCODE_VARIANT || "").trim();
+ if (variant) {
+ args.push("--variant", variant);
+ }
+
+ const proc = spawnSync(opencodePath, args, {
+ input: prompt,
+ cwd: workDir,
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ CLAUDE_SMART_HOST: "opencode",
+ CLAUDE_SMART_INTERNAL: "1",
+ CLAUDE_CODE_ENTRYPOINT: "optimizer",
+ },
+ timeout: timeoutMs(),
+ windowsHide: true,
+ shell: process.platform === "win32" && /\.(?:cmd|bat)$/i.test(opencodePath),
+ });
+ if (proc.error) {
+ if (proc.error.code === "ETIMEDOUT") {
+ throw new Error(`opencode CLI timed out after ${timeoutMs() / 1000}s`);
+ }
+ throw proc.error;
+ }
+ if (proc.status !== 0) {
+ const stderr = String(proc.stderr || "").trim().slice(0, 500);
+ throw new Error(`opencode CLI exited ${proc.status}: ${stderr}`);
+ }
+ const content = parseOpenCodeJson(proc.stdout);
+ if (!content) {
+ throw new Error("opencode CLI returned empty output");
+ }
+ return content;
+}
+
+function opencodeModel(modelFromArgs) {
+ const explicit = (process.env.CLAUDE_SMART_OPENCODE_MODEL || "").trim();
+ if (explicit) return explicit;
+ const candidate = (modelFromArgs || "").trim();
+ return candidate.includes("/") ? candidate : "";
+}
+
+function parseOpenCodeJson(stdout) {
+ const chunks = [];
+ for (const line of String(stdout || "").split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ let event;
+ try {
+ event = JSON.parse(trimmed);
+ } catch {
+ continue;
+ }
+ const part = event && typeof event === "object" ? event.part : null;
+ if (part && part.type === "text" && typeof part.text === "string") {
+ chunks.push(part.text);
+ } else if (event.type === "text" && typeof event.text === "string") {
+ chunks.push(event.text);
+ } else if (event.type === "result" && typeof event.result === "string") {
+ chunks.push(event.result);
+ }
+ }
+ return chunks.join("").trim();
+}
+
+function timeoutMs() {
+ const raw = Number(process.env.CLAUDE_SMART_CLI_TIMEOUT || "");
+ if (Number.isFinite(raw) && raw > 0) return raw * 1000;
+ return DEFAULT_TIMEOUT_MS;
+}
+
+function opencodeNames() {
+ return process.platform === "win32" ? ["opencode.cmd", "opencode.exe", "opencode"] : ["opencode"];
+}
+
+function commandPath(names) {
+ const pathParts = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
+ for (const dir of pathParts) {
+ for (const name of names) {
+ const candidate = path.join(dir, name);
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ }
+ return null;
+}
+
+function combinedPrompt({ prompt, systemPrompt }) {
+ if (!systemPrompt) return prompt;
+ return `${systemPrompt}\n\n## Task\n${prompt}`;
+}
+
+process.exitCode = main(process.argv.slice(2));
diff --git a/plugin/src/claude_smart/cli.py b/plugin/src/claude_smart/cli.py
index 6fdd933..c307c94 100644
--- a/plugin/src/claude_smart/cli.py
+++ b/plugin/src/claude_smart/cli.py
@@ -56,6 +56,8 @@
/ "claude-smart"
)
_CODEX_CLI_TIMEOUT_SECONDS = 30
+_OPENCODE_PLUGIN_SPEC = "claude-smart"
+_OPENCODE_CONFIG_NAMES = ("opencode.json", "opencode.jsonc")
_REFLEXIO_UNREACHABLE_MSG = (
"Failed to reach reflexio. Check ~/.claude-smart/backend.log "
"or restart Claude Code.\n"
@@ -92,6 +94,9 @@
Path("plugin/scripts/codex-claude-compat"),
Path("plugin/scripts/codex-claude-compat.cmd"),
Path("plugin/scripts/codex-claude-compat.js"),
+ Path("plugin/scripts/opencode-claude-compat"),
+ Path("plugin/scripts/opencode-claude-compat.cmd"),
+ Path("plugin/scripts/opencode-claude-compat.js"),
Path("plugin/scripts/codex-hook.js"),
Path("plugin/scripts/backend-log-runner.sh"),
Path("plugin/scripts/_codex_env.sh"),
@@ -963,6 +968,237 @@ def _configure_reflexio_setup() -> bool:
return read_only
+def _strip_jsonc(text: str) -> str:
+ """Remove JSONC comments and trailing commas without touching strings."""
+
+ def skip_trivia(index: int) -> int:
+ while index < len(text):
+ while index < len(text) and text[index].isspace():
+ index += 1
+ if text[index : index + 2] == "//":
+ index += 2
+ while index < len(text) and text[index] not in "\r\n":
+ index += 1
+ continue
+ if text[index : index + 2] == "/*":
+ index += 2
+ while index + 1 < len(text) and text[index : index + 2] != "*/":
+ index += 1
+ index = min(index + 2, len(text))
+ continue
+ break
+ return index
+
+ out: list[str] = []
+ in_string = False
+ escaped = False
+ i = 0
+ while i < len(text):
+ ch = text[i]
+ nxt = text[i + 1] if i + 1 < len(text) else ""
+ if in_string:
+ out.append(ch)
+ if escaped:
+ escaped = False
+ elif ch == "\\":
+ escaped = True
+ elif ch == '"':
+ in_string = False
+ i += 1
+ continue
+ if ch == '"':
+ in_string = True
+ out.append(ch)
+ i += 1
+ continue
+ if ch == "/" and nxt == "/":
+ while i < len(text) and text[i] not in "\r\n":
+ i += 1
+ continue
+ if ch == "/" and nxt == "*":
+ i += 2
+ while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
+ i += 1
+ i += 2
+ continue
+ if ch == ",":
+ j = skip_trivia(i + 1)
+ if j < len(text) and text[j] in "}]":
+ i += 1
+ continue
+ out.append(ch)
+ i += 1
+ return "".join(out)
+
+
+def _read_jsonc_object(path: Path) -> dict[str, object]:
+ try:
+ text = path.read_text() if path.exists() else "{}"
+ parsed = json.loads(_strip_jsonc(text or "{}"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise ValueError(f"could not parse OpenCode config {path}: {exc}") from exc
+ if not isinstance(parsed, dict):
+ raise ValueError(f"OpenCode config {path} must be a JSON object")
+ return parsed
+
+
+def _opencode_global_config_dir() -> Path:
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
+ base = Path(xdg) if xdg else Path.home() / ".config"
+ return base / "opencode"
+
+
+def _opencode_config_path(*, global_config: bool = False, cwd: Path | None = None) -> Path:
+ if global_config:
+ config_dir = _opencode_global_config_dir()
+ for name in _OPENCODE_CONFIG_NAMES:
+ candidate = config_dir / name
+ if candidate.exists():
+ return candidate
+ return config_dir / "opencode.json"
+ base = cwd or Path.cwd()
+ candidates = [
+ *(base / name for name in _OPENCODE_CONFIG_NAMES),
+ *(base / ".opencode" / name for name in _OPENCODE_CONFIG_NAMES),
+ ]
+ for candidate in candidates:
+ if candidate.exists():
+ return candidate
+ return base / "opencode.json"
+
+
+def _opencode_plugin_spec(entry: object) -> str | None:
+ if isinstance(entry, str):
+ return entry
+ if isinstance(entry, list) and entry and isinstance(entry[0], str):
+ return entry[0]
+ return None
+
+
+def _patch_opencode_plugin_config(
+ config_path: Path, *, install: bool
+) -> tuple[bool, Path]:
+ data = _read_jsonc_object(config_path)
+ for field in ("plugins", "plugin"):
+ value = data.get(field)
+ if value is not None and not isinstance(value, list):
+ raise ValueError(
+ f'OpenCode config {config_path} field "{field}" must be a JSON array'
+ )
+ current_plugin = data.get("plugin")
+ plugins: list[object] = []
+ if isinstance(current_plugin, list):
+ plugins.extend(current_plugin)
+ legacy_plugins = data.get("plugins")
+ if isinstance(legacy_plugins, list):
+ plugins.extend(legacy_plugins)
+ kept = [
+ item
+ for item in plugins
+ if _opencode_plugin_spec(item) != _OPENCODE_PLUGIN_SPEC
+ ]
+ next_plugins = [*kept, _OPENCODE_PLUGIN_SPEC] if install else kept
+ if isinstance(current_plugin, list):
+ changed = ("plugins" in data) or next_plugins != current_plugin
+ else:
+ changed = ("plugins" in data) or (install and bool(next_plugins))
+ if not changed:
+ return False, config_path
+ if config_path.exists():
+ original = config_path.read_text()
+ if original.strip() and original != _strip_jsonc(original):
+ config_path.with_suffix(config_path.suffix + ".bak").write_text(original)
+ data["plugin"] = next_plugins
+ data.pop("plugins", None)
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(json.dumps(data, indent=2) + "\n")
+ return True, config_path
+
+
+def _has_extraction_provider() -> bool:
+ env_config.load_reflexio_env(_REFLEXIO_ENV_PATH)
+ if os.environ.get(env_config.REFLEXIO_API_KEY_ENV, "").strip():
+ return True
+ cli_path = os.environ.get("CLAUDE_SMART_CLI_PATH", "").strip()
+ if cli_path:
+ resolved = Path(cli_path).expanduser()
+ if resolved.is_file() and os.access(resolved, os.X_OK):
+ return True
+ return bool(shutil.which("claude") or shutil.which("codex") or shutil.which("opencode"))
+
+
+def _extraction_provider_error() -> str:
+ return (
+ "error: OpenCode support needs a working learning/extraction provider.\n"
+ "Run `npx claude-smart setup` to configure Reflexio, or install "
+ "OpenCode, Claude Code, or Codex so local extraction can use a supported CLI.\n"
+ )
+
+
+def _opencode_install_supported_from_this_package() -> bool:
+ return (_SCRIPTS_DIR / "smart-install.sh").is_file()
+
+
+def _bootstrap_opencode_install(read_only: bool) -> tuple[bool, str]:
+ if not _opencode_install_supported_from_this_package():
+ return (
+ False,
+ "OpenCode install is supported from the npm package. "
+ "Run `npx claude-smart install --host opencode`.",
+ )
+ try:
+ _force_plugin_root(_PLUGIN_ROOT)
+ except OSError as exc:
+ return False, str(exc)
+ bash = _resolve_bash()
+ if not bash:
+ return False, "bash is required to bootstrap claude-smart dependencies"
+ result = subprocess.run([bash, str(_SCRIPTS_DIR / "smart-install.sh")], cwd=_PLUGIN_ROOT)
+ if result.returncode != 0:
+ return False, f"smart-install.sh failed in {_PLUGIN_ROOT}"
+ if _INSTALL_FAILURE_MARKER.is_file():
+ first_line = _INSTALL_FAILURE_MARKER.read_text().splitlines()
+ reason = (first_line[0].strip() if first_line else "") or "unknown error"
+ return False, reason
+ if read_only:
+ _prune_publish_hooks_for_read_only(_PLUGIN_ROOT)
+ return True, str(_PLUGIN_ROOT)
+
+
+def cmd_install_opencode(args: argparse.Namespace) -> int:
+ if not _opencode_install_supported_from_this_package():
+ sys.stderr.write(
+ "error: OpenCode install is supported from the npm package. "
+ "Run `npx claude-smart install --host opencode`.\n"
+ )
+ return 1
+ read_only = _configure_reflexio_setup()
+ if not _has_extraction_provider():
+ sys.stderr.write(_extraction_provider_error())
+ return 1
+ bootstrapped, message = _bootstrap_opencode_install(read_only)
+ if not bootstrapped:
+ sys.stderr.write(
+ f"error: claude-smart OpenCode setup failed during dependency bootstrap: {message}\n"
+ )
+ return 1
+ config_path = _opencode_config_path(
+ global_config=bool(getattr(args, "global_config", False))
+ )
+ try:
+ changed, config_path = _patch_opencode_plugin_config(config_path, install=True)
+ except ValueError as exc:
+ sys.stderr.write(f"error: {exc}\n")
+ return 1
+ action = "Updated" if changed else "OpenCode config already includes"
+ sys.stdout.write(
+ f"{action} `{_OPENCODE_PLUGIN_SPEC}` in {config_path}.\n"
+ f"Prepared claude-smart runtime at {message}.\n"
+ "Restart OpenCode in your project so it loads the plugin.\n"
+ )
+ return 0
+
+
def cmd_install_codex(args: argparse.Namespace) -> int:
"""Install the claude-smart plugin marketplace for Codex.
@@ -1084,6 +1320,8 @@ def cmd_install(args: argparse.Namespace) -> int:
"""
if getattr(args, "host", "claude-code") == "codex":
return cmd_install_codex(args)
+ if getattr(args, "host", "claude-code") == "opencode":
+ return cmd_install_opencode(args)
if not shutil.which("claude"):
sys.stderr.write(
@@ -1156,6 +1394,8 @@ def cmd_update(args: argparse.Namespace) -> int:
"""
if getattr(args, "host", "claude-code") == "codex":
return cmd_update_codex(args)
+ if getattr(args, "host", "claude-code") == "opencode":
+ return cmd_update_opencode(args)
_run_service(_DASHBOARD_SCRIPT, "stop")
_run_service(_BACKEND_SCRIPT, "stop")
@@ -1182,6 +1422,17 @@ def cmd_update_codex(_args: argparse.Namespace) -> int:
return cmd_install_codex(install_args)
+def cmd_update_opencode(args: argparse.Namespace) -> int:
+ _run_service(_DASHBOARD_SCRIPT, "stop")
+ _run_service(_BACKEND_SCRIPT, "stop")
+ sys.stdout.write(
+ "Updating claude-smart OpenCode support by reinstalling from this package...\n"
+ )
+ install_args = argparse.Namespace(**vars(args))
+ install_args.host = "opencode"
+ return cmd_install_opencode(install_args)
+
+
def cmd_uninstall(_args: argparse.Namespace) -> int:
"""Uninstall claude-smart from Claude Code via the native plugin CLI.
@@ -1196,6 +1447,8 @@ def cmd_uninstall(_args: argparse.Namespace) -> int:
"""
if getattr(_args, "host", "claude-code") == "codex":
return cmd_uninstall_codex(_args)
+ if getattr(_args, "host", "claude-code") == "opencode":
+ return cmd_uninstall_opencode(_args)
if not shutil.which("claude"):
sys.stderr.write(
@@ -1256,6 +1509,28 @@ def cmd_uninstall_codex(_args: argparse.Namespace) -> int:
return 0
+def cmd_uninstall_opencode(args: argparse.Namespace) -> int:
+ _run_service(_DASHBOARD_SCRIPT, "stop")
+ _run_service(_BACKEND_SCRIPT, "stop")
+ config_path = _opencode_config_path(
+ global_config=bool(getattr(args, "global_config", False))
+ )
+ try:
+ changed, config_path = _patch_opencode_plugin_config(config_path, install=False)
+ except ValueError as exc:
+ sys.stderr.write(f"error: {exc}\n")
+ return 1
+ if changed:
+ sys.stdout.write(f"Removed `{_OPENCODE_PLUGIN_SPEC}` from {config_path}.\n")
+ else:
+ sys.stdout.write(f"OpenCode config did not include `{_OPENCODE_PLUGIN_SPEC}`.\n")
+ sys.stdout.write(
+ "Restart OpenCode to apply. "
+ f"{_LOCAL_DATA_NOTICE}"
+ )
+ return 0
+
+
def cmd_show(args: argparse.Namespace) -> int:
"""Print this project's skills + preferences, plus globally-shared skills.
@@ -1981,28 +2256,46 @@ def _build_parser() -> argparse.ArgumentParser:
inst = sub.add_parser("install", help="Install claude-smart")
inst.add_argument(
"--host",
- choices=("claude-code", "codex"),
+ choices=("claude-code", "codex", "opencode"),
default="claude-code",
help="Install target host",
)
+ inst.add_argument(
+ "--global",
+ dest="global_config",
+ action="store_true",
+ help="For OpenCode, patch ~/.config/opencode instead of the project config",
+ )
inst.set_defaults(func=cmd_install)
upd = sub.add_parser("update", help="Update claude-smart to the latest version")
upd.add_argument(
"--host",
- choices=("claude-code", "codex"),
+ choices=("claude-code", "codex", "opencode"),
default="claude-code",
help="Update target host",
)
+ upd.add_argument(
+ "--global",
+ dest="global_config",
+ action="store_true",
+ help="For OpenCode, patch ~/.config/opencode instead of the project config",
+ )
upd.set_defaults(func=cmd_update)
uni = sub.add_parser("uninstall", help="Remove claude-smart")
uni.add_argument(
"--host",
- choices=("claude-code", "codex"),
+ choices=("claude-code", "codex", "opencode"),
default="claude-code",
help="Uninstall target host",
)
+ uni.add_argument(
+ "--global",
+ dest="global_config",
+ action="store_true",
+ help="For OpenCode, patch ~/.config/opencode instead of the project config",
+ )
uni.set_defaults(func=cmd_uninstall)
sh = sub.add_parser(
diff --git a/plugin/src/claude_smart/events/stop.py b/plugin/src/claude_smart/events/stop.py
index 6b904aa..3bfc645 100644
--- a/plugin/src/claude_smart/events/stop.py
+++ b/plugin/src/claude_smart/events/stop.py
@@ -377,7 +377,7 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
last_assistant_message = payload.get("last_assistant_message")
assistant_text = (
last_assistant_message
- if runtime.is_codex()
+ if (runtime.is_codex() or runtime.is_opencode())
and isinstance(last_assistant_message, str)
and last_assistant_message
else _scan_transcript_for_assistant_text(entries)
diff --git a/plugin/src/claude_smart/runtime.py b/plugin/src/claude_smart/runtime.py
index 8847aa7..13bd133 100644
--- a/plugin/src/claude_smart/runtime.py
+++ b/plugin/src/claude_smart/runtime.py
@@ -14,7 +14,8 @@
HOST_CLAUDE_CODE = "claude-code"
HOST_CODEX = "codex"
-VALID_HOSTS = frozenset({HOST_CLAUDE_CODE, HOST_CODEX})
+HOST_OPENCODE = "opencode"
+VALID_HOSTS = frozenset({HOST_CLAUDE_CODE, HOST_CODEX, HOST_OPENCODE})
_SHARED_AGENT_VERSION = "claude-code"
_current_host: str | None = None
@@ -42,6 +43,11 @@ def is_codex() -> bool:
return host() == HOST_CODEX
+def is_opencode() -> bool:
+ """True when the current hook invocation came from OpenCode."""
+ return host() == HOST_OPENCODE
+
+
def agent_version() -> str:
"""Reflexio agent version used for shared learning across hosts."""
return _SHARED_AGENT_VERSION
diff --git a/scripts/setup-claude-smart.sh b/scripts/setup-claude-smart.sh
index 2b5e8ee..6bed1aa 100755
--- a/scripts/setup-claude-smart.sh
+++ b/scripts/setup-claude-smart.sh
@@ -202,7 +202,8 @@ normalize_host() {
case "$1" in
1|claude|claude-code|Claude*) printf 'claude-code\n' ;;
2|codex|Codex*) printf 'codex\n' ;;
- 3|both|Both*) printf 'both\n' ;;
+ 3|all|both|Both*) printf 'all\n' ;;
+ 4|opencode|OpenCode*) printf 'opencode\n' ;;
*) return 1 ;;
esac
}
@@ -276,9 +277,13 @@ install_for_host() {
codex)
"$node_bin" "$INSTALLER" install --host codex
;;
- both)
+ opencode)
+ "$node_bin" "$INSTALLER" install --host opencode
+ ;;
+ all)
"$node_bin" "$INSTALLER" install
"$node_bin" "$INSTALLER" install --host codex
+ "$node_bin" "$INSTALLER" install --host opencode
;;
esac
}
@@ -306,7 +311,7 @@ main() {
default_scope="global"
fi
- host="$(prompt_normalized "Host (1=Claude Code, 2=Codex, 3=both)" "claude-code" normalize_host)"
+ host="$(prompt_normalized "Host (1=Claude Code, 2=Codex, 3=all, 4=OpenCode)" "claude-code" normalize_host)"
mode="$(prompt_normalized "Setup mode (1=local, 2=managed Reflexio)" "$default_mode" normalize_mode)"
if [ "$mode" = "local" ]; then
diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py
index 4ac06c9..038e4f5 100644
--- a/tests/test_install_scripts.py
+++ b/tests/test_install_scripts.py
@@ -1955,6 +1955,7 @@ def test_hook_entry_failure_marker_uses_resolved_python_on_windows(
(marker_dir / "install-failed").write_text(
f"cached env is broken\nfingerprint={fingerprint}\n"
)
+ (tmp_path / "python3-called").unlink(missing_ok=True)
result = subprocess.run(
[str(scripts / "hook_entry.sh"), "claude-code", "session-start"],
diff --git a/tests/test_opencode_support.py b/tests/test_opencode_support.py
new file mode 100644
index 0000000..ce2922f
--- /dev/null
+++ b/tests/test_opencode_support.py
@@ -0,0 +1,1463 @@
+"""Tests for OpenCode host support."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shutil
+import subprocess
+import textwrap
+from collections.abc import Generator
+from pathlib import Path
+from typing import Any
+
+import pytest # type: ignore[reportMissingImports]
+
+from claude_smart import cli, hook, runtime, state
+from claude_smart.events import post_tool, stop, user_prompt
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+@pytest.fixture(autouse=True)
+def restore_runtime_host() -> Generator[None, None, None]:
+ previous_host = getattr(runtime, "_current_host", None)
+ previous_env = os.environ.get(runtime.HOST_ENV)
+ yield
+ runtime._current_host = previous_host
+ if previous_env is None:
+ os.environ.pop(runtime.HOST_ENV, None)
+ else:
+ os.environ[runtime.HOST_ENV] = previous_env
+
+
+def _read_json(path: str) -> dict[str, Any]:
+ return json.loads((REPO_ROOT / path).read_text())
+
+
+def _run_node_script(script: str, *args: str) -> subprocess.CompletedProcess[str] | None:
+ node = shutil_which_node()
+ if node is None:
+ return None
+ return subprocess.run(
+ [node, "-e", script, *args],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+
+def _write_fake_bash_recorder(tmp_path: Path, *, user_prompt_context: str = "") -> Path:
+ fake_bash = tmp_path / "bash"
+ response = (
+ f'{{"hookSpecificOutput":{{"additionalContext":{json.dumps(user_prompt_context)}}}}}'
+ if user_prompt_context
+ else "{}"
+ )
+ fake_bash.write_text(
+ "#!/bin/sh\n"
+ "script=\"$1\"\n"
+ "shift\n"
+ "payload=$(cat || true)\n"
+ "PAYLOAD=\"$payload\" node -e 'require(\"fs\").appendFileSync(process.env.CALL_LOG, JSON.stringify({ script: process.argv[1], args: process.argv.slice(2), payload: process.env.PAYLOAD }) + \"\\n\")' \"$script\" \"$@\"\n"
+ "if [ \"${2:-}\" = \"user-prompt\" ]; then\n"
+ f" printf '%s\\n' '{response}'\n"
+ "else\n"
+ " printf '%s\\n' '{}'\n"
+ "fi\n",
+ )
+ fake_bash.chmod(0o755)
+ return fake_bash
+
+
+def test_package_exposes_opencode_server_entrypoint() -> None:
+ package = _read_json("package.json")
+
+ assert package["exports"]["./server"]["import"] == "./plugin/opencode/dist/server.mjs"
+ assert "build:opencode" in package["scripts"]
+ assert "opencode" in package["keywords"]
+ assert "opencode-plugin" in package["keywords"]
+ assert package["engines"]["opencode"] == ">=1.17.0"
+ assert package["description"].startswith("Self-improving Claude Code, Codex, and OpenCode")
+ assert package["oc-plugin"] == ["server"]
+
+
+def test_opencode_package_includes_local_cli_bridge() -> None:
+ for name in (
+ "opencode-claude-compat",
+ "opencode-claude-compat.cmd",
+ "opencode-claude-compat.js",
+ ):
+ assert (REPO_ROOT / "plugin" / "scripts" / name).is_file()
+
+
+def test_opencode_bridge_does_not_call_pre_tool() -> None:
+ server = (REPO_ROOT / "plugin" / "opencode" / "server.mts").read_text()
+
+ assert '"tool.execute.before"' not in server
+ assert '"pre-tool"' not in server
+ assert '["opencode", "post-tool"]' in server
+ assert '["opencode", "user-prompt"]' in server
+ assert '["opencode", "stop"]' in server
+
+
+def test_runtime_accepts_opencode_host() -> None:
+ assert runtime.set_host("opencode") == "opencode"
+ assert runtime.host() == "opencode"
+ assert runtime.is_opencode()
+ assert runtime.agent_version() == "claude-code"
+
+
+def test_hook_entry_accepts_opencode_host() -> None:
+ script = (REPO_ROOT / "plugin" / "scripts" / "hook_entry.sh").read_text()
+
+ assert "claude-code|codex|opencode" in script
+
+
+def test_hook_main_accepts_opencode_host(monkeypatch) -> None:
+ calls: list[tuple[str, str]] = []
+
+ def fake_read() -> dict[str, Any]:
+ return {"session_id": "s1"}
+
+ def fake_handlers():
+ return {"stop": lambda _payload: calls.append((runtime.host(), "stop"))}
+
+ monkeypatch.setattr(hook, "_read_stdin_json", fake_read)
+ monkeypatch.setattr(hook, "_load_handlers", fake_handlers)
+
+ assert hook.main(["opencode", "stop"]) == 0
+ assert calls == [("opencode", "stop")]
+
+
+def test_opencode_stop_uses_last_assistant_message(session_dir, monkeypatch) -> None:
+ runtime.set_host(runtime.HOST_OPENCODE)
+ calls: list[dict[str, Any]] = []
+ monkeypatch.setattr(
+ stop.publish, "publish_unpublished", lambda **kw: calls.append(kw)
+ )
+ monkeypatch.setattr(stop.ids, "resolve_user_id", lambda *_a, **_kw: "demo")
+
+ stop.handle(
+ {
+ "session_id": "s1",
+ "cwd": str(REPO_ROOT),
+ "last_assistant_message": "final answer from opencode",
+ "transcript_path": "/does/not/exist.jsonl",
+ }
+ )
+
+ records = state.read_all("s1")
+ assert records[-1]["role"] == "Assistant"
+ assert records[-1]["content"] == "final answer from opencode"
+ assert calls and calls[0]["project_id"] == "demo"
+
+
+def test_opencode_learning_loop_buffers_injects_tools_and_publishes(
+ session_dir, monkeypatch
+) -> None:
+ runtime.set_host(runtime.HOST_OPENCODE)
+ published: list[dict[str, Any]] = []
+ injected: list[dict[str, Any]] = []
+
+ monkeypatch.setattr(user_prompt.ids, "resolve_user_id", lambda *_a, **_kw: "demo")
+ monkeypatch.setattr(stop.ids, "resolve_user_id", lambda *_a, **_kw: "demo")
+ monkeypatch.setattr(
+ user_prompt.context_inject,
+ "emit_context",
+ lambda **kw: injected.append(kw) or True,
+ )
+ monkeypatch.setattr(
+ stop.publish,
+ "publish_unpublished",
+ lambda **kw: published.append(kw) or ("ok", 3),
+ )
+
+ user_prompt.handle(
+ {
+ "session_id": "s-loop",
+ "cwd": str(REPO_ROOT),
+ "prompt": "Use my learned rules before editing AGENTS.md",
+ }
+ )
+ post_tool.handle(
+ {
+ "session_id": "s-loop",
+ "tool_name": "Bash",
+ "tool_input": {"command": "TOKEN=Abcdefghijk1234567890 echo safe"},
+ "tool_response": {"stdout": "done"},
+ }
+ )
+ result = stop.handle(
+ {
+ "session_id": "s-loop",
+ "cwd": str(REPO_ROOT),
+ "last_assistant_message": "Applied the remembered rule.",
+ }
+ )
+
+ records = state.read_all("s-loop")
+ assert result == ("ok", 3)
+ assert injected == [
+ {
+ "session_id": "s-loop",
+ "project_id": "demo",
+ "query": "Use my learned rules before editing AGENTS.md",
+ "hook_event_name": "UserPromptSubmit",
+ "top_k": 3,
+ }
+ ]
+ assert [record["role"] for record in records] == [
+ "User",
+ "Assistant_tool",
+ "Assistant",
+ ]
+ assert records[0]["content"] == "Use my learned rules before editing AGENTS.md"
+ assert records[1]["tool_output"] == "done"
+ assert records[1]["tool_input"]["command"] == "TOKEN= echo safe"
+ assert records[2]["content"] == "Applied the remembered rule."
+ assert published == [
+ {
+ "session_id": "s-loop",
+ "project_id": "demo",
+ "force_extraction": False,
+ "skip_aggregation": False,
+ }
+ ]
+
+
+def test_opencode_config_patch_preserves_legacy_plugin_field_and_unrelated(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / ".opencode" / "opencode.jsonc"
+ config.parent.mkdir()
+ config.write_text(
+ '{\n'
+ ' // existing user config\n'
+ ' "theme": "system",\n'
+ ' "plugin": ["other-plugin",],\n'
+ '}\n'
+ )
+
+ changed, path = cli._patch_opencode_plugin_config(config, install=True)
+
+ assert changed is True
+ assert path == config
+ parsed = json.loads(config.read_text())
+ assert parsed["theme"] == "system"
+ assert parsed["plugin"] == ["other-plugin", "claude-smart"]
+
+
+def test_opencode_config_patch_uninstall_removes_only_claude_smart(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / ".opencode" / "opencode.json"
+ config.parent.mkdir()
+ config.write_text(
+ json.dumps(
+ {
+ "plugin": [
+ "other-plugin",
+ "claude-smart",
+ ["another-plugin", {"enabled": True}],
+ ],
+ "model": "anthropic/claude-sonnet-4",
+ }
+ )
+ )
+
+ changed, _ = cli._patch_opencode_plugin_config(config, install=False)
+
+ assert changed is True
+ parsed = json.loads(config.read_text())
+ assert parsed["plugin"] == [
+ "other-plugin",
+ ["another-plugin", {"enabled": True}],
+ ]
+ assert parsed["model"] == "anthropic/claude-sonnet-4"
+
+
+def test_opencode_config_patch_install_dedupes_existing_entries(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / ".opencode" / "opencode.json"
+ config.parent.mkdir()
+ config.write_text(
+ json.dumps({"plugin": ["claude-smart", "other-plugin", "claude-smart"]})
+ )
+
+ changed, _ = cli._patch_opencode_plugin_config(config, install=True)
+
+ assert changed is True
+ assert json.loads(config.read_text())["plugin"] == ["other-plugin", "claude-smart"]
+
+
+def test_opencode_config_patch_fresh_config_uses_current_plugin_field(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / "opencode.json"
+
+ changed, _ = cli._patch_opencode_plugin_config(config, install=True)
+
+ assert changed is True
+ assert json.loads(config.read_text()) == {"plugin": ["claude-smart"]}
+
+
+def test_opencode_config_patch_migrates_existing_plugins_field(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / "opencode.json"
+ config.write_text(json.dumps({"plugins": ["other-plugin"], "theme": "system"}))
+
+ changed, _ = cli._patch_opencode_plugin_config(config, install=True)
+
+ parsed = json.loads(config.read_text())
+ assert changed is True
+ assert parsed["plugin"] == ["other-plugin", "claude-smart"]
+ assert "plugins" not in parsed
+ assert parsed["theme"] == "system"
+
+
+def test_opencode_config_patch_uninstall_migrates_existing_plugins_field(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / "opencode.json"
+ config.write_text(
+ json.dumps({"plugins": ["claude-smart", "other-plugin", "claude-smart"]})
+ )
+
+ changed, _ = cli._patch_opencode_plugin_config(config, install=False)
+
+ assert changed is True
+ assert json.loads(config.read_text()) == {"plugin": ["other-plugin"]}
+
+
+def test_opencode_jsonc_parser_preserves_comma_brace_inside_strings() -> None:
+ parsed = cli._strip_jsonc('{"prompt": "keep,}", "plugin": ["claude-smart",],}\n')
+
+ assert json.loads(parsed) == {"prompt": "keep,}", "plugin": ["claude-smart"]}
+
+
+def test_opencode_jsonc_parser_skips_comments_after_trailing_commas() -> None:
+ parsed = cli._strip_jsonc(
+ '{\n'
+ ' "plugin": [\n'
+ ' "other-plugin", // keep plugin note\n'
+ ' ], /* trailing array comment */\n'
+ '}\n'
+ )
+
+ assert json.loads(parsed) == {"plugin": ["other-plugin"]}
+
+
+def test_opencode_config_patch_rejects_non_array_plugin_without_rewrite(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / ".opencode" / "opencode.json"
+ config.parent.mkdir()
+ original = json.dumps({"plugin": "other-plugin", "theme": "system"})
+ config.write_text(original)
+
+ with pytest.raises(ValueError, match='field "plugin" must be a JSON array'):
+ cli._patch_opencode_plugin_config(config, install=True)
+
+ assert config.read_text() == original
+
+
+def test_opencode_config_patch_rejects_non_array_plugins_without_rewrite(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / "opencode.json"
+ original = json.dumps({"plugins": "other-plugin", "theme": "system"})
+ config.write_text(original)
+
+ with pytest.raises(ValueError, match='field "plugins" must be a JSON array'):
+ cli._patch_opencode_plugin_config(config, install=True)
+
+ assert config.read_text() == original
+
+
+def test_opencode_config_patch_uninstall_noops_without_plugin_array(
+ tmp_path: Path,
+) -> None:
+ config = tmp_path / ".opencode" / "opencode.json"
+ config.parent.mkdir()
+ config.write_text(json.dumps({"theme": "system"}))
+
+ changed, _ = cli._patch_opencode_plugin_config(config, install=False)
+
+ assert changed is False
+ assert json.loads(config.read_text()) == {"theme": "system"}
+
+
+def test_opencode_config_patch_backs_up_jsonc_config(tmp_path: Path) -> None:
+ config = tmp_path / ".opencode" / "opencode.jsonc"
+ config.parent.mkdir()
+ original = (
+ '{\n'
+ ' // keep my notes\n'
+ ' "theme": "system",\n'
+ ' "plugin": ["other-plugin"]\n'
+ '}\n'
+ )
+ config.write_text(original)
+
+ changed, _ = cli._patch_opencode_plugin_config(config, install=True)
+
+ assert changed is True
+ assert config.with_suffix(config.suffix + ".bak").read_text() == original
+ assert json.loads(config.read_text())["plugin"] == ["other-plugin", "claude-smart"]
+
+
+def test_opencode_config_patch_skips_backup_for_plain_json(tmp_path: Path) -> None:
+ config = tmp_path / ".opencode" / "opencode.json"
+ config.parent.mkdir()
+ config.write_text(json.dumps({"plugin": ["other-plugin"]}))
+
+ changed, _ = cli._patch_opencode_plugin_config(config, install=True)
+
+ assert changed is True
+ assert not config.with_suffix(config.suffix + ".bak").exists()
+
+
+def test_opencode_global_config_path_honors_xdg_config_home(
+ monkeypatch, tmp_path: Path
+) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
+
+ path = cli._opencode_config_path(global_config=True)
+
+ assert path == tmp_path / "xdg" / "opencode" / "opencode.json"
+
+
+def test_opencode_global_config_path_defaults_without_xdg(monkeypatch) -> None:
+ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
+
+ path = cli._opencode_config_path(global_config=True)
+
+ assert path == Path.home() / ".config" / "opencode" / "opencode.json"
+
+
+def test_opencode_project_config_path_defaults_to_root(tmp_path: Path) -> None:
+ path = cli._opencode_config_path(cwd=tmp_path)
+
+ assert path == tmp_path / "opencode.json"
+
+
+def test_opencode_project_config_path_prefers_existing_root_config(
+ tmp_path: Path,
+) -> None:
+ root_config = tmp_path / "opencode.jsonc"
+ legacy_config = tmp_path / ".opencode" / "opencode.json"
+ legacy_config.parent.mkdir()
+ root_config.write_text('{"plugin": []}\n')
+ legacy_config.write_text('{"plugin": ["legacy"]}\n')
+
+ path = cli._opencode_config_path(cwd=tmp_path)
+
+ assert path == root_config
+
+
+def test_opencode_project_config_path_honors_existing_dot_opencode_config(
+ tmp_path: Path,
+) -> None:
+ legacy_config = tmp_path / ".opencode" / "opencode.json"
+ legacy_config.parent.mkdir()
+ legacy_config.write_text('{"plugin": []}\n')
+
+ path = cli._opencode_config_path(cwd=tmp_path)
+
+ assert path == legacy_config
+
+
+def test_opencode_install_from_python_wheel_path_points_to_npm(
+ monkeypatch, tmp_path, capsys
+) -> None:
+ monkeypatch.setattr(cli, "_SCRIPTS_DIR", tmp_path)
+
+ rc = cli.cmd_install(argparse.Namespace(host="opencode", global_config=False))
+
+ assert rc == 1
+ assert "npx claude-smart install --host opencode" in capsys.readouterr().err
+
+
+def test_opencode_bootstrap_guard_rejects_unsupported_package(
+ monkeypatch, tmp_path
+) -> None:
+ monkeypatch.setattr(cli, "_SCRIPTS_DIR", tmp_path)
+
+ bootstrapped, message = cli._bootstrap_opencode_install(read_only=False)
+
+ assert bootstrapped is False
+ assert "npx claude-smart install --host opencode" in message
+
+
+def test_opencode_install_fails_without_extraction_provider(monkeypatch, tmp_path) -> None:
+ env_path = tmp_path / ".reflexio" / ".env"
+ monkeypatch.setattr(cli, "_REFLEXIO_ENV_PATH", env_path)
+ monkeypatch.setattr(cli.shutil, "which", lambda _name: None)
+
+ rc = cli.cmd_install(argparse.Namespace(host="opencode", global_config=False))
+
+ assert rc == 1
+
+
+def test_opencode_extraction_provider_requires_executable_cli_path(
+ monkeypatch, tmp_path
+) -> None:
+ cli_path = tmp_path / "claude-smart-provider"
+ cli_path.write_text("#!/bin/sh\nexit 0\n")
+ cli_path.chmod(0o644)
+ monkeypatch.setattr(cli, "_REFLEXIO_ENV_PATH", tmp_path / ".reflexio" / ".env")
+ monkeypatch.setenv("CLAUDE_SMART_CLI_PATH", str(cli_path))
+ monkeypatch.delenv(cli.env_config.REFLEXIO_API_KEY_ENV, raising=False)
+ monkeypatch.setattr(cli.shutil, "which", lambda _name: None)
+
+ assert cli._has_extraction_provider() is False
+
+ cli_path.chmod(0o755)
+ assert cli._has_extraction_provider() is True
+
+
+def test_opencode_extraction_provider_accepts_opencode_only(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr(cli, "_REFLEXIO_ENV_PATH", tmp_path / ".reflexio" / ".env")
+ monkeypatch.delenv("CLAUDE_SMART_CLI_PATH", raising=False)
+ monkeypatch.delenv(cli.env_config.REFLEXIO_API_KEY_ENV, raising=False)
+ monkeypatch.setattr(
+ cli.shutil, "which", lambda name: f"/bin/{name}" if name == "opencode" else None
+ )
+
+ assert cli._has_extraction_provider() is True
+
+
+def test_opencode_backend_service_prefers_opencode_bridge() -> None:
+ service = (REPO_ROOT / "plugin" / "scripts" / "backend-service.sh").read_text()
+
+ assert 'CLAUDE_SMART_HOST:-claude-code}" = "opencode"' in service
+ assert 'CLAUDE_SMART_CLI_PATH="$PLUGIN_ROOT/scripts/opencode-claude-compat"' in service
+ assert 'CLAUDE_SMART_CLI_PATH="$PLUGIN_ROOT/scripts/codex-claude-compat"' in service
+
+
+def test_opencode_cli_bridge_translates_claude_contract(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ bridge = REPO_ROOT / "plugin" / "scripts" / "opencode-claude-compat.js"
+ fake_opencode = tmp_path / "opencode"
+ call_log = tmp_path / "calls.json"
+ fake_opencode.write_text(
+ f"""#!/usr/bin/env node
+const fs = require("fs");
+fs.writeFileSync(
+ {json.dumps(str(call_log))},
+ JSON.stringify({{
+ args: process.argv.slice(2),
+ cwd: process.cwd(),
+ stdin: fs.readFileSync(0, "utf8"),
+ env: {{
+ CLAUDE_SMART_HOST: process.env.CLAUDE_SMART_HOST,
+ CLAUDE_SMART_INTERNAL: process.env.CLAUDE_SMART_INTERNAL
+ }}
+ }})
+);
+process.stdout.write(JSON.stringify({{
+ type: "text",
+ part: {{ type: "text", text: "bridge output" }}
+}}) + "\\n");
+"""
+ )
+ fake_opencode.chmod(0o755)
+ env = {
+ **os.environ,
+ "PATH": f"{tmp_path}{os.pathsep}{os.environ.get('PATH', '')}",
+ }
+
+ result = subprocess.run(
+ [
+ shutil_which_node() or "node",
+ str(bridge),
+ "-p",
+ "--output-format",
+ "stream-json",
+ "--verbose",
+ "--include-partial-messages",
+ "--append-system-prompt",
+ "system text",
+ "--model",
+ "claude-sonnet-4-6",
+ ],
+ input="user prompt",
+ text=True,
+ capture_output=True,
+ env=env,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "type": "result",
+ "subtype": "success",
+ "result": "bridge output",
+ }
+ call = json.loads(call_log.read_text())
+ assert call["env"] == {
+ "CLAUDE_SMART_HOST": "opencode",
+ "CLAUDE_SMART_INTERNAL": "1",
+ }
+ assert call["args"][:4] == ["run", "--pure", "--format", "json"]
+ assert "--agent" in call["args"]
+ assert "--dir" in call["args"]
+ assert "--model" not in call["args"]
+ assert "system text\n\n## Task\nuser prompt" not in call["args"]
+ assert call["stdin"] == "system text\n\n## Task\nuser prompt"
+
+
+def test_opencode_cli_bridge_pipes_large_prompt_via_stdin(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ bridge = REPO_ROOT / "plugin" / "scripts" / "opencode-claude-compat.js"
+ fake_opencode = tmp_path / "opencode"
+ call_log = tmp_path / "calls.json"
+ fake_opencode.write_text(
+ textwrap.dedent(
+ f"""\
+ #!/usr/bin/env node
+ const fs = require("fs");
+ const stdin = fs.readFileSync(0, "utf8");
+ fs.writeFileSync(
+ {json.dumps(str(call_log))},
+ JSON.stringify({{
+ args: process.argv.slice(2),
+ stdinLength: stdin.length
+ }})
+ );
+ process.stdout.write(JSON.stringify({{
+ type: "result",
+ result: "large prompt ok"
+ }}) + "\\n");
+ """
+ )
+ )
+ fake_opencode.chmod(0o755)
+ env = {
+ **os.environ,
+ "PATH": f"{tmp_path}{os.pathsep}{os.environ.get('PATH', '')}",
+ }
+ prompt = "x" * 200_000
+
+ result = subprocess.run(
+ [
+ shutil_which_node() or "node",
+ str(bridge),
+ "-p",
+ "--output-format",
+ "stream-json",
+ ],
+ input=prompt,
+ text=True,
+ capture_output=True,
+ env=env,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout)["result"] == "large prompt ok"
+ call = json.loads(call_log.read_text())
+ assert prompt not in call["args"]
+ assert call["stdinLength"] == len(prompt)
+
+
+def test_node_installer_accepts_opencode_only_extraction_provider(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ fake_opencode = tmp_path / "opencode"
+ fake_opencode.write_text("#!/bin/sh\nexit 0\n")
+ fake_opencode.chmod(0o755)
+ script = (
+ f"process.env.PATH = {json.dumps(str(tmp_path))} + ':' + process.env.PATH;"
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ "process.stdout.write(String(installer.hasExtractionProvider()));"
+ )
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ assert result.stdout == "true"
+
+
+def _fake_opencode_path(tmp_path: Path) -> Path:
+ fake_opencode = tmp_path / "opencode"
+ fake_opencode.write_text("#!/bin/sh\nexit 0\n")
+ fake_opencode.chmod(0o755)
+ return fake_opencode
+
+
+def _isolated_installer_env(tmp_path: Path) -> dict[str, str]:
+ fake_bin = tmp_path / "fake-bin"
+ fake_bin.mkdir()
+ _fake_opencode_path(fake_bin)
+ env = os.environ.copy()
+ env.update(
+ {
+ "HOME": str(tmp_path / "home"),
+ "XDG_CONFIG_HOME": str(tmp_path / "xdg"),
+ "PATH": f"{fake_bin}{os.pathsep}{env.get('PATH', '')}",
+ }
+ )
+ env.pop("REFLEXIO_API_KEY", None)
+ env.pop("CLAUDE_SMART_CLI_PATH", None)
+ return env
+
+
+def test_node_opencode_install_from_npx_root_patches_config_without_bootstrap(
+ tmp_path: Path,
+) -> None:
+ node = shutil_which_node()
+ if node is None:
+ return
+ package_root = tmp_path / "_npx" / "abc" / "node_modules" / "claude-smart"
+ bin_dir = package_root / "bin"
+ bin_dir.mkdir(parents=True)
+ shutil.copy2(REPO_ROOT / "bin" / "claude-smart.js", bin_dir / "claude-smart.js")
+ project = tmp_path / "project"
+ project.mkdir()
+
+ result = subprocess.run(
+ [node, str(bin_dir / "claude-smart.js"), "install", "--host", "opencode"],
+ cwd=project,
+ env=_isolated_installer_env(tmp_path),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert "npx's temporary package cache" in result.stdout
+ assert json.loads((project / "opencode.json").read_text()) == {
+ "plugin": ["claude-smart"]
+ }
+
+
+def test_node_opencode_install_stable_root_bootstrap_failure_leaves_config_unchanged(
+ tmp_path: Path,
+) -> None:
+ node = shutil_which_node()
+ if node is None:
+ return
+ package_root = tmp_path / "global" / "node_modules" / "claude-smart"
+ bin_dir = package_root / "bin"
+ bin_dir.mkdir(parents=True)
+ shutil.copy2(REPO_ROOT / "bin" / "claude-smart.js", bin_dir / "claude-smart.js")
+ project = tmp_path / "project"
+ project.mkdir()
+ config = project / "opencode.json"
+ original = json.dumps({"theme": "system"}) + "\n"
+ config.write_text(original)
+
+ result = subprocess.run(
+ [node, str(bin_dir / "claude-smart.js"), "install", "--host", "opencode"],
+ cwd=project,
+ env=_isolated_installer_env(tmp_path),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ assert result.returncode != 0
+ assert "dependency bootstrap" in result.stderr
+ assert config.read_text() == original
+
+
+def test_opencode_install_patches_project_config_after_bootstrap(
+ monkeypatch, tmp_path, capsys
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(cli, "_REFLEXIO_ENV_PATH", tmp_path / ".reflexio" / ".env")
+ monkeypatch.setattr(
+ cli.shutil, "which", lambda name: f"/bin/{name}" if name == "codex" else None
+ )
+ monkeypatch.setattr(cli, "_bootstrap_opencode_install", lambda _read_only: (True, "/plugin"))
+
+ rc = cli.cmd_install(argparse.Namespace(host="opencode", global_config=False))
+
+ assert rc == 0
+ parsed = json.loads((tmp_path / "opencode.json").read_text())
+ assert parsed["plugin"] == ["claude-smart"]
+ assert "Restart OpenCode" in capsys.readouterr().out
+
+
+def test_opencode_install_patches_existing_dot_opencode_config(
+ monkeypatch, tmp_path
+) -> None:
+ legacy_config = tmp_path / ".opencode" / "opencode.json"
+ legacy_config.parent.mkdir()
+ legacy_config.write_text(json.dumps({"plugin": ["other-plugin"]}))
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(cli, "_REFLEXIO_ENV_PATH", tmp_path / ".reflexio" / ".env")
+ monkeypatch.setattr(
+ cli.shutil, "which", lambda name: f"/bin/{name}" if name == "codex" else None
+ )
+ monkeypatch.setattr(cli, "_bootstrap_opencode_install", lambda _read_only: (True, "/plugin"))
+
+ rc = cli.cmd_install(argparse.Namespace(host="opencode", global_config=False))
+
+ assert rc == 0
+ assert not (tmp_path / "opencode.json").exists()
+ assert json.loads(legacy_config.read_text())["plugin"] == [
+ "other-plugin",
+ "claude-smart",
+ ]
+
+
+def test_opencode_install_migrates_existing_plugins_field(
+ monkeypatch, tmp_path
+) -> None:
+ config = tmp_path / "opencode.json"
+ config.write_text(json.dumps({"plugins": ["other-plugin"], "theme": "system"}))
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(cli, "_REFLEXIO_ENV_PATH", tmp_path / ".reflexio" / ".env")
+ monkeypatch.setattr(
+ cli.shutil, "which", lambda name: f"/bin/{name}" if name == "codex" else None
+ )
+ monkeypatch.setattr(cli, "_bootstrap_opencode_install", lambda _read_only: (True, "/plugin"))
+
+ rc = cli.cmd_install(argparse.Namespace(host="opencode", global_config=False))
+
+ parsed = json.loads(config.read_text())
+ assert rc == 0
+ assert parsed["plugin"] == ["other-plugin", "claude-smart"]
+ assert "plugins" not in parsed
+
+
+def test_node_installer_patches_opencode_jsonc(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ config = tmp_path / "opencode.jsonc"
+ config.write_text('{"plugin": ["other",], // keep parseable\n"theme": "system"}\n')
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ f"installer.patchOpenCodePluginConfig({json.dumps(str(config))}, {{install: true}});"
+ "process.stdout.write(require('fs').readFileSync(process.argv[1], 'utf8'));"
+ )
+
+ result = _run_node_script(script, str(config))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed["plugin"] == ["other", "claude-smart"]
+ assert parsed["theme"] == "system"
+
+
+def test_node_installer_uninstalls_and_preserves_other_plugin_shapes(
+ tmp_path: Path,
+) -> None:
+ if shutil_which_node() is None:
+ return
+ config = tmp_path / "opencode.json"
+ config.write_text(
+ json.dumps(
+ {
+ "plugin": [
+ "claude-smart",
+ "other",
+ ["tuple-plugin", {"enabled": True}],
+ "claude-smart",
+ ],
+ "theme": "system",
+ }
+ )
+ )
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ f"installer.patchOpenCodePluginConfig({json.dumps(str(config))}, {{install: false}});"
+ "process.stdout.write(require('fs').readFileSync(process.argv[1], 'utf8'));"
+ )
+
+ result = _run_node_script(script, str(config))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed["plugin"] == ["other", ["tuple-plugin", {"enabled": True}]]
+ assert parsed["theme"] == "system"
+
+
+def test_node_installer_migrates_existing_plugins_field(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ config = tmp_path / "opencode.json"
+ config.write_text(json.dumps({"plugins": ["other"], "theme": "system"}))
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ f"installer.patchOpenCodePluginConfig({json.dumps(str(config))}, {{install: true}});"
+ "process.stdout.write(require('fs').readFileSync(process.argv[1], 'utf8'));"
+ )
+
+ result = _run_node_script(script, str(config))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed["plugin"] == ["other", "claude-smart"]
+ assert "plugins" not in parsed
+ assert parsed["theme"] == "system"
+
+
+def test_node_installer_uninstall_migrates_existing_plugins_field(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ config = tmp_path / "opencode.json"
+ config.write_text(json.dumps({"plugins": ["claude-smart", "other", "claude-smart"]}))
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ f"installer.patchOpenCodePluginConfig({json.dumps(str(config))}, {{install: false}});"
+ "process.stdout.write(require('fs').readFileSync(process.argv[1], 'utf8'));"
+ )
+
+ result = _run_node_script(script, str(config))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {"plugin": ["other"]}
+
+
+def test_node_installer_uninstall_noops_without_plugin_array(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ config = tmp_path / "opencode.json"
+ config.write_text(json.dumps({"theme": "system"}))
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ f"const result = installer.patchOpenCodePluginConfig({json.dumps(str(config))}, {{install: false}});"
+ "process.stdout.write(JSON.stringify({ result, text: require('fs').readFileSync(process.argv[1], 'utf8') }));"
+ )
+
+ result = _run_node_script(script, str(config))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed["result"]["changed"] is False
+ assert json.loads(parsed["text"]) == {"theme": "system"}
+
+
+def test_node_jsonc_parser_preserves_comma_brace_inside_strings() -> None:
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ "process.stdout.write(installer.stripJsonc('{\"prompt\":\"keep,}\",\"plugin\":[\"claude-smart\",],}\\n'));"
+ )
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {"prompt": "keep,}", "plugin": ["claude-smart"]}
+
+
+def test_node_jsonc_parser_skips_comments_after_trailing_commas() -> None:
+ if shutil_which_node() is None:
+ return
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ "process.stdout.write(installer.stripJsonc('{\"plugin\":[\"other\", // note\\n], /* end */}\\n'));"
+ )
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {"plugin": ["other"]}
+
+
+def test_node_installer_backs_up_jsonc_config(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ config = tmp_path / "opencode.jsonc"
+ original = '{\n // keep my notes\n "plugin": ["other"]\n}\n'
+ config.write_text(original)
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ f"const result = installer.patchOpenCodePluginConfig({json.dumps(str(config))}, {{install: true}});"
+ "process.stdout.write(JSON.stringify({ result, text: require('fs').readFileSync(process.argv[1], 'utf8'), backup: require('fs').readFileSync(`${process.argv[1]}.bak`, 'utf8') }));"
+ )
+
+ result = _run_node_script(script, str(config))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed["result"]["backupPath"] == f"{config}.bak"
+ assert parsed["backup"] == original
+ assert json.loads(parsed["text"])["plugin"] == ["other", "claude-smart"]
+
+
+def test_node_installer_project_config_path_uses_root_by_default_and_legacy_if_present(
+ tmp_path: Path,
+) -> None:
+ if shutil_which_node() is None:
+ return
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ "const fs = require('fs');"
+ "const path = require('path');"
+ "const root = installer.opencodeConfigPath([], process.argv[1]);"
+ "fs.mkdirSync(path.join(process.argv[1], '.opencode'));"
+ "fs.writeFileSync(path.join(process.argv[1], '.opencode', 'opencode.json'), '{\"plugin\":[]}\\n');"
+ "const legacy = installer.opencodeConfigPath([], process.argv[1]);"
+ "process.stdout.write(JSON.stringify({ root, legacy }));"
+ )
+
+ result = _run_node_script(script, str(tmp_path))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed == {
+ "root": str(tmp_path / "opencode.json"),
+ "legacy": str(tmp_path / ".opencode" / "opencode.json"),
+ }
+
+
+def test_node_installer_detects_npx_package_root() -> None:
+ if shutil_which_node() is None:
+ return
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ "process.stdout.write(JSON.stringify({"
+ "unix: installer.isNpxPackageRoot('/home/me/.npm/_npx/abc/node_modules/claude-smart'),"
+ "win: installer.isNpxPackageRoot('C:\\\\Users\\\\me\\\\AppData\\\\Local\\\\npm-cache\\\\_npx\\\\abc\\\\node_modules\\\\claude-smart'),"
+ "global: installer.isNpxPackageRoot('/opt/homebrew/lib/node_modules/claude-smart')"
+ "}));"
+ )
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {"unix": True, "win": True, "global": False}
+
+
+def test_node_installer_skips_backup_for_plain_json(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ config = tmp_path / "opencode.json"
+ config.write_text(json.dumps({"plugin": ["other"]}))
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ f"const result = installer.patchOpenCodePluginConfig({json.dumps(str(config))}, {{install: true}});"
+ "process.stdout.write(JSON.stringify({ result, backupExists: require('fs').existsSync(`${process.argv[1]}.bak`) }));"
+ )
+
+ result = _run_node_script(script, str(config))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed["result"]["backupPath"] is None
+ assert parsed["backupExists"] is False
+
+
+def test_node_installer_rejects_non_array_plugin_without_rewrite(
+ tmp_path: Path,
+) -> None:
+ if shutil_which_node() is None:
+ return
+ config = tmp_path / "opencode.json"
+ original = json.dumps({"plugin": "other", "theme": "system"})
+ config.write_text(original)
+ script = (
+ f"const installer = require({json.dumps(str(REPO_ROOT / 'bin' / 'claude-smart.js'))});"
+ "try {"
+ f" installer.patchOpenCodePluginConfig({json.dumps(str(config))}, {{install: true}});"
+ " process.stdout.write('unexpected success');"
+ " process.exit(2);"
+ "} catch (err) {"
+ " process.stdout.write(JSON.stringify({ message: err.message, text: require('fs').readFileSync(process.argv[1], 'utf8') }));"
+ "}"
+ )
+
+ result = _run_node_script(script, str(config))
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert 'field "plugin" must be a JSON array' in parsed["message"]
+ assert parsed["text"] == original
+
+
+def test_node_opencode_payload_normalizes_tool_contracts() -> None:
+ if shutil_which_node() is None:
+ return
+ script = f"""
+ import({json.dumps(str(REPO_ROOT / "plugin" / "opencode" / "dist" / "payload.js"))}).then((payload) => {{
+ const edit = payload.toolAfterPayload(
+ {{ sessionID: "s1", tool: "edit", args: {{ filePath: "README.md", oldString: "old", newString: "new" }} }},
+ {{ output: "edited" }},
+ "/repo"
+ );
+ const patch = payload.toolAfterPayload(
+ {{ sessionID: "s1", tool: "apply_patch", args: {{ patchText: "*** Begin Patch" }} }},
+ {{ output: "patched" }},
+ "/repo"
+ );
+ const shell = payload.toolAfterPayload(
+ {{ sessionID: "s1", tool: "shell", args: {{ cmd: "npm test" }} }},
+ {{ output: "ok", title: "test", metadata: {{ error: "boom" }} }},
+ "/repo"
+ );
+ process.stdout.write(JSON.stringify({{ edit, patch, shell }}));
+ }}).catch((err) => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed["edit"]["tool_name"] == "Edit"
+ assert parsed["edit"]["tool_input"] == {
+ "filePath": "README.md",
+ "oldString": "old",
+ "newString": "new",
+ "file_path": "README.md",
+ "old_string": "old",
+ "new_string": "new",
+ }
+ assert parsed["edit"]["tool_response"] == {"output": "edited", "stdout": "edited"}
+ assert parsed["patch"]["tool_name"] == "apply_patch"
+ assert parsed["patch"]["tool_input"]["command"] == "*** Begin Patch"
+ assert parsed["patch"]["tool_response"] == {"output": "patched", "stdout": "patched"}
+ assert parsed["shell"]["tool_name"] == "Bash"
+ assert parsed["shell"]["tool_input"]["command"] == "npm test"
+ assert parsed["shell"]["tool_response"] == {
+ "output": "ok",
+ "stdout": "ok",
+ "title": "test",
+ "metadata": {"error": "boom"},
+ "error": "boom",
+ }
+
+
+def test_node_opencode_assistant_buffer_tracks_last_assistant_turn() -> None:
+ script = f"""
+ import({json.dumps(str(REPO_ROOT / "plugin" / "opencode" / "dist" / "assistant-buffer.js"))}).then((mod) => {{
+ const buffer = new mod.AssistantBuffer();
+ buffer.update({{ type: "message.updated", properties: {{ sessionID: "s1", info: {{ id: "m1", role: "assistant" }} }} }});
+ buffer.update({{ type: "message.part.updated", properties: {{ sessionID: "s1", part: {{ id: "p1", messageID: "m1", type: "text", text: "hello" }} }} }});
+ buffer.update({{ type: "message.part.delta", properties: {{ sessionID: "s1", partID: "p1", delta: " world" }} }});
+ buffer.update({{ type: "message.part.updated", properties: {{ sessionID: "s1", part: {{ id: "p2", messageID: "m1", type: "reasoning", text: "reason" }} }} }});
+ buffer.update({{ type: "message.part.delta", properties: {{ sessionID: "s1", partID: "p2", delta: " leaked" }} }});
+ buffer.update({{ type: "message.part.delta", properties: {{ sessionID: "s1", partID: "p3", delta: " unknown" }} }});
+ buffer.update({{ type: "message.part.updated", properties: {{ sessionID: "s1", part: {{ id: "p4", messageID: "m1", type: "text", text: "" }} }} }});
+ buffer.update({{ type: "message.part.delta", properties: {{ sessionID: "s1", partID: "p4", delta: "streamed later" }} }});
+ const beforeClear = buffer.text("s1");
+ buffer.clear("s1");
+ process.stdout.write(JSON.stringify({{ beforeClear, afterClear: buffer.text("s1") }}));
+ }}).catch((err) => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "beforeClear": "hello world\n\nstreamed later",
+ "afterClear": "",
+ }
+
+
+def test_node_opencode_server_injects_cached_context_for_session_ids(
+ tmp_path: Path,
+) -> None:
+ if shutil_which_node() is None:
+ return
+ fake_bash = tmp_path / "bash"
+ fake_bash.write_text(
+ "#!/bin/sh\n"
+ "cat >/dev/null || true\n"
+ "if [ \"${3:-}\" = \"user-prompt\" ]; then\n"
+ " printf '%s\\n' '{\"hookSpecificOutput\":{\"additionalContext\":\"learned context\"}}'\n"
+ "else\n"
+ " printf '%s\\n' '{}'\n"
+ "fi\n"
+ )
+ fake_bash.chmod(0o755)
+ script = f"""
+ process.env.PATH = {json.dumps(str(tmp_path))} + ":" + process.env.PATH;
+ import({json.dumps(str(REPO_ROOT / "plugin" / "opencode" / "dist" / "server.mjs"))}).then(async (mod) => {{
+ const plugin = await mod.default.server({{ directory: "/repo" }});
+ const output1 = {{ system: [] }};
+ await plugin["chat.message"]({{ sessionID: "s1" }}, {{ parts: [{{ type: "text", text: "remember" }}] }});
+ await plugin["experimental.chat.system.transform"]({{ sessionID: "s1" }}, output1);
+ const output2 = {{ system: [] }};
+ await plugin["chat.message"]({{ session_id: "s2" }}, {{ message: {{ content: "remember again" }} }});
+ await plugin["experimental.chat.system.transform"]({{ session_id: "s2" }}, output2);
+ process.stdout.write(JSON.stringify({{ output1, output2 }}));
+ }}).catch((err) => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "output1": {"system": ["learned context"]},
+ "output2": {"system": ["learned context"]},
+ }
+
+
+def test_node_opencode_server_tolerates_closed_child_stdin(tmp_path: Path) -> None:
+ if shutil_which_node() is None:
+ return
+ fake_bash = tmp_path / "bash"
+ fake_bash.write_text("#!/bin/sh\nexit 0\n")
+ fake_bash.chmod(0o755)
+ script = f"""
+ process.env.PATH = {json.dumps(str(tmp_path))} + ":" + process.env.PATH;
+ import({json.dumps(str(REPO_ROOT / "plugin" / "opencode" / "dist" / "server.mjs"))}).then(async (mod) => {{
+ const plugin = await mod.default.server({{ directory: "/repo" }});
+ await plugin["chat.message"]({{ sessionID: "s1" }}, {{ parts: [{{ type: "text", text: "remember" }}] }});
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ process.stdout.write("ok");
+ }}).catch((err) => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ assert result.stdout == "ok"
+
+
+def test_node_opencode_server_dispatches_lifecycle_tool_idle_and_dispose(
+ tmp_path: Path,
+) -> None:
+ if shutil_which_node() is None:
+ return
+ log_path = tmp_path / "calls.jsonl"
+ _write_fake_bash_recorder(tmp_path, user_prompt_context="learned context")
+ script = f"""
+ process.env.PATH = {json.dumps(str(tmp_path))} + ":" + process.env.PATH;
+ process.env.CALL_LOG = {json.dumps(str(log_path))};
+ import({json.dumps(str(REPO_ROOT / "plugin" / "opencode" / "dist" / "server.mjs"))}).then(async (mod) => {{
+ const plugin = await mod.default.server({{ directory: "/repo" }});
+ await plugin.event({{ event: {{ type: "session.created", properties: {{ sessionID: "s-life", info: {{ directory: "/repo" }} }} }} }});
+ await plugin["chat.message"]({{ sessionID: "s-life" }}, {{ message: {{ content: "Use remembered rules" }} }});
+ const systemOutput = {{ system: [] }};
+ await plugin["experimental.chat.system.transform"]({{ sessionID: "s-life" }}, systemOutput);
+ await plugin.event({{ event: {{ type: "message.updated", properties: {{ sessionID: "s-life", info: {{ id: "m1", role: "assistant" }} }} }} }});
+ await plugin.event({{ event: {{ type: "message.part.updated", properties: {{ sessionID: "s-life", part: {{ id: "p1", messageID: "m1", type: "text", text: "final" }} }} }} }});
+ await plugin.event({{ event: {{ type: "message.part.delta", properties: {{ sessionID: "s-life", partID: "p1", delta: " answer" }} }} }});
+ await plugin["tool.execute.after"]({{ sessionID: "s-life", tool: "shell", args: {{ cmd: "echo ok" }} }}, {{ output: "ok" }});
+ await plugin.event({{ event: {{ type: "session.idle", properties: {{ sessionID: "s-life" }} }} }});
+ await plugin.dispose();
+ process.stdout.write(JSON.stringify({{ systemOutput, calls: require("fs").readFileSync(process.env.CALL_LOG, "utf8").trim().split("\\n").map(JSON.parse) }}));
+ }}).catch((err) => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ parsed = json.loads(result.stdout)
+ assert parsed["systemOutput"] == {"system": ["learned context"]}
+ command_pairs = [(Path(call["script"]).name, call["args"]) for call in parsed["calls"]]
+ assert ("backend-service.sh", ["start"]) in command_pairs
+ assert ("dashboard-service.sh", ["start"]) in command_pairs
+ assert ("hook_entry.sh", ["opencode", "session-start"]) in command_pairs
+ assert ("hook_entry.sh", ["opencode", "user-prompt"]) in command_pairs
+ assert ("hook_entry.sh", ["opencode", "post-tool"]) in command_pairs
+ assert ("hook_entry.sh", ["opencode", "stop"]) in command_pairs
+ assert ("dashboard-service.sh", ["session-end"]) in command_pairs
+ assert ("backend-service.sh", ["session-end"]) in command_pairs
+ stop_calls = [
+ json.loads(call["payload"])
+ for call in parsed["calls"]
+ if Path(call["script"]).name == "hook_entry.sh" and call["args"] == ["opencode", "stop"]
+ ]
+ assert stop_calls == [
+ {"session_id": "s-life", "cwd": "/repo", "last_assistant_message": "final answer"}
+ ]
+
+
+def test_node_opencode_server_dispose_flushes_active_session_without_idle(
+ tmp_path: Path,
+) -> None:
+ if shutil_which_node() is None:
+ return
+ log_path = tmp_path / "calls.jsonl"
+ _write_fake_bash_recorder(tmp_path)
+ script = f"""
+ process.env.PATH = {json.dumps(str(tmp_path))} + ":" + process.env.PATH;
+ process.env.CALL_LOG = {json.dumps(str(log_path))};
+ import({json.dumps(str(REPO_ROOT / "plugin" / "opencode" / "dist" / "server.mjs"))}).then(async (mod) => {{
+ const plugin = await mod.default.server({{ directory: "/repo" }});
+ await plugin["chat.message"]({{ sessionID: "s-dispose" }}, {{ message: {{ content: "remember this" }} }});
+ await plugin.event({{ event: {{ type: "message.updated", properties: {{ sessionID: "s-dispose", info: {{ id: "m1", role: "assistant" }} }} }} }});
+ await plugin.event({{ event: {{ type: "message.part.updated", properties: {{ sessionID: "s-dispose", part: {{ id: "p1", messageID: "m1", type: "text", text: "remembered" }} }} }} }});
+ await Promise.all([plugin.dispose(), plugin.dispose()]);
+ process.stdout.write(require("fs").readFileSync(process.env.CALL_LOG, "utf8"));
+ }}).catch((err) => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ calls = [json.loads(line) for line in result.stdout.strip().split("\n")]
+ stop_calls = [
+ json.loads(call["payload"])
+ for call in calls
+ if Path(call["script"]).name == "hook_entry.sh" and call["args"] == ["opencode", "stop"]
+ ]
+ assert stop_calls == [
+ {"session_id": "s-dispose", "cwd": "/repo", "last_assistant_message": "remembered"}
+ ]
+
+
+def test_node_opencode_server_text_complete_flushes_once(
+ tmp_path: Path,
+) -> None:
+ if shutil_which_node() is None:
+ return
+ log_path = tmp_path / "calls.jsonl"
+ _write_fake_bash_recorder(tmp_path)
+ script = f"""
+ process.env.PATH = {json.dumps(str(tmp_path))} + ":" + process.env.PATH;
+ process.env.CALL_LOG = {json.dumps(str(log_path))};
+ import({json.dumps(str(REPO_ROOT / "plugin" / "opencode" / "dist" / "server.mjs"))}).then(async (mod) => {{
+ const plugin = await mod.default.server({{ directory: "/repo" }});
+ await plugin["chat.message"]({{ sessionID: "s-complete" }}, {{ message: {{ content: "remember this" }} }});
+ await plugin["experimental.text.complete"]({{ sessionID: "s-complete" }}, {{ text: "done" }});
+ await plugin.event({{ event: {{ type: "session.idle", properties: {{ sessionID: "s-complete" }} }} }});
+ await plugin.dispose();
+ process.stdout.write(require("fs").readFileSync(process.env.CALL_LOG, "utf8"));
+ }}).catch((err) => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ calls = [json.loads(line) for line in result.stdout.strip().split("\n")]
+ stop_calls = [
+ json.loads(call["payload"])
+ for call in calls
+ if Path(call["script"]).name == "hook_entry.sh" and call["args"] == ["opencode", "stop"]
+ ]
+ assert stop_calls == [
+ {"session_id": "s-complete", "cwd": "/repo", "last_assistant_message": "done"}
+ ]
+
+
+def test_node_opencode_server_keeps_multi_segment_assistant_text(
+ tmp_path: Path,
+) -> None:
+ if shutil_which_node() is None:
+ return
+ log_path = tmp_path / "calls.jsonl"
+ _write_fake_bash_recorder(tmp_path)
+ script = f"""
+ process.env.PATH = {json.dumps(str(tmp_path))} + ":" + process.env.PATH;
+ process.env.CALL_LOG = {json.dumps(str(log_path))};
+ import({json.dumps(str(REPO_ROOT / "plugin" / "opencode" / "dist" / "server.mjs"))}).then(async (mod) => {{
+ const plugin = await mod.default.server({{ directory: "/repo" }});
+ await plugin["chat.message"]({{ sessionID: "s-multi" }}, {{ message: {{ content: "remember this" }} }});
+ await plugin.event({{ event: {{ type: "message.updated", properties: {{ sessionID: "s-multi", info: {{ id: "m1", role: "assistant" }} }} }} }});
+ await plugin.event({{ event: {{ type: "message.part.updated", properties: {{ sessionID: "s-multi", part: {{ id: "p1", messageID: "m1", type: "text", text: "first" }} }} }} }});
+ await plugin["experimental.text.complete"]({{ sessionID: "s-multi" }}, {{ text: "first" }});
+ await plugin.event({{ event: {{ type: "message.part.updated", properties: {{ sessionID: "s-multi", part: {{ id: "p2", messageID: "m1", type: "text", text: "second" }} }} }} }});
+ await plugin["experimental.text.complete"]({{ sessionID: "s-multi" }}, {{ text: "second" }});
+ await plugin.event({{ event: {{ type: "session.idle", properties: {{ sessionID: "s-multi" }} }} }});
+ await plugin.dispose();
+ process.stdout.write(require("fs").readFileSync(process.env.CALL_LOG, "utf8"));
+ }}).catch((err) => {{
+ console.error(err);
+ process.exit(1);
+ }});
+ """
+
+ result = _run_node_script(script)
+ assert result is not None
+
+ assert result.returncode == 0, result.stderr
+ calls = [json.loads(line) for line in result.stdout.strip().split("\n")]
+ stop_calls = [
+ json.loads(call["payload"])
+ for call in calls
+ if Path(call["script"]).name == "hook_entry.sh" and call["args"] == ["opencode", "stop"]
+ ]
+ assert stop_calls == [
+ {"session_id": "s-multi", "cwd": "/repo", "last_assistant_message": "first\n\nsecond"}
+ ]
+
+
+def test_opencode_dist_files_are_packaged() -> None:
+ package = _read_json("package.json")
+
+ assert "plugin" in package["files"]
+ assert package["exports"]["./server"]["import"] == "./plugin/opencode/dist/server.mjs"
+
+
+def test_opencode_dist_matches_typescript_sources(tmp_path: Path) -> None:
+ import filecmp
+ import shutil
+
+ if shutil.which("npx") is None:
+ return
+ out_dir = tmp_path / "dist"
+ result = subprocess.run(
+ [
+ "npx",
+ "tsc",
+ "-p",
+ str(REPO_ROOT / "plugin" / "opencode" / "tsconfig.json"),
+ "--outDir",
+ str(out_dir),
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ for filename in ("assistant-buffer.js", "internal.js", "payload.js", "server.mjs"):
+ expected = REPO_ROOT / "plugin" / "opencode" / "dist" / filename
+ generated = out_dir / filename
+ assert filecmp.cmp(expected, generated, shallow=False), filename
+
+
+def test_node_parse_host_accepts_opencode() -> None:
+ installer = (REPO_ROOT / "bin" / "claude-smart.js").read_text()
+
+ assert 'value !== "claude-code" && value !== "codex" && value !== "opencode"' in installer
+ assert "runInstallOpenCode" in installer
+ assert "runUninstallOpenCode" in installer
+
+
+def shutil_which_node() -> str | None:
+ import shutil
+
+ return shutil.which("node")
diff --git a/tests/test_stall_banner.py b/tests/test_stall_banner.py
index a83c791..0fad4c3 100644
--- a/tests/test_stall_banner.py
+++ b/tests/test_stall_banner.py
@@ -4,8 +4,6 @@
from datetime import datetime, timezone
-import pytest
-
from claude_smart.stall_banner import render_banner