canary-test-cli 5.15.0 → 6.0.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/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- package/scripts/install.js +0 -104
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovers Canary skills: bundled defaults and local project overlays.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/core/skill_registry.py`. Implements the
|
|
5
|
+
* discovery convention defined in docs/specs/skill-discovery.md.
|
|
6
|
+
*
|
|
7
|
+
* Skills are SKILL.md files with YAML-style frontmatter. The optional `cli:` and
|
|
8
|
+
* `entry:` frontmatter fields let a skill ship executable code alongside its
|
|
9
|
+
* prose:
|
|
10
|
+
* - `cli:` filesystem path (relative to the skill directory) of an
|
|
11
|
+
* executable to invoke as a subprocess.
|
|
12
|
+
* - `entry:` a `module:callable` string to call in-process (reserved).
|
|
13
|
+
* These fields are mutually exclusive; specifying both is rejected at discovery.
|
|
14
|
+
*
|
|
15
|
+
* Python->TS nuances:
|
|
16
|
+
* - Python patches `Path.home()` in its tests to isolate the home-dir tiers.
|
|
17
|
+
* There is no global monkeypatch in JS, so the home directory is an
|
|
18
|
+
* injectable constructor argument (defaults to `os.homedir()`), mirroring the
|
|
19
|
+
* `home?` seam the overlays.ts port added. The bundled-skill directory is
|
|
20
|
+
* resolved from `import.meta.url` exactly as Python resolves it from
|
|
21
|
+
* `__file__` (three levels up to the repo root, then agents/skills).
|
|
22
|
+
* - `Path.read_text` / `OSError` -> `readFileSync` in a try/catch returning
|
|
23
|
+
* `null`. `Path.resolve()` (absolute + symlink resolution) -> `realpathSync`,
|
|
24
|
+
* except a *non-existent* candidate is normalized without throwing (Python's
|
|
25
|
+
* non-strict `resolve()` does not raise), so {@link resolveCliPath} only
|
|
26
|
+
* realpaths a path that exists.
|
|
27
|
+
* - Python truthiness (`""`/`None` falsy) via {@link pyTruthy}.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, } from 'node:fs';
|
|
30
|
+
import { homedir } from 'node:os';
|
|
31
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import { registryPrecedence } from './overlays.js';
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Python-compatibility helper (copied locally per-module, matching reporter.ts)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* Python-truthiness for the values used here: `null`/`undefined`/`""` and an
|
|
39
|
+
* empty array are falsy (mirrors `if x:`).
|
|
40
|
+
*/
|
|
41
|
+
function pyTruthy(value) {
|
|
42
|
+
if (value === null || value === undefined || value === false)
|
|
43
|
+
return false;
|
|
44
|
+
if (value === 0 || value === '')
|
|
45
|
+
return false;
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return value.length > 0;
|
|
48
|
+
if (typeof value === 'object')
|
|
49
|
+
return Object.keys(value).length > 0;
|
|
50
|
+
return Boolean(value);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Order two strings by Unicode code point, matching Python `sorted()`. JS's
|
|
54
|
+
* default string comparison is by UTF-16 code unit, which mis-orders names
|
|
55
|
+
* containing astral characters (a lead surrogate 0xD800-0xDBFF sorts before
|
|
56
|
+
* BMP chars like U+E000, where Python orders by the true code point). Skill
|
|
57
|
+
* names are realistically ASCII, but this keeps discovery order oracle-faithful.
|
|
58
|
+
*/
|
|
59
|
+
function codePointCompare(a, b) {
|
|
60
|
+
const ca = [...a];
|
|
61
|
+
const cb = [...b];
|
|
62
|
+
const n = Math.min(ca.length, cb.length);
|
|
63
|
+
for (let i = 0; i < n; i++) {
|
|
64
|
+
const d = ca[i].codePointAt(0) - cb[i].codePointAt(0);
|
|
65
|
+
if (d !== 0)
|
|
66
|
+
return d < 0 ? -1 : 1;
|
|
67
|
+
}
|
|
68
|
+
return ca.length - cb.length;
|
|
69
|
+
}
|
|
70
|
+
/** Bundled skills live at `<repo>/agents/skills`. Python: `_AGENTS_SKILLS_DIR`. */
|
|
71
|
+
function defaultAgentsSkillsDir() {
|
|
72
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
73
|
+
// here = ts/src/core -> repo root is three levels up (Python: parents[2]).
|
|
74
|
+
return resolve(here, '..', '..', '..', 'agents', 'skills');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A discovered skill (Python: `SkillInfo` dataclass).
|
|
78
|
+
*
|
|
79
|
+
* `cli`/`entry` are populated only when the SKILL.md frontmatter declares them;
|
|
80
|
+
* markdown-only skills leave both `null`.
|
|
81
|
+
*/
|
|
82
|
+
export class SkillInfo {
|
|
83
|
+
name;
|
|
84
|
+
path; // the SKILL.md file
|
|
85
|
+
source; // "bundled" | "overlay" | "global" | "local"
|
|
86
|
+
description;
|
|
87
|
+
cli;
|
|
88
|
+
entry;
|
|
89
|
+
// Shapes this skill should be deployed to during `canary migrate`. Empty
|
|
90
|
+
// means the skill is not auto-deployed; ["all"] deploys regardless of shape.
|
|
91
|
+
deploy_to;
|
|
92
|
+
// Runtime tools the skill needs (#336), e.g. ["python3>=3.10", "node>=20"].
|
|
93
|
+
requires;
|
|
94
|
+
// Validation error captured at discovery time; when set the skill is still
|
|
95
|
+
// listed but `canary skills run` refuses to invoke it.
|
|
96
|
+
error;
|
|
97
|
+
constructor(init) {
|
|
98
|
+
this.name = init.name;
|
|
99
|
+
this.path = init.path;
|
|
100
|
+
this.source = init.source;
|
|
101
|
+
this.description = init.description ?? '';
|
|
102
|
+
this.cli = init.cli ?? null;
|
|
103
|
+
this.entry = init.entry ?? null;
|
|
104
|
+
this.deploy_to = init.deploy_to ?? [];
|
|
105
|
+
this.requires = init.requires ?? [];
|
|
106
|
+
this.error = init.error ?? null;
|
|
107
|
+
}
|
|
108
|
+
/** Directory containing SKILL.md - base for relative cli paths (Python: `dir`). */
|
|
109
|
+
get dir() {
|
|
110
|
+
return dirname(this.path);
|
|
111
|
+
}
|
|
112
|
+
/** Python: `is_executable`. */
|
|
113
|
+
get isExecutable() {
|
|
114
|
+
return (this.cli !== null || this.entry !== null) && this.error === null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Discover skills from bundled defaults, global home-dir, and local overlays.
|
|
119
|
+
*
|
|
120
|
+
* Precedence (lowest -> highest): bundled, overlay, global, local. See the
|
|
121
|
+
* Python module docstring for the full source-tier description.
|
|
122
|
+
*/
|
|
123
|
+
export class SkillRegistry {
|
|
124
|
+
home;
|
|
125
|
+
agentsSkillsDir;
|
|
126
|
+
constructor(home, agentsSkillsDir) {
|
|
127
|
+
this.home = home ?? homedir();
|
|
128
|
+
this.agentsSkillsDir = agentsSkillsDir ?? defaultAgentsSkillsDir();
|
|
129
|
+
}
|
|
130
|
+
discover(root) {
|
|
131
|
+
const skills = new Map();
|
|
132
|
+
for (const info of this.bundledSlashSkills()) {
|
|
133
|
+
skills.set(info.name, info);
|
|
134
|
+
}
|
|
135
|
+
for (const info of this.bundledHarnessSkills()) {
|
|
136
|
+
if (!skills.has(info.name))
|
|
137
|
+
skills.set(info.name, info); // setdefault
|
|
138
|
+
}
|
|
139
|
+
for (const info of this.overlaySkills()) {
|
|
140
|
+
skills.set(info.name, info); // overlay wins over bundled
|
|
141
|
+
}
|
|
142
|
+
for (const info of this.globalSkills()) {
|
|
143
|
+
skills.set(info.name, info); // global wins over overlay
|
|
144
|
+
}
|
|
145
|
+
const searchRoot = resolve(root ?? process.cwd());
|
|
146
|
+
for (const candidate of SkillRegistry.ancestorsToGitRoot(searchRoot)) {
|
|
147
|
+
for (const info of this.localOverlaySkills(candidate)) {
|
|
148
|
+
skills.set(info.name, info); // local wins over global
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...skills.values()].sort((a, b) => codePointCompare(a.name, b.name));
|
|
152
|
+
}
|
|
153
|
+
/** Return the SkillInfo for `name` honoring precedence, or null. */
|
|
154
|
+
find(name, root) {
|
|
155
|
+
for (const skill of this.discover(root)) {
|
|
156
|
+
if (skill.name === name)
|
|
157
|
+
return skill;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
// ------------------------------------------------------------------
|
|
162
|
+
// Bundled skill sources
|
|
163
|
+
// ------------------------------------------------------------------
|
|
164
|
+
/** Flat `*.md` files in `agents/skills/` - Claude Code slash commands. */
|
|
165
|
+
bundledSlashSkills() {
|
|
166
|
+
const results = [];
|
|
167
|
+
if (!existsSync(this.agentsSkillsDir))
|
|
168
|
+
return results;
|
|
169
|
+
const names = SkillRegistry.listEntries(this.agentsSkillsDir)
|
|
170
|
+
.filter((n) => n.endsWith('.md'))
|
|
171
|
+
.sort();
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
if (name === 'README.md')
|
|
174
|
+
continue;
|
|
175
|
+
const path = join(this.agentsSkillsDir, name);
|
|
176
|
+
if (!SkillRegistry.isFile(path))
|
|
177
|
+
continue;
|
|
178
|
+
const info = this.parseFlat(path, 'bundled');
|
|
179
|
+
if (info)
|
|
180
|
+
results.push(info);
|
|
181
|
+
}
|
|
182
|
+
return results;
|
|
183
|
+
}
|
|
184
|
+
/** Nested `claude-code/<name>/SKILL.md` - prescriptive harness skills. */
|
|
185
|
+
bundledHarnessSkills() {
|
|
186
|
+
const results = [];
|
|
187
|
+
const harnessDir = join(this.agentsSkillsDir, 'claude-code');
|
|
188
|
+
if (!existsSync(harnessDir))
|
|
189
|
+
return results;
|
|
190
|
+
for (const dirName of SkillRegistry.listEntries(harnessDir).sort()) {
|
|
191
|
+
const path = join(harnessDir, dirName, 'SKILL.md');
|
|
192
|
+
if (existsSync(path)) {
|
|
193
|
+
const info = this.parseNested(path, dirName, 'bundled');
|
|
194
|
+
if (info)
|
|
195
|
+
results.push(info);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
// ------------------------------------------------------------------
|
|
201
|
+
// Global home-dir skills
|
|
202
|
+
// ------------------------------------------------------------------
|
|
203
|
+
/** Skills in `~/.canary/skills/<name>/SKILL.md`. */
|
|
204
|
+
globalSkills() {
|
|
205
|
+
const results = [];
|
|
206
|
+
const globalDir = join(this.home, '.canary', 'skills');
|
|
207
|
+
if (!SkillRegistry.isDir(globalDir))
|
|
208
|
+
return results;
|
|
209
|
+
for (const dirName of SkillRegistry.listDirs(globalDir).sort()) {
|
|
210
|
+
const path = join(globalDir, dirName, 'SKILL.md');
|
|
211
|
+
if (existsSync(path)) {
|
|
212
|
+
const info = this.parseNested(path, dirName, 'global');
|
|
213
|
+
if (info)
|
|
214
|
+
results.push(info);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return results;
|
|
218
|
+
}
|
|
219
|
+
// ------------------------------------------------------------------
|
|
220
|
+
// Tracked-overlay skills
|
|
221
|
+
// ------------------------------------------------------------------
|
|
222
|
+
/**
|
|
223
|
+
* Skills from tracked overlays under `~/.canary/overlays/`.
|
|
224
|
+
*
|
|
225
|
+
* Overlays are visited in ascending `(precedence, dir-name)` order and
|
|
226
|
+
* `discover()` lets the last writer win, so the highest-precedence overlay
|
|
227
|
+
* wins a skill-name collision (ties broken by dir-name). `overlays.json` is
|
|
228
|
+
* read read-only for precedence; an absent/malformed registry means every
|
|
229
|
+
* precedence is 0 and the old pure dir-name order applies (#333).
|
|
230
|
+
*/
|
|
231
|
+
overlaySkills() {
|
|
232
|
+
const results = [];
|
|
233
|
+
const overlaysRoot = join(this.home, '.canary', 'overlays');
|
|
234
|
+
if (!SkillRegistry.isDir(overlaysRoot))
|
|
235
|
+
return results;
|
|
236
|
+
const precedence = registryPrecedence(this.home);
|
|
237
|
+
const overlayDirs = SkillRegistry.listDirs(overlaysRoot);
|
|
238
|
+
overlayDirs.sort((a, b) => {
|
|
239
|
+
const pa = precedence[a] ?? 0;
|
|
240
|
+
const pb = precedence[b] ?? 0;
|
|
241
|
+
if (pa !== pb)
|
|
242
|
+
return pa - pb;
|
|
243
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
244
|
+
});
|
|
245
|
+
for (const overlayName of overlayDirs) {
|
|
246
|
+
const skillsDir = join(overlaysRoot, overlayName, '.canary', 'skills');
|
|
247
|
+
if (!SkillRegistry.isDir(skillsDir))
|
|
248
|
+
continue;
|
|
249
|
+
for (const dirName of SkillRegistry.listDirs(skillsDir).sort()) {
|
|
250
|
+
const path = join(skillsDir, dirName, 'SKILL.md');
|
|
251
|
+
if (existsSync(path)) {
|
|
252
|
+
const info = this.parseNested(path, dirName, 'overlay');
|
|
253
|
+
if (info)
|
|
254
|
+
results.push(info);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return results;
|
|
259
|
+
}
|
|
260
|
+
// ------------------------------------------------------------------
|
|
261
|
+
// Local overlay skills
|
|
262
|
+
// ------------------------------------------------------------------
|
|
263
|
+
/** Skills in `<candidate>/.canary/skills/<name>/SKILL.md`. */
|
|
264
|
+
localOverlaySkills(candidate) {
|
|
265
|
+
const results = [];
|
|
266
|
+
const overlayDir = join(candidate, '.canary', 'skills');
|
|
267
|
+
if (!existsSync(overlayDir))
|
|
268
|
+
return results;
|
|
269
|
+
for (const dirName of SkillRegistry.listDirs(overlayDir).sort()) {
|
|
270
|
+
const path = join(overlayDir, dirName, 'SKILL.md');
|
|
271
|
+
if (existsSync(path)) {
|
|
272
|
+
const info = this.parseNested(path, dirName, 'local');
|
|
273
|
+
if (info)
|
|
274
|
+
results.push(info);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return results;
|
|
278
|
+
}
|
|
279
|
+
// ------------------------------------------------------------------
|
|
280
|
+
// Filesystem helpers
|
|
281
|
+
// ------------------------------------------------------------------
|
|
282
|
+
/** Directories from start up to (and including) the git root. */
|
|
283
|
+
static ancestorsToGitRoot(start) {
|
|
284
|
+
const candidates = [];
|
|
285
|
+
let current = start;
|
|
286
|
+
for (;;) {
|
|
287
|
+
candidates.push(current);
|
|
288
|
+
if (existsSync(join(current, '.git')))
|
|
289
|
+
break;
|
|
290
|
+
const parent = dirname(current);
|
|
291
|
+
if (parent === current)
|
|
292
|
+
break;
|
|
293
|
+
current = parent;
|
|
294
|
+
}
|
|
295
|
+
return candidates;
|
|
296
|
+
}
|
|
297
|
+
static listEntries(path) {
|
|
298
|
+
try {
|
|
299
|
+
return readdirSync(path);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
static listDirs(path) {
|
|
306
|
+
try {
|
|
307
|
+
return readdirSync(path, { withFileTypes: true })
|
|
308
|
+
.filter((e) => e.isDirectory())
|
|
309
|
+
.map((e) => e.name);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
static isDir(path) {
|
|
316
|
+
try {
|
|
317
|
+
return statSync(path).isDirectory();
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
static isFile(path) {
|
|
324
|
+
try {
|
|
325
|
+
return statSync(path).isFile();
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// ------------------------------------------------------------------
|
|
332
|
+
// Parsers
|
|
333
|
+
// ------------------------------------------------------------------
|
|
334
|
+
parseFlat(path, source) {
|
|
335
|
+
let text;
|
|
336
|
+
try {
|
|
337
|
+
text = readFileSync(path, 'utf-8');
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const fm = SkillRegistry.parseFrontmatter(text);
|
|
343
|
+
const stem = basename(path, extname(path));
|
|
344
|
+
const name = pyTruthy(fm['name']) ? fm['name'] : stem;
|
|
345
|
+
return new SkillInfo({
|
|
346
|
+
name,
|
|
347
|
+
path,
|
|
348
|
+
source,
|
|
349
|
+
description: SkillRegistry.get(fm, 'description', '') ?? '',
|
|
350
|
+
cli: SkillRegistry.scalar(fm['cli']),
|
|
351
|
+
entry: SkillRegistry.scalar(fm['entry']),
|
|
352
|
+
deploy_to: SkillRegistry.parseDeployTo(fm),
|
|
353
|
+
requires: SkillRegistry.parseStrList(fm, 'requires'),
|
|
354
|
+
error: SkillRegistry.validateExecutableFields(fm),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Public (Python `_parse_nested` is underscore-private but used cross-module):
|
|
358
|
+
// the migrator port parses overlay SKILL.md files directly through this, as
|
|
359
|
+
// `agent/core/migrator.py::_collect_overlay_skills` does with `reg._parse_nested`.
|
|
360
|
+
parseNested(path, dirName, source) {
|
|
361
|
+
let text;
|
|
362
|
+
try {
|
|
363
|
+
text = readFileSync(path, 'utf-8');
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const fm = SkillRegistry.parseFrontmatter(text);
|
|
369
|
+
const name = pyTruthy(fm['name']) ? fm['name'] : dirName;
|
|
370
|
+
// Python: `fm.get("description") or self._blockquote_tagline(text)`.
|
|
371
|
+
const description = pyTruthy(fm['description'])
|
|
372
|
+
? fm['description']
|
|
373
|
+
: SkillRegistry.blockquoteTagline(text);
|
|
374
|
+
return new SkillInfo({
|
|
375
|
+
name,
|
|
376
|
+
path,
|
|
377
|
+
source,
|
|
378
|
+
description,
|
|
379
|
+
cli: SkillRegistry.scalar(fm['cli']),
|
|
380
|
+
entry: SkillRegistry.scalar(fm['entry']),
|
|
381
|
+
deploy_to: SkillRegistry.parseDeployTo(fm),
|
|
382
|
+
requires: SkillRegistry.parseStrList(fm, 'requires'),
|
|
383
|
+
error: SkillRegistry.validateExecutableFields(fm),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/** Python `dict.get(key, default)`: default only on a missing key. */
|
|
387
|
+
static get(fm, key, fallback) {
|
|
388
|
+
return Object.prototype.hasOwnProperty.call(fm, key) ? fm[key] : fallback;
|
|
389
|
+
}
|
|
390
|
+
/** A scalar frontmatter value as `string | null` (Python: `fm.get(key)`). */
|
|
391
|
+
static scalar(value) {
|
|
392
|
+
return typeof value === 'string' ? value : null;
|
|
393
|
+
}
|
|
394
|
+
static parseDeployTo(fm) {
|
|
395
|
+
return SkillRegistry.parseStrList(fm, 'deploy_to');
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Normalize a frontmatter field to `string[]`, accepting either a flow list
|
|
399
|
+
* (`[a, b]`) or a bare scalar (`a`). Python: `_parse_str_list`.
|
|
400
|
+
*/
|
|
401
|
+
static parseStrList(fm, key) {
|
|
402
|
+
const raw = Object.prototype.hasOwnProperty.call(fm, key) ? fm[key] : [];
|
|
403
|
+
if (Array.isArray(raw)) {
|
|
404
|
+
return raw.map((v) => String(v).trim()).filter((v) => v);
|
|
405
|
+
}
|
|
406
|
+
if (typeof raw === 'string' && raw)
|
|
407
|
+
return [raw.trim()];
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Tiny YAML-subset parser: top-level scalar and flow-list fields between `---`
|
|
412
|
+
* delimiters. No nesting, no block sequences, no quoting. Python:
|
|
413
|
+
* `_parse_frontmatter`.
|
|
414
|
+
*/
|
|
415
|
+
static parseFrontmatter(text) {
|
|
416
|
+
const result = {};
|
|
417
|
+
if (!text.startsWith('---'))
|
|
418
|
+
return result;
|
|
419
|
+
const lines = text.split('\n');
|
|
420
|
+
for (let i = 1; i < lines.length; i++) {
|
|
421
|
+
const line = lines[i];
|
|
422
|
+
if (line.trim() === '---')
|
|
423
|
+
break;
|
|
424
|
+
if (!line || line.replace(/^\s+/, '').startsWith('#'))
|
|
425
|
+
continue;
|
|
426
|
+
if (!line.includes(':'))
|
|
427
|
+
continue;
|
|
428
|
+
const idx = line.indexOf(':'); // Python str.partition -> first colon.
|
|
429
|
+
const key = line.slice(0, idx);
|
|
430
|
+
const value = line.slice(idx + 1);
|
|
431
|
+
const v = value.trim();
|
|
432
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
433
|
+
const inner = v.slice(1, -1);
|
|
434
|
+
result[key.trim()] = inner
|
|
435
|
+
.split(',')
|
|
436
|
+
.map((item) => item.trim())
|
|
437
|
+
.filter((item) => item);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
result[key.trim()] = v;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return result;
|
|
444
|
+
}
|
|
445
|
+
/** Return an error string if the cli/entry combination is invalid. */
|
|
446
|
+
static validateExecutableFields(fm) {
|
|
447
|
+
const cli = fm['cli'];
|
|
448
|
+
const entry = fm['entry'];
|
|
449
|
+
// INTENTIONAL DIVERGENCE from the oracle: Python stores a list-valued
|
|
450
|
+
// `cli:`/`entry:` as-is (marking the skill executable), then crashes with a
|
|
451
|
+
// TypeError in resolve_cli_path (`skill_dir / [...]`). Reject the malformed
|
|
452
|
+
// shape loudly here instead — a non-executable skill with a clear diagnostic
|
|
453
|
+
// beats both a runtime crash and the silent markdown-only downgrade the
|
|
454
|
+
// scalar() coercion would otherwise produce. Pinned by a regression test.
|
|
455
|
+
if (Array.isArray(cli) || Array.isArray(entry)) {
|
|
456
|
+
return 'cli:/entry: must be a scalar path, not a list';
|
|
457
|
+
}
|
|
458
|
+
if (pyTruthy(cli) && pyTruthy(entry)) {
|
|
459
|
+
return 'skill declares both cli: and entry: \u{2014} they are mutually exclusive';
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
/** Extract the first blockquote block as a one-line description. */
|
|
464
|
+
static blockquoteTagline(text) {
|
|
465
|
+
const quoteLines = [];
|
|
466
|
+
for (const line of text.split('\n')) {
|
|
467
|
+
const stripped = line.replace(/^\s+/, '');
|
|
468
|
+
if (stripped.startsWith('> ')) {
|
|
469
|
+
quoteLines.push(stripped.slice(2).trim());
|
|
470
|
+
}
|
|
471
|
+
else if (quoteLines.length) {
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return quoteLines.join(' ');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Resolve `skill.cli` to an absolute path inside the skill directory.
|
|
480
|
+
*
|
|
481
|
+
* Throws (Python `ValueError`) if the skill has no `cli:` field, the path
|
|
482
|
+
* escapes the skill dir after symlink resolution, or the target doesn't exist.
|
|
483
|
+
*/
|
|
484
|
+
export function resolveCliPath(skill) {
|
|
485
|
+
if (!pyTruthy(skill.cli)) {
|
|
486
|
+
throw new Error(`skill '${skill.name}' has no cli: field`);
|
|
487
|
+
}
|
|
488
|
+
const skillDir = realpathSync(skill.dir);
|
|
489
|
+
const joined = resolve(skillDir, skill.cli);
|
|
490
|
+
// Python's non-strict `resolve()` never raises on a missing path; realpathSync
|
|
491
|
+
// does, so only resolve symlinks when the candidate exists (which is what
|
|
492
|
+
// catches a symlink that escapes the skill dir).
|
|
493
|
+
const candidate = existsSync(joined) ? realpathSync(joined) : joined;
|
|
494
|
+
const rel = relative(skillDir, candidate);
|
|
495
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
496
|
+
throw new Error(`skill '${skill.name}' cli path escapes the skill directory: ` +
|
|
497
|
+
`'${skill.cli}'`);
|
|
498
|
+
}
|
|
499
|
+
if (!existsSync(candidate)) {
|
|
500
|
+
throw new Error(`skill '${skill.name}' cli target does not exist: '${skill.cli}'`);
|
|
501
|
+
}
|
|
502
|
+
return candidate;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Whether to honor cli:/entry: invocation in the current context.
|
|
506
|
+
*
|
|
507
|
+
* In non-interactive contexts (no TTY, or `CI=true`) executable skills require
|
|
508
|
+
* an explicit opt-in via `allowFlag` to prevent a freshly cloned malicious
|
|
509
|
+
* overlay from silently executing code on the next CI run. Interactive contexts
|
|
510
|
+
* allow execution by default. Python: `is_executable_skill_allowed`.
|
|
511
|
+
*/
|
|
512
|
+
export function isExecutableSkillAllowed(allowFlag) {
|
|
513
|
+
if (allowFlag)
|
|
514
|
+
return true;
|
|
515
|
+
const ci = (process.env.CI ?? '').toLowerCase();
|
|
516
|
+
if (['1', 'true', 'yes'].includes(ci))
|
|
517
|
+
return false;
|
|
518
|
+
if (!process.stdin.isTTY)
|
|
519
|
+
return false;
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
//# sourceMappingURL=skill-registry.js.map
|