Skip to content

Commit 66e9aec

Browse files
authored
Merge pull request #1392 from DeusData/feat/generated-client-adapters
feat(install): generate pi and OpenCode extensions from the tool registry
2 parents 297d747 + 3a5790b commit 66e9aec

5 files changed

Lines changed: 482 additions & 3 deletions

File tree

‎Makefile.cbm‎

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

354354
# CLI module (new)
355-
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c \
355+
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
356356
src/cli/agent_clients.c src/cli/agent_profiles.c \
357357
src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \
358358
src/cli/config_text_edit.c src/cli/activation_transaction.c

‎src/cli/cli.c‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "foundation/constants.h"
2222
#include "foundation/log.h"
2323
#include "foundation/sha256.h"
24+
#include "cli/client_adapter.h"
2425
#include "mcp/mcp.h" // cbm_mcp_tool_input_schema — CLI flag parser + per-tool --help
2526
#include "mcp/index_supervisor.h"
2627

@@ -7826,13 +7827,69 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re
78267827
}
78277828
}
78287829

7829-
static void install_pi_durable_context(const char *home, bool force, bool dry_run) {
7830+
/* Write a generated client extension module into `path` as a managed block.
7831+
*
7832+
* The module is generated from the tool registry rather than shipped (see
7833+
* src/cli/client_adapter.h), and it goes in as a MARKED BLOCK rather than a
7834+
* whole-file write: the directory is auto-loaded by the client, so a user may
7835+
* legitimately keep their own module there, and clobbering it would be the
7836+
* install routine destroying user content.
7837+
*
7838+
* `generate` returns heap-allocated text or NULL; NULL is a hard error rather
7839+
* than a skip, because a silently absent extension is exactly the failure mode
7840+
* that left #616 a no-op for six weeks. */
7841+
static void install_generated_client_extension(const char *label, const char *path,
7842+
const char *binary_path,
7843+
char *(*generate)(const char *), bool dry_run) {
7844+
if (g_install_plan) {
7845+
plan_record(label, "extension", path);
7846+
return;
7847+
}
7848+
char *content = generate(binary_path);
7849+
if (!content) {
7850+
record_agent_config_error(false, label, "extension_generate", path);
7851+
return;
7852+
}
7853+
bool installed = true;
7854+
if (!dry_run && (!prepare_config_parent(path) ||
7855+
cbm_text_upsert_managed_block(path, CBM_ADAPTER_MARKER_START,
7856+
CBM_ADAPTER_MARKER_END, content) != 0)) {
7857+
installed = false;
7858+
record_agent_config_error(false, label, "extension_install", path);
7859+
}
7860+
free(content);
7861+
if (installed) {
7862+
printf(" extension: %s\n", path);
7863+
}
7864+
}
7865+
7866+
/* Remove only OUR marked block, never the file: a user's own module may share
7867+
* it, and owned removal is the convention every other uninstall path here
7868+
* follows. */
7869+
static void uninstall_generated_client_extension(const char *label, const char *path,
7870+
bool dry_run) {
7871+
if (!dry_run && cbm_file_exists(path) &&
7872+
cbm_text_remove_managed_block(path, CBM_ADAPTER_MARKER_START, CBM_ADAPTER_MARKER_END) !=
7873+
0) {
7874+
record_agent_config_error(true, label, "extension_uninstall", path);
7875+
return;
7876+
}
7877+
printf(" extension: removed managed block\n");
7878+
}
7879+
7880+
static void install_pi_durable_context(const char *home, const char *binary_path, bool force,
7881+
bool dry_run) {
78307882
char instructions_path[CLI_BUF_1K];
78317883
char skills_dir[CLI_BUF_1K];
7884+
char extension_path[CLI_BUF_1K];
78327885
snprintf(instructions_path, sizeof(instructions_path), "%s/.pi/agent/AGENTS.md", home);
78337886
snprintf(skills_dir, sizeof(skills_dir), "%s/.pi/agent/skills", home);
7887+
snprintf(extension_path, sizeof(extension_path), "%s/.pi/agent/extensions/cbmem.ts", home);
78347888
install_managed_agent_instructions("Pi", instructions_path, dry_run);
78357889
install_agent_skill("Pi", skills_dir, force, dry_run);
7890+
/* pi has no MCP client, so this bridge is its ONLY route to the graph. */
7891+
install_generated_client_extension("Pi", extension_path, binary_path, cbm_client_adapter_pi,
7892+
dry_run);
78367893
}
78377894

78387895
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
80108067
} else if (profile->id == CBM_AGENT_CLIENT_POCHI) {
80118068
install_pochi_durable_context(home, force, dry_run);
80128069
} else if (profile->id == CBM_AGENT_CLIENT_PI) {
8013-
install_pi_durable_context(home, force, dry_run);
8070+
install_pi_durable_context(home, binary_path, force, dry_run);
80148071
}
80158072
}
80168073
}
@@ -8162,6 +8219,16 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const
81628219
.dialect = CBM_GRAPH_DIALECT_OPENCODE,
81638220
},
81648221
dry_run);
8222+
/* OpenCode already reaches every tool over MCP (installed just above),
8223+
* so this adds no tools -- only the automatic graph lookup before a
8224+
* grep/glob that other clients get from their own hook configuration.
8225+
* OpenCode has no such configuration; a plugin module is its only
8226+
* extension point (verified against their plugin documentation). */
8227+
char plugin_path[CLI_BUF_1K];
8228+
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts",
8229+
home);
8230+
install_generated_client_extension("OpenCode", plugin_path, binary_path,
8231+
cbm_client_adapter_opencode, dry_run);
81658232
}
81668233
if (agents->antigravity) {
81678234
char cp[CLI_BUF_1K];
@@ -9797,6 +9864,9 @@ static void uninstall_pi_durable_context(const char *home, bool dry_run) {
97979864
}
97989865
printf(" instructions: removed managed context\n");
97999866
uninstall_agent_skill("Pi", skills_dir, dry_run);
9867+
char extension_path[CLI_BUF_1K];
9868+
snprintf(extension_path, sizeof(extension_path), "%s/.pi/agent/extensions/cbmem.ts", home);
9869+
uninstall_generated_client_extension("Pi", extension_path, dry_run);
98009870
}
98019871

98029872
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
1010910179
.dialect = CBM_GRAPH_DIALECT_OPENCODE,
1011010180
},
1011110181
dry_run);
10182+
char plugin_path[CLI_BUF_1K];
10183+
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts",
10184+
home);
10185+
uninstall_generated_client_extension("OpenCode", plugin_path, dry_run);
1011210186
}
1011310187
if (agents->antigravity) {
1011410188
char cp[CLI_BUF_1K];

‎src/cli/client_adapter.c‎

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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+
/* No markers here: cbm_text_upsert_managed_block adds them and REJECTS
98+
* content that already carries them, so the body must stay marker-free. */
99+
sb_append(sb, "// Generated by codebase-memory-mcp for ");
100+
sb_append(sb, client);
101+
sb_append(sb,
102+
".\n"
103+
"// Regenerated on install/update from the MCP tool registry, so it cannot\n"
104+
"// drift from the tools the server actually serves. Edits inside this block\n"
105+
"// are overwritten; edit outside it, or remove the markers to take ownership.\n");
106+
}
107+
108+
char *cbm_client_adapter_pi(const char *binary_path) {
109+
if (!binary_path || !binary_path[0]) {
110+
return NULL;
111+
}
112+
char bin[CBM_SZ_1K];
113+
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
114+
return NULL;
115+
}
116+
int count = cbm_mcp_tool_count();
117+
if (count <= 0) {
118+
return NULL;
119+
}
120+
121+
adapter_sb_t sb = {0};
122+
emit_header(&sb, "pi");
123+
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
124+
sb_append(&sb, "const BIN = '");
125+
sb_append(&sb, bin);
126+
sb_append(&sb, "';\n\n");
127+
128+
/* One shared invoker: the binary prints JSON on stdout, so take the last
129+
* line that parses rather than trusting the first — a stray log line on
130+
* stdout must not break the bridge. */
131+
sb_append(
132+
&sb, "async function call(tool, args, signal) {\n"
133+
" return new Promise((resolve) => {\n"
134+
" const child = spawn(BIN, ['cli', tool, JSON.stringify(args ?? {})], {\n"
135+
" stdio: ['ignore', 'pipe', 'pipe'],\n"
136+
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
137+
" });\n"
138+
" let out = '';\n"
139+
" const onAbort = () => { if (!child.killed) child.kill(); };\n"
140+
" signal?.addEventListener('abort', onAbort, { once: true });\n"
141+
" child.stdout.on('data', (d) => (out += d.toString()));\n"
142+
" child.on('error', (e) => {\n"
143+
" signal?.removeEventListener('abort', onAbort);\n"
144+
" resolve({ error: String(e && e.message ? e.message : e) });\n"
145+
" });\n"
146+
" child.on('close', () => {\n"
147+
" signal?.removeEventListener('abort', onAbort);\n"
148+
" const lines = out.split('\\n').map((l) => l.trim()).filter(Boolean);\n"
149+
" for (let i = lines.length - 1; i >= 0; i--) {\n"
150+
" try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n"
151+
" }\n"
152+
" resolve({ error: 'no JSON response from codebase-memory-mcp' });\n"
153+
" });\n"
154+
" });\n"
155+
"}\n\n");
156+
157+
/* Register every tool the registry advertises — never a hand-picked subset,
158+
* which is the drift this generator exists to prevent. */
159+
sb_append(&sb, "export function register(pi) {\n");
160+
for (int i = 0; i < count; i++) {
161+
const char *name = cbm_mcp_tool_name(i);
162+
if (!name || !name[0]) {
163+
continue;
164+
}
165+
sb_append(&sb, " pi.registerTool({ name: '");
166+
sb_append(&sb, name);
167+
sb_append(&sb, "', run: (args, ctx) => call('");
168+
sb_append(&sb, name);
169+
sb_append(&sb, "', args, ctx?.signal) });\n");
170+
}
171+
sb_append(&sb, "}\n");
172+
173+
if (sb.failed) {
174+
free(sb.buf);
175+
return NULL;
176+
}
177+
return sb.buf;
178+
}
179+
180+
char *cbm_client_adapter_opencode(const char *binary_path) {
181+
if (!binary_path || !binary_path[0]) {
182+
return NULL;
183+
}
184+
char bin[CBM_SZ_1K];
185+
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
186+
return NULL;
187+
}
188+
189+
adapter_sb_t sb = {0};
190+
emit_header(&sb, "OpenCode");
191+
sb_append(&sb, "// OpenCode already reaches every tool over MCP; this adds only the\n"
192+
"// automatic graph lookup before a grep/glob, which other clients get\n"
193+
"// through their own hook configuration.\n");
194+
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
195+
sb_append(&sb, "const BIN = '");
196+
sb_append(&sb, bin);
197+
sb_append(&sb, "';\n\n");
198+
199+
/* hook-augment requires hook_event_name and accepts Grep/Glob only under
200+
* PreToolUse; omitting it makes the whole hook a silent no-op. */
201+
sb_append(&sb, "function augment(tool, args) {\n"
202+
" return new Promise((resolve) => {\n"
203+
" const child = spawn(BIN, ['hook-augment'], {\n"
204+
" stdio: ['pipe', 'pipe', 'ignore'],\n"
205+
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
206+
" });\n"
207+
" let out = '';\n"
208+
" child.stdout.on('data', (d) => (out += d.toString()));\n"
209+
" child.on('error', () => resolve(''));\n"
210+
" child.on('close', () => resolve(out));\n"
211+
" child.stdin.end(JSON.stringify({\n"
212+
" hook_event_name: 'PreToolUse',\n"
213+
" tool_name: tool,\n"
214+
" tool_input: args ?? {},\n"
215+
" }));\n"
216+
" });\n"
217+
"}\n\n");
218+
219+
sb_append(&sb,
220+
"export const CodebaseMemory = async () => ({\n"
221+
" 'tool.execute.after': async (input, output) => {\n"
222+
" const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' "
223+
": null;\n"
224+
" if (!tool) return;\n"
225+
" const extra = await augment(tool, output?.args);\n"
226+
" if (extra && typeof output?.output === 'string') {\n"
227+
" output.output += '\\n' + extra;\n"
228+
" }\n"
229+
" },\n"
230+
"});\n");
231+
232+
if (sb.failed) {
233+
free(sb.buf);
234+
return NULL;
235+
}
236+
return sb.buf;
237+
}

0 commit comments

Comments
 (0)