canary-test-cli 5.11.0 → 5.13.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/bin/canary +0 -0
- package/bin/canary.js +12 -10
- package/dist/doctor-manifest.js +83 -52
- package/dist/doctor.js +90 -44
- package/dist/engine-checks.js +277 -60
- package/dist/overlay-commands.js +155 -42
- package/dist/overlay-conflicts.js +121 -0
- package/dist/overlay-lint.js +211 -0
- package/dist/overlays-registry.js +23 -12
- package/dist/router.js +15 -14
- package/dist/skill-requirements.js +129 -0
- package/dist/source-spec.js +19 -11
- package/package.json +1 -1
- package/scripts/install.js +49 -32
package/dist/engine-checks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
2
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
3
|
if (k2 === undefined) k2 = k;
|
|
4
4
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -40,6 +40,8 @@ exports.checkGit = checkGit;
|
|
|
40
40
|
exports.checkOverlays = checkOverlays;
|
|
41
41
|
exports.checkProjectConfig = checkProjectConfig;
|
|
42
42
|
exports.checkMcpConfig = checkMcpConfig;
|
|
43
|
+
exports.checkOverlayConflicts = checkOverlayConflicts;
|
|
44
|
+
exports.checkSkillRequirements = checkSkillRequirements;
|
|
43
45
|
exports.runEngineChecks = runEngineChecks;
|
|
44
46
|
/**
|
|
45
47
|
* Built-in `canary doctor` engine checks (Phase 2, tier 1). Each returns a
|
|
@@ -51,23 +53,34 @@ const os = __importStar(require("node:os"));
|
|
|
51
53
|
const path = __importStar(require("node:path"));
|
|
52
54
|
const overlay_commands_js_1 = require("./overlay-commands.js");
|
|
53
55
|
const registry = __importStar(require("./overlays-registry.js"));
|
|
56
|
+
const overlay_conflicts_js_1 = require("./overlay-conflicts.js");
|
|
57
|
+
const skill_requirements_js_1 = require("./skill-requirements.js");
|
|
54
58
|
/** The published npm package name (mirrors the install remedy in the shim). */
|
|
55
|
-
const PKG =
|
|
59
|
+
const PKG = 'canary-test-cli';
|
|
56
60
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
57
61
|
const realGit = (args, opts = {}) => {
|
|
58
|
-
const { spawnSync } = require(
|
|
59
|
-
const r = spawnSync(
|
|
62
|
+
const { spawnSync } = require('node:child_process');
|
|
63
|
+
const r = spawnSync('git', args, { cwd: opts.cwd, encoding: 'utf8' });
|
|
60
64
|
if (r.error) {
|
|
61
65
|
const code = r.error.code;
|
|
62
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
status: code === 'ENOENT' ? 127 : 1,
|
|
68
|
+
stdout: '',
|
|
69
|
+
stderr: String(r.error.message),
|
|
70
|
+
};
|
|
63
71
|
}
|
|
64
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
status: r.status ?? 1,
|
|
74
|
+
stdout: r.stdout ?? '',
|
|
75
|
+
stderr: r.stderr ?? '',
|
|
76
|
+
};
|
|
65
77
|
};
|
|
66
78
|
/** Read this package's own version from its package.json (best effort). */
|
|
67
79
|
function ownVersion() {
|
|
68
80
|
try {
|
|
69
|
-
const pkgPath = path.join(__dirname,
|
|
70
|
-
return JSON.parse(fs.readFileSync(pkgPath,
|
|
81
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
82
|
+
return (JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
83
|
+
.version ?? null);
|
|
71
84
|
}
|
|
72
85
|
catch {
|
|
73
86
|
return null;
|
|
@@ -77,7 +90,7 @@ function ownVersion() {
|
|
|
77
90
|
function parseRegistryVersion(rawBody) {
|
|
78
91
|
try {
|
|
79
92
|
const v = JSON.parse(rawBody)?.version;
|
|
80
|
-
return typeof v ===
|
|
93
|
+
return typeof v === 'string' ? v : null;
|
|
81
94
|
}
|
|
82
95
|
catch {
|
|
83
96
|
return null;
|
|
@@ -92,18 +105,18 @@ function fetchLatestVersion(timeoutMs) {
|
|
|
92
105
|
resolve(null);
|
|
93
106
|
return;
|
|
94
107
|
}
|
|
95
|
-
let body =
|
|
96
|
-
res.on(
|
|
97
|
-
res.on(
|
|
108
|
+
let body = '';
|
|
109
|
+
res.on('data', (c) => (body += c));
|
|
110
|
+
res.on('end', () => resolve(parseRegistryVersion(body)));
|
|
98
111
|
});
|
|
99
|
-
req.on(
|
|
112
|
+
req.on('error', () => resolve(null));
|
|
100
113
|
req.setTimeout(timeoutMs, () => req.destroy());
|
|
101
114
|
});
|
|
102
115
|
}
|
|
103
116
|
/** True when semver `a` is strictly older than `b` (numeric compare, no prerelease). */
|
|
104
117
|
function isOlder(a, b) {
|
|
105
|
-
const pa = a.split(
|
|
106
|
-
const pb = b.split(
|
|
118
|
+
const pa = a.split('.').map((n) => Number.parseInt(n, 10));
|
|
119
|
+
const pb = b.split('.').map((n) => Number.parseInt(n, 10));
|
|
107
120
|
for (let i = 0; i < 3; i += 1) {
|
|
108
121
|
const x = pa[i] ?? 0;
|
|
109
122
|
const y = pb[i] ?? 0;
|
|
@@ -117,35 +130,52 @@ function isOlder(a, b) {
|
|
|
117
130
|
async function checkVersion(deps = {}) {
|
|
118
131
|
const current = deps.currentVersion ?? ownVersion();
|
|
119
132
|
if (!current) {
|
|
120
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
id: 'engine:version',
|
|
135
|
+
status: 'info',
|
|
136
|
+
label: 'CLI version: unknown',
|
|
137
|
+
};
|
|
121
138
|
}
|
|
122
|
-
const getLatest = deps.getLatestVersion ??
|
|
139
|
+
const getLatest = deps.getLatestVersion ??
|
|
140
|
+
(() => fetchLatestVersion(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS));
|
|
123
141
|
const latest = await getLatest();
|
|
124
142
|
if (!latest) {
|
|
125
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
id: 'engine:version',
|
|
145
|
+
status: 'info',
|
|
146
|
+
label: `CLI ${current} (could not check latest — offline?)`,
|
|
147
|
+
};
|
|
126
148
|
}
|
|
127
149
|
if (isOlder(current, latest)) {
|
|
128
150
|
return {
|
|
129
|
-
id:
|
|
130
|
-
status:
|
|
151
|
+
id: 'engine:version',
|
|
152
|
+
status: 'fail',
|
|
131
153
|
label: `CLI ${current} is behind latest ${latest}`,
|
|
132
154
|
remedy: `Upgrade: npm install -g ${PKG}@latest`,
|
|
133
155
|
};
|
|
134
156
|
}
|
|
135
|
-
return {
|
|
157
|
+
return {
|
|
158
|
+
id: 'engine:version',
|
|
159
|
+
status: 'pass',
|
|
160
|
+
label: `CLI ${current} (latest)`,
|
|
161
|
+
};
|
|
136
162
|
}
|
|
137
163
|
/** git present on PATH. */
|
|
138
164
|
function checkGit(deps = {}) {
|
|
139
165
|
const git = deps.git ?? realGit;
|
|
140
|
-
const res = git([
|
|
166
|
+
const res = git(['--version']);
|
|
141
167
|
if (res.status === 0) {
|
|
142
|
-
return {
|
|
168
|
+
return {
|
|
169
|
+
id: 'engine:git',
|
|
170
|
+
status: 'pass',
|
|
171
|
+
label: `git present (${res.stdout.trim() || 'ok'})`,
|
|
172
|
+
};
|
|
143
173
|
}
|
|
144
174
|
return {
|
|
145
|
-
id:
|
|
146
|
-
status:
|
|
147
|
-
label:
|
|
148
|
-
remedy:
|
|
175
|
+
id: 'engine:git',
|
|
176
|
+
status: 'fail',
|
|
177
|
+
label: 'git not found on PATH',
|
|
178
|
+
remedy: 'Install git and ensure it is on your PATH — overlay add/update need it.',
|
|
149
179
|
};
|
|
150
180
|
}
|
|
151
181
|
/** Registered overlays present, fresh, and free of local modifications. */
|
|
@@ -159,48 +189,62 @@ function checkOverlays(deps = {}) {
|
|
|
159
189
|
catch (e) {
|
|
160
190
|
return [
|
|
161
191
|
{
|
|
162
|
-
id:
|
|
163
|
-
status:
|
|
164
|
-
label:
|
|
192
|
+
id: 'engine:overlays',
|
|
193
|
+
status: 'fail',
|
|
194
|
+
label: 'overlays registry unreadable',
|
|
165
195
|
remedy: e.message,
|
|
166
196
|
},
|
|
167
197
|
];
|
|
168
198
|
}
|
|
169
199
|
if (reg.overlays.length === 0) {
|
|
170
|
-
return [
|
|
200
|
+
return [
|
|
201
|
+
{
|
|
202
|
+
id: 'engine:overlays',
|
|
203
|
+
status: 'info',
|
|
204
|
+
label: 'no overlays registered',
|
|
205
|
+
},
|
|
206
|
+
];
|
|
171
207
|
}
|
|
172
208
|
const results = [];
|
|
173
209
|
for (const o of reg.overlays) {
|
|
174
210
|
const fresh = (0, overlay_commands_js_1.freshness)(o.path, o, git);
|
|
175
|
-
if (fresh.startsWith(
|
|
211
|
+
if (fresh.startsWith('missing')) {
|
|
176
212
|
results.push({
|
|
177
213
|
id: `overlay:${o.name}:present`,
|
|
178
|
-
status:
|
|
214
|
+
status: 'fail',
|
|
179
215
|
label: `overlay "${o.name}": clone missing`,
|
|
180
216
|
remedy: `Re-add it: canary overlay remove ${o.name} (if needed) then canary overlay add ${o.source}`,
|
|
181
217
|
});
|
|
182
218
|
continue;
|
|
183
219
|
}
|
|
184
|
-
if (fresh.includes(
|
|
220
|
+
if (fresh.includes('behind')) {
|
|
185
221
|
results.push({
|
|
186
222
|
id: `overlay:${o.name}:fresh`,
|
|
187
|
-
status:
|
|
223
|
+
status: 'fail',
|
|
188
224
|
label: `overlay "${o.name}": ${fresh}`,
|
|
189
225
|
remedy: `Update it: canary overlay update ${o.name}`,
|
|
190
226
|
});
|
|
191
227
|
}
|
|
192
228
|
else {
|
|
193
|
-
results.push({
|
|
229
|
+
results.push({
|
|
230
|
+
id: `overlay:${o.name}:fresh`,
|
|
231
|
+
status: 'pass',
|
|
232
|
+
label: `overlay "${o.name}": ${fresh}`,
|
|
233
|
+
});
|
|
194
234
|
}
|
|
195
235
|
const clean = (0, overlay_commands_js_1.workingTreeStatus)(o.path, git);
|
|
196
|
-
if (clean ===
|
|
197
|
-
results.push({
|
|
236
|
+
if (clean === 'clean') {
|
|
237
|
+
results.push({
|
|
238
|
+
id: `overlay:${o.name}:clean`,
|
|
239
|
+
status: 'pass',
|
|
240
|
+
label: `overlay "${o.name}": no local changes`,
|
|
241
|
+
});
|
|
198
242
|
}
|
|
199
243
|
else {
|
|
200
244
|
results.push({
|
|
201
245
|
id: `overlay:${o.name}:clean`,
|
|
202
|
-
status:
|
|
203
|
-
label: `overlay "${o.name}": ${clean ===
|
|
246
|
+
status: 'fail',
|
|
247
|
+
label: `overlay "${o.name}": ${clean === 'dirty' ? 'local modifications' : 'git status unreadable'}`,
|
|
204
248
|
remedy: `Commit/stash changes in ${o.path}, or canary overlay remove ${o.name} and re-add.`,
|
|
205
249
|
});
|
|
206
250
|
}
|
|
@@ -211,10 +255,12 @@ function checkOverlays(deps = {}) {
|
|
|
211
255
|
function parseErrorOrNull(file) {
|
|
212
256
|
let raw;
|
|
213
257
|
try {
|
|
214
|
-
raw = fs.readFileSync(file,
|
|
258
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
215
259
|
}
|
|
216
260
|
catch (e) {
|
|
217
|
-
return e.code ===
|
|
261
|
+
return e.code === 'ENOENT'
|
|
262
|
+
? null
|
|
263
|
+
: e.message;
|
|
218
264
|
}
|
|
219
265
|
try {
|
|
220
266
|
JSON.parse(raw);
|
|
@@ -227,38 +273,52 @@ function parseErrorOrNull(file) {
|
|
|
227
273
|
/** Project `.canary/` config files parse as JSON. */
|
|
228
274
|
function checkProjectConfig(deps = {}) {
|
|
229
275
|
const cwd = deps.cwd ?? process.cwd();
|
|
230
|
-
const dir = path.join(cwd,
|
|
276
|
+
const dir = path.join(cwd, '.canary');
|
|
231
277
|
let names;
|
|
232
278
|
try {
|
|
233
|
-
names = fs
|
|
279
|
+
names = fs
|
|
280
|
+
.readdirSync(dir)
|
|
281
|
+
.filter((n) => /^company(\.[\w-]+)?\.json$/.test(n));
|
|
234
282
|
}
|
|
235
283
|
catch {
|
|
236
|
-
return {
|
|
284
|
+
return {
|
|
285
|
+
id: 'engine:project-config',
|
|
286
|
+
status: 'skip',
|
|
287
|
+
label: 'no project .canary/ config',
|
|
288
|
+
};
|
|
237
289
|
}
|
|
238
290
|
if (names.length === 0) {
|
|
239
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
id: 'engine:project-config',
|
|
293
|
+
status: 'skip',
|
|
294
|
+
label: 'no project .canary/ config',
|
|
295
|
+
};
|
|
240
296
|
}
|
|
241
297
|
for (const name of names) {
|
|
242
298
|
const err = parseErrorOrNull(path.join(dir, name));
|
|
243
299
|
if (err) {
|
|
244
300
|
return {
|
|
245
|
-
id:
|
|
246
|
-
status:
|
|
301
|
+
id: 'engine:project-config',
|
|
302
|
+
status: 'fail',
|
|
247
303
|
label: `project .canary/${name} does not parse`,
|
|
248
304
|
remedy: `Fix the JSON in .canary/${name}: ${err}`,
|
|
249
305
|
};
|
|
250
306
|
}
|
|
251
307
|
}
|
|
252
|
-
return {
|
|
308
|
+
return {
|
|
309
|
+
id: 'engine:project-config',
|
|
310
|
+
status: 'pass',
|
|
311
|
+
label: `project .canary/ config parses (${names.length} file(s))`,
|
|
312
|
+
};
|
|
253
313
|
}
|
|
254
314
|
/** Assert a single `.mcp.json` parses and each server entry is well-formed. */
|
|
255
315
|
function inspectMcpFile(file) {
|
|
256
316
|
let raw;
|
|
257
317
|
try {
|
|
258
|
-
raw = fs.readFileSync(file,
|
|
318
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
259
319
|
}
|
|
260
320
|
catch (e) {
|
|
261
|
-
if (e.code ===
|
|
321
|
+
if (e.code === 'ENOENT') {
|
|
262
322
|
return { present: false };
|
|
263
323
|
}
|
|
264
324
|
return { present: true, error: e.message };
|
|
@@ -274,13 +334,16 @@ function inspectMcpFile(file) {
|
|
|
274
334
|
if (servers === undefined) {
|
|
275
335
|
return { present: true };
|
|
276
336
|
}
|
|
277
|
-
if (typeof servers !==
|
|
278
|
-
return { present: true, error:
|
|
337
|
+
if (typeof servers !== 'object' || servers === null) {
|
|
338
|
+
return { present: true, error: 'mcpServers is not an object' };
|
|
279
339
|
}
|
|
280
340
|
for (const [key, entry] of Object.entries(servers)) {
|
|
281
341
|
const e = entry;
|
|
282
|
-
if (typeof e.command !==
|
|
283
|
-
return {
|
|
342
|
+
if (typeof e.command !== 'string' && typeof e.url !== 'string') {
|
|
343
|
+
return {
|
|
344
|
+
present: true,
|
|
345
|
+
error: `server "${key}" has neither a command nor a url`,
|
|
346
|
+
};
|
|
284
347
|
}
|
|
285
348
|
}
|
|
286
349
|
return { present: true };
|
|
@@ -289,24 +352,176 @@ function inspectMcpFile(file) {
|
|
|
289
352
|
function checkMcpConfig(deps = {}) {
|
|
290
353
|
const cwd = deps.cwd ?? process.cwd();
|
|
291
354
|
const homeDir = deps.homeDir ?? os.homedir();
|
|
292
|
-
const files = [path.join(cwd,
|
|
355
|
+
const files = [path.join(cwd, '.mcp.json'), path.join(homeDir, '.mcp.json')];
|
|
293
356
|
let anyPresent = false;
|
|
294
357
|
for (const file of files) {
|
|
295
358
|
const r = inspectMcpFile(file);
|
|
296
359
|
anyPresent = anyPresent || r.present;
|
|
297
360
|
if (r.error) {
|
|
298
361
|
return {
|
|
299
|
-
id:
|
|
300
|
-
status:
|
|
362
|
+
id: 'engine:mcp',
|
|
363
|
+
status: 'fail',
|
|
301
364
|
label: `MCP config ${file} is invalid`,
|
|
302
365
|
remedy: `Fix ${file}: ${r.error}`,
|
|
303
366
|
};
|
|
304
367
|
}
|
|
305
368
|
}
|
|
306
369
|
if (!anyPresent) {
|
|
307
|
-
return { id:
|
|
370
|
+
return { id: 'engine:mcp', status: 'skip', label: 'no .mcp.json found' };
|
|
371
|
+
}
|
|
372
|
+
return { id: 'engine:mcp', status: 'pass', label: 'MCP config resolves' };
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Skill-name collisions across registered overlays are resolved by a declared
|
|
376
|
+
* precedence (#333). A collision with no precedence winner is a `fail` — which
|
|
377
|
+
* definition wins is otherwise accidental (directory-name order). No overlays
|
|
378
|
+
* or no collisions → an informational/passing line, never a false alarm.
|
|
379
|
+
*/
|
|
380
|
+
function checkOverlayConflicts(deps = {}) {
|
|
381
|
+
const homeDir = deps.homeDir ?? os.homedir();
|
|
382
|
+
let reg;
|
|
383
|
+
try {
|
|
384
|
+
reg = registry.read(homeDir);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// The dedicated engine:overlays check already reports an unreadable
|
|
388
|
+
// registry; don't double-fail here.
|
|
389
|
+
return {
|
|
390
|
+
id: 'engine:overlay-conflicts',
|
|
391
|
+
status: 'skip',
|
|
392
|
+
label: 'overlay skill conflicts: registry unreadable',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
const conflicts = (0, overlay_conflicts_js_1.detectSkillConflicts)(reg);
|
|
396
|
+
const unresolved = conflicts.filter((c) => !c.resolved);
|
|
397
|
+
if (unresolved.length > 0) {
|
|
398
|
+
const names = unresolved.map((c) => c.skill).join(', ');
|
|
399
|
+
return {
|
|
400
|
+
id: 'engine:overlay-conflicts',
|
|
401
|
+
status: 'fail',
|
|
402
|
+
label: `overlay skill conflicts unresolved: ${names}`,
|
|
403
|
+
remedy: 'Two overlays ship these skill name(s) with equal precedence — the ' +
|
|
404
|
+
'winner is accidental. Set a higher `precedence` on the overlay you ' +
|
|
405
|
+
'want to win in ~/.canary/overlays.json, or run ' +
|
|
406
|
+
'`canary overlay list --conflicts` for details.',
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
if (conflicts.length > 0) {
|
|
410
|
+
return {
|
|
411
|
+
id: 'engine:overlay-conflicts',
|
|
412
|
+
status: 'pass',
|
|
413
|
+
label: `overlay skill conflicts: ${conflicts.length} resolved by precedence`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
id: 'engine:overlay-conflicts',
|
|
418
|
+
status: 'pass',
|
|
419
|
+
label: 'no overlay skill conflicts',
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/** Real command probe (#336): `<cmd> --version`, extract the first x.y[.z]. */
|
|
423
|
+
const realProbe = (command) => {
|
|
424
|
+
const { spawnSync } = require('node:child_process');
|
|
425
|
+
const r = spawnSync(command, ['--version'], {
|
|
426
|
+
encoding: 'utf8',
|
|
427
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
428
|
+
});
|
|
429
|
+
// ENOENT (not on PATH) sets r.error; a non-zero exit does not — the command
|
|
430
|
+
// exists, it just may not print a parseable version.
|
|
431
|
+
if (r.error)
|
|
432
|
+
return { present: false, version: null };
|
|
433
|
+
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
|
434
|
+
const m = out.match(/(\d+\.\d+(?:\.\d+)?)/);
|
|
435
|
+
return { present: true, version: m ? m[1] : null };
|
|
436
|
+
};
|
|
437
|
+
/** SKILL.md paths + source labels for skills installed in a consuming repo. */
|
|
438
|
+
function skillMdRoots(homeDir, cwd) {
|
|
439
|
+
const roots = [];
|
|
440
|
+
const overlays = path.join(homeDir, '.canary', 'overlays');
|
|
441
|
+
for (const ov of safeDirs(overlays)) {
|
|
442
|
+
roots.push([path.join(overlays, ov, '.canary', 'skills'), `overlay:${ov}`]);
|
|
443
|
+
}
|
|
444
|
+
roots.push([path.join(homeDir, '.canary', 'skills'), 'global']);
|
|
445
|
+
roots.push([path.join(cwd, '.canary', 'skills'), 'local']);
|
|
446
|
+
return roots;
|
|
447
|
+
}
|
|
448
|
+
/** Directory entries (names) under `dir`, or [] when it is absent/unreadable. */
|
|
449
|
+
function safeDirs(dir) {
|
|
450
|
+
try {
|
|
451
|
+
return fs
|
|
452
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
453
|
+
.filter((d) => d.isDirectory())
|
|
454
|
+
.map((d) => d.name);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** Collect `requires` declarations from every skill installed in this repo. */
|
|
461
|
+
function scanSkillRequirements(homeDir, cwd) {
|
|
462
|
+
const found = [];
|
|
463
|
+
for (const [skillsDir, source] of skillMdRoots(homeDir, cwd)) {
|
|
464
|
+
for (const name of safeDirs(skillsDir)) {
|
|
465
|
+
const md = path.join(skillsDir, name, 'SKILL.md');
|
|
466
|
+
let text;
|
|
467
|
+
try {
|
|
468
|
+
text = fs.readFileSync(md, 'utf8');
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const requires = (0, skill_requirements_js_1.parseRequiresField)(text);
|
|
474
|
+
if (requires.length > 0)
|
|
475
|
+
found.push({ name, source, requires });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return found;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Verify the runtime requirements declared by installed skills (#336). Reads
|
|
482
|
+
* `requires:` from each overlay/global/local skill's SKILL.md and checks every
|
|
483
|
+
* declared command (and optional version) against the environment. A missing
|
|
484
|
+
* or too-old requirement is a `fail` naming the skill and command; a bare
|
|
485
|
+
* presence check that passes, or a token whose version cannot be read, never
|
|
486
|
+
* fails. No declarations at all → an informational line (not a false "all
|
|
487
|
+
* good"). Bundled skills ship inside the engine and are out of scope here.
|
|
488
|
+
*/
|
|
489
|
+
function checkSkillRequirements(deps = {}) {
|
|
490
|
+
const homeDir = deps.homeDir ?? os.homedir();
|
|
491
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
492
|
+
const probe = deps.skillProbe ?? realProbe;
|
|
493
|
+
const skills = scanSkillRequirements(homeDir, cwd);
|
|
494
|
+
if (skills.length === 0) {
|
|
495
|
+
return {
|
|
496
|
+
id: 'engine:skill-requirements',
|
|
497
|
+
status: 'info',
|
|
498
|
+
label: 'no installed skill declares runtime requirements',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const failures = [];
|
|
502
|
+
let checked = 0;
|
|
503
|
+
for (const skill of skills) {
|
|
504
|
+
for (const token of skill.requires) {
|
|
505
|
+
checked += 1;
|
|
506
|
+
const r = (0, skill_requirements_js_1.checkRequirement)((0, skill_requirements_js_1.parseRequirement)(token), probe);
|
|
507
|
+
if (r.status === 'missing' || r.status === 'too-old') {
|
|
508
|
+
failures.push(`${skill.source}/${skill.name} needs ${token}: ${r.detail}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
308
511
|
}
|
|
309
|
-
|
|
512
|
+
if (failures.length > 0) {
|
|
513
|
+
return {
|
|
514
|
+
id: 'engine:skill-requirements',
|
|
515
|
+
status: 'fail',
|
|
516
|
+
label: `skill runtime requirements unmet: ${failures.length} of ${checked}`,
|
|
517
|
+
remedy: `Install/upgrade the missing tools — ${failures.join('; ')}`,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
id: 'engine:skill-requirements',
|
|
522
|
+
status: 'pass',
|
|
523
|
+
label: `skill runtime requirements: ${checked} satisfied across ${skills.length} skill(s)`,
|
|
524
|
+
};
|
|
310
525
|
}
|
|
311
526
|
/** Run every engine check, in display order. */
|
|
312
527
|
async function runEngineChecks(deps = {}) {
|
|
@@ -314,6 +529,8 @@ async function runEngineChecks(deps = {}) {
|
|
|
314
529
|
await checkVersion(deps),
|
|
315
530
|
checkGit(deps),
|
|
316
531
|
...checkOverlays(deps),
|
|
532
|
+
checkOverlayConflicts(deps),
|
|
533
|
+
checkSkillRequirements(deps),
|
|
317
534
|
checkProjectConfig(deps),
|
|
318
535
|
checkMcpConfig(deps),
|
|
319
536
|
];
|