@anyslate/cli 0.1.0 → 0.3.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.
- package/README.md +313 -19
- package/package.json +6 -6
- package/src/auth.mjs +310 -0
- package/src/commands/checkpoint.mjs +61 -18
- package/src/commands/doctor.mjs +616 -0
- package/src/commands/hook.mjs +81 -23
- package/src/commands/login.mjs +362 -25
- package/src/commands/logout.mjs +131 -0
- package/src/commands/upload-artifact.mjs +136 -26
- package/src/config.mjs +162 -13
- package/src/credentials.mjs +162 -0
- package/src/hooks.mjs +170 -8
- package/src/index.mjs +61 -6
- package/src/io.mjs +30 -0
- package/src/mcp-client.mjs +291 -45
- package/src/oauth.mjs +633 -0
- package/src/runlog.mjs +196 -0
- package/src/stdin.mjs +85 -15
- package/src/verify.mjs +262 -0
- package/src/version.mjs +21 -0
- package/templates/git/post-commit +71 -0
|
@@ -0,0 +1,616 @@
|
|
|
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 { hasOauthCredentials, isExpired, minutesUntilExpiry, resolveBearer } from '../auth.mjs';
|
|
30
|
+
import { lastRunPath, readLastRun } from '../runlog.mjs';
|
|
31
|
+
import { callTool } from '../mcp-client.mjs';
|
|
32
|
+
import { VERSION, USER_AGENT } from '../version.mjs';
|
|
33
|
+
import { makeIo } from '../io.mjs';
|
|
34
|
+
|
|
35
|
+
const PASS = 'PASS';
|
|
36
|
+
const FAIL = 'FAIL';
|
|
37
|
+
const WARN = 'WARN';
|
|
38
|
+
const SKIP = 'SKIP';
|
|
39
|
+
|
|
40
|
+
const SETTINGS_BLOCK = `{
|
|
41
|
+
"hooks": {
|
|
42
|
+
"SessionStart": [
|
|
43
|
+
{ "hooks": [{ "type": "command", "command": "anyslate hook session-start" }] }
|
|
44
|
+
],
|
|
45
|
+
"PostToolUse": [
|
|
46
|
+
{
|
|
47
|
+
"matcher": "Edit|Write|MultiEdit|Bash",
|
|
48
|
+
"hooks": [{ "type": "command", "command": "anyslate hook post-tool-use" }]
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"Stop": [
|
|
52
|
+
{ "hooks": [{ "type": "command", "command": "anyslate hook stop" }] }
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}`;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string[]} argv
|
|
59
|
+
* @param {{fetchImpl?: typeof fetch, env?: NodeJS.ProcessEnv, out?: NodeJS.WritableStream,
|
|
60
|
+
* probePath?: () => {status: string, found?: string|null, message?: string}}} [deps]
|
|
61
|
+
* @returns {Promise<number>}
|
|
62
|
+
*/
|
|
63
|
+
export async function runDoctor(argv = [], deps = {}) {
|
|
64
|
+
const env = deps.env ?? process.env;
|
|
65
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
66
|
+
const { out } = makeIo({ stdout: deps.out ?? deps.stdout });
|
|
67
|
+
const probePath = deps.probePath ?? probeLoginShellPath;
|
|
68
|
+
const flags = parseFlags(argv);
|
|
69
|
+
|
|
70
|
+
const results = [];
|
|
71
|
+
const add = (status, id, message, remediation) => {
|
|
72
|
+
results.push({ status, id, message, remediation });
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
out.write(`anyslate doctor ${VERSION}\n\n`);
|
|
76
|
+
|
|
77
|
+
// ---- 0. Kill switch ----------------------------------------------------
|
|
78
|
+
if (isCaptureDisabled(env)) {
|
|
79
|
+
add(
|
|
80
|
+
WARN,
|
|
81
|
+
'kill-switch',
|
|
82
|
+
`ANYSLATE_DISABLE=${env.ANYSLATE_DISABLE} — capture is disabled for this shell.`,
|
|
83
|
+
'Unset ANYSLATE_DISABLE to re-enable capture. doctor still runs its checks so you can verify the setup while capture is off.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- 1. Config resolution ---------------------------------------------
|
|
88
|
+
const cfg = loadConfig(env);
|
|
89
|
+
const candidates = configPaths(env);
|
|
90
|
+
const fileFound = cfg.source !== 'env';
|
|
91
|
+
|
|
92
|
+
if (!fileFound && !env.ANYSLATE_MCP_TOKEN) {
|
|
93
|
+
add(
|
|
94
|
+
FAIL,
|
|
95
|
+
'config',
|
|
96
|
+
`No config found (looked in ${candidates.join(', ')}).`,
|
|
97
|
+
'Run `anyslate login --token <BEARER>`.',
|
|
98
|
+
);
|
|
99
|
+
} else if (fileFound && cfg.source.endsWith('session.json')) {
|
|
100
|
+
add(
|
|
101
|
+
FAIL,
|
|
102
|
+
'config',
|
|
103
|
+
`Using legacy ${cfg.source}.`,
|
|
104
|
+
'That is the desktop editor-state file and can never contain a token. Run `anyslate login`.',
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
// Render the per-key provenance as SHORT labels. `sources.*` carries the
|
|
108
|
+
// absolute config path, and printing an 80-char path three times on one
|
|
109
|
+
// line makes the single most important diagnostic unreadable.
|
|
110
|
+
const label = (v) => (v === 'env' ? 'env' : v === 'default' ? 'default' : 'file');
|
|
111
|
+
const layers = `apiUrl=${label(cfg.sources.apiUrl)}, token=${label(cfg.sources.mcpToken)}, handle=${label(cfg.sources.handle)}`;
|
|
112
|
+
add(PASS, 'config', `Resolved from ${fileFound ? cfg.source : 'environment'} (${layers}).`);
|
|
113
|
+
if (cfg.sources.apiUrl === 'env' && fileFound) {
|
|
114
|
+
add(
|
|
115
|
+
WARN,
|
|
116
|
+
'config-override',
|
|
117
|
+
`ANYSLATE_API_URL in your environment is overriding ${cfg.source}.`,
|
|
118
|
+
`Unset ANYSLATE_API_URL if that is not intended (env value: ${env.ANYSLATE_API_URL}).`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (cfg.sources.mcpToken === 'env' && fileFound) {
|
|
122
|
+
add(
|
|
123
|
+
WARN,
|
|
124
|
+
'token-override',
|
|
125
|
+
`ANYSLATE_MCP_TOKEN in your environment is overriding ${cfg.source}.`,
|
|
126
|
+
'Unset ANYSLATE_MCP_TOKEN if that is not intended.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- 2. Token present --------------------------------------------------
|
|
132
|
+
// "Present" now has three shapes: an env/static token, a live OAuth access
|
|
133
|
+
// token, or OAuth credentials whose access token has aged out but which carry
|
|
134
|
+
// a refresh token. The third is NOT a failure — the refresh happens further
|
|
135
|
+
// down, once the URL and reachability checks have cleared.
|
|
136
|
+
const oauthPresent = hasOauthCredentials(cfg.oauth);
|
|
137
|
+
const tokenCheck = requireToken(cfg);
|
|
138
|
+
if (!tokenCheck.ok && !oauthPresent) {
|
|
139
|
+
add(FAIL, 'token-present', 'No MCP token configured.', tokenCheck.error);
|
|
140
|
+
} else if (!tokenCheck.ok) {
|
|
141
|
+
add(
|
|
142
|
+
WARN,
|
|
143
|
+
'token-present',
|
|
144
|
+
'OAuth access token has expired; a refresh is needed before the next call.',
|
|
145
|
+
'This is normal — access tokens live one hour. The refresh is exercised by the oauth-refresh check below.',
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
add(
|
|
149
|
+
PASS,
|
|
150
|
+
'token-present',
|
|
151
|
+
`Token ${maskToken(cfg.mcpToken)} (from ${cfg.sources.mcpToken === 'env' ? 'ANYSLATE_MCP_TOKEN' : cfg.sources.mcpToken}).`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- 2a. Auth mode -----------------------------------------------------
|
|
156
|
+
// Pure config inspection, no network. Which credential the CLI would use is
|
|
157
|
+
// the first thing anyone debugging "capture stopped" needs to know, and it
|
|
158
|
+
// must be answerable even when the host is down.
|
|
159
|
+
if (cfg.sources.mcpToken === 'env' && cfg.mcpToken) {
|
|
160
|
+
add(PASS, 'auth-mode', 'Static token from ANYSLATE_MCP_TOKEN (env) — no refresh, never expires.');
|
|
161
|
+
if (oauthPresent) {
|
|
162
|
+
add(
|
|
163
|
+
WARN,
|
|
164
|
+
'auth-override',
|
|
165
|
+
'ANYSLATE_MCP_TOKEN is overriding a stored OAuth session.',
|
|
166
|
+
'Unset ANYSLATE_MCP_TOKEN to go back to the browser-issued credential.',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
} else if (oauthPresent) {
|
|
170
|
+
const mins = minutesUntilExpiry(cfg.oauth);
|
|
171
|
+
const when =
|
|
172
|
+
mins == null
|
|
173
|
+
? 'expiry unknown (no parseable expires_at)'
|
|
174
|
+
: mins >= 0
|
|
175
|
+
? `expires in ${mins} min (${cfg.oauth.expires_at})`
|
|
176
|
+
: `expired ${Math.abs(mins)} min ago (${cfg.oauth.expires_at})`;
|
|
177
|
+
if (!cfg.oauth.refresh_token) {
|
|
178
|
+
add(
|
|
179
|
+
WARN,
|
|
180
|
+
'auth-mode',
|
|
181
|
+
`OAuth (browser sign-in), ${when}, no refresh token stored.`,
|
|
182
|
+
'Without a refresh token the session dies at expiry. Run `anyslate login` to get one.',
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
add(PASS, 'auth-mode', `OAuth (browser sign-in), ${when}, refresh token stored.`);
|
|
186
|
+
}
|
|
187
|
+
} else if (cfg.staticToken) {
|
|
188
|
+
add(PASS, 'auth-mode', `Static MCP token from ${cfg.sources.mcpToken} — no refresh, never expires.`);
|
|
189
|
+
} else {
|
|
190
|
+
add(SKIP, 'auth-mode', 'No credentials to classify.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- 2b. Handle FORMAT — never gated behind a server round trip ---------
|
|
194
|
+
// This is the exact live failure that produced a 100% invisible capture
|
|
195
|
+
// loss: cli/README.md told users to export `ANYSLATE_HANDLE=h_xxx`, but real
|
|
196
|
+
// handle ids are `mh_`-prefixed, so every call returned
|
|
197
|
+
// {"error":"handle_not_found"} as HTTP 200 + isError. It is pure string
|
|
198
|
+
// inspection, so it must NOT sit behind `verified.ok` (check #8) — otherwise
|
|
199
|
+
// any upstream breakage hides it, which is how it stayed invisible.
|
|
200
|
+
if (!cfg.handle) {
|
|
201
|
+
add(SKIP, 'handle-format', 'No handle configured (bearer-token scope).');
|
|
202
|
+
} else if (/^mh_/.test(cfg.handle)) {
|
|
203
|
+
add(PASS, 'handle-format', `Handle ${cfg.handle} has the expected \`mh_\` prefix.`);
|
|
204
|
+
} else {
|
|
205
|
+
add(
|
|
206
|
+
FAIL,
|
|
207
|
+
'handle-format',
|
|
208
|
+
`Handle "${cfg.handle}" (from ${cfg.sources.handle === 'env' ? 'ANYSLATE_HANDLE' : cfg.sources.handle}) is not a valid handle id — real ids are \`mh_\`-prefixed.`,
|
|
209
|
+
'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.',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- 3. Token format ---------------------------------------------------
|
|
214
|
+
if (tokenCheck.ok) {
|
|
215
|
+
if (isValidTokenFormat(cfg.mcpToken)) {
|
|
216
|
+
add(PASS, 'token-format', 'Token matches as_mcp_<48 hex> or as_oauth_<64 hex>.');
|
|
217
|
+
} else {
|
|
218
|
+
add(
|
|
219
|
+
FAIL,
|
|
220
|
+
'token-format',
|
|
221
|
+
`That doesn't look like an AnySlate token (expected as_mcp_… or as_oauth_…, got "${tokenPreview(cfg.mcpToken)}").`,
|
|
222
|
+
'Mint one in the desktop app at Avatar (top-right) → API Tokens → Create Token.',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
} else if (oauthPresent) {
|
|
226
|
+
add(SKIP, 'token-format', 'OAuth access token is past its expiry; the format is checked after it is refreshed.');
|
|
227
|
+
} else {
|
|
228
|
+
add(SKIP, 'token-format', 'No token to check.');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- 4. URL shape (MUST run before #6) ---------------------------------
|
|
232
|
+
const shape = checkUrlShape(cfg.apiUrlRaw);
|
|
233
|
+
let root = cfg.apiUrl;
|
|
234
|
+
if (!shape.ok) {
|
|
235
|
+
add(FAIL, 'url-shape', shape.message, 'Run `anyslate login --api-url <service root>`.');
|
|
236
|
+
} else {
|
|
237
|
+
root = shape.root;
|
|
238
|
+
if (cfg.apiUrlNormalized) {
|
|
239
|
+
add(
|
|
240
|
+
WARN,
|
|
241
|
+
'url-shape',
|
|
242
|
+
`apiUrl "${cfg.apiUrlRaw}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself. Using "${root}".`,
|
|
243
|
+
`The read path self-heals this, but the stored value is still wrong. Run \`anyslate login --api-url ${root}\` to persist.`,
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
add(PASS, 'url-shape', `Service root resolves to ${root} (requests go to ${root}/mcp).`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- 5. Reachability, no token ----------------------------------------
|
|
251
|
+
let reachable = false;
|
|
252
|
+
if (shape.ok) {
|
|
253
|
+
const health = await probeHealth(root, fetchImpl);
|
|
254
|
+
if (health.ok) {
|
|
255
|
+
reachable = true;
|
|
256
|
+
if (health.degraded) {
|
|
257
|
+
add(
|
|
258
|
+
WARN,
|
|
259
|
+
'reachability',
|
|
260
|
+
`Service degraded: ${health.degraded}.`,
|
|
261
|
+
'Capture may fail — this is a service problem, not a config problem.',
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
add(PASS, 'reachability', `${root}/health responded 200.`);
|
|
265
|
+
}
|
|
266
|
+
} else if (health.status === 404) {
|
|
267
|
+
add(
|
|
268
|
+
FAIL,
|
|
269
|
+
'reachability',
|
|
270
|
+
`${root}/health returned 404 — not an AnySlate MCP service, or --api-url has a path prefix.`,
|
|
271
|
+
'Check the host in `anyslate login --api-url <root>`.',
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
add(FAIL, 'reachability', `Cannot reach ${shape.host} — ${health.detail}.`, 'Check --api-url and your network.');
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
add(SKIP, 'reachability', 'URL shape failed; not probing.');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---- 5b. OAuth refresh -------------------------------------------------
|
|
281
|
+
// Deliberately AFTER url-shape and reachability. A refresh against an
|
|
282
|
+
// unreachable host fails for a reason that has nothing to do with the
|
|
283
|
+
// credential, and reporting that as "refresh is broken" sends the user
|
|
284
|
+
// hunting for the wrong bug.
|
|
285
|
+
let bearer = cfg.mcpToken;
|
|
286
|
+
if (shape.ok && reachable && oauthPresent && cfg.sources.mcpToken !== 'env') {
|
|
287
|
+
const wasStale = isExpired(cfg.oauth) || flags.refresh;
|
|
288
|
+
const resolved = await resolveBearer({ cfg, env, fetchImpl, force: flags.refresh });
|
|
289
|
+
if (resolved.ok) {
|
|
290
|
+
bearer = resolved.token;
|
|
291
|
+
if (resolved.refreshed) {
|
|
292
|
+
add(PASS, 'oauth-refresh', 'Refreshed the access token successfully; the rotated refresh token was persisted.');
|
|
293
|
+
} else if (wasStale) {
|
|
294
|
+
add(PASS, 'oauth-refresh', 'Access token was already renewed by another process; nothing to do.');
|
|
295
|
+
} else {
|
|
296
|
+
add(
|
|
297
|
+
PASS,
|
|
298
|
+
'oauth-refresh',
|
|
299
|
+
'Refresh token is stored and the access token is still valid — refresh not exercised.',
|
|
300
|
+
'Run `anyslate doctor --refresh` to force a real refresh round trip (it rotates the refresh token).',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
add(
|
|
305
|
+
FAIL,
|
|
306
|
+
'oauth-refresh',
|
|
307
|
+
`OAuth refresh failed: ${String(resolved.message).replace(/^anyslate: /, '')}`,
|
|
308
|
+
'Run `anyslate login` to sign in again. Refresh tokens live 30 days and are single-use (rotated on every refresh).',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
} else if (oauthPresent && cfg.sources.mcpToken === 'env') {
|
|
312
|
+
add(SKIP, 'oauth-refresh', 'ANYSLATE_MCP_TOKEN is in use; the stored OAuth session is not consulted.');
|
|
313
|
+
} else if (oauthPresent) {
|
|
314
|
+
add(SKIP, 'oauth-refresh', 'Skipped — the URL and reachability checks must pass before a refresh is meaningful.');
|
|
315
|
+
} else {
|
|
316
|
+
add(SKIP, 'oauth-refresh', 'Not an OAuth session.');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---- 6. Token valid ----------------------------------------------------
|
|
320
|
+
let verified = null;
|
|
321
|
+
if (shape.ok && reachable && bearer) {
|
|
322
|
+
verified = await probeVerify({ root, token: bearer, fetchImpl });
|
|
323
|
+
if (verified.ok) {
|
|
324
|
+
add(PASS, 'token-valid', `${verified.message.replace(/^anyslate: /, '')}`);
|
|
325
|
+
} else {
|
|
326
|
+
add(FAIL, 'token-valid', verified.message.replace(/^anyslate: /, ''), remediationFor(verified.code));
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
add(SKIP, 'token-valid', 'Skipped — an earlier check must pass first (a wrong URL masks every token verdict).');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- 7. Scope sufficient ----------------------------------------------
|
|
333
|
+
if (verified?.ok) {
|
|
334
|
+
if (hasWriteScope(verified.scopes)) {
|
|
335
|
+
add(PASS, 'scope', `Token scopes [${verified.scopes.join(',')}] include memory:write.`);
|
|
336
|
+
} else {
|
|
337
|
+
add(
|
|
338
|
+
FAIL,
|
|
339
|
+
'scope',
|
|
340
|
+
`Token scopes are [${(verified.scopes || []).join(',')}]; \`anyslate hook\` needs memory:write.`,
|
|
341
|
+
'Mint a token with the Memory profile.',
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
add(SKIP, 'scope', 'Token not verified.');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---- 8. Effective handle scope ----------------------------------------
|
|
349
|
+
if (verified?.ok) {
|
|
350
|
+
const bound = verified.defaultHandleId;
|
|
351
|
+
if (bound === undefined || (bound === null && !('default_handle_id' in (verified.raw ?? {})))) {
|
|
352
|
+
add(
|
|
353
|
+
WARN,
|
|
354
|
+
'handle-scope',
|
|
355
|
+
'Server did not report default_handle_id, so the effective memory scope cannot be shown.',
|
|
356
|
+
'Requires the /mcp/auth/verify companion change. Until then, check the token in Settings → MCP Tokens.',
|
|
357
|
+
);
|
|
358
|
+
} else if (bound) {
|
|
359
|
+
add(
|
|
360
|
+
WARN,
|
|
361
|
+
'handle-scope',
|
|
362
|
+
`Token is bound to handle ${bound} — capture is narrower than workspace-wide.`,
|
|
363
|
+
'ANYSLATE_HANDLE/--handle is ignored for scoped tokens (the dispatcher logs and discards it). Mint an unscoped token for workspace-wide capture.',
|
|
364
|
+
);
|
|
365
|
+
} else if (cfg.handle) {
|
|
366
|
+
add(
|
|
367
|
+
WARN,
|
|
368
|
+
'handle-scope',
|
|
369
|
+
`Token is unscoped, and ANYSLATE_HANDLE/--handle is set to ${cfg.handle}.`,
|
|
370
|
+
'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).',
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
add(PASS, 'handle-scope', 'Token is unscoped — workspace-wide capture.');
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
add(SKIP, 'handle-scope', 'Token not verified.');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- 9. Binary on the hook subprocess's PATH --------------------------
|
|
380
|
+
const pathCheck = probePath();
|
|
381
|
+
if (pathCheck.status === SKIP) {
|
|
382
|
+
add(SKIP, 'path', pathCheck.message);
|
|
383
|
+
} else if (pathCheck.found) {
|
|
384
|
+
add(PASS, 'path', `Login-shell PATH resolves anyslate to ${pathCheck.found}.`);
|
|
385
|
+
} else {
|
|
386
|
+
add(
|
|
387
|
+
FAIL,
|
|
388
|
+
'path',
|
|
389
|
+
'anyslate is not on the login-shell PATH.',
|
|
390
|
+
`Claude Code spawns hooks with a minimal PATH — use the absolute path ${process.argv[1] ?? '/usr/local/bin/anyslate'} in ~/.claude/settings.json.`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---- 10. Hooks wired ---------------------------------------------------
|
|
395
|
+
const hooks = inspectClaudeSettings(env);
|
|
396
|
+
if (hooks.error) {
|
|
397
|
+
add(FAIL, 'hooks', hooks.error, `Add this to ${hooks.path}:\n${indent(SETTINGS_BLOCK)}`);
|
|
398
|
+
} else {
|
|
399
|
+
for (const missing of hooks.missing) {
|
|
400
|
+
add(FAIL, 'hooks', `${missing} hook not wired.`, `Add this to ${hooks.path}:\n${indent(SETTINGS_BLOCK)}`);
|
|
401
|
+
}
|
|
402
|
+
if (!hooks.missing.length) {
|
|
403
|
+
add(PASS, 'hooks', `SessionStart, PostToolUse and Stop all invoke \`anyslate hook\` (${hooks.path}).`);
|
|
404
|
+
}
|
|
405
|
+
if (hooks.matcherGaps.length) {
|
|
406
|
+
add(
|
|
407
|
+
WARN,
|
|
408
|
+
'hooks-matcher',
|
|
409
|
+
`PostToolUse matcher does not cover ${hooks.matcherGaps.join(', ')}.`,
|
|
410
|
+
'Use "matcher": "Edit|Write|MultiEdit|Bash" so file edits and shell commands are both captured.',
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---- 11. Last run outcome ---------------------------------------------
|
|
416
|
+
const last = readLastRun(env);
|
|
417
|
+
if (!last) {
|
|
418
|
+
add(
|
|
419
|
+
WARN,
|
|
420
|
+
'last-run',
|
|
421
|
+
`No run record at ${lastRunPath(env)} — this CLI has never run, or never got far enough to write one.`,
|
|
422
|
+
'If hooks are wired but there is no run record, the hook command is not being executed at all (check check #9 and #10).',
|
|
423
|
+
);
|
|
424
|
+
} else if (last.ok) {
|
|
425
|
+
add(PASS, 'last-run', `Last run ${last.ts} (${last.command}) succeeded.`);
|
|
426
|
+
} else {
|
|
427
|
+
add(
|
|
428
|
+
FAIL,
|
|
429
|
+
'last-run',
|
|
430
|
+
`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'}.`,
|
|
431
|
+
'Fix whichever check above is FAILing, then re-run the command.',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---- 12. Live round trip (opt-in) -------------------------------------
|
|
436
|
+
if (flags.deep) {
|
|
437
|
+
if (verified?.ok) {
|
|
438
|
+
const res = await callTool({
|
|
439
|
+
apiUrl: root,
|
|
440
|
+
token: bearer,
|
|
441
|
+
toolName: 'activity_submit',
|
|
442
|
+
args: {
|
|
443
|
+
source: 'api',
|
|
444
|
+
kind: 'topic_shift',
|
|
445
|
+
payload: { notes: 'anyslate doctor probe', host_hint: 'cli-doctor', confidence: 0.1 },
|
|
446
|
+
},
|
|
447
|
+
fetchImpl,
|
|
448
|
+
});
|
|
449
|
+
if (res.ok) {
|
|
450
|
+
add(PASS, 'deep', `activity_submit round trip OK: ${JSON.stringify(res.data)}`);
|
|
451
|
+
} else {
|
|
452
|
+
const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
|
|
453
|
+
add(FAIL, 'deep', `activity_submit failed: ${res.networkError ? msg : `server ${res.status} — ${msg}`}.`);
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
add(SKIP, 'deep', 'Token not verified; not writing a probe row.');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---- Render ------------------------------------------------------------
|
|
461
|
+
let failures = 0;
|
|
462
|
+
for (const r of results) {
|
|
463
|
+
if (r.status === FAIL) failures += 1;
|
|
464
|
+
out.write(`${r.status.padEnd(4)} ${r.id.padEnd(16)} ${r.message}\n`);
|
|
465
|
+
if (r.remediation) out.write(`${''.padEnd(4)} ${''.padEnd(16)} → ${r.remediation}\n`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
out.write('\n');
|
|
469
|
+
out.write(`user-agent: ${USER_AGENT}\n`);
|
|
470
|
+
if (failures) {
|
|
471
|
+
out.write(`\n${failures} check(s) FAILED.\n`);
|
|
472
|
+
return 1;
|
|
473
|
+
}
|
|
474
|
+
out.write('\nAll checks passed.\n');
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function remediationFor(code) {
|
|
479
|
+
switch (code) {
|
|
480
|
+
case 'revoked':
|
|
481
|
+
return 'Mint a new token at Avatar (top-right) → API Tokens, then re-run `anyslate login`.';
|
|
482
|
+
case 'expired':
|
|
483
|
+
return 'Mint a new token at Avatar (top-right) → API Tokens.';
|
|
484
|
+
case 'invalid_token':
|
|
485
|
+
return 'Check for a typo, or that you minted the token in the same environment (dev vs prod).';
|
|
486
|
+
case 'not_found':
|
|
487
|
+
return 'The host is not an AnySlate MCP service, or --api-url includes a path prefix.';
|
|
488
|
+
case 'authorization_required':
|
|
489
|
+
return 'This is a CLI bug — please report it.';
|
|
490
|
+
case 'unreachable':
|
|
491
|
+
return 'Check --api-url and your network.';
|
|
492
|
+
default:
|
|
493
|
+
return 'Re-run `anyslate login` against the correct environment.';
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function probeHealth(root, fetchImpl) {
|
|
498
|
+
const ac = new AbortController();
|
|
499
|
+
const timer = setTimeout(() => ac.abort(), 10_000);
|
|
500
|
+
try {
|
|
501
|
+
const res = await fetchImpl(`${root}/health`, {
|
|
502
|
+
method: 'GET',
|
|
503
|
+
headers: { accept: 'application/json', 'user-agent': USER_AGENT },
|
|
504
|
+
signal: ac.signal,
|
|
505
|
+
});
|
|
506
|
+
if (res.status === 404) return { ok: false, status: 404 };
|
|
507
|
+
if (!res.ok) return { ok: false, status: res.status, detail: `HTTP ${res.status}` };
|
|
508
|
+
let body = null;
|
|
509
|
+
try {
|
|
510
|
+
body = await res.json();
|
|
511
|
+
} catch {
|
|
512
|
+
body = null;
|
|
513
|
+
}
|
|
514
|
+
const checks = body?.checks && typeof body.checks === 'object' ? body.checks : null;
|
|
515
|
+
let degraded = null;
|
|
516
|
+
if (checks) {
|
|
517
|
+
const bad = Object.entries(checks).filter(([, v]) => v !== true && v !== 'ok' && v !== 'healthy');
|
|
518
|
+
if (bad.length) degraded = bad.map(([k, v]) => `${k}=${v}`).join(' ');
|
|
519
|
+
}
|
|
520
|
+
return { ok: true, status: res.status, degraded };
|
|
521
|
+
} catch (e) {
|
|
522
|
+
return { ok: false, status: 0, detail: String(e?.cause?.code ?? e?.message ?? e) };
|
|
523
|
+
} finally {
|
|
524
|
+
clearTimeout(timer);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Claude Code spawns hooks with a minimal, non-interactive PATH. Resolving
|
|
530
|
+
* `anyslate` from *this* process proves nothing — probe a login shell.
|
|
531
|
+
*/
|
|
532
|
+
function probeLoginShellPath() {
|
|
533
|
+
if (process.platform === 'win32') {
|
|
534
|
+
return { status: SKIP, message: 'Login-shell PATH probe is POSIX-only; skipped on Windows.' };
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
const r = spawnSync('sh', ['-lc', 'command -v anyslate'], {
|
|
538
|
+
encoding: 'utf8',
|
|
539
|
+
timeout: 5_000,
|
|
540
|
+
});
|
|
541
|
+
const found = (r.stdout || '').trim();
|
|
542
|
+
return { status: 'ran', found: found || null };
|
|
543
|
+
} catch {
|
|
544
|
+
return { status: SKIP, message: 'Could not spawn a login shell to probe PATH.' };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function inspectClaudeSettings(env) {
|
|
549
|
+
const home = env.CLAUDE_HOME || homedir();
|
|
550
|
+
const path = join(home, '.claude', 'settings.json');
|
|
551
|
+
let parsed;
|
|
552
|
+
try {
|
|
553
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
554
|
+
} catch (e) {
|
|
555
|
+
if (e && e.code === 'ENOENT') {
|
|
556
|
+
return { path, error: `${path} does not exist — Claude Code hooks are not wired.`, missing: [], matcherGaps: [] };
|
|
557
|
+
}
|
|
558
|
+
return { path, error: `${path} could not be parsed (${e?.message ?? e}).`, missing: [], matcherGaps: [] };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const hooks = parsed?.hooks;
|
|
562
|
+
if (!hooks || typeof hooks !== 'object') {
|
|
563
|
+
return { path, error: `${path} has no "hooks" key — Claude Code hooks are not wired.`, missing: [], matcherGaps: [] };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const missing = [];
|
|
567
|
+
const matcherGaps = [];
|
|
568
|
+
for (const eventName of ['SessionStart', 'PostToolUse', 'Stop']) {
|
|
569
|
+
const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
|
|
570
|
+
const commands = [];
|
|
571
|
+
for (const entry of entries) {
|
|
572
|
+
const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
573
|
+
for (const h of inner) if (typeof h?.command === 'string') commands.push({ command: h.command, matcher: entry.matcher });
|
|
574
|
+
}
|
|
575
|
+
const hit = commands.find((c) => /anyslate\s+hook/.test(c.command));
|
|
576
|
+
if (!hit) {
|
|
577
|
+
missing.push(eventName);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (eventName === 'PostToolUse') {
|
|
581
|
+
const matcher = String(hit.matcher ?? '');
|
|
582
|
+
if (matcher && matcher !== '*') {
|
|
583
|
+
for (const tool of ['Edit', 'Write', 'MultiEdit', 'Bash']) {
|
|
584
|
+
if (!matcher.includes(tool)) matcherGaps.push(tool);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return { path, missing, matcherGaps };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function maskToken(token) {
|
|
593
|
+
const s = String(token ?? '');
|
|
594
|
+
const prefixMatch = s.match(/^(as_mcp_|as_oauth_)/);
|
|
595
|
+
const prefix = prefixMatch ? prefixMatch[1] : s.slice(0, 4);
|
|
596
|
+
return `${prefix}…${s.slice(-4)}`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function indent(block) {
|
|
600
|
+
return block
|
|
601
|
+
.split('\n')
|
|
602
|
+
.map((l) => ` ${l}`)
|
|
603
|
+
.join('\n');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** @param {string[]} argv */
|
|
607
|
+
function parseFlags(argv) {
|
|
608
|
+
const out = { deep: false, refresh: false };
|
|
609
|
+
for (const a of argv) {
|
|
610
|
+
if (a === '--deep') out.deep = true;
|
|
611
|
+
else if (a === '--refresh') out.refresh = true;
|
|
612
|
+
}
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export const __testing = { inspectClaudeSettings, maskToken, SETTINGS_BLOCK, anyslateDir, normalizeApiRoot };
|