auxilo-mcp 0.9.2 → 0.9.3

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
@@ -36,6 +36,24 @@ const DEFAULT_BASE_URL = 'https://auxilo.io';
36
36
  /** Root of the installed npm package (or the repo, when run from a clone). */
37
37
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
38
38
 
39
+ /**
40
+ * Source-adapter rows for the runner stack, DERIVED by enumerating
41
+ * scripts/sources/*.js (UC-3): a new adapter file is automatically shipped —
42
+ * the d8c7099 bug class (adapter added, manifest not) is structurally
43
+ * impossible for the sources dir. Mirrored by runner.js sweeperManifest();
44
+ * the manifest-closure test asserts both stay complete.
45
+ */
46
+ function sourceAdapterRows(packageRoot = PACKAGE_ROOT) {
47
+ try {
48
+ return fs.readdirSync(path.join(packageRoot, 'scripts', 'sources'))
49
+ .filter((f) => f.endsWith('.js'))
50
+ .sort()
51
+ .map((f) => [`scripts/sources/${f}`, `scripts/sources/${f}`, 0o644]);
52
+ } catch {
53
+ return []; // missing dir surfaces as installRunner missing-file errors
54
+ }
55
+ }
56
+
39
57
  /**
40
58
  * Runner stack shipped in the npm tarball and copied into <home>/.auxilo/bin,
41
59
  * preserving relative layout so runner.js's requires resolve
@@ -51,12 +69,9 @@ const RUNNER_STACK = Object.freeze([
51
69
  // at first extraction; omitting it from the installed stack is a MODULE_NOT_FOUND
52
70
  // for every npm-installed user (test/runner-packaging-closure.test.js guards this).
53
71
  ['scripts/extract-local.js', 'scripts/extract-local.js', 0o644],
54
- ['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
55
- ['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
56
- ['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
57
- ['scripts/sources/gemini-cli.js', 'scripts/sources/gemini-cli.js', 0o644],
58
- ['scripts/sources/antigravity.js', 'scripts/sources/antigravity.js', 0o644],
59
- ['scripts/sources/generic-jsonl.js', 'scripts/sources/generic-jsonl.js', 0o644],
72
+ // LW-18 layer 1b: SessionStart held-count notice (shim target).
73
+ ['scripts/review-notice.js', 'scripts/review-notice.js', 0o755],
74
+ ...sourceAdapterRows(),
60
75
  ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
61
76
  ]);
62
77
 
@@ -89,6 +104,21 @@ const RUNNER_STACK = Object.freeze([
89
104
  * @param {object} [opts.env=process.env] For %APPDATA% on win32.
90
105
  * @returns {Array<object>} registry entries (detected or not)
91
106
  */
107
+ /**
108
+ * VS Code-family globalStorage dirs for an extension publisher id, per
109
+ * platform (UC-3 poll-source detection: Cline / Roo Code). Any-of probing —
110
+ * Code, Code - Insiders, VSCodium.
111
+ */
112
+ function vscodeGlobalStorageDirs(homeDir, platform, env, publisherDirs) {
113
+ const apps = ['Code', 'Code - Insiders', 'VSCodium'];
114
+ const userDirs = platform === 'darwin'
115
+ ? apps.map((a) => path.join(homeDir, 'Library', 'Application Support', a, 'User'))
116
+ : platform === 'win32'
117
+ ? apps.map((a) => path.join(env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), a, 'User'))
118
+ : apps.map((a) => path.join(homeDir, '.config', a, 'User'));
119
+ return userDirs.flatMap((u) => publisherDirs.map((p) => path.join(u, 'globalStorage', p)));
120
+ }
121
+
92
122
  function clientRegistry(homeDir, opts = {}) {
93
123
  if (!homeDir) throw new Error('clientRegistry: homeDir is required');
94
124
  const platform = opts.platform || process.platform;
@@ -138,6 +168,24 @@ function clientRegistry(homeDir, opts = {}) {
138
168
  mcp: false,
139
169
  hooks: false,
140
170
  },
171
+ // ── UC-3 poll-based sources (no MCP config; sweeper adapters) ───────────
172
+ {
173
+ id: 'cline',
174
+ name: 'Cline (VS Code)',
175
+ detectDirs: vscodeGlobalStorageDirs(homeDir, platform, env, ['saoudrizwan.claude-dev']),
176
+ configPath: null, // poll-based source adapter (scripts/sources/cline.js)
177
+ mcp: false,
178
+ hooks: false,
179
+ },
180
+ {
181
+ id: 'roo-code',
182
+ name: 'Roo Code (VS Code)',
183
+ detectDirs: vscodeGlobalStorageDirs(homeDir, platform, env,
184
+ ['rooveterinaryinc.roo-cline', 'rooveterinaryinc.roo-code']),
185
+ configPath: null, // poll-based source adapter (scripts/sources/roo-code.js)
186
+ mcp: false,
187
+ hooks: false,
188
+ },
141
189
  // ── UC-0 additions (config paths web-verified June 2026, BUILD-SPEC-UNIVERSAL-CLIENTS §5) ──
142
190
  {
143
191
  id: 'windsurf',
@@ -544,11 +592,19 @@ function writeCredentials(homeDir, creds) {
544
592
  *
545
593
  * @param {object} opts
546
594
  * @param {string} [opts.baseUrl=DEFAULT_BASE_URL]
595
+ * @param {string} [opts.scope] Wave 3.4 (D2/NF-3): requested key scope
596
+ * (read | earnings-read | contribute). Omitted →
597
+ * no request body at all (exact pre-3.4 wire
598
+ * shape; server mints 'contribute').
599
+ * @param {string} [opts.label] Wave 3.4: requested key label (`auxilo init`).
547
600
  * @param {Function} [opts.fetchImpl=fetch] Injectable for tests.
548
601
  * @param {Function} [opts.onCode] (userCode, verificationUrl) → void; print/open browser.
549
602
  * @param {Function} [opts.sleep] (ms) → Promise; injectable for tests.
550
603
  * @param {number} [opts.maxWaitMs=600000] 10 min TTL (server DEVICE_CODE_TTL).
551
- * @returns {Promise<{ api_key: string, account_id: string, email: string, base_url: string }>}
604
+ * @returns {Promise<{ api_key: string, account_id: string, email: string, base_url: string,
605
+ * scope?: string, label?: string }>}
606
+ * scope/label are the server's echo of what was ACTUALLY minted (absent on
607
+ * pre-Wave-3.4 servers — callers requesting a scope should compare and warn).
552
608
  * @throws Error on expired code, HTTP failure, or timeout.
553
609
  */
554
610
  async function deviceLogin(opts = {}) {
@@ -557,10 +613,17 @@ async function deviceLogin(opts = {}) {
557
613
  const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
558
614
  const maxWaitMs = opts.maxWaitMs !== undefined ? opts.maxWaitMs : 600000;
559
615
 
560
- const res = await fetchImpl(`${baseUrl}/auth/device`, {
561
- method: 'POST',
562
- headers: { 'Content-Type': 'application/json' },
563
- });
616
+ // Wave 3.4: send a body ONLY when a scope/label was requested — an omitted
617
+ // body keeps the exact pre-3.4 request shape (and old servers ignore the
618
+ // body entirely, minting the historical contribute-scoped Device Login Key).
619
+ const init = { method: 'POST', headers: { 'Content-Type': 'application/json' } };
620
+ if (opts.scope !== undefined || opts.label !== undefined) {
621
+ const body = {};
622
+ if (opts.scope !== undefined) body.scope = opts.scope;
623
+ if (opts.label !== undefined) body.label = opts.label;
624
+ init.body = JSON.stringify(body);
625
+ }
626
+ const res = await fetchImpl(`${baseUrl}/auth/device`, init);
564
627
  if (!res.ok) throw new Error(`Device code request failed (HTTP ${res.status})`);
565
628
  const device = await res.json();
566
629
  // A-1: device_code is the secret polling credential; user_code is the human
@@ -590,6 +653,10 @@ async function deviceLogin(opts = {}) {
590
653
  account_id: status.account_id,
591
654
  email: status.email,
592
655
  base_url: baseUrl,
656
+ // Wave 3.4: server echo of the minted scope/label (undefined on
657
+ // pre-3.4 servers — callers compare against the request and warn).
658
+ scope: status.scope,
659
+ label: status.label,
593
660
  };
594
661
  }
595
662
  if (status.status === 'expired') {
@@ -600,6 +667,50 @@ async function deviceLogin(opts = {}) {
600
667
  throw new Error('Timed out waiting for device authorization (10 minutes).');
601
668
  }
602
669
 
670
+ // ─── Env-file writer (Wave 3.4 / NF-3 `auxilo init --env-file`) ─────────────
671
+
672
+ /**
673
+ * Write (or update in place) an env file with the minted key. Idempotent:
674
+ * existing AUXILO_API_KEY / AUXILO_BASE_URL lines are REPLACED, never
675
+ * duplicated; every other line is preserved byte-for-byte. New files are
676
+ * created 0600; existing files are chmod'd 0600 before the secret lands
677
+ * (same tmp(0600)+rename discipline as writeCredentials).
678
+ *
679
+ * @param {string} filePath Absolute or cwd-relative env file path.
680
+ * @param {{ api_key: string, base_url?: string }} values
681
+ * @returns {{ path: string, created: boolean }}
682
+ */
683
+ function writeEnvFile(filePath, values) {
684
+ if (!filePath) throw new Error('writeEnvFile: filePath is required');
685
+ if (!values || !values.api_key) throw new Error('writeEnvFile: values.api_key is required');
686
+
687
+ const existed = fs.existsSync(filePath);
688
+ let lines = [];
689
+ if (existed) {
690
+ fs.chmodSync(filePath, 0o600);
691
+ lines = fs.readFileSync(filePath, 'utf-8').split('\n');
692
+ // Drop a single trailing empty line so we can re-append cleanly.
693
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
694
+ }
695
+
696
+ const upsert = (name, value) => {
697
+ const rendered = `${name}=${value}`;
698
+ const idx = lines.findIndex((l) => l.startsWith(`${name}=`) || l.startsWith(`export ${name}=`));
699
+ if (idx === -1) lines.push(rendered);
700
+ else lines[idx] = lines[idx].startsWith('export ') ? `export ${rendered}` : rendered;
701
+ };
702
+ upsert('AUXILO_API_KEY', values.api_key);
703
+ if (values.base_url) upsert('AUXILO_BASE_URL', values.base_url);
704
+
705
+ const dir = path.dirname(filePath);
706
+ if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
707
+ const tmp = `${filePath}.tmp`;
708
+ fs.writeFileSync(tmp, lines.join('\n') + '\n', { mode: 0o600 });
709
+ fs.renameSync(tmp, filePath);
710
+ fs.chmodSync(filePath, 0o600);
711
+ return { path: filePath, created: !existed };
712
+ }
713
+
603
714
  // ─── Runner install (spec §LW-12 step 3; layout per P1-13) ──────────────────
604
715
 
605
716
  function binRootFor(homeDir) {
@@ -782,6 +893,52 @@ function registerClaudeCodeHook(homeDir) {
782
893
  return { changed: true, hookCmd, removedLegacy: removedLegacy.filter((c) => c !== hookCmd) };
783
894
  }
784
895
 
896
+ /**
897
+ * UC-1a: remove every Auxilo extraction entry from Claude Code's SessionEnd
898
+ * hooks — bare-string legacy entries AND structured command entries, while
899
+ * preserving non-Auxilo commands sharing a matcher group. Used by the setup
900
+ * consent=No path so no capture artifact remains (including ones left by
901
+ * earlier installs that wrote hooks pre-consent).
902
+ *
903
+ * Idempotent; missing settings file is a no-op; malformed JSON throws (B15).
904
+ *
905
+ * @returns {{ changed: boolean, removed: string[] }}
906
+ */
907
+ function removeClaudeCodeHook(homeDir) {
908
+ if (!homeDir) throw new Error('removeClaudeCodeHook: homeDir is required');
909
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
910
+ if (!fs.existsSync(settingsPath)) return { changed: false, removed: [] };
911
+
912
+ const settings = readClientConfig(settingsPath);
913
+ if (!settings.hooks || !Array.isArray(settings.hooks.SessionEnd)) {
914
+ return { changed: false, removed: [] };
915
+ }
916
+
917
+ const removed = [];
918
+ const kept = [];
919
+ for (const entry of settings.hooks.SessionEnd) {
920
+ if (typeof entry === 'string' && entry.includes('auxilo-extract')) {
921
+ removed.push(entry);
922
+ continue;
923
+ }
924
+ if (entry && typeof entry === 'object' && Array.isArray(entry.hooks)) {
925
+ const auxilo = entry.hooks.filter(isAuxiloCommandHook);
926
+ if (auxilo.length > 0) {
927
+ removed.push(...auxilo.map((h) => h.command));
928
+ const rest = entry.hooks.filter((h) => !isAuxiloCommandHook(h));
929
+ if (rest.length > 0) kept.push({ ...entry, hooks: rest });
930
+ continue;
931
+ }
932
+ }
933
+ kept.push(entry);
934
+ }
935
+
936
+ if (removed.length === 0) return { changed: false, removed: [] };
937
+ settings.hooks.SessionEnd = kept;
938
+ writeJsonAtomic(settingsPath, settings);
939
+ return { changed: true, removed };
940
+ }
941
+
785
942
  // ─── UC-1 capture-hook registration ─────────────────────────────────────────
786
943
  //
787
944
  // Per-client session-end hooks that pipe the client's hook JSON into the
@@ -800,6 +957,16 @@ function captureShimPath(homeDir, sourceId) {
800
957
  return path.join(binRootFor(homeDir), `auxilo-capture-${sourceId}.sh`);
801
958
  }
802
959
 
960
+ /**
961
+ * Shell-quote a literal for generated bash: single-quoted, embedded single
962
+ * quotes escaped as '\''. UC-1a L2: paths were previously interpolated inside
963
+ * double quotes, so a homeDir containing `"`, `$`, or backticks broke (or
964
+ * worse, executed inside) the generated script.
965
+ */
966
+ function shellQuote(s) {
967
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
968
+ }
969
+
803
970
  /**
804
971
  * Generate the capture shim body. stdin (the client's hook JSON) passes
805
972
  * straight through `exec` to capture-core.js; the PATH export covers GUI-
@@ -810,7 +977,7 @@ function renderCaptureShim(homeDir, sourceId) {
810
977
  return `#!/bin/bash
811
978
  # Auxilo capture shim (${sourceId}) — generated by \`auxilo setup\` (UC-1).
812
979
  export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
813
- exec /usr/bin/env node "${corePath}" --source ${sourceId}
980
+ exec /usr/bin/env node ${shellQuote(corePath)} --source ${shellQuote(sourceId)}
814
981
  `;
815
982
  }
816
983
 
@@ -1073,6 +1240,208 @@ function installCaptureHooks(homeDir, clients, opts = {}) {
1073
1240
  return results;
1074
1241
  }
1075
1242
 
1243
+ // ─── UC-1a capture-hook removal (consent=No cleanup) ────────────────────────
1244
+ //
1245
+ // Setup previously wrote capture hooks into third-party client configs BEFORE
1246
+ // the consent prompt (boundary smell + uninstall surprise — GOV-3 L1). The
1247
+ // fix is two-sided: bin/auxilo-cli.js now writes hooks only AFTER consent=yes,
1248
+ // and the No path calls these removers so no hook artifact remains — including
1249
+ // artifacts left by earlier installs that wrote pre-consent.
1250
+
1251
+ /** Strip every auxilo-capture entry from a flat hook array; drop empty result. */
1252
+ function stripFlatHookArray(arr) {
1253
+ if (!Array.isArray(arr)) return arr;
1254
+ return arr.filter((e) => !(e && typeof e === 'object' && isAuxiloCaptureCommand(e.command)));
1255
+ }
1256
+
1257
+ /** Strip auxilo-capture inner hooks from matcher groups; drop groups left empty. */
1258
+ function stripGroupHookArray(arr) {
1259
+ if (!Array.isArray(arr)) return arr;
1260
+ const kept = [];
1261
+ for (const group of arr) {
1262
+ if (group && typeof group === 'object' && Array.isArray(group.hooks)) {
1263
+ const rest = group.hooks.filter((h) => !(h && isAuxiloCaptureCommand(h.command)));
1264
+ if (rest.length === 0 && group.hooks.length > 0) continue; // group was all ours
1265
+ kept.push(rest.length === group.hooks.length ? group : { ...group, hooks: rest });
1266
+ continue;
1267
+ }
1268
+ kept.push(group);
1269
+ }
1270
+ return kept;
1271
+ }
1272
+
1273
+ /**
1274
+ * Remove the UC-1 capture hook for one client: strip the config entry (per
1275
+ * the client's dialect) and delete the per-source shim. Idempotent — absent
1276
+ * artifacts report changed:false. Malformed config JSON throws (B15 — caller
1277
+ * skips loudly).
1278
+ *
1279
+ * @returns {{ changed: boolean, hookPath: string, configPath: string }}
1280
+ */
1281
+ function removeCaptureHook(client, homeDir) {
1282
+ if (!homeDir) throw new Error('removeCaptureHook: homeDir is required');
1283
+ if (!client || !client.captureHook || !client.captureConfigPath) {
1284
+ throw new Error(`removeCaptureHook: client ${client && client.id} has no capture-hook support`);
1285
+ }
1286
+ const sourceId = client.sourceId || client.id;
1287
+ const shimPath = captureShimPath(homeDir, sourceId);
1288
+ let changed = false;
1289
+
1290
+ if (fs.existsSync(client.captureConfigPath)) {
1291
+ if (client.id === 'copilot-cli') {
1292
+ // Drop-in file we own entirely — delete it.
1293
+ fs.unlinkSync(client.captureConfigPath);
1294
+ changed = true;
1295
+ } else {
1296
+ changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
1297
+ if (client.id === 'antigravity') {
1298
+ delete config['auxilo-capture']; // we own only this top-level key
1299
+ return;
1300
+ }
1301
+ if (!config.hooks || typeof config.hooks !== 'object') return;
1302
+ for (const event of Object.keys(config.hooks)) {
1303
+ const arr = config.hooks[event];
1304
+ if (!Array.isArray(arr)) continue;
1305
+ const flat = stripFlatHookArray(arr);
1306
+ config.hooks[event] = stripGroupHookArray(flat);
1307
+ }
1308
+ });
1309
+ }
1310
+ }
1311
+
1312
+ if (fs.existsSync(shimPath)) {
1313
+ fs.unlinkSync(shimPath);
1314
+ changed = true;
1315
+ }
1316
+
1317
+ return { changed, hookPath: shimPath, configPath: client.captureConfigPath };
1318
+ }
1319
+
1320
+ /**
1321
+ * Remove capture hooks for every captureHook-capable client in `clients`.
1322
+ * Per-client try/catch (B15 skip-loudly), mirroring installCaptureHooks.
1323
+ *
1324
+ * @returns {Array<{ id, name, changed?, hookPath?, configPath?, error? }>}
1325
+ */
1326
+ function removeCaptureHooks(homeDir, clients) {
1327
+ if (!homeDir) throw new Error('removeCaptureHooks: homeDir is required');
1328
+ const results = [];
1329
+ for (const client of (clients || []).filter((c) => c && c.captureHook)) {
1330
+ try {
1331
+ results.push({ id: client.id, name: client.name, ...removeCaptureHook(client, homeDir) });
1332
+ } catch (err) {
1333
+ results.push({ id: client.id, name: client.name, error: err.message });
1334
+ }
1335
+ }
1336
+ return results;
1337
+ }
1338
+
1339
+ // ─── SessionStart held-count notice (LW-18 layer 1b) ────────────────────────
1340
+ //
1341
+ // A structured Claude Code SessionStart hook that runs
1342
+ // scripts/review-notice.js (count-only, fail-silent, ≤1 notice per 4h — the
1343
+ // script owns all of that; see its header for the threat-model contract).
1344
+ // NOT a capture hook: it reads the account's pending count from the server
1345
+ // and never touches transcripts, so it installs at setup step 4 independent
1346
+ // of extraction consent (the review queue also holds MCP contributions).
1347
+
1348
+ /** Absolute path of the SessionStart notice shim. */
1349
+ function noticeShimPath(homeDir) {
1350
+ return path.join(binRootFor(homeDir), 'auxilo-review-notice.sh');
1351
+ }
1352
+
1353
+ /** Marker every notice entry carries (registration + removal + status probe). */
1354
+ function isAuxiloNoticeCommand(cmd) {
1355
+ return typeof cmd === 'string' && cmd.includes('auxilo-review-notice');
1356
+ }
1357
+
1358
+ /** Generate the notice shim body (fail-silent: always exits 0). */
1359
+ function renderNoticeShim(homeDir) {
1360
+ const noticePath = path.join(binRootFor(homeDir), 'scripts', 'review-notice.js');
1361
+ return `#!/bin/bash
1362
+ # Auxilo SessionStart review notice — generated by \`auxilo setup\` (LW-18).
1363
+ # Count-only pending-review notice; fail-silent (never blocks session start).
1364
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
1365
+ /usr/bin/env node ${shellQuote(noticePath)} 2>/dev/null
1366
+ exit 0
1367
+ `;
1368
+ }
1369
+
1370
+ /**
1371
+ * Register the SessionStart notice: write the shim (0755) and patch
1372
+ * <home>/.claude/settings.json hooks.SessionStart[] to contain exactly one
1373
+ * STRUCTURED Auxilo entry ({hooks:[{type:'command',command}]}) — bare-string
1374
+ * entries are silently ignored by Claude Code (the LW-17 0.8.1 lesson).
1375
+ * Idempotent; stale auxilo-review-notice references replaced; non-Auxilo
1376
+ * SessionStart hooks preserved; malformed settings throws (B15).
1377
+ *
1378
+ * @returns {{ changed: boolean, hookCmd: string }}
1379
+ */
1380
+ function registerClaudeCodeSessionStartNotice(homeDir) {
1381
+ if (!homeDir) throw new Error('registerClaudeCodeSessionStartNotice: homeDir is required');
1382
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
1383
+ const hookCmd = noticeShimPath(homeDir);
1384
+
1385
+ // Shim first (its own idempotency: rewritten only when stale).
1386
+ const body = renderNoticeShim(homeDir);
1387
+ let shimChanged = false;
1388
+ if (!fs.existsSync(hookCmd) || fs.readFileSync(hookCmd, 'utf-8') !== body) {
1389
+ fs.mkdirSync(path.dirname(hookCmd), { recursive: true });
1390
+ fs.writeFileSync(hookCmd, body);
1391
+ fs.chmodSync(hookCmd, 0o755);
1392
+ shimChanged = true;
1393
+ }
1394
+
1395
+ const settings = readClientConfig(settingsPath);
1396
+ if (!settings.hooks) settings.hooks = {};
1397
+ if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
1398
+
1399
+ const kept = [];
1400
+ let alreadyExact = false;
1401
+ for (const entry of settings.hooks.SessionStart) {
1402
+ if (typeof entry === 'string' && isAuxiloNoticeCommand(entry)) continue; // dead bare-string form
1403
+ if (entry && typeof entry === 'object' && Array.isArray(entry.hooks)) {
1404
+ const ours = entry.hooks.filter((h) => h && isAuxiloNoticeCommand(h.command));
1405
+ if (ours.length > 0) {
1406
+ if (entry.hooks.length === 1 && ours[0].command === hookCmd && ours[0].type === 'command') {
1407
+ alreadyExact = true;
1408
+ }
1409
+ const rest = entry.hooks.filter((h) => !(h && isAuxiloNoticeCommand(h.command)));
1410
+ if (rest.length > 0) kept.push({ ...entry, hooks: rest });
1411
+ continue;
1412
+ }
1413
+ }
1414
+ kept.push(entry);
1415
+ }
1416
+
1417
+ if (alreadyExact && kept.length === settings.hooks.SessionStart.length - 1) {
1418
+ return { changed: shimChanged, hookCmd };
1419
+ }
1420
+
1421
+ settings.hooks.SessionStart = [...kept, { hooks: [{ type: 'command', command: hookCmd }] }];
1422
+ writeJsonAtomic(settingsPath, settings);
1423
+ return { changed: true, hookCmd };
1424
+ }
1425
+
1426
+ /**
1427
+ * Read-only probe for `auxilo status`: notice counts as registered when the
1428
+ * shim exists AND a STRUCTURED SessionStart entry references it. Never throws.
1429
+ */
1430
+ function sessionStartNoticeRegistered(homeDir) {
1431
+ try {
1432
+ if (!fs.existsSync(noticeShimPath(homeDir))) return false;
1433
+ const settings = JSON.parse(
1434
+ fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
1435
+ );
1436
+ return Array.isArray(settings.hooks && settings.hooks.SessionStart) &&
1437
+ settings.hooks.SessionStart.some((e) =>
1438
+ e && typeof e === 'object' && Array.isArray(e.hooks) &&
1439
+ e.hooks.some((h) => h && isAuxiloNoticeCommand(h.command)));
1440
+ } catch {
1441
+ return false;
1442
+ }
1443
+ }
1444
+
1076
1445
  // ─── Consent (spec §LW-12 step 5; server.js POST /extract/consent) ──────────
1077
1446
 
1078
1447
  /**
@@ -1222,6 +1591,8 @@ async function getStatus(homeDir, opts = {}) {
1222
1591
  runnerInstalled,
1223
1592
  hookInstalled: fs.existsSync(hookPath),
1224
1593
  hookRegistered,
1594
+ // LW-18 layer 1b: SessionStart held-count notice
1595
+ noticeRegistered: sessionStartNoticeRegistered(homeDir),
1225
1596
  lastSweep,
1226
1597
  pendingCount,
1227
1598
  };
@@ -1315,17 +1686,26 @@ module.exports = {
1315
1686
  credentialsPath,
1316
1687
  readCredentials,
1317
1688
  writeCredentials,
1689
+ writeEnvFile,
1318
1690
  deviceLogin,
1319
1691
  binRootFor,
1320
1692
  hookScriptPathFor,
1321
1693
  renderHookScript,
1322
1694
  installRunner,
1323
1695
  registerClaudeCodeHook,
1696
+ removeClaudeCodeHook,
1324
1697
  captureShimPath,
1325
1698
  renderCaptureShim,
1699
+ shellQuote,
1326
1700
  registerCaptureHook,
1327
1701
  captureHookRegistered,
1328
1702
  installCaptureHooks,
1703
+ removeCaptureHook,
1704
+ removeCaptureHooks,
1705
+ noticeShimPath,
1706
+ renderNoticeShim,
1707
+ registerClaudeCodeSessionStartNotice,
1708
+ sessionStartNoticeRegistered,
1329
1709
  recordConsent,
1330
1710
  sentinelPath,
1331
1711
  sentinelPresent,
package/mcp-server.js CHANGED
@@ -160,7 +160,7 @@ async function postBulkChunks(headers, decisions) {
160
160
  }
161
161
 
162
162
  const server = new Server(
163
- { name: 'auxilo', version: '0.9.2' },
163
+ { name: 'auxilo', version: '0.9.3' },
164
164
  {
165
165
  capabilities: { tools: {} },
166
166
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
@@ -295,7 +295,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
295
295
  },
296
296
  {
297
297
  name: 'auxilo_rate',
298
- description: 'Rate a learning 1-5 after using it. WHEN TO USE: After you unlock and apply knowledge from auxilo_unlock, always come back and rate it. Your rating helps other agents find the best knowledge and deprioritizes low-quality submissions. This is how the marketplace stays useful. Free.',
298
+ description: 'Rate a learning 1-5 after using it. WHEN TO USE: After you unlock and apply knowledge from auxilo_unlock, always come back and rate it. Your rating helps other agents find the best knowledge and deprioritizes low-quality submissions. This is how the marketplace stays useful. Free. REQUIRES: your API key (run `npx auxilo setup` if unset) and a prior unlock of this learning by your account — only verified purchasers can rate (LW-7).',
299
299
  inputSchema: {
300
300
  type: 'object',
301
301
  properties: {
@@ -736,12 +736,12 @@ if (require.main !== module) {
736
736
  return;
737
737
  }
738
738
 
739
- // ─── CLI delegation (LW-17) ────────────────────────────────────────────────────
740
- // `npx auxilo-mcp setup|status|review|disable` runs the full turnkey CLI.
739
+ // ─── CLI delegation (LW-17; init added Wave 3.4/NF-3) ─────────────────────────
740
+ // `npx auxilo-mcp setup|init|status|review|disable` runs the full turnkey CLI.
741
741
  // npx resolves PACKAGE names, not bin aliases — the documented `npx auxilo
742
742
  // setup` 404s until the `auxilo` npm package name is claimed, so the package
743
743
  // bin must handle these commands itself.
744
- if (['setup', 'status', 'review', 'disable'].includes(process.argv[2])) {
744
+ if (['setup', 'init', 'status', 'review', 'disable'].includes(process.argv[2])) {
745
745
  require('./bin/auxilo-cli.js').run();
746
746
  return; // module-level return in CommonJS stops the MCP server from starting
747
747
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "mcpName": "io.github.silent-architects/auxilo",
5
5
  "description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
6
6
  "main": "mcp-server.js",
@@ -17,6 +17,7 @@
17
17
  "scripts/runner.js",
18
18
  "scripts/capture-core.js",
19
19
  "scripts/extract-local.js",
20
+ "scripts/review-notice.js",
20
21
  "scripts/sources/",
21
22
  "scripts/hooks/auxilo-extract.sh",
22
23
  "README.md",
@@ -49,6 +50,8 @@
49
50
  "homepage": "https://auxilo.io",
50
51
  "license": "MIT",
51
52
  "dependencies": {
53
+ "@aws-sdk/client-s3": "^3.1090.0",
54
+ "@aws-sdk/lib-storage": "^3.1090.0",
52
55
  "@modelcontextprotocol/sdk": "^1.0.0",
53
56
  "stripe": "^17.0.0",
54
57
  "viem": "^2.46.3"
@@ -56,4 +59,4 @@
56
59
  "devDependencies": {
57
60
  "proxyquire": "^2.1.3"
58
61
  }
59
- }
62
+ }