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 @@ Node - Hosts + Hosts Discord @@ -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