auxilo-mcp 0.8.1 → 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 +82 -11
- package/lib/installer.js +777 -47
- package/lib/sensitivity-filter.js +7 -1
- package/mcp-server.js +20 -7
- 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) ───────────────────────────────────────
|
|
@@ -246,8 +559,11 @@ async function deviceLogin(opts = {}) {
|
|
|
246
559
|
});
|
|
247
560
|
if (!res.ok) throw new Error(`Device code request failed (HTTP ${res.status})`);
|
|
248
561
|
const device = await res.json();
|
|
249
|
-
|
|
562
|
+
// A-1: device_code is the secret polling credential; user_code is the human
|
|
563
|
+
// code shown on the verification page only.
|
|
564
|
+
const { user_code: userCode, device_code: deviceCode, verification_url: verificationUrl } = device;
|
|
250
565
|
if (!userCode) throw new Error('Device code request failed: no user_code in response');
|
|
566
|
+
if (!deviceCode) throw new Error('Device code request failed: no device_code in response');
|
|
251
567
|
|
|
252
568
|
// Server returns interval in seconds (default 5 per server.js /auth/device).
|
|
253
569
|
const intervalMs = (device.interval || 5) * 1000;
|
|
@@ -259,7 +575,7 @@ async function deviceLogin(opts = {}) {
|
|
|
259
575
|
await sleep(intervalMs);
|
|
260
576
|
let status;
|
|
261
577
|
try {
|
|
262
|
-
const poll = await fetchImpl(`${baseUrl}/auth/device/status?
|
|
578
|
+
const poll = await fetchImpl(`${baseUrl}/auth/device/status?device_code=${encodeURIComponent(deviceCode)}`);
|
|
263
579
|
status = await poll.json();
|
|
264
580
|
} catch {
|
|
265
581
|
continue; // transient network error during poll — keep polling
|
|
@@ -338,7 +654,10 @@ LOG="${auxiloDir}/extract.log"
|
|
|
338
654
|
# P1-13 lesson: do NOT set AUXILO_EXTRACTING here — runner.js's own recursion
|
|
339
655
|
# guard would trip and silently no-op every run. The runner sets it itself
|
|
340
656
|
# for child processes.
|
|
341
|
-
|
|
657
|
+
# LW-17: stdout goes to /dev/null — runner.js log() already appends every
|
|
658
|
+
# line to extract.log itself; redirecting stdout there too double-wrote each
|
|
659
|
+
# line and the daily digest double-counted. stderr still captured for crashes.
|
|
660
|
+
nohup /usr/bin/env node "$RUNNER" --transcript "$transcript_path" > /dev/null 2>> "$LOG" &
|
|
342
661
|
|
|
343
662
|
exit 0
|
|
344
663
|
`;
|
|
@@ -385,14 +704,27 @@ function installRunner(homeDir, opts = {}) {
|
|
|
385
704
|
|
|
386
705
|
// ─── Claude Code hook registration (spec §LW-12 step 4) ─────────────────────
|
|
387
706
|
|
|
707
|
+
/** True when an inner hook object is an Auxilo extraction command. */
|
|
708
|
+
function isAuxiloCommandHook(h) {
|
|
709
|
+
return h && typeof h === 'object' && typeof h.command === 'string' &&
|
|
710
|
+
h.command.includes('auxilo-extract');
|
|
711
|
+
}
|
|
712
|
+
|
|
388
713
|
/**
|
|
389
714
|
* Patch <home>/.claude/settings.json hooks.SessionEnd[] to contain exactly one
|
|
390
|
-
* Auxilo entry: <home>/.auxilo/bin/auxilo-extract.sh
|
|
715
|
+
* Auxilo entry: <home>/.auxilo/bin/auxilo-extract.sh, in Claude Code's
|
|
716
|
+
* STRUCTURED form ({ hooks: [{ type: 'command', command }] }).
|
|
717
|
+
*
|
|
718
|
+
* LW-17: 0.8.1 appended the command as a bare string — Claude Code silently
|
|
719
|
+
* ignores string entries, so the hook NEVER fired. It also only removed
|
|
720
|
+
* legacy entries that were strings, missing the pre-LW-12 structured entry
|
|
721
|
+
* (~/.claude/hooks/auxilo-extract.sh nested inside a matcher group).
|
|
391
722
|
*
|
|
392
|
-
* Idempotent.
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
* Malformed settings.json throws (B15) — never
|
|
723
|
+
* Idempotent. Removes every Auxilo reference (bare strings, and command
|
|
724
|
+
* objects inside matcher groups — preserving any non-Auxilo commands sharing
|
|
725
|
+
* the group), then appends one canonical structured entry. Non-Auxilo entries
|
|
726
|
+
* are preserved untouched. Malformed settings.json throws (B15) — never
|
|
727
|
+
* overwritten.
|
|
396
728
|
*
|
|
397
729
|
* @returns {{ changed: boolean, hookCmd: string, removedLegacy: string[] }}
|
|
398
730
|
*/
|
|
@@ -406,21 +738,335 @@ function registerClaudeCodeHook(homeDir) {
|
|
|
406
738
|
if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
|
|
407
739
|
|
|
408
740
|
const before = settings.hooks.SessionEnd;
|
|
409
|
-
const removedLegacy =
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
741
|
+
const removedLegacy = [];
|
|
742
|
+
const kept = [];
|
|
743
|
+
|
|
744
|
+
for (const entry of before) {
|
|
745
|
+
if (typeof entry === 'string' && entry.includes('auxilo-extract')) {
|
|
746
|
+
removedLegacy.push(entry); // includes 0.8.1's dead bare-string entry
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (entry && typeof entry === 'object' && Array.isArray(entry.hooks)) {
|
|
750
|
+
const auxilo = entry.hooks.filter(isAuxiloCommandHook);
|
|
751
|
+
if (auxilo.length > 0) {
|
|
752
|
+
removedLegacy.push(...auxilo.map((h) => h.command));
|
|
753
|
+
const rest = entry.hooks.filter((h) => !isAuxiloCommandHook(h));
|
|
754
|
+
if (rest.length > 0) kept.push({ ...entry, hooks: rest });
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
kept.push(entry);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const canonical = { hooks: [{ type: 'command', command: hookCmd }] };
|
|
762
|
+
|
|
763
|
+
// Idempotency: unchanged iff the only Auxilo reference found was already
|
|
764
|
+
// the canonical command in a structured single-command entry.
|
|
765
|
+
const alreadyExact =
|
|
766
|
+
removedLegacy.length === 1 && removedLegacy[0] === hookCmd &&
|
|
767
|
+
before.some((e) =>
|
|
768
|
+
e && typeof e === 'object' && Array.isArray(e.hooks) &&
|
|
769
|
+
e.hooks.length === 1 && isAuxiloCommandHook(e.hooks[0]) &&
|
|
770
|
+
e.hooks[0].command === hookCmd && e.hooks[0].type === 'command'
|
|
771
|
+
);
|
|
417
772
|
if (alreadyExact) {
|
|
418
773
|
return { changed: false, hookCmd, removedLegacy: [] };
|
|
419
774
|
}
|
|
420
775
|
|
|
421
|
-
settings.hooks.SessionEnd = [...kept,
|
|
776
|
+
settings.hooks.SessionEnd = [...kept, canonical];
|
|
422
777
|
writeJsonAtomic(settingsPath, settings);
|
|
423
|
-
return { changed: true, hookCmd, removedLegacy };
|
|
778
|
+
return { changed: true, hookCmd, removedLegacy: removedLegacy.filter((c) => c !== hookCmd) };
|
|
779
|
+
}
|
|
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;
|
|
424
1070
|
}
|
|
425
1071
|
|
|
426
1072
|
// ─── Consent (spec §LW-12 step 5; server.js POST /extract/consent) ──────────
|
|
@@ -499,15 +1145,16 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
499
1145
|
|
|
500
1146
|
// 1. Clients: detected + whether MCP registration is present
|
|
501
1147
|
const clients = detectClients(homeDir, opts).map((c) => {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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);
|
|
509
1156
|
}
|
|
510
|
-
return
|
|
1157
|
+
return base;
|
|
511
1158
|
});
|
|
512
1159
|
|
|
513
1160
|
// 2. Auth state
|
|
@@ -541,8 +1188,12 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
541
1188
|
const settings = JSON.parse(
|
|
542
1189
|
fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
|
|
543
1190
|
);
|
|
1191
|
+
// LW-17: only STRUCTURED entries count — Claude Code silently ignores
|
|
1192
|
+
// bare-string entries (the 0.8.1 dead-hook bug), so reporting one as
|
|
1193
|
+
// "registered" would mask exactly the failure this status exists to catch.
|
|
544
1194
|
hookRegistered = Array.isArray(settings.hooks && settings.hooks.SessionEnd) &&
|
|
545
|
-
settings.hooks.SessionEnd.some((h) =>
|
|
1195
|
+
settings.hooks.SessionEnd.some((h) =>
|
|
1196
|
+
h && typeof h === 'object' && Array.isArray(h.hooks) && h.hooks.some(isAuxiloCommandHook));
|
|
546
1197
|
} catch { /* no settings */ }
|
|
547
1198
|
|
|
548
1199
|
// 5. Last extraction + pending queue (ledger conventions from scripts/runner.js)
|
|
@@ -572,6 +1223,75 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
572
1223
|
};
|
|
573
1224
|
}
|
|
574
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
|
+
|
|
575
1295
|
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
576
1296
|
|
|
577
1297
|
module.exports = {
|
|
@@ -582,7 +1302,12 @@ module.exports = {
|
|
|
582
1302
|
clientRegistry,
|
|
583
1303
|
detectClients,
|
|
584
1304
|
registerMcp,
|
|
1305
|
+
mcpRegistrationPresent,
|
|
585
1306
|
readClientConfig,
|
|
1307
|
+
RULES_MARKER_BEGIN,
|
|
1308
|
+
RULES_MARKER_END,
|
|
1309
|
+
RULES_SNIPPET,
|
|
1310
|
+
installRulesSnippet,
|
|
586
1311
|
credentialsPath,
|
|
587
1312
|
readCredentials,
|
|
588
1313
|
writeCredentials,
|
|
@@ -592,6 +1317,11 @@ module.exports = {
|
|
|
592
1317
|
renderHookScript,
|
|
593
1318
|
installRunner,
|
|
594
1319
|
registerClaudeCodeHook,
|
|
1320
|
+
captureShimPath,
|
|
1321
|
+
renderCaptureShim,
|
|
1322
|
+
registerCaptureHook,
|
|
1323
|
+
captureHookRegistered,
|
|
1324
|
+
installCaptureHooks,
|
|
595
1325
|
recordConsent,
|
|
596
1326
|
sentinelPath,
|
|
597
1327
|
sentinelPresent,
|