Skip to content

Commit 2a353b2

Browse files
committed
feat(cli): generate client extension modules from the tool registry
pi has no MCP client, and OpenCode has no declarative hook configuration -- verified against OpenCode's own plugin documentation, which states hooks are available only through JavaScript/TypeScript plugin modules. For those two clients a module is the only extension point. We do not want to ship such a module as a repository asset. DeusData#534 proposed one embedding 380 lines of TypeScript as C string literals and registering 7 of the 15 registry tools; every tool added afterwards would have been silently missing for that client. Hand-maintained copies of the tool list have already produced defects here (DeusData#1361, and the smoke-invariants count). So the module is GENERATED from the live registry instead. cbm_client_adapter_pi walks cbm_mcp_tool_count()/cbm_mcp_tool_name() and registers every tool, which makes drift structurally impossible rather than merely discouraged: adding a tool to TOOLS[] adds it to every generated adapter with no second edit. Nothing in the repository is a .ts file. Both emitters wrap their output in ownership markers so a caller can rewrite its own block and leave a user-authored file alone. Two defects from the prior proposals are closed by construction: - Path escaping. DeusData#616's template rejected the double quote but not the backslash, so a Windows home like C:\Users\urs\bin produced an invalid unicode escape and the whole auto-loaded plugin failed to parse, which is worse than an absent plugin. cbm_client_adapter_escape_js escapes backslash, quote, newline and CR, and fails closed rather than emitting a truncated literal; generation aborts if the path cannot be escaped. - The silent no-op. DeusData#616's payload omitted hook_event_name, which hook-augment requires and without which it accepts nothing, so the plugin emitted zero bytes for six weeks. The generated payload carries it and a test pins it. Recorded honestly: the OpenCode emitter 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 they change it the augmentation stops with no error. That risk is accepted deliberately and the emitter says so in a comment, so a future reader does not have to rediscover it. This commit adds the generator and its tests only; wiring into the install and uninstall routines follows separately so the two are reviewable apart. Tests: every registry tool appears in the pi module (revert-checked by simulating the 7-of-15 subset, which fails); Windows/quote/newline escaping and its truncation boundary; the OpenCode payload carries hook_event_name and registers no tools; NULL/empty binary paths generate nothing. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent 2789301 commit 2a353b2

4 files changed

Lines changed: 407 additions & 1 deletion

File tree

‎Makefile.cbm‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ WATCHER_SRCS = src/watcher/watcher.c
340340
GIT_SRCS = src/git/git_context.c
341341

342342
# CLI module (new)
343-
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c \
343+
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
344344
src/cli/agent_clients.c src/cli/agent_profiles.c \
345345
src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \
346346
src/cli/config_text_edit.c src/cli/activation_transaction.c

‎src/cli/client_adapter.c‎

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/*
2+
* client_adapter.c — Generate client extension modules from the MCP tool registry.
3+
*
4+
* See client_adapter.h for why these are generated rather than shipped.
5+
*/
6+
#include "cli/client_adapter.h"
7+
8+
#include "foundation/constants.h"
9+
#include "mcp/mcp.h"
10+
11+
#include <stdbool.h>
12+
#include <stdio.h>
13+
#include <stdlib.h>
14+
#include <string.h>
15+
16+
/* A generated module is small and bounded by the registry size; grow a plain
17+
* buffer rather than pulling in a builder for two call sites. */
18+
typedef struct {
19+
char *buf;
20+
size_t len;
21+
size_t cap;
22+
bool failed;
23+
} adapter_sb_t;
24+
25+
static void sb_append(adapter_sb_t *sb, const char *s) {
26+
if (sb->failed || !s) {
27+
return;
28+
}
29+
size_t add = strlen(s);
30+
if (sb->len + add + 1 > sb->cap) {
31+
size_t want = sb->cap ? sb->cap : CBM_SZ_2K;
32+
while (want < sb->len + add + 1) {
33+
if (want > SIZE_MAX / PAIR_LEN) {
34+
sb->failed = true;
35+
return;
36+
}
37+
want *= PAIR_LEN;
38+
}
39+
char *grown = realloc(sb->buf, want);
40+
if (!grown) {
41+
sb->failed = true;
42+
return;
43+
}
44+
sb->buf = grown;
45+
sb->cap = want;
46+
}
47+
memcpy(sb->buf + sb->len, s, add);
48+
sb->len += add;
49+
sb->buf[sb->len] = '\0';
50+
}
51+
52+
bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz) {
53+
if (!in || !out || out_sz == 0) {
54+
return false;
55+
}
56+
size_t o = 0;
57+
for (const char *p = in; *p; p++) {
58+
/* Only these four can terminate or corrupt a single-quoted literal.
59+
* Escaping more would be harmless but makes the emitted path harder to
60+
* read when a user opens the generated file. */
61+
const char *esc = NULL;
62+
switch (*p) {
63+
case '\\':
64+
esc = "\\\\";
65+
break;
66+
case '\'':
67+
esc = "\\'";
68+
break;
69+
case '\n':
70+
esc = "\\n";
71+
break;
72+
case '\r':
73+
esc = "\\r";
74+
break;
75+
default:
76+
break;
77+
}
78+
size_t need = esc ? strlen(esc) : 1U;
79+
if (o + need + 1 > out_sz) {
80+
out[0] = '\0';
81+
return false; /* never emit a truncated literal */
82+
}
83+
if (esc) {
84+
memcpy(out + o, esc, need);
85+
} else {
86+
out[o] = *p;
87+
}
88+
o += need;
89+
}
90+
out[o] = '\0';
91+
return true;
92+
}
93+
94+
/* Shared preamble: the ownership markers and a note telling whoever opens the
95+
* file that edits inside the block are overwritten. */
96+
static void emit_header(adapter_sb_t *sb, const char *client) {
97+
sb_append(sb, CBM_ADAPTER_MARKER_START);
98+
sb_append(sb, "\n// Generated by codebase-memory-mcp for ");
99+
sb_append(sb, client);
100+
sb_append(sb,
101+
".\n"
102+
"// Regenerated on install/update from the MCP tool registry, so it cannot\n"
103+
"// drift from the tools the server actually serves. Edits inside this block\n"
104+
"// are overwritten; edit outside it, or remove the markers to take ownership.\n");
105+
}
106+
107+
char *cbm_client_adapter_pi(const char *binary_path) {
108+
if (!binary_path || !binary_path[0]) {
109+
return NULL;
110+
}
111+
char bin[CBM_SZ_1K];
112+
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
113+
return NULL;
114+
}
115+
int count = cbm_mcp_tool_count();
116+
if (count <= 0) {
117+
return NULL;
118+
}
119+
120+
adapter_sb_t sb = {0};
121+
emit_header(&sb, "pi");
122+
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
123+
sb_append(&sb, "const BIN = '");
124+
sb_append(&sb, bin);
125+
sb_append(&sb, "';\n\n");
126+
127+
/* One shared invoker: the binary prints JSON on stdout, so take the last
128+
* line that parses rather than trusting the first — a stray log line on
129+
* stdout must not break the bridge. */
130+
sb_append(
131+
&sb, "async function call(tool, args, signal) {\n"
132+
" return new Promise((resolve) => {\n"
133+
" const child = spawn(BIN, ['cli', tool, JSON.stringify(args ?? {})], {\n"
134+
" stdio: ['ignore', 'pipe', 'pipe'],\n"
135+
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
136+
" });\n"
137+
" let out = '';\n"
138+
" const onAbort = () => { if (!child.killed) child.kill(); };\n"
139+
" signal?.addEventListener('abort', onAbort, { once: true });\n"
140+
" child.stdout.on('data', (d) => (out += d.toString()));\n"
141+
" child.on('error', (e) => {\n"
142+
" signal?.removeEventListener('abort', onAbort);\n"
143+
" resolve({ error: String(e && e.message ? e.message : e) });\n"
144+
" });\n"
145+
" child.on('close', () => {\n"
146+
" signal?.removeEventListener('abort', onAbort);\n"
147+
" const lines = out.split('\\n').map((l) => l.trim()).filter(Boolean);\n"
148+
" for (let i = lines.length - 1; i >= 0; i--) {\n"
149+
" try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n"
150+
" }\n"
151+
" resolve({ error: 'no JSON response from codebase-memory-mcp' });\n"
152+
" });\n"
153+
" });\n"
154+
"}\n\n");
155+
156+
/* Register every tool the registry advertises — never a hand-picked subset,
157+
* which is the drift this generator exists to prevent. */
158+
sb_append(&sb, "export function register(pi) {\n");
159+
for (int i = 0; i < count; i++) {
160+
const char *name = cbm_mcp_tool_name(i);
161+
if (!name || !name[0]) {
162+
continue;
163+
}
164+
sb_append(&sb, " pi.registerTool({ name: '");
165+
sb_append(&sb, name);
166+
sb_append(&sb, "', run: (args, ctx) => call('");
167+
sb_append(&sb, name);
168+
sb_append(&sb, "', args, ctx?.signal) });\n");
169+
}
170+
sb_append(&sb, "}\n");
171+
sb_append(&sb, CBM_ADAPTER_MARKER_END);
172+
sb_append(&sb, "\n");
173+
174+
if (sb.failed) {
175+
free(sb.buf);
176+
return NULL;
177+
}
178+
return sb.buf;
179+
}
180+
181+
char *cbm_client_adapter_opencode(const char *binary_path) {
182+
if (!binary_path || !binary_path[0]) {
183+
return NULL;
184+
}
185+
char bin[CBM_SZ_1K];
186+
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
187+
return NULL;
188+
}
189+
190+
adapter_sb_t sb = {0};
191+
emit_header(&sb, "OpenCode");
192+
sb_append(&sb, "// OpenCode already reaches every tool over MCP; this adds only the\n"
193+
"// automatic graph lookup before a grep/glob, which other clients get\n"
194+
"// through their own hook configuration.\n");
195+
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
196+
sb_append(&sb, "const BIN = '");
197+
sb_append(&sb, bin);
198+
sb_append(&sb, "';\n\n");
199+
200+
/* hook-augment requires hook_event_name and accepts Grep/Glob only under
201+
* PreToolUse; omitting it makes the whole hook a silent no-op. */
202+
sb_append(&sb, "function augment(tool, args) {\n"
203+
" return new Promise((resolve) => {\n"
204+
" const child = spawn(BIN, ['hook-augment'], {\n"
205+
" stdio: ['pipe', 'pipe', 'ignore'],\n"
206+
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
207+
" });\n"
208+
" let out = '';\n"
209+
" child.stdout.on('data', (d) => (out += d.toString()));\n"
210+
" child.on('error', () => resolve(''));\n"
211+
" child.on('close', () => resolve(out));\n"
212+
" child.stdin.end(JSON.stringify({\n"
213+
" hook_event_name: 'PreToolUse',\n"
214+
" tool_name: tool,\n"
215+
" tool_input: args ?? {},\n"
216+
" }));\n"
217+
" });\n"
218+
"}\n\n");
219+
220+
sb_append(&sb,
221+
"export const CodebaseMemory = async () => ({\n"
222+
" 'tool.execute.after': async (input, output) => {\n"
223+
" const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' "
224+
": null;\n"
225+
" if (!tool) return;\n"
226+
" const extra = await augment(tool, output?.args);\n"
227+
" if (extra && typeof output?.output === 'string') {\n"
228+
" output.output += '\\n' + extra;\n"
229+
" }\n"
230+
" },\n"
231+
"});\n");
232+
sb_append(&sb, CBM_ADAPTER_MARKER_END);
233+
sb_append(&sb, "\n");
234+
235+
if (sb.failed) {
236+
free(sb.buf);
237+
return NULL;
238+
}
239+
return sb.buf;
240+
}

‎src/cli/client_adapter.h‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* client_adapter.h — Generate client extension modules from the MCP tool registry.
3+
*
4+
* Some clients have no MCP support (pi), and some have no declarative hook
5+
* configuration (OpenCode); their only extension point is a JavaScript/TypeScript
6+
* module. We do not ship such a module as a repository asset — it would be a
7+
* second, hand-maintained copy of the tool surface, and hand-maintained copies of
8+
* the tool list have already produced defects here.
9+
*
10+
* Instead the module is GENERATED at install time from the live registry
11+
* (cbm_mcp_tool_count / cbm_mcp_tool_name), so it cannot drift: adding a tool to
12+
* TOOLS[] adds it to every generated adapter with no second edit.
13+
*
14+
* Generated text is returned heap-allocated; the caller frees it. Nothing here
15+
* touches the filesystem — writing is the caller's job, so dry-run and
16+
* owned-block semantics stay in one place.
17+
*/
18+
#ifndef CBM_CLI_CLIENT_ADAPTER_H
19+
#define CBM_CLI_CLIENT_ADAPTER_H
20+
21+
#include <stdbool.h>
22+
#include <stddef.h>
23+
24+
/* Marker lines delimiting the generated region inside a client's extension
25+
* module. A file carrying these is ours to rewrite; one without them is the
26+
* user's and is left alone. Kept in the comment syntax shared by JS and TS. */
27+
#define CBM_ADAPTER_MARKER_START "// codebase-memory-mcp:start"
28+
#define CBM_ADAPTER_MARKER_END "// codebase-memory-mcp:end"
29+
30+
/* Emit a pi extension registering every tool in the MCP registry.
31+
*
32+
* pi has no MCP client, so this bridge is the only route to the graph for it.
33+
* Each registered tool shells out to `<binary> cli <tool> '<json-args>'`, which
34+
* is an existing public entry point rather than a private one.
35+
*
36+
* `binary_path` is embedded as a JS string literal and is escaped; a Windows
37+
* path such as C:\Users\x\bin\cbm.exe must not corrupt the module or, worse,
38+
* produce a file that fails to parse inside an auto-loaded extension directory.
39+
*
40+
* Returns NULL on allocation failure or when binary_path is NULL/empty. */
41+
char *cbm_client_adapter_pi(const char *binary_path);
42+
43+
/* Emit an OpenCode plugin that augments grep/glob results from the graph.
44+
*
45+
* OpenCode HAS MCP (see cbm_upsert_opencode_mcp), so this adds no tools — it
46+
* only supplies the automatic "consult the graph before grepping" reflex that
47+
* other clients get through their own native hook configuration. OpenCode has
48+
* no such configuration; a plugin module is its only extension point.
49+
*
50+
* NOTE for maintainers: this hooks `tool.execute.after`, whose ability to
51+
* modify a tool's output is NOT part of OpenCode's documented plugin contract
52+
* (only `tool.execute.before`'s argument mutation is). If OpenCode changes it,
53+
* augmentation stops silently — no error, no signal. That risk is accepted
54+
* deliberately and recorded here so it is not rediscovered as a mystery.
55+
*
56+
* Returns NULL on allocation failure or when binary_path is NULL/empty. */
57+
char *cbm_client_adapter_opencode(const char *binary_path);
58+
59+
/* Escape a path for embedding in a single-quoted JS string literal: backslash,
60+
* single quote, and the newline/CR that would terminate the literal outright.
61+
* Writes at most out_sz-1 characters plus NUL. Returns false when the escaped
62+
* form would not fit, so a caller never emits a truncated literal.
63+
*
64+
* Exposed for tests: the escaping is the part most likely to break a user's
65+
* install on Windows, and it deserves direct coverage rather than only being
66+
* reachable through a full generate call. */
67+
bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz);
68+
69+
#endif /* CBM_CLI_CLIENT_ADAPTER_H */

0 commit comments

Comments
 (0)