@sdsrs/code-graph 0.7.16 → 0.8.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/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/hooks/hooks.json +3 -14
- package/claude-plugin/scripts/adopt.js +110 -0
- package/claude-plugin/scripts/adopt.test.js +114 -0
- package/claude-plugin/scripts/auto-update.js +1 -26
- package/claude-plugin/scripts/doctor.js +387 -0
- package/claude-plugin/scripts/doctor.test.js +47 -0
- package/claude-plugin/scripts/lifecycle.js +99 -3
- package/claude-plugin/scripts/lifecycle.test.js +78 -0
- package/claude-plugin/scripts/session-init.js +110 -3
- package/claude-plugin/scripts/session-init.test.js +33 -0
- package/claude-plugin/scripts/user-prompt-context.js +4 -0
- package/claude-plugin/scripts/user-prompt-context.test.js +14 -0
- package/claude-plugin/scripts/version-utils.js +49 -0
- package/claude-plugin/scripts/version-utils.test.js +83 -0
- package/claude-plugin/templates/plugin_code_graph_mcp.md +70 -0
- package/package.json +6 -6
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const { execFileSync, execSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
8
|
+
const {
|
|
9
|
+
getPluginVersion, readJson, healthCheck, CACHE_DIR,
|
|
10
|
+
findStalePluginHooksJson, clearStalePluginCacheHooks,
|
|
11
|
+
} = require('./lifecycle');
|
|
12
|
+
const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
|
|
13
|
+
|
|
14
|
+
// ── Diagnostics ───────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run all diagnostic checks. Returns an array of:
|
|
18
|
+
* { name: string, status: 'ok'|'warn'|'error'|'skip', detail: string, fixId?: string }
|
|
19
|
+
*/
|
|
20
|
+
function runDiagnostics() {
|
|
21
|
+
const results = [];
|
|
22
|
+
const binary = findBinary();
|
|
23
|
+
|
|
24
|
+
// 1. Binary executable
|
|
25
|
+
if (!binary) {
|
|
26
|
+
results.push({ name: 'Binary', status: 'error', detail: 'not found', fixId: 'binary-missing' });
|
|
27
|
+
results.push({ name: 'Binary version', status: 'skip', detail: 'binary not found' });
|
|
28
|
+
results.push({ name: 'Source fresh', status: 'skip', detail: 'binary not found' });
|
|
29
|
+
results.push({ name: 'Schema', status: 'skip', detail: 'binary not found' });
|
|
30
|
+
results.push({ name: 'Index', status: 'skip', detail: 'binary not found' });
|
|
31
|
+
results.push({ name: 'Embeddings', status: 'skip', detail: 'binary not found' });
|
|
32
|
+
} else {
|
|
33
|
+
let execOk = true;
|
|
34
|
+
try {
|
|
35
|
+
fs.accessSync(binary, fs.constants.X_OK);
|
|
36
|
+
results.push({ name: 'Binary exec', status: 'ok', detail: binary });
|
|
37
|
+
} catch {
|
|
38
|
+
results.push({ name: 'Binary exec', status: 'error', detail: `not executable: ${binary}`, fixId: 'binary-not-exec' });
|
|
39
|
+
execOk = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Binary version vs plugin version
|
|
43
|
+
const pluginVersion = getPluginVersion();
|
|
44
|
+
const binaryVersion = execOk ? readBinaryVersion(binary) : null;
|
|
45
|
+
if (!binaryVersion) {
|
|
46
|
+
results.push({ name: 'Binary version', status: 'error', detail: 'failed to read version', fixId: 'binary-broken' });
|
|
47
|
+
} else if (binaryVersion !== pluginVersion) {
|
|
48
|
+
results.push({
|
|
49
|
+
name: 'Binary version',
|
|
50
|
+
status: 'warn',
|
|
51
|
+
detail: `v${binaryVersion} (plugin expects v${pluginVersion})`,
|
|
52
|
+
fixId: 'version-mismatch',
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
results.push({ name: 'Binary version', status: 'ok', detail: `v${binaryVersion}` });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 3. Source freshness (dev mode only)
|
|
59
|
+
if (isDevMode()) {
|
|
60
|
+
const srcDir = path.resolve(__dirname, '..', '..', 'src');
|
|
61
|
+
try {
|
|
62
|
+
const binaryMtime = fs.statSync(binary).mtimeMs;
|
|
63
|
+
const latestSrcMtime = getNewestMtime(srcDir, '.rs');
|
|
64
|
+
if (latestSrcMtime > binaryMtime) {
|
|
65
|
+
const deltaMin = Math.round((latestSrcMtime - binaryMtime) / 60000);
|
|
66
|
+
results.push({
|
|
67
|
+
name: 'Source fresh',
|
|
68
|
+
status: 'warn',
|
|
69
|
+
detail: `src/ modified ${deltaMin}min after binary`,
|
|
70
|
+
fixId: 'binary-stale',
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
results.push({ name: 'Source fresh', status: 'ok', detail: 'binary up-to-date' });
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
results.push({ name: 'Source fresh', status: 'skip', detail: 'could not stat files' });
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
results.push({ name: 'Source fresh', status: 'skip', detail: 'not dev mode' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. health-check (schema, index, embeddings) via binary --json
|
|
83
|
+
if (execOk) {
|
|
84
|
+
try {
|
|
85
|
+
const cwd = process.cwd();
|
|
86
|
+
const hcOutput = execFileSync(binary, ['health-check', '--json'], {
|
|
87
|
+
cwd,
|
|
88
|
+
timeout: 5000,
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
+
}).trim();
|
|
92
|
+
const hc = JSON.parse(hcOutput);
|
|
93
|
+
|
|
94
|
+
// Schema
|
|
95
|
+
if (hc.issue && hc.issue.includes('schema')) {
|
|
96
|
+
results.push({ name: 'Schema', status: 'warn', detail: hc.issue, fixId: 'schema-mismatch' });
|
|
97
|
+
} else {
|
|
98
|
+
results.push({ name: 'Schema', status: 'ok', detail: `v${hc.schema_version}` });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Index
|
|
102
|
+
if (hc.nodes === 0) {
|
|
103
|
+
results.push({ name: 'Index', status: 'warn', detail: 'empty', fixId: 'index-empty' });
|
|
104
|
+
} else {
|
|
105
|
+
const age = hc.index_age ? ` (${hc.index_age})` : '';
|
|
106
|
+
results.push({
|
|
107
|
+
name: 'Index',
|
|
108
|
+
status: 'ok',
|
|
109
|
+
detail: `${hc.nodes} nodes, ${hc.edges} edges, ${hc.files} files${age}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Embeddings
|
|
114
|
+
const ep = hc.embedding_progress || '0/0';
|
|
115
|
+
const [done, total] = ep.split('/').map(Number);
|
|
116
|
+
if (total > 0 && done < total) {
|
|
117
|
+
const pct = Math.round((done / total) * 100);
|
|
118
|
+
results.push({ name: 'Embeddings', status: 'ok', detail: `${pct}% (${done}/${total})` });
|
|
119
|
+
} else if (total === 0) {
|
|
120
|
+
results.push({ name: 'Embeddings', status: 'ok', detail: 'no embeddable nodes' });
|
|
121
|
+
} else {
|
|
122
|
+
results.push({ name: 'Embeddings', status: 'ok', detail: `100% (${done}/${total})` });
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const msg = e.stderr ? e.stderr.toString().trim().slice(0, 100) : e.message.slice(0, 100);
|
|
126
|
+
results.push({ name: 'Schema', status: 'error', detail: `health-check failed: ${msg}`, fixId: 'binary-broken' });
|
|
127
|
+
results.push({ name: 'Index', status: 'skip', detail: 'health-check failed' });
|
|
128
|
+
results.push({ name: 'Embeddings', status: 'skip', detail: 'health-check failed' });
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
results.push({ name: 'Schema', status: 'skip', detail: 'binary not executable' });
|
|
132
|
+
results.push({ name: 'Index', status: 'skip', detail: 'binary not executable' });
|
|
133
|
+
results.push({ name: 'Embeddings', status: 'skip', detail: 'binary not executable' });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. Auto-update state
|
|
138
|
+
try {
|
|
139
|
+
const state = readJson(path.join(CACHE_DIR, 'update-state.json'));
|
|
140
|
+
if (state && state.updateAvailable && state.binaryUpdated === false) {
|
|
141
|
+
results.push({
|
|
142
|
+
name: 'Auto-update',
|
|
143
|
+
status: 'warn',
|
|
144
|
+
detail: `plugin v${state.latestVersion}, binary download incomplete`,
|
|
145
|
+
fixId: 'update-incomplete',
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
results.push({ name: 'Auto-update', status: 'ok', detail: 'up-to-date' });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
results.push({ name: 'Auto-update', status: 'ok', detail: 'no update state' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 6. Hook paths validity
|
|
155
|
+
const hookResult = healthCheck();
|
|
156
|
+
if (hookResult.healthy) {
|
|
157
|
+
results.push({ name: 'Hooks', status: 'ok', detail: 'all paths valid' });
|
|
158
|
+
} else {
|
|
159
|
+
results.push({
|
|
160
|
+
name: 'Hooks',
|
|
161
|
+
status: hookResult.repaired ? 'ok' : 'warn',
|
|
162
|
+
detail: hookResult.repaired
|
|
163
|
+
? `${hookResult.issues.length} issue(s) auto-repaired`
|
|
164
|
+
: `${hookResult.issues.length} invalid path(s)`,
|
|
165
|
+
fixId: hookResult.repaired ? undefined : 'hooks-invalid',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 7. Plugin cache hooks.json sanity — non-empty copies cause every hook to fire twice
|
|
170
|
+
try {
|
|
171
|
+
const stale = findStalePluginHooksJson();
|
|
172
|
+
if (stale.length === 0) {
|
|
173
|
+
results.push({ name: 'Plugin cache', status: 'ok', detail: 'no stale hooks.json' });
|
|
174
|
+
} else {
|
|
175
|
+
results.push({
|
|
176
|
+
name: 'Plugin cache',
|
|
177
|
+
status: 'warn',
|
|
178
|
+
detail: `${stale.length} stale hooks.json (hooks fire twice per event)`,
|
|
179
|
+
fixId: 'hooks-cache-stale',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
} catch { /* lifecycle probe failed — skip */ }
|
|
183
|
+
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Report Formatting ─────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const STATUS_ICONS = { ok: '\u2705', warn: '\u26a0\ufe0f', error: '\u274c', skip: '\u2796' };
|
|
190
|
+
|
|
191
|
+
function formatReport(results) {
|
|
192
|
+
const pluginVersion = getPluginVersion();
|
|
193
|
+
const lines = [`\ud83d\udd0d code-graph doctor v${pluginVersion}`, ''];
|
|
194
|
+
|
|
195
|
+
const maxName = Math.max(...results.map(r => r.name.length));
|
|
196
|
+
for (const r of results) {
|
|
197
|
+
const icon = STATUS_ICONS[r.status] || '?';
|
|
198
|
+
const pad = ' '.repeat(maxName - r.name.length + 2);
|
|
199
|
+
lines.push(` ${r.name}${pad}${icon} ${r.detail}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const issues = results.filter(r => r.status === 'warn' || r.status === 'error');
|
|
203
|
+
lines.push('');
|
|
204
|
+
if (issues.length === 0) {
|
|
205
|
+
lines.push(' All checks passed.');
|
|
206
|
+
} else {
|
|
207
|
+
const fixable = issues.filter(r => r.fixId);
|
|
208
|
+
lines.push(` ${issues.length} issue(s) found.${fixable.length > 0 ? ' Fixing...' : ''}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return lines.join('\n');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Repair Actions ────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function runRepairs(results) {
|
|
217
|
+
const fixable = results.filter(r => r.fixId);
|
|
218
|
+
if (fixable.length === 0) return 0;
|
|
219
|
+
|
|
220
|
+
let fixed = 0;
|
|
221
|
+
for (const issue of fixable) {
|
|
222
|
+
switch (issue.fixId) {
|
|
223
|
+
case 'binary-stale':
|
|
224
|
+
case 'version-mismatch': {
|
|
225
|
+
if (!isDevMode()) {
|
|
226
|
+
console.log('\n Triggering binary update...');
|
|
227
|
+
try {
|
|
228
|
+
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], {
|
|
229
|
+
timeout: 60000,
|
|
230
|
+
stdio: 'inherit',
|
|
231
|
+
});
|
|
232
|
+
console.log(' \u2705 Update check complete');
|
|
233
|
+
fixed++;
|
|
234
|
+
} catch {
|
|
235
|
+
console.log(' \u274c Update check failed — install manually');
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
console.log('\n Building binary...');
|
|
240
|
+
console.log(' \u2192 cargo build --release --no-default-features');
|
|
241
|
+
try {
|
|
242
|
+
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
243
|
+
execSync('cargo build --release --no-default-features', {
|
|
244
|
+
cwd: projectRoot,
|
|
245
|
+
stdio: 'inherit',
|
|
246
|
+
timeout: 300000,
|
|
247
|
+
});
|
|
248
|
+
clearBinaryCache();
|
|
249
|
+
console.log(' \u2705 Build complete');
|
|
250
|
+
fixed++;
|
|
251
|
+
} catch {
|
|
252
|
+
console.log(' \u274c Build failed');
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
case 'binary-missing': {
|
|
258
|
+
console.log('\n Installing binary...');
|
|
259
|
+
if (isDevMode()) {
|
|
260
|
+
console.log(' \u2192 cargo build --release --no-default-features');
|
|
261
|
+
try {
|
|
262
|
+
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
263
|
+
execSync('cargo build --release --no-default-features', {
|
|
264
|
+
cwd: projectRoot,
|
|
265
|
+
stdio: 'inherit',
|
|
266
|
+
timeout: 300000,
|
|
267
|
+
});
|
|
268
|
+
clearBinaryCache();
|
|
269
|
+
console.log(' \u2705 Build complete');
|
|
270
|
+
fixed++;
|
|
271
|
+
} catch {
|
|
272
|
+
console.log(' \u274c Build failed');
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
console.log(' Install: npm install -g @sdsrs/code-graph');
|
|
276
|
+
console.log(' Or download from: https://github.com/sdsrss/code-graph-mcp/releases');
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case 'binary-not-exec': {
|
|
282
|
+
const binary = findBinary();
|
|
283
|
+
if (binary) {
|
|
284
|
+
try {
|
|
285
|
+
fs.chmodSync(binary, 0o755);
|
|
286
|
+
console.log(`\n \u2705 Fixed permissions: chmod +x ${binary}`);
|
|
287
|
+
fixed++;
|
|
288
|
+
} catch {
|
|
289
|
+
console.log(`\n \u274c Could not fix permissions: ${binary}`);
|
|
290
|
+
}
|
|
291
|
+
if (os.platform() === 'darwin') {
|
|
292
|
+
console.log(` Also try: xattr -d com.apple.quarantine "${binary}"`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case 'index-empty': {
|
|
299
|
+
const binary = findBinary();
|
|
300
|
+
if (binary) {
|
|
301
|
+
console.log('\n Rebuilding index...');
|
|
302
|
+
console.log(' \u2192 code-graph-mcp incremental-index');
|
|
303
|
+
try {
|
|
304
|
+
execFileSync(binary, ['incremental-index'], {
|
|
305
|
+
cwd: process.cwd(),
|
|
306
|
+
stdio: 'inherit',
|
|
307
|
+
timeout: 120000,
|
|
308
|
+
});
|
|
309
|
+
console.log(' \u2705 Index rebuilt');
|
|
310
|
+
fixed++;
|
|
311
|
+
} catch {
|
|
312
|
+
console.log(' \u274c Index rebuild failed');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case 'update-incomplete': {
|
|
319
|
+
console.log('\n Completing auto-update...');
|
|
320
|
+
try {
|
|
321
|
+
execFileSync(process.execPath, [path.join(__dirname, 'auto-update.js'), 'check'], {
|
|
322
|
+
timeout: 60000,
|
|
323
|
+
stdio: 'inherit',
|
|
324
|
+
});
|
|
325
|
+
console.log(' \u2705 Update check complete');
|
|
326
|
+
fixed++;
|
|
327
|
+
} catch {
|
|
328
|
+
console.log(' \u274c Update check failed');
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
case 'hooks-invalid': {
|
|
334
|
+
console.log('\n Repairing hooks...');
|
|
335
|
+
const { install } = require('./lifecycle');
|
|
336
|
+
install();
|
|
337
|
+
console.log(' \u2705 Hooks repaired');
|
|
338
|
+
fixed++;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
case 'hooks-cache-stale': {
|
|
343
|
+
console.log('\n Clearing stale plugin cache hooks.json...');
|
|
344
|
+
const cleared = clearStalePluginCacheHooks();
|
|
345
|
+
console.log(` \u2705 Cleared ${cleared.length} file(s) — restart Claude Code to apply`);
|
|
346
|
+
for (const p of cleared) console.log(` - ${p}`);
|
|
347
|
+
fixed++;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case 'schema-mismatch': {
|
|
352
|
+
console.log('\n Schema migration happens automatically when the binary runs.');
|
|
353
|
+
console.log(' If binary is older than DB, update the binary first.');
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
default:
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return fixed;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Main ──────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
function runDoctor(opts = {}) {
|
|
367
|
+
const results = runDiagnostics();
|
|
368
|
+
console.log(formatReport(results));
|
|
369
|
+
|
|
370
|
+
const issues = results.filter(r => r.status === 'warn' || r.status === 'error');
|
|
371
|
+
|
|
372
|
+
if (issues.length > 0 && !opts.checkOnly) {
|
|
373
|
+
const fixed = runRepairs(results);
|
|
374
|
+
console.log(`\n ${fixed}/${issues.length} issue(s) addressed.`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return { results, issueCount: issues.length };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor };
|
|
381
|
+
|
|
382
|
+
if (require.main === module) {
|
|
383
|
+
const args = process.argv.slice(2);
|
|
384
|
+
const checkOnly = args.includes('--check-only');
|
|
385
|
+
const { issueCount } = runDoctor({ checkOnly });
|
|
386
|
+
process.exit(issueCount > 0 ? 1 : 0);
|
|
387
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
|
|
5
|
+
const { runDiagnostics, formatReport } = require('./doctor');
|
|
6
|
+
|
|
7
|
+
test('runDiagnostics returns an array of check results', () => {
|
|
8
|
+
const results = runDiagnostics();
|
|
9
|
+
assert.ok(Array.isArray(results));
|
|
10
|
+
assert.ok(results.length > 0, 'should have at least one check result');
|
|
11
|
+
for (const r of results) {
|
|
12
|
+
assert.equal(typeof r.name, 'string');
|
|
13
|
+
assert.ok(['ok', 'warn', 'error', 'skip'].includes(r.status));
|
|
14
|
+
assert.equal(typeof r.detail, 'string');
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('formatReport produces readable output', () => {
|
|
19
|
+
const results = [
|
|
20
|
+
{ name: 'Binary version', status: 'ok', detail: 'v0.7.16' },
|
|
21
|
+
{ name: 'Source fresh', status: 'warn', detail: 'src/ modified 3min after binary', fixId: 'binary-stale' },
|
|
22
|
+
{ name: 'Schema', status: 'ok', detail: 'v6' },
|
|
23
|
+
];
|
|
24
|
+
const output = formatReport(results);
|
|
25
|
+
assert.ok(output.includes('Binary version'));
|
|
26
|
+
assert.ok(output.includes('v0.7.16'));
|
|
27
|
+
assert.ok(output.includes('Source fresh'));
|
|
28
|
+
assert.ok(output.includes('3min'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('formatReport shows issue count when problems exist', () => {
|
|
32
|
+
const results = [
|
|
33
|
+
{ name: 'Test', status: 'warn', detail: 'problem', fixId: 'test-fix' },
|
|
34
|
+
];
|
|
35
|
+
const output = formatReport(results);
|
|
36
|
+
assert.ok(output.includes('1'));
|
|
37
|
+
assert.ok(output.includes('issue'));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('formatReport shows all-clear when no problems', () => {
|
|
41
|
+
const results = [
|
|
42
|
+
{ name: 'Binary version', status: 'ok', detail: 'v0.7.16' },
|
|
43
|
+
{ name: 'Schema', status: 'ok', detail: 'v6' },
|
|
44
|
+
];
|
|
45
|
+
const output = formatReport(results);
|
|
46
|
+
assert.ok(output.includes('All checks passed') || output.includes('0 issues'));
|
|
47
|
+
});
|
|
@@ -220,6 +220,88 @@ function migrateOldPluginIds(settings) {
|
|
|
220
220
|
return changed;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
// --- Plugin-cache hooks.json guard ---
|
|
224
|
+
// Claude Code loads hooks from TWO places: settings.json AND the plugin cache
|
|
225
|
+
// at ~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json. If both have
|
|
226
|
+
// our hooks, every event fires twice. We register to settings.json (reliable),
|
|
227
|
+
// so cache copies must stay empty. Auto-updates can re-populate cache hooks.json
|
|
228
|
+
// from the marketplace source — this scan+clear runs on every install/update and
|
|
229
|
+
// every SessionStart (via session-init.js) as a second layer of defense.
|
|
230
|
+
|
|
231
|
+
const EMPTY_HOOKS_STUB = Object.freeze({
|
|
232
|
+
description: 'code-graph-mcp hooks',
|
|
233
|
+
_note: 'Hooks are registered to ~/.claude/settings.json by lifecycle.js. Cleared automatically to prevent double-firing.',
|
|
234
|
+
hooks: {},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
function isOurPluginMarketplace(mpDir) {
|
|
238
|
+
try {
|
|
239
|
+
const meta = readJson(path.join(mpDir, '.claude-plugin', 'marketplace.json'));
|
|
240
|
+
if (meta && meta.name === MARKETPLACE_NAME) return true;
|
|
241
|
+
} catch { /* fallthrough */ }
|
|
242
|
+
return path.basename(mpDir) === MARKETPLACE_NAME;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function scanPluginHooksJsonCopies() {
|
|
246
|
+
const HOME = os.homedir();
|
|
247
|
+
const paths = [];
|
|
248
|
+
|
|
249
|
+
// Marketplace source (git-cloned by Claude Code on install)
|
|
250
|
+
const mpRoot = path.join(HOME, '.claude', 'plugins', 'marketplaces');
|
|
251
|
+
try {
|
|
252
|
+
for (const name of fs.readdirSync(mpRoot)) {
|
|
253
|
+
const mpDir = path.join(mpRoot, name);
|
|
254
|
+
try { if (!fs.statSync(mpDir).isDirectory()) continue; } catch { continue; }
|
|
255
|
+
if (!isOurPluginMarketplace(mpDir)) continue;
|
|
256
|
+
const p = path.join(mpDir, 'claude-plugin', 'hooks', 'hooks.json');
|
|
257
|
+
if (fs.existsSync(p)) paths.push(p);
|
|
258
|
+
}
|
|
259
|
+
} catch { /* no marketplaces dir */ }
|
|
260
|
+
|
|
261
|
+
// Cache (what Claude Code actually loads at runtime), per plugin + per version
|
|
262
|
+
const cacheRoot = path.join(HOME, '.claude', 'plugins', 'cache', MARKETPLACE_NAME);
|
|
263
|
+
try {
|
|
264
|
+
for (const pluginName of fs.readdirSync(cacheRoot)) {
|
|
265
|
+
const pluginDir = path.join(cacheRoot, pluginName);
|
|
266
|
+
try { if (!fs.statSync(pluginDir).isDirectory()) continue; } catch { continue; }
|
|
267
|
+
for (const ver of fs.readdirSync(pluginDir)) {
|
|
268
|
+
const verDir = path.join(pluginDir, ver);
|
|
269
|
+
try { if (!fs.statSync(verDir).isDirectory()) continue; } catch { continue; }
|
|
270
|
+
const p = path.join(verDir, 'hooks', 'hooks.json');
|
|
271
|
+
if (fs.existsSync(p)) paths.push(p);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch { /* no cache dir */ }
|
|
275
|
+
|
|
276
|
+
return paths;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function findStalePluginHooksJson() {
|
|
280
|
+
const stale = [];
|
|
281
|
+
for (const p of scanPluginHooksJsonCopies()) {
|
|
282
|
+
try {
|
|
283
|
+
const cur = readJson(p);
|
|
284
|
+
if (cur && cur.hooks && typeof cur.hooks === 'object' && Object.keys(cur.hooks).length > 0) {
|
|
285
|
+
stale.push(p);
|
|
286
|
+
}
|
|
287
|
+
} catch { /* unreadable — skip */ }
|
|
288
|
+
}
|
|
289
|
+
return stale;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clearStalePluginCacheHooks() {
|
|
293
|
+
const cleared = [];
|
|
294
|
+
const stamp = new Date().toISOString();
|
|
295
|
+
for (const p of findStalePluginHooksJson()) {
|
|
296
|
+
try {
|
|
297
|
+
const stub = { ...EMPTY_HOOKS_STUB, _note: `${EMPTY_HOOKS_STUB._note} (cleared ${stamp})` };
|
|
298
|
+
writeJsonAtomic(p, stub);
|
|
299
|
+
cleared.push(p);
|
|
300
|
+
} catch { /* write failure — skip */ }
|
|
301
|
+
}
|
|
302
|
+
return cleared;
|
|
303
|
+
}
|
|
304
|
+
|
|
223
305
|
// --- Hook Registration ---
|
|
224
306
|
// Plugin system's hooks.json auto-loading is unreliable (observed across GSD,
|
|
225
307
|
// superpowers, code-graph-mcp). Write hooks directly to settings.json instead.
|
|
@@ -366,13 +448,17 @@ function install() {
|
|
|
366
448
|
writeJsonAtomic(SETTINGS_PATH, settings);
|
|
367
449
|
}
|
|
368
450
|
|
|
451
|
+
// 3b. Clear cache/marketplace hooks.json copies after settings.json is authoritative,
|
|
452
|
+
// so next session only fires hooks from settings.json (no double-firing).
|
|
453
|
+
const clearedHookCopies = clearStalePluginCacheHooks();
|
|
454
|
+
|
|
369
455
|
// 4. Write manifest with version
|
|
370
456
|
manifest.version = version;
|
|
371
457
|
manifest.installedAt = manifest.installedAt || new Date().toISOString();
|
|
372
458
|
manifest.updatedAt = new Date().toISOString();
|
|
373
459
|
writeManifest(manifest);
|
|
374
460
|
|
|
375
|
-
return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine };
|
|
461
|
+
return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, clearedHookCopies };
|
|
376
462
|
}
|
|
377
463
|
|
|
378
464
|
// --- Uninstall (clean all config) ---
|
|
@@ -475,6 +561,10 @@ function update() {
|
|
|
475
561
|
writeJsonAtomic(SETTINGS_PATH, settings);
|
|
476
562
|
}
|
|
477
563
|
|
|
564
|
+
// 4b. Clear cache/marketplace hooks.json copies after settings.json is updated.
|
|
565
|
+
// Auto-update can re-populate cache from marketplace source; stamp it out.
|
|
566
|
+
const clearedHookCopies = clearStalePluginCacheHooks();
|
|
567
|
+
|
|
478
568
|
// 5. Clear update-check cache (force re-check after update)
|
|
479
569
|
const updateCache = path.join(CACHE_DIR, 'update-check');
|
|
480
570
|
try { fs.unlinkSync(updateCache); } catch { /* ok */ }
|
|
@@ -487,7 +577,7 @@ function update() {
|
|
|
487
577
|
// 7. Clean up old cached versions (keep latest 3)
|
|
488
578
|
cleanupOldCacheVersions(3);
|
|
489
579
|
|
|
490
|
-
return { oldVersion, version, settingsChanged };
|
|
580
|
+
return { oldVersion, version, settingsChanged, clearedHookCopies };
|
|
491
581
|
}
|
|
492
582
|
|
|
493
583
|
/**
|
|
@@ -584,6 +674,7 @@ module.exports = {
|
|
|
584
674
|
readRegistry, writeRegistry,
|
|
585
675
|
getPluginVersion, cleanupOldCacheVersions,
|
|
586
676
|
registerHooksToSettings, removeHooksFromSettings, getHookDefinitions,
|
|
677
|
+
scanPluginHooksJsonCopies, findStalePluginHooksJson, clearStalePluginCacheHooks,
|
|
587
678
|
PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
|
|
588
679
|
};
|
|
589
680
|
|
|
@@ -609,8 +700,13 @@ if (require.main === module) {
|
|
|
609
700
|
console.log(` ${issue.type}: ${issue.path || issue.id}`);
|
|
610
701
|
}
|
|
611
702
|
}
|
|
703
|
+
} else if (cmd === 'doctor') {
|
|
704
|
+
const { runDoctor } = require('./doctor');
|
|
705
|
+
const checkOnly = process.argv.includes('--check-only');
|
|
706
|
+
const { issueCount } = runDoctor({ checkOnly });
|
|
707
|
+
process.exit(issueCount > 0 ? 1 : 0);
|
|
612
708
|
} else {
|
|
613
|
-
console.error('Usage: lifecycle.js <install|uninstall|update|health>');
|
|
709
|
+
console.error('Usage: lifecycle.js <install|uninstall|update|health|doctor>');
|
|
614
710
|
process.exit(1);
|
|
615
711
|
}
|
|
616
712
|
}
|
|
@@ -94,4 +94,82 @@ test('cleanupDisabledStatusline also heals orphaned statusline after uninstall',
|
|
|
94
94
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
95
95
|
assert.equal(settings.statusLine.command, 'echo previous-status');
|
|
96
96
|
assert.equal(fs.existsSync(registryPath), false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function nonEmptyHooksJson() {
|
|
100
|
+
return {
|
|
101
|
+
hooks: {
|
|
102
|
+
SessionStart: [{
|
|
103
|
+
matcher: 'startup',
|
|
104
|
+
hooks: [{ type: 'command', command: 'node "/plugin/session-init.js"' }],
|
|
105
|
+
}],
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test('findStalePluginHooksJson detects non-empty cache and marketplace copies', () => {
|
|
111
|
+
const homeDir = mkHome();
|
|
112
|
+
const mpHooks = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'code-graph-mcp', 'claude-plugin', 'hooks', 'hooks.json');
|
|
113
|
+
const mpManifest = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'code-graph-mcp', '.claude-plugin', 'marketplace.json');
|
|
114
|
+
const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
|
|
115
|
+
|
|
116
|
+
writeJson(mpManifest, { name: 'code-graph-mcp' });
|
|
117
|
+
writeJson(mpHooks, nonEmptyHooksJson());
|
|
118
|
+
writeJson(cacheHooks, nonEmptyHooksJson());
|
|
119
|
+
|
|
120
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
121
|
+
const { findStalePluginHooksJson } = require(${JSON.stringify(lifecyclePath)});
|
|
122
|
+
process.stdout.write(JSON.stringify(findStalePluginHooksJson()));
|
|
123
|
+
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
124
|
+
|
|
125
|
+
const stale = JSON.parse(out).sort();
|
|
126
|
+
assert.equal(stale.length, 2);
|
|
127
|
+
assert.ok(stale.some(p => p === mpHooks));
|
|
128
|
+
assert.ok(stale.some(p => p === cacheHooks));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('clearStalePluginCacheHooks empties non-empty hooks.json copies', () => {
|
|
132
|
+
const homeDir = mkHome();
|
|
133
|
+
const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
|
|
134
|
+
writeJson(cacheHooks, nonEmptyHooksJson());
|
|
135
|
+
|
|
136
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
137
|
+
const { clearStalePluginCacheHooks } = require(${JSON.stringify(lifecyclePath)});
|
|
138
|
+
process.stdout.write(JSON.stringify(clearStalePluginCacheHooks()));
|
|
139
|
+
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
140
|
+
|
|
141
|
+
const cleared = JSON.parse(out);
|
|
142
|
+
assert.deepEqual(cleared, [cacheHooks]);
|
|
143
|
+
|
|
144
|
+
const payload = JSON.parse(fs.readFileSync(cacheHooks, 'utf8'));
|
|
145
|
+
assert.deepEqual(payload.hooks, {});
|
|
146
|
+
assert.ok(payload._note && payload._note.includes('cleared'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('clearStalePluginCacheHooks is idempotent and skips already-empty copies', () => {
|
|
150
|
+
const homeDir = mkHome();
|
|
151
|
+
const cacheHooks = path.join(homeDir, '.claude', 'plugins', 'cache', 'code-graph-mcp', 'code-graph-mcp', '0.7.17', 'hooks', 'hooks.json');
|
|
152
|
+
writeJson(cacheHooks, { hooks: {} });
|
|
153
|
+
|
|
154
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
155
|
+
const { clearStalePluginCacheHooks } = require(${JSON.stringify(lifecyclePath)});
|
|
156
|
+
process.stdout.write(JSON.stringify(clearStalePluginCacheHooks()));
|
|
157
|
+
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(JSON.parse(out), []);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('scanPluginHooksJsonCopies ignores unrelated marketplaces', () => {
|
|
163
|
+
const homeDir = mkHome();
|
|
164
|
+
const otherMp = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'some-other-plugin', 'claude-plugin', 'hooks', 'hooks.json');
|
|
165
|
+
const otherManifest = path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'some-other-plugin', '.claude-plugin', 'marketplace.json');
|
|
166
|
+
writeJson(otherManifest, { name: 'some-other-plugin' });
|
|
167
|
+
writeJson(otherMp, nonEmptyHooksJson());
|
|
168
|
+
|
|
169
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
170
|
+
const { scanPluginHooksJsonCopies } = require(${JSON.stringify(lifecyclePath)});
|
|
171
|
+
process.stdout.write(JSON.stringify(scanPluginHooksJsonCopies()));
|
|
172
|
+
`], { env: { ...process.env, HOME: homeDir } }).toString();
|
|
173
|
+
|
|
174
|
+
assert.deepEqual(JSON.parse(out), []);
|
|
97
175
|
});
|