auxilo-mcp 0.2.0 → 0.8.1

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,601 @@
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
+ const { user_code: userCode, verification_url: verificationUrl } = device;
250
+ if (!userCode) throw new Error('Device code request failed: no user_code in response');
251
+
252
+ // Server returns interval in seconds (default 5 per server.js /auth/device).
253
+ const intervalMs = (device.interval || 5) * 1000;
254
+
255
+ if (typeof opts.onCode === 'function') opts.onCode(userCode, verificationUrl);
256
+
257
+ const deadline = Date.now() + maxWaitMs;
258
+ while (Date.now() <= deadline) {
259
+ await sleep(intervalMs);
260
+ let status;
261
+ try {
262
+ const poll = await fetchImpl(`${baseUrl}/auth/device/status?code=${encodeURIComponent(userCode)}`);
263
+ status = await poll.json();
264
+ } catch {
265
+ continue; // transient network error during poll — keep polling
266
+ }
267
+ if (status.status === 'authorized') {
268
+ return {
269
+ api_key: status.api_key,
270
+ account_id: status.account_id,
271
+ email: status.email,
272
+ base_url: baseUrl,
273
+ };
274
+ }
275
+ if (status.status === 'expired') {
276
+ throw new Error('Device code expired — run `auxilo setup` again to get a new code.');
277
+ }
278
+ // pending → keep polling
279
+ }
280
+ throw new Error('Timed out waiting for device authorization (10 minutes).');
281
+ }
282
+
283
+ // ─── Runner install (spec §LW-12 step 3; layout per P1-13) ──────────────────
284
+
285
+ function binRootFor(homeDir) {
286
+ return path.join(homeDir, '.auxilo', 'bin');
287
+ }
288
+
289
+ function hookScriptPathFor(homeDir) {
290
+ return path.join(binRootFor(homeDir), 'auxilo-extract.sh');
291
+ }
292
+
293
+ /**
294
+ * Generate the SessionEnd hook script body. Paths are embedded ABSOLUTELY
295
+ * (spec: hook references <home>/.auxilo/bin/scripts/runner.js absolutely).
296
+ * Logic mirrors the proven P2.1a hook: sentinel kill-switch, AUXILO_EXTRACTING
297
+ * recursion guard, detached spawn so session teardown is never blocked.
298
+ */
299
+ function renderHookScript(homeDir) {
300
+ const auxiloDir = path.join(homeDir, '.auxilo');
301
+ const runnerPath = path.join(auxiloDir, 'bin', 'scripts', 'runner.js');
302
+ return `#!/bin/bash
303
+ # Auxilo SessionEnd hook — generated by \`auxilo setup\` (LW-12).
304
+ # Reads the Claude Code SessionEnd JSON from stdin, extracts the transcript
305
+ # path, and spawns the extraction runner detached so shutdown is not blocked.
306
+ # Re-run \`auxilo setup\` after upgrading the auxilo-mcp package.
307
+
308
+ set -u
309
+
310
+ # Kill-switch: runner fires only if the consent sentinel exists.
311
+ # enable: auxilo setup (consent step) disable: auxilo disable
312
+ if [ ! -f "${auxiloDir}/autonomous-enabled" ]; then
313
+ exit 0
314
+ fi
315
+
316
+ # Recursion guard: bail if we're already inside an extraction chain.
317
+ if [ "\${AUXILO_EXTRACTING:-0}" = "1" ]; then
318
+ exit 0
319
+ fi
320
+
321
+ input_json=$(cat)
322
+
323
+ 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)
324
+
325
+ if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
326
+ exit 0
327
+ fi
328
+
329
+ RUNNER="${runnerPath}"
330
+ if [ ! -f "$RUNNER" ]; then
331
+ exit 0
332
+ fi
333
+
334
+ mkdir -p "${auxiloDir}"
335
+ LOG="${auxiloDir}/extract.log"
336
+
337
+ # Spawn detached — do not block session teardown.
338
+ # P1-13 lesson: do NOT set AUXILO_EXTRACTING here — runner.js's own recursion
339
+ # guard would trip and silently no-op every run. The runner sets it itself
340
+ # for child processes.
341
+ nohup /usr/bin/env node "$RUNNER" --transcript "$transcript_path" >> "$LOG" 2>&1 &
342
+
343
+ exit 0
344
+ `;
345
+ }
346
+
347
+ /**
348
+ * Install the extraction runner stack into <home>/.auxilo/bin and generate
349
+ * the SessionEnd hook script (0755).
350
+ *
351
+ * Files are COPIED (not symlinked) from the npm package, preserving the
352
+ * relative layout runner.js requires. Missing package files throw — the lib
353
+ * never calls process.exit.
354
+ *
355
+ * @param {string} homeDir
356
+ * @param {object} [opts]
357
+ * @param {string} [opts.packageRoot=PACKAGE_ROOT]
358
+ * @returns {{ binRoot: string, hookPath: string, installed: string[] }}
359
+ */
360
+ function installRunner(homeDir, opts = {}) {
361
+ if (!homeDir) throw new Error('installRunner: homeDir is required');
362
+ const packageRoot = opts.packageRoot || PACKAGE_ROOT;
363
+ const binRoot = binRootFor(homeDir);
364
+ const installed = [];
365
+
366
+ for (const [src, dest, mode] of RUNNER_STACK) {
367
+ const srcPath = path.join(packageRoot, src);
368
+ const destPath = path.join(binRoot, dest);
369
+ if (!fs.existsSync(srcPath)) {
370
+ throw new Error(`installRunner: missing package file ${srcPath} — reinstall auxilo-mcp`);
371
+ }
372
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
373
+ fs.copyFileSync(srcPath, destPath);
374
+ fs.chmodSync(destPath, mode);
375
+ installed.push(destPath);
376
+ }
377
+
378
+ const hookPath = hookScriptPathFor(homeDir);
379
+ fs.writeFileSync(hookPath, renderHookScript(homeDir));
380
+ fs.chmodSync(hookPath, 0o755);
381
+ installed.push(hookPath);
382
+
383
+ return { binRoot, hookPath, installed };
384
+ }
385
+
386
+ // ─── Claude Code hook registration (spec §LW-12 step 4) ─────────────────────
387
+
388
+ /**
389
+ * Patch <home>/.claude/settings.json hooks.SessionEnd[] to contain exactly one
390
+ * Auxilo entry: <home>/.auxilo/bin/auxilo-extract.sh.
391
+ *
392
+ * Idempotent. Replaces any legacy string entry referencing auxilo-extract.sh
393
+ * (e.g. the pre-LW-12 ~/.claude/hooks/auxilo-extract.sh location). Non-Auxilo
394
+ * entries — including non-string structured entries — are preserved untouched.
395
+ * Malformed settings.json throws (B15) — never overwritten.
396
+ *
397
+ * @returns {{ changed: boolean, hookCmd: string, removedLegacy: string[] }}
398
+ */
399
+ function registerClaudeCodeHook(homeDir) {
400
+ if (!homeDir) throw new Error('registerClaudeCodeHook: homeDir is required');
401
+ const settingsPath = path.join(homeDir, '.claude', 'settings.json');
402
+ const hookCmd = hookScriptPathFor(homeDir);
403
+
404
+ const settings = readClientConfig(settingsPath);
405
+ if (!settings.hooks) settings.hooks = {};
406
+ if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
407
+
408
+ const before = settings.hooks.SessionEnd;
409
+ const removedLegacy = before.filter(
410
+ (h) => typeof h === 'string' && h.includes('auxilo-extract') && h !== hookCmd
411
+ );
412
+ const kept = before.filter(
413
+ (h) => !(typeof h === 'string' && h.includes('auxilo-extract'))
414
+ );
415
+
416
+ const alreadyExact = before.includes(hookCmd) && removedLegacy.length === 0;
417
+ if (alreadyExact) {
418
+ return { changed: false, hookCmd, removedLegacy: [] };
419
+ }
420
+
421
+ settings.hooks.SessionEnd = [...kept, hookCmd];
422
+ writeJsonAtomic(settingsPath, settings);
423
+ return { changed: true, hookCmd, removedLegacy };
424
+ }
425
+
426
+ // ─── Consent (spec §LW-12 step 5; server.js POST /extract/consent) ──────────
427
+
428
+ /**
429
+ * Record extraction consent grant/revoke on the server.
430
+ *
431
+ * @param {object} opts
432
+ * @param {'grant'|'revoke'} opts.action
433
+ * @param {string} opts.apiKey
434
+ * @param {string} [opts.baseUrl=DEFAULT_BASE_URL]
435
+ * @param {Function} [opts.fetchImpl=fetch]
436
+ * @returns {Promise<object>} server response body
437
+ */
438
+ async function recordConsent(opts = {}) {
439
+ const { action, apiKey } = opts;
440
+ if (action !== 'grant' && action !== 'revoke') {
441
+ throw new Error('recordConsent: action must be "grant" or "revoke"');
442
+ }
443
+ if (!apiKey) throw new Error('recordConsent: apiKey is required');
444
+ const baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
445
+ const fetchImpl = opts.fetchImpl || fetch;
446
+
447
+ const res = await fetchImpl(`${baseUrl}/extract/consent`, {
448
+ method: 'POST',
449
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
450
+ body: JSON.stringify({ action }),
451
+ });
452
+ let body = {};
453
+ try { body = await res.json(); } catch { /* non-JSON error body */ }
454
+ if (!res.ok) {
455
+ throw new Error(`Consent ${action} failed (HTTP ${res.status}): ${body.error || 'unknown error'}`);
456
+ }
457
+ return body;
458
+ }
459
+
460
+ // ─── Kill-switch sentinel (spec §LW-12 step 5 / `auxilo disable`) ───────────
461
+
462
+ function sentinelPath(homeDir) {
463
+ return path.join(homeDir, '.auxilo', 'autonomous-enabled');
464
+ }
465
+
466
+ function sentinelPresent(homeDir) {
467
+ return fs.existsSync(sentinelPath(homeDir));
468
+ }
469
+
470
+ /** Create the autonomous-extraction sentinel. Only called after explicit consent. */
471
+ function enableSentinel(homeDir) {
472
+ const p = sentinelPath(homeDir);
473
+ fs.mkdirSync(path.dirname(p), { recursive: true });
474
+ fs.writeFileSync(p, `enabled ${new Date().toISOString()}\n`);
475
+ return p;
476
+ }
477
+
478
+ /** Remove the sentinel (local kill-switch). Returns true if it existed. */
479
+ function disableSentinel(homeDir) {
480
+ const p = sentinelPath(homeDir);
481
+ if (!fs.existsSync(p)) return false;
482
+ fs.unlinkSync(p);
483
+ return true;
484
+ }
485
+
486
+ // ─── Status (spec §LW-12 `auxilo status`) ───────────────────────────────────
487
+
488
+ /**
489
+ * Collect installer/extraction status for `auxilo status`.
490
+ * Network lookups are best-effort; everything else is local file probes.
491
+ *
492
+ * @param {string} homeDir
493
+ * @param {object} [opts] { platform, env, fetchImpl }
494
+ * @returns {Promise<object>}
495
+ */
496
+ async function getStatus(homeDir, opts = {}) {
497
+ if (!homeDir) throw new Error('getStatus: homeDir is required');
498
+ const fetchImpl = opts.fetchImpl || fetch;
499
+
500
+ // 1. Clients: detected + whether MCP registration is present
501
+ const clients = detectClients(homeDir, opts).map((c) => {
502
+ let registered = null; // null = not applicable (no MCP config)
503
+ if (c.mcp) {
504
+ registered = false;
505
+ try {
506
+ const config = JSON.parse(fs.readFileSync(c.configPath, 'utf-8'));
507
+ registered = Boolean(config.mcpServers && config.mcpServers.auxilo);
508
+ } catch { /* missing or malformed config → not registered */ }
509
+ }
510
+ return { id: c.id, name: c.name, mcp: c.mcp, registered };
511
+ });
512
+
513
+ // 2. Auth state
514
+ const creds = readCredentials(homeDir);
515
+ const auth = {
516
+ credentialsFile: fs.existsSync(credentialsPath(homeDir)),
517
+ keyPrefix: creds && creds.api_key ? `${String(creds.api_key).slice(0, 8)}…` : null,
518
+ email: (creds && creds.email) || null,
519
+ baseUrl: (creds && creds.base_url) || null,
520
+ };
521
+
522
+ // 3. Account mode (best-effort — server may be unreachable or route absent)
523
+ let accountMode = 'unknown';
524
+ if (creds && creds.api_key) {
525
+ try {
526
+ const res = await fetchImpl(`${(creds.base_url || DEFAULT_BASE_URL).replace(/\/+$/, '')}/account/settings`, {
527
+ headers: { 'X-API-Key': creds.api_key },
528
+ });
529
+ if (res.ok) {
530
+ const data = await res.json();
531
+ accountMode = data.autonomous_extraction_mode || 'off';
532
+ }
533
+ } catch { /* unreachable */ }
534
+ }
535
+
536
+ // 4. Sentinel + runner + hook install state
537
+ const hookPath = hookScriptPathFor(homeDir);
538
+ const runnerInstalled = fs.existsSync(path.join(binRootFor(homeDir), 'scripts', 'runner.js'));
539
+ let hookRegistered = false;
540
+ try {
541
+ const settings = JSON.parse(
542
+ fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
543
+ );
544
+ hookRegistered = Array.isArray(settings.hooks && settings.hooks.SessionEnd) &&
545
+ settings.hooks.SessionEnd.some((h) => typeof h === 'string' && h.includes('auxilo-extract'));
546
+ } catch { /* no settings */ }
547
+
548
+ // 5. Last extraction + pending queue (ledger conventions from scripts/runner.js)
549
+ let lastSweep = null;
550
+ try {
551
+ lastSweep = JSON.parse(
552
+ fs.readFileSync(path.join(homeDir, '.auxilo', 'ledger.json'), 'utf-8')
553
+ ).lastSweep || null;
554
+ } catch { /* no ledger yet */ }
555
+
556
+ let pendingCount = 0;
557
+ try {
558
+ pendingCount = fs.readdirSync(path.join(homeDir, '.auxilo', 'pending-learnings'))
559
+ .filter((f) => f.endsWith('.json')).length;
560
+ } catch { /* no queue dir yet */ }
561
+
562
+ return {
563
+ clients,
564
+ auth,
565
+ accountMode,
566
+ sentinel: sentinelPresent(homeDir),
567
+ runnerInstalled,
568
+ hookInstalled: fs.existsSync(hookPath),
569
+ hookRegistered,
570
+ lastSweep,
571
+ pendingCount,
572
+ };
573
+ }
574
+
575
+ // ─── Exports ────────────────────────────────────────────────────────────────
576
+
577
+ module.exports = {
578
+ MCP_ENTRY,
579
+ DEFAULT_BASE_URL,
580
+ PACKAGE_ROOT,
581
+ RUNNER_STACK,
582
+ clientRegistry,
583
+ detectClients,
584
+ registerMcp,
585
+ readClientConfig,
586
+ credentialsPath,
587
+ readCredentials,
588
+ writeCredentials,
589
+ deviceLogin,
590
+ binRootFor,
591
+ hookScriptPathFor,
592
+ renderHookScript,
593
+ installRunner,
594
+ registerClaudeCodeHook,
595
+ recordConsent,
596
+ sentinelPath,
597
+ sentinelPresent,
598
+ enableSentinel,
599
+ disableSentinel,
600
+ getStatus,
601
+ };