@viloforge/vfkb 0.2.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/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/args.js +82 -0
- package/dist/backend.js +115 -0
- package/dist/bootstrap.test.js +64 -0
- package/dist/bundles/vfkb-mcp.mjs +32139 -0
- package/dist/bundles/vfkb.mjs +17583 -0
- package/dist/bundles.test.js +177 -0
- package/dist/cli.js +635 -0
- package/dist/counters.js +61 -0
- package/dist/curator.js +87 -0
- package/dist/distiller.js +116 -0
- package/dist/doctor.js +479 -0
- package/dist/doctor.test.js +179 -0
- package/dist/engine.js +668 -0
- package/dist/env-compat.test.js +23 -0
- package/dist/export.js +371 -0
- package/dist/gating.js +34 -0
- package/dist/git.js +43 -0
- package/dist/import.js +99 -0
- package/dist/import.test.js +54 -0
- package/dist/index-store.js +101 -0
- package/dist/init.js +264 -0
- package/dist/init.test.js +148 -0
- package/dist/lock.js +158 -0
- package/dist/manifest.js +38 -0
- package/dist/mcp-server.js +213 -0
- package/dist/pi-extension.js +117 -0
- package/dist/pi-mcp-bridge.js +94 -0
- package/dist/pi-types.js +6 -0
- package/dist/read.js +112 -0
- package/dist/secrets.js +36 -0
- package/dist/session-end.js +183 -0
- package/dist/session-end.test.js +149 -0
- package/dist/session.js +157 -0
- package/dist/stop-reminder.js +130 -0
- package/dist/storage.js +145 -0
- package/dist/types.js +6 -0
- package/dist/validate.js +83 -0
- package/dist/version.js +32 -0
- package/package.json +51 -0
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// FR-4 (ADR-0030) — `vfkb doctor`: catches the failure modes a consumer trips
|
|
2
|
+
// over before they corrupt a brain — an incompatible/stale engine binding, missing
|
|
3
|
+
// or inconsistent wiring, and the dual-clone drift signal (a brain last stamped by
|
|
4
|
+
// a different engine build). Deterministic; unit-tested (the inner gate per ADR-0023).
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { SCHEMA_VERSION, ENGINE_VERSION, ENGINE_COMMIT } from './version.js';
|
|
9
|
+
import { readManifest } from './manifest.js';
|
|
10
|
+
function readJson(path) {
|
|
11
|
+
if (!existsSync(path))
|
|
12
|
+
return undefined;
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function projectFromSettings(settings) {
|
|
21
|
+
const blob = JSON.stringify(settings ?? '');
|
|
22
|
+
const m = blob.match(/VFKB_PROJECT=([^\s"\\]+)/);
|
|
23
|
+
return m?.[1];
|
|
24
|
+
}
|
|
25
|
+
function detectPluginWiring(settings, root, pluginsFile) {
|
|
26
|
+
const registry = pluginsFile ? readJson(pluginsFile) : undefined;
|
|
27
|
+
// Prefer the install entry for THIS root; fall back to a user-scope entry; never
|
|
28
|
+
// report another project's install as ours (it would mask the not-installed WARN
|
|
29
|
+
// and can carry the wrong version).
|
|
30
|
+
const findInstall = (key) => {
|
|
31
|
+
const entries = registry?.plugins?.[key];
|
|
32
|
+
if (!Array.isArray(entries))
|
|
33
|
+
return undefined;
|
|
34
|
+
return entries.find((e) => e?.projectPath === root) ?? entries.find((e) => e?.scope === 'user');
|
|
35
|
+
};
|
|
36
|
+
const enabled = settings?.enabledPlugins ?? {};
|
|
37
|
+
for (const [key, on] of Object.entries(enabled)) {
|
|
38
|
+
if (/^vfkb@/.test(key) && on) {
|
|
39
|
+
return { key, installed: findInstall(key), registryReadable: registry !== undefined };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Registry fallback (user-scope enablement never shows in the project file) — but an
|
|
43
|
+
// EXPLICIT `false` in the project settings is a deliberate disable and always wins:
|
|
44
|
+
// a lingering registry entry must not make doctor advise dismantling working
|
|
45
|
+
// fallback wiring (the #77 failure class, inverted).
|
|
46
|
+
for (const [key, entries] of Object.entries(registry?.plugins ?? {})) {
|
|
47
|
+
if (!/^vfkb@/.test(key) || !Array.isArray(entries))
|
|
48
|
+
continue;
|
|
49
|
+
if (enabled[key] === false)
|
|
50
|
+
continue;
|
|
51
|
+
const forRoot = entries.find((e) => e?.projectPath === root);
|
|
52
|
+
if (forRoot)
|
|
53
|
+
return { key, installed: forRoot, registryReadable: true };
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
// Claude Code relocates its ENTIRE config dir via CLAUDE_CONFIG_DIR, `plugins/`
|
|
58
|
+
// included. Deriving the registry from $HOME alone makes doctor inspect HOST
|
|
59
|
+
// state even when pointed at a sandbox — observed 2026-07-09, and a prerequisite
|
|
60
|
+
// for any currency check (RFC-024 §1).
|
|
61
|
+
function claudeConfigDir(env) {
|
|
62
|
+
if (env.CLAUDE_CONFIG_DIR)
|
|
63
|
+
return env.CLAUDE_CONFIG_DIR;
|
|
64
|
+
return env.HOME ? join(env.HOME, '.claude') : undefined;
|
|
65
|
+
}
|
|
66
|
+
// Read the clone's checked-out sha WITHOUT shelling git: `ls-remote` must be the
|
|
67
|
+
// only subcommand this module ever issues, so that "the diagnostic does not
|
|
68
|
+
// write" is observable rather than promised.
|
|
69
|
+
function localHeadSha(cloneDir) {
|
|
70
|
+
const head = readFileMaybe(join(cloneDir, '.git', 'HEAD'));
|
|
71
|
+
if (!head)
|
|
72
|
+
return undefined;
|
|
73
|
+
const ref = head.trim().match(/^ref:\s*(\S+)$/)?.[1];
|
|
74
|
+
if (!ref)
|
|
75
|
+
return /^[0-9a-f]{40}$/.test(head.trim()) ? head.trim() : undefined; // detached HEAD
|
|
76
|
+
const loose = readFileMaybe(join(cloneDir, '.git', ...ref.split('/')));
|
|
77
|
+
if (loose)
|
|
78
|
+
return loose.trim();
|
|
79
|
+
const packed = readFileMaybe(join(cloneDir, '.git', 'packed-refs'));
|
|
80
|
+
for (const line of packed?.split('\n') ?? []) {
|
|
81
|
+
const [sha, name] = line.trim().split(/\s+/);
|
|
82
|
+
if (name === ref)
|
|
83
|
+
return sha;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
function readFileMaybe(path) {
|
|
88
|
+
try {
|
|
89
|
+
return readFileSync(path, 'utf8');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Read-only, short-timeout. `git fetch` is rejected: it writes refs and objects
|
|
96
|
+
// into the user's clone and can contend on the repo lock with a running Claude
|
|
97
|
+
// Code. A diagnostic must not write.
|
|
98
|
+
const realGit = (args, cwd) => execFileSync('git', args, {
|
|
99
|
+
cwd,
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
timeout: 5000,
|
|
102
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
103
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -oBatchMode=yes' },
|
|
104
|
+
});
|
|
105
|
+
/**
|
|
106
|
+
* Plugin currency (RFC-024 §1) — the check that would have caught the 2026-07-09
|
|
107
|
+
* incident: the plugin was correctly packaged and correctly installed, but the
|
|
108
|
+
* operator's MARKETPLACE CLONE never advanced, so `plugin update` had nothing
|
|
109
|
+
* newer to install and nothing said so.
|
|
110
|
+
*
|
|
111
|
+
* One axis: compare the clone's checked-out sha to its remote's HEAD via
|
|
112
|
+
* `git ls-remote` (read-only). Behind → warn, naming the remedy. Offline,
|
|
113
|
+
* unreachable, no clone, or a directory-source marketplace → skip, NEVER fail.
|
|
114
|
+
* A detector nobody runs detects nothing, so it is attempted by default with a
|
|
115
|
+
* short timeout.
|
|
116
|
+
*
|
|
117
|
+
* Returns the verdict rather than emitting it, so the install-state line above
|
|
118
|
+
* can say whether currency was compared instead of asserting it was not.
|
|
119
|
+
*/
|
|
120
|
+
function checkCurrency(plugin, opts, configDir) {
|
|
121
|
+
const marketplace = plugin.key.split('@')[1];
|
|
122
|
+
const kmFile = opts.knownMarketplacesFile ?? (configDir ? join(configDir, 'plugins', 'known_marketplaces.json') : undefined);
|
|
123
|
+
const entry = kmFile ? readJson(kmFile)?.[marketplace] : undefined;
|
|
124
|
+
const skip = (detail) => ({ status: 'skip', detail });
|
|
125
|
+
if (!entry)
|
|
126
|
+
return skip(`no marketplace "${marketplace}" in ${kmFile ?? 'the plugin registry'} — cannot check currency`);
|
|
127
|
+
// A directory source records the path itself and creates no clone — there is
|
|
128
|
+
// nothing that can be stale.
|
|
129
|
+
if (entry.source?.source !== 'github') {
|
|
130
|
+
return skip(`marketplace "${marketplace}" is a ${entry.source?.source ?? 'unknown'}-source — no clone to compare`);
|
|
131
|
+
}
|
|
132
|
+
if (!entry.installLocation || !existsSync(join(entry.installLocation, '.git'))) {
|
|
133
|
+
return skip(`marketplace clone not found at ${entry.installLocation ?? '(unset installLocation)'}`);
|
|
134
|
+
}
|
|
135
|
+
const clone = entry.installLocation;
|
|
136
|
+
const local = localHeadSha(clone);
|
|
137
|
+
let remote;
|
|
138
|
+
try {
|
|
139
|
+
remote = (opts.git ?? realGit)(['ls-remote', 'origin', 'HEAD'], clone).trim().split(/\s+/)[0];
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
remote = undefined;
|
|
143
|
+
}
|
|
144
|
+
if (!local)
|
|
145
|
+
return skip(`cannot read the checked-out revision of ${clone}`);
|
|
146
|
+
// Offline is the common case. Say so plainly: vfkb cannot tell you that you
|
|
147
|
+
// are running old code, and must not imply health it did not verify.
|
|
148
|
+
if (!remote || !/^[0-9a-f]{7,40}$/.test(remote)) {
|
|
149
|
+
return skip(`remote unreachable (offline?) — cannot tell whether ${marketplace} is current`);
|
|
150
|
+
}
|
|
151
|
+
if (remote === local) {
|
|
152
|
+
// Answer the reader's question, but only as far as the check actually goes.
|
|
153
|
+
//
|
|
154
|
+
// An earlier version said "you are running the newest published version".
|
|
155
|
+
// That is axis (b) — installed-vs-offered — which RFC-024 §1 explicitly
|
|
156
|
+
// GATES and this code never performs. All that is compared here is the
|
|
157
|
+
// marketplace clone against its remote (axis (a)). In the half-upgraded
|
|
158
|
+
// `--scope user` state the RFC documents, the clone is level while the
|
|
159
|
+
// INSTALL lags, and that wording would have told an operator running old
|
|
160
|
+
// code that they were current. It made the L4's contrast arm clean by being
|
|
161
|
+
// more confident, not by being more true.
|
|
162
|
+
//
|
|
163
|
+
// So: state the clone's currency plainly, and name the limit in the same
|
|
164
|
+
// breath. The stale branch says "you are running an old copy" because in
|
|
165
|
+
// that state the clone cannot be ahead of itself; the healthy branch cannot
|
|
166
|
+
// honestly say the converse about the install.
|
|
167
|
+
return {
|
|
168
|
+
status: 'ok',
|
|
169
|
+
detail: `${marketplace} marketplace clone is CURRENT — level with its remote (${local.slice(0, 7)}), so ` +
|
|
170
|
+
`\`claude plugin update\` will find nothing newer to install from it. Note: this compares the clone ` +
|
|
171
|
+
`to its remote; it does not compare your INSTALLED version (${plugin.installed?.version ?? 'unknown'}) ` +
|
|
172
|
+
`against what the clone offers.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
status: 'warn',
|
|
177
|
+
detail: `${marketplace} marketplace clone is STALE — it sits at ${local.slice(0, 7)} but the remote is at ` +
|
|
178
|
+
`${remote.slice(0, 7)}. You are running an old copy of the plugin, and \`claude plugin update\` ` +
|
|
179
|
+
`will find nothing newer until the clone advances. Remedy: ` +
|
|
180
|
+
`\`claude plugin marketplace update ${marketplace}\` then \`claude plugin update ${plugin.key}\`, ` +
|
|
181
|
+
`then restart Claude Code.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* npm currency (ADR-0058 / RFC-030) — the npm-channel sibling of `checkCurrency`
|
|
186
|
+
* above. Opt-in ONLY (the CLI calls this solely under `--check-remote`; plain
|
|
187
|
+
* `runDoctor` never touches it, so plain `doctor` stays fully offline with zero
|
|
188
|
+
* network calls — verified by the "plain doctor" unit tests and by diffing
|
|
189
|
+
* `vfkb doctor`'s output against pre-ADR-0058 `main`).
|
|
190
|
+
*
|
|
191
|
+
* Axis-(b) wording discipline (the meta-lesson behind this whole ADR): the
|
|
192
|
+
* healthy line says exactly what was compared — the RUNNING CLI's version
|
|
193
|
+
* against the npmjs `latest` dist-tag for @viloforge/vfkb — and nothing more.
|
|
194
|
+
* Never "you are up to date". A unit test pins this with a positive regex (the
|
|
195
|
+
* comparison claim) and a negative regex (forbidden overclaim phrases).
|
|
196
|
+
*/
|
|
197
|
+
const NPM_PACKAGE_NAME = '@viloforge/vfkb';
|
|
198
|
+
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@viloforge%2Fvfkb/latest';
|
|
199
|
+
const NPM_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
200
|
+
const NPM_CACHE_FILE = 'npm-currency-cache.json';
|
|
201
|
+
function npmCacheFilePath(opts) {
|
|
202
|
+
return opts.cacheFile ?? join(opts.brainDir, '.signals', NPM_CACHE_FILE);
|
|
203
|
+
}
|
|
204
|
+
// Corrupt or missing cache is treated as absent, silently — never an error
|
|
205
|
+
// surfaced to the user (RFC-030 §2 cache design).
|
|
206
|
+
function readNpmCache(path) {
|
|
207
|
+
const raw = readJson(path);
|
|
208
|
+
if (!raw || typeof raw.version !== 'string' || typeof raw.fetchedAt !== 'string')
|
|
209
|
+
return undefined;
|
|
210
|
+
if (!Number.isFinite(Date.parse(raw.fetchedAt)))
|
|
211
|
+
return undefined;
|
|
212
|
+
return { version: raw.version, fetchedAt: raw.fetchedAt };
|
|
213
|
+
}
|
|
214
|
+
function writeNpmCache(path, entry) {
|
|
215
|
+
try {
|
|
216
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
217
|
+
writeFileSync(path, JSON.stringify(entry));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Best-effort — a failed cache write must never fail the check itself.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Small, dependency-free semver-ish comparator: numeric major.minor.patch,
|
|
224
|
+
// with a release ranking above any pre-release of the same core (so 1.0.0 >
|
|
225
|
+
// 1.0.0-beta.1). Sufficient for comparing two npm-published version strings;
|
|
226
|
+
// not a full semver implementation.
|
|
227
|
+
function parseSemverish(v) {
|
|
228
|
+
const [core, ...preParts] = v.replace(/^v/, '').split('-');
|
|
229
|
+
const nums = core.split('.').map((n) => parseInt(n, 10));
|
|
230
|
+
while (nums.length < 3)
|
|
231
|
+
nums.push(0);
|
|
232
|
+
return { core: nums.map((n) => (Number.isFinite(n) ? n : 0)), pre: preParts.join('-') };
|
|
233
|
+
}
|
|
234
|
+
function compareVersions(a, b) {
|
|
235
|
+
const pa = parseSemverish(a);
|
|
236
|
+
const pb = parseSemverish(b);
|
|
237
|
+
for (let i = 0; i < 3; i++) {
|
|
238
|
+
if (pa.core[i] !== pb.core[i])
|
|
239
|
+
return pa.core[i] - pb.core[i];
|
|
240
|
+
}
|
|
241
|
+
if (pa.pre === pb.pre)
|
|
242
|
+
return 0;
|
|
243
|
+
if (!pa.pre)
|
|
244
|
+
return 1; // a is a release, b is a pre-release of the same core
|
|
245
|
+
if (!pb.pre)
|
|
246
|
+
return -1;
|
|
247
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
248
|
+
}
|
|
249
|
+
// The comparison + wording (shared by the cache-hit and live-fetch paths — the
|
|
250
|
+
// data source suffix is the only thing that differs between them).
|
|
251
|
+
function renderCurrencyVerdict(installed, latest, sourceSuffix) {
|
|
252
|
+
const cmp = compareVersions(installed, latest);
|
|
253
|
+
if (cmp === 0) {
|
|
254
|
+
return {
|
|
255
|
+
status: 'ok',
|
|
256
|
+
detail: `installed ${installed} matches the npmjs latest dist-tag (${latest}) ${sourceSuffix}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (cmp < 0) {
|
|
260
|
+
return {
|
|
261
|
+
status: 'warn',
|
|
262
|
+
detail: `installed ${installed}; npmjs latest dist-tag is ${latest} — a newer version is ` +
|
|
263
|
+
`published. Remedy: npm i -g ${NPM_PACKAGE_NAME}@latest ${sourceSuffix}`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// Ahead: installed > latest — the normal state right after cutting a
|
|
267
|
+
// release, before/without npm publish, or on a local dev build. Not a
|
|
268
|
+
// problem, so `ok`, but named plainly rather than folded into the "matches"
|
|
269
|
+
// wording (that would overclaim a comparison result that isn't equality).
|
|
270
|
+
return {
|
|
271
|
+
status: 'ok',
|
|
272
|
+
detail: `installed ${installed} is newer than the npmjs latest dist-tag (${latest}) — normal ` +
|
|
273
|
+
`right after a release ${sourceSuffix}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
export async function checkNpmCurrency(opts) {
|
|
277
|
+
const cachePath = npmCacheFilePath(opts);
|
|
278
|
+
const now = opts.now ?? Date.now;
|
|
279
|
+
const nowMs = now();
|
|
280
|
+
const cached = readNpmCache(cachePath);
|
|
281
|
+
if (cached) {
|
|
282
|
+
const ageMs = nowMs - Date.parse(cached.fetchedAt);
|
|
283
|
+
if (ageMs >= 0 && ageMs < NPM_CACHE_TTL_MS) {
|
|
284
|
+
const ageH = Math.max(0, Math.floor(ageMs / 3600000));
|
|
285
|
+
return renderCurrencyVerdict(opts.installedVersion, cached.version, `(cached ${ageH}h)`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
289
|
+
const timeoutMs = opts.timeoutMs ?? 4000;
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
292
|
+
try {
|
|
293
|
+
const res = await fetchImpl(NPM_REGISTRY_URL, { signal: controller.signal });
|
|
294
|
+
if (res.status === 404) {
|
|
295
|
+
return { status: 'skip', detail: 'skipped (package not on npmjs)' };
|
|
296
|
+
}
|
|
297
|
+
if (!res.ok) {
|
|
298
|
+
return { status: 'skip', detail: 'skipped (registry unreachable)' };
|
|
299
|
+
}
|
|
300
|
+
const body = await res.json();
|
|
301
|
+
const latest = typeof body?.version === 'string' ? body.version : undefined;
|
|
302
|
+
if (!latest) {
|
|
303
|
+
return { status: 'skip', detail: 'skipped (registry unreachable)' };
|
|
304
|
+
}
|
|
305
|
+
writeNpmCache(cachePath, { version: latest, fetchedAt: new Date(nowMs).toISOString() });
|
|
306
|
+
return renderCurrencyVerdict(opts.installedVersion, latest, '(live)');
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return { status: 'skip', detail: 'skipped (registry unreachable)' };
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
export function runDoctor(opts) {
|
|
316
|
+
const { root, brainDir, env } = opts;
|
|
317
|
+
const checks = [];
|
|
318
|
+
const add = (name, status, detail) => checks.push({ name, status, detail });
|
|
319
|
+
// 1. Engine identity (info).
|
|
320
|
+
add('engine', 'ok', `version ${ENGINE_VERSION} · commit ${ENGINE_COMMIT} · schema v${SCHEMA_VERSION}`);
|
|
321
|
+
// Read the project settings once (used by plugin detection, hooks checks, and
|
|
322
|
+
// project-consistency below), and detect ADR-0045 plugin wiring up front — it changes
|
|
323
|
+
// what "healthy" means for the wiring checks AND which advice is safe to give
|
|
324
|
+
// (`vfkb init` on a plugin-wired repo scaffolds double wiring — issue #77).
|
|
325
|
+
const settings = readJson(join(root, '.claude', 'settings.json'));
|
|
326
|
+
const configDir = claudeConfigDir(env);
|
|
327
|
+
const pluginsFile = opts.pluginsFile ?? (configDir ? join(configDir, 'plugins', 'installed_plugins.json') : undefined);
|
|
328
|
+
const plugin = detectPluginWiring(settings, root, pluginsFile);
|
|
329
|
+
// 2. Brain ↔ engine compat (the load-bearing check).
|
|
330
|
+
const mf = readManifest(brainDir);
|
|
331
|
+
if (!mf) {
|
|
332
|
+
add('brain manifest', 'warn', plugin
|
|
333
|
+
? `no manifest.json in ${brainDir} — it will be stamped on the next write`
|
|
334
|
+
: `no manifest.json in ${brainDir} — run \`vfkb init\` (or it will be stamped on next write)`);
|
|
335
|
+
}
|
|
336
|
+
else if (typeof mf.schema_version !== 'number') {
|
|
337
|
+
add('brain manifest', 'warn', 'manifest has no numeric schema_version');
|
|
338
|
+
}
|
|
339
|
+
else if (mf.schema_version > SCHEMA_VERSION) {
|
|
340
|
+
add('brain↔engine compat', 'fail', `brain schema v${mf.schema_version} is NEWER than engine v${SCHEMA_VERSION} — update the engine before using this brain`);
|
|
341
|
+
}
|
|
342
|
+
else if (mf.schema_version < SCHEMA_VERSION) {
|
|
343
|
+
add('brain↔engine compat', 'warn', `brain schema v${mf.schema_version} is older than engine v${SCHEMA_VERSION} — migration may be needed`);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
add('brain↔engine compat', 'ok', `schema v${mf.schema_version} matches`);
|
|
347
|
+
// Drift signal: same schema but a different engine build last stamped the brain.
|
|
348
|
+
if (mf.engine_commit && ENGINE_COMMIT !== 'dev' && mf.engine_commit !== ENGINE_COMMIT) {
|
|
349
|
+
add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${ENGINE_COMMIT} — possible dual-clone drift`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// 3. $VFKB_BUNDLE_DIR resolves the bundles (the portability indirection, FR-2).
|
|
353
|
+
// On a plugin-wired repo the plugin vendors its own engine copy (ADR-0045), so an
|
|
354
|
+
// unset bundle dir is healthy, not a gap.
|
|
355
|
+
const home = env.VFKB_BUNDLE_DIR || env.VFKB_HOME;
|
|
356
|
+
if (!home) {
|
|
357
|
+
if (plugin) {
|
|
358
|
+
add('$VFKB_BUNDLE_DIR', 'ok', 'unset — not needed here: the plugin vendors the engine (ADR-0045)');
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
add('$VFKB_BUNDLE_DIR', 'warn', 'unset — set it once per machine to the vfkb bundles dir (so the wiring resolves the engine)');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else if (!existsSync(join(home, 'vfkb.mjs')) || !existsSync(join(home, 'vfkb-mcp.mjs'))) {
|
|
365
|
+
add('$VFKB_BUNDLE_DIR', 'warn', `set to ${home} but vfkb.mjs / vfkb-mcp.mjs not found there (run \`npm run build:bundles\`)`);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
add('$VFKB_BUNDLE_DIR', 'ok', home);
|
|
369
|
+
}
|
|
370
|
+
// 3b. Deprecated env-var aliases still in use (ADR-0032) — work, but should be renamed.
|
|
371
|
+
if (env.VFKB_DIR && !env.VFKB_DATA_DIR) {
|
|
372
|
+
add('env (deprecated)', 'warn', 'VFKB_DIR is a deprecated alias — rename it to VFKB_DATA_DIR');
|
|
373
|
+
}
|
|
374
|
+
if (env.VFKB_HOME && !env.VFKB_BUNDLE_DIR) {
|
|
375
|
+
add('env (deprecated)', 'warn', 'VFKB_HOME is a deprecated alias — rename it to VFKB_BUNDLE_DIR');
|
|
376
|
+
}
|
|
377
|
+
// 4. MCP wiring. Plugin-wired repos need no .mcp.json (the plugin bundles the MCP
|
|
378
|
+
// server); one present ALONGSIDE the plugin is double wiring — advise removal, never
|
|
379
|
+
// `vfkb init` (issue #77: that advice is what causes the double wiring).
|
|
380
|
+
const mcp = readJson(join(root, '.mcp.json'));
|
|
381
|
+
const mcpProject = mcp?.mcpServers?.vfkb?.env?.VFKB_PROJECT;
|
|
382
|
+
const mcpPresent = Boolean(mcp?.mcpServers?.vfkb);
|
|
383
|
+
if (plugin && mcpPresent) {
|
|
384
|
+
add('.mcp.json', 'warn', `vfkb MCP server registered ALONGSIDE the plugin (double wiring) — remove the .mcp.json vfkb entry; the plugin is primary (ADR-0045)`);
|
|
385
|
+
}
|
|
386
|
+
else if (plugin) {
|
|
387
|
+
add('.mcp.json', 'ok', `not needed — MCP server wired via plugin ${plugin.key} (ADR-0045)`);
|
|
388
|
+
}
|
|
389
|
+
else if (!mcpPresent) {
|
|
390
|
+
add('.mcp.json', 'warn', 'no vfkb MCP server registered — run `vfkb init`');
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
add('.mcp.json', 'ok', `vfkb server present (project ${mcpProject ?? '?'})`);
|
|
394
|
+
}
|
|
395
|
+
// 5. Hooks wiring (same plugin logic as check 4).
|
|
396
|
+
const hooks = settings?.hooks ?? {};
|
|
397
|
+
const expected = ['SessionStart', 'PreToolUse', 'Stop', 'SessionEnd'];
|
|
398
|
+
const have = expected.filter((e) => JSON.stringify(hooks[e] ?? '').includes('vfkb'));
|
|
399
|
+
if (plugin && have.length > 0) {
|
|
400
|
+
add('.claude/settings.json', 'warn', `vfkb hooks present ALONGSIDE the plugin (double wiring) — remove them; the plugin's hooks are primary (ADR-0045)`);
|
|
401
|
+
}
|
|
402
|
+
else if (plugin) {
|
|
403
|
+
add('.claude/settings.json', 'ok', `hooks wired via plugin ${plugin.key} (ADR-0045)`);
|
|
404
|
+
}
|
|
405
|
+
else if (have.length === 0) {
|
|
406
|
+
add('.claude/settings.json', 'warn', 'no vfkb hooks — run `vfkb init`');
|
|
407
|
+
}
|
|
408
|
+
else if (have.length < expected.length) {
|
|
409
|
+
add('.claude/settings.json', 'warn', `only ${have.join(', ')} wired (expected ${expected.join(', ')})`);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
add('.claude/settings.json', 'ok', `${have.join(', ')} wired`);
|
|
413
|
+
}
|
|
414
|
+
// 5a. Hooks must anchor to $CLAUDE_PROJECT_DIR (issue #22 / ADR-0035) — a bare
|
|
415
|
+
// CWD-relative bootstrap path breaks (MODULE_NOT_FOUND) when the session cd's out
|
|
416
|
+
// of the repo root, silently disabling the write-gate and the SessionEnd auto-commit.
|
|
417
|
+
// Skipped on plugin wiring: the plugin owns its hook paths, and any stray fallback
|
|
418
|
+
// hooks are already flagged for removal above — anchoring advice would be moot.
|
|
419
|
+
const hooksBlob = JSON.stringify(hooks ?? '');
|
|
420
|
+
if (!plugin && have.length > 0 && hooksBlob.includes('bootstrap.mjs') && !hooksBlob.includes('CLAUDE_PROJECT_DIR')) {
|
|
421
|
+
add('hooks anchor', 'warn', 'vfkb hooks use a CWD-relative bootstrap path — they break when the session cd\'s out of the repo root; re-run `vfkb init` to anchor them to $CLAUDE_PROJECT_DIR (issue #22)');
|
|
422
|
+
}
|
|
423
|
+
// 5b. The committed bootstrap entry-point (ADR-0031). Fallback wiring only — the
|
|
424
|
+
// plugin resolves its own engine and needs no committed bootstrap.
|
|
425
|
+
if (existsSync(join(root, '.vfkb', 'bin', 'bootstrap.mjs'))) {
|
|
426
|
+
add('bootstrap', 'ok', '.vfkb/bin/bootstrap.mjs present');
|
|
427
|
+
}
|
|
428
|
+
else if (!plugin && (mcpPresent || have.length > 0)) {
|
|
429
|
+
add('bootstrap', 'warn', 'wiring present but .vfkb/bin/bootstrap.mjs is missing — run `vfkb init`');
|
|
430
|
+
}
|
|
431
|
+
// 5c/5d. Plugin install state, and whether that installed copy is CURRENT.
|
|
432
|
+
//
|
|
433
|
+
// These are computed together because they used to contradict each other. The
|
|
434
|
+
// install line said "(informational — currency not compared)" while the very
|
|
435
|
+
// next line compared it. An agent reading the report inferred staleness from
|
|
436
|
+
// the bare version number plus that hedge, and reported a level clone as "an
|
|
437
|
+
// older version" — the sole contrast leak in the doctor-staleness L4, sitting
|
|
438
|
+
// exactly at ADR-0022's error budget. Every individual line was true; the
|
|
439
|
+
// report as a whole misled. That is a defect in a diagnostic.
|
|
440
|
+
//
|
|
441
|
+
// So the currency verdict is computed FIRST, and the install line says
|
|
442
|
+
// "currency not compared" only when it genuinely was not.
|
|
443
|
+
const currency = plugin ? checkCurrency(plugin, opts, configDir) : undefined;
|
|
444
|
+
if (plugin && pluginsFile) {
|
|
445
|
+
if (plugin.installed?.version) {
|
|
446
|
+
const compared = currency && currency.status !== 'skip';
|
|
447
|
+
add('plugin', 'ok', compared
|
|
448
|
+
? `${plugin.key} installed, version ${plugin.installed.version} — see \`plugin currency\` below`
|
|
449
|
+
: `${plugin.key} installed, version ${plugin.installed.version} (currency not compared)`);
|
|
450
|
+
}
|
|
451
|
+
else if (plugin.installed) {
|
|
452
|
+
add('plugin', 'ok', `${plugin.key} installed (version unknown)`);
|
|
453
|
+
}
|
|
454
|
+
else if (plugin.registryReadable) {
|
|
455
|
+
add('plugin', 'warn', `${plugin.key} enabled in settings but not found in the local plugin registry — run \`/plugin install ${plugin.key}\` in Claude Code`);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
add('plugin', 'warn', `${plugin.key} enabled but the plugin registry at ${pluginsFile} is missing or unreadable — install state unverified`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (currency)
|
|
462
|
+
add('plugin currency', currency.status, currency.detail);
|
|
463
|
+
// 6. VFKB_PROJECT consistency across the two wiring files.
|
|
464
|
+
const settingsProject = projectFromSettings(settings);
|
|
465
|
+
if (mcpProject && settingsProject && mcpProject !== settingsProject) {
|
|
466
|
+
add('VFKB_PROJECT', 'fail', `mismatch: .mcp.json says "${mcpProject}", settings says "${settingsProject}"`);
|
|
467
|
+
}
|
|
468
|
+
else if (mcpProject || settingsProject) {
|
|
469
|
+
add('VFKB_PROJECT', 'ok', `${mcpProject ?? settingsProject}`);
|
|
470
|
+
}
|
|
471
|
+
return { checks, ok: !checks.some((c) => c.status === 'fail') };
|
|
472
|
+
}
|
|
473
|
+
const ICON = { ok: 'OK ', warn: 'WARN', fail: 'FAIL', skip: 'SKIP' };
|
|
474
|
+
export function renderDoctor(report) {
|
|
475
|
+
const lines = report.checks.map((c) => `${ICON[c.status]} ${c.name} — ${c.detail}`);
|
|
476
|
+
lines.push('');
|
|
477
|
+
lines.push(report.ok ? 'doctor: OK (no failures)' : 'doctor: FAIL — fix the FAIL item(s) above');
|
|
478
|
+
return lines.join('\n');
|
|
479
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// FR-4 (ADR-0030) inner gate — `vfkb doctor` catches the failure modes a consumer
|
|
2
|
+
// trips over: incompatible/stale engine binding, missing/inconsistent wiring.
|
|
3
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
4
|
+
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { initProject } from './init.js';
|
|
8
|
+
import { runDoctor } from './doctor.js';
|
|
9
|
+
import { SCHEMA_VERSION } from './version.js';
|
|
10
|
+
let root;
|
|
11
|
+
let home;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
root = mkdtempSync(join(tmpdir(), 'vfkb-doctor-'));
|
|
14
|
+
// a fake $VFKB_BUNDLE_DIR with the two bundle files present
|
|
15
|
+
home = mkdtempSync(join(tmpdir(), 'vfkb-home-'));
|
|
16
|
+
writeFileSync(join(home, 'vfkb.mjs'), '');
|
|
17
|
+
writeFileSync(join(home, 'vfkb-mcp.mjs'), '');
|
|
18
|
+
});
|
|
19
|
+
const doctor = (env) => runDoctor({ root, brainDir: join(root, '.vfkb'), env });
|
|
20
|
+
const status = (r, name) => r.checks.find((c) => c.name === name)?.status;
|
|
21
|
+
describe('vfkb doctor (FR-4)', () => {
|
|
22
|
+
it('a freshly init-ed repo with $VFKB_BUNDLE_DIR set is healthy (ok)', () => {
|
|
23
|
+
initProject(root, { project: 'demo' });
|
|
24
|
+
const r = doctor({ VFKB_BUNDLE_DIR: home });
|
|
25
|
+
expect(r.ok).toBe(true);
|
|
26
|
+
expect(status(r, 'brain↔engine compat')).toBe('ok');
|
|
27
|
+
expect(status(r, '$VFKB_BUNDLE_DIR')).toBe('ok');
|
|
28
|
+
expect(status(r, '.mcp.json')).toBe('ok');
|
|
29
|
+
expect(status(r, '.claude/settings.json')).toBe('ok');
|
|
30
|
+
expect(status(r, 'VFKB_PROJECT')).toBe('ok');
|
|
31
|
+
});
|
|
32
|
+
it('warns (not fails) on a bare repo with no wiring and no $VFKB_BUNDLE_DIR', () => {
|
|
33
|
+
const r = doctor({ VFKB_BUNDLE_DIR: undefined });
|
|
34
|
+
expect(r.ok).toBe(true); // warnings don't fail
|
|
35
|
+
expect(status(r, '$VFKB_BUNDLE_DIR')).toBe('warn');
|
|
36
|
+
expect(status(r, '.mcp.json')).toBe('warn');
|
|
37
|
+
expect(status(r, 'brain manifest')).toBe('warn');
|
|
38
|
+
});
|
|
39
|
+
it('FAILS when the brain schema is newer than the engine (incompatible)', () => {
|
|
40
|
+
initProject(root, { project: 'demo' });
|
|
41
|
+
writeFileSync(join(root, '.vfkb', 'manifest.json'), JSON.stringify({ schema_version: SCHEMA_VERSION + 1, engine_version: '9.9.9', engine_commit: 'x' }));
|
|
42
|
+
const r = doctor({ VFKB_BUNDLE_DIR: home });
|
|
43
|
+
expect(r.ok).toBe(false);
|
|
44
|
+
expect(status(r, 'brain↔engine compat')).toBe('fail');
|
|
45
|
+
});
|
|
46
|
+
it('FAILS on a VFKB_PROJECT mismatch between .mcp.json and settings', () => {
|
|
47
|
+
initProject(root, { project: 'demo' });
|
|
48
|
+
// corrupt .mcp.json to a different project than the settings hooks
|
|
49
|
+
const mcp = JSON.parse(readFileSync(join(root, '.mcp.json'), 'utf8'));
|
|
50
|
+
mcp.mcpServers.vfkb.env.VFKB_PROJECT = 'other';
|
|
51
|
+
writeFileSync(join(root, '.mcp.json'), JSON.stringify(mcp));
|
|
52
|
+
const r = doctor({ VFKB_BUNDLE_DIR: home });
|
|
53
|
+
expect(r.ok).toBe(false);
|
|
54
|
+
expect(status(r, 'VFKB_PROJECT')).toBe('fail');
|
|
55
|
+
});
|
|
56
|
+
it('warns when a deprecated env alias (VFKB_DIR / VFKB_HOME) is in use', () => {
|
|
57
|
+
initProject(root, { project: 'demo' });
|
|
58
|
+
const r = doctor({ VFKB_BUNDLE_DIR: home, VFKB_DIR: '.vfkb' });
|
|
59
|
+
expect(status(r, 'env (deprecated)')).toBe('warn');
|
|
60
|
+
expect(r.ok).toBe(true); // a deprecation is a warning, not a failure
|
|
61
|
+
});
|
|
62
|
+
it('warns when $VFKB_BUNDLE_DIR is set but the bundles are missing', () => {
|
|
63
|
+
initProject(root, { project: 'demo' });
|
|
64
|
+
const r = doctor({ VFKB_BUNDLE_DIR: mkdtempSync(join(tmpdir(), 'empty-home-')) });
|
|
65
|
+
expect(status(r, '$VFKB_BUNDLE_DIR')).toBe('warn');
|
|
66
|
+
});
|
|
67
|
+
// ADR-0058/RFC-030 — plain `runDoctor` (no --check-remote) never runs the npm
|
|
68
|
+
// currency check: there is no code path from here to `checkNpmCurrency`, so it
|
|
69
|
+
// makes zero fetch calls and the report never gains an 'npm currency' line.
|
|
70
|
+
// The CLI only ever calls checkNpmCurrency under the --check-remote flag.
|
|
71
|
+
it('never runs the npm currency check on its own — no "npm currency" line, ever', () => {
|
|
72
|
+
initProject(root, { project: 'demo' });
|
|
73
|
+
const r = doctor({ VFKB_BUNDLE_DIR: home });
|
|
74
|
+
expect(r.checks.find((c) => c.name === 'npm currency')).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
// ADR-0045 — plugin-wired repos (issue #77): doctor must recognize the plugin as the
|
|
78
|
+
// primary wiring and stop prescribing `vfkb init` (which would double-wire the repo).
|
|
79
|
+
describe('vfkb doctor — plugin wiring (ADR-0045 / issue #77)', () => {
|
|
80
|
+
const PLUGIN_KEY = 'vfkb@vfkb';
|
|
81
|
+
function wirePlugin(extra = {}) {
|
|
82
|
+
mkdirSync(join(root, '.claude'), { recursive: true });
|
|
83
|
+
writeFileSync(join(root, '.claude', 'settings.json'), JSON.stringify({ enabledPlugins: { [PLUGIN_KEY]: true }, ...extra }));
|
|
84
|
+
}
|
|
85
|
+
function writeRegistry(entries) {
|
|
86
|
+
const file = join(mkdtempSync(join(tmpdir(), 'vfkb-plugreg-')), 'installed_plugins.json');
|
|
87
|
+
writeFileSync(file, JSON.stringify({ version: 2, plugins: entries }));
|
|
88
|
+
return file;
|
|
89
|
+
}
|
|
90
|
+
const pluginDoctor = (env, pluginsFile) => runDoctor({ root, brainDir: join(root, '.vfkb'), env, pluginsFile });
|
|
91
|
+
it('plugin-wired repo: wiring checks are OK, no check prescribes `vfkb init`, bundle dir may be unset', () => {
|
|
92
|
+
wirePlugin();
|
|
93
|
+
const file = writeRegistry({ [PLUGIN_KEY]: [{ scope: 'project', projectPath: root, version: '0.2.0' }] });
|
|
94
|
+
const r = pluginDoctor({}, file);
|
|
95
|
+
expect(r.ok).toBe(true);
|
|
96
|
+
expect(status(r, '.mcp.json')).toBe('ok');
|
|
97
|
+
expect(status(r, '.claude/settings.json')).toBe('ok');
|
|
98
|
+
expect(status(r, '$VFKB_BUNDLE_DIR')).toBe('ok'); // unset is fine — the plugin vendors the engine
|
|
99
|
+
expect(status(r, 'plugin')).toBe('ok');
|
|
100
|
+
for (const c of r.checks)
|
|
101
|
+
expect(c.detail).not.toContain('vfkb init');
|
|
102
|
+
});
|
|
103
|
+
it('reports the installed plugin version as information, without claiming currency', () => {
|
|
104
|
+
wirePlugin();
|
|
105
|
+
const file = writeRegistry({ [PLUGIN_KEY]: [{ scope: 'project', projectPath: root, version: '0.2.0' }] });
|
|
106
|
+
const r = pluginDoctor({}, file);
|
|
107
|
+
const plugin = r.checks.find((c) => c.name === 'plugin');
|
|
108
|
+
expect(plugin?.detail).toContain('0.2.0');
|
|
109
|
+
expect(plugin?.detail).not.toMatch(/up.to.date|latest|current/i);
|
|
110
|
+
});
|
|
111
|
+
it('warns when the plugin is enabled but not in the local install registry', () => {
|
|
112
|
+
wirePlugin();
|
|
113
|
+
const file = writeRegistry({});
|
|
114
|
+
const r = pluginDoctor({}, file);
|
|
115
|
+
expect(status(r, 'plugin')).toBe('warn');
|
|
116
|
+
expect(r.ok).toBe(true); // warn, never fail — doctor may run on a machine without Claude Code
|
|
117
|
+
});
|
|
118
|
+
it('soft-skips the install check when neither HOME nor pluginsFile is available', () => {
|
|
119
|
+
wirePlugin();
|
|
120
|
+
const r = pluginDoctor({}); // no HOME in env, no pluginsFile
|
|
121
|
+
expect(r.checks.find((c) => c.name === 'plugin')).toBeUndefined();
|
|
122
|
+
expect(status(r, '.mcp.json')).toBe('ok'); // wiring recognition does not depend on the registry
|
|
123
|
+
expect(r.ok).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
it('WARNs on double wiring (plugin + leftover init wiring) and advises removal, not `vfkb init`', () => {
|
|
126
|
+
initProject(root, { project: 'demo' }); // writes .mcp.json + hooks (the fallback wiring)
|
|
127
|
+
wirePlugin(); // overwrites settings with plugin config…
|
|
128
|
+
// …but keep the init hooks too, to simulate a genuinely double-wired settings file
|
|
129
|
+
const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8'));
|
|
130
|
+
settings.hooks = { Stop: [{ hooks: [{ type: 'command', command: 'node .vfkb/bin/bootstrap.mjs cli hook stop # vfkb' }] }] };
|
|
131
|
+
writeFileSync(join(root, '.claude', 'settings.json'), JSON.stringify(settings));
|
|
132
|
+
const file = writeRegistry({ [PLUGIN_KEY]: [{ scope: 'project', projectPath: root, version: '0.2.0' }] });
|
|
133
|
+
const r = pluginDoctor({}, file);
|
|
134
|
+
expect(status(r, '.mcp.json')).toBe('warn'); // stray fallback MCP server alongside the plugin
|
|
135
|
+
expect(status(r, '.claude/settings.json')).toBe('warn'); // stray fallback hooks alongside the plugin
|
|
136
|
+
const blob = JSON.stringify(r.checks);
|
|
137
|
+
expect(blob).toMatch(/double|alongside/i);
|
|
138
|
+
expect(blob).not.toContain('run `vfkb init`');
|
|
139
|
+
expect(r.ok).toBe(true); // warn tier
|
|
140
|
+
});
|
|
141
|
+
it('detects user-scope enablement via the registry projectPath when settings has no enabledPlugins', () => {
|
|
142
|
+
// no .claude/settings.json enabledPlugins at all — plugin enabled at user scope
|
|
143
|
+
const file = writeRegistry({ [PLUGIN_KEY]: [{ scope: 'project', projectPath: root, version: '0.2.0' }] });
|
|
144
|
+
const r = pluginDoctor({}, file);
|
|
145
|
+
expect(status(r, '.mcp.json')).toBe('ok');
|
|
146
|
+
expect(status(r, '.claude/settings.json')).toBe('ok');
|
|
147
|
+
for (const c of r.checks)
|
|
148
|
+
expect(c.detail).not.toContain('vfkb init');
|
|
149
|
+
});
|
|
150
|
+
it('an explicit enabledPlugins:false disable beats a lingering registry entry — fallback advice stays intact', () => {
|
|
151
|
+
// The repo deliberately disabled the plugin and runs the init fallback; a stale
|
|
152
|
+
// registry entry must NOT make doctor advise removing the working hooks.
|
|
153
|
+
initProject(root, { project: 'demo' });
|
|
154
|
+
const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8'));
|
|
155
|
+
settings.enabledPlugins = { [PLUGIN_KEY]: false };
|
|
156
|
+
writeFileSync(join(root, '.claude', 'settings.json'), JSON.stringify(settings));
|
|
157
|
+
const file = writeRegistry({ [PLUGIN_KEY]: [{ scope: 'project', projectPath: root, version: '0.2.0' }] });
|
|
158
|
+
const r = pluginDoctor({ VFKB_BUNDLE_DIR: home }, file);
|
|
159
|
+
expect(status(r, '.mcp.json')).toBe('ok'); // real init wiring, reported as such
|
|
160
|
+
expect(status(r, '.claude/settings.json')).toBe('ok');
|
|
161
|
+
expect(r.checks.find((c) => c.name === 'plugin')).toBeUndefined();
|
|
162
|
+
expect(JSON.stringify(r.checks)).not.toMatch(/double|alongside|via plugin/i);
|
|
163
|
+
});
|
|
164
|
+
it('reports the install entry for THIS root, not another project\'s (and never masks not-installed with a foreign entry)', () => {
|
|
165
|
+
wirePlugin();
|
|
166
|
+
const file = writeRegistry({
|
|
167
|
+
[PLUGIN_KEY]: [
|
|
168
|
+
{ scope: 'project', projectPath: '/somewhere/else', version: '0.1.0' },
|
|
169
|
+
{ scope: 'project', projectPath: root, version: '0.2.0' },
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
const r = pluginDoctor({}, file);
|
|
173
|
+
expect(r.checks.find((c) => c.name === 'plugin')?.detail).toContain('0.2.0');
|
|
174
|
+
// only a foreign project's entry exists → the not-installed WARN must NOT be masked
|
|
175
|
+
const foreignOnly = writeRegistry({ [PLUGIN_KEY]: [{ scope: 'project', projectPath: '/somewhere/else', version: '0.1.0' }] });
|
|
176
|
+
const r2 = pluginDoctor({}, foreignOnly);
|
|
177
|
+
expect(status(r2, 'plugin')).toBe('warn');
|
|
178
|
+
});
|
|
179
|
+
});
|