Skip to content

Commit 7116a76

Browse files
committed
feat: OpenCode feature parity — plugin + skills + hooks
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 Signed-off-by: ZeR020 <ZeR020@users.noreply.github.com>
1 parent 34efbc0 commit 7116a76

3 files changed

Lines changed: 288 additions & 0 deletions

File tree

‎src/cli/cli.c‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,58 @@ static const char skill_content[] =
524524
"`direction=\"both\"`.\n"
525525
"5. Results default to 10 per page — check `has_more` and use `offset`.\n";
526526

527+
/* ── OpenCode plugin (TypeScript, auto-discovered from ~/.config/opencode/plugins/) ──
528+
* Installs a .ts plugin that wires tool.execute.after (grep/glob graph augment)
529+
* and experimental.chat.system.transform (session reminder). The binary path is
530+
* embedded at install time via fprintf %s — same pattern as the Claude Code
531+
* hook gate script. Uses tool.execute.after (NOT .before) because .before output
532+
* mutation has no effect on the tool result the model sees (verified against
533+
* anomalyco/opencode source: packages/opencode/src/session/tools.ts). */
534+
static const char opencode_plugin_content[] =
535+
"import type { Plugin } from \"@opencode-ai/plugin\"\n"
536+
"import { spawn } from \"child_process\"\n"
537+
"\n"
538+
"const BIN = \"%s\"\n"
539+
"\n"
540+
"export default (async () => {\n"
541+
" return {\n"
542+
" \"tool.execute.after\": async (input: { tool: string; args: any }, "
543+
"output: { output: string }) => {\n"
544+
" if (input.tool !== \"grep\" && input.tool !== \"glob\") return\n"
545+
" const pattern = input.args?.pattern\n"
546+
" if (!pattern) return\n"
547+
" const toolName = input.tool === \"grep\" ? \"Grep\" : \"Glob\"\n"
548+
" try {\n"
549+
" const payload = JSON.stringify({ tool_name: toolName, "
550+
"tool_input: { pattern } })\n"
551+
" const child = spawn(BIN, [\"hook-augment\"], "
552+
"{ stdio: [\"pipe\", \"pipe\", \"ignore\"] })\n"
553+
" child.stdin.write(payload)\n"
554+
" child.stdin.end()\n"
555+
" let stdout = \"\"\n"
556+
" child.stdout.on(\"data\", (d: Buffer) => { stdout += d.toString() })\n"
557+
" await new Promise<void>((resolve) => {\n"
558+
" child.on(\"close\", () => resolve())\n"
559+
" child.on(\"error\", () => resolve())\n"
560+
" })\n"
561+
" if (!stdout.trim()) return\n"
562+
" const parsed = JSON.parse(stdout)\n"
563+
" const ctx = parsed?.hookSpecificOutput?.additionalContext\n"
564+
" if (typeof ctx === \"string\" && ctx.length > 0) {\n"
565+
" output.output += \"\\n\\n\" + ctx\n"
566+
" }\n"
567+
" } catch (e) {}\n"
568+
" },\n"
569+
" \"experimental.chat.system.transform\": async (_input: any, "
570+
"output: { system: string[] }) => {\n"
571+
" output.system.push(\"Code discovery: prefer codebase-memory-mcp "
572+
"(search_graph, trace_path, get_code_snippet, query_graph, search_code) "
573+
"over grep/file-read; run index_repository first if the project is not "
574+
"indexed.\")\n"
575+
" }\n"
576+
" }\n"
577+
"}) satisfies Plugin\n";
578+
527579
static const char codex_instructions_content[] =
528580
"# Codebase Knowledge Graph\n"
529581
"\n"
@@ -1668,6 +1720,67 @@ int cbm_remove_opencode_mcp(const char *config_path) {
16681720
return rc;
16691721
}
16701722

1723+
/* ── OpenCode plugin install/remove ──────────────────────────────
1724+
* Writes the TypeScript plugin to ~/.config/opencode/plugins/cbm-augment.ts.
1725+
* OpenCode auto-discovers .ts files in this directory — no opencode.json entry
1726+
* needed. The plugin wires tool.execute.after (grep/glob graph augment) and
1727+
* experimental.chat.system.transform (session reminder). Follows the same
1728+
* TOCTOU-safe pattern as cbm_install_hook_gate_script: fchmod before fclose. */
1729+
1730+
#define CMM_OPENCODE_PLUGIN_NAME "cbm-augment.ts"
1731+
1732+
int cbm_upsert_opencode_plugin(const char *home, const char *binary_path, bool dry_run) {
1733+
if (!home || !binary_path) {
1734+
return CLI_ERR;
1735+
}
1736+
if (strchr(binary_path, '"') != NULL) {
1737+
return CLI_ERR;
1738+
}
1739+
char plugins_dir[CLI_BUF_1K];
1740+
snprintf(plugins_dir, sizeof(plugins_dir), "%s/.config/opencode/plugins", home);
1741+
if (!dry_run) {
1742+
cbm_mkdir_p(plugins_dir, CLI_OCTAL_PERM);
1743+
}
1744+
1745+
char plugin_path[CLI_BUF_1K];
1746+
snprintf(plugin_path, sizeof(plugin_path), "%s/%s", plugins_dir, CMM_OPENCODE_PLUGIN_NAME);
1747+
1748+
if (dry_run) {
1749+
return 0;
1750+
}
1751+
1752+
FILE *f = fopen(plugin_path, "w");
1753+
if (!f) {
1754+
return CLI_ERR;
1755+
}
1756+
(void)fprintf(f, opencode_plugin_content, binary_path);
1757+
#ifndef _WIN32
1758+
fchmod(fileno(f), CLI_OCTAL_PERM);
1759+
#endif
1760+
(void)fclose(f);
1761+
#ifdef _WIN32
1762+
chmod(plugin_path, CLI_OCTAL_PERM);
1763+
#endif
1764+
return 0;
1765+
}
1766+
1767+
int cbm_remove_opencode_plugin(const char *home, bool dry_run) {
1768+
if (!home) {
1769+
return CLI_ERR;
1770+
}
1771+
char plugin_path[CLI_BUF_1K];
1772+
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/%s", home,
1773+
CMM_OPENCODE_PLUGIN_NAME);
1774+
if (dry_run) {
1775+
return 0;
1776+
}
1777+
struct stat st;
1778+
if (stat(plugin_path, &st) == 0) {
1779+
return cbm_unlink(plugin_path);
1780+
}
1781+
return 0;
1782+
}
1783+
16711784
/* ── Antigravity MCP config (JSON, same mcpServers format) ────── */
16721785

16731786
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
31353248
if (agents->opencode) {
31363249
char cp[CLI_BUF_1K];
31373250
char ip[CLI_BUF_1K];
3251+
char skills_dir[CLI_BUF_1K];
31383252
snprintf(cp, sizeof(cp), "%s/.config/opencode/opencode.json", home);
31393253
snprintf(ip, sizeof(ip), "%s/.config/opencode/AGENTS.md", home);
3254+
snprintf(skills_dir, sizeof(skills_dir), "%s/.config/opencode/skills", home);
3255+
3256+
/* Plan mode: record planned writes for skills + plugin, mutate nothing
3257+
* (#388). MCP config + instructions are recorded by install_generic_agent_config. */
3258+
if (g_install_plan) {
3259+
char plugin_path[CLI_BUF_1K];
3260+
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/%s", home,
3261+
CMM_OPENCODE_PLUGIN_NAME);
3262+
plan_record("OpenCode", "skills", skills_dir);
3263+
plan_record("OpenCode", "plugin", plugin_path);
3264+
}
3265+
31403266
install_generic_agent_config("OpenCode", binary_path, cp, ip, dry_run,
31413267
cbm_upsert_opencode_mcp);
3268+
3269+
if (!g_install_plan) {
3270+
/* Install skills (same consolidated skill as Claude Code). */
3271+
int skill_count = cbm_install_skills(skills_dir, true, dry_run);
3272+
printf(" skills: %d installed\n", skill_count);
3273+
3274+
/* Install TypeScript plugin (tool.execute.after + system.transform). */
3275+
if (!dry_run) {
3276+
cbm_upsert_opencode_plugin(home, binary_path, dry_run);
3277+
}
3278+
printf(" plugin: tool.execute.after (Grep/Glob graph augment) + "
3279+
"experimental.chat.system.transform (session reminder)\n");
3280+
}
31423281
}
31433282
if (agents->antigravity) {
31443283
char cp[CLI_BUF_1K];
@@ -3632,10 +3771,20 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char
36323771
if (agents->opencode) {
36333772
char cp[CLI_BUF_1K];
36343773
char ip[CLI_BUF_1K];
3774+
char skills_dir[CLI_BUF_1K];
36353775
snprintf(cp, sizeof(cp), "%s/.config/opencode/opencode.json", home);
36363776
snprintf(ip, sizeof(ip), "%s/.config/opencode/AGENTS.md", home);
3777+
snprintf(skills_dir, sizeof(skills_dir), "%s/.config/opencode/skills", home);
36373778
uninstall_agent_mcp_instr((mcp_uninstall_args_t){"OpenCode", cp, ip}, dry_run,
36383779
cbm_remove_opencode_mcp);
3780+
/* Remove skills. */
3781+
int removed = cbm_remove_skills(skills_dir, dry_run);
3782+
printf("OpenCode: removed %d skill(s)\n", removed);
3783+
/* Remove TypeScript plugin. */
3784+
if (!dry_run) {
3785+
cbm_remove_opencode_plugin(home, dry_run);
3786+
}
3787+
printf(" removed plugin\n");
36393788
}
36403789
if (agents->antigravity) {
36413790
char cp[CLI_BUF_1K];

‎src/cli/cli.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,15 @@ int cbm_upsert_opencode_mcp(const char *binary_path, const char *config_path);
148148
/* Remove CMM MCP entry from opencode.json. Returns 0 on success. */
149149
int cbm_remove_opencode_mcp(const char *config_path);
150150

151+
/* OpenCode: install TypeScript plugin to ~/.config/opencode/plugins/cbm-augment.ts.
152+
* Wires tool.execute.after (grep/glob graph augment) and
153+
* experimental.chat.system.transform (session reminder).
154+
* Returns 0 on success. */
155+
int cbm_upsert_opencode_plugin(const char *home, const char *binary_path, bool dry_run);
156+
157+
/* Remove the OpenCode plugin file. Returns 0 on success. */
158+
int cbm_remove_opencode_plugin(const char *home, bool dry_run);
159+
151160
/* Antigravity: upsert MCP entry in ~/.gemini/antigravity/mcp_config.json.
152161
* Returns 0 on success. */
153162
int cbm_upsert_antigravity_mcp(const char *binary_path, const char *config_path);

‎tests/test_cli.c‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,6 +1963,129 @@ TEST(cli_upsert_opencode_mcp_existing) {
19631963
PASS();
19641964
}
19651965

1966+
/* ═══════════════════════════════════════════════════════════════════
1967+
* Group B: OpenCode Plugin + Skills
1968+
* ═══════════════════════════════════════════════════════════════════ */
1969+
1970+
TEST(cli_upsert_opencode_plugin_fresh) {
1971+
char tmpdir[256];
1972+
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-plug-XXXXXX");
1973+
if (!cbm_mkdtemp(tmpdir))
1974+
FAIL("cbm_mkdtemp failed");
1975+
1976+
int rc = cbm_upsert_opencode_plugin(tmpdir, "/usr/local/bin/codebase-memory-mcp", false);
1977+
ASSERT_EQ(rc, 0);
1978+
1979+
char plugin_path[512];
1980+
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts",
1981+
tmpdir);
1982+
const char *data = read_test_file(plugin_path);
1983+
ASSERT_NOT_NULL(data);
1984+
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL);
1985+
ASSERT(strstr(data, "tool.execute.after") != NULL);
1986+
ASSERT(strstr(data, "experimental.chat.system.transform") != NULL);
1987+
ASSERT(strstr(data, "hook-augment") != NULL);
1988+
ASSERT(strstr(data, "satisfies Plugin") != NULL);
1989+
1990+
test_rmdir_r(tmpdir);
1991+
PASS();
1992+
}
1993+
1994+
TEST(cli_upsert_opencode_plugin_idempotent) {
1995+
char tmpdir[256];
1996+
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-plug2-XXXXXX");
1997+
if (!cbm_mkdtemp(tmpdir))
1998+
FAIL("cbm_mkdtemp failed");
1999+
2000+
/* First install */
2001+
int rc = cbm_upsert_opencode_plugin(tmpdir, "/usr/local/bin/codebase-memory-mcp", false);
2002+
ASSERT_EQ(rc, 0);
2003+
2004+
/* Re-install with different path — should overwrite cleanly */
2005+
rc = cbm_upsert_opencode_plugin(tmpdir, "/opt/cbm/codebase-memory-mcp", false);
2006+
ASSERT_EQ(rc, 0);
2007+
2008+
char plugin_path[512];
2009+
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts",
2010+
tmpdir);
2011+
const char *data = read_test_file(plugin_path);
2012+
ASSERT_NOT_NULL(data);
2013+
ASSERT(strstr(data, "/opt/cbm/codebase-memory-mcp") != NULL);
2014+
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") == NULL);
2015+
2016+
test_rmdir_r(tmpdir);
2017+
PASS();
2018+
}
2019+
2020+
TEST(cli_upsert_opencode_plugin_rejects_quote) {
2021+
char tmpdir[256];
2022+
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-plug3-XXXXXX");
2023+
if (!cbm_mkdtemp(tmpdir))
2024+
FAIL("cbm_mkdtemp failed");
2025+
2026+
/* Binary path containing a double-quote must be rejected (security). */
2027+
int rc = cbm_upsert_opencode_plugin(tmpdir, "/usr/local/bin/\"evil\"", false);
2028+
ASSERT_NEQ(rc, 0);
2029+
2030+
test_rmdir_r(tmpdir);
2031+
PASS();
2032+
}
2033+
2034+
TEST(cli_remove_opencode_plugin) {
2035+
char tmpdir[256];
2036+
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-plug4-XXXXXX");
2037+
if (!cbm_mkdtemp(tmpdir))
2038+
FAIL("cbm_mkdtemp failed");
2039+
2040+
/* Install then remove */
2041+
int rc = cbm_upsert_opencode_plugin(tmpdir, "/usr/local/bin/codebase-memory-mcp", false);
2042+
ASSERT_EQ(rc, 0);
2043+
2044+
char plugin_path[512];
2045+
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts",
2046+
tmpdir);
2047+
struct stat st;
2048+
ASSERT(stat(plugin_path, &st) == 0);
2049+
2050+
rc = cbm_remove_opencode_plugin(tmpdir, false);
2051+
ASSERT_EQ(rc, 0);
2052+
ASSERT(stat(plugin_path, &st) != 0);
2053+
2054+
/* Remove again — should succeed (idempotent) */
2055+
rc = cbm_remove_opencode_plugin(tmpdir, false);
2056+
ASSERT_EQ(rc, 0);
2057+
2058+
test_rmdir_r(tmpdir);
2059+
PASS();
2060+
}
2061+
2062+
TEST(cli_opencode_skills_installed) {
2063+
char tmpdir[256];
2064+
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-skill-XXXXXX");
2065+
if (!cbm_mkdtemp(tmpdir))
2066+
FAIL("cbm_mkdtemp failed");
2067+
2068+
char skills_dir[512];
2069+
snprintf(skills_dir, sizeof(skills_dir), "%s/skills", tmpdir);
2070+
2071+
int count = cbm_install_skills(skills_dir, true, false);
2072+
ASSERT_EQ(count, CBM_SKILL_COUNT);
2073+
2074+
char skill_file[512];
2075+
snprintf(skill_file, sizeof(skill_file), "%s/codebase-memory/SKILL.md", skills_dir);
2076+
const char *data = read_test_file(skill_file);
2077+
ASSERT_NOT_NULL(data);
2078+
ASSERT(strstr(data, "codebase-memory") != NULL);
2079+
ASSERT(strstr(data, "search_graph") != NULL);
2080+
2081+
/* Remove skills */
2082+
int removed = cbm_remove_skills(skills_dir, false);
2083+
ASSERT_EQ(removed, CBM_SKILL_COUNT);
2084+
2085+
test_rmdir_r(tmpdir);
2086+
PASS();
2087+
}
2088+
19662089
/* ═══════════════════════════════════════════════════════════════════
19672090
* Group B: MCP Config Upsert — Antigravity
19682091
* ═══════════════════════════════════════════════════════════════════ */
@@ -2770,6 +2893,13 @@ SUITE(cli) {
27702893
RUN_TEST(cli_upsert_opencode_mcp_fresh);
27712894
RUN_TEST(cli_upsert_opencode_mcp_existing);
27722895

2896+
/* OpenCode plugin + skills (5 tests — group B) */
2897+
RUN_TEST(cli_upsert_opencode_plugin_fresh);
2898+
RUN_TEST(cli_upsert_opencode_plugin_idempotent);
2899+
RUN_TEST(cli_upsert_opencode_plugin_rejects_quote);
2900+
RUN_TEST(cli_remove_opencode_plugin);
2901+
RUN_TEST(cli_opencode_skills_installed);
2902+
27732903
/* Antigravity MCP config upsert (2 tests — group B) */
27742904
RUN_TEST(cli_upsert_antigravity_mcp_fresh);
27752905
RUN_TEST(cli_upsert_antigravity_mcp_replace);

0 commit comments

Comments
 (0)