canary-test-cli 5.10.1 → 5.12.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 +99 -46
- package/dist/doctor.js +110 -29
- package/dist/engine-checks.js +281 -62
- 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);
|
|
@@ -33,12 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseRegistryVersion = parseRegistryVersion;
|
|
36
37
|
exports.isOlder = isOlder;
|
|
37
38
|
exports.checkVersion = checkVersion;
|
|
38
39
|
exports.checkGit = checkGit;
|
|
39
40
|
exports.checkOverlays = checkOverlays;
|
|
40
41
|
exports.checkProjectConfig = checkProjectConfig;
|
|
41
42
|
exports.checkMcpConfig = checkMcpConfig;
|
|
43
|
+
exports.checkOverlayConflicts = checkOverlayConflicts;
|
|
44
|
+
exports.checkSkillRequirements = checkSkillRequirements;
|
|
42
45
|
exports.runEngineChecks = runEngineChecks;
|
|
43
46
|
/**
|
|
44
47
|
* Built-in `canary doctor` engine checks (Phase 2, tier 1). Each returns a
|
|
@@ -50,32 +53,44 @@ const os = __importStar(require("node:os"));
|
|
|
50
53
|
const path = __importStar(require("node:path"));
|
|
51
54
|
const overlay_commands_js_1 = require("./overlay-commands.js");
|
|
52
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");
|
|
53
58
|
/** The published npm package name (mirrors the install remedy in the shim). */
|
|
54
|
-
const PKG =
|
|
59
|
+
const PKG = 'canary-test-cli';
|
|
55
60
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
56
61
|
const realGit = (args, opts = {}) => {
|
|
57
|
-
const { spawnSync } = require(
|
|
58
|
-
const r = spawnSync(
|
|
62
|
+
const { spawnSync } = require('node:child_process');
|
|
63
|
+
const r = spawnSync('git', args, { cwd: opts.cwd, encoding: 'utf8' });
|
|
59
64
|
if (r.error) {
|
|
60
65
|
const code = r.error.code;
|
|
61
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
status: code === 'ENOENT' ? 127 : 1,
|
|
68
|
+
stdout: '',
|
|
69
|
+
stderr: String(r.error.message),
|
|
70
|
+
};
|
|
62
71
|
}
|
|
63
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
status: r.status ?? 1,
|
|
74
|
+
stdout: r.stdout ?? '',
|
|
75
|
+
stderr: r.stderr ?? '',
|
|
76
|
+
};
|
|
64
77
|
};
|
|
65
78
|
/** Read this package's own version from its package.json (best effort). */
|
|
66
79
|
function ownVersion() {
|
|
67
80
|
try {
|
|
68
|
-
const pkgPath = path.join(__dirname,
|
|
69
|
-
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);
|
|
70
84
|
}
|
|
71
85
|
catch {
|
|
72
86
|
return null;
|
|
73
87
|
}
|
|
74
88
|
}
|
|
75
|
-
/**
|
|
76
|
-
function parseRegistryVersion(
|
|
89
|
+
/** String `version` from a registry body; null on parse error or non-string shape (SEC-DES-001: registry JSON is untrusted). */
|
|
90
|
+
function parseRegistryVersion(rawBody) {
|
|
77
91
|
try {
|
|
78
|
-
|
|
92
|
+
const v = JSON.parse(rawBody)?.version;
|
|
93
|
+
return typeof v === 'string' ? v : null;
|
|
79
94
|
}
|
|
80
95
|
catch {
|
|
81
96
|
return null;
|
|
@@ -90,18 +105,18 @@ function fetchLatestVersion(timeoutMs) {
|
|
|
90
105
|
resolve(null);
|
|
91
106
|
return;
|
|
92
107
|
}
|
|
93
|
-
let body =
|
|
94
|
-
res.on(
|
|
95
|
-
res.on(
|
|
108
|
+
let body = '';
|
|
109
|
+
res.on('data', (c) => (body += c));
|
|
110
|
+
res.on('end', () => resolve(parseRegistryVersion(body)));
|
|
96
111
|
});
|
|
97
|
-
req.on(
|
|
112
|
+
req.on('error', () => resolve(null));
|
|
98
113
|
req.setTimeout(timeoutMs, () => req.destroy());
|
|
99
114
|
});
|
|
100
115
|
}
|
|
101
116
|
/** True when semver `a` is strictly older than `b` (numeric compare, no prerelease). */
|
|
102
117
|
function isOlder(a, b) {
|
|
103
|
-
const pa = a.split(
|
|
104
|
-
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));
|
|
105
120
|
for (let i = 0; i < 3; i += 1) {
|
|
106
121
|
const x = pa[i] ?? 0;
|
|
107
122
|
const y = pb[i] ?? 0;
|
|
@@ -115,35 +130,52 @@ function isOlder(a, b) {
|
|
|
115
130
|
async function checkVersion(deps = {}) {
|
|
116
131
|
const current = deps.currentVersion ?? ownVersion();
|
|
117
132
|
if (!current) {
|
|
118
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
id: 'engine:version',
|
|
135
|
+
status: 'info',
|
|
136
|
+
label: 'CLI version: unknown',
|
|
137
|
+
};
|
|
119
138
|
}
|
|
120
|
-
const getLatest = deps.getLatestVersion ??
|
|
139
|
+
const getLatest = deps.getLatestVersion ??
|
|
140
|
+
(() => fetchLatestVersion(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS));
|
|
121
141
|
const latest = await getLatest();
|
|
122
142
|
if (!latest) {
|
|
123
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
id: 'engine:version',
|
|
145
|
+
status: 'info',
|
|
146
|
+
label: `CLI ${current} (could not check latest — offline?)`,
|
|
147
|
+
};
|
|
124
148
|
}
|
|
125
149
|
if (isOlder(current, latest)) {
|
|
126
150
|
return {
|
|
127
|
-
id:
|
|
128
|
-
status:
|
|
151
|
+
id: 'engine:version',
|
|
152
|
+
status: 'fail',
|
|
129
153
|
label: `CLI ${current} is behind latest ${latest}`,
|
|
130
154
|
remedy: `Upgrade: npm install -g ${PKG}@latest`,
|
|
131
155
|
};
|
|
132
156
|
}
|
|
133
|
-
return {
|
|
157
|
+
return {
|
|
158
|
+
id: 'engine:version',
|
|
159
|
+
status: 'pass',
|
|
160
|
+
label: `CLI ${current} (latest)`,
|
|
161
|
+
};
|
|
134
162
|
}
|
|
135
163
|
/** git present on PATH. */
|
|
136
164
|
function checkGit(deps = {}) {
|
|
137
165
|
const git = deps.git ?? realGit;
|
|
138
|
-
const res = git([
|
|
166
|
+
const res = git(['--version']);
|
|
139
167
|
if (res.status === 0) {
|
|
140
|
-
return {
|
|
168
|
+
return {
|
|
169
|
+
id: 'engine:git',
|
|
170
|
+
status: 'pass',
|
|
171
|
+
label: `git present (${res.stdout.trim() || 'ok'})`,
|
|
172
|
+
};
|
|
141
173
|
}
|
|
142
174
|
return {
|
|
143
|
-
id:
|
|
144
|
-
status:
|
|
145
|
-
label:
|
|
146
|
-
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.',
|
|
147
179
|
};
|
|
148
180
|
}
|
|
149
181
|
/** Registered overlays present, fresh, and free of local modifications. */
|
|
@@ -157,48 +189,62 @@ function checkOverlays(deps = {}) {
|
|
|
157
189
|
catch (e) {
|
|
158
190
|
return [
|
|
159
191
|
{
|
|
160
|
-
id:
|
|
161
|
-
status:
|
|
162
|
-
label:
|
|
192
|
+
id: 'engine:overlays',
|
|
193
|
+
status: 'fail',
|
|
194
|
+
label: 'overlays registry unreadable',
|
|
163
195
|
remedy: e.message,
|
|
164
196
|
},
|
|
165
197
|
];
|
|
166
198
|
}
|
|
167
199
|
if (reg.overlays.length === 0) {
|
|
168
|
-
return [
|
|
200
|
+
return [
|
|
201
|
+
{
|
|
202
|
+
id: 'engine:overlays',
|
|
203
|
+
status: 'info',
|
|
204
|
+
label: 'no overlays registered',
|
|
205
|
+
},
|
|
206
|
+
];
|
|
169
207
|
}
|
|
170
208
|
const results = [];
|
|
171
209
|
for (const o of reg.overlays) {
|
|
172
210
|
const fresh = (0, overlay_commands_js_1.freshness)(o.path, o, git);
|
|
173
|
-
if (fresh.startsWith(
|
|
211
|
+
if (fresh.startsWith('missing')) {
|
|
174
212
|
results.push({
|
|
175
213
|
id: `overlay:${o.name}:present`,
|
|
176
|
-
status:
|
|
214
|
+
status: 'fail',
|
|
177
215
|
label: `overlay "${o.name}": clone missing`,
|
|
178
216
|
remedy: `Re-add it: canary overlay remove ${o.name} (if needed) then canary overlay add ${o.source}`,
|
|
179
217
|
});
|
|
180
218
|
continue;
|
|
181
219
|
}
|
|
182
|
-
if (fresh.includes(
|
|
220
|
+
if (fresh.includes('behind')) {
|
|
183
221
|
results.push({
|
|
184
222
|
id: `overlay:${o.name}:fresh`,
|
|
185
|
-
status:
|
|
223
|
+
status: 'fail',
|
|
186
224
|
label: `overlay "${o.name}": ${fresh}`,
|
|
187
225
|
remedy: `Update it: canary overlay update ${o.name}`,
|
|
188
226
|
});
|
|
189
227
|
}
|
|
190
228
|
else {
|
|
191
|
-
results.push({
|
|
229
|
+
results.push({
|
|
230
|
+
id: `overlay:${o.name}:fresh`,
|
|
231
|
+
status: 'pass',
|
|
232
|
+
label: `overlay "${o.name}": ${fresh}`,
|
|
233
|
+
});
|
|
192
234
|
}
|
|
193
235
|
const clean = (0, overlay_commands_js_1.workingTreeStatus)(o.path, git);
|
|
194
|
-
if (clean ===
|
|
195
|
-
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
|
+
});
|
|
196
242
|
}
|
|
197
243
|
else {
|
|
198
244
|
results.push({
|
|
199
245
|
id: `overlay:${o.name}:clean`,
|
|
200
|
-
status:
|
|
201
|
-
label: `overlay "${o.name}": ${clean ===
|
|
246
|
+
status: 'fail',
|
|
247
|
+
label: `overlay "${o.name}": ${clean === 'dirty' ? 'local modifications' : 'git status unreadable'}`,
|
|
202
248
|
remedy: `Commit/stash changes in ${o.path}, or canary overlay remove ${o.name} and re-add.`,
|
|
203
249
|
});
|
|
204
250
|
}
|
|
@@ -209,10 +255,12 @@ function checkOverlays(deps = {}) {
|
|
|
209
255
|
function parseErrorOrNull(file) {
|
|
210
256
|
let raw;
|
|
211
257
|
try {
|
|
212
|
-
raw = fs.readFileSync(file,
|
|
258
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
213
259
|
}
|
|
214
260
|
catch (e) {
|
|
215
|
-
return e.code ===
|
|
261
|
+
return e.code === 'ENOENT'
|
|
262
|
+
? null
|
|
263
|
+
: e.message;
|
|
216
264
|
}
|
|
217
265
|
try {
|
|
218
266
|
JSON.parse(raw);
|
|
@@ -225,38 +273,52 @@ function parseErrorOrNull(file) {
|
|
|
225
273
|
/** Project `.canary/` config files parse as JSON. */
|
|
226
274
|
function checkProjectConfig(deps = {}) {
|
|
227
275
|
const cwd = deps.cwd ?? process.cwd();
|
|
228
|
-
const dir = path.join(cwd,
|
|
276
|
+
const dir = path.join(cwd, '.canary');
|
|
229
277
|
let names;
|
|
230
278
|
try {
|
|
231
|
-
names = fs
|
|
279
|
+
names = fs
|
|
280
|
+
.readdirSync(dir)
|
|
281
|
+
.filter((n) => /^company(\.[\w-]+)?\.json$/.test(n));
|
|
232
282
|
}
|
|
233
283
|
catch {
|
|
234
|
-
return {
|
|
284
|
+
return {
|
|
285
|
+
id: 'engine:project-config',
|
|
286
|
+
status: 'skip',
|
|
287
|
+
label: 'no project .canary/ config',
|
|
288
|
+
};
|
|
235
289
|
}
|
|
236
290
|
if (names.length === 0) {
|
|
237
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
id: 'engine:project-config',
|
|
293
|
+
status: 'skip',
|
|
294
|
+
label: 'no project .canary/ config',
|
|
295
|
+
};
|
|
238
296
|
}
|
|
239
297
|
for (const name of names) {
|
|
240
298
|
const err = parseErrorOrNull(path.join(dir, name));
|
|
241
299
|
if (err) {
|
|
242
300
|
return {
|
|
243
|
-
id:
|
|
244
|
-
status:
|
|
301
|
+
id: 'engine:project-config',
|
|
302
|
+
status: 'fail',
|
|
245
303
|
label: `project .canary/${name} does not parse`,
|
|
246
304
|
remedy: `Fix the JSON in .canary/${name}: ${err}`,
|
|
247
305
|
};
|
|
248
306
|
}
|
|
249
307
|
}
|
|
250
|
-
return {
|
|
308
|
+
return {
|
|
309
|
+
id: 'engine:project-config',
|
|
310
|
+
status: 'pass',
|
|
311
|
+
label: `project .canary/ config parses (${names.length} file(s))`,
|
|
312
|
+
};
|
|
251
313
|
}
|
|
252
314
|
/** Assert a single `.mcp.json` parses and each server entry is well-formed. */
|
|
253
315
|
function inspectMcpFile(file) {
|
|
254
316
|
let raw;
|
|
255
317
|
try {
|
|
256
|
-
raw = fs.readFileSync(file,
|
|
318
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
257
319
|
}
|
|
258
320
|
catch (e) {
|
|
259
|
-
if (e.code ===
|
|
321
|
+
if (e.code === 'ENOENT') {
|
|
260
322
|
return { present: false };
|
|
261
323
|
}
|
|
262
324
|
return { present: true, error: e.message };
|
|
@@ -272,13 +334,16 @@ function inspectMcpFile(file) {
|
|
|
272
334
|
if (servers === undefined) {
|
|
273
335
|
return { present: true };
|
|
274
336
|
}
|
|
275
|
-
if (typeof servers !==
|
|
276
|
-
return { present: true, error:
|
|
337
|
+
if (typeof servers !== 'object' || servers === null) {
|
|
338
|
+
return { present: true, error: 'mcpServers is not an object' };
|
|
277
339
|
}
|
|
278
340
|
for (const [key, entry] of Object.entries(servers)) {
|
|
279
341
|
const e = entry;
|
|
280
|
-
if (typeof e.command !==
|
|
281
|
-
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
|
+
};
|
|
282
347
|
}
|
|
283
348
|
}
|
|
284
349
|
return { present: true };
|
|
@@ -287,24 +352,176 @@ function inspectMcpFile(file) {
|
|
|
287
352
|
function checkMcpConfig(deps = {}) {
|
|
288
353
|
const cwd = deps.cwd ?? process.cwd();
|
|
289
354
|
const homeDir = deps.homeDir ?? os.homedir();
|
|
290
|
-
const files = [path.join(cwd,
|
|
355
|
+
const files = [path.join(cwd, '.mcp.json'), path.join(homeDir, '.mcp.json')];
|
|
291
356
|
let anyPresent = false;
|
|
292
357
|
for (const file of files) {
|
|
293
358
|
const r = inspectMcpFile(file);
|
|
294
359
|
anyPresent = anyPresent || r.present;
|
|
295
360
|
if (r.error) {
|
|
296
361
|
return {
|
|
297
|
-
id:
|
|
298
|
-
status:
|
|
362
|
+
id: 'engine:mcp',
|
|
363
|
+
status: 'fail',
|
|
299
364
|
label: `MCP config ${file} is invalid`,
|
|
300
365
|
remedy: `Fix ${file}: ${r.error}`,
|
|
301
366
|
};
|
|
302
367
|
}
|
|
303
368
|
}
|
|
304
369
|
if (!anyPresent) {
|
|
305
|
-
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
|
+
}
|
|
306
511
|
}
|
|
307
|
-
|
|
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
|
+
};
|
|
308
525
|
}
|
|
309
526
|
/** Run every engine check, in display order. */
|
|
310
527
|
async function runEngineChecks(deps = {}) {
|
|
@@ -312,6 +529,8 @@ async function runEngineChecks(deps = {}) {
|
|
|
312
529
|
await checkVersion(deps),
|
|
313
530
|
checkGit(deps),
|
|
314
531
|
...checkOverlays(deps),
|
|
532
|
+
checkOverlayConflicts(deps),
|
|
533
|
+
checkSkillRequirements(deps),
|
|
315
534
|
checkProjectConfig(deps),
|
|
316
535
|
checkMcpConfig(deps),
|
|
317
536
|
];
|