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.
- package/README.md +49 -14
- package/bin/auxilo-cli.js +425 -0
- package/lib/installer.js +601 -0
- package/lib/review.js +124 -0
- package/lib/sensitivity-filter.js +382 -0
- package/mcp-server.js +454 -48
- package/package.json +22 -5
- package/prompts/auxilo-learning-claude-code.md +250 -0
- package/prompts/auxilo-learning-generic.md +327 -0
- package/prompts/auxilo-learning-schema.json +98 -0
- package/scripts/hooks/auxilo-extract.sh +49 -0
- package/scripts/runner.js +843 -0
- package/scripts/sources/claude-code.js +182 -0
- package/scripts/sources/openclaw.js +171 -0
- package/scripts/sources/source.interface.js +85 -0
package/lib/installer.js
ADDED
|
@@ -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
|
+
};
|