@cristobalme/skill-test 0.1.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.
@@ -0,0 +1,1290 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/skill-test.ts
4
+ import { Command } from "commander";
5
+
6
+ // package.json
7
+ var package_default = {
8
+ name: "@cristobalme/skill-test",
9
+ version: "0.1.0",
10
+ description: "Test agent Skills (SKILL.md): static lint, activation triggering, and behavioral grading. Zero-config, offline-capable, CI-first.",
11
+ license: "MIT",
12
+ author: "Cristobal Medina Meza",
13
+ type: "module",
14
+ bin: {
15
+ "skill-test": "dist/bin/skill-test.js",
16
+ "skill-test-comment": "dist/action/comment.js"
17
+ },
18
+ main: "dist/index.js",
19
+ module: "dist/index.js",
20
+ types: "dist/index.d.ts",
21
+ exports: {
22
+ ".": {
23
+ types: "./dist/index.d.ts",
24
+ import: "./dist/index.js",
25
+ require: "./dist/index.cjs"
26
+ }
27
+ },
28
+ files: [
29
+ "dist/index.*",
30
+ "dist/bin",
31
+ "dist/action",
32
+ "action/action.yml",
33
+ "examples",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ engines: {
38
+ node: ">=18"
39
+ },
40
+ publishConfig: {
41
+ access: "public"
42
+ },
43
+ keywords: [
44
+ "skill",
45
+ "skills",
46
+ "agent",
47
+ "agent-skills",
48
+ "SKILL.md",
49
+ "lint",
50
+ "test",
51
+ "ci",
52
+ "claude",
53
+ "anthropic"
54
+ ],
55
+ scripts: {
56
+ build: "tsup",
57
+ dev: "tsup --watch",
58
+ test: "vitest run",
59
+ "test:watch": "vitest",
60
+ typecheck: "tsc --noEmit",
61
+ lint: "eslint .",
62
+ format: "prettier --write .",
63
+ "format:check": "prettier --check .",
64
+ "audit:skills": "node dist/scripts/audit.js",
65
+ prepublishOnly: "npm run build"
66
+ },
67
+ dependencies: {
68
+ "@anthropic-ai/sdk": "^0.32.1",
69
+ commander: "^12.1.0",
70
+ yaml: "^2.6.1"
71
+ },
72
+ devDependencies: {
73
+ "@eslint/js": "^9.17.0",
74
+ "@types/node": "^22.10.0",
75
+ "@typescript-eslint/eslint-plugin": "^8.18.0",
76
+ "@typescript-eslint/parser": "^8.18.0",
77
+ eslint: "^9.17.0",
78
+ prettier: "^3.4.2",
79
+ tsup: "^8.3.5",
80
+ typescript: "^5.7.2",
81
+ vitest: "^2.1.8"
82
+ }
83
+ };
84
+
85
+ // src/cli/commands.ts
86
+ import { writeFileSync as writeFileSync2 } from "fs";
87
+
88
+ // src/types.ts
89
+ var EXIT = {
90
+ OK: 0,
91
+ FAILURES: 1,
92
+ USAGE: 2
93
+ };
94
+
95
+ // src/cli/discover.ts
96
+ import { existsSync, readdirSync, statSync } from "fs";
97
+ import { basename, join, resolve } from "path";
98
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".skill-test-cache"]);
99
+ function discoverSkills(paths) {
100
+ const found = /* @__PURE__ */ new Set();
101
+ for (const p of paths) {
102
+ const abs = resolve(p);
103
+ if (!existsSync(abs)) continue;
104
+ const st = statSync(abs);
105
+ if (st.isFile()) {
106
+ if (basename(abs) === "SKILL.md") found.add(abs);
107
+ continue;
108
+ }
109
+ if (st.isDirectory()) {
110
+ const direct = join(abs, "SKILL.md");
111
+ if (existsSync(direct)) {
112
+ found.add(direct);
113
+ } else {
114
+ for (const f of walk(abs)) found.add(f);
115
+ }
116
+ }
117
+ }
118
+ return [...found].sort();
119
+ }
120
+ function* walk(dir) {
121
+ let entries;
122
+ try {
123
+ entries = readdirSync(dir, { withFileTypes: true });
124
+ } catch {
125
+ return;
126
+ }
127
+ for (const entry of entries) {
128
+ if (entry.isDirectory()) {
129
+ if (SKIP_DIRS.has(entry.name)) continue;
130
+ yield* walk(join(dir, entry.name));
131
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
132
+ yield join(dir, entry.name);
133
+ }
134
+ }
135
+ }
136
+
137
+ // src/parser/parse.ts
138
+ import { readFileSync } from "fs";
139
+ import { dirname, resolve as resolve2 } from "path";
140
+ import { parse as parseYaml } from "yaml";
141
+
142
+ // src/parser/tokens.ts
143
+ function estimateTokens(text) {
144
+ if (!text) return 0;
145
+ const chars = text.length;
146
+ const words = text.trim().split(/\s+/).filter(Boolean).length;
147
+ const byChars = chars / 4;
148
+ const byWords = words * 1.3;
149
+ return Math.ceil(Math.max(byChars, byWords));
150
+ }
151
+
152
+ // src/parser/parse.ts
153
+ var FRONTMATTER_DELIM = /^---\s*$/;
154
+ function parseSkillFile(path) {
155
+ const abs = resolve2(path);
156
+ let raw;
157
+ try {
158
+ raw = readFileSync(abs, "utf8");
159
+ } catch (err2) {
160
+ return emptyParsed(abs, `could not read file: ${err2.message}`);
161
+ }
162
+ return parseSkillContent(raw, abs);
163
+ }
164
+ function parseSkillContent(raw, path) {
165
+ const abs = resolve2(path);
166
+ const dir = dirname(abs);
167
+ const normalized = raw.replace(/\r\n/g, "\n");
168
+ const lines = normalized.split("\n");
169
+ const first = stripBom(lines[0] ?? "");
170
+ if (!FRONTMATTER_DELIM.test(first)) {
171
+ return {
172
+ path: abs,
173
+ dir,
174
+ frontmatter: {},
175
+ body: normalized,
176
+ bodyStartLine: 1,
177
+ bodyTokens: estimateTokens(normalized),
178
+ bodyLines: countBodyLines(normalized),
179
+ hasFrontmatter: false
180
+ };
181
+ }
182
+ let closeIdx = -1;
183
+ for (let i = 1; i < lines.length; i++) {
184
+ if (FRONTMATTER_DELIM.test(lines[i] ?? "")) {
185
+ closeIdx = i;
186
+ break;
187
+ }
188
+ }
189
+ if (closeIdx === -1) {
190
+ return {
191
+ path: abs,
192
+ dir,
193
+ frontmatter: {},
194
+ body: normalized,
195
+ bodyStartLine: 1,
196
+ bodyTokens: estimateTokens(normalized),
197
+ bodyLines: countBodyLines(normalized),
198
+ hasFrontmatter: false,
199
+ parseError: "frontmatter opening '---' has no matching closing '---'"
200
+ };
201
+ }
202
+ const yamlText = lines.slice(1, closeIdx).join("\n");
203
+ const bodyLinesArr = lines.slice(closeIdx + 1);
204
+ const body = bodyLinesArr.join("\n");
205
+ const bodyStartLine = closeIdx + 2;
206
+ let frontmatter = {};
207
+ let parseError;
208
+ try {
209
+ const parsed = parseYaml(yamlText);
210
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
211
+ frontmatter = parsed;
212
+ } else if (parsed != null) {
213
+ parseError = "frontmatter is not a YAML mapping";
214
+ }
215
+ } catch (err2) {
216
+ parseError = `invalid YAML frontmatter: ${err2.message}`;
217
+ }
218
+ return {
219
+ path: abs,
220
+ dir,
221
+ frontmatter,
222
+ body,
223
+ bodyStartLine,
224
+ bodyTokens: estimateTokens(body),
225
+ bodyLines: countBodyLines(body),
226
+ hasFrontmatter: parseError === void 0,
227
+ parseError
228
+ };
229
+ }
230
+ var BOM = String.fromCharCode(65279);
231
+ function stripBom(s) {
232
+ return s.startsWith(BOM) ? s.slice(1) : s;
233
+ }
234
+ function countBodyLines(body) {
235
+ const trimmed = body.replace(/\n+$/, "");
236
+ if (trimmed === "") return 0;
237
+ return trimmed.split("\n").length;
238
+ }
239
+ function emptyParsed(abs, parseError) {
240
+ return {
241
+ path: abs,
242
+ dir: dirname(abs),
243
+ frontmatter: {},
244
+ body: "",
245
+ bodyStartLine: 1,
246
+ bodyTokens: 0,
247
+ bodyLines: 0,
248
+ hasFrontmatter: false,
249
+ parseError
250
+ };
251
+ }
252
+
253
+ // src/lint/rules.ts
254
+ import { existsSync as existsSync2, statSync as statSync2 } from "fs";
255
+ import { basename as basename2, resolve as resolve3 } from "path";
256
+
257
+ // src/spec.ts
258
+ var NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
259
+ var NAME_MAX_LENGTH = 64;
260
+ var DESCRIPTION_MAX_LENGTH = 1024;
261
+ var COMPATIBILITY_MAX_LENGTH = 500;
262
+ var BODY_RECOMMENDED_MAX_TOKENS = 5e3;
263
+ var BODY_WARN_TOKENS = Math.floor(BODY_RECOMMENDED_MAX_TOKENS * 0.9);
264
+ var BODY_RECOMMENDED_MAX_LINES = 500;
265
+ var KNOWN_FRONTMATTER_FIELDS = [
266
+ "name",
267
+ "description",
268
+ "license",
269
+ "compatibility",
270
+ "metadata",
271
+ "allowed-tools"
272
+ ];
273
+
274
+ // src/lint/locate.ts
275
+ function lineOf(skill, needle) {
276
+ if (!needle) return void 0;
277
+ const idx = skill.body.indexOf(needle);
278
+ if (idx === -1) return void 0;
279
+ const before = skill.body.slice(0, idx);
280
+ const newlines = before.split("\n").length - 1;
281
+ return skill.bodyStartLine + newlines;
282
+ }
283
+
284
+ // src/lint/security.ts
285
+ var PATTERNS = [
286
+ {
287
+ ruleId: "security/rm-rf",
288
+ severity: "warn",
289
+ regex: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/i,
290
+ message: "instruction uses `rm -rf`; destructive deletes should be scoped and confirmed"
291
+ },
292
+ {
293
+ ruleId: "security/curl-pipe-sh",
294
+ severity: "warn",
295
+ regex: /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba|z|d)?sh\b/i,
296
+ message: "instruction pipes a download straight into a shell (`curl \u2026 | sh`)"
297
+ },
298
+ {
299
+ ruleId: "security/read-credentials",
300
+ severity: "warn",
301
+ regex: /(\.env\b|id_rsa\b|id_ed25519\b|\.ssh\/|\.aws\/credentials|\.npmrc\b|private[_ -]?key)/i,
302
+ message: "instruction references credentials or secret material (.env, SSH/AWS keys, etc.)"
303
+ },
304
+ {
305
+ ruleId: "security/exfiltration",
306
+ severity: "info",
307
+ regex: /\b(curl|wget|fetch)\b[^\n]*(-d|--data|-X\s*POST|--upload-file)[^\n]*https?:\/\//i,
308
+ message: "instruction sends data to an external URL; verify this is not exfiltration"
309
+ }
310
+ ];
311
+ function securityRule(skill) {
312
+ if (!skill.body) return [];
313
+ const findings = [];
314
+ for (const p of PATTERNS) {
315
+ const m = skill.body.match(p.regex);
316
+ if (m) {
317
+ findings.push({
318
+ ruleId: p.ruleId,
319
+ severity: p.severity,
320
+ message: p.message,
321
+ file: skill.path,
322
+ line: lineOf(skill, m[0])
323
+ });
324
+ }
325
+ }
326
+ return findings;
327
+ }
328
+
329
+ // src/lint/rules.ts
330
+ var charLen = (s) => [...s].length;
331
+ function at(skill, partial) {
332
+ return { file: skill.path, ...partial };
333
+ }
334
+ var frontmatterRule = (skill) => {
335
+ if (skill.parseError && !skill.hasFrontmatter) {
336
+ return [
337
+ at(skill, { ruleId: "frontmatter", severity: "error", message: skill.parseError, line: 1 })
338
+ ];
339
+ }
340
+ if (!skill.hasFrontmatter) {
341
+ return [
342
+ at(skill, {
343
+ ruleId: "frontmatter",
344
+ severity: "error",
345
+ message: "missing YAML frontmatter (a SKILL.md must begin with a '---' block)",
346
+ line: 1
347
+ })
348
+ ];
349
+ }
350
+ return [];
351
+ };
352
+ var nameRule = (skill) => {
353
+ if (!skill.hasFrontmatter) return [];
354
+ const findings = [];
355
+ const name = skill.frontmatter.name;
356
+ if (name === void 0 || name === null || name === "") {
357
+ return [
358
+ at(skill, {
359
+ ruleId: "name-required",
360
+ severity: "error",
361
+ message: "frontmatter is missing the required `name` field",
362
+ line: 1
363
+ })
364
+ ];
365
+ }
366
+ if (typeof name !== "string") {
367
+ return [
368
+ at(skill, {
369
+ ruleId: "name-type",
370
+ severity: "error",
371
+ message: `\`name\` must be a string, got ${typeof name}`,
372
+ line: 1
373
+ })
374
+ ];
375
+ }
376
+ if (charLen(name) > NAME_MAX_LENGTH) {
377
+ findings.push(
378
+ at(skill, {
379
+ ruleId: "name-length",
380
+ severity: "error",
381
+ message: `\`name\` is ${charLen(name)} chars; max is ${NAME_MAX_LENGTH}`,
382
+ line: 1
383
+ })
384
+ );
385
+ }
386
+ if (!NAME_PATTERN.test(name)) {
387
+ findings.push(
388
+ at(skill, {
389
+ ruleId: "name-pattern",
390
+ severity: "error",
391
+ message: `\`name\` "${name}" must be lowercase a-z, 0-9 and single hyphens, not starting/ending with a hyphen`,
392
+ line: 1
393
+ })
394
+ );
395
+ }
396
+ return findings;
397
+ };
398
+ var nameDirMatchRule = (skill) => {
399
+ if (!skill.hasFrontmatter) return [];
400
+ const name = skill.frontmatter.name;
401
+ if (typeof name !== "string" || name === "") return [];
402
+ const dirName = basename2(skill.dir);
403
+ if (name !== dirName) {
404
+ return [
405
+ at(skill, {
406
+ ruleId: "name-dir-match",
407
+ severity: "error",
408
+ message: `\`name\` "${name}" must match the parent directory name "${dirName}"`,
409
+ line: 1
410
+ })
411
+ ];
412
+ }
413
+ return [];
414
+ };
415
+ var descriptionRule = (skill) => {
416
+ if (!skill.hasFrontmatter) return [];
417
+ const desc = skill.frontmatter.description;
418
+ if (desc === void 0 || desc === null || desc === "") {
419
+ return [
420
+ at(skill, {
421
+ ruleId: "description-required",
422
+ severity: "error",
423
+ message: "frontmatter is missing the required, non-empty `description` field",
424
+ line: 1
425
+ })
426
+ ];
427
+ }
428
+ if (typeof desc !== "string") {
429
+ return [
430
+ at(skill, {
431
+ ruleId: "description-type",
432
+ severity: "error",
433
+ message: `\`description\` must be a string, got ${typeof desc}`,
434
+ line: 1
435
+ })
436
+ ];
437
+ }
438
+ if (desc.trim() === "") {
439
+ return [
440
+ at(skill, {
441
+ ruleId: "description-required",
442
+ severity: "error",
443
+ message: "`description` must not be blank",
444
+ line: 1
445
+ })
446
+ ];
447
+ }
448
+ if (charLen(desc) > DESCRIPTION_MAX_LENGTH) {
449
+ return [
450
+ at(skill, {
451
+ ruleId: "description-length",
452
+ severity: "error",
453
+ message: `\`description\` is ${charLen(desc)} chars; max is ${DESCRIPTION_MAX_LENGTH}`,
454
+ line: 1
455
+ })
456
+ ];
457
+ }
458
+ return [];
459
+ };
460
+ var compatibilityRule = (skill) => {
461
+ if (!skill.hasFrontmatter) return [];
462
+ const c = skill.frontmatter.compatibility;
463
+ if (c === void 0 || c === null) return [];
464
+ if (typeof c !== "string") {
465
+ return [
466
+ at(skill, {
467
+ ruleId: "compatibility-type",
468
+ severity: "error",
469
+ message: `\`compatibility\` must be a string, got ${typeof c}`,
470
+ line: 1
471
+ })
472
+ ];
473
+ }
474
+ if (charLen(c) > COMPATIBILITY_MAX_LENGTH) {
475
+ return [
476
+ at(skill, {
477
+ ruleId: "compatibility-length",
478
+ severity: "error",
479
+ message: `\`compatibility\` is ${charLen(c)} chars; max is ${COMPATIBILITY_MAX_LENGTH}`,
480
+ line: 1
481
+ })
482
+ ];
483
+ }
484
+ return [];
485
+ };
486
+ var metadataRule = (skill) => {
487
+ if (!skill.hasFrontmatter) return [];
488
+ const m = skill.frontmatter.metadata;
489
+ if (m === void 0 || m === null) return [];
490
+ if (typeof m !== "object" || Array.isArray(m)) {
491
+ return [
492
+ at(skill, {
493
+ ruleId: "metadata-type",
494
+ severity: "error",
495
+ message: "`metadata` must be a mapping of string keys to string values",
496
+ line: 1
497
+ })
498
+ ];
499
+ }
500
+ const findings = [];
501
+ for (const [k, v] of Object.entries(m)) {
502
+ if (typeof v !== "string") {
503
+ findings.push(
504
+ at(skill, {
505
+ ruleId: "metadata-value-type",
506
+ severity: "warn",
507
+ message: `\`metadata.${k}\` should be a string (the spec defines metadata as string\u2192string)`,
508
+ line: 1
509
+ })
510
+ );
511
+ }
512
+ }
513
+ return findings;
514
+ };
515
+ var allowedToolsRule = (skill) => {
516
+ if (!skill.hasFrontmatter) return [];
517
+ const a = skill.frontmatter["allowed-tools"];
518
+ if (a === void 0 || a === null) return [];
519
+ if (typeof a !== "string") {
520
+ return [
521
+ at(skill, {
522
+ ruleId: "allowed-tools-type",
523
+ severity: "warn",
524
+ message: "`allowed-tools` should be a space-separated string",
525
+ line: 1
526
+ })
527
+ ];
528
+ }
529
+ return [];
530
+ };
531
+ var unknownFieldsRule = (skill) => {
532
+ if (!skill.hasFrontmatter) return [];
533
+ const known = new Set(KNOWN_FRONTMATTER_FIELDS);
534
+ const findings = [];
535
+ for (const key of Object.keys(skill.frontmatter)) {
536
+ if (!known.has(key)) {
537
+ findings.push(
538
+ at(skill, {
539
+ ruleId: "unknown-field",
540
+ severity: "info",
541
+ message: `frontmatter field \`${key}\` is not defined by the Agent Skills spec`,
542
+ line: 1
543
+ })
544
+ );
545
+ }
546
+ }
547
+ return findings;
548
+ };
549
+ var bodyRule = (skill) => {
550
+ const findings = [];
551
+ if (skill.body.trim() === "") {
552
+ findings.push(
553
+ at(skill, {
554
+ ruleId: "body-empty",
555
+ severity: "warn",
556
+ message: "SKILL.md body is empty; add instructions for the agent",
557
+ line: skill.bodyStartLine
558
+ })
559
+ );
560
+ return findings;
561
+ }
562
+ if (skill.bodyTokens > BODY_RECOMMENDED_MAX_TOKENS) {
563
+ findings.push(
564
+ at(skill, {
565
+ ruleId: "body-size",
566
+ severity: "warn",
567
+ message: `body is ~${skill.bodyTokens} tokens; spec recommends under ${BODY_RECOMMENDED_MAX_TOKENS}. Move detail into referenced files.`,
568
+ line: skill.bodyStartLine
569
+ })
570
+ );
571
+ } else if (skill.bodyTokens >= BODY_WARN_TOKENS) {
572
+ findings.push(
573
+ at(skill, {
574
+ ruleId: "body-size",
575
+ severity: "info",
576
+ message: `body is ~${skill.bodyTokens} tokens, approaching the ${BODY_RECOMMENDED_MAX_TOKENS}-token recommendation`,
577
+ line: skill.bodyStartLine
578
+ })
579
+ );
580
+ }
581
+ if (skill.bodyLines > BODY_RECOMMENDED_MAX_LINES) {
582
+ findings.push(
583
+ at(skill, {
584
+ ruleId: "body-lines",
585
+ severity: "warn",
586
+ message: `SKILL.md body is ${skill.bodyLines} lines; spec recommends keeping it under ${BODY_RECOMMENDED_MAX_LINES}`,
587
+ line: skill.bodyStartLine
588
+ })
589
+ );
590
+ }
591
+ return findings;
592
+ };
593
+ var URLISH = /^(https?:|mailto:|tel:|#|\/\/)/i;
594
+ function extractReferences(body) {
595
+ const candidates = /* @__PURE__ */ new Set();
596
+ const linkRe = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
597
+ for (const m of body.matchAll(linkRe)) {
598
+ if (m[1]) candidates.add(m[1].trim());
599
+ }
600
+ const bareRe = /(?:^|[\s`("'])((?:scripts|references|assets)\/[\w./-]+)/g;
601
+ for (const m of body.matchAll(bareRe)) {
602
+ if (m[1]) candidates.add(m[1].trim());
603
+ }
604
+ return [...candidates].filter(isLocalFileRef);
605
+ }
606
+ function isLocalFileRef(p) {
607
+ if (!p) return false;
608
+ if (URLISH.test(p)) return false;
609
+ if (p.startsWith("/")) return false;
610
+ return p.includes("/") || /\.[a-z0-9]+$/i.test(p);
611
+ }
612
+ var referencesRule = (skill) => {
613
+ const findings = [];
614
+ for (const ref of extractReferences(skill.body)) {
615
+ const cleaned = ref.replace(/^\.\//, "");
616
+ const abs = resolve3(skill.dir, cleaned);
617
+ const exists = existsSync2(abs);
618
+ if (!exists) {
619
+ findings.push(
620
+ at(skill, {
621
+ ruleId: "missing-reference",
622
+ severity: "error",
623
+ message: `references a file that does not exist on disk: ${ref}`,
624
+ line: lineOf(skill, ref)
625
+ })
626
+ );
627
+ continue;
628
+ }
629
+ const segments = cleaned.split("/").filter(Boolean);
630
+ if (segments.length > 2 && statSync2(abs).isFile()) {
631
+ findings.push(
632
+ at(skill, {
633
+ ruleId: "reference-depth",
634
+ severity: "info",
635
+ message: `referenced file "${ref}" is more than one level deep; the spec recommends shallow references`,
636
+ line: lineOf(skill, ref)
637
+ })
638
+ );
639
+ }
640
+ }
641
+ return findings;
642
+ };
643
+ var rules = [
644
+ frontmatterRule,
645
+ nameRule,
646
+ nameDirMatchRule,
647
+ descriptionRule,
648
+ compatibilityRule,
649
+ metadataRule,
650
+ allowedToolsRule,
651
+ unknownFieldsRule,
652
+ bodyRule,
653
+ referencesRule,
654
+ securityRule
655
+ ];
656
+
657
+ // src/lint/lint.ts
658
+ function lintSkill(skillPath) {
659
+ const skill = parseSkillFile(skillPath);
660
+ const findings = [];
661
+ for (const rule of rules) {
662
+ findings.push(...rule(skill));
663
+ }
664
+ findings.sort(byLineThenSeverity);
665
+ return {
666
+ layer: "lint",
667
+ skillPath: skill.path,
668
+ findings,
669
+ ok: !findings.some((f) => f.severity === "error")
670
+ };
671
+ }
672
+ var SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
673
+ function byLineThenSeverity(a, b) {
674
+ const la = a.line ?? 0;
675
+ const lb = b.line ?? 0;
676
+ if (la !== lb) return la - lb;
677
+ return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
678
+ }
679
+
680
+ // src/trigger/run-trigger.ts
681
+ import { join as join3 } from "path";
682
+
683
+ // src/trigger/classify.ts
684
+ var DEFAULT_MODEL = "claude-haiku-4-5-20251001";
685
+ async function classifyPrompts(meta, prompts, backend, cache) {
686
+ const results = [];
687
+ for (const prompt of prompts) {
688
+ const key = cache?.key(backend.model, meta.name, meta.description, prompt);
689
+ const hit = key ? cache?.get(key) : void 0;
690
+ if (hit) {
691
+ results.push({ prompt, activated: hit.activated, reason: hit.reason, cached: true });
692
+ continue;
693
+ }
694
+ const res = await backend.classify({
695
+ name: meta.name,
696
+ description: meta.description,
697
+ prompt,
698
+ model: backend.model
699
+ });
700
+ if (key && cache) cache.set(key, { activated: res.activated, reason: res.reason });
701
+ results.push({ prompt, activated: res.activated, reason: res.reason, cached: false });
702
+ }
703
+ return results;
704
+ }
705
+
706
+ // src/trigger/cache.ts
707
+ import { createHash } from "crypto";
708
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
709
+ import { dirname as dirname2 } from "path";
710
+ var TriggerCache = class {
711
+ constructor(file, enabled2 = true) {
712
+ this.file = file;
713
+ this.enabled = enabled2;
714
+ if (this.enabled && existsSync3(file)) {
715
+ try {
716
+ this.store = JSON.parse(readFileSync2(file, "utf8"));
717
+ } catch {
718
+ this.store = {};
719
+ }
720
+ }
721
+ }
722
+ file;
723
+ enabled;
724
+ store = {};
725
+ dirty = false;
726
+ key(model, name, description, prompt) {
727
+ return createHash("sha256").update([model, name, description, prompt].join("\0")).digest("hex");
728
+ }
729
+ get(key) {
730
+ if (!this.enabled) return void 0;
731
+ return this.store[key];
732
+ }
733
+ set(key, value) {
734
+ if (!this.enabled) return;
735
+ this.store[key] = value;
736
+ this.dirty = true;
737
+ }
738
+ /** Persist to disk if anything changed. */
739
+ flush() {
740
+ if (!this.enabled || !this.dirty) return;
741
+ mkdirSync(dirname2(this.file), { recursive: true });
742
+ writeFileSync(this.file, JSON.stringify(this.store, null, 2), "utf8");
743
+ this.dirty = false;
744
+ }
745
+ };
746
+
747
+ // src/trigger/score.ts
748
+ function ratio(numerator, denominator) {
749
+ return denominator === 0 ? 1 : numerator / denominator;
750
+ }
751
+ function computeTriggerResult(skillPath, spec, classifications) {
752
+ const decision = /* @__PURE__ */ new Map();
753
+ for (const c of classifications) decision.set(c.prompt, c.activated);
754
+ let tp = 0;
755
+ let fn = 0;
756
+ const falseNegatives = [];
757
+ for (const prompt of spec.shouldActivate) {
758
+ if (decision.get(prompt)) tp++;
759
+ else {
760
+ fn++;
761
+ falseNegatives.push(prompt);
762
+ }
763
+ }
764
+ let fp = 0;
765
+ let tn = 0;
766
+ const falsePositives = [];
767
+ for (const prompt of spec.shouldNotActivate) {
768
+ if (decision.get(prompt)) {
769
+ fp++;
770
+ falsePositives.push(prompt);
771
+ } else tn++;
772
+ }
773
+ const precision = ratio(tp, tp + fp);
774
+ const recall = ratio(tp, tp + fn);
775
+ const f1 = precision + recall === 0 ? 0 : 2 * precision * recall / (precision + recall);
776
+ const score = {
777
+ truePositives: tp,
778
+ falsePositives: fp,
779
+ trueNegatives: tn,
780
+ falseNegatives: fn,
781
+ precision,
782
+ recall,
783
+ f1
784
+ };
785
+ return {
786
+ layer: "trigger",
787
+ skillPath,
788
+ score,
789
+ classifications,
790
+ falseNegatives,
791
+ falsePositives,
792
+ // A skill passes triggering when it activates exactly when it should.
793
+ ok: fp === 0 && fn === 0
794
+ };
795
+ }
796
+
797
+ // src/trigger/spec.ts
798
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
799
+ import { dirname as dirname3, join as join2 } from "path";
800
+ import { parse as parseYaml2 } from "yaml";
801
+ var SpecValidationError = class extends Error {
802
+ };
803
+ function findSpec(skillPath) {
804
+ const candidate = join2(dirname3(skillPath), "SKILL.test.yaml");
805
+ return existsSync4(candidate) ? candidate : void 0;
806
+ }
807
+ function asStringArray(value, field) {
808
+ if (value === void 0 || value === null) return [];
809
+ if (!Array.isArray(value)) {
810
+ throw new SpecValidationError(`\`${field}\` must be a list of strings`);
811
+ }
812
+ for (const item of value) {
813
+ if (typeof item !== "string") {
814
+ throw new SpecValidationError(`\`${field}\` must contain only strings`);
815
+ }
816
+ }
817
+ return value;
818
+ }
819
+ function loadSpec(specPath) {
820
+ let raw;
821
+ try {
822
+ raw = readFileSync3(specPath, "utf8");
823
+ } catch (err2) {
824
+ throw new SpecValidationError(`could not read spec: ${err2.message}`);
825
+ }
826
+ let doc;
827
+ try {
828
+ doc = parseYaml2(raw);
829
+ } catch (err2) {
830
+ throw new SpecValidationError(`invalid YAML: ${err2.message}`);
831
+ }
832
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
833
+ throw new SpecValidationError("spec must be a YAML mapping");
834
+ }
835
+ const obj = doc;
836
+ const triggering = obj.triggering ?? {};
837
+ if (typeof triggering !== "object" || Array.isArray(triggering)) {
838
+ throw new SpecValidationError("`triggering` must be a mapping");
839
+ }
840
+ const shouldActivate = asStringArray(triggering.should_activate, "triggering.should_activate");
841
+ const shouldNotActivate = asStringArray(
842
+ triggering.should_not_activate,
843
+ "triggering.should_not_activate"
844
+ );
845
+ if (shouldActivate.length === 0 && shouldNotActivate.length === 0) {
846
+ throw new SpecValidationError(
847
+ "spec has no triggering prompts (add triggering.should_activate / should_not_activate)"
848
+ );
849
+ }
850
+ const tasks = obj.tasks === void 0 ? [] : obj.tasks;
851
+ if (!Array.isArray(tasks)) {
852
+ throw new SpecValidationError("`tasks` must be a list");
853
+ }
854
+ return {
855
+ specPath,
856
+ skillRef: typeof obj.skill === "string" ? obj.skill : void 0,
857
+ shouldActivate,
858
+ shouldNotActivate,
859
+ tasks
860
+ };
861
+ }
862
+
863
+ // src/trigger/run-trigger.ts
864
+ var DEFAULT_CACHE_DIR = ".skill-test-cache";
865
+ async function triggerSkill(skillPath, opts = {}) {
866
+ const specPath = findSpec(skillPath);
867
+ if (!specPath) {
868
+ return { layer: "trigger", skipped: true, reason: "no SKILL.test.yaml beside SKILL.md" };
869
+ }
870
+ const skill = parseSkillFile(skillPath);
871
+ const name = skill.frontmatter.name;
872
+ const description = skill.frontmatter.description;
873
+ if (typeof name !== "string" || typeof description !== "string" || !name || !description) {
874
+ return {
875
+ layer: "trigger",
876
+ skipped: true,
877
+ reason: "skill metadata (name/description) missing or invalid \u2014 fix lint errors first"
878
+ };
879
+ }
880
+ if (!opts.backend) {
881
+ return { layer: "trigger", skipped: true, reason: "no ANTHROPIC_API_KEY set" };
882
+ }
883
+ const spec = loadSpec(specPath);
884
+ const prompts = [...spec.shouldActivate, ...spec.shouldNotActivate];
885
+ const cache = opts.cache ?? new TriggerCache(join3(opts.cacheDir ?? DEFAULT_CACHE_DIR, "trigger.json"), !opts.noCache);
886
+ const classifications = await classifyPrompts(
887
+ { name, description },
888
+ prompts,
889
+ opts.backend,
890
+ cache
891
+ );
892
+ cache.flush();
893
+ return computeTriggerResult(skillPath, spec, classifications);
894
+ }
895
+
896
+ // src/backend/anthropic.ts
897
+ import Anthropic from "@anthropic-ai/sdk";
898
+ var SYSTEM = `You simulate how an AI coding agent decides whether to activate a Skill.
899
+
900
+ At startup the agent sees only each skill's name and description \u2014 never its full instructions. When the user sends a message, the agent activates a skill if and only if that skill's description indicates it is relevant to the request.
901
+
902
+ You are given ONE skill's metadata and ONE user message. Decide whether the agent would activate this skill for that message. A good, specific description should activate for on-topic requests and stay silent for unrelated ones.
903
+
904
+ Respond with ONLY a JSON object, no prose:
905
+ {"activate": <true|false>, "reason": "<one short sentence>"}`;
906
+ function buildUserMessage(req) {
907
+ return `Skill name: ${req.name}
908
+ Skill description: ${req.description}
909
+
910
+ User message: ${req.prompt}`;
911
+ }
912
+ function parseDecision(text) {
913
+ const match = text.match(/\{[\s\S]*\}/);
914
+ if (!match) {
915
+ return { activated: false, reason: `could not parse model response: ${text.slice(0, 80)}` };
916
+ }
917
+ try {
918
+ const obj = JSON.parse(match[0]);
919
+ return {
920
+ activated: obj.activate === true,
921
+ reason: typeof obj.reason === "string" ? obj.reason : ""
922
+ };
923
+ } catch {
924
+ return { activated: false, reason: `invalid JSON in model response: ${text.slice(0, 80)}` };
925
+ }
926
+ }
927
+ function createAnthropicBackend(opts = {}) {
928
+ const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
929
+ if (!apiKey) {
930
+ throw new Error("ANTHROPIC_API_KEY is not set");
931
+ }
932
+ const model = opts.model ?? process.env.SKILL_TEST_MODEL ?? DEFAULT_MODEL;
933
+ const client = new Anthropic({ apiKey });
934
+ return {
935
+ model,
936
+ async classify(req) {
937
+ const msg = await client.messages.create({
938
+ model: req.model,
939
+ max_tokens: 200,
940
+ temperature: 0,
941
+ system: SYSTEM,
942
+ messages: [{ role: "user", content: buildUserMessage(req) }]
943
+ });
944
+ const text = msg.content.map((b) => b.type === "text" ? b.text : "").join("").trim();
945
+ return parseDecision(text);
946
+ }
947
+ };
948
+ }
949
+ function hasApiKey() {
950
+ return Boolean(process.env.ANTHROPIC_API_KEY);
951
+ }
952
+
953
+ // src/report/colors.ts
954
+ var enabled = true;
955
+ function setColorEnabled(on) {
956
+ enabled = on;
957
+ }
958
+ var ESC = String.fromCharCode(27);
959
+ function wrap(code, close) {
960
+ return (s) => enabled ? `${ESC}[${code}m${s}${ESC}[${close}m` : s;
961
+ }
962
+ var color = {
963
+ red: wrap(31, 39),
964
+ green: wrap(32, 39),
965
+ yellow: wrap(33, 39),
966
+ blue: wrap(34, 39),
967
+ gray: wrap(90, 39),
968
+ bold: wrap(1, 22),
969
+ dim: wrap(2, 22)
970
+ };
971
+
972
+ // src/report/terminal.ts
973
+ import { relative } from "path";
974
+ function severityLabel(severity) {
975
+ switch (severity) {
976
+ case "error":
977
+ return color.red("error");
978
+ case "warn":
979
+ return color.yellow("warn ");
980
+ case "info":
981
+ return color.blue("info ");
982
+ }
983
+ }
984
+ function rel(p) {
985
+ const r = relative(process.cwd(), p);
986
+ return r === "" || r.startsWith("..") ? p : r;
987
+ }
988
+ function renderFinding(f) {
989
+ const loc = f.line ? `:${f.line}` : "";
990
+ const where = color.dim(`${rel(f.file)}${loc}`);
991
+ const id = color.gray(f.ruleId);
992
+ return ` ${severityLabel(f.severity)} ${f.message} ${id}
993
+ ${where}`;
994
+ }
995
+ function counts(findings) {
996
+ return {
997
+ errors: findings.filter((f) => f.severity === "error").length,
998
+ warns: findings.filter((f) => f.severity === "warn").length,
999
+ infos: findings.filter((f) => f.severity === "info").length
1000
+ };
1001
+ }
1002
+ function renderTrigger(t) {
1003
+ const s = t.score;
1004
+ const pct = (n) => `${(n * 100).toFixed(0)}%`;
1005
+ const head = ` trigger precision ${pct(s.precision)} \xB7 recall ${pct(s.recall)} \xB7 F1 ${pct(s.f1)}`;
1006
+ const lines = [t.ok ? color.green(head) : color.yellow(head)];
1007
+ for (const fn of t.falseNegatives) {
1008
+ lines.push(color.yellow(` \u2717 should activate but didn't: "${fn}"`));
1009
+ }
1010
+ for (const fp of t.falsePositives) {
1011
+ lines.push(color.yellow(` \u2717 should NOT activate but did: "${fp}"`));
1012
+ }
1013
+ return lines.join("\n");
1014
+ }
1015
+ function renderReport(report, opts = {}) {
1016
+ const lines = [];
1017
+ let errors = 0;
1018
+ let warns = 0;
1019
+ for (const sr of report.results) {
1020
+ if (sr.lint) {
1021
+ const c = counts(sr.lint.findings);
1022
+ errors += c.errors;
1023
+ warns += c.warns;
1024
+ }
1025
+ lines.push(renderSkillReport(sr, opts));
1026
+ }
1027
+ lines.push("");
1028
+ const failed = report.results.filter((r) => !r.ok).length;
1029
+ const summary = `${report.results.length} skill(s) \xB7 ${failed} failing \xB7 ${errors} error(s) \xB7 ${warns} warning(s)`;
1030
+ lines.push(report.ok ? color.green(summary) : color.red(summary));
1031
+ return lines.join("\n");
1032
+ }
1033
+ function renderSkillReport(sr, opts) {
1034
+ const status = sr.ok ? color.green("\u2713") : color.red("\u2717");
1035
+ const lines = [`${status} ${color.bold(rel(sr.skillPath))}`];
1036
+ if (sr.lint) {
1037
+ if (!opts.quiet || !sr.lint.ok) {
1038
+ for (const f of sr.lint.findings) lines.push(renderFinding(f));
1039
+ }
1040
+ }
1041
+ if (sr.trigger) {
1042
+ if ("skipped" in sr.trigger) {
1043
+ lines.push(color.dim(` trigger skipped \u2014 ${sr.trigger.reason}`));
1044
+ } else {
1045
+ lines.push(renderTrigger(sr.trigger));
1046
+ }
1047
+ }
1048
+ return lines.join("\n");
1049
+ }
1050
+
1051
+ // src/report/json.ts
1052
+ function reportToJson(report) {
1053
+ return {
1054
+ tool: "skill-test",
1055
+ ok: report.ok,
1056
+ skills: report.results.map((sr) => ({
1057
+ skill: sr.skillPath,
1058
+ ok: sr.ok,
1059
+ lint: sr.lint ? { ok: sr.lint.ok, findings: sr.lint.findings } : void 0,
1060
+ trigger: sr.trigger ? "skipped" in sr.trigger ? { skipped: true, reason: sr.trigger.reason } : {
1061
+ ok: sr.trigger.ok,
1062
+ score: sr.trigger.score,
1063
+ falseNegatives: sr.trigger.falseNegatives,
1064
+ falsePositives: sr.trigger.falsePositives
1065
+ } : void 0
1066
+ }))
1067
+ };
1068
+ }
1069
+
1070
+ // src/report/junit.ts
1071
+ import { basename as basename3, dirname as dirname4 } from "path";
1072
+ function esc(s) {
1073
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1074
+ }
1075
+ function skillName(skillPath) {
1076
+ return basename3(dirname4(skillPath)) || skillPath;
1077
+ }
1078
+ function findingLine(f) {
1079
+ const loc = f.line ? `:${f.line}` : "";
1080
+ return `[${f.severity}] ${f.ruleId} \u2014 ${f.message}${loc}`;
1081
+ }
1082
+ function renderCase(classname, c) {
1083
+ const open = ` <testcase classname="${esc(classname)}" name="${esc(c.name)}">`;
1084
+ if (c.skipped !== void 0) {
1085
+ return `${open}
1086
+ <skipped message="${esc(c.skipped)}"/>
1087
+ </testcase>`;
1088
+ }
1089
+ if (c.failure !== void 0) {
1090
+ return `${open}
1091
+ <failure message="${esc(firstLine(c.failure))}">${esc(c.failure)}</failure>
1092
+ </testcase>`;
1093
+ }
1094
+ return ` <testcase classname="${esc(classname)}" name="${esc(c.name)}"/>`;
1095
+ }
1096
+ function firstLine(s) {
1097
+ return s.split("\n")[0] ?? s;
1098
+ }
1099
+ function casesForSkill(sr) {
1100
+ const cases = [];
1101
+ if (sr.lint) {
1102
+ const errs = sr.lint.findings.filter((f) => f.severity === "error");
1103
+ cases.push({
1104
+ name: "lint",
1105
+ failure: errs.length ? errs.map(findingLine).join("\n") : void 0
1106
+ });
1107
+ }
1108
+ if (sr.trigger) {
1109
+ if ("skipped" in sr.trigger) {
1110
+ cases.push({ name: "trigger", skipped: sr.trigger.reason });
1111
+ } else {
1112
+ const t = sr.trigger;
1113
+ const fail = t.ok === false ? [
1114
+ `precision ${t.score.precision.toFixed(2)} recall ${t.score.recall.toFixed(2)} F1 ${t.score.f1.toFixed(2)}`,
1115
+ ...t.falseNegatives.map((p) => `false negative (should activate): ${p}`),
1116
+ ...t.falsePositives.map((p) => `false positive (should not activate): ${p}`)
1117
+ ].join("\n") : void 0;
1118
+ cases.push({ name: "trigger", failure: fail });
1119
+ }
1120
+ }
1121
+ return cases;
1122
+ }
1123
+ function reportToJUnit(report) {
1124
+ const suites = [];
1125
+ let totalTests = 0;
1126
+ let totalFailures = 0;
1127
+ let totalSkipped = 0;
1128
+ for (const sr of report.results) {
1129
+ const cases = casesForSkill(sr);
1130
+ const failures = cases.filter((c) => c.failure !== void 0).length;
1131
+ const skipped = cases.filter((c) => c.skipped !== void 0).length;
1132
+ totalTests += cases.length;
1133
+ totalFailures += failures;
1134
+ totalSkipped += skipped;
1135
+ const name = skillName(sr.skillPath);
1136
+ const body = cases.map((c) => renderCase(name, c)).join("\n");
1137
+ suites.push(
1138
+ ` <testsuite name="${esc(name)}" tests="${cases.length}" failures="${failures}" skipped="${skipped}">
1139
+ ${body}
1140
+ </testsuite>`
1141
+ );
1142
+ }
1143
+ return [
1144
+ '<?xml version="1.0" encoding="UTF-8"?>',
1145
+ `<testsuites name="skill-test" tests="${totalTests}" failures="${totalFailures}" errors="0" skipped="${totalSkipped}">`,
1146
+ ...suites,
1147
+ "</testsuites>",
1148
+ ""
1149
+ ].join("\n");
1150
+ }
1151
+
1152
+ // src/cli/commands.ts
1153
+ function reportPasses(report, failOn) {
1154
+ for (const sr of report.results) {
1155
+ if (sr.lint) {
1156
+ if (!sr.lint.ok) return false;
1157
+ if (failOn === "warn" && sr.lint.findings.some((f) => f.severity === "warn")) return false;
1158
+ }
1159
+ if (sr.trigger && !("skipped" in sr.trigger) && !sr.trigger.ok) return false;
1160
+ }
1161
+ return true;
1162
+ }
1163
+ function applyColor(opts) {
1164
+ const disabled = opts.color === false || process.env.NO_COLOR !== void 0 || !process.stdout.isTTY;
1165
+ setColorEnabled(!disabled);
1166
+ }
1167
+ function out(s) {
1168
+ process.stdout.write(s.endsWith("\n") ? s : s + "\n");
1169
+ }
1170
+ function err(s) {
1171
+ process.stderr.write(s.endsWith("\n") ? s : s + "\n");
1172
+ }
1173
+ function noSkills(paths) {
1174
+ err(`skill-test: no SKILL.md found at: ${paths.join(", ")}`);
1175
+ return EXIT.USAGE;
1176
+ }
1177
+ function runLint(paths, opts) {
1178
+ applyColor(opts);
1179
+ const files = discoverSkills(paths);
1180
+ if (files.length === 0) return noSkills(paths);
1181
+ const results = files.map((file) => {
1182
+ const lint = lintSkill(file);
1183
+ return { skillPath: file, lint, ok: lint.ok };
1184
+ });
1185
+ const report = { results, ok: results.every((r) => r.ok) };
1186
+ emitReport(report, opts);
1187
+ return reportPasses(report, opts.failOn) ? EXIT.OK : EXIT.FAILURES;
1188
+ }
1189
+ async function runTrigger(paths, opts) {
1190
+ applyColor(opts);
1191
+ const files = discoverSkills(paths);
1192
+ if (files.length === 0) return noSkills(paths);
1193
+ if (!hasApiKey()) {
1194
+ err(
1195
+ "skill-test: `trigger` needs ANTHROPIC_API_KEY (it asks the model to make the same\nload/skip decision the host agent makes). Set the key, or run `lint` for offline checks."
1196
+ );
1197
+ return EXIT.USAGE;
1198
+ }
1199
+ const backend = createAnthropicBackend({ model: opts.model });
1200
+ const results = [];
1201
+ for (const file of files) {
1202
+ const { trigger, ok } = await triggerWithErrorHandling(file, backend);
1203
+ results.push({ skillPath: file, trigger, ok });
1204
+ }
1205
+ const report = { results, ok: results.every((r) => r.ok) };
1206
+ emitReport(report, opts);
1207
+ return reportPasses(report, opts.failOn) ? EXIT.OK : EXIT.FAILURES;
1208
+ }
1209
+ async function runRun(paths, opts) {
1210
+ applyColor(opts);
1211
+ void paths;
1212
+ err("skill-test: the `run` layer is implemented in Phase 5 (stretch).");
1213
+ return EXIT.USAGE;
1214
+ }
1215
+ async function runCheck(paths, opts) {
1216
+ applyColor(opts);
1217
+ const files = discoverSkills(paths);
1218
+ if (files.length === 0) return noSkills(paths);
1219
+ const backend = hasApiKey() ? createAnthropicBackend({ model: opts.model }) : void 0;
1220
+ if (!backend) {
1221
+ err("skill-test: no ANTHROPIC_API_KEY \u2014 running the static layer only (trigger skipped).");
1222
+ }
1223
+ const results = [];
1224
+ for (const file of files) {
1225
+ const lint = lintSkill(file);
1226
+ const { trigger, ok: triggerOk } = await triggerWithErrorHandling(file, backend);
1227
+ results.push({ skillPath: file, lint, trigger, ok: lint.ok && triggerOk });
1228
+ }
1229
+ const report = { results, ok: results.every((r) => r.ok) };
1230
+ emitReport(report, opts);
1231
+ return reportPasses(report, opts.failOn) ? EXIT.OK : EXIT.FAILURES;
1232
+ }
1233
+ async function triggerWithErrorHandling(file, backend) {
1234
+ try {
1235
+ const trigger = await triggerSkill(file, { backend });
1236
+ return { trigger, ok: "skipped" in trigger ? true : trigger.ok };
1237
+ } catch (e) {
1238
+ if (e instanceof SpecValidationError) {
1239
+ err(`skill-test: ${file}: invalid SKILL.test.yaml: ${e.message}`);
1240
+ return {
1241
+ trigger: {
1242
+ layer: "trigger",
1243
+ skipped: true,
1244
+ reason: `invalid SKILL.test.yaml: ${e.message}`
1245
+ },
1246
+ ok: false
1247
+ };
1248
+ }
1249
+ throw e;
1250
+ }
1251
+ }
1252
+ function emitReport(report, opts) {
1253
+ if (opts.junit) {
1254
+ writeFileSync2(opts.junit, reportToJUnit(report), "utf8");
1255
+ }
1256
+ if (opts.json) {
1257
+ out(JSON.stringify(reportToJson(report), null, 2));
1258
+ } else {
1259
+ out(renderReport(report, { quiet: opts.quiet }));
1260
+ }
1261
+ }
1262
+
1263
+ // bin/skill-test.ts
1264
+ var program = new Command();
1265
+ program.name("skill-test").description(
1266
+ "Test agent Skills (SKILL.md): static lint, activation triggering, and behavioral grading."
1267
+ ).version(package_default.version, "-v, --version", "print version").option("--json", "emit machine-readable JSON").option("--junit <file>", "write JUnit XML to <file>").option("--cheap", "skip the behavioral (run) layer").option("--fail-on <level>", "fail on 'error' (default) or 'warn'", "error").option("--model <id>", "classifier model for the trigger layer").option("--quiet", "only print failures").option("--no-color", "disable colored output");
1268
+ program.command("lint").description("Layer 1 \u2014 static, offline checks against the SKILL.md spec").argument("<path...>", "SKILL.md file(s), skill dir(s), or a dir of skills").action((paths, _opts, cmd) => {
1269
+ process.exitCode = runLint(paths, cmd.optsWithGlobals());
1270
+ });
1271
+ program.command("trigger").description("Layer 2 \u2014 activation precision/recall (needs API key + SKILL.test.yaml)").argument("<path...>", "SKILL.md file(s), skill dir(s), or a dir of skills").action(async (paths, _opts, cmd) => {
1272
+ process.exitCode = await runTrigger(paths, cmd.optsWithGlobals());
1273
+ });
1274
+ program.command("run").description("Layer 3 \u2014 behavioral task grading (later phase)").argument("<path...>", "SKILL.md file(s), skill dir(s), or a dir of skills").action(async (paths, _opts, cmd) => {
1275
+ process.exitCode = await runRun(paths, cmd.optsWithGlobals());
1276
+ });
1277
+ program.command("check").description("Run every layer available given config and keys").argument("<path...>", "SKILL.md file(s), skill dir(s), or a dir of skills").action(async (paths, _opts, cmd) => {
1278
+ process.exitCode = await runCheck(paths, cmd.optsWithGlobals());
1279
+ });
1280
+ async function main() {
1281
+ try {
1282
+ await program.parseAsync(process.argv);
1283
+ } catch (err2) {
1284
+ process.stderr.write(`skill-test: ${err2.message}
1285
+ `);
1286
+ process.exitCode = EXIT.USAGE;
1287
+ }
1288
+ }
1289
+ void main();
1290
+ //# sourceMappingURL=skill-test.js.map