Merge pull request #1392 from DeusData/feat/generated-client-adapters

feat(install): generate pi and OpenCode extensions from the tool registry
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-08-04 13:52:09 +02:00
commit 66e9aec28d
5 files changed
+482 -3

No files matched your search

+1 -1
View File
@@ -352,7 +352,7 @@ WATCHER_SRCS = src/watcher/watcher.c
GIT_SRCS = src/git/git_context.c
# CLI module (new)
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c \
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
src/cli/agent_clients.c src/cli/agent_profiles.c \
src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \
src/cli/config_text_edit.c src/cli/activation_transaction.c
+76 -2
View File
@@ -21,6 +21,7 @@
#include "foundation/constants.h"
#include "foundation/log.h"
#include "foundation/sha256.h"
#include "cli/client_adapter.h"
#include "mcp/mcp.h" // cbm_mcp_tool_input_schema — CLI flag parser + per-tool --help
#include "mcp/index_supervisor.h"
@@ -7826,13 +7827,69 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re
}
}
static void install_pi_durable_context(const char *home, bool force, bool dry_run) {
/* Write a generated client extension module into `path` as a managed block.
*
* The module is generated from the tool registry rather than shipped (see
* src/cli/client_adapter.h), and it goes in as a MARKED BLOCK rather than a
* whole-file write: the directory is auto-loaded by the client, so a user may
* legitimately keep their own module there, and clobbering it would be the
* install routine destroying user content.
*
* `generate` returns heap-allocated text or NULL; NULL is a hard error rather
* than a skip, because a silently absent extension is exactly the failure mode
* that left #616 a no-op for six weeks. */
static void install_generated_client_extension(const char *label, const char *path,
const char *binary_path,
char *(*generate)(const char *), bool dry_run) {
if (g_install_plan) {
plan_record(label, "extension", path);
return;
}
char *content = generate(binary_path);
if (!content) {
record_agent_config_error(false, label, "extension_generate", path);
return;
}
bool installed = true;
if (!dry_run && (!prepare_config_parent(path) ||
cbm_text_upsert_managed_block(path, CBM_ADAPTER_MARKER_START,
CBM_ADAPTER_MARKER_END, content) != 0)) {
installed = false;
record_agent_config_error(false, label, "extension_install", path);
}
free(content);
if (installed) {
printf(" extension: %s\n", path);
}
}
/* Remove only OUR marked block, never the file: a user's own module may share
* it, and owned removal is the convention every other uninstall path here
* follows. */
static void uninstall_generated_client_extension(const char *label, const char *path,
bool dry_run) {
if (!dry_run && cbm_file_exists(path) &&
cbm_text_remove_managed_block(path, CBM_ADAPTER_MARKER_START, CBM_ADAPTER_MARKER_END) !=
0) {
record_agent_config_error(true, label, "extension_uninstall", path);
return;
}
printf(" extension: removed managed block\n");
}
static void install_pi_durable_context(const char *home, const char *binary_path, bool force,
bool dry_run) {
char instructions_path[CLI_BUF_1K];
char skills_dir[CLI_BUF_1K];
char extension_path[CLI_BUF_1K];
snprintf(instructions_path, sizeof(instructions_path), "%s/.pi/agent/AGENTS.md", home);
snprintf(skills_dir, sizeof(skills_dir), "%s/.pi/agent/skills", home);
snprintf(extension_path, sizeof(extension_path), "%s/.pi/agent/extensions/cbmem.ts", home);
install_managed_agent_instructions("Pi", instructions_path, dry_run);
install_agent_skill("Pi", skills_dir, force, dry_run);
/* pi has no MCP client, so this bridge is its ONLY route to the graph. */
install_generated_client_extension("Pi", extension_path, binary_path, cbm_client_adapter_pi,
dry_run);
}
static void install_kimi_durable_context(const cbm_agent_registry_context_t *registry,
@@ -8010,7 +8067,7 @@ static void install_agent_client_registry(const char *home, const char *binary_p
} else if (profile->id == CBM_AGENT_CLIENT_POCHI) {
install_pochi_durable_context(home, force, dry_run);
} else if (profile->id == CBM_AGENT_CLIENT_PI) {
install_pi_durable_context(home, force, dry_run);
install_pi_durable_context(home, binary_path, force, dry_run);
}
}
}
@@ -8162,6 +8219,16 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const
.dialect = CBM_GRAPH_DIALECT_OPENCODE,
},
dry_run);
/* OpenCode already reaches every tool over MCP (installed just above),
* so this adds no tools -- only the automatic graph lookup before a
* grep/glob that other clients get from their own hook configuration.
* OpenCode has no such configuration; a plugin module is its only
* extension point (verified against their plugin documentation). */
char plugin_path[CLI_BUF_1K];
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts",
home);
install_generated_client_extension("OpenCode", plugin_path, binary_path,
cbm_client_adapter_opencode, dry_run);
}
if (agents->antigravity) {
char cp[CLI_BUF_1K];
@@ -9797,6 +9864,9 @@ static void uninstall_pi_durable_context(const char *home, bool dry_run) {
}
printf(" instructions: removed managed context\n");
uninstall_agent_skill("Pi", skills_dir, dry_run);
char extension_path[CLI_BUF_1K];
snprintf(extension_path, sizeof(extension_path), "%s/.pi/agent/extensions/cbmem.ts", home);
uninstall_generated_client_extension("Pi", extension_path, dry_run);
}
static void uninstall_managed_agent_instructions(const char *label, const char *instructions_path,
@@ -10109,6 +10179,10 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char
.dialect = CBM_GRAPH_DIALECT_OPENCODE,
},
dry_run);
char plugin_path[CLI_BUF_1K];
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts",
home);
uninstall_generated_client_extension("OpenCode", plugin_path, dry_run);
}
if (agents->antigravity) {
char cp[CLI_BUF_1K];
+237
View File
@@ -0,0 +1,237 @@
/*
* client_adapter.c — Generate client extension modules from the MCP tool registry.
*
* See client_adapter.h for why these are generated rather than shipped.
*/
#include "cli/client_adapter.h"
#include "foundation/constants.h"
#include "mcp/mcp.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* A generated module is small and bounded by the registry size; grow a plain
* buffer rather than pulling in a builder for two call sites. */
typedef struct {
char *buf;
size_t len;
size_t cap;
bool failed;
} adapter_sb_t;
static void sb_append(adapter_sb_t *sb, const char *s) {
if (sb->failed || !s) {
return;
}
size_t add = strlen(s);
if (sb->len + add + 1 > sb->cap) {
size_t want = sb->cap ? sb->cap : CBM_SZ_2K;
while (want < sb->len + add + 1) {
if (want > SIZE_MAX / PAIR_LEN) {
sb->failed = true;
return;
}
want *= PAIR_LEN;
}
char *grown = realloc(sb->buf, want);
if (!grown) {
sb->failed = true;
return;
}
sb->buf = grown;
sb->cap = want;
}
memcpy(sb->buf + sb->len, s, add);
sb->len += add;
sb->buf[sb->len] = '\0';
}
bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz) {
if (!in || !out || out_sz == 0) {
return false;
}
size_t o = 0;
for (const char *p = in; *p; p++) {
/* Only these four can terminate or corrupt a single-quoted literal.
* Escaping more would be harmless but makes the emitted path harder to
* read when a user opens the generated file. */
const char *esc = NULL;
switch (*p) {
case '\\':
esc = "\\\\";
break;
case '\'':
esc = "\\'";
break;
case '\n':
esc = "\\n";
break;
case '\r':
esc = "\\r";
break;
default:
break;
}
size_t need = esc ? strlen(esc) : 1U;
if (o + need + 1 > out_sz) {
out[0] = '\0';
return false; /* never emit a truncated literal */
}
if (esc) {
memcpy(out + o, esc, need);
} else {
out[o] = *p;
}
o += need;
}
out[o] = '\0';
return true;
}
/* Shared preamble: the ownership markers and a note telling whoever opens the
* file that edits inside the block are overwritten. */
static void emit_header(adapter_sb_t *sb, const char *client) {
/* No markers here: cbm_text_upsert_managed_block adds them and REJECTS
* content that already carries them, so the body must stay marker-free. */
sb_append(sb, "// Generated by codebase-memory-mcp for ");
sb_append(sb, client);
sb_append(sb,
".\n"
"// Regenerated on install/update from the MCP tool registry, so it cannot\n"
"// drift from the tools the server actually serves. Edits inside this block\n"
"// are overwritten; edit outside it, or remove the markers to take ownership.\n");
}
char *cbm_client_adapter_pi(const char *binary_path) {
if (!binary_path || !binary_path[0]) {
return NULL;
}
char bin[CBM_SZ_1K];
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
return NULL;
}
int count = cbm_mcp_tool_count();
if (count <= 0) {
return NULL;
}
adapter_sb_t sb = {0};
emit_header(&sb, "pi");
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
sb_append(&sb, "const BIN = '");
sb_append(&sb, bin);
sb_append(&sb, "';\n\n");
/* One shared invoker: the binary prints JSON on stdout, so take the last
* line that parses rather than trusting the first — a stray log line on
* stdout must not break the bridge. */
sb_append(
&sb, "async function call(tool, args, signal) {\n"
" return new Promise((resolve) => {\n"
" const child = spawn(BIN, ['cli', tool, JSON.stringify(args ?? {})], {\n"
" stdio: ['ignore', 'pipe', 'pipe'],\n"
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
" });\n"
" let out = '';\n"
" const onAbort = () => { if (!child.killed) child.kill(); };\n"
" signal?.addEventListener('abort', onAbort, { once: true });\n"
" child.stdout.on('data', (d) => (out += d.toString()));\n"
" child.on('error', (e) => {\n"
" signal?.removeEventListener('abort', onAbort);\n"
" resolve({ error: String(e && e.message ? e.message : e) });\n"
" });\n"
" child.on('close', () => {\n"
" signal?.removeEventListener('abort', onAbort);\n"
" const lines = out.split('\\n').map((l) => l.trim()).filter(Boolean);\n"
" for (let i = lines.length - 1; i >= 0; i--) {\n"
" try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n"
" }\n"
" resolve({ error: 'no JSON response from codebase-memory-mcp' });\n"
" });\n"
" });\n"
"}\n\n");
/* Register every tool the registry advertises — never a hand-picked subset,
* which is the drift this generator exists to prevent. */
sb_append(&sb, "export function register(pi) {\n");
for (int i = 0; i < count; i++) {
const char *name = cbm_mcp_tool_name(i);
if (!name || !name[0]) {
continue;
}
sb_append(&sb, " pi.registerTool({ name: '");
sb_append(&sb, name);
sb_append(&sb, "', run: (args, ctx) => call('");
sb_append(&sb, name);
sb_append(&sb, "', args, ctx?.signal) });\n");
}
sb_append(&sb, "}\n");
if (sb.failed) {
free(sb.buf);
return NULL;
}
return sb.buf;
}
char *cbm_client_adapter_opencode(const char *binary_path) {
if (!binary_path || !binary_path[0]) {
return NULL;
}
char bin[CBM_SZ_1K];
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
return NULL;
}
adapter_sb_t sb = {0};
emit_header(&sb, "OpenCode");
sb_append(&sb, "// OpenCode already reaches every tool over MCP; this adds only the\n"
"// automatic graph lookup before a grep/glob, which other clients get\n"
"// through their own hook configuration.\n");
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
sb_append(&sb, "const BIN = '");
sb_append(&sb, bin);
sb_append(&sb, "';\n\n");
/* hook-augment requires hook_event_name and accepts Grep/Glob only under
* PreToolUse; omitting it makes the whole hook a silent no-op. */
sb_append(&sb, "function augment(tool, args) {\n"
" return new Promise((resolve) => {\n"
" const child = spawn(BIN, ['hook-augment'], {\n"
" stdio: ['pipe', 'pipe', 'ignore'],\n"
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
" });\n"
" let out = '';\n"
" child.stdout.on('data', (d) => (out += d.toString()));\n"
" child.on('error', () => resolve(''));\n"
" child.on('close', () => resolve(out));\n"
" child.stdin.end(JSON.stringify({\n"
" hook_event_name: 'PreToolUse',\n"
" tool_name: tool,\n"
" tool_input: args ?? {},\n"
" }));\n"
" });\n"
"}\n\n");
sb_append(&sb,
"export const CodebaseMemory = async () => ({\n"
" 'tool.execute.after': async (input, output) => {\n"
" const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' "
": null;\n"
" if (!tool) return;\n"
" const extra = await augment(tool, output?.args);\n"
" if (extra && typeof output?.output === 'string') {\n"
" output.output += '\\n' + extra;\n"
" }\n"
" },\n"
"});\n");
if (sb.failed) {
free(sb.buf);
return NULL;
}
return sb.buf;
}
+69
View File
@@ -0,0 +1,69 @@
/*
* client_adapter.h — Generate client extension modules from the MCP tool registry.
*
* Some clients have no MCP support (pi), and some have no declarative hook
* configuration (OpenCode); their only extension point is a JavaScript/TypeScript
* module. We do not ship such a module as a repository asset — it would be a
* second, hand-maintained copy of the tool surface, and hand-maintained copies of
* the tool list have already produced defects here.
*
* Instead the module is GENERATED at install time from the live registry
* (cbm_mcp_tool_count / cbm_mcp_tool_name), so it cannot drift: adding a tool to
* TOOLS[] adds it to every generated adapter with no second edit.
*
* Generated text is returned heap-allocated; the caller frees it. Nothing here
* touches the filesystem — writing is the caller's job, so dry-run and
* owned-block semantics stay in one place.
*/
#ifndef CBM_CLI_CLIENT_ADAPTER_H
#define CBM_CLI_CLIENT_ADAPTER_H
#include <stdbool.h>
#include <stddef.h>
/* Marker lines delimiting the generated region inside a client's extension
* module. A file carrying these is ours to rewrite; one without them is the
* user's and is left alone. Kept in the comment syntax shared by JS and TS. */
#define CBM_ADAPTER_MARKER_START "// codebase-memory-mcp:start"
#define CBM_ADAPTER_MARKER_END "// codebase-memory-mcp:end"
/* Emit a pi extension registering every tool in the MCP registry.
*
* pi has no MCP client, so this bridge is the only route to the graph for it.
* Each registered tool shells out to `<binary> cli <tool> '<json-args>'`, which
* is an existing public entry point rather than a private one.
*
* `binary_path` is embedded as a JS string literal and is escaped; a Windows
* path such as C:\Users\x\bin\cbm.exe must not corrupt the module or, worse,
* produce a file that fails to parse inside an auto-loaded extension directory.
*
* Returns NULL on allocation failure or when binary_path is NULL/empty. */
char *cbm_client_adapter_pi(const char *binary_path);
/* Emit an OpenCode plugin that augments grep/glob results from the graph.
*
* OpenCode HAS MCP (see cbm_upsert_opencode_mcp), so this adds no tools — it
* only supplies the automatic "consult the graph before grepping" reflex that
* other clients get through their own native hook configuration. OpenCode has
* no such configuration; a plugin module is its only extension point.
*
* NOTE for maintainers: this hooks `tool.execute.after`, whose ability to
* modify a tool's output is NOT part of OpenCode's documented plugin contract
* (only `tool.execute.before`'s argument mutation is). If OpenCode changes it,
* augmentation stops silently — no error, no signal. That risk is accepted
* deliberately and recorded here so it is not rediscovered as a mystery.
*
* Returns NULL on allocation failure or when binary_path is NULL/empty. */
char *cbm_client_adapter_opencode(const char *binary_path);
/* Escape a path for embedding in a single-quoted JS string literal: backslash,
* single quote, and the newline/CR that would terminate the literal outright.
* Writes at most out_sz-1 characters plus NUL. Returns false when the escaped
* form would not fit, so a caller never emits a truncated literal.
*
* Exposed for tests: the escaping is the part most likely to break a user's
* install on Windows, and it deserves direct coverage rather than only being
* reachable through a full generate call. */
bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz);
#endif /* CBM_CLI_CLIENT_ADAPTER_H */
+99
View File
@@ -3,6 +3,9 @@
#include "test_helpers.h"
#include <cli/agent_clients.h>
#include <cli/client_adapter.h>
#include <foundation/constants.h>
#include <mcp/mcp.h>
#include <stdio.h>
#include <stdlib.h>
@@ -1062,6 +1065,98 @@ TEST(agent_clients_continue_refuses_foreign_same_name_and_nonsequence_section) {
PASS();
}
/* ── Generated client adapters ──────────────────────────────────── */
/* The generator exists so a client extension cannot carry a hand-picked subset
* of the tool surface. #534 shipped one mirroring 7 of 15 tools; every tool
* added afterwards would have been silently missing for that client. This pins
* the property that makes generation worth doing: EVERY registry tool appears. */
TEST(client_adapter_pi_registers_every_registry_tool) {
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
int count = cbm_mcp_tool_count();
ASSERT_GT(count, 0);
for (int i = 0; i < count; i++) {
const char *name = cbm_mcp_tool_name(i);
ASSERT_NOT_NULL(name);
char needle[128];
snprintf(needle, sizeof(needle), "name: '%s'", name);
/* A missing tool here means the adapter drifted from the registry —
* exactly the failure this design removes. */
ASSERT_NOT_NULL(strstr(js, needle));
}
/* The body must NOT carry the ownership markers: cbm_text_upsert_managed_block
* adds them and rejects content that already has them, so emitting them here
* would make every install fail. */
ASSERT_NULL(strstr(js, CBM_ADAPTER_MARKER_START));
ASSERT_NULL(strstr(js, CBM_ADAPTER_MARKER_END));
ASSERT_NOT_NULL(strstr(js, "Generated by codebase-memory-mcp"));
free(js);
PASS();
}
/* #616 rejected `"` in its path template but not `\`, so a Windows home like
* C:\Users\urs\bin produced `\u` — an invalid unicode escape — and the whole
* auto-loaded plugin failed to parse. A broken plugin in an auto-load directory
* is worse than an absent one, so the escaping gets direct coverage. */
TEST(client_adapter_escapes_windows_paths_and_quotes) {
char out[256];
ASSERT_TRUE(cbm_client_adapter_escape_js("C:\\Users\\urs\\bin\\cbm.exe", out, sizeof(out)));
ASSERT_STR_EQ(out, "C:\\\\Users\\\\urs\\\\bin\\\\cbm.exe");
/* The backslash before "urs" is DOUBLED, which is what stops JS reading
* `\u` as a unicode escape — the exact break in #616. Asserting the
* doubled form is the property; a bare `\u` substring still appears (as
* escaped-backslash + 'u') and would be a misleading thing to test. */
ASSERT_NOT_NULL(strstr(out, "\\\\urs"));
ASSERT_TRUE(cbm_client_adapter_escape_js("/home/o'brien/bin/cbm", out, sizeof(out)));
ASSERT_STR_EQ(out, "/home/o\\'brien/bin/cbm");
/* A newline would terminate the literal outright. */
ASSERT_TRUE(cbm_client_adapter_escape_js("/tmp/a\nb", out, sizeof(out)));
ASSERT_STR_EQ(out, "/tmp/a\\nb");
/* Truncation must fail closed rather than emit half a literal. */
char tiny[4];
ASSERT_FALSE(cbm_client_adapter_escape_js("C:\\Users", tiny, sizeof(tiny)));
ASSERT_STR_EQ(tiny, "");
/* And a path that cannot be escaped must abort generation, not produce a
* module with a corrupt BIN. */
char huge[CBM_SZ_2K];
memset(huge, 'x', sizeof(huge) - 1);
huge[sizeof(huge) - 1] = '\0';
ASSERT_NULL(cbm_client_adapter_pi(huge));
PASS();
}
/* hook-augment requires hook_event_name and accepts Grep/Glob only under
* PreToolUse. #616's payload omitted it, so the plugin produced zero bytes and
* was a silent no-op for six weeks. Pin the field into the generated payload. */
TEST(client_adapter_opencode_sends_the_required_hook_event) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'PreToolUse'"));
ASSERT_NOT_NULL(strstr(js, "tool.execute.after"));
/* OpenCode reaches the tools over MCP already; this adapter must not
* register any, or we reintroduce the second tool surface. */
ASSERT_NULL(strstr(js, "registerTool"));
ASSERT_NULL(strstr(js, CBM_ADAPTER_MARKER_START));
free(js);
PASS();
}
/* Empty/NULL inputs must not produce a module at all. */
TEST(client_adapter_rejects_missing_binary_path) {
ASSERT_NULL(cbm_client_adapter_pi(NULL));
ASSERT_NULL(cbm_client_adapter_pi(""));
ASSERT_NULL(cbm_client_adapter_opencode(NULL));
ASSERT_NULL(cbm_client_adapter_opencode(""));
PASS();
}
SUITE(agent_clients) {
RUN_TEST(agent_clients_registry_is_stable_and_callback_driven);
RUN_TEST(agent_clients_next_wave_metadata_matches_supported_surfaces);
@@ -1089,4 +1184,8 @@ SUITE(agent_clients) {
RUN_TEST(agent_clients_deep_same_name_json_fails_closed_byte_identically);
RUN_TEST(agent_clients_continue_uses_owned_yaml_sequence_item);
RUN_TEST(agent_clients_continue_refuses_foreign_same_name_and_nonsequence_section);
RUN_TEST(client_adapter_pi_registers_every_registry_tool);
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
RUN_TEST(client_adapter_rejects_missing_binary_path);
}