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.
Files changed (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. 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