auxilo-mcp 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/auxilo-cli.js +52 -3
- package/lib/installer.js +712 -28
- package/package.json +4 -3
- package/scripts/capture-core.js +218 -0
- package/scripts/runner.js +54 -7
- package/scripts/sources/antigravity.js +131 -0
- package/scripts/sources/gemini-cli.js +178 -0
- package/scripts/sources/generic-jsonl.js +157 -0
package/lib/installer.js
CHANGED
|
@@ -46,17 +46,39 @@ const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
|
46
46
|
const RUNNER_STACK = Object.freeze([
|
|
47
47
|
// [package-relative source, bin-relative dest, mode]
|
|
48
48
|
['scripts/runner.js', 'scripts/runner.js', 0o755],
|
|
49
|
+
['scripts/capture-core.js', 'scripts/capture-core.js', 0o755], // UC-1 shim target
|
|
49
50
|
['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
|
|
50
51
|
['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
|
|
51
52
|
['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
|
|
53
|
+
['scripts/sources/gemini-cli.js', 'scripts/sources/gemini-cli.js', 0o644],
|
|
54
|
+
['scripts/sources/antigravity.js', 'scripts/sources/antigravity.js', 0o644],
|
|
55
|
+
['scripts/sources/generic-jsonl.js', 'scripts/sources/generic-jsonl.js', 0o644],
|
|
52
56
|
['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
|
|
53
57
|
]);
|
|
54
58
|
|
|
55
|
-
// ─── Client registry + detection (spec §LW-12 step 1)
|
|
59
|
+
// ─── Client registry + detection (spec §LW-12 step 1; UC-0 expansion) ───────
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
62
|
* Build the supported-client registry for a given home directory.
|
|
59
63
|
*
|
|
64
|
+
* Each entry's `format` names the config dialect registerMcp writes
|
|
65
|
+
* (UC-0: "MCP registration writers for every Tier-1/2 client config format"):
|
|
66
|
+
* 'json-mcpServers' — Claude-Desktop-style `mcpServers` object (the default)
|
|
67
|
+
* 'toml-codex' — Codex CLI config.toml, append-only (no TOML parser)
|
|
68
|
+
* 'json-dropin' — standalone drop-in file we own entirely (Continue.dev)
|
|
69
|
+
* 'opencode' — opencode.json `mcp` key, `{type:'local',command:[...]}`
|
|
70
|
+
* 'amp' — settings.json flat `amp.mcpServers` key
|
|
71
|
+
* 'openhands-stdio' — mcp.json `stdio_servers` ARRAY keyed by entry name
|
|
72
|
+
*
|
|
73
|
+
* Optional fields: `detectDirs` (any-of detection, e.g. opencode's two roots),
|
|
74
|
+
* `rulesPath` (global rules file for the UC-0 agent-prompted-contribution
|
|
75
|
+
* snippet — see installRulesSnippet), and the UC-1 capture-hook trio:
|
|
76
|
+
* `captureHook` (client supports a session-end hook we can register),
|
|
77
|
+
* `captureEvent` (the client's event name), `captureConfigPath` (the hook
|
|
78
|
+
* config file registerCaptureHook patches), plus `sourceId` when the
|
|
79
|
+
* runner-side source id differs from the registry id (codex → codex-cli,
|
|
80
|
+
* copilot-cli → copilot; must match model_config.json source_allowlist).
|
|
81
|
+
*
|
|
60
82
|
* @param {string} homeDir Explicit home directory (fixture dir in tests).
|
|
61
83
|
* @param {object} [opts]
|
|
62
84
|
* @param {string} [opts.platform=process.platform] 'darwin' | 'win32' | ...
|
|
@@ -78,6 +100,7 @@ function clientRegistry(homeDir, opts = {}) {
|
|
|
78
100
|
name: 'Claude Code',
|
|
79
101
|
detectDir: path.join(homeDir, '.claude'),
|
|
80
102
|
configPath: path.join(homeDir, '.claude', 'settings.json'),
|
|
103
|
+
format: 'json-mcpServers',
|
|
81
104
|
mcp: true,
|
|
82
105
|
hooks: true, // SessionEnd hook (background extraction)
|
|
83
106
|
},
|
|
@@ -86,6 +109,7 @@ function clientRegistry(homeDir, opts = {}) {
|
|
|
86
109
|
name: 'Claude Desktop',
|
|
87
110
|
detectDir: claudeDesktopDir,
|
|
88
111
|
configPath: path.join(claudeDesktopDir, 'claude_desktop_config.json'),
|
|
112
|
+
format: 'json-mcpServers',
|
|
89
113
|
mcp: true,
|
|
90
114
|
hooks: false,
|
|
91
115
|
},
|
|
@@ -94,8 +118,13 @@ function clientRegistry(homeDir, opts = {}) {
|
|
|
94
118
|
name: 'Cursor',
|
|
95
119
|
detectDir: path.join(homeDir, '.cursor'),
|
|
96
120
|
configPath: path.join(homeDir, '.cursor', 'mcp.json'),
|
|
121
|
+
format: 'json-mcpServers',
|
|
97
122
|
mcp: true,
|
|
98
123
|
hooks: false,
|
|
124
|
+
// UC-1 capture hook (session-end → capture-core shim)
|
|
125
|
+
captureHook: true,
|
|
126
|
+
captureEvent: 'sessionEnd',
|
|
127
|
+
captureConfigPath: path.join(homeDir, '.cursor', 'hooks.json'),
|
|
99
128
|
},
|
|
100
129
|
{
|
|
101
130
|
id: 'openclaw',
|
|
@@ -105,21 +134,177 @@ function clientRegistry(homeDir, opts = {}) {
|
|
|
105
134
|
mcp: false,
|
|
106
135
|
hooks: false,
|
|
107
136
|
},
|
|
137
|
+
// ── UC-0 additions (config paths web-verified June 2026, BUILD-SPEC-UNIVERSAL-CLIENTS §5) ──
|
|
138
|
+
{
|
|
139
|
+
id: 'windsurf',
|
|
140
|
+
name: 'Windsurf',
|
|
141
|
+
detectDir: path.join(homeDir, '.codeium', 'windsurf'),
|
|
142
|
+
configPath: path.join(homeDir, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
143
|
+
format: 'json-mcpServers',
|
|
144
|
+
mcp: true,
|
|
145
|
+
hooks: false,
|
|
146
|
+
captureHook: true,
|
|
147
|
+
captureEvent: 'post_cascade_response_with_transcript',
|
|
148
|
+
captureConfigPath: path.join(homeDir, '.codeium', 'windsurf', 'hooks.json'),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'codex',
|
|
152
|
+
name: 'Codex CLI',
|
|
153
|
+
detectDir: path.join(homeDir, '.codex'),
|
|
154
|
+
configPath: path.join(homeDir, '.codex', 'config.toml'),
|
|
155
|
+
format: 'toml-codex',
|
|
156
|
+
mcp: true,
|
|
157
|
+
hooks: false,
|
|
158
|
+
captureHook: true,
|
|
159
|
+
captureEvent: 'Stop',
|
|
160
|
+
captureConfigPath: path.join(homeDir, '.codex', 'hooks.json'),
|
|
161
|
+
// Runner-side source id (model_config.json source_allowlist uses
|
|
162
|
+
// 'codex-cli'; the registry/client id stays 'codex').
|
|
163
|
+
sourceId: 'codex-cli',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'gemini-cli',
|
|
167
|
+
name: 'Gemini CLI',
|
|
168
|
+
// Bare ~/.gemini is NOT sufficient: Antigravity also creates ~/.gemini
|
|
169
|
+
// (verified on a real machine with Antigravity but no Gemini CLI —
|
|
170
|
+
// phantom detection). Require CLI-specific artifacts: the session tmp
|
|
171
|
+
// dir, or an existing settings.json.
|
|
172
|
+
detectDirs: [path.join(homeDir, '.gemini', 'tmp')],
|
|
173
|
+
detectFiles: [path.join(homeDir, '.gemini', 'settings.json')],
|
|
174
|
+
configPath: path.join(homeDir, '.gemini', 'settings.json'),
|
|
175
|
+
format: 'json-mcpServers',
|
|
176
|
+
mcp: true,
|
|
177
|
+
hooks: false,
|
|
178
|
+
// Global context file — target for the UC-0 rules-snippet opt-in.
|
|
179
|
+
rulesPath: path.join(homeDir, '.gemini', 'GEMINI.md'),
|
|
180
|
+
captureHook: true,
|
|
181
|
+
captureEvent: 'SessionEnd',
|
|
182
|
+
captureConfigPath: path.join(homeDir, '.gemini', 'settings.json'),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
// Separate client from Gemini CLI: detection requires the antigravity
|
|
186
|
+
// SUBDIR (a bare ~/.gemini means Gemini CLI only).
|
|
187
|
+
id: 'antigravity',
|
|
188
|
+
name: 'Antigravity',
|
|
189
|
+
detectDir: path.join(homeDir, '.gemini', 'antigravity'),
|
|
190
|
+
configPath: path.join(homeDir, '.gemini', 'config', 'mcp_config.json'),
|
|
191
|
+
format: 'json-mcpServers',
|
|
192
|
+
mcp: true,
|
|
193
|
+
hooks: false,
|
|
194
|
+
captureHook: true,
|
|
195
|
+
captureEvent: 'Stop',
|
|
196
|
+
captureConfigPath: path.join(homeDir, '.gemini', 'config', 'hooks.json'),
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: 'factory',
|
|
200
|
+
name: 'Factory droid',
|
|
201
|
+
detectDir: path.join(homeDir, '.factory'),
|
|
202
|
+
configPath: path.join(homeDir, '.factory', 'mcp.json'),
|
|
203
|
+
format: 'json-mcpServers',
|
|
204
|
+
mcp: true,
|
|
205
|
+
hooks: false,
|
|
206
|
+
captureHook: true,
|
|
207
|
+
captureEvent: 'SessionEnd',
|
|
208
|
+
// NOT settings.json — Factory reads hooks from ~/.factory/hooks.json.
|
|
209
|
+
captureConfigPath: path.join(homeDir, '.factory', 'hooks.json'),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: 'copilot-cli',
|
|
213
|
+
name: 'GitHub Copilot CLI',
|
|
214
|
+
detectDir: path.join(homeDir, '.copilot'),
|
|
215
|
+
configPath: path.join(homeDir, '.copilot', 'mcp-config.json'),
|
|
216
|
+
format: 'json-mcpServers',
|
|
217
|
+
mcp: true,
|
|
218
|
+
hooks: false,
|
|
219
|
+
captureHook: true,
|
|
220
|
+
captureEvent: 'Stop',
|
|
221
|
+
// Drop-in file we own entirely; the ~/.copilot/hooks dir is shared with
|
|
222
|
+
// VS Code Copilot user-level hooks (same machine config root).
|
|
223
|
+
captureConfigPath: path.join(homeDir, '.copilot', 'hooks', 'auxilo.json'),
|
|
224
|
+
// Runner-side source id (allowlist uses 'copilot', not 'copilot-cli').
|
|
225
|
+
sourceId: 'copilot',
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
// Continue auto-loads Claude-style JSON drop-ins from ~/.continue/mcpServers/.
|
|
229
|
+
id: 'continue',
|
|
230
|
+
name: 'Continue.dev',
|
|
231
|
+
detectDir: path.join(homeDir, '.continue'),
|
|
232
|
+
configPath: path.join(homeDir, '.continue', 'mcpServers', 'auxilo.json'),
|
|
233
|
+
format: 'json-dropin',
|
|
234
|
+
mcp: true,
|
|
235
|
+
hooks: false,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'opencode',
|
|
239
|
+
name: 'opencode',
|
|
240
|
+
detectDir: path.join(homeDir, '.config', 'opencode'),
|
|
241
|
+
detectDirs: [
|
|
242
|
+
path.join(homeDir, '.config', 'opencode'),
|
|
243
|
+
path.join(homeDir, '.local', 'share', 'opencode'),
|
|
244
|
+
],
|
|
245
|
+
configPath: path.join(homeDir, '.config', 'opencode', 'opencode.json'),
|
|
246
|
+
format: 'opencode',
|
|
247
|
+
mcp: true,
|
|
248
|
+
hooks: false,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: 'kiro',
|
|
252
|
+
name: 'Kiro',
|
|
253
|
+
detectDir: path.join(homeDir, '.kiro'),
|
|
254
|
+
configPath: path.join(homeDir, '.kiro', 'settings', 'mcp.json'),
|
|
255
|
+
format: 'json-mcpServers',
|
|
256
|
+
mcp: true,
|
|
257
|
+
hooks: false,
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'junie',
|
|
261
|
+
name: 'JetBrains Junie',
|
|
262
|
+
detectDir: path.join(homeDir, '.junie'),
|
|
263
|
+
configPath: path.join(homeDir, '.junie', 'mcp', 'mcp.json'),
|
|
264
|
+
format: 'json-mcpServers',
|
|
265
|
+
mcp: true,
|
|
266
|
+
hooks: false,
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: 'amp',
|
|
270
|
+
name: 'Amp',
|
|
271
|
+
detectDir: path.join(homeDir, '.config', 'amp'),
|
|
272
|
+
configPath: path.join(homeDir, '.config', 'amp', 'settings.json'),
|
|
273
|
+
format: 'amp',
|
|
274
|
+
mcp: true,
|
|
275
|
+
hooks: false,
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 'openhands',
|
|
279
|
+
name: 'OpenHands',
|
|
280
|
+
detectDir: path.join(homeDir, '.openhands'),
|
|
281
|
+
configPath: path.join(homeDir, '.openhands', 'mcp.json'),
|
|
282
|
+
format: 'openhands-stdio',
|
|
283
|
+
mcp: true,
|
|
284
|
+
hooks: false,
|
|
285
|
+
},
|
|
108
286
|
];
|
|
109
287
|
}
|
|
110
288
|
|
|
289
|
+
/** True when p exists and is a directory (detection probe). */
|
|
290
|
+
function dirExists(p) {
|
|
291
|
+
try {
|
|
292
|
+
return fs.statSync(p).isDirectory();
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
111
298
|
/**
|
|
112
299
|
* Detect which supported clients are present under homeDir.
|
|
113
|
-
* @returns {Array<object>} subset of clientRegistry whose detectDir
|
|
300
|
+
* @returns {Array<object>} subset of clientRegistry whose detectDir (or any
|
|
301
|
+
* of detectDirs, when a client has multiple roots) exists, or any of whose
|
|
302
|
+
* detectFiles exist (clients whose only reliable marker is a file)
|
|
114
303
|
*/
|
|
115
304
|
function detectClients(homeDir, opts = {}) {
|
|
116
|
-
return clientRegistry(homeDir, opts).filter((c) =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
} catch {
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
122
|
-
});
|
|
305
|
+
return clientRegistry(homeDir, opts).filter((c) =>
|
|
306
|
+
(c.detectDirs || [c.detectDir]).some(dirExists) ||
|
|
307
|
+
(c.detectFiles || []).some((p) => fs.existsSync(p)));
|
|
123
308
|
}
|
|
124
309
|
|
|
125
310
|
// ─── MCP registration (spec §LW-12 step 1) ──────────────────────────────────
|
|
@@ -149,29 +334,157 @@ function readClientConfig(configPath) {
|
|
|
149
334
|
}
|
|
150
335
|
}
|
|
151
336
|
|
|
337
|
+
/** Fresh copy of the standard MCP server entry (never share the frozen one). */
|
|
338
|
+
function mcpEntry() {
|
|
339
|
+
return { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Result helpers shared by every registerMcp format writer. */
|
|
343
|
+
function unchangedResult(configPath) {
|
|
344
|
+
return { changed: false, status: 'already-registered', configPath };
|
|
345
|
+
}
|
|
346
|
+
function registeredResult(configPath) {
|
|
347
|
+
return { changed: true, status: 'registered', configPath };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Default writer: Claude-Desktop-style `mcpServers` object (most clients). */
|
|
351
|
+
function registerJsonMcpServers(client) {
|
|
352
|
+
const config = readClientConfig(client.configPath);
|
|
353
|
+
if (config.mcpServers && config.mcpServers.auxilo) return unchangedResult(client.configPath);
|
|
354
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
355
|
+
config.mcpServers.auxilo = mcpEntry();
|
|
356
|
+
writeJsonAtomic(client.configPath, config);
|
|
357
|
+
return registeredResult(client.configPath);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** TOML section header used for Codex idempotency (substring probe — no TOML dep). */
|
|
361
|
+
const CODEX_SECTION_HEADER = '[mcp_servers.auxilo]';
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Codex CLI writer: APPEND-ONLY into ~/.codex/config.toml. Existing TOML is
|
|
365
|
+
* never parsed, rewritten, or reformatted — we only ever append our own
|
|
366
|
+
* section, and only when the `[mcp_servers.auxilo]` header is absent.
|
|
367
|
+
*/
|
|
368
|
+
function registerCodexToml(client) {
|
|
369
|
+
const raw = fs.existsSync(client.configPath)
|
|
370
|
+
? fs.readFileSync(client.configPath, 'utf-8')
|
|
371
|
+
: '';
|
|
372
|
+
if (raw.includes(CODEX_SECTION_HEADER)) return unchangedResult(client.configPath);
|
|
373
|
+
|
|
374
|
+
const section = `\n${CODEX_SECTION_HEADER}\n` +
|
|
375
|
+
`command = ${JSON.stringify(MCP_ENTRY.command)}\n` +
|
|
376
|
+
`args = ${JSON.stringify(MCP_ENTRY.args)}\n`;
|
|
377
|
+
|
|
378
|
+
const tmp = `${client.configPath}.tmp`;
|
|
379
|
+
fs.mkdirSync(path.dirname(client.configPath), { recursive: true });
|
|
380
|
+
fs.writeFileSync(tmp, raw + section);
|
|
381
|
+
fs.renameSync(tmp, client.configPath);
|
|
382
|
+
return registeredResult(client.configPath);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Continue.dev writer: standalone drop-in file under ~/.continue/mcpServers/. */
|
|
386
|
+
function registerJsonDropin(client) {
|
|
387
|
+
const config = readClientConfig(client.configPath);
|
|
388
|
+
if (config.mcpServers && config.mcpServers.auxilo) return unchangedResult(client.configPath);
|
|
389
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
390
|
+
config.mcpServers.auxilo = mcpEntry();
|
|
391
|
+
writeJsonAtomic(client.configPath, config);
|
|
392
|
+
return registeredResult(client.configPath);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** opencode writer: `mcp` key, entry shape {type:'local', command:[bin,...args]}. */
|
|
396
|
+
function registerOpencode(client) {
|
|
397
|
+
const config = readClientConfig(client.configPath);
|
|
398
|
+
if (config.mcp && config.mcp.auxilo) return unchangedResult(client.configPath);
|
|
399
|
+
if (!config.mcp) config.mcp = {};
|
|
400
|
+
config.mcp.auxilo = { type: 'local', command: [MCP_ENTRY.command, ...MCP_ENTRY.args] };
|
|
401
|
+
writeJsonAtomic(client.configPath, config);
|
|
402
|
+
return registeredResult(client.configPath);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Amp writer: settings.json flat `amp.mcpServers` key (an object of servers). */
|
|
406
|
+
function registerAmp(client) {
|
|
407
|
+
const config = readClientConfig(client.configPath);
|
|
408
|
+
const servers = config['amp.mcpServers'];
|
|
409
|
+
if (servers && servers.auxilo) return unchangedResult(client.configPath);
|
|
410
|
+
config['amp.mcpServers'] = { ...(servers || {}), auxilo: mcpEntry() };
|
|
411
|
+
writeJsonAtomic(client.configPath, config);
|
|
412
|
+
return registeredResult(client.configPath);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** OpenHands writer: `stdio_servers` ARRAY of {name, command, args}, keyed by name. */
|
|
416
|
+
function registerOpenHands(client) {
|
|
417
|
+
const config = readClientConfig(client.configPath);
|
|
418
|
+
// GOV-3 M1: a non-array stdio_servers means the schema isn't what we expect
|
|
419
|
+
// (drift, or an object map) — skip loudly rather than clobbering the user's
|
|
420
|
+
// existing registrations with a coerced array (B15 discipline).
|
|
421
|
+
if (config.stdio_servers !== undefined && !Array.isArray(config.stdio_servers)) {
|
|
422
|
+
throw new Error(`Unexpected stdio_servers shape in ${client.configPath} (not an array) — add the Auxilo entry manually`);
|
|
423
|
+
}
|
|
424
|
+
const servers = config.stdio_servers || [];
|
|
425
|
+
if (servers.some((s) => s && s.name === 'auxilo')) return unchangedResult(client.configPath);
|
|
426
|
+
config.stdio_servers = [...servers, { name: 'auxilo', ...mcpEntry() }];
|
|
427
|
+
writeJsonAtomic(client.configPath, config);
|
|
428
|
+
return registeredResult(client.configPath);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Format → writer dispatch table (extend here when a new dialect appears). */
|
|
432
|
+
const MCP_WRITERS = Object.freeze({
|
|
433
|
+
'json-mcpServers': registerJsonMcpServers,
|
|
434
|
+
'toml-codex': registerCodexToml,
|
|
435
|
+
'json-dropin': registerJsonDropin,
|
|
436
|
+
'opencode': registerOpencode,
|
|
437
|
+
'amp': registerAmp,
|
|
438
|
+
'openhands-stdio': registerOpenHands,
|
|
439
|
+
});
|
|
440
|
+
|
|
152
441
|
/**
|
|
153
442
|
* Register the Auxilo MCP server in one client's config file.
|
|
154
|
-
*
|
|
155
|
-
*
|
|
443
|
+
* Dispatches on the client's `format` (default 'json-mcpServers'). All JSON
|
|
444
|
+
* writers are read-modify-write with tmp+rename; existing keys preserved;
|
|
445
|
+
* no-op (file untouched) when the auxilo entry is already present. The Codex
|
|
446
|
+
* TOML writer is append-only and never touches existing content.
|
|
156
447
|
*
|
|
157
448
|
* @param {object} client Entry from clientRegistry (must have configPath).
|
|
158
449
|
* @returns {{ changed: boolean, status: 'registered'|'already-registered', configPath: string }}
|
|
159
|
-
* @throws on malformed existing JSON (caller skips that client loudly)
|
|
450
|
+
* @throws on malformed existing JSON (B15 — caller skips that client loudly)
|
|
160
451
|
*/
|
|
161
452
|
function registerMcp(client) {
|
|
162
453
|
if (!client || !client.configPath) {
|
|
163
454
|
throw new Error(`registerMcp: client ${client && client.id} has no MCP config path`);
|
|
164
455
|
}
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return { changed: false, status: 'already-registered', configPath: client.configPath };
|
|
456
|
+
const writer = MCP_WRITERS[client.format || 'json-mcpServers'];
|
|
457
|
+
if (!writer) {
|
|
458
|
+
throw new Error(`registerMcp: client ${client.id} has unknown config format "${client.format}"`);
|
|
169
459
|
}
|
|
460
|
+
return writer(client);
|
|
461
|
+
}
|
|
170
462
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
463
|
+
/**
|
|
464
|
+
* True when the auxilo MCP entry is present in a client's config, per the
|
|
465
|
+
* client's format. Read-only probe for `auxilo status` — missing or malformed
|
|
466
|
+
* config reads as "not registered" (status must never throw on local probes).
|
|
467
|
+
*/
|
|
468
|
+
function mcpRegistrationPresent(client) {
|
|
469
|
+
try {
|
|
470
|
+
if (client.format === 'toml-codex') {
|
|
471
|
+
return fs.readFileSync(client.configPath, 'utf-8').includes(CODEX_SECTION_HEADER);
|
|
472
|
+
}
|
|
473
|
+
const config = JSON.parse(fs.readFileSync(client.configPath, 'utf-8'));
|
|
474
|
+
switch (client.format || 'json-mcpServers') {
|
|
475
|
+
case 'opencode':
|
|
476
|
+
return Boolean(config.mcp && config.mcp.auxilo);
|
|
477
|
+
case 'amp':
|
|
478
|
+
return Boolean(config['amp.mcpServers'] && config['amp.mcpServers'].auxilo);
|
|
479
|
+
case 'openhands-stdio':
|
|
480
|
+
return Array.isArray(config.stdio_servers) &&
|
|
481
|
+
config.stdio_servers.some((s) => s && s.name === 'auxilo');
|
|
482
|
+
default: // json-mcpServers + json-dropin share the mcpServers shape
|
|
483
|
+
return Boolean(config.mcpServers && config.mcpServers.auxilo);
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
return false; // missing or malformed config → not registered
|
|
487
|
+
}
|
|
175
488
|
}
|
|
176
489
|
|
|
177
490
|
// ─── Credentials (spec §LW-12 step 2) ───────────────────────────────────────
|
|
@@ -465,6 +778,297 @@ function registerClaudeCodeHook(homeDir) {
|
|
|
465
778
|
return { changed: true, hookCmd, removedLegacy: removedLegacy.filter((c) => c !== hookCmd) };
|
|
466
779
|
}
|
|
467
780
|
|
|
781
|
+
// ─── UC-1 capture-hook registration ─────────────────────────────────────────
|
|
782
|
+
//
|
|
783
|
+
// Per-client session-end hooks that pipe the client's hook JSON into the
|
|
784
|
+
// shared capture core (<home>/.auxilo/bin/scripts/capture-core.js), which owns
|
|
785
|
+
// ALL guard logic (consent sentinel, AUXILO_EXTRACTING recursion guard,
|
|
786
|
+
// fail-silent). Each client gets its own tiny bash shim so the hook config
|
|
787
|
+
// carries a stable absolute path and the right --source id.
|
|
788
|
+
//
|
|
789
|
+
// Hook config schemas web-verified June 2026 from official docs
|
|
790
|
+
// (BUILD-SPEC-UNIVERSAL-CLIENTS §UC-1). All JSON writers: merge-preserve
|
|
791
|
+
// unrelated content, idempotent, stale 'auxilo-capture' entries replaced not
|
|
792
|
+
// duplicated, malformed JSON throws (B15 — never overwritten).
|
|
793
|
+
|
|
794
|
+
/** Absolute path of the per-source capture shim. */
|
|
795
|
+
function captureShimPath(homeDir, sourceId) {
|
|
796
|
+
return path.join(binRootFor(homeDir), `auxilo-capture-${sourceId}.sh`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Generate the capture shim body. stdin (the client's hook JSON) passes
|
|
801
|
+
* straight through `exec` to capture-core.js; the PATH export covers GUI-
|
|
802
|
+
* launched clients whose hooks run without a login shell (no node on PATH).
|
|
803
|
+
*/
|
|
804
|
+
function renderCaptureShim(homeDir, sourceId) {
|
|
805
|
+
const corePath = path.join(binRootFor(homeDir), 'scripts', 'capture-core.js');
|
|
806
|
+
return `#!/bin/bash
|
|
807
|
+
# Auxilo capture shim (${sourceId}) — generated by \`auxilo setup\` (UC-1).
|
|
808
|
+
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
809
|
+
exec /usr/bin/env node "${corePath}" --source ${sourceId}
|
|
810
|
+
`;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Write the shim (0755) if missing or stale. Returns true when (re)written. */
|
|
814
|
+
function writeCaptureShim(homeDir, sourceId) {
|
|
815
|
+
const shimPath = captureShimPath(homeDir, sourceId);
|
|
816
|
+
const body = renderCaptureShim(homeDir, sourceId);
|
|
817
|
+
if (fs.existsSync(shimPath) && fs.readFileSync(shimPath, 'utf-8') === body) {
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
fs.mkdirSync(path.dirname(shimPath), { recursive: true });
|
|
821
|
+
fs.writeFileSync(shimPath, body);
|
|
822
|
+
fs.chmodSync(shimPath, 0o755);
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** True when an entry's command string references an Auxilo capture shim. */
|
|
827
|
+
function isAuxiloCaptureCommand(cmd) {
|
|
828
|
+
return typeof cmd === 'string' && cmd.includes('auxilo-capture');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Flat hook arrays ({command} entries — Cursor, Windsurf): strip stale
|
|
833
|
+
* auxilo-capture entries, append the canonical one.
|
|
834
|
+
*/
|
|
835
|
+
function patchFlatHookArray(arr, shimPath) {
|
|
836
|
+
const kept = (Array.isArray(arr) ? arr : [])
|
|
837
|
+
.filter((e) => !(e && typeof e === 'object' && isAuxiloCaptureCommand(e.command)));
|
|
838
|
+
return [...kept, { command: shimPath }];
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Matcher-group hook arrays ([{hooks:[{type,command,...}]}] — Gemini CLI,
|
|
843
|
+
* Factory, Codex): strip stale auxilo-capture inner hooks (preserving any
|
|
844
|
+
* non-Auxilo commands sharing a group, dropping groups left empty), then
|
|
845
|
+
* append one canonical group containing `entry`.
|
|
846
|
+
*/
|
|
847
|
+
function patchGroupHookArray(arr, entry) {
|
|
848
|
+
const kept = [];
|
|
849
|
+
for (const group of Array.isArray(arr) ? arr : []) {
|
|
850
|
+
if (group && typeof group === 'object' && Array.isArray(group.hooks)) {
|
|
851
|
+
const rest = group.hooks.filter((h) => !(h && isAuxiloCaptureCommand(h.command)));
|
|
852
|
+
if (rest.length === 0 && rest.length !== group.hooks.length) continue; // group was all ours
|
|
853
|
+
kept.push(rest.length === group.hooks.length ? group : { ...group, hooks: rest });
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
kept.push(group);
|
|
857
|
+
}
|
|
858
|
+
return [...kept, { hooks: [entry] }];
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Patch a JSON hook config in place: read (B15: malformed throws), mutate via
|
|
863
|
+
* `mutate(config)`, write atomically ONLY when the result differs (idempotent
|
|
864
|
+
* re-runs leave the file byte-identical).
|
|
865
|
+
* @returns {boolean} changed
|
|
866
|
+
*/
|
|
867
|
+
function patchJsonHookConfig(configPath, mutate) {
|
|
868
|
+
const config = readClientConfig(configPath);
|
|
869
|
+
const before = JSON.stringify(config);
|
|
870
|
+
mutate(config);
|
|
871
|
+
if (JSON.stringify(config) === before && fs.existsSync(configPath)) return false;
|
|
872
|
+
writeJsonAtomic(configPath, config);
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Per-client capture-hook config writers. Each receives (client, homeDir,
|
|
878
|
+
* shimPath) and returns { changed, notes? } for the hook CONFIG only (the
|
|
879
|
+
* shim is handled by registerCaptureHook). Keyed by registry id.
|
|
880
|
+
*/
|
|
881
|
+
const CAPTURE_WRITERS = Object.freeze({
|
|
882
|
+
// ~/.cursor/hooks.json — {"version":1,"hooks":{"sessionEnd":[{"command":...}]}}
|
|
883
|
+
// version:1 is REQUIRED by Cursor; other events/entries preserved.
|
|
884
|
+
'cursor': (client, homeDir, shimPath) => {
|
|
885
|
+
const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
|
|
886
|
+
if (config.version === undefined) config.version = 1;
|
|
887
|
+
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
888
|
+
config.hooks.sessionEnd = patchFlatHookArray(config.hooks.sessionEnd, shimPath);
|
|
889
|
+
});
|
|
890
|
+
return { changed };
|
|
891
|
+
},
|
|
892
|
+
|
|
893
|
+
// ~/.gemini/settings.json — hooks.SessionEnd: array of matcher groups, each
|
|
894
|
+
// {hooks:[{name,type,command,timeout}]}. Other settings keys (mcpServers,
|
|
895
|
+
// theme, ...) and other hook events preserved.
|
|
896
|
+
'gemini-cli': (client, homeDir, shimPath) => {
|
|
897
|
+
const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
|
|
898
|
+
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
899
|
+
config.hooks.SessionEnd = patchGroupHookArray(config.hooks.SessionEnd, {
|
|
900
|
+
name: 'auxilo-capture',
|
|
901
|
+
type: 'command',
|
|
902
|
+
command: shimPath,
|
|
903
|
+
timeout: 5000,
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
return { changed };
|
|
907
|
+
},
|
|
908
|
+
|
|
909
|
+
// ~/.gemini/config/hooks.json — top level keyed by hook-GROUP name (NOT a
|
|
910
|
+
// "hooks" key). We own ONLY the "auxilo-capture" group key; everything else
|
|
911
|
+
// is user content and is never touched.
|
|
912
|
+
'antigravity': (client, homeDir, shimPath) => {
|
|
913
|
+
const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
|
|
914
|
+
config['auxilo-capture'] = {
|
|
915
|
+
Stop: [{ hooks: [{ type: 'command', command: shimPath, timeout: 15 }] }],
|
|
916
|
+
};
|
|
917
|
+
});
|
|
918
|
+
return { changed, notes: 'schema low-confidence — verify live' };
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
// ~/.codeium/windsurf/hooks.json — flat {command} entries, no version field.
|
|
922
|
+
'windsurf': (client, homeDir, shimPath) => {
|
|
923
|
+
const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
|
|
924
|
+
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
925
|
+
config.hooks.post_cascade_response_with_transcript =
|
|
926
|
+
patchFlatHookArray(config.hooks.post_cascade_response_with_transcript, shimPath);
|
|
927
|
+
});
|
|
928
|
+
return { changed, notes: 'fires per-response; dedupe relies on runner ledger + server idempotency' };
|
|
929
|
+
},
|
|
930
|
+
|
|
931
|
+
// ~/.copilot/hooks/auxilo.json — drop-in file we own entirely (the hooks
|
|
932
|
+
// dir is shared with VS Code Copilot user-level hooks).
|
|
933
|
+
'copilot-cli': (client, homeDir, shimPath) => {
|
|
934
|
+
const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
|
|
935
|
+
for (const key of Object.keys(config)) delete config[key]; // we own the whole file
|
|
936
|
+
config.hooks = { Stop: [{ type: 'command', command: shimPath }] };
|
|
937
|
+
});
|
|
938
|
+
return { changed };
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
// ~/.factory/hooks.json (NOT settings.json) — matcher-group SessionEnd.
|
|
942
|
+
'factory': (client, homeDir, shimPath) => {
|
|
943
|
+
const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
|
|
944
|
+
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
945
|
+
config.hooks.SessionEnd = patchGroupHookArray(config.hooks.SessionEnd,
|
|
946
|
+
{ type: 'command', command: shimPath });
|
|
947
|
+
});
|
|
948
|
+
return { changed };
|
|
949
|
+
},
|
|
950
|
+
|
|
951
|
+
// ~/.codex/hooks.json — matcher-group Stop with timeout. GOTCHA: when
|
|
952
|
+
// ~/.codex/config.toml already defines a [hooks...] table, writing
|
|
953
|
+
// hooks.json too would double-layer (Codex warns) — skip with a note.
|
|
954
|
+
'codex': (client, homeDir, shimPath) => {
|
|
955
|
+
try {
|
|
956
|
+
const toml = fs.readFileSync(client.configPath, 'utf-8');
|
|
957
|
+
if (toml.includes('[hooks')) {
|
|
958
|
+
return {
|
|
959
|
+
changed: false,
|
|
960
|
+
notes: 'config.toml already defines hooks — add manually to avoid double-layer warning',
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
} catch { /* no config.toml — hooks.json is safe */ }
|
|
964
|
+
const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
|
|
965
|
+
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
966
|
+
config.hooks.Stop = patchGroupHookArray(config.hooks.Stop,
|
|
967
|
+
{ type: 'command', command: shimPath, timeout: 30 });
|
|
968
|
+
});
|
|
969
|
+
return { changed, notes: 'requires one-time interactive /hooks trust approval in Codex' };
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Register the UC-1 capture hook for one client: write the per-source shim
|
|
975
|
+
* (0755) and patch the client's hook config. Idempotent — identical re-runs
|
|
976
|
+
* report changed:false and leave both files byte-identical.
|
|
977
|
+
*
|
|
978
|
+
* @param {object} client Registry entry with captureHook:true.
|
|
979
|
+
* @param {string} homeDir
|
|
980
|
+
* @returns {{ changed: boolean, hookPath: string, configPath: string, notes?: string }}
|
|
981
|
+
* @throws on malformed existing hook config JSON (B15 — caller skips loudly)
|
|
982
|
+
*/
|
|
983
|
+
function registerCaptureHook(client, homeDir) {
|
|
984
|
+
if (!homeDir) throw new Error('registerCaptureHook: homeDir is required');
|
|
985
|
+
if (!client || !client.captureHook || !client.captureConfigPath) {
|
|
986
|
+
throw new Error(`registerCaptureHook: client ${client && client.id} has no capture-hook support`);
|
|
987
|
+
}
|
|
988
|
+
const writer = CAPTURE_WRITERS[client.id];
|
|
989
|
+
if (!writer) {
|
|
990
|
+
throw new Error(`registerCaptureHook: no capture writer for client "${client.id}"`);
|
|
991
|
+
}
|
|
992
|
+
const sourceId = client.sourceId || client.id;
|
|
993
|
+
const shimPath = captureShimPath(homeDir, sourceId);
|
|
994
|
+
|
|
995
|
+
// Codex skip-gotcha must not leave a dangling shim behind — let the writer
|
|
996
|
+
// decide first, then write the shim only when the hook is actually wired.
|
|
997
|
+
const result = writer(client, homeDir, shimPath);
|
|
998
|
+
let shimChanged = false;
|
|
999
|
+
if (!(client.id === 'codex' && result.changed === false &&
|
|
1000
|
+
result.notes && result.notes.includes('config.toml already defines hooks'))) {
|
|
1001
|
+
shimChanged = writeCaptureShim(homeDir, sourceId);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return {
|
|
1005
|
+
changed: Boolean(result.changed || shimChanged),
|
|
1006
|
+
hookPath: shimPath,
|
|
1007
|
+
configPath: client.captureConfigPath,
|
|
1008
|
+
...(result.notes ? { notes: result.notes } : {}),
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Read-only probe for `auxilo status`: a client's capture hook counts as
|
|
1014
|
+
* registered when the shim file exists AND the hook config references it.
|
|
1015
|
+
* Never throws (missing/malformed config reads as "not registered").
|
|
1016
|
+
*/
|
|
1017
|
+
function captureHookRegistered(client, homeDir) {
|
|
1018
|
+
if (!client || !client.captureHook || !client.captureConfigPath) return false;
|
|
1019
|
+
const shimPath = captureShimPath(homeDir, client.sourceId || client.id);
|
|
1020
|
+
try {
|
|
1021
|
+
return fs.existsSync(shimPath) &&
|
|
1022
|
+
fs.readFileSync(client.captureConfigPath, 'utf-8').includes(shimPath);
|
|
1023
|
+
} catch {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Register capture hooks for every captureHook-capable client in `clients`.
|
|
1030
|
+
* Ensures the capture core is installed under <home>/.auxilo/bin first (it
|
|
1031
|
+
* ships via RUNNER_STACK; installRunner normally ran already — this is the
|
|
1032
|
+
* belt-and-braces copy for partial installs). Per-client try/catch: one
|
|
1033
|
+
* malformed config skips loudly (B15) without stopping the loop.
|
|
1034
|
+
*
|
|
1035
|
+
* @param {string} homeDir
|
|
1036
|
+
* @param {Array<object>} clients Registry entries (e.g. the user's chosen set).
|
|
1037
|
+
* @param {object} [opts] { packageRoot }
|
|
1038
|
+
* @returns {Array<{ id, name, event, changed?, hookPath?, configPath?, notes?, error? }>}
|
|
1039
|
+
*/
|
|
1040
|
+
function installCaptureHooks(homeDir, clients, opts = {}) {
|
|
1041
|
+
if (!homeDir) throw new Error('installCaptureHooks: homeDir is required');
|
|
1042
|
+
const packageRoot = opts.packageRoot || PACKAGE_ROOT;
|
|
1043
|
+
const results = [];
|
|
1044
|
+
|
|
1045
|
+
const targets = (clients || []).filter((c) => c && c.captureHook);
|
|
1046
|
+
if (targets.length === 0) return results;
|
|
1047
|
+
|
|
1048
|
+
// Ensure the shared capture core exists where the shims point.
|
|
1049
|
+
const corePath = path.join(binRootFor(homeDir), 'scripts', 'capture-core.js');
|
|
1050
|
+
if (!fs.existsSync(corePath)) {
|
|
1051
|
+
const src = path.join(packageRoot, 'scripts', 'capture-core.js');
|
|
1052
|
+
if (!fs.existsSync(src)) {
|
|
1053
|
+
throw new Error(`installCaptureHooks: missing package file ${src} — reinstall auxilo-mcp`);
|
|
1054
|
+
}
|
|
1055
|
+
fs.mkdirSync(path.dirname(corePath), { recursive: true });
|
|
1056
|
+
fs.copyFileSync(src, corePath);
|
|
1057
|
+
fs.chmodSync(corePath, 0o755);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
for (const client of targets) {
|
|
1061
|
+
try {
|
|
1062
|
+
const r = registerCaptureHook(client, homeDir);
|
|
1063
|
+
results.push({ id: client.id, name: client.name, event: client.captureEvent, ...r });
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
// B15 skip-loudly: collect the error, keep going for the other clients.
|
|
1066
|
+
results.push({ id: client.id, name: client.name, event: client.captureEvent, error: err.message });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return results;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
468
1072
|
// ─── Consent (spec §LW-12 step 5; server.js POST /extract/consent) ──────────
|
|
469
1073
|
|
|
470
1074
|
/**
|
|
@@ -541,15 +1145,16 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
541
1145
|
|
|
542
1146
|
// 1. Clients: detected + whether MCP registration is present
|
|
543
1147
|
const clients = detectClients(homeDir, opts).map((c) => {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1148
|
+
// null = not applicable (no MCP config); per-format probe otherwise (UC-0).
|
|
1149
|
+
const registered = c.mcp ? mcpRegistrationPresent(c) : null;
|
|
1150
|
+
const base = { id: c.id, name: c.name, mcp: c.mcp, registered };
|
|
1151
|
+
// UC-1: capture-hook probe (shim exists AND hook config references it).
|
|
1152
|
+
if (c.captureHook) {
|
|
1153
|
+
base.captureHook = true;
|
|
1154
|
+
base.captureEvent = c.captureEvent;
|
|
1155
|
+
base.captureRegistered = captureHookRegistered(c, homeDir);
|
|
551
1156
|
}
|
|
552
|
-
return
|
|
1157
|
+
return base;
|
|
553
1158
|
});
|
|
554
1159
|
|
|
555
1160
|
// 2. Auth state
|
|
@@ -618,6 +1223,75 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
618
1223
|
};
|
|
619
1224
|
}
|
|
620
1225
|
|
|
1226
|
+
// ─── Rules-file snippet (UC-0 "agent-prompted contribution", R-class) ────────
|
|
1227
|
+
|
|
1228
|
+
/** Fenced-block markers: everything between them is ours to replace. */
|
|
1229
|
+
const RULES_MARKER_BEGIN = '<!-- auxilo:begin -->';
|
|
1230
|
+
const RULES_MARKER_END = '<!-- auxilo:end -->';
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* The instruction written into agents' GLOBAL rules files (R-class fallback,
|
|
1234
|
+
* BUILD-SPEC-UNIVERSAL-CLIENTS §3). Mirrors the auxilo_contribute contract:
|
|
1235
|
+
* quality gate >= 14/20, no credentials/PII, and the R-class recursion guard
|
|
1236
|
+
* (never contribute from an Auxilo-extraction session — §6).
|
|
1237
|
+
*/
|
|
1238
|
+
const RULES_SNIPPET = `## Auxilo: contribute what you learn
|
|
1239
|
+
|
|
1240
|
+
At the end of a completed task, if you solved a non-trivial problem — one that
|
|
1241
|
+
required debugging, multiple attempts, a workaround, or undocumented behavior —
|
|
1242
|
+
submit the learning to the Auxilo marketplace by calling the
|
|
1243
|
+
\`auxilo_contribute\` MCP tool. Only submit when the learning self-assesses at
|
|
1244
|
+
14/20 or higher across specificity, actionability, novelty, and completeness.
|
|
1245
|
+
Never include credentials, API keys, or PII in a submission, and never submit
|
|
1246
|
+
from an Auxilo-extraction session.`;
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Write the Auxilo contribution snippet into one or more GLOBAL rules files
|
|
1250
|
+
* (UC-0 scope: client-global files only — e.g. ~/.gemini/GEMINI.md — never
|
|
1251
|
+
* project-level AGENTS.md). Idempotent replace-between-markers: an existing
|
|
1252
|
+
* fenced block is replaced in place; otherwise the block is appended,
|
|
1253
|
+
* preserving all surrounding content. Writes are tmp + rename.
|
|
1254
|
+
*
|
|
1255
|
+
* @param {string} homeDir Explicit home (consistency guard — paths must live
|
|
1256
|
+
* under it; tests run on fixture homes only).
|
|
1257
|
+
* @param {object} opts
|
|
1258
|
+
* @param {string[]} opts.targets Absolute rules-file paths to write.
|
|
1259
|
+
* @returns {Array<{ path: string, changed: boolean }>} one result per target
|
|
1260
|
+
*/
|
|
1261
|
+
function installRulesSnippet(homeDir, opts = {}) {
|
|
1262
|
+
if (!homeDir) throw new Error('installRulesSnippet: homeDir is required');
|
|
1263
|
+
const targets = opts.targets || [];
|
|
1264
|
+
const block = `${RULES_MARKER_BEGIN}\n${RULES_SNIPPET}\n${RULES_MARKER_END}`;
|
|
1265
|
+
const results = [];
|
|
1266
|
+
|
|
1267
|
+
for (const target of targets) {
|
|
1268
|
+
const raw = fs.existsSync(target) ? fs.readFileSync(target, 'utf-8') : '';
|
|
1269
|
+
const begin = raw.indexOf(RULES_MARKER_BEGIN);
|
|
1270
|
+
const end = raw.indexOf(RULES_MARKER_END);
|
|
1271
|
+
|
|
1272
|
+
let next;
|
|
1273
|
+
if (begin !== -1 && end !== -1 && end > begin) {
|
|
1274
|
+
// Replace ONLY between (and including) the markers; surrounding content untouched.
|
|
1275
|
+
next = raw.slice(0, begin) + block + raw.slice(end + RULES_MARKER_END.length);
|
|
1276
|
+
} else if (raw === '') {
|
|
1277
|
+
next = `${block}\n`;
|
|
1278
|
+
} else {
|
|
1279
|
+
next = raw + (raw.endsWith('\n') ? '' : '\n') + `\n${block}\n`;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (next === raw) {
|
|
1283
|
+
results.push({ path: target, changed: false });
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
const tmp = `${target}.tmp`;
|
|
1287
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1288
|
+
fs.writeFileSync(tmp, next);
|
|
1289
|
+
fs.renameSync(tmp, target);
|
|
1290
|
+
results.push({ path: target, changed: true });
|
|
1291
|
+
}
|
|
1292
|
+
return results;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
621
1295
|
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
622
1296
|
|
|
623
1297
|
module.exports = {
|
|
@@ -628,7 +1302,12 @@ module.exports = {
|
|
|
628
1302
|
clientRegistry,
|
|
629
1303
|
detectClients,
|
|
630
1304
|
registerMcp,
|
|
1305
|
+
mcpRegistrationPresent,
|
|
631
1306
|
readClientConfig,
|
|
1307
|
+
RULES_MARKER_BEGIN,
|
|
1308
|
+
RULES_MARKER_END,
|
|
1309
|
+
RULES_SNIPPET,
|
|
1310
|
+
installRulesSnippet,
|
|
632
1311
|
credentialsPath,
|
|
633
1312
|
readCredentials,
|
|
634
1313
|
writeCredentials,
|
|
@@ -638,6 +1317,11 @@ module.exports = {
|
|
|
638
1317
|
renderHookScript,
|
|
639
1318
|
installRunner,
|
|
640
1319
|
registerClaudeCodeHook,
|
|
1320
|
+
captureShimPath,
|
|
1321
|
+
renderCaptureShim,
|
|
1322
|
+
registerCaptureHook,
|
|
1323
|
+
captureHookRegistered,
|
|
1324
|
+
installCaptureHooks,
|
|
641
1325
|
recordConsent,
|
|
642
1326
|
sentinelPath,
|
|
643
1327
|
sentinelPresent,
|