auxilo-mcp 0.7.0 → 0.8.2

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.
@@ -0,0 +1,647 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/installer.js — Turnkey installer core (LW-12)
5
+ *
6
+ * Testable core for the `auxilo setup|status|disable` CLI (bin/auxilo-cli.js).
7
+ * Every filesystem-touching function takes an explicit `homeDir` and every
8
+ * network-touching function takes an injectable `fetchImpl`, so unit tests run
9
+ * against fixture directories and mocked fetch — NEVER the real `~/.claude`
10
+ * or `~/.auxilo` (spec: specs/BUILD-SPEC-LAUNCH-WAVE.md §LW-12).
11
+ *
12
+ * Conventions inherited from scripts/runner.js (B15 / P1-13):
13
+ * - Malformed client config JSON FAILS LOUDLY — never overwritten.
14
+ * - Config writes are read-modify-write with tmp + rename.
15
+ * - Executable surface installs to <home>/.auxilo/bin (NEVER ~/Documents — TCC).
16
+ * - Credentials file is chmod 0600.
17
+ *
18
+ * NOTE: this module must not resolve the real home directory itself (no `os`
19
+ * module at all) — the CLI binding layer (bin/auxilo-cli.js) does that.
20
+ * Enforced by a source guard in test/lw12-installer.test.js.
21
+ *
22
+ * @module installer
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ // ─── Constants ──────────────────────────────────────────────────────────────
29
+
30
+ /** MCP registration entry written into every client config (spec §LW-12 step 1). */
31
+ const MCP_ENTRY = Object.freeze({ command: 'npx', args: ['auxilo-mcp'] });
32
+
33
+ /** Default production API base (README.md / openapi.json servers[0]). */
34
+ const DEFAULT_BASE_URL = 'https://api.auxilo.io';
35
+
36
+ /** Root of the installed npm package (or the repo, when run from a clone). */
37
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
38
+
39
+ /**
40
+ * Runner stack shipped in the npm tarball and copied into <home>/.auxilo/bin,
41
+ * preserving relative layout so runner.js's requires resolve
42
+ * (bin/scripts/runner.js → bin/lib/sensitivity-filter.js, bin/scripts/sources/).
43
+ * Mirrors the P1-13 sweeper file set (scripts/runner.js installSweeper) minus
44
+ * the launchd wrapper — LW-12 is hook-fired only; P1-13 owns the plist.
45
+ */
46
+ const RUNNER_STACK = Object.freeze([
47
+ // [package-relative source, bin-relative dest, mode]
48
+ ['scripts/runner.js', 'scripts/runner.js', 0o755],
49
+ ['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
50
+ ['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
51
+ ['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
52
+ ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
53
+ ]);
54
+
55
+ // ─── Client registry + detection (spec §LW-12 step 1) ───────────────────────
56
+
57
+ /**
58
+ * Build the supported-client registry for a given home directory.
59
+ *
60
+ * @param {string} homeDir Explicit home directory (fixture dir in tests).
61
+ * @param {object} [opts]
62
+ * @param {string} [opts.platform=process.platform] 'darwin' | 'win32' | ...
63
+ * @param {object} [opts.env=process.env] For %APPDATA% on win32.
64
+ * @returns {Array<object>} registry entries (detected or not)
65
+ */
66
+ function clientRegistry(homeDir, opts = {}) {
67
+ if (!homeDir) throw new Error('clientRegistry: homeDir is required');
68
+ const platform = opts.platform || process.platform;
69
+ const env = opts.env || process.env;
70
+
71
+ const claudeDesktopDir = platform === 'win32'
72
+ ? path.join(env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), 'Claude')
73
+ : path.join(homeDir, 'Library', 'Application Support', 'Claude');
74
+
75
+ return [
76
+ {
77
+ id: 'claude-code',
78
+ name: 'Claude Code',
79
+ detectDir: path.join(homeDir, '.claude'),
80
+ configPath: path.join(homeDir, '.claude', 'settings.json'),
81
+ mcp: true,
82
+ hooks: true, // SessionEnd hook (background extraction)
83
+ },
84
+ {
85
+ id: 'claude-desktop',
86
+ name: 'Claude Desktop',
87
+ detectDir: claudeDesktopDir,
88
+ configPath: path.join(claudeDesktopDir, 'claude_desktop_config.json'),
89
+ mcp: true,
90
+ hooks: false,
91
+ },
92
+ {
93
+ id: 'cursor',
94
+ name: 'Cursor',
95
+ detectDir: path.join(homeDir, '.cursor'),
96
+ configPath: path.join(homeDir, '.cursor', 'mcp.json'),
97
+ mcp: true,
98
+ hooks: false,
99
+ },
100
+ {
101
+ id: 'openclaw',
102
+ name: 'OpenClaw',
103
+ detectDir: path.join(homeDir, '.openclaw'),
104
+ configPath: null, // no MCP config; poll-based source adapter (scripts/sources/openclaw.js)
105
+ mcp: false,
106
+ hooks: false,
107
+ },
108
+ ];
109
+ }
110
+
111
+ /**
112
+ * Detect which supported clients are present under homeDir.
113
+ * @returns {Array<object>} subset of clientRegistry whose detectDir exists
114
+ */
115
+ 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
+ });
123
+ }
124
+
125
+ // ─── MCP registration (spec §LW-12 step 1) ──────────────────────────────────
126
+
127
+ /** Atomic JSON write: tmp + rename (same convention as mcp-server.js setup). */
128
+ function writeJsonAtomic(filePath, obj) {
129
+ const tmp = `${filePath}.tmp`;
130
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
131
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
132
+ fs.renameSync(tmp, filePath);
133
+ }
134
+
135
+ /**
136
+ * Read-parse a client config file. Malformed JSON throws (B15: fail loudly,
137
+ * never overwrite). Missing file returns {}.
138
+ */
139
+ function readClientConfig(configPath) {
140
+ if (!fs.existsSync(configPath)) return {};
141
+ const raw = fs.readFileSync(configPath, 'utf-8');
142
+ try {
143
+ return JSON.parse(raw);
144
+ } catch (parseErr) {
145
+ throw new Error(
146
+ `Malformed JSON in ${configPath} — refusing to overwrite. ` +
147
+ `Fix the file manually, then re-run \`auxilo setup\`. Parse error: ${parseErr.message}`
148
+ );
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 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.
156
+ *
157
+ * @param {object} client Entry from clientRegistry (must have configPath).
158
+ * @returns {{ changed: boolean, status: 'registered'|'already-registered', configPath: string }}
159
+ * @throws on malformed existing JSON (caller skips that client loudly)
160
+ */
161
+ function registerMcp(client) {
162
+ if (!client || !client.configPath) {
163
+ throw new Error(`registerMcp: client ${client && client.id} has no MCP config path`);
164
+ }
165
+ const config = readClientConfig(client.configPath);
166
+
167
+ if (config.mcpServers && config.mcpServers.auxilo) {
168
+ return { changed: false, status: 'already-registered', configPath: client.configPath };
169
+ }
170
+
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 };
175
+ }
176
+
177
+ // ─── Credentials (spec §LW-12 step 2) ───────────────────────────────────────
178
+
179
+ function credentialsPath(homeDir) {
180
+ return path.join(homeDir, '.auxilo', 'credentials.json');
181
+ }
182
+
183
+ /**
184
+ * Read ~/.auxilo/credentials.json. Returns null when absent/malformed
185
+ * (same tolerance as scripts/runner.js loadCredentials).
186
+ */
187
+ function readCredentials(homeDir) {
188
+ try {
189
+ const creds = JSON.parse(fs.readFileSync(credentialsPath(homeDir), 'utf-8'));
190
+ return creds && typeof creds === 'object' ? creds : null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Write ~/.auxilo/credentials.json with mode 0600.
198
+ * Existing file is chmod 0600 BEFORE rewrite (spec), then replaced via
199
+ * tmp(0600) + rename so the secret never exists world-readable.
200
+ *
201
+ * @param {string} homeDir
202
+ * @param {{ api_key: string, base_url: string, email?: string, account_id?: string }} creds
203
+ * @returns {string} path written
204
+ */
205
+ function writeCredentials(homeDir, creds) {
206
+ if (!homeDir) throw new Error('writeCredentials: homeDir is required');
207
+ if (!creds || !creds.api_key) throw new Error('writeCredentials: creds.api_key is required');
208
+
209
+ const credPath = credentialsPath(homeDir);
210
+ fs.mkdirSync(path.dirname(credPath), { recursive: true });
211
+
212
+ if (fs.existsSync(credPath)) fs.chmodSync(credPath, 0o600);
213
+
214
+ const tmp = `${credPath}.tmp`;
215
+ fs.writeFileSync(tmp, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
216
+ fs.renameSync(tmp, credPath);
217
+ fs.chmodSync(credPath, 0o600); // belt-and-braces: rename preserves tmp mode, assert anyway
218
+ return credPath;
219
+ }
220
+
221
+ // ─── Device-code auth (spec §LW-12 step 2; server.js /auth/device) ──────────
222
+
223
+ /**
224
+ * Run the device-code login flow against the Auxilo server.
225
+ * Pure network flow — does NOT write credentials (caller does, so an expired
226
+ * code can never leave a partial credentials file: T-LW12-11).
227
+ *
228
+ * @param {object} opts
229
+ * @param {string} [opts.baseUrl=DEFAULT_BASE_URL]
230
+ * @param {Function} [opts.fetchImpl=fetch] Injectable for tests.
231
+ * @param {Function} [opts.onCode] (userCode, verificationUrl) → void; print/open browser.
232
+ * @param {Function} [opts.sleep] (ms) → Promise; injectable for tests.
233
+ * @param {number} [opts.maxWaitMs=600000] 10 min TTL (server DEVICE_CODE_TTL).
234
+ * @returns {Promise<{ api_key: string, account_id: string, email: string, base_url: string }>}
235
+ * @throws Error on expired code, HTTP failure, or timeout.
236
+ */
237
+ async function deviceLogin(opts = {}) {
238
+ const baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
239
+ const fetchImpl = opts.fetchImpl || fetch;
240
+ const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
241
+ const maxWaitMs = opts.maxWaitMs !== undefined ? opts.maxWaitMs : 600000;
242
+
243
+ const res = await fetchImpl(`${baseUrl}/auth/device`, {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ });
247
+ if (!res.ok) throw new Error(`Device code request failed (HTTP ${res.status})`);
248
+ const device = await res.json();
249
+ // A-1: device_code is the secret polling credential; user_code is the human
250
+ // code shown on the verification page only.
251
+ const { user_code: userCode, device_code: deviceCode, verification_url: verificationUrl } = device;
252
+ if (!userCode) throw new Error('Device code request failed: no user_code in response');
253
+ if (!deviceCode) throw new Error('Device code request failed: no device_code in response');
254
+
255
+ // Server returns interval in seconds (default 5 per server.js /auth/device).
256
+ const intervalMs = (device.interval || 5) * 1000;
257
+
258
+ if (typeof opts.onCode === 'function') opts.onCode(userCode, verificationUrl);
259
+
260
+ const deadline = Date.now() + maxWaitMs;
261
+ while (Date.now() <= deadline) {
262
+ await sleep(intervalMs);
263
+ let status;
264
+ try {
265
+ const poll = await fetchImpl(`${baseUrl}/auth/device/status?device_code=${encodeURIComponent(deviceCode)}`);
266
+ status = await poll.json();
267
+ } catch {
268
+ continue; // transient network error during poll — keep polling
269
+ }
270
+ if (status.status === 'authorized') {
271
+ return {
272
+ api_key: status.api_key,
273
+ account_id: status.account_id,
274
+ email: status.email,
275
+ base_url: baseUrl,
276
+ };
277
+ }
278
+ if (status.status === 'expired') {
279
+ throw new Error('Device code expired — run `auxilo setup` again to get a new code.');
280
+ }
281
+ // pending → keep polling
282
+ }
283
+ throw new Error('Timed out waiting for device authorization (10 minutes).');
284
+ }
285
+
286
+ // ─── Runner install (spec §LW-12 step 3; layout per P1-13) ──────────────────
287
+
288
+ function binRootFor(homeDir) {
289
+ return path.join(homeDir, '.auxilo', 'bin');
290
+ }
291
+
292
+ function hookScriptPathFor(homeDir) {
293
+ return path.join(binRootFor(homeDir), 'auxilo-extract.sh');
294
+ }
295
+
296
+ /**
297
+ * Generate the SessionEnd hook script body. Paths are embedded ABSOLUTELY
298
+ * (spec: hook references <home>/.auxilo/bin/scripts/runner.js absolutely).
299
+ * Logic mirrors the proven P2.1a hook: sentinel kill-switch, AUXILO_EXTRACTING
300
+ * recursion guard, detached spawn so session teardown is never blocked.
301
+ */
302
+ function renderHookScript(homeDir) {
303
+ const auxiloDir = path.join(homeDir, '.auxilo');
304
+ const runnerPath = path.join(auxiloDir, 'bin', 'scripts', 'runner.js');
305
+ return `#!/bin/bash
306
+ # Auxilo SessionEnd hook — generated by \`auxilo setup\` (LW-12).
307
+ # Reads the Claude Code SessionEnd JSON from stdin, extracts the transcript
308
+ # path, and spawns the extraction runner detached so shutdown is not blocked.
309
+ # Re-run \`auxilo setup\` after upgrading the auxilo-mcp package.
310
+
311
+ set -u
312
+
313
+ # Kill-switch: runner fires only if the consent sentinel exists.
314
+ # enable: auxilo setup (consent step) disable: auxilo disable
315
+ if [ ! -f "${auxiloDir}/autonomous-enabled" ]; then
316
+ exit 0
317
+ fi
318
+
319
+ # Recursion guard: bail if we're already inside an extraction chain.
320
+ if [ "\${AUXILO_EXTRACTING:-0}" = "1" ]; then
321
+ exit 0
322
+ fi
323
+
324
+ input_json=$(cat)
325
+
326
+ transcript_path=$(printf '%s' "$input_json" | /usr/bin/env node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(JSON.parse(d).transcript_path||"")}catch{}})' 2>/dev/null)
327
+
328
+ if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
329
+ exit 0
330
+ fi
331
+
332
+ RUNNER="${runnerPath}"
333
+ if [ ! -f "$RUNNER" ]; then
334
+ exit 0
335
+ fi
336
+
337
+ mkdir -p "${auxiloDir}"
338
+ LOG="${auxiloDir}/extract.log"
339
+
340
+ # Spawn detached — do not block session teardown.
341
+ # P1-13 lesson: do NOT set AUXILO_EXTRACTING here — runner.js's own recursion
342
+ # guard would trip and silently no-op every run. The runner sets it itself
343
+ # for child processes.
344
+ # LW-17: stdout goes to /dev/null — runner.js log() already appends every
345
+ # line to extract.log itself; redirecting stdout there too double-wrote each
346
+ # line and the daily digest double-counted. stderr still captured for crashes.
347
+ nohup /usr/bin/env node "$RUNNER" --transcript "$transcript_path" > /dev/null 2>> "$LOG" &
348
+
349
+ exit 0
350
+ `;
351
+ }
352
+
353
+ /**
354
+ * Install the extraction runner stack into <home>/.auxilo/bin and generate
355
+ * the SessionEnd hook script (0755).
356
+ *
357
+ * Files are COPIED (not symlinked) from the npm package, preserving the
358
+ * relative layout runner.js requires. Missing package files throw — the lib
359
+ * never calls process.exit.
360
+ *
361
+ * @param {string} homeDir
362
+ * @param {object} [opts]
363
+ * @param {string} [opts.packageRoot=PACKAGE_ROOT]
364
+ * @returns {{ binRoot: string, hookPath: string, installed: string[] }}
365
+ */
366
+ function installRunner(homeDir, opts = {}) {
367
+ if (!homeDir) throw new Error('installRunner: homeDir is required');
368
+ const packageRoot = opts.packageRoot || PACKAGE_ROOT;
369
+ const binRoot = binRootFor(homeDir);
370
+ const installed = [];
371
+
372
+ for (const [src, dest, mode] of RUNNER_STACK) {
373
+ const srcPath = path.join(packageRoot, src);
374
+ const destPath = path.join(binRoot, dest);
375
+ if (!fs.existsSync(srcPath)) {
376
+ throw new Error(`installRunner: missing package file ${srcPath} — reinstall auxilo-mcp`);
377
+ }
378
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
379
+ fs.copyFileSync(srcPath, destPath);
380
+ fs.chmodSync(destPath, mode);
381
+ installed.push(destPath);
382
+ }
383
+
384
+ const hookPath = hookScriptPathFor(homeDir);
385
+ fs.writeFileSync(hookPath, renderHookScript(homeDir));
386
+ fs.chmodSync(hookPath, 0o755);
387
+ installed.push(hookPath);
388
+
389
+ return { binRoot, hookPath, installed };
390
+ }
391
+
392
+ // ─── Claude Code hook registration (spec §LW-12 step 4) ─────────────────────
393
+
394
+ /** True when an inner hook object is an Auxilo extraction command. */
395
+ function isAuxiloCommandHook(h) {
396
+ return h && typeof h === 'object' && typeof h.command === 'string' &&
397
+ h.command.includes('auxilo-extract');
398
+ }
399
+
400
+ /**
401
+ * Patch <home>/.claude/settings.json hooks.SessionEnd[] to contain exactly one
402
+ * Auxilo entry: <home>/.auxilo/bin/auxilo-extract.sh, in Claude Code's
403
+ * STRUCTURED form ({ hooks: [{ type: 'command', command }] }).
404
+ *
405
+ * LW-17: 0.8.1 appended the command as a bare string — Claude Code silently
406
+ * ignores string entries, so the hook NEVER fired. It also only removed
407
+ * legacy entries that were strings, missing the pre-LW-12 structured entry
408
+ * (~/.claude/hooks/auxilo-extract.sh nested inside a matcher group).
409
+ *
410
+ * Idempotent. Removes every Auxilo reference (bare strings, and command
411
+ * objects inside matcher groups — preserving any non-Auxilo commands sharing
412
+ * the group), then appends one canonical structured entry. Non-Auxilo entries
413
+ * are preserved untouched. Malformed settings.json throws (B15) — never
414
+ * overwritten.
415
+ *
416
+ * @returns {{ changed: boolean, hookCmd: string, removedLegacy: string[] }}
417
+ */
418
+ function registerClaudeCodeHook(homeDir) {
419
+ if (!homeDir) throw new Error('registerClaudeCodeHook: homeDir is required');
420
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
421
+ const hookCmd = hookScriptPathFor(homeDir);
422
+
423
+ const settings = readClientConfig(settingsPath);
424
+ if (!settings.hooks) settings.hooks = {};
425
+ if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
426
+
427
+ const before = settings.hooks.SessionEnd;
428
+ const removedLegacy = [];
429
+ const kept = [];
430
+
431
+ for (const entry of before) {
432
+ if (typeof entry === 'string' && entry.includes('auxilo-extract')) {
433
+ removedLegacy.push(entry); // includes 0.8.1's dead bare-string entry
434
+ continue;
435
+ }
436
+ if (entry && typeof entry === 'object' && Array.isArray(entry.hooks)) {
437
+ const auxilo = entry.hooks.filter(isAuxiloCommandHook);
438
+ if (auxilo.length > 0) {
439
+ removedLegacy.push(...auxilo.map((h) => h.command));
440
+ const rest = entry.hooks.filter((h) => !isAuxiloCommandHook(h));
441
+ if (rest.length > 0) kept.push({ ...entry, hooks: rest });
442
+ continue;
443
+ }
444
+ }
445
+ kept.push(entry);
446
+ }
447
+
448
+ const canonical = { hooks: [{ type: 'command', command: hookCmd }] };
449
+
450
+ // Idempotency: unchanged iff the only Auxilo reference found was already
451
+ // the canonical command in a structured single-command entry.
452
+ const alreadyExact =
453
+ removedLegacy.length === 1 && removedLegacy[0] === hookCmd &&
454
+ before.some((e) =>
455
+ e && typeof e === 'object' && Array.isArray(e.hooks) &&
456
+ e.hooks.length === 1 && isAuxiloCommandHook(e.hooks[0]) &&
457
+ e.hooks[0].command === hookCmd && e.hooks[0].type === 'command'
458
+ );
459
+ if (alreadyExact) {
460
+ return { changed: false, hookCmd, removedLegacy: [] };
461
+ }
462
+
463
+ settings.hooks.SessionEnd = [...kept, canonical];
464
+ writeJsonAtomic(settingsPath, settings);
465
+ return { changed: true, hookCmd, removedLegacy: removedLegacy.filter((c) => c !== hookCmd) };
466
+ }
467
+
468
+ // ─── Consent (spec §LW-12 step 5; server.js POST /extract/consent) ──────────
469
+
470
+ /**
471
+ * Record extraction consent grant/revoke on the server.
472
+ *
473
+ * @param {object} opts
474
+ * @param {'grant'|'revoke'} opts.action
475
+ * @param {string} opts.apiKey
476
+ * @param {string} [opts.baseUrl=DEFAULT_BASE_URL]
477
+ * @param {Function} [opts.fetchImpl=fetch]
478
+ * @returns {Promise<object>} server response body
479
+ */
480
+ async function recordConsent(opts = {}) {
481
+ const { action, apiKey } = opts;
482
+ if (action !== 'grant' && action !== 'revoke') {
483
+ throw new Error('recordConsent: action must be "grant" or "revoke"');
484
+ }
485
+ if (!apiKey) throw new Error('recordConsent: apiKey is required');
486
+ const baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
487
+ const fetchImpl = opts.fetchImpl || fetch;
488
+
489
+ const res = await fetchImpl(`${baseUrl}/extract/consent`, {
490
+ method: 'POST',
491
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
492
+ body: JSON.stringify({ action }),
493
+ });
494
+ let body = {};
495
+ try { body = await res.json(); } catch { /* non-JSON error body */ }
496
+ if (!res.ok) {
497
+ throw new Error(`Consent ${action} failed (HTTP ${res.status}): ${body.error || 'unknown error'}`);
498
+ }
499
+ return body;
500
+ }
501
+
502
+ // ─── Kill-switch sentinel (spec §LW-12 step 5 / `auxilo disable`) ───────────
503
+
504
+ function sentinelPath(homeDir) {
505
+ return path.join(homeDir, '.auxilo', 'autonomous-enabled');
506
+ }
507
+
508
+ function sentinelPresent(homeDir) {
509
+ return fs.existsSync(sentinelPath(homeDir));
510
+ }
511
+
512
+ /** Create the autonomous-extraction sentinel. Only called after explicit consent. */
513
+ function enableSentinel(homeDir) {
514
+ const p = sentinelPath(homeDir);
515
+ fs.mkdirSync(path.dirname(p), { recursive: true });
516
+ fs.writeFileSync(p, `enabled ${new Date().toISOString()}\n`);
517
+ return p;
518
+ }
519
+
520
+ /** Remove the sentinel (local kill-switch). Returns true if it existed. */
521
+ function disableSentinel(homeDir) {
522
+ const p = sentinelPath(homeDir);
523
+ if (!fs.existsSync(p)) return false;
524
+ fs.unlinkSync(p);
525
+ return true;
526
+ }
527
+
528
+ // ─── Status (spec §LW-12 `auxilo status`) ───────────────────────────────────
529
+
530
+ /**
531
+ * Collect installer/extraction status for `auxilo status`.
532
+ * Network lookups are best-effort; everything else is local file probes.
533
+ *
534
+ * @param {string} homeDir
535
+ * @param {object} [opts] { platform, env, fetchImpl }
536
+ * @returns {Promise<object>}
537
+ */
538
+ async function getStatus(homeDir, opts = {}) {
539
+ if (!homeDir) throw new Error('getStatus: homeDir is required');
540
+ const fetchImpl = opts.fetchImpl || fetch;
541
+
542
+ // 1. Clients: detected + whether MCP registration is present
543
+ 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 */ }
551
+ }
552
+ return { id: c.id, name: c.name, mcp: c.mcp, registered };
553
+ });
554
+
555
+ // 2. Auth state
556
+ const creds = readCredentials(homeDir);
557
+ const auth = {
558
+ credentialsFile: fs.existsSync(credentialsPath(homeDir)),
559
+ keyPrefix: creds && creds.api_key ? `${String(creds.api_key).slice(0, 8)}…` : null,
560
+ email: (creds && creds.email) || null,
561
+ baseUrl: (creds && creds.base_url) || null,
562
+ };
563
+
564
+ // 3. Account mode (best-effort — server may be unreachable or route absent)
565
+ let accountMode = 'unknown';
566
+ if (creds && creds.api_key) {
567
+ try {
568
+ const res = await fetchImpl(`${(creds.base_url || DEFAULT_BASE_URL).replace(/\/+$/, '')}/account/settings`, {
569
+ headers: { 'X-API-Key': creds.api_key },
570
+ });
571
+ if (res.ok) {
572
+ const data = await res.json();
573
+ accountMode = data.autonomous_extraction_mode || 'off';
574
+ }
575
+ } catch { /* unreachable */ }
576
+ }
577
+
578
+ // 4. Sentinel + runner + hook install state
579
+ const hookPath = hookScriptPathFor(homeDir);
580
+ const runnerInstalled = fs.existsSync(path.join(binRootFor(homeDir), 'scripts', 'runner.js'));
581
+ let hookRegistered = false;
582
+ try {
583
+ const settings = JSON.parse(
584
+ fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
585
+ );
586
+ // LW-17: only STRUCTURED entries count — Claude Code silently ignores
587
+ // bare-string entries (the 0.8.1 dead-hook bug), so reporting one as
588
+ // "registered" would mask exactly the failure this status exists to catch.
589
+ hookRegistered = Array.isArray(settings.hooks && settings.hooks.SessionEnd) &&
590
+ settings.hooks.SessionEnd.some((h) =>
591
+ h && typeof h === 'object' && Array.isArray(h.hooks) && h.hooks.some(isAuxiloCommandHook));
592
+ } catch { /* no settings */ }
593
+
594
+ // 5. Last extraction + pending queue (ledger conventions from scripts/runner.js)
595
+ let lastSweep = null;
596
+ try {
597
+ lastSweep = JSON.parse(
598
+ fs.readFileSync(path.join(homeDir, '.auxilo', 'ledger.json'), 'utf-8')
599
+ ).lastSweep || null;
600
+ } catch { /* no ledger yet */ }
601
+
602
+ let pendingCount = 0;
603
+ try {
604
+ pendingCount = fs.readdirSync(path.join(homeDir, '.auxilo', 'pending-learnings'))
605
+ .filter((f) => f.endsWith('.json')).length;
606
+ } catch { /* no queue dir yet */ }
607
+
608
+ return {
609
+ clients,
610
+ auth,
611
+ accountMode,
612
+ sentinel: sentinelPresent(homeDir),
613
+ runnerInstalled,
614
+ hookInstalled: fs.existsSync(hookPath),
615
+ hookRegistered,
616
+ lastSweep,
617
+ pendingCount,
618
+ };
619
+ }
620
+
621
+ // ─── Exports ────────────────────────────────────────────────────────────────
622
+
623
+ module.exports = {
624
+ MCP_ENTRY,
625
+ DEFAULT_BASE_URL,
626
+ PACKAGE_ROOT,
627
+ RUNNER_STACK,
628
+ clientRegistry,
629
+ detectClients,
630
+ registerMcp,
631
+ readClientConfig,
632
+ credentialsPath,
633
+ readCredentials,
634
+ writeCredentials,
635
+ deviceLogin,
636
+ binRootFor,
637
+ hookScriptPathFor,
638
+ renderHookScript,
639
+ installRunner,
640
+ registerClaudeCodeHook,
641
+ recordConsent,
642
+ sentinelPath,
643
+ sentinelPresent,
644
+ enableSentinel,
645
+ disableSentinel,
646
+ getStatus,
647
+ };