From 98a9684356089953641a7e71e3dd24d1a540ad55 Mon Sep 17 00:00:00 2001 From: ZeR020 Date: Wed, 24 Jun 2026 19:16:06 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20OpenCode=20feature=20parity=20=E2=80=94?= =?UTF-8?q?=20plugin=20+=20skills=20+=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full OpenCode integration matching Claude Code parity: - TypeScript plugin (tool.execute.after for grep/glob graph augmentation, experimental.chat.system.transform for session reminder) - Consolidated codebase-memory skill installation - Plugin auto-discovered from ~/.config/opencode/plugins/ (no config entry needed) - Skill auto-discovered from ~/.config/opencode/skills/ Design note: uses tool.execute.after (NOT .before as proposed in #585) because source verification against anomalyco/opencode proved .before output mutation has no effect — the tool uses original args, not output.args. The .after hook mutates output.output which is model-visible. Non-blocking: all plugin failures silently swallowed (consistent with Claude Code hook gate script behavior). TOCTOU-safe fchmod pattern. Defensive binary-path quote rejection (security). 5 new tests: plugin install/idempotent/quote-reject/remove, skills install. Closes #585 --- src/cli/cli.c | 149 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli/cli.h | 9 +++ tests/test_cli.c | 130 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+) diff --git a/src/cli/cli.c b/src/cli/cli.c index f159f591..7a46eca1 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -524,6 +524,58 @@ static const char skill_content[] = "`direction=\"both\"`.\n" "5. Results default to 10 per page — check `has_more` and use `offset`.\n"; +/* ── OpenCode plugin (TypeScript, auto-discovered from ~/.config/opencode/plugins/) ── + * Installs a .ts plugin that wires tool.execute.after (grep/glob graph augment) + * and experimental.chat.system.transform (session reminder). The binary path is + * embedded at install time via fprintf %s — same pattern as the Claude Code + * hook gate script. Uses tool.execute.after (NOT .before) because .before output + * mutation has no effect on the tool result the model sees (verified against + * anomalyco/opencode source: packages/opencode/src/session/tools.ts). */ +static const char opencode_plugin_content[] = + "import type { Plugin } from \"@opencode-ai/plugin\"\n" + "import { spawn } from \"child_process\"\n" + "\n" + "const BIN = \"%s\"\n" + "\n" + "export default (async () => {\n" + " return {\n" + " \"tool.execute.after\": async (input: { tool: string; args: any }, " + "output: { output: string }) => {\n" + " if (input.tool !== \"grep\" && input.tool !== \"glob\") return\n" + " const pattern = input.args?.pattern\n" + " if (!pattern) return\n" + " const toolName = input.tool === \"grep\" ? \"Grep\" : \"Glob\"\n" + " try {\n" + " const payload = JSON.stringify({ tool_name: toolName, " + "tool_input: { pattern } })\n" + " const child = spawn(BIN, [\"hook-augment\"], " + "{ stdio: [\"pipe\", \"pipe\", \"ignore\"] })\n" + " child.stdin.write(payload)\n" + " child.stdin.end()\n" + " let stdout = \"\"\n" + " child.stdout.on(\"data\", (d: Buffer) => { stdout += d.toString() })\n" + " await new Promise((resolve) => {\n" + " child.on(\"close\", () => resolve())\n" + " child.on(\"error\", () => resolve())\n" + " })\n" + " if (!stdout.trim()) return\n" + " const parsed = JSON.parse(stdout)\n" + " const ctx = parsed?.hookSpecificOutput?.additionalContext\n" + " if (typeof ctx === \"string\" && ctx.length > 0) {\n" + " output.output += \"\\n\\n\" + ctx\n" + " }\n" + " } catch (e) {}\n" + " },\n" + " \"experimental.chat.system.transform\": async (_input: any, " + "output: { system: string[] }) => {\n" + " output.system.push(\"Code discovery: prefer codebase-memory-mcp " + "(search_graph, trace_path, get_code_snippet, query_graph, search_code) " + "over grep/file-read; run index_repository first if the project is not " + "indexed.\")\n" + " }\n" + " }\n" + "}) satisfies Plugin\n"; + static const char codex_instructions_content[] = "# Codebase Knowledge Graph\n" "\n" @@ -1668,6 +1720,67 @@ int cbm_remove_opencode_mcp(const char *config_path) { return rc; } +/* ── OpenCode plugin install/remove ────────────────────────────── + * Writes the TypeScript plugin to ~/.config/opencode/plugins/cbm-augment.ts. + * OpenCode auto-discovers .ts files in this directory — no opencode.json entry + * needed. The plugin wires tool.execute.after (grep/glob graph augment) and + * experimental.chat.system.transform (session reminder). Follows the same + * TOCTOU-safe pattern as cbm_install_hook_gate_script: fchmod before fclose. */ + +#define CMM_OPENCODE_PLUGIN_NAME "cbm-augment.ts" + +int cbm_upsert_opencode_plugin(const char *home, const char *binary_path, bool dry_run) { + if (!home || !binary_path) { + return CLI_ERR; + } + if (strchr(binary_path, '"') != NULL) { + return CLI_ERR; + } + char plugins_dir[CLI_BUF_1K]; + snprintf(plugins_dir, sizeof(plugins_dir), "%s/.config/opencode/plugins", home); + if (!dry_run) { + cbm_mkdir_p(plugins_dir, CLI_OCTAL_PERM); + } + + char plugin_path[CLI_BUF_1K]; + snprintf(plugin_path, sizeof(plugin_path), "%s/%s", plugins_dir, CMM_OPENCODE_PLUGIN_NAME); + + if (dry_run) { + return 0; + } + + FILE *f = fopen(plugin_path, "w"); + if (!f) { + return CLI_ERR; + } + (void)fprintf(f, opencode_plugin_content, binary_path); +#ifndef _WIN32 + fchmod(fileno(f), CLI_OCTAL_PERM); +#endif + (void)fclose(f); +#ifdef _WIN32 + chmod(plugin_path, CLI_OCTAL_PERM); +#endif + return 0; +} + +int cbm_remove_opencode_plugin(const char *home, bool dry_run) { + if (!home) { + return CLI_ERR; + } + char plugin_path[CLI_BUF_1K]; + snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/%s", + home, CMM_OPENCODE_PLUGIN_NAME); + if (dry_run) { + return 0; + } + struct stat st; + if (stat(plugin_path, &st) == 0) { + return cbm_unlink(plugin_path); + } + return 0; +} + /* ── Antigravity MCP config (JSON, same mcpServers format) ────── */ int cbm_upsert_antigravity_mcp(const char *binary_path, const char *config_path) { @@ -3135,10 +3248,36 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const if (agents->opencode) { char cp[CLI_BUF_1K]; char ip[CLI_BUF_1K]; + char skills_dir[CLI_BUF_1K]; snprintf(cp, sizeof(cp), "%s/.config/opencode/opencode.json", home); snprintf(ip, sizeof(ip), "%s/.config/opencode/AGENTS.md", home); + snprintf(skills_dir, sizeof(skills_dir), "%s/.config/opencode/skills", home); + + /* Plan mode: record planned writes for skills + plugin, mutate nothing + * (#388). MCP config + instructions are recorded by install_generic_agent_config. */ + if (g_install_plan) { + char plugin_path[CLI_BUF_1K]; + snprintf(plugin_path, sizeof(plugin_path), + "%s/.config/opencode/plugins/%s", home, CMM_OPENCODE_PLUGIN_NAME); + plan_record("OpenCode", "skills", skills_dir); + plan_record("OpenCode", "plugin", plugin_path); + } + install_generic_agent_config("OpenCode", binary_path, cp, ip, dry_run, cbm_upsert_opencode_mcp); + + if (!g_install_plan) { + /* Install skills (same consolidated skill as Claude Code). */ + int skill_count = cbm_install_skills(skills_dir, true, dry_run); + printf(" skills: %d installed\n", skill_count); + + /* Install TypeScript plugin (tool.execute.after + system.transform). */ + if (!dry_run) { + cbm_upsert_opencode_plugin(home, binary_path, dry_run); + } + printf(" plugin: tool.execute.after (Grep/Glob graph augment) + " + "experimental.chat.system.transform (session reminder)\n"); + } } if (agents->antigravity) { char cp[CLI_BUF_1K]; @@ -3632,10 +3771,20 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char if (agents->opencode) { char cp[CLI_BUF_1K]; char ip[CLI_BUF_1K]; + char skills_dir[CLI_BUF_1K]; snprintf(cp, sizeof(cp), "%s/.config/opencode/opencode.json", home); snprintf(ip, sizeof(ip), "%s/.config/opencode/AGENTS.md", home); + snprintf(skills_dir, sizeof(skills_dir), "%s/.config/opencode/skills", home); uninstall_agent_mcp_instr((mcp_uninstall_args_t){"OpenCode", cp, ip}, dry_run, cbm_remove_opencode_mcp); + /* Remove skills. */ + int removed = cbm_remove_skills(skills_dir, dry_run); + printf("OpenCode: removed %d skill(s)\n", removed); + /* Remove TypeScript plugin. */ + if (!dry_run) { + cbm_remove_opencode_plugin(home, dry_run); + } + printf(" removed plugin\n"); } if (agents->antigravity) { char cp[CLI_BUF_1K]; diff --git a/src/cli/cli.h b/src/cli/cli.h index 9efe6789..dfdc47a1 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -148,6 +148,15 @@ int cbm_upsert_opencode_mcp(const char *binary_path, const char *config_path); /* Remove CMM MCP entry from opencode.json. Returns 0 on success. */ int cbm_remove_opencode_mcp(const char *config_path); +/* OpenCode: install TypeScript plugin to ~/.config/opencode/plugins/cbm-augment.ts. + * Wires tool.execute.after (grep/glob graph augment) and + * experimental.chat.system.transform (session reminder). + * Returns 0 on success. */ +int cbm_upsert_opencode_plugin(const char *home, const char *binary_path, bool dry_run); + +/* Remove the OpenCode plugin file. Returns 0 on success. */ +int cbm_remove_opencode_plugin(const char *home, bool dry_run); + /* Antigravity: upsert MCP entry in ~/.gemini/antigravity/mcp_config.json. * Returns 0 on success. */ int cbm_upsert_antigravity_mcp(const char *binary_path, const char *config_path); diff --git a/tests/test_cli.c b/tests/test_cli.c index 0b78537c..011f6a13 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -1963,6 +1963,129 @@ TEST(cli_upsert_opencode_mcp_existing) { PASS(); } +/* ═══════════════════════════════════════════════════════════════════ + * Group B: OpenCode Plugin + Skills + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(cli_upsert_opencode_plugin_fresh) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-plug-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + int rc = cbm_upsert_opencode_plugin(tmpdir, "/usr/local/bin/codebase-memory-mcp", false); + ASSERT_EQ(rc, 0); + + char plugin_path[512]; + snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts", + tmpdir); + const char *data = read_test_file(plugin_path); + ASSERT_NOT_NULL(data); + ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL); + ASSERT(strstr(data, "tool.execute.after") != NULL); + ASSERT(strstr(data, "experimental.chat.system.transform") != NULL); + ASSERT(strstr(data, "hook-augment") != NULL); + ASSERT(strstr(data, "satisfies Plugin") != NULL); + + test_rmdir_r(tmpdir); + PASS(); +} + +TEST(cli_upsert_opencode_plugin_idempotent) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-plug2-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + /* First install */ + int rc = cbm_upsert_opencode_plugin(tmpdir, "/usr/local/bin/codebase-memory-mcp", false); + ASSERT_EQ(rc, 0); + + /* Re-install with different path — should overwrite cleanly */ + rc = cbm_upsert_opencode_plugin(tmpdir, "/opt/cbm/codebase-memory-mcp", false); + ASSERT_EQ(rc, 0); + + char plugin_path[512]; + snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts", + tmpdir); + const char *data = read_test_file(plugin_path); + ASSERT_NOT_NULL(data); + ASSERT(strstr(data, "/opt/cbm/codebase-memory-mcp") != NULL); + ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") == NULL); + + test_rmdir_r(tmpdir); + PASS(); +} + +TEST(cli_upsert_opencode_plugin_rejects_quote) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-plug3-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + /* Binary path containing a double-quote must be rejected (security). */ + int rc = cbm_upsert_opencode_plugin(tmpdir, "/usr/local/bin/\"evil\"", false); + ASSERT_NEQ(rc, 0); + + test_rmdir_r(tmpdir); + PASS(); +} + +TEST(cli_remove_opencode_plugin) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-plug4-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + /* Install then remove */ + int rc = cbm_upsert_opencode_plugin(tmpdir, "/usr/local/bin/codebase-memory-mcp", false); + ASSERT_EQ(rc, 0); + + char plugin_path[512]; + snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts", + tmpdir); + struct stat st; + ASSERT(stat(plugin_path, &st) == 0); + + rc = cbm_remove_opencode_plugin(tmpdir, false); + ASSERT_EQ(rc, 0); + ASSERT(stat(plugin_path, &st) != 0); + + /* Remove again — should succeed (idempotent) */ + rc = cbm_remove_opencode_plugin(tmpdir, false); + ASSERT_EQ(rc, 0); + + test_rmdir_r(tmpdir); + PASS(); +} + +TEST(cli_opencode_skills_installed) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-skill-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char skills_dir[512]; + snprintf(skills_dir, sizeof(skills_dir), "%s/skills", tmpdir); + + int count = cbm_install_skills(skills_dir, true, false); + ASSERT_EQ(count, CBM_SKILL_COUNT); + + char skill_file[512]; + snprintf(skill_file, sizeof(skill_file), "%s/codebase-memory/SKILL.md", skills_dir); + const char *data = read_test_file(skill_file); + ASSERT_NOT_NULL(data); + ASSERT(strstr(data, "codebase-memory") != NULL); + ASSERT(strstr(data, "search_graph") != NULL); + + /* Remove skills */ + int removed = cbm_remove_skills(skills_dir, false); + ASSERT_EQ(removed, CBM_SKILL_COUNT); + + test_rmdir_r(tmpdir); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * Group B: MCP Config Upsert — Antigravity * ═══════════════════════════════════════════════════════════════════ */ @@ -2770,6 +2893,13 @@ SUITE(cli) { RUN_TEST(cli_upsert_opencode_mcp_fresh); RUN_TEST(cli_upsert_opencode_mcp_existing); + /* OpenCode plugin + skills (5 tests — group B) */ + RUN_TEST(cli_upsert_opencode_plugin_fresh); + RUN_TEST(cli_upsert_opencode_plugin_idempotent); + RUN_TEST(cli_upsert_opencode_plugin_rejects_quote); + RUN_TEST(cli_remove_opencode_plugin); + RUN_TEST(cli_opencode_skills_installed); + /* Antigravity MCP config upsert (2 tests — group B) */ RUN_TEST(cli_upsert_antigravity_mcp_fresh); RUN_TEST(cli_upsert_antigravity_mcp_replace);