@vibe-agent-toolkit/cli 0.1.41-rc.4 → 0.1.41-rc.6

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 (77) hide show
  1. package/dist/bin.js +2 -4
  2. package/dist/bin.js.map +1 -1
  3. package/dist/commands/agent/help.d.ts.map +1 -1
  4. package/dist/commands/agent/help.js +2 -4
  5. package/dist/commands/agent/help.js.map +1 -1
  6. package/dist/commands/claude/org/skills.d.ts +13 -0
  7. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  8. package/dist/commands/claude/org/skills.js +57 -30
  9. package/dist/commands/claude/org/skills.js.map +1 -1
  10. package/dist/commands/claude/plugin/build.d.ts +1 -1
  11. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  12. package/dist/commands/claude/plugin/build.js +239 -82
  13. package/dist/commands/claude/plugin/build.js.map +1 -1
  14. package/dist/commands/claude/plugin/install.d.ts.map +1 -1
  15. package/dist/commands/claude/plugin/install.js +7 -2
  16. package/dist/commands/claude/plugin/install.js.map +1 -1
  17. package/dist/commands/claude/plugin/tree-copy.d.ts +10 -3
  18. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  19. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  20. package/dist/commands/claude/plugin/uninstall.d.ts.map +1 -1
  21. package/dist/commands/claude/plugin/uninstall.js +10 -2
  22. package/dist/commands/claude/plugin/uninstall.js.map +1 -1
  23. package/dist/commands/rag/help.d.ts.map +1 -1
  24. package/dist/commands/rag/help.js +2 -4
  25. package/dist/commands/rag/help.js.map +1 -1
  26. package/dist/commands/resources/help.d.ts.map +1 -1
  27. package/dist/commands/resources/help.js +2 -4
  28. package/dist/commands/resources/help.js.map +1 -1
  29. package/dist/commands/resources/validate.d.ts +19 -0
  30. package/dist/commands/resources/validate.d.ts.map +1 -1
  31. package/dist/commands/resources/validate.js +51 -0
  32. package/dist/commands/resources/validate.js.map +1 -1
  33. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  34. package/dist/commands/skill/test/configure.js +1 -1
  35. package/dist/commands/skill/test/configure.js.map +1 -1
  36. package/dist/commands/skill/test/run.d.ts +111 -24
  37. package/dist/commands/skill/test/run.d.ts.map +1 -1
  38. package/dist/commands/skill/test/run.js +328 -63
  39. package/dist/commands/skill/test/run.js.map +1 -1
  40. package/dist/commands/skills/build.d.ts.map +1 -1
  41. package/dist/commands/skills/build.js +3 -32
  42. package/dist/commands/skills/build.js.map +1 -1
  43. package/dist/commands/skills/install.d.ts.map +1 -1
  44. package/dist/commands/skills/install.js +79 -25
  45. package/dist/commands/skills/install.js.map +1 -1
  46. package/dist/commands/skills/list.d.ts.map +1 -1
  47. package/dist/commands/skills/list.js +9 -5
  48. package/dist/commands/skills/list.js.map +1 -1
  49. package/dist/commands/skills/skill-discovery.d.ts +7 -0
  50. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  51. package/dist/commands/skills/skill-discovery.js +5 -3
  52. package/dist/commands/skills/skill-discovery.js.map +1 -1
  53. package/dist/commands/verify.d.ts.map +1 -1
  54. package/dist/commands/verify.js +7 -2
  55. package/dist/commands/verify.js.map +1 -1
  56. package/dist/skill-resolution/classify.d.ts +7 -2
  57. package/dist/skill-resolution/classify.d.ts.map +1 -1
  58. package/dist/skill-resolution/classify.js +7 -2
  59. package/dist/skill-resolution/classify.js.map +1 -1
  60. package/dist/skill-resolution/resolve-skill-reference.d.ts +10 -8
  61. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  62. package/dist/skill-resolution/resolve-skill-reference.js +62 -23
  63. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  64. package/dist/skill-resolution/types.d.ts +3 -2
  65. package/dist/skill-resolution/types.d.ts.map +1 -1
  66. package/dist/utils/help-loader.d.ts +16 -0
  67. package/dist/utils/help-loader.d.ts.map +1 -1
  68. package/dist/utils/help-loader.js +31 -1
  69. package/dist/utils/help-loader.js.map +1 -1
  70. package/dist/utils/skill-packaging-config.d.ts +15 -6
  71. package/dist/utils/skill-packaging-config.d.ts.map +1 -1
  72. package/dist/utils/skill-packaging-config.js +23 -5
  73. package/dist/utils/skill-packaging-config.js.map +1 -1
  74. package/docs/index.md +39 -0
  75. package/docs/skill-test.md +213 -0
  76. package/docs/skills.md +29 -4
  77. package/package.json +13 -13
@@ -7,12 +7,12 @@
7
7
  */
8
8
  import { existsSync } from 'node:fs';
9
9
  import { basename, dirname, extname } from 'node:path';
10
- import { BootstrapNeededError, DuplicateStagedSkillError, isAcknowledged, mapErrorToExitCode, packageSkill, packagingConfigToPackageOptions, runPreStageBuild, runSkillTestHarness, SecurityAckError, SKILL_TEST_BUILTIN_CAPS, SkillBuildError, SkillTestExitCode, } from '@vibe-agent-toolkit/agent-skills';
11
- import { findProjectRoot, safePath, toForwardSlash } from '@vibe-agent-toolkit/utils';
10
+ import { BootstrapNeededError, buildStaleDistWarningLines, DuplicateStagedSkillError, isAcknowledged, mapErrorToExitCode, packageSkill, packagingConfigToPackageOptions, runPreStageBuild, runSkillTestHarness, SecurityAckError, SKILL_TEST_BUILTIN_CAPS, SkillBuildError, SkillTestExitCode, } from '@vibe-agent-toolkit/agent-skills';
11
+ import { findProjectRoot, resolveAssetReference, safePath, toForwardSlash } from '@vibe-agent-toolkit/utils';
12
12
  import { Command } from 'commander';
13
13
  import { parseSourceSpec } from '../../../skill-resolution/classify.js';
14
14
  import { findDeclaredSkillForPath, findDeclaredSkillForSourceDir, resolveSkillReference, } from '../../../skill-resolution/index.js';
15
- import { ConfigLoadError, loadConfig } from '../../../utils/config-loader.js';
15
+ import { ConfigLoadError, loadConfig, loadConfigCached } from '../../../utils/config-loader.js';
16
16
  import { runClaudePluginBuild } from '../../claude/plugin/build.js';
17
17
  import { assertValidAuth, assertValidRequireAuth } from './auth-flags.js';
18
18
  /** Extract the trailing path segment (cross-platform) from a path-like string. */
@@ -164,34 +164,62 @@ export function descriptorsToRecord(list) {
164
164
  // ---------------------------------------------------------------------------
165
165
  // Precedence resolution: flag > config > default
166
166
  // ---------------------------------------------------------------------------
167
+ /**
168
+ * Read a DECLARED skill's own `skills.config.<name>.test` block from the config that
169
+ * governs IT — keyed by the declared name against that skill's `configRoot`, never by
170
+ * cwd. The one lookup used wherever "what did THIS skill declare?" is the question:
171
+ * {@link loadTestConfig}'s path-target arm and companion `test.build` resolution
172
+ * ({@link resolveCompanionSpec}), so a companion in a nested config never inherits the
173
+ * subject's block.
174
+ */
175
+ function declaredSkillTestConfig(link) {
176
+ return loadConfigCached(link.configRoot)?.skills?.config?.[link.name]?.test;
177
+ }
167
178
  /**
168
179
  * Load the persisted `skills.config.<skill>.test` block for the subject skill,
169
- * PROJECT-AWARE (mirrors the resolver — not a parallel cwd+basename resolver).
180
+ * PROJECT-AWARE (mirrors the resolver's ladder ORDER — not a parallel cwd+basename
181
+ * resolver).
182
+ *
183
+ * A PATH target is mapped back to its declared skill through the SAME two lookups
184
+ * {@link resolveDefinitePath} (in resolve-skill-reference.ts) uses, tried in the SAME
185
+ * order: first {@link findDeclaredSkillForSourceDir} (a path AT a declared skill's
186
+ * SOURCE dir — rung 2a, `buildable`), then {@link findDeclaredSkillForPath} (a path AT
187
+ * its already-built DIST dir — the `source` + `declaredSkill` back-link rung). Either
188
+ * hit is keyed by the DECLARED name against ITS OWN governing config via
189
+ * {@link declaredSkillTestConfig} — cwd-independent, so a path target honors the same
190
+ * model/evals/timeout/`test.build` as its name.
170
191
  *
171
- * A PATH target (`./dist/skills/my-skill/`) is mapped back to its declared skill
172
- * via {@link findDeclaredSkillForPath} (walks up from the path, config-first) and
173
- * keyed by the DECLARED name against the GOVERNING config — so a path target honors
174
- * the same model/evals/timeout as its name, regardless of cwd. A NAME target (or a
175
- * path that maps to no declared skill) falls back to the cwd-anchored governing
176
- * config keyed by basename. Missing config / missing file → undefined (defaults apply).
192
+ * The basename fallback below is a LAST RESORT, reached only when `subject` carries
193
+ * NEITHER link — a bare name (matched by literal string), or a path outside every
194
+ * declared skill's source/dist dir. It must never be the path a LINKED subject's
195
+ * config resolves through: a directory basename that happens to differ from its
196
+ * declared skill's name would otherwise silently miss that skill's `test:` block
197
+ * (including a required `test.build` hook) with no error — the exact failure mode
198
+ * this ordering exists to prevent. Missing config / missing file → undefined
199
+ * (defaults apply).
177
200
  *
178
201
  * A broken config throws {@link ConfigLoadError}; we let it propagate to the preflight
179
202
  * guard in runSkillTestRun, which surfaces it as a clean exit-2 error rather than
180
203
  * silently applying defaults against a config the author clearly intended.
181
204
  */
182
205
  async function loadTestConfig(subject, cwd) {
183
- // Path target → resolve the declared skill it materializes, and read its test
184
- // block from that skill's own governing config root (cwd-independent).
206
+ // Rung 2a: a path AT a declared skill's SOURCE dir — tried FIRST, matching
207
+ // resolveDefinitePath's own precedence.
208
+ const sourceLink = await findDeclaredSkillForSourceDir(subject, cwd);
209
+ if (sourceLink !== undefined)
210
+ return declaredSkillTestConfig(sourceLink);
211
+ // Reverse rung: a path AT a declared skill's already-built DIST dir.
185
212
  const link = await findDeclaredSkillForPath(subject, cwd);
186
- if (link !== undefined) {
187
- return loadConfig(link.configRoot)?.skills?.config?.[link.name]?.test;
188
- }
213
+ if (link !== undefined)
214
+ return declaredSkillTestConfig(link);
189
215
  const projectRoot = findProjectRoot(cwd);
190
216
  if (projectRoot === null)
191
217
  return undefined;
192
218
  const perSkill = loadConfig(projectRoot)?.skills?.config;
193
219
  if (perSkill === undefined)
194
220
  return undefined;
221
+ // Last-resort basename fallback (see doc comment above): only reached when neither
222
+ // lookup above found a declared-skill link for `subject`.
195
223
  const base = lastPathSegment(subject) || subject;
196
224
  return perSkill[subject]?.test ?? perSkill[base]?.test;
197
225
  }
@@ -250,8 +278,14 @@ function applyScalarMerges(opts, options, config) {
250
278
  const model = options.model ?? config?.model;
251
279
  if (model !== undefined)
252
280
  opts.model = model;
253
- if (config?.evals !== undefined)
254
- opts.evalsSubpath = config.evals;
281
+ // `--evals` is resolved HERE, against the process cwd, so the operator's own
282
+ // shell path means what they typed; `test.evals` is left alone and resolves
283
+ // against the skill source, because config travels with the skill. Both then
284
+ // reach the harness as one field, and both accept a path or an npm bare
285
+ // specifier (a suite published as a shared corpus).
286
+ const evalsRef = options.evals === undefined ? config?.evals : resolveAssetReference(options.evals, process.cwd());
287
+ if (evalsRef !== undefined)
288
+ opts.evalsSubpath = evalsRef;
255
289
  }
256
290
  /**
257
291
  * Resolve one cost/runtime knob with flag > config > default precedence AND a
@@ -399,8 +433,16 @@ function sourceScaffoldFields(source, declaredSkill, cwd) {
399
433
  * reference; for a declared skill, builds it (real entry points) and returns the
400
434
  * dist dir to stage; everything else is staged as-is. Throws SkillBuildError
401
435
  * (exit 2) for name-miss / not-found / --no-build-without-dist / build failure.
436
+ *
437
+ * `memo` is the per-run build memo (see {@link BuildMemo}) — pass the SAME map used
438
+ * for companion resolution so a skill that is both subject and companion builds once.
439
+ *
440
+ * `buildCommand` is the SUBJECT's own `test.build` hook, resolved by
441
+ * {@link loadTestConfig} (which carries a bare-name/basename fallback a raw config
442
+ * lookup lacks). It applies to the subject's build ONLY — companions resolve theirs
443
+ * from their own governing config.
402
444
  */
403
- export async function resolveSubjectForTest(ref, cwd, flags) {
445
+ export async function resolveSubjectForTest(ref, cwd, flags, memo, buildCommand) {
404
446
  const resolved = await resolveSkillReference(ref, cwd);
405
447
  switch (resolved.kind) {
406
448
  case 'source':
@@ -418,7 +460,7 @@ export async function resolveSubjectForTest(ref, cwd, flags) {
418
460
  // A buildable ref carries `packagingConfig` — the ONE cleanly-reachable place
419
461
  // the subject's declared executables live — so attach the grader recognition
420
462
  // aid here (a plain source has no packaging config and leaves it undefined).
421
- const subject = await resolveBuildableSubject(resolved, flags);
463
+ const subject = await resolveBuildableSubject(resolved, flags, memo, buildCommand);
422
464
  const declaredExecutables = deriveDeclaredExecutableNames(resolved.packagingConfig.executables);
423
465
  return declaredExecutables === undefined ? subject : { ...subject, declaredExecutables };
424
466
  }
@@ -449,6 +491,98 @@ function resolveExistingDistOrThrow(ref, flags) {
449
491
  }
450
492
  throw new SkillBuildError(`--no-build: no built dist at ${ref.expectedDistDir} for '${ref.name}'. Run \`vat build\` first, or point at a built path.`);
451
493
  }
494
+ /**
495
+ * Identity of the BUILD OPERATION a {@link BuildableReference} triggers — the memo key.
496
+ *
497
+ * A `pool` skill is built by `packageSkill` into its own `expectedDistDir`, so that
498
+ * dir IS the operation's identity. A `plugin-local` skill is built by
499
+ * `runClaudePluginBuild`, which `rm -rf`s and rebuilds EVERY plugin in the whole
500
+ * marketplace — so the operation's identity is the (configRoot, marketplace) pair,
501
+ * NOT the skill. Keying plugin-local per skill would make a second companion in the
502
+ * same marketplace trigger a full marketplace wipe-and-rebuild that reproduces work
503
+ * the first call already did.
504
+ */
505
+ export function buildMemoKey(ref) {
506
+ return ref.distribution.kind === 'pool'
507
+ ? `pool:${ref.expectedDistDir}`
508
+ : `plugin:${ref.configRoot}:${ref.distribution.marketplaceName}`;
509
+ }
510
+ /**
511
+ * Assert a completed build actually MATERIALIZED `ref.expectedDistDir` with a real
512
+ * skill in it, and return the result to stage. A build that reports success but
513
+ * writes nothing (or writes an empty shell with no `SKILL.md`) would otherwise hand
514
+ * back a path a required companion then fails opaquely at staging, or an OPTIONAL
515
+ * one silently skips — the exact non-functional-companion-with-no-diagnostic
516
+ * symptom of issue #158. Two distinct checks, two distinct messages, so the error
517
+ * tells the author which failure mode they hit:
518
+ * 1. `expectedDistDir` itself is missing — "no output at all".
519
+ * 2. `expectedDistDir` exists but has no `SKILL.md` — an empty/incomplete shell.
520
+ *
521
+ * Also runs on a memo HIT, because a plugin-local hit may have been recorded by a
522
+ * DIFFERENT skill in the same marketplace: the dist dir always comes from THIS ref
523
+ * and is always checked.
524
+ */
525
+ function verifyBuiltDist(ref) {
526
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- expectedDistDir derived from project config
527
+ if (!existsSync(ref.expectedDistDir)) {
528
+ throw new SkillBuildError(`Skill build for '${ref.name}' reported success but produced no output at ${ref.expectedDistDir}. ` +
529
+ `Check the skill's packaging config (\`vat build\` should create this directory).`);
530
+ }
531
+ const skillMdPath = safePath.join(ref.expectedDistDir, 'SKILL.md');
532
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- skillMdPath derived from project config
533
+ if (!existsSync(skillMdPath)) {
534
+ throw new SkillBuildError(`Skill build for '${ref.name}' reported success but produced no SKILL.md at ${ref.expectedDistDir}. ` +
535
+ `Check the skill's packaging config (\`vat build\` should create this file).`);
536
+ }
537
+ return { distDir: ref.expectedDistDir, rebuilt: true };
538
+ }
539
+ /**
540
+ * Identity of a `test.build` PRE-STAGE HOOK invocation — the (cwd, command) pair, which
541
+ * is everything that distinguishes one shell invocation from another. Namespaced `hook:`
542
+ * so it can never collide with a {@link buildMemoKey} entry in the shared {@link BuildMemo}.
543
+ */
544
+ function preStageHookMemoKey(configRoot, buildCommand) {
545
+ return `hook:${configRoot}:${buildCommand}`;
546
+ }
547
+ /**
548
+ * Run a skill's `test.build` pre-stage hook, at most ONCE per (configRoot, command)
549
+ * within a run — the "runs that shell command ONCE before staging" contract documented
550
+ * on {@link runPreStageBuild}, preserved now that a run may build several skills.
551
+ *
552
+ * The command ALWAYS runs with the config root of the skill that DECLARED it as cwd
553
+ * (both values come from the same ref), so a subject's hook can never execute against
554
+ * a companion's package root. Memoized only on SUCCESS: a hook that failed and was
555
+ * tolerated (optional companion) is retried rather than treated as done.
556
+ *
557
+ * KNOWN LIMITATION: for `plugin-local` skills, the memo key this call is gated behind
558
+ * ({@link buildMemoKey} in {@link buildDeclaredSkill}) is the shared `plugin:<configRoot>:
559
+ * <marketplace>` key, not a per-skill one — because one marketplace build serves every
560
+ * participant. When two declared skills share a marketplace, only the FIRST participant's
561
+ * `test.build` hook runs; the second skill's own hook is silently skipped, since by the
562
+ * time its build call is reached the memo already reports the marketplace as built and
563
+ * this function is never invoked for it. Fixing that requires gathering the hook set for
564
+ * every marketplace participant BEFORE the one marketplace build — out of scope here.
565
+ */
566
+ function runPreStageBuildOnce(buildCommand, configRoot, memo) {
567
+ if (buildCommand === undefined)
568
+ return;
569
+ const key = preStageHookMemoKey(configRoot, buildCommand);
570
+ if (memo.has(key))
571
+ return;
572
+ runPreStageBuild({ buildCommand, configRoot });
573
+ memo.add(key);
574
+ }
575
+ /** Dispatch the real build for a declared skill, by how it ships. Throws the raw build error. */
576
+ async function runDeclaredSkillBuild(ref) {
577
+ if (ref.distribution.kind === 'pool') {
578
+ await packageSkill(ref.sourcePath, packagingConfigToPackageOptions(ref.packagingConfig, {
579
+ skillPath: ref.sourcePath,
580
+ outputPath: ref.expectedDistDir,
581
+ }));
582
+ return;
583
+ }
584
+ await runClaudePluginBuild(ref.configRoot, { marketplace: ref.distribution.marketplaceName });
585
+ }
452
586
  /**
453
587
  * Build a declared skill (BuildableReference) into its `expectedDistDir`, or reuse
454
588
  * the existing dist under --no-build/--dry-run. Shared by SUBJECT resolution
@@ -457,10 +591,33 @@ function resolveExistingDistOrThrow(ref, flags) {
457
591
  * skill gets the exact same build treatment as the subject (issue #158) — its
458
592
  * `files:` injection runs before staging, instead of a raw, possibly
459
593
  * build-artifact-incomplete, source-tree copy.
594
+ *
595
+ * `buildCommand` is the `test.build` pre-stage hook DECLARED BY THIS REF's skill —
596
+ * never a shared per-run flag. It runs with `ref.configRoot` as cwd, so command and
597
+ * cwd always come from the same skill's config (see {@link runPreStageBuildOnce}).
598
+ * GUARANTEED to run (once, on the first call for this ref) for a `pool`-distributed
599
+ * skill, whose memo key is per-skill. KNOWN LIMITATION for `plugin-local` skills: the
600
+ * memo key is the shared marketplace build, so when two declared skills share a
601
+ * marketplace, only the FIRST participant reaching this function gets its hook run —
602
+ * the second skill's build is served by the memo hit at the check below, and its own
603
+ * `test.build` hook is silently skipped (see {@link runPreStageBuildOnce}).
604
+ *
605
+ * `memo` collapses repeat work within ONE run (see {@link buildMemoKey}). It is
606
+ * consulted only AFTER the no-build/dry-run short-circuit and AFTER the security-ack
607
+ * gate: a cached build result must never be handed to a caller that has not passed
608
+ * the ack, and the no-build branch performs no build to memoize.
460
609
  */
461
- async function buildDeclaredSkill(ref, flags) {
462
- // --no-build and --dry-run never build — delegate to the dedicated branch helper.
463
- if (flags.noBuild || flags.dryRun) {
610
+ async function buildDeclaredSkill(ref, flags, memo, buildCommand) {
611
+ // --dry-run DOES build once acknowledged: the "dry" part is the skill TESTING (no
612
+ // Claude session, no tokens), and a preview assembled from a stale dist previews
613
+ // something a real run would not test — which is the one job a preview has.
614
+ //
615
+ // Without the acknowledgement it still does NOT build. Building runs the repo's
616
+ // committed `test.build` hook, an arbitrary shell command, so an unacknowledged
617
+ // dry run stays the one mode that is safe to point at an untrusted clone. That
618
+ // preview falls back to an existing dist and says so (stale warning) — the honest
619
+ // trade, since accuracy requires consent to execute the repo's own build.
620
+ if (flags.noBuild || (flags.dryRun && !flags.explicitAck)) {
464
621
  return resolveExistingDistOrThrow(ref, flags);
465
622
  }
466
623
  // SECURITY (§12): reaching here means a real build WOULD run — runPreStageBuild
@@ -471,30 +628,36 @@ async function buildDeclaredSkill(ref, flags) {
471
628
  if (!flags.acknowledged) {
472
629
  throw new SecurityAckError();
473
630
  }
631
+ const key = buildMemoKey(ref);
632
+ // Memo HIT: this operation already ran in THIS process — skip the rebuild, but
633
+ // still verify this ref's own dist (the hit may be a marketplace sibling's).
634
+ if (memo.has(key))
635
+ return verifyBuiltDist(ref);
474
636
  // test.build hook (upstream artifacts) BEFORE the skill build -- ordering matters.
475
- if (flags.build !== undefined) {
476
- runPreStageBuild({ buildCommand: flags.build, configRoot: ref.configRoot });
477
- }
637
+ runPreStageBuildOnce(buildCommand, ref.configRoot, memo);
638
+ // ANY error thrown here is wrapped as `SkillBuildError` below, not just a narrowly-
639
+ // scoped build-tool failure — see {@link isSurvivableCompanionFailure} for why that
640
+ // matters to companion degradation. `err.stack` is also captured at construction,
641
+ // so it keeps reflecting THIS un-prefixed message even after a caller mutates
642
+ // `.message` in place (see {@link rethrowNamingCompanion}).
478
643
  try {
479
- if (ref.distribution.kind === 'pool') {
480
- await packageSkill(ref.sourcePath, packagingConfigToPackageOptions(ref.packagingConfig, {
481
- skillPath: ref.sourcePath,
482
- outputPath: ref.expectedDistDir,
483
- }));
484
- }
485
- else {
486
- await runClaudePluginBuild(ref.configRoot, { marketplace: ref.distribution.marketplaceName });
487
- }
644
+ await runDeclaredSkillBuild(ref);
488
645
  }
489
646
  catch (e) {
490
647
  throw new SkillBuildError(`Skill build failed for '${ref.name}': ${e instanceof Error ? e.message : String(e)}`);
491
648
  }
492
- return { distDir: ref.expectedDistDir, rebuilt: true };
649
+ // Record the operation as soon as the build COMMAND succeeded — before verifying
650
+ // this ref's output. The build did run; a marketplace sibling must not re-run it
651
+ // just because this skill's own dist turned out to be missing. Verification is
652
+ // deliberately OUTSIDE the try/catch so its SkillBuildError is not re-wrapped into
653
+ // a doubled "Skill build failed for 'x': Skill build for 'x' reported success…".
654
+ memo.add(key);
655
+ return verifyBuiltDist(ref);
493
656
  }
494
657
  /** Build a declared skill (or stage its existing dist under --no-build/--dry-run), then return the dist to stage. */
495
- async function resolveBuildableSubject(ref, flags) {
658
+ async function resolveBuildableSubject(ref, flags, memo, buildCommand) {
496
659
  const scaffoldDir = dirname(ref.sourcePath);
497
- const build = await buildDeclaredSkill(ref, flags);
660
+ const build = await buildDeclaredSkill(ref, flags, memo, buildCommand);
498
661
  return {
499
662
  subjectSource: { path: build.distDir },
500
663
  subjectScaffoldDir: scaffoldDir,
@@ -505,6 +668,69 @@ async function resolveBuildableSubject(ref, flags) {
505
668
  : { dryRunStagedExistingDist: build.dryRunStagedExistingDist }),
506
669
  };
507
670
  }
671
+ /**
672
+ * May an OPTIONAL companion's failure be DEGRADED to "stage its raw source instead"
673
+ * rather than failing the run? True for a genuine build failure ({@link SkillBuildError})
674
+ * that could not have been DESTRUCTIVE. Both narrowings matter:
675
+ *
676
+ * - **Error class.** Everything else reaching the catch is a user-fixable PREFLIGHT
677
+ * problem, not a degraded companion: a {@link SecurityAckError} (the fix is
678
+ * `--i-understand-this-runs-skill-code`, not "your companion didn't build"), a
679
+ * `ConfigLoadError` (a broken config reported as a build failure), or a
680
+ * `BuildHookError` (a genuinely misconfigured `test.build`). Swallowing those
681
+ * replaces the actionable message with a misleading one. Note that
682
+ * {@link buildDeclaredSkill} wraps ANY error `runDeclaredSkillBuild` throws —
683
+ * including a malformed packaging config or a stray `TypeError` deep inside
684
+ * `packageSkill` — into `SkillBuildError`, so "genuine build failure" here really
685
+ * means "anything the build call raised", not just a narrowly-scoped build error.
686
+ * - **Destructiveness.** A `plugin-local` build is DESTRUCTIVE once it reaches
687
+ * `runClaudePluginBuild`: that function `rm -rf`s the whole marketplace output
688
+ * tree BEFORE building, so a failure THERE may leave the user's
689
+ * `dist/.claude/plugins/marketplaces/<mp>/` already gone. Continuing would stage
690
+ * from an inconsistent dist and finish SUCCESS with the build output deleted — a
691
+ * test command must never destroy build output and report only a "Note:". A
692
+ * `pool` build writes only its own `expectedDistDir` and destroys nothing, so
693
+ * falling back to the raw source tree is always safe there. And under
694
+ * `--no-build`/`--dry-run`, `resolveExistingDistOrThrow` throws BEFORE
695
+ * `buildDeclaredSkill` reaches any build path at all — no build was attempted,
696
+ * plugin-local or not, so nothing was wiped either way and degrading is safe.
697
+ *
698
+ * A real (non `--no-build`/`--dry-run`) `plugin-local` failure stays fail-closed
699
+ * because it MAY have failed mid-wipe; `runClaudePluginBuild` can also throw BEFORE
700
+ * the `rm` (broken config, colliding plugin names), but those are indistinguishable
701
+ * from outside once wrapped in `SkillBuildError`, so fail-closed is deliberate there.
702
+ */
703
+ function isSurvivableCompanionFailure(err, declared, optional, flags) {
704
+ if (!optional || !(err instanceof SkillBuildError))
705
+ return false;
706
+ return declared.distribution.kind === 'pool' || flags.noBuild;
707
+ }
708
+ /**
709
+ * Rethrow a companion failure with the `--with`/`--with-optional` ALIAS the user
710
+ * TYPED prefixed onto its message. Build errors name only the DECLARED skill, which
711
+ * with several companions leaves the user unable to tell which flag to fix.
712
+ *
713
+ * The message is prefixed IN PLACE so the error's CLASS (and therefore its
714
+ * `exitCode` / {@link mapErrorToExitCode} mapping) is unchanged — no new error class,
715
+ * no re-wrapping. Prefixing is skipped when the message is ALREADY prefixed (starts
716
+ * with `companion '`) — this makes the function idempotent against repeat rethrows of
717
+ * the SAME error object, structurally, rather than relying on a hand-maintained
718
+ * argument that no caught error class is ever cached/reused across companions.
719
+ * {@link ConfigLoadError} instances ARE cached and re-thrown per config root by
720
+ * `loadConfigCached`, so mutating one in place would otherwise corrupt the cache; the
721
+ * already-prefixed check covers this case too (in practice `ConfigLoadError` is
722
+ * thrown by the lookup ABOVE this try and never reaches here — belt-and-braces).
723
+ *
724
+ * NOTE: `err.stack` was captured at construction time and therefore still reflects
725
+ * the UN-PREFIXED message — mutating `.message` afterward does not update `.stack`.
726
+ * Debug output that prints `.stack` can disagree with the prefixed text on stderr.
727
+ */
728
+ function rethrowNamingCompanion(err, alias, declaredName) {
729
+ if (err instanceof Error && !err.message.startsWith("companion '")) {
730
+ err.message = `companion '${alias}' (declared skill '${declaredName}'): ${err.message}`;
731
+ }
732
+ throw err;
733
+ }
508
734
  /**
509
735
  * Companion analog of {@link resolveBuildableSubject} (issue #158): resolve every
510
736
  * `--with`/`--with-optional` companion whose spec is a `{ path }` pointing at a
@@ -513,37 +739,53 @@ async function resolveBuildableSubject(ref, flags) {
513
739
  * url:/vendored specs, or a path outside this project's config — is "a different
514
740
  * story" (per the issue's own framing): left untouched, staged as today.
515
741
  *
516
- * A REQUIRED companion's build failure propagates (matches the existing "--with
517
- * fails if a source cannot be resolved" contract, extended to "cannot be built").
518
- * An OPTIONAL companion's build failure falls back to the ORIGINAL (unbuilt) path
519
- * spec — this fix must never make an already-degraded optional companion WORSE;
520
- * staging's existing skip-with-warning fallback still applies if that raw copy
521
- * also fails to stage.
742
+ * The `test.build` hook used is the COMPANION's own — read from the config that
743
+ * governs IT ({@link declaredSkillTestConfig}), not the subject's. The subject's hook
744
+ * is a different skill's command in a (possibly) different package, and running it
745
+ * here would execute it with the companion's config root as cwd.
746
+ *
747
+ * FAILURE HANDLING. A REQUIRED companion's failure ALWAYS propagates (the existing
748
+ * "--with fails if a source cannot be resolved" contract, extended to "cannot be
749
+ * built"), re-messaged to name the alias the user typed. An OPTIONAL companion
750
+ * degrades to the ORIGINAL (unbuilt) path spec — with a mandatory stderr note — only
751
+ * for the narrow, non-destructive case {@link isSurvivableCompanionFailure} allows;
752
+ * every other failure propagates for it too. Staging's existing skip-with-warning
753
+ * fallback still applies if the raw copy also fails to stage.
754
+ *
755
+ * DRY-RUN STALENESS: a `--dry-run` preview that staged an EXISTING
756
+ * companion dist WITHOUT rebuilding it warns on stderr, naming the companion +
757
+ * its declared skill, using {@link buildStaleDistWarningLines} — the SAME
758
+ * warning-construction the subject's own dry-run summary uses (never a copied
759
+ * string), so the identical fact ("this preview may be stale") warns for either
760
+ * role instead of only the subject.
522
761
  */
523
- export async function resolveCompanionSpec(name, spec, repoRoot, flags, optional) {
762
+ export async function resolveCompanionSpec(name, spec, repoRoot, flags, optional, memo) {
524
763
  if (!('path' in spec))
525
764
  return spec;
526
765
  const declared = await findDeclaredSkillForSourceDir(spec.path, repoRoot);
527
766
  if (declared === undefined)
528
767
  return spec;
529
768
  try {
530
- const build = await buildDeclaredSkill(declared, flags);
769
+ const build = await buildDeclaredSkill(declared, flags, memo, declaredSkillTestConfig(declared)?.build);
770
+ if (build.dryRunStagedExistingDist === true) {
771
+ process.stderr.write(buildStaleDistWarningLines(`companion '${name}' (declared skill '${declared.name}')`).join('\n') + '\n');
772
+ }
531
773
  return { path: build.distDir };
532
774
  }
533
775
  catch (e) {
534
- if (!optional)
535
- throw e;
776
+ if (!isSurvivableCompanionFailure(e, declared, optional, flags))
777
+ rethrowNamingCompanion(e, name, declared.name);
536
778
  process.stderr.write(`Note: companion '${name}' (declared skill '${declared.name}') failed to build; staging its raw source instead: ${e instanceof Error ? e.message : String(e)}\n`);
537
779
  return spec;
538
780
  }
539
781
  }
540
782
  /** Resolve every entry of a `--with`/`--with-optional` companion record (see {@link resolveCompanionSpec}). */
541
- export async function resolveCompanionSources(sources, repoRoot, flags, optional) {
783
+ export async function resolveCompanionSources(sources, repoRoot, flags, optional, memo) {
542
784
  if (sources === undefined)
543
785
  return undefined;
544
786
  const resolved = {};
545
787
  for (const [name, spec] of Object.entries(sources)) {
546
- resolved[name] = await resolveCompanionSpec(name, spec, repoRoot, flags, optional);
788
+ resolved[name] = await resolveCompanionSpec(name, spec, repoRoot, flags, optional, memo);
547
789
  }
548
790
  return resolved;
549
791
  }
@@ -595,6 +837,19 @@ async function preflightKnobsAndConfig(subject, options, cwd) {
595
837
  process.exit(SkillTestExitCode.Preflight);
596
838
  }
597
839
  }
840
+ /**
841
+ * Exit code for an error escaping EITHER phase of {@link runSkillTestRun}.
842
+ *
843
+ * A broken governing config ({@link ConfigLoadError}) is a user-fixable PREFLIGHT
844
+ * problem (exit 2), not an internal harness failure — {@link mapErrorToExitCode} has
845
+ * no case for it and falls through to Internal (1). ONE helper shared by BOTH catch
846
+ * blocks so the SUBJECT arm and the COMPANION arm can never drift: a companion's
847
+ * governing config root can differ from the subject's, so a broken config is
848
+ * reachable from either arm and must report the same code from both.
849
+ */
850
+ function exitCodeForRunError(err) {
851
+ return err instanceof ConfigLoadError ? SkillTestExitCode.Preflight : mapErrorToExitCode(err);
852
+ }
598
853
  /**
599
854
  * Copy the resolved-subject fields onto the harness options. Optional fields are
600
855
  * only assigned when present (exactOptionalPropertyTypes). Extracted so
@@ -636,22 +891,28 @@ export async function runSkillTestRun(subject, options) {
636
891
  acknowledgedRunsSkillCode: options.iUnderstandThisRunsSkillCode === true,
637
892
  });
638
893
  // Shared by subject AND companion resolution (issue #158) so a --with/--with-optional
639
- // companion that maps to a declared skill gets the exact same build gating (no-build/
640
- // dry-run/security-ack) as the subject.
894
+ // companion that maps to a declared skill gets the exact same build GATING (no-build/
895
+ // dry-run/security-ack) as the subject. The `test.build` hook is deliberately NOT in
896
+ // here: it is per-skill (see BuildFlags), so the subject's own command is passed only
897
+ // to subject resolution and each companion resolves its own.
641
898
  const buildFlags = {
642
899
  noBuild,
643
900
  dryRun: options.dryRun === true,
644
901
  acknowledged,
645
- ...(config?.build === undefined ? {} : { build: config.build }),
902
+ explicitAck: options.iUnderstandThisRunsSkillCode === true,
646
903
  };
904
+ // ONE memo for the whole run, shared by subject AND companion resolution: a skill
905
+ // that is both the subject and a companion builds exactly once, and N companions
906
+ // in one marketplace trigger ONE marketplace build, not N.
907
+ const buildMemo = new Set();
647
908
  let resolvedSubject;
648
909
  try {
649
- resolvedSubject = await resolveSubjectForTest(subject, process.cwd(), buildFlags);
910
+ resolvedSubject = await resolveSubjectForTest(subject, process.cwd(), buildFlags, buildMemo, config?.build);
650
911
  }
651
912
  catch (err) {
652
913
  // A broken governing config surfacing during subject resolution is a preflight
653
914
  // problem the user must fix (exit 2), not an internal harness failure (exit 1).
654
- const exitCode = err instanceof ConfigLoadError ? SkillTestExitCode.Preflight : mapErrorToExitCode(err);
915
+ const exitCode = exitCodeForRunError(err);
655
916
  process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
656
917
  process.exit(exitCode);
657
918
  return;
@@ -667,10 +928,10 @@ export async function runSkillTestRun(subject, options) {
667
928
  // whose source is a path into a declared skill gets built (its `files:`
668
929
  // injection runs) exactly like the subject, instead of a raw source-tree copy.
669
930
  const repoRoot = resolveRepoRoot();
670
- const withSources = await resolveCompanionSources(harnessOpts.withSources, repoRoot, buildFlags, false);
931
+ const withSources = await resolveCompanionSources(harnessOpts.withSources, repoRoot, buildFlags, false, buildMemo);
671
932
  if (withSources !== undefined)
672
933
  harnessOpts.withSources = withSources;
673
- const withOptional = await resolveCompanionSources(harnessOpts.withOptional, repoRoot, buildFlags, true);
934
+ const withOptional = await resolveCompanionSources(harnessOpts.withOptional, repoRoot, buildFlags, true, buildMemo);
674
935
  if (withOptional !== undefined)
675
936
  harnessOpts.withOptional = withOptional;
676
937
  const result = await runSkillTestHarness(harnessOpts);
@@ -680,7 +941,10 @@ export async function runSkillTestRun(subject, options) {
680
941
  return;
681
942
  }
682
943
  catch (err) {
683
- const exitCode = mapErrorToExitCode(err);
944
+ // Same rule as the subject arm above (see {@link exitCodeForRunError}): companion
945
+ // resolution can surface a ConfigLoadError from a DIFFERENT config root than the
946
+ // subject's, and that must exit 2 (preflight), not 1 (internal).
947
+ const exitCode = exitCodeForRunError(err);
684
948
  // BootstrapNeededError (exit 3) is the happy "wrote a template, fill it in
685
949
  // and re-run" path — surface its message plainly, not as a hard `Error:`.
686
950
  if (err instanceof BootstrapNeededError) {
@@ -701,16 +965,16 @@ export function createSkillTestRunCommand() {
701
965
  command
702
966
  .description('Execute a packaged skill\'s eval suite in a headless, context-isolated Claude session (runs the skill\'s code; not an OS sandbox)')
703
967
  .argument('<skill>', 'The skill to test')
704
- .option('--with <pair...>', 'Stage a REQUIRED companion skill the subject can invoke, as name=<src> (repeatable). <src> is workspace:<pkg> | npm:<spec> | url:<u> | path:<dir> | vendored (e.g. helper=npm:@scope/s@1.2.3). The run fails if a source cannot be resolved.')
705
- .option('--with-optional <pair...>', 'Stage an OPTIONAL companion skill, as name=<src> (same syntax, repeatable). Skipped with a warning if its source cannot be resolved; the run continues.')
706
- .option('--env <pair...>', 'Inject an env var into the executor spawn as KEY=VALUE (repeatable). Values support ${fixturesDir}, ${stagedSkillDir}, ${harnessRoot}, ${resultsDir}. CLI overrides config for the same key.')
968
+ .option('--with <pair...>', 'Stage a REQUIRED companion skill the subject can invoke, as name=<src> (repeatable). <src> is workspace:<pkg> | npm:<spec> | url:<u> | path:<dir> | vendored (e.g. helper=npm:@scope/s@1.2.3). A path:<dir> that maps to a declared skill is BUILT like the subject (files: build artifacts included), not tree-copied as raw source. The run fails if a source cannot be resolved or its build fails.')
969
+ .option('--with-optional <pair...>', 'Stage an OPTIONAL companion skill, as name=<src> (same syntax, repeatable). Staged from its raw (unbuilt) source with a warning if its source cannot be resolved, or if a non-destructive build fails; the run continues.')
970
+ .option('--env <pair...>', 'Inject an env var into the executor spawn as KEY=VALUE (repeatable). Values support ${fixturesDir}, ${stagedSkillDir}, ${harnessRoot}, ${resultsDir}. ${fixturesDir} is per-eval and requires that eval to declare input `files`. CLI overrides config for the same key.')
707
971
  .option('--pass-env <key...>', 'Forward a host env var by NAME to the executor spawn if present (repeatable). Protected names (PATH, auth, model) are ignored.')
708
972
  .option('--refresh', 'Force a full re-stage (ignore existing staged content)')
709
- .option('--no-build', 'Skip building a declared skill; stage its existing dist instead (errors if absent)')
973
+ .option('--no-build', 'Skip building declared skills (subject and any --with/--with-optional companion); stage existing dist instead. Errors if absent for the subject or a REQUIRED companion; an OPTIONAL companion falls back to raw source with a warning.')
710
974
  .option('--workdir <dir>', 'Override the harness working directory')
711
975
  .option('--out <dir>', 'Override the harness output directory')
712
976
  .option('--keep', 'Keep the harness directory after the run')
713
- .option('--dry-run', 'Assemble the command without spawning Claude')
977
+ .option('--dry-run', 'Build and stage exactly as a real run would, then stop without spawning Claude (no tokens spent). Combine with --no-build to skip the build too.')
714
978
  .option('--auth <mode>', 'Auth mechanism: inherit | subscription | api-key | auto')
715
979
  .option('--require-auth <mech>', 'Require a specific auth mechanism: subscription | api-key')
716
980
  .option('--baseline', 'Enable A/B baseline run (with/without skill)')
@@ -719,6 +983,7 @@ export function createSkillTestRunCommand() {
719
983
  .option('--i-understand-this-runs-skill-code', 'Acknowledge this command executes skill code (required)')
720
984
  .option('--model <id>', "Model ID passed VERBATIM to `claude --model <id>` for the model UNDER TEST (the executor). VAT does no mapping/validation (e.g. opus, sonnet, claude-opus-4-8). Omit to use claude's own default model. Independent of --grader-model.")
721
985
  .option('--grader-model <id>', 'Model for the fixed grader/judge, passed VERBATIM to `claude --model` (default claude-sonnet-5). GLOBAL (top-level `test:` config), independent of --model (the model UNDER TEST).')
986
+ .option('--evals <path>', "Eval suite to grade against: a path to an evals.json (resolved against the CURRENT DIRECTORY, so it may point outside the skill's tree) or an npm bare specifier honoring that package's exports map. Overrides `test.evals`, which is resolved against the skill source instead. Use this to test a skill you did not author — a correctly packaged skill ships no evals, because the suite is the answer key.")
722
987
  .option('--max-turns <n>', 'Per-spawn cap on executor/grader turns (positive integer)')
723
988
  .option('--max-budget-usd <n>', 'Hard USD budget cap (positive number)')
724
989
  .option('--timeout <s>', 'Wall-clock timeout in seconds (positive integer)')
@@ -761,7 +1026,7 @@ Model:
761
1026
  Exit Codes:
762
1027
  0 - Harness ran to completion and every eval passed (or --allow-eval-failure suppressed a failing verdict)
763
1028
  1 - Internal error (grader fragment absent/invalid, summary/expectations skew, executor/grader crash, stall/timeout)
764
- 2 - Preflight failed (missing binary, auth error, eval inputs absent, unsafe workdir, ack missing, skill build failed, or --no-build with no existing dist)
1029
+ 2 - Preflight failed (missing binary, auth error, eval inputs absent, unsafe workdir, ack missing, broken project config, a required skill -- subject or --with companion -- failed to build, --no-build with no existing dist for one of them, or an OPTIONAL --with-optional companion hitting a non-survivable failure: a destructive plugin-local build failure, missing security ack, or broken config)
765
1030
  3 - Bootstrap needed: evals.json was absent, so VAT wrote a starter template next to the skill source. Fill it in and re-run.
766
1031
  4 - An eval FAILED (the harness completed and produced a valid grading.json; expectations did not all pass). This is the fail-closed DEFAULT -- suppress with --allow-eval-failure.
767
1032