@anyslate/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,523 @@
1
+ // `anyslate doctor` (W4) — one command that prints every broken state.
2
+ //
3
+ // Designed against three simultaneously-broken states found live on one
4
+ // machine, none of which produced a single signal anywhere:
5
+ // 1. apiUrl ended in `/mcp` → the client appended another → permanent 404
6
+ // 2. the token had been revoked (for an unknown length of time)
7
+ // 3. the Claude Code hooks were never wired into ~/.claude/settings.json
8
+ //
9
+ // Ordering matters: the URL-shape check must run BEFORE any token verdict,
10
+ // because a wrong URL 404s at app.notFound() before auth middleware and so
11
+ // masks revocation, expiry and typos alike.
12
+ //
13
+ // Exit code: non-zero if any check FAILs. `doctor` is the one command whose
14
+ // purpose is to fail loudly.
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import {
21
+ anyslateDir,
22
+ configPaths,
23
+ isCaptureDisabled,
24
+ loadConfig,
25
+ normalizeApiRoot,
26
+ requireToken,
27
+ } from '../config.mjs';
28
+ import { checkUrlShape, hasWriteScope, isValidTokenFormat, probeVerify, tokenPreview } from '../verify.mjs';
29
+ import { lastRunPath, readLastRun } from '../runlog.mjs';
30
+ import { callTool } from '../mcp-client.mjs';
31
+ import { VERSION, USER_AGENT } from '../version.mjs';
32
+ import { makeIo } from '../io.mjs';
33
+
34
+ const PASS = 'PASS';
35
+ const FAIL = 'FAIL';
36
+ const WARN = 'WARN';
37
+ const SKIP = 'SKIP';
38
+
39
+ const SETTINGS_BLOCK = `{
40
+ "hooks": {
41
+ "SessionStart": [
42
+ { "hooks": [{ "type": "command", "command": "anyslate hook session-start" }] }
43
+ ],
44
+ "PostToolUse": [
45
+ {
46
+ "matcher": "Edit|Write|MultiEdit|Bash",
47
+ "hooks": [{ "type": "command", "command": "anyslate hook post-tool-use" }]
48
+ }
49
+ ],
50
+ "Stop": [
51
+ { "hooks": [{ "type": "command", "command": "anyslate hook stop" }] }
52
+ ]
53
+ }
54
+ }`;
55
+
56
+ /**
57
+ * @param {string[]} argv
58
+ * @param {{fetchImpl?: typeof fetch, env?: NodeJS.ProcessEnv, out?: NodeJS.WritableStream,
59
+ * probePath?: () => {status: string, found?: string|null, message?: string}}} [deps]
60
+ * @returns {Promise<number>}
61
+ */
62
+ export async function runDoctor(argv = [], deps = {}) {
63
+ const env = deps.env ?? process.env;
64
+ const fetchImpl = deps.fetchImpl ?? fetch;
65
+ const { out } = makeIo({ stdout: deps.out ?? deps.stdout });
66
+ const probePath = deps.probePath ?? probeLoginShellPath;
67
+ const flags = parseFlags(argv);
68
+
69
+ const results = [];
70
+ const add = (status, id, message, remediation) => {
71
+ results.push({ status, id, message, remediation });
72
+ };
73
+
74
+ out.write(`anyslate doctor ${VERSION}\n\n`);
75
+
76
+ // ---- 0. Kill switch ----------------------------------------------------
77
+ if (isCaptureDisabled(env)) {
78
+ add(
79
+ WARN,
80
+ 'kill-switch',
81
+ `ANYSLATE_DISABLE=${env.ANYSLATE_DISABLE} — capture is disabled for this shell.`,
82
+ 'Unset ANYSLATE_DISABLE to re-enable capture. doctor still runs its checks so you can verify the setup while capture is off.',
83
+ );
84
+ }
85
+
86
+ // ---- 1. Config resolution ---------------------------------------------
87
+ const cfg = loadConfig(env);
88
+ const candidates = configPaths(env);
89
+ const fileFound = cfg.source !== 'env';
90
+
91
+ if (!fileFound && !env.ANYSLATE_MCP_TOKEN) {
92
+ add(
93
+ FAIL,
94
+ 'config',
95
+ `No config found (looked in ${candidates.join(', ')}).`,
96
+ 'Run `anyslate login --token <BEARER>`.',
97
+ );
98
+ } else if (fileFound && cfg.source.endsWith('session.json')) {
99
+ add(
100
+ FAIL,
101
+ 'config',
102
+ `Using legacy ${cfg.source}.`,
103
+ 'That is the desktop editor-state file and can never contain a token. Run `anyslate login`.',
104
+ );
105
+ } else {
106
+ // Render the per-key provenance as SHORT labels. `sources.*` carries the
107
+ // absolute config path, and printing an 80-char path three times on one
108
+ // line makes the single most important diagnostic unreadable.
109
+ const label = (v) => (v === 'env' ? 'env' : v === 'default' ? 'default' : 'file');
110
+ const layers = `apiUrl=${label(cfg.sources.apiUrl)}, token=${label(cfg.sources.mcpToken)}, handle=${label(cfg.sources.handle)}`;
111
+ add(PASS, 'config', `Resolved from ${fileFound ? cfg.source : 'environment'} (${layers}).`);
112
+ if (cfg.sources.apiUrl === 'env' && fileFound) {
113
+ add(
114
+ WARN,
115
+ 'config-override',
116
+ `ANYSLATE_API_URL in your environment is overriding ${cfg.source}.`,
117
+ `Unset ANYSLATE_API_URL if that is not intended (env value: ${env.ANYSLATE_API_URL}).`,
118
+ );
119
+ }
120
+ if (cfg.sources.mcpToken === 'env' && fileFound) {
121
+ add(
122
+ WARN,
123
+ 'token-override',
124
+ `ANYSLATE_MCP_TOKEN in your environment is overriding ${cfg.source}.`,
125
+ 'Unset ANYSLATE_MCP_TOKEN if that is not intended.',
126
+ );
127
+ }
128
+ }
129
+
130
+ // ---- 2. Token present --------------------------------------------------
131
+ const tokenCheck = requireToken(cfg);
132
+ if (!tokenCheck.ok) {
133
+ add(FAIL, 'token-present', 'No MCP token configured.', tokenCheck.error);
134
+ } else {
135
+ add(
136
+ PASS,
137
+ 'token-present',
138
+ `Token ${maskToken(cfg.mcpToken)} (from ${cfg.sources.mcpToken === 'env' ? 'ANYSLATE_MCP_TOKEN' : cfg.sources.mcpToken}).`,
139
+ );
140
+ }
141
+
142
+ // ---- 2b. Handle FORMAT — never gated behind a server round trip ---------
143
+ // This is the exact live failure that produced a 100% invisible capture
144
+ // loss: cli/README.md told users to export `ANYSLATE_HANDLE=h_xxx`, but real
145
+ // handle ids are `mh_`-prefixed, so every call returned
146
+ // {"error":"handle_not_found"} as HTTP 200 + isError. It is pure string
147
+ // inspection, so it must NOT sit behind `verified.ok` (check #8) — otherwise
148
+ // any upstream breakage hides it, which is how it stayed invisible.
149
+ if (!cfg.handle) {
150
+ add(SKIP, 'handle-format', 'No handle configured (bearer-token scope).');
151
+ } else if (/^mh_/.test(cfg.handle)) {
152
+ add(PASS, 'handle-format', `Handle ${cfg.handle} has the expected \`mh_\` prefix.`);
153
+ } else {
154
+ add(
155
+ FAIL,
156
+ 'handle-format',
157
+ `Handle "${cfg.handle}" (from ${cfg.sources.handle === 'env' ? 'ANYSLATE_HANDLE' : cfg.sources.handle}) is not a valid handle id — real ids are \`mh_\`-prefixed.`,
158
+ 'Every call with an unknown handle fails as handle_not_found, which the server returns as HTTP 200 + isError. Unset ANYSLATE_HANDLE for workspace-wide capture, or copy a real `mh_…` id from Settings → MCP Tokens → Handles.',
159
+ );
160
+ }
161
+
162
+ // ---- 3. Token format ---------------------------------------------------
163
+ if (tokenCheck.ok) {
164
+ if (isValidTokenFormat(cfg.mcpToken)) {
165
+ add(PASS, 'token-format', 'Token matches as_mcp_<48 hex> or as_oauth_<64 hex>.');
166
+ } else {
167
+ add(
168
+ FAIL,
169
+ 'token-format',
170
+ `That doesn't look like an AnySlate token (expected as_mcp_… or as_oauth_…, got "${tokenPreview(cfg.mcpToken)}").`,
171
+ 'Mint one in the desktop app at Avatar (top-right) → API Tokens → Create Token.',
172
+ );
173
+ }
174
+ } else {
175
+ add(SKIP, 'token-format', 'No token to check.');
176
+ }
177
+
178
+ // ---- 4. URL shape (MUST run before #6) ---------------------------------
179
+ const shape = checkUrlShape(cfg.apiUrlRaw);
180
+ let root = cfg.apiUrl;
181
+ if (!shape.ok) {
182
+ add(FAIL, 'url-shape', shape.message, 'Run `anyslate login --api-url <service root>`.');
183
+ } else {
184
+ root = shape.root;
185
+ if (cfg.apiUrlNormalized) {
186
+ add(
187
+ WARN,
188
+ 'url-shape',
189
+ `apiUrl "${cfg.apiUrlRaw}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself. Using "${root}".`,
190
+ `The read path self-heals this, but the stored value is still wrong. Run \`anyslate login --api-url ${root}\` to persist.`,
191
+ );
192
+ } else {
193
+ add(PASS, 'url-shape', `Service root resolves to ${root} (requests go to ${root}/mcp).`);
194
+ }
195
+ }
196
+
197
+ // ---- 5. Reachability, no token ----------------------------------------
198
+ let reachable = false;
199
+ if (shape.ok) {
200
+ const health = await probeHealth(root, fetchImpl);
201
+ if (health.ok) {
202
+ reachable = true;
203
+ if (health.degraded) {
204
+ add(
205
+ WARN,
206
+ 'reachability',
207
+ `Service degraded: ${health.degraded}.`,
208
+ 'Capture may fail — this is a service problem, not a config problem.',
209
+ );
210
+ } else {
211
+ add(PASS, 'reachability', `${root}/health responded 200.`);
212
+ }
213
+ } else if (health.status === 404) {
214
+ add(
215
+ FAIL,
216
+ 'reachability',
217
+ `${root}/health returned 404 — not an AnySlate MCP service, or --api-url has a path prefix.`,
218
+ 'Check the host in `anyslate login --api-url <root>`.',
219
+ );
220
+ } else {
221
+ add(FAIL, 'reachability', `Cannot reach ${shape.host} — ${health.detail}.`, 'Check --api-url and your network.');
222
+ }
223
+ } else {
224
+ add(SKIP, 'reachability', 'URL shape failed; not probing.');
225
+ }
226
+
227
+ // ---- 6. Token valid ----------------------------------------------------
228
+ let verified = null;
229
+ if (shape.ok && tokenCheck.ok && reachable) {
230
+ verified = await probeVerify({ root, token: cfg.mcpToken, fetchImpl });
231
+ if (verified.ok) {
232
+ add(PASS, 'token-valid', `${verified.message.replace(/^anyslate: /, '')}`);
233
+ } else {
234
+ add(FAIL, 'token-valid', verified.message.replace(/^anyslate: /, ''), remediationFor(verified.code));
235
+ }
236
+ } else {
237
+ add(SKIP, 'token-valid', 'Skipped — an earlier check must pass first (a wrong URL masks every token verdict).');
238
+ }
239
+
240
+ // ---- 7. Scope sufficient ----------------------------------------------
241
+ if (verified?.ok) {
242
+ if (hasWriteScope(verified.scopes)) {
243
+ add(PASS, 'scope', `Token scopes [${verified.scopes.join(',')}] include memory:write.`);
244
+ } else {
245
+ add(
246
+ FAIL,
247
+ 'scope',
248
+ `Token scopes are [${(verified.scopes || []).join(',')}]; \`anyslate hook\` needs memory:write.`,
249
+ 'Mint a token with the Memory profile.',
250
+ );
251
+ }
252
+ } else {
253
+ add(SKIP, 'scope', 'Token not verified.');
254
+ }
255
+
256
+ // ---- 8. Effective handle scope ----------------------------------------
257
+ if (verified?.ok) {
258
+ const bound = verified.defaultHandleId;
259
+ if (bound === undefined || (bound === null && !('default_handle_id' in (verified.raw ?? {})))) {
260
+ add(
261
+ WARN,
262
+ 'handle-scope',
263
+ 'Server did not report default_handle_id, so the effective memory scope cannot be shown.',
264
+ 'Requires the /mcp/auth/verify companion change. Until then, check the token in Settings → MCP Tokens.',
265
+ );
266
+ } else if (bound) {
267
+ add(
268
+ WARN,
269
+ 'handle-scope',
270
+ `Token is bound to handle ${bound} — capture is narrower than workspace-wide.`,
271
+ 'ANYSLATE_HANDLE/--handle is ignored for scoped tokens (the dispatcher logs and discards it). Mint an unscoped token for workspace-wide capture.',
272
+ );
273
+ } else if (cfg.handle) {
274
+ add(
275
+ WARN,
276
+ 'handle-scope',
277
+ `Token is unscoped, and ANYSLATE_HANDLE/--handle is set to ${cfg.handle}.`,
278
+ 'Handle ids are `mh_`-prefixed. An unknown handle makes every call fail with handle_not_found (HTTP 200 + isError, which older CLI builds reported as success).',
279
+ );
280
+ } else {
281
+ add(PASS, 'handle-scope', 'Token is unscoped — workspace-wide capture.');
282
+ }
283
+ } else {
284
+ add(SKIP, 'handle-scope', 'Token not verified.');
285
+ }
286
+
287
+ // ---- 9. Binary on the hook subprocess's PATH --------------------------
288
+ const pathCheck = probePath();
289
+ if (pathCheck.status === SKIP) {
290
+ add(SKIP, 'path', pathCheck.message);
291
+ } else if (pathCheck.found) {
292
+ add(PASS, 'path', `Login-shell PATH resolves anyslate to ${pathCheck.found}.`);
293
+ } else {
294
+ add(
295
+ FAIL,
296
+ 'path',
297
+ 'anyslate is not on the login-shell PATH.',
298
+ `Claude Code spawns hooks with a minimal PATH — use the absolute path ${process.argv[1] ?? '/usr/local/bin/anyslate'} in ~/.claude/settings.json.`,
299
+ );
300
+ }
301
+
302
+ // ---- 10. Hooks wired ---------------------------------------------------
303
+ const hooks = inspectClaudeSettings(env);
304
+ if (hooks.error) {
305
+ add(FAIL, 'hooks', hooks.error, `Add this to ${hooks.path}:\n${indent(SETTINGS_BLOCK)}`);
306
+ } else {
307
+ for (const missing of hooks.missing) {
308
+ add(FAIL, 'hooks', `${missing} hook not wired.`, `Add this to ${hooks.path}:\n${indent(SETTINGS_BLOCK)}`);
309
+ }
310
+ if (!hooks.missing.length) {
311
+ add(PASS, 'hooks', `SessionStart, PostToolUse and Stop all invoke \`anyslate hook\` (${hooks.path}).`);
312
+ }
313
+ if (hooks.matcherGaps.length) {
314
+ add(
315
+ WARN,
316
+ 'hooks-matcher',
317
+ `PostToolUse matcher does not cover ${hooks.matcherGaps.join(', ')}.`,
318
+ 'Use "matcher": "Edit|Write|MultiEdit|Bash" so file edits and shell commands are both captured.',
319
+ );
320
+ }
321
+ }
322
+
323
+ // ---- 11. Last run outcome ---------------------------------------------
324
+ const last = readLastRun(env);
325
+ if (!last) {
326
+ add(
327
+ WARN,
328
+ 'last-run',
329
+ `No run record at ${lastRunPath(env)} — this CLI has never run, or never got far enough to write one.`,
330
+ 'If hooks are wired but there is no run record, the hook command is not being executed at all (check check #9 and #10).',
331
+ );
332
+ } else if (last.ok) {
333
+ add(PASS, 'last-run', `Last run ${last.ts} (${last.command}) succeeded.`);
334
+ } else {
335
+ add(
336
+ FAIL,
337
+ 'last-run',
338
+ `Last run ${last.ts} (${last.command}) failed: ${last.error ?? 'unknown'} [http ${last.http_status ?? 'n/a'}]. ${last.consecutive_failures ?? 1} consecutive failure(s)${last.last_success_at ? `; last success ${last.last_success_at}` : '; no success on record'}.`,
339
+ 'Fix whichever check above is FAILing, then re-run the command.',
340
+ );
341
+ }
342
+
343
+ // ---- 12. Live round trip (opt-in) -------------------------------------
344
+ if (flags.deep) {
345
+ if (verified?.ok) {
346
+ const res = await callTool({
347
+ apiUrl: root,
348
+ token: cfg.mcpToken,
349
+ toolName: 'activity_submit',
350
+ args: {
351
+ source: 'api',
352
+ kind: 'topic_shift',
353
+ payload: { notes: 'anyslate doctor probe', host_hint: 'cli-doctor', confidence: 0.1 },
354
+ },
355
+ fetchImpl,
356
+ });
357
+ if (res.ok) {
358
+ add(PASS, 'deep', `activity_submit round trip OK: ${JSON.stringify(res.data)}`);
359
+ } else {
360
+ const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
361
+ add(FAIL, 'deep', `activity_submit failed: ${res.networkError ? msg : `server ${res.status} — ${msg}`}.`);
362
+ }
363
+ } else {
364
+ add(SKIP, 'deep', 'Token not verified; not writing a probe row.');
365
+ }
366
+ }
367
+
368
+ // ---- Render ------------------------------------------------------------
369
+ let failures = 0;
370
+ for (const r of results) {
371
+ if (r.status === FAIL) failures += 1;
372
+ out.write(`${r.status.padEnd(4)} ${r.id.padEnd(16)} ${r.message}\n`);
373
+ if (r.remediation) out.write(`${''.padEnd(4)} ${''.padEnd(16)} → ${r.remediation}\n`);
374
+ }
375
+
376
+ out.write('\n');
377
+ out.write(`user-agent: ${USER_AGENT}\n`);
378
+ if (failures) {
379
+ out.write(`\n${failures} check(s) FAILED.\n`);
380
+ return 1;
381
+ }
382
+ out.write('\nAll checks passed.\n');
383
+ return 0;
384
+ }
385
+
386
+ function remediationFor(code) {
387
+ switch (code) {
388
+ case 'revoked':
389
+ return 'Mint a new token at Avatar (top-right) → API Tokens, then re-run `anyslate login`.';
390
+ case 'expired':
391
+ return 'Mint a new token at Avatar (top-right) → API Tokens.';
392
+ case 'invalid_token':
393
+ return 'Check for a typo, or that you minted the token in the same environment (dev vs prod).';
394
+ case 'not_found':
395
+ return 'The host is not an AnySlate MCP service, or --api-url includes a path prefix.';
396
+ case 'authorization_required':
397
+ return 'This is a CLI bug — please report it.';
398
+ case 'unreachable':
399
+ return 'Check --api-url and your network.';
400
+ default:
401
+ return 'Re-run `anyslate login` against the correct environment.';
402
+ }
403
+ }
404
+
405
+ async function probeHealth(root, fetchImpl) {
406
+ const ac = new AbortController();
407
+ const timer = setTimeout(() => ac.abort(), 10_000);
408
+ try {
409
+ const res = await fetchImpl(`${root}/health`, {
410
+ method: 'GET',
411
+ headers: { accept: 'application/json', 'user-agent': USER_AGENT },
412
+ signal: ac.signal,
413
+ });
414
+ if (res.status === 404) return { ok: false, status: 404 };
415
+ if (!res.ok) return { ok: false, status: res.status, detail: `HTTP ${res.status}` };
416
+ let body = null;
417
+ try {
418
+ body = await res.json();
419
+ } catch {
420
+ body = null;
421
+ }
422
+ const checks = body?.checks && typeof body.checks === 'object' ? body.checks : null;
423
+ let degraded = null;
424
+ if (checks) {
425
+ const bad = Object.entries(checks).filter(([, v]) => v !== true && v !== 'ok' && v !== 'healthy');
426
+ if (bad.length) degraded = bad.map(([k, v]) => `${k}=${v}`).join(' ');
427
+ }
428
+ return { ok: true, status: res.status, degraded };
429
+ } catch (e) {
430
+ return { ok: false, status: 0, detail: String(e?.cause?.code ?? e?.message ?? e) };
431
+ } finally {
432
+ clearTimeout(timer);
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Claude Code spawns hooks with a minimal, non-interactive PATH. Resolving
438
+ * `anyslate` from *this* process proves nothing — probe a login shell.
439
+ */
440
+ function probeLoginShellPath() {
441
+ if (process.platform === 'win32') {
442
+ return { status: SKIP, message: 'Login-shell PATH probe is POSIX-only; skipped on Windows.' };
443
+ }
444
+ try {
445
+ const r = spawnSync('sh', ['-lc', 'command -v anyslate'], {
446
+ encoding: 'utf8',
447
+ timeout: 5_000,
448
+ });
449
+ const found = (r.stdout || '').trim();
450
+ return { status: 'ran', found: found || null };
451
+ } catch {
452
+ return { status: SKIP, message: 'Could not spawn a login shell to probe PATH.' };
453
+ }
454
+ }
455
+
456
+ function inspectClaudeSettings(env) {
457
+ const home = env.CLAUDE_HOME || homedir();
458
+ const path = join(home, '.claude', 'settings.json');
459
+ let parsed;
460
+ try {
461
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
462
+ } catch (e) {
463
+ if (e && e.code === 'ENOENT') {
464
+ return { path, error: `${path} does not exist — Claude Code hooks are not wired.`, missing: [], matcherGaps: [] };
465
+ }
466
+ return { path, error: `${path} could not be parsed (${e?.message ?? e}).`, missing: [], matcherGaps: [] };
467
+ }
468
+
469
+ const hooks = parsed?.hooks;
470
+ if (!hooks || typeof hooks !== 'object') {
471
+ return { path, error: `${path} has no "hooks" key — Claude Code hooks are not wired.`, missing: [], matcherGaps: [] };
472
+ }
473
+
474
+ const missing = [];
475
+ const matcherGaps = [];
476
+ for (const eventName of ['SessionStart', 'PostToolUse', 'Stop']) {
477
+ const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
478
+ const commands = [];
479
+ for (const entry of entries) {
480
+ const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
481
+ for (const h of inner) if (typeof h?.command === 'string') commands.push({ command: h.command, matcher: entry.matcher });
482
+ }
483
+ const hit = commands.find((c) => /anyslate\s+hook/.test(c.command));
484
+ if (!hit) {
485
+ missing.push(eventName);
486
+ continue;
487
+ }
488
+ if (eventName === 'PostToolUse') {
489
+ const matcher = String(hit.matcher ?? '');
490
+ if (matcher && matcher !== '*') {
491
+ for (const tool of ['Edit', 'Write', 'MultiEdit', 'Bash']) {
492
+ if (!matcher.includes(tool)) matcherGaps.push(tool);
493
+ }
494
+ }
495
+ }
496
+ }
497
+ return { path, missing, matcherGaps };
498
+ }
499
+
500
+ function maskToken(token) {
501
+ const s = String(token ?? '');
502
+ const prefixMatch = s.match(/^(as_mcp_|as_oauth_)/);
503
+ const prefix = prefixMatch ? prefixMatch[1] : s.slice(0, 4);
504
+ return `${prefix}…${s.slice(-4)}`;
505
+ }
506
+
507
+ function indent(block) {
508
+ return block
509
+ .split('\n')
510
+ .map((l) => ` ${l}`)
511
+ .join('\n');
512
+ }
513
+
514
+ /** @param {string[]} argv */
515
+ function parseFlags(argv) {
516
+ const out = { deep: false };
517
+ for (const a of argv) {
518
+ if (a === '--deep') out.deep = true;
519
+ }
520
+ return out;
521
+ }
522
+
523
+ export const __testing = { inspectClaudeSettings, maskToken, SETTINGS_BLOCK, anyslateDir, normalizeApiRoot };
@@ -1,4 +1,4 @@
1
- // `anyslate hook <subcommand>` submit a lifecycle event to the Activity feed.
1
+ // `anyslate hook <subcommand>` - submit a lifecycle event to the Activity feed.
2
2
  //
3
3
  // Subcommands:
4
4
  // anyslate hook session-start
@@ -8,41 +8,73 @@
8
8
  // Hooks fail open: any error short-circuits to a stderr warning + exit 0 so a
9
9
  // misconfigured CLI never breaks the parent Claude Code session. Pass
10
10
  // --strict to opt into exit 1 on failure (useful for setup verification).
11
+ //
12
+ // Fail-open is correct policy. Fail-SILENT was the bug: Claude Code files
13
+ // `exit 0 + stderr` as `hook_success` and nothing reads the stderr. So every
14
+ // run is now persisted (W7) and, after N consecutive failures, SessionStart
15
+ // escalates through a channel that actually renders.
16
+ //
17
+ // Tool-level errors (HTTP 200 + result.isError) now count as failures and are
18
+ // therefore visible to --strict — previously a 403 handle denial printed
19
+ // nothing and exited 0.
11
20
 
12
- import { loadConfig, requireToken } from '../config.mjs';
13
- import { callTool } from '../mcp-client.mjs';
21
+ import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
22
+ import { callTool, formatCallFailure } from '../mcp-client.mjs';
14
23
  import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
15
- import { readStdin } from '../stdin.mjs';
24
+ import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
25
+ import { recordRun, shouldEscalate, escalationPayload } from '../runlog.mjs';
26
+ import { VERSION } from '../version.mjs';
27
+ import { makeIo } from '../io.mjs';
16
28
 
17
29
  const ALLOWED = new Set(['session-start', 'post-tool-use', 'stop']);
18
30
 
19
31
  /**
20
32
  * @param {string[]} argv arguments after `hook`
33
+ * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
21
34
  * @returns {Promise<number>}
22
35
  */
23
- export async function runHook(argv) {
36
+ export async function runHook(argv, deps = {}) {
37
+ const env = deps.env ?? process.env;
38
+ const { out, err } = makeIo(deps);
24
39
  const sub = argv[0];
25
40
  if (!sub || !ALLOWED.has(sub)) {
26
- process.stderr.write('usage: anyslate hook <session-start|post-tool-use|stop> [--strict] [--session <id>] [--note <text>] [--host <hint>]\n');
41
+ err.write('usage: anyslate hook <session-start|post-tool-use|stop> [--strict] [--session <id>] [--note <text>] [--host <hint>]\n');
27
42
  return 2;
28
43
  }
29
44
 
30
45
  const flags = parseFlags(argv.slice(1));
31
46
  const strict = flags.strict;
32
- const cfg = loadConfig();
47
+ const cfg = loadConfig(env);
48
+
49
+ if (cfg.disabled) {
50
+ err.write(`${DISABLED_NOTICE}\n`);
51
+ return 0;
52
+ }
53
+
54
+ const prefix = `anyslate hook ${sub}`;
55
+ const fail = (message, extra = {}) => {
56
+ err.write(message);
57
+ const state = recordRun(
58
+ { command: `hook ${sub}`, ok: false, apiUrl: cfg.apiUrl, version: VERSION, error: message.trim(), exitCode: strict ? 1 : 0, ...extra },
59
+ env,
60
+ );
61
+ escalateIfNeeded(out, sub, state, message.trim());
62
+ return strict ? 1 : 0;
63
+ };
64
+
65
+ const notice = apiUrlNormalizationNotice(cfg);
66
+ if (notice) err.write(notice);
33
67
 
34
68
  const tokenCheck = requireToken(cfg);
35
69
  if (!tokenCheck.ok) {
36
- process.stderr.write(`anyslate hook ${sub}: ${tokenCheck.error}\n`);
37
- return strict ? 1 : 0;
70
+ return fail(`${prefix}: ${tokenCheck.error}\n`);
38
71
  }
39
72
 
40
73
  let stdinRaw = '';
41
74
  try {
42
- stdinRaw = await readStdin();
75
+ stdinRaw = await readStdin(deps.stdin ?? process.stdin, { timeoutMs: stdinTimeoutFromEnv(env) });
43
76
  } catch (e) {
44
- process.stderr.write(`anyslate hook ${sub}: stdin read failed (${e?.message ?? e})\n`);
45
- return strict ? 1 : 0;
77
+ return fail(`${prefix}: stdin read failed (${e?.message ?? e})\n`);
46
78
  }
47
79
 
48
80
  const event = parseHookEvent(stdinRaw);
@@ -68,22 +100,41 @@ export async function runHook(argv) {
68
100
  token: cfg.mcpToken,
69
101
  toolName: 'activity_submit',
70
102
  args,
103
+ fetchImpl: deps.fetchImpl,
71
104
  });
72
105
  if (!res.ok) {
73
- const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
74
- process.stderr.write(`anyslate hook ${sub}: server ${res.status} — ${msg}\n`);
75
- return strict ? 1 : 0;
106
+ return fail(formatCallFailure(prefix, res), {
107
+ status: res.status,
108
+ isError: !!res.isError,
109
+ networkError: !!res.networkError,
110
+ });
76
111
  }
77
- if (process.env.ANYSLATE_VERBOSE) {
78
- process.stdout.write(`${JSON.stringify(res.data)}\n`);
112
+ recordRun(
113
+ { command: `hook ${sub}`, ok: true, apiUrl: cfg.apiUrl, status: res.status, version: VERSION, exitCode: 0 },
114
+ env,
115
+ );
116
+ if (env.ANYSLATE_VERBOSE) {
117
+ out.write(`${JSON.stringify(res.data)}\n`);
79
118
  }
80
119
  return 0;
81
120
  } catch (e) {
82
- process.stderr.write(`anyslate hook ${sub}: request failed (${e?.message ?? e})\n`);
83
- return strict ? 1 : 0;
121
+ return fail(`${prefix}: request failed (${e?.message ?? e})\n`);
84
122
  }
85
123
  }
86
124
 
125
+ /**
126
+ * Escalate through a channel Claude Code renders — plain stderr on an exit-0
127
+ * hook is filed as `hook_success` and read by nobody.
128
+ *
129
+ * Only from SessionStart: PostToolUse exit 2 is a real blocking channel and
130
+ * must stay reserved for hard failures.
131
+ */
132
+ function escalateIfNeeded(out, sub, state, error) {
133
+ if (sub !== 'session-start') return;
134
+ if (!shouldEscalate(state)) return;
135
+ out.write(escalationPayload({ ...state, error }));
136
+ }
137
+
87
138
  /** @param {string[]} argv */
88
139
  function parseFlags(argv) {
89
140
  const out = { strict: false };