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/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 exists
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
- try {
118
- return fs.statSync(c.detectDir).isDirectory();
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
- * Read-modify-write with tmp+rename; existing keys preserved; no-op (file
155
- * untouched) when `mcpServers.auxilo` is already present.
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 config = readClientConfig(client.configPath);
166
-
167
- if (config.mcpServers && config.mcpServers.auxilo) {
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
- if (!config.mcpServers) config.mcpServers = {};
172
- config.mcpServers.auxilo = { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] };
173
- writeJsonAtomic(client.configPath, config);
174
- return { changed: true, status: 'registered', configPath: client.configPath };
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
- let registered = null; // null = not applicable (no MCP config)
545
- if (c.mcp) {
546
- registered = false;
547
- try {
548
- const config = JSON.parse(fs.readFileSync(c.configPath, 'utf-8'));
549
- registered = Boolean(config.mcpServers && config.mcpServers.auxilo);
550
- } catch { /* missing or malformed config → not registered */ }
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 { id: c.id, name: c.name, mcp: c.mcp, registered };
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,