@vibe-agent-toolkit/cli 0.1.41-rc.5 → 0.1.41-rc.7

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 (171) hide show
  1. package/README.md +4 -3
  2. package/dist/bin/vat +1 -1
  3. package/dist/bin/vat.js +1 -1
  4. package/dist/bin/vat.js.map +1 -1
  5. package/dist/bin.js +2 -4
  6. package/dist/bin.js.map +1 -1
  7. package/dist/commands/agent/help.d.ts.map +1 -1
  8. package/dist/commands/agent/help.js +2 -4
  9. package/dist/commands/agent/help.js.map +1 -1
  10. package/dist/commands/audit/hierarchical-output.d.ts +6 -2
  11. package/dist/commands/audit/hierarchical-output.d.ts.map +1 -1
  12. package/dist/commands/audit/hierarchical-output.js +93 -50
  13. package/dist/commands/audit/hierarchical-output.js.map +1 -1
  14. package/dist/commands/audit-settings.d.ts +35 -0
  15. package/dist/commands/audit-settings.d.ts.map +1 -1
  16. package/dist/commands/audit-settings.js +121 -51
  17. package/dist/commands/audit-settings.js.map +1 -1
  18. package/dist/commands/audit.d.ts +63 -1
  19. package/dist/commands/audit.d.ts.map +1 -1
  20. package/dist/commands/audit.js +329 -187
  21. package/dist/commands/audit.js.map +1 -1
  22. package/dist/commands/build.d.ts.map +1 -1
  23. package/dist/commands/build.js +47 -18
  24. package/dist/commands/build.js.map +1 -1
  25. package/dist/commands/claude/marketplace/license-utils.d.ts +39 -2
  26. package/dist/commands/claude/marketplace/license-utils.d.ts.map +1 -1
  27. package/dist/commands/claude/marketplace/license-utils.js +114 -68
  28. package/dist/commands/claude/marketplace/license-utils.js.map +1 -1
  29. package/dist/commands/claude/marketplace/publish.js +5 -4
  30. package/dist/commands/claude/marketplace/publish.js.map +1 -1
  31. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  32. package/dist/commands/claude/marketplace/validate.js +13 -19
  33. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  34. package/dist/commands/claude/org/skills.d.ts +21 -0
  35. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  36. package/dist/commands/claude/org/skills.js +107 -30
  37. package/dist/commands/claude/org/skills.js.map +1 -1
  38. package/dist/commands/claude/plugin/build.d.ts +94 -1
  39. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  40. package/dist/commands/claude/plugin/build.js +307 -106
  41. package/dist/commands/claude/plugin/build.js.map +1 -1
  42. package/dist/commands/claude/plugin/install.d.ts.map +1 -1
  43. package/dist/commands/claude/plugin/install.js +7 -2
  44. package/dist/commands/claude/plugin/install.js.map +1 -1
  45. package/dist/commands/claude/plugin/marketplace-json.d.ts +36 -0
  46. package/dist/commands/claude/plugin/marketplace-json.d.ts.map +1 -0
  47. package/dist/commands/claude/plugin/marketplace-json.js +34 -0
  48. package/dist/commands/claude/plugin/marketplace-json.js.map +1 -0
  49. package/dist/commands/claude/plugin/plugin-json-merge.d.ts +30 -1
  50. package/dist/commands/claude/plugin/plugin-json-merge.d.ts.map +1 -1
  51. package/dist/commands/claude/plugin/plugin-json-merge.js +92 -14
  52. package/dist/commands/claude/plugin/plugin-json-merge.js.map +1 -1
  53. package/dist/commands/claude/plugin/tree-copy.d.ts +10 -3
  54. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  55. package/dist/commands/claude/plugin/tree-copy.js +0 -1
  56. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  57. package/dist/commands/claude/plugin/uninstall.d.ts.map +1 -1
  58. package/dist/commands/claude/plugin/uninstall.js +10 -2
  59. package/dist/commands/claude/plugin/uninstall.js.map +1 -1
  60. package/dist/commands/corpus/runner.d.ts.map +1 -1
  61. package/dist/commands/corpus/runner.js +15 -26
  62. package/dist/commands/corpus/runner.js.map +1 -1
  63. package/dist/commands/doctor.d.ts +57 -8
  64. package/dist/commands/doctor.d.ts.map +1 -1
  65. package/dist/commands/doctor.js +142 -67
  66. package/dist/commands/doctor.js.map +1 -1
  67. package/dist/commands/phase-utils.d.ts +62 -2
  68. package/dist/commands/phase-utils.d.ts.map +1 -1
  69. package/dist/commands/phase-utils.js +74 -2
  70. package/dist/commands/phase-utils.js.map +1 -1
  71. package/dist/commands/rag/help.d.ts.map +1 -1
  72. package/dist/commands/rag/help.js +2 -4
  73. package/dist/commands/rag/help.js.map +1 -1
  74. package/dist/commands/resources/help.d.ts.map +1 -1
  75. package/dist/commands/resources/help.js +2 -4
  76. package/dist/commands/resources/help.js.map +1 -1
  77. package/dist/commands/resources/index.d.ts.map +1 -1
  78. package/dist/commands/resources/index.js +13 -6
  79. package/dist/commands/resources/index.js.map +1 -1
  80. package/dist/commands/resources/validate.d.ts +19 -0
  81. package/dist/commands/resources/validate.d.ts.map +1 -1
  82. package/dist/commands/resources/validate.js +89 -24
  83. package/dist/commands/resources/validate.js.map +1 -1
  84. package/dist/commands/skill/review.d.ts.map +1 -1
  85. package/dist/commands/skill/review.js +33 -20
  86. package/dist/commands/skill/review.js.map +1 -1
  87. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  88. package/dist/commands/skill/test/configure.js +1 -1
  89. package/dist/commands/skill/test/configure.js.map +1 -1
  90. package/dist/commands/skill/test/run.d.ts +29 -2
  91. package/dist/commands/skill/test/run.d.ts.map +1 -1
  92. package/dist/commands/skill/test/run.js +67 -19
  93. package/dist/commands/skill/test/run.js.map +1 -1
  94. package/dist/commands/skills/build.d.ts +51 -0
  95. package/dist/commands/skills/build.d.ts.map +1 -1
  96. package/dist/commands/skills/build.js +94 -86
  97. package/dist/commands/skills/build.js.map +1 -1
  98. package/dist/commands/skills/install.d.ts.map +1 -1
  99. package/dist/commands/skills/install.js +79 -25
  100. package/dist/commands/skills/install.js.map +1 -1
  101. package/dist/commands/skills/list.d.ts.map +1 -1
  102. package/dist/commands/skills/list.js +9 -5
  103. package/dist/commands/skills/list.js.map +1 -1
  104. package/dist/commands/skills/package.d.ts +2 -0
  105. package/dist/commands/skills/package.d.ts.map +1 -1
  106. package/dist/commands/skills/package.js +61 -42
  107. package/dist/commands/skills/package.js.map +1 -1
  108. package/dist/commands/skills/skill-discovery.d.ts +7 -0
  109. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  110. package/dist/commands/skills/skill-discovery.js +14 -4
  111. package/dist/commands/skills/skill-discovery.js.map +1 -1
  112. package/dist/commands/skills/validate.d.ts +38 -0
  113. package/dist/commands/skills/validate.d.ts.map +1 -1
  114. package/dist/commands/skills/validate.js +125 -70
  115. package/dist/commands/skills/validate.js.map +1 -1
  116. package/dist/commands/validate.d.ts.map +1 -1
  117. package/dist/commands/validate.js +11 -5
  118. package/dist/commands/validate.js.map +1 -1
  119. package/dist/commands/verify.d.ts.map +1 -1
  120. package/dist/commands/verify.js +76 -28
  121. package/dist/commands/verify.js.map +1 -1
  122. package/dist/skill-resolution/classify.d.ts +7 -2
  123. package/dist/skill-resolution/classify.d.ts.map +1 -1
  124. package/dist/skill-resolution/classify.js +7 -2
  125. package/dist/skill-resolution/classify.js.map +1 -1
  126. package/dist/skill-resolution/resolve-skill-reference.d.ts +10 -8
  127. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  128. package/dist/skill-resolution/resolve-skill-reference.js +62 -23
  129. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  130. package/dist/skill-resolution/types.d.ts +3 -2
  131. package/dist/skill-resolution/types.d.ts.map +1 -1
  132. package/dist/utils/config-loader.d.ts.map +1 -1
  133. package/dist/utils/config-loader.js +0 -2
  134. package/dist/utils/config-loader.js.map +1 -1
  135. package/dist/utils/help-loader.d.ts +16 -0
  136. package/dist/utils/help-loader.d.ts.map +1 -1
  137. package/dist/utils/help-loader.js +31 -1
  138. package/dist/utils/help-loader.js.map +1 -1
  139. package/dist/utils/issue-anchor.d.ts +26 -0
  140. package/dist/utils/issue-anchor.d.ts.map +1 -0
  141. package/dist/utils/issue-anchor.js +39 -0
  142. package/dist/utils/issue-anchor.js.map +1 -0
  143. package/dist/utils/issue-rendering.d.ts +61 -0
  144. package/dist/utils/issue-rendering.d.ts.map +1 -0
  145. package/dist/utils/issue-rendering.js +135 -0
  146. package/dist/utils/issue-rendering.js.map +1 -0
  147. package/dist/utils/output.d.ts +26 -4
  148. package/dist/utils/output.d.ts.map +1 -1
  149. package/dist/utils/output.js +27 -6
  150. package/dist/utils/output.js.map +1 -1
  151. package/dist/utils/project-root-policy.d.ts +2 -2
  152. package/dist/utils/project-root-policy.js +2 -2
  153. package/dist/utils/skill-packaging-config.d.ts +15 -6
  154. package/dist/utils/skill-packaging-config.d.ts.map +1 -1
  155. package/dist/utils/skill-packaging-config.js +23 -5
  156. package/dist/utils/skill-packaging-config.js.map +1 -1
  157. package/dist/utils/validate-help-files.d.ts +3 -2
  158. package/dist/utils/validate-help-files.d.ts.map +1 -1
  159. package/dist/utils/validate-help-files.js +48 -5
  160. package/dist/utils/validate-help-files.js.map +1 -1
  161. package/dist/utils/verdict-helpers.d.ts +10 -2
  162. package/dist/utils/verdict-helpers.d.ts.map +1 -1
  163. package/dist/utils/verdict-helpers.js +13 -3
  164. package/dist/utils/verdict-helpers.js.map +1 -1
  165. package/docs/audit.md +40 -14
  166. package/docs/doctor.md +46 -10
  167. package/docs/index.md +1 -1
  168. package/docs/resources.md +24 -8
  169. package/docs/skill-test.md +47 -3
  170. package/docs/skills.md +28 -6
  171. package/package.json +12 -12
@@ -8,14 +8,20 @@
8
8
  */
9
9
  import { cpSync, existsSync, readFileSync } from 'node:fs';
10
10
  import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
11
- import { applyFilesConfig, getPluginOutputDir, getPluginSourceDir, listPluginSourceSkillDirs, mergeFilesConfig, skillNameToFsPath } from '@vibe-agent-toolkit/agent-skills';
11
+ import { basename } from 'node:path';
12
+ import { countBySeverity } from '@vibe-agent-toolkit/agent-schema';
13
+ import { createProjectRegistry, getPluginOutputDir, getPluginSourceDir, listPluginSourceSkillDirs, listUntrackedPluginSkillDirs, packageSkill, packagingConfigToPackageOptions, skillNameToFsPath } from '@vibe-agent-toolkit/agent-skills';
12
14
  import { safePath, toForwardSlash } from '@vibe-agent-toolkit/utils';
13
15
  import { Command } from 'commander';
16
+ import { readSkillName } from '../../../commands/skills/skill-discovery.js';
14
17
  import { handleCommandError } from '../../../utils/command-error.js';
15
18
  import { loadConfig } from '../../../utils/config-loader.js';
19
+ import { collectPostBuildIssues, formatIssueLines, formatIssueSetHeading, sumSeverityCounts, } from '../../../utils/issue-rendering.js';
16
20
  import { createLogger } from '../../../utils/logger.js';
17
21
  import { writeYamlOutput } from '../../../utils/output.js';
22
+ import { mergeSkillPackagingConfig } from '../../../utils/skill-packaging-config.js';
18
23
  import { loadClaudeProjectConfig } from '../claude-config.js';
24
+ import { buildMarketplaceJson } from './marketplace-json.js';
19
25
  import { resolvePluginChangelogPath } from './plugin-changelog.js';
20
26
  import { applyPluginFiles } from './plugin-files.js';
21
27
  import { mergePluginJson, resolveVersion } from './plugin-json-merge.js';
@@ -36,7 +42,10 @@ Description:
36
42
  plugin's skills: selector.
37
43
 
38
44
  For each marketplace, for each plugin:
39
- - Tree-copies plugins/<name>/ (commands, hooks, agents, skills, .mcp.json) verbatim
45
+ - Tree-copies plugins/<name>/ non-skill content (commands, hooks, agents, .mcp.json)
46
+ - Packages each plugin-local skill (plugins/<name>/skills/*) with the same
47
+ packager used for pool skills — links rewritten, files: applied, declared
48
+ test input excluded. Skills are never copied verbatim.
40
49
  - Imports pool skills (dist/skills/) via the plugin's skills: selector
41
50
  - Applies explicit files: source→dest mappings for compiled artifacts
42
51
  - Merges plugin.json with author, description, and VAT-supplied metadata
@@ -114,15 +123,21 @@ export async function runClaudePluginBuild(configDir, options = {}) {
114
123
  }
115
124
  // Discover available skills from dist/skills/ for pool-to-plugin selectors
116
125
  const availableSkills = await discoverBuiltSkills(configDir);
117
- // Load the project's skills config (defaults + per-skill) so the plugin build
118
- // can apply each tree-copied skill's skill-level `files:` mapping — the
119
- // verbatim tree-copy never runs the skill-packager, so without this a skill's
120
- // build-provided artifacts (a bundled engine, generated data, a catalog)
121
- // would be missing from the shipped plugin tree. Undefined when no config.
126
+ // Load the project's skills config (defaults + per-skill) so each plugin-local
127
+ // skill is PACKAGED with its own effective packaging config — the same config
128
+ // `vat skills build` would use for it. Undefined when no config, in which case
129
+ // plugin-local skills package with schema defaults.
122
130
  const skillsConfig = projectConfig?.skills;
123
131
  logger.info(`Building Claude plugin artifacts`);
124
132
  logger.info(` Config: ${safePath.join(configDir, 'vibe-agent-toolkit.config.yaml')}`);
125
133
  logger.info(` Skills available: ${availableSkills.length}`);
134
+ // THE registry for this build: one crawl+parse of the project's markdown,
135
+ // shared by every plugin-local skill in every marketplace. `packageSkill`
136
+ // builds this itself when it is not given one, so omitting it does not fail —
137
+ // it just re-reads the whole project once per skill, which is how a 46-skill
138
+ // build came to take longer than a 30-minute CI budget.
139
+ const sharedRegistry = await createProjectRegistry(configDir);
140
+ logger.debug(`Project registry: ${sharedRegistry.getAllResources().length} markdown resources (built once)`);
126
141
  const results = [];
127
142
  const allPluginNames = [];
128
143
  for (const mp of Object.values(marketplaces)) {
@@ -137,7 +152,16 @@ export async function runClaudePluginBuild(configDir, options = {}) {
137
152
  }
138
153
  const mpConfig = marketplaces[name];
139
154
  logger.info(`\n Building marketplace: ${name}`);
140
- const result = await buildMarketplace(name, mpConfig, availableSkills, configDir, skillsConfig, rootVersion, logger);
155
+ const result = await buildMarketplace({
156
+ name,
157
+ config: mpConfig,
158
+ availableSkills,
159
+ configDir,
160
+ skillsConfig,
161
+ rootVersion,
162
+ registry: sharedRegistry,
163
+ logger,
164
+ });
141
165
  results.push(result);
142
166
  if (result.status === 'error') {
143
167
  throw new Error(`Claude plugin build failed for marketplace '${name}': ${result.reason ?? 'unknown error'}`);
@@ -165,8 +189,12 @@ async function pluginBuildCommand(options) {
165
189
  const duration = Date.now() - startTime;
166
190
  const totalPlugins = results.flatMap((r) => r.plugins).length;
167
191
  const totalSkills = results.flatMap((r) => r.plugins).flatMap((p) => p.skillsCopied).length;
192
+ const allPlugins = results.flatMap((r) => r.plugins);
168
193
  writeYamlOutput({
169
194
  status: 'success',
195
+ // The build gate is two-valued and a warning does not fail it, so the
196
+ // distribution has to travel next to the status rather than inside it.
197
+ issueCounts: sumSeverityCounts(allPlugins.map((p) => p.localSkillIssueCounts)),
170
198
  marketplacesBuilt: results.filter((r) => r.status === 'built').length,
171
199
  pluginsBuilt: totalPlugins,
172
200
  skillsPackaged: totalSkills,
@@ -184,7 +212,8 @@ async function pluginBuildCommand(options) {
184
212
  mcpCopied: p.mcpCopied,
185
213
  treeFilesCopied: p.treeFilesCopied,
186
214
  explicitFilesCopied: p.explicitFilesCopied,
187
- skillFilesCopied: p.skillFilesCopied,
215
+ localSkillsPackaged: p.localSkillsPackaged,
216
+ issueCounts: p.localSkillIssueCounts,
188
217
  })),
189
218
  })),
190
219
  duration: `${duration}ms`,
@@ -219,7 +248,8 @@ async function copyDistributionFiles(marketplaceDir, configDir, config, logger)
219
248
  }
220
249
  }
221
250
  }
222
- async function buildMarketplace(name, config, availableSkills, configDir, skillsConfig, rootVersion, logger) {
251
+ async function buildMarketplace(input) {
252
+ const { name, config, availableSkills, configDir, skillsConfig, rootVersion, registry, logger } = input;
223
253
  const plugins = [];
224
254
  // Clean stale marketplace directory before rebuilding — removes orphaned plugins
225
255
  const marketplaceBaseDir = safePath.join(configDir, 'dist', '.claude', 'plugins', 'marketplaces', name);
@@ -238,6 +268,7 @@ async function buildMarketplace(name, config, availableSkills, configDir, skills
238
268
  skillsConfig,
239
269
  owner: config.owner,
240
270
  rootVersion,
271
+ registry,
241
272
  logger,
242
273
  });
243
274
  plugins.push(pluginResult);
@@ -247,27 +278,18 @@ async function buildMarketplace(name, config, availableSkills, configDir, skills
247
278
  const claudePluginDir = safePath.join(marketplaceDir, CLAUDE_PLUGIN_DIRNAME);
248
279
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved paths
249
280
  await mkdir(claudePluginDir, { recursive: true });
250
- const marketplaceJson = {
251
- $schema: 'https://anthropic.com/claude-code/marketplace.schema.json',
281
+ // Each entry's author is the plugin's own MERGED author (see marketplace-json.ts),
282
+ // so marketplace.json and that plugin's plugin.json cannot disagree.
283
+ const marketplaceJson = buildMarketplaceJson({
252
284
  name,
253
- owner: {
254
- name: config.owner.name,
255
- ...(config.owner.email ? { email: config.owner.email } : {}),
256
- },
257
- plugins: plugins.map((p) => {
258
- const pluginDesc = config.plugins.find((pd) => pd.name === p.pluginName)?.description;
259
- return {
260
- name: p.pluginName,
261
- ...(pluginDesc ? { description: pluginDesc } : {}),
262
- source: `./plugins/${p.pluginName}`,
263
- ...(p.pluginVersion ? { version: p.pluginVersion } : {}),
264
- author: {
265
- name: config.owner.name,
266
- ...(config.owner.email ? { email: config.owner.email } : {}),
267
- },
268
- };
269
- }),
270
- };
285
+ owner: config.owner,
286
+ plugins: plugins.map((p) => ({
287
+ name: p.pluginName,
288
+ description: config.plugins.find((pd) => pd.name === p.pluginName)?.description,
289
+ version: p.pluginVersion,
290
+ author: p.pluginAuthor,
291
+ })),
292
+ });
271
293
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved paths
272
294
  await writeFile(safePath.join(claudePluginDir, 'marketplace.json'), JSON.stringify(marketplaceJson, null, 2));
273
295
  logger.info(` .claude-plugin/marketplace.json`);
@@ -350,7 +372,7 @@ async function writeMergedPluginJson(pluginDef, authorJson, pluginVersion, plugi
350
372
  const pluginJsonDir = safePath.join(pluginDir, CLAUDE_PLUGIN_DIRNAME);
351
373
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved paths
352
374
  await mkdir(pluginJsonDir, { recursive: true });
353
- const { merged, warnings } = mergePluginJson({
375
+ const { merged, author, warnings } = mergePluginJson({
354
376
  vat: {
355
377
  name: pluginDef.name,
356
378
  version: pluginVersion,
@@ -364,12 +386,24 @@ async function writeMergedPluginJson(pluginDef, authorJson, pluginVersion, plugi
364
386
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved paths
365
387
  await writeFile(safePath.join(pluginJsonDir, 'plugin.json'), JSON.stringify(merged, null, 2));
366
388
  logger.info(` .claude-plugin/plugin.json`);
389
+ return author;
367
390
  }
368
391
  /**
369
392
  * Copy pool skills (from dist/skills/) selected by the plugin's skills: selector
370
393
  * into the plugin bundle's skills/ directory.
394
+ *
395
+ * `destOverrides` maps a selected skill's NAME to the `skills/`-relative directory
396
+ * it must land in, and carries the collision referee's decision (see
397
+ * {@link resolveCollidingSkills}): when the pool copy wins over a plugin-local copy,
398
+ * it takes over the plugin-local skill's own authored directory path rather than the
399
+ * default `skills/<fsName>`. That keeps ONE invariant true for every plugin-local
400
+ * skill, refereed or not — it ships at the path it was authored at, which is exactly
401
+ * what `DistributedSkillLocation.skillOutputDir` promises every consumer of the
402
+ * layout module. Without it a NESTED collision (`skills/group/foo` losing to pool
403
+ * `foo`) landed at `skills/foo`, and `vat skill test foo` then hard-failed looking for
404
+ * a dist at `skills/group/foo` that the build never wrote.
371
405
  */
372
- async function copyPoolSkills(pluginDef, marketplaceAvailable, configDir, pluginDir, logger) {
406
+ async function copyPoolSkills(pluginDef, marketplaceAvailable, configDir, pluginDir, destOverrides, logger) {
373
407
  const selected = resolvePluginSkills(pluginDef, marketplaceAvailable);
374
408
  const copied = [];
375
409
  for (const skillName of selected) {
@@ -379,7 +413,7 @@ async function copyPoolSkills(pluginDef, marketplaceAvailable, configDir, plugin
379
413
  throw new Error(`Skill "${skillName}" not built at ${skillDistPath}. ` +
380
414
  `Run: vat skills build (or vat build to build everything)`);
381
415
  }
382
- const fsPath = skillNameToFsPath(skillName);
416
+ const fsPath = destOverrides.get(skillName) ?? skillNameToFsPath(skillName);
383
417
  const destPath = safePath.join(pluginDir, 'skills', fsPath);
384
418
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved paths
385
419
  await mkdir(destPath, { recursive: true });
@@ -390,56 +424,178 @@ async function copyPoolSkills(pluginDef, marketplaceAvailable, configDir, plugin
390
424
  return copied;
391
425
  }
392
426
  /**
393
- * Apply each tree-copied (plugin-source) skill's skill-level `files:` mapping
394
- * into the distributed skill dir, mirroring what `vat skills build` does for pool
395
- * skills. The plugin tree-copy is verbatim and never runs the skill-packager, so
396
- * a skill whose build-provided artifacts live in its own dir would otherwise ship
397
- * without them. Pool skills (copied from dist/skills/) already had `files:` baked
398
- * in by `vat skills build`, so only plugin-source skills are processed here.
399
- * Returns the number of files copied.
427
+ * Discover the plugin's own skills ONCE, resolving each one's declared name.
428
+ *
429
+ * Every later phase — the collision referee, the packager, and the tree-copy's
430
+ * exclusion list — reads this one list, so "which directories under `skills/` are
431
+ * skills" has exactly one answer per build. Two independent listings is the shape
432
+ * that previously let a directory be excluded by one phase and skipped by the
433
+ * other, shipping NOWHERE with no diagnostic.
434
+ *
435
+ * {@link listPluginSourceSkillDirs} supplies the directories: recursive (a nested
436
+ * `skills/<group>/<skill>/SKILL.md` is a skill Claude Code loads, so VAT packages
437
+ * it) and filtered to the same git-visible file set the tree-copy sees (a
438
+ * gitignored/untracked skill dir must not be published by one producer when the
439
+ * other would never have shipped it).
400
440
  */
401
- async function applyTreeCopiedSkillFiles(input) {
402
- // Enumerate via the shared layout helper so build and verify resolve a plugin's
403
- // tree-copied skills through ONE definition — verify's files-config-dests check
404
- // (via computeTreeCopiedSkillLocations) and this write path can never diverge.
405
- let copied = 0;
406
- for (const skillName of listPluginSourceSkillDirs(input.pluginSourceDir)) {
407
- // A colliding skill (Fix 1) is pool-sourced now, not tree-copied — its
408
- // files: config was already applied by `vat skills build` and is baked
409
- // into dist/skills/<name>/, which Phase 3 copies in. Applying it again
410
- // here would write into a dir the tree-copy no longer produced and
411
- // double-count skillFilesCopied.
412
- if (input.excludeSkillDirs.has(skillName))
413
- continue;
414
- const filesConfig = mergeFilesConfig(input.skillsConfig?.defaults?.files, input.skillsConfig?.config?.[skillName]?.files);
415
- if (filesConfig.length === 0)
416
- continue;
417
- const skillOutputDir = safePath.join(input.pluginDir, 'skills', skillName);
418
- const dests = await applyFilesConfig({
419
- filesConfig,
420
- projectRoot: input.configDir,
421
- skillOutputDir,
441
+ async function discoverPluginLocalSkills(pluginSourceDir, logger) {
442
+ // Git visibility is the right filter, but a SILENT drop is not: a skill the author
443
+ // just created and has not `git add`ed is simply absent from the built plugin, and
444
+ // a build that says "success" while omitting it reads as one that shipped it.
445
+ // Gitignored dirs are excluded from this list — ignoring one IS the instruction.
446
+ for (const dir of listUntrackedPluginSkillDirs(pluginSourceDir)) {
447
+ logger.info(`warning: skills/${dir}/SKILL.md exists but is not tracked by git, so it was NOT packaged ` +
448
+ `into this plugin (the plugin build ships tracked files only). Run \`git add\` on it, or ` +
449
+ `add it to .gitignore to silence this.`);
450
+ }
451
+ const skills = [];
452
+ for (const skillDirPath of listPluginSourceSkillDirs(pluginSourceDir)) {
453
+ const skillPath = safePath.join(pluginSourceDir, 'skills', skillDirPath, 'SKILL.md');
454
+ // Resolved through the SAME reader `vat skills build` uses — per-skill config is
455
+ // keyed by name, so two answers would mean two effective configs.
456
+ const skillName = await readSkillName(skillPath) ?? lastPathSegment(skillDirPath);
457
+ skills.push({ skillDirPath, skillPath, skillName });
458
+ }
459
+ return skills;
460
+ }
461
+ /** Trailing segment of a skill dir path (`group/nested` → `nested`). */
462
+ function lastPathSegment(dirPath) {
463
+ return basename(dirPath);
464
+ }
465
+ /**
466
+ * PACKAGE each plugin-local skill (a skill living in the plugin's own `skills/`
467
+ * source tree) with the SAME packager that produces pool skills.
468
+ *
469
+ * This is the "one production path" rule. A plugin-local skill used to be
470
+ * tree-copied VERBATIM — every byte in its source directory shipped, links were
471
+ * never rewritten, and `files:` had to be re-applied separately because the
472
+ * packager never ran. That produced a materially different artifact from the
473
+ * pool path for the same kind of thing, and the difference was invisible: it
474
+ * shipped eval suites (answer keys included), scratch files, and un-rewritten
475
+ * links. The collision referee already conceded the point by preferring the
476
+ * pool copy whenever both existed.
477
+ *
478
+ * Now both paths run `packageSkill`, so "what ships in a skill" has exactly one
479
+ * answer: link-reachable resources plus declared `files:`, links rewritten,
480
+ * declared test input excluded, post-build checks applied.
481
+ *
482
+ * `input.skills` is the already-refereed set: pool-sourced collisions have been
483
+ * filtered out by {@link resolveCollidingSkills} and are copied in by Phase 3
484
+ * instead. Returns each packaged skill's result for error reporting.
485
+ *
486
+ * The returned list is also the ONLY definition of "which dirs under `skills/` did
487
+ * the packager produce": the tree-copy exclusion list is derived from it (see
488
+ * Phase 2b in {@link buildPlugin}), so a directory absent from it is a directory
489
+ * the tree-copy still ships.
490
+ */
491
+ export async function packagePluginLocalSkills(input) {
492
+ const packaged = [];
493
+ for (const { skillDirPath, skillPath, skillName } of input.skills) {
494
+ // Per-skill config is keyed by the skill's declared NAME; the directory path and
495
+ // its trailing segment are fallbacks for the common cases where they coincide.
496
+ // The fallback LIST must stay a superset of `vat verify`'s (checkFilesConfigDests
497
+ // cannot read declared names, so it tries path-then-leaf): if verify resolved a
498
+ // key the build did not, verify would check `files:` dests for a skill the build
499
+ // was never told to copy them into, and fail a build that had in fact succeeded.
500
+ const packagingConfig = mergeSkillPackagingConfig(input.skillsConfig?.defaults, input.skillsConfig?.config?.[skillName] ??
501
+ input.skillsConfig?.config?.[skillDirPath] ??
502
+ input.skillsConfig?.config?.[lastPathSegment(skillDirPath)]);
503
+ const skillOutputDir = safePath.join(input.pluginDir, 'skills', skillDirPath);
504
+ const result = await packageSkill(skillPath, {
505
+ ...packagingConfigToPackageOptions(packagingConfig, { skillPath, outputPath: skillOutputDir }),
506
+ registry: input.registry,
422
507
  });
423
- for (const dest of dests) {
424
- input.logger.info(` ${skillName} files: -> skills/${skillName}/${dest}`);
508
+ input.logger.info(` ${skillName} -> skills/${skillDirPath} (${result.files.dependencies.length + 1} files)`);
509
+ packaged.push({ skillDirPath, result });
510
+ }
511
+ return packaged;
512
+ }
513
+ /**
514
+ * Summarize post-build issues for the plugin-local skills: the lines to print,
515
+ * the dirs that emitted errors, and the per-severity distribution.
516
+ *
517
+ * Mirrors `vat skills build`: a skill whose packaged output fails validation
518
+ * fails the build, so the two lanes hold the same bar, and they now render
519
+ * findings through the same helper — this lane used to label every non-error
520
+ * severity `[WARNING]`, so `info` findings were reported as warnings, and it
521
+ * read only `postBuildIssues`, so a skill failing purely on the built-output
522
+ * validation aborted the plugin build with no issue text at all.
523
+ *
524
+ * Pure so the whole rendered set is assertable.
525
+ */
526
+ export function summarizePackagedSkillIssues(packaged) {
527
+ const lines = [];
528
+ const withErrors = [];
529
+ const perSkillCounts = [];
530
+ for (const { skillDirPath, result } of packaged) {
531
+ const issues = collectPostBuildIssues(result);
532
+ perSkillCounts.push(countBySeverity(issues));
533
+ if (issues.length > 0) {
534
+ lines.push(` ${skillDirPath}: ${formatIssueSetHeading(issues, 'post-build')}`);
535
+ for (const issue of issues) {
536
+ lines.push(...formatIssueLines(issue, ' '));
537
+ }
425
538
  }
426
- copied += dests.length;
539
+ if (result.hasErrors)
540
+ withErrors.push(skillDirPath);
427
541
  }
428
- return copied;
542
+ return { lines, withErrors, issueCounts: sumSeverityCounts(perSkillCounts) };
543
+ }
544
+ /** Print the summary to stderr and return what the caller gates on. */
545
+ function reportPackagedSkillIssues(packaged, logger) {
546
+ const { lines, withErrors, issueCounts } = summarizePackagedSkillIssues(packaged);
547
+ for (const line of lines) {
548
+ logger.info(line);
549
+ }
550
+ return { withErrors, issueCounts };
429
551
  }
430
552
  /**
431
- * Skill directory names (fs-safe) present in BOTH the plugin's own `skills/`
432
- * source tree AND the plugin's resolved pool selector. These collide: the
433
- * pool-packaged copy has rewritten links, the raw tree-copy does not. The
434
- * tree-copy phase excludes them so the pool copy is the sole source (Fix 1
435
- * — collision referee, docs/plans/2026-07-01-plugin-skill-double-production-spec.md §5).
553
+ * Split the plugin's own skills into those that COLLIDE with its resolved pool
554
+ * selector and those that do not. A colliding skill would otherwise be produced
555
+ * twice — once by the local packager, once by the Phase 3 pool copy-in — putting
556
+ * two definitions of the same skill in one plugin.
557
+ *
558
+ * Matched on the DECLARED NAME, and ONLY the declared name. Name is what makes two
559
+ * copies the same skill: a skill named `foo` authored in `skills/bar/` collides
560
+ * with pool `foo` just as surely as one in `skills/foo/`, and a NESTED skill
561
+ * (`skills/group/foo/`) collides too even though its directory path shares no
562
+ * segment with the pool copy's `skills/foo/`. A dirname-only comparison missed all
563
+ * of those, and the nested case shipped the skill twice, at two depths, in one plugin.
564
+ *
565
+ * The directory leaf is deliberately NOT also matched. It adds nothing (when a
566
+ * skill's `SKILL.md` declares no name, {@link discoverPluginLocalSkills} already
567
+ * falls back to that leaf, so the name comparison covers it) and it over-matches:
568
+ * a plugin-local skill named `bar` living in `skills/foo/`, in a plugin that selects
569
+ * an unrelated pool skill `foo`, was refereed away as if it were the pool's `foo` —
570
+ * so `bar` was neither packaged nor tree-copied and shipped NOWHERE, under a warning
571
+ * claiming a skill named `bar` had been "selected from the pool".
572
+ *
573
+ * `conflicts` is the residue that name-matching alone cannot resolve: a
574
+ * non-colliding plugin-local skill whose authored directory is the same directory a
575
+ * selected pool skill copies into. Two DIFFERENT skills, one output dir — the
576
+ * packager and the Phase 3 copy would write over each other. There is no correct
577
+ * winner, so the caller fails the build instead of silently picking one.
436
578
  */
437
- function resolveCollidingSkillDirs(pluginSourceDir, selectedSkillNames) {
579
+ function resolveCollidingSkills(skills, selectedSkillNames) {
438
580
  const selectedFsNames = new Set(selectedSkillNames.map((name) => skillNameToFsPath(name)));
439
- return listPluginSourceSkillDirs(pluginSourceDir).filter((dirName) => selectedFsNames.has(dirName));
581
+ const colliding = [];
582
+ const packageable = [];
583
+ const conflicts = [];
584
+ for (const skill of skills) {
585
+ if (selectedFsNames.has(skillNameToFsPath(skill.skillName))) {
586
+ colliding.push(skill);
587
+ continue;
588
+ }
589
+ packageable.push(skill);
590
+ const clash = [...selectedFsNames].find((fsName) => toForwardSlash(skill.skillDirPath) === fsName ||
591
+ toForwardSlash(skill.skillDirPath).startsWith(`${toForwardSlash(fsName)}/`));
592
+ if (clash !== undefined)
593
+ conflicts.push({ skill, poolSkillFsPath: clash });
594
+ }
595
+ return { colliding, packageable, conflicts };
440
596
  }
441
597
  async function buildPlugin(input) {
442
- const { marketplaceName, pluginDef, marketplaceAvailable, configDir, skillsConfig, owner, rootVersion, logger } = input;
598
+ const { marketplaceName, pluginDef, marketplaceAvailable, configDir, skillsConfig, owner, rootVersion, registry, logger } = input;
443
599
  const pluginDir = getPluginOutputDir(configDir, marketplaceName, pluginDef.name);
444
600
  const pluginSourceDir = getPluginSourceDir(configDir, pluginDef);
445
601
  logger.info(` Building plugin: ${pluginDef.name}`);
@@ -463,47 +619,90 @@ async function buildPlugin(input) {
463
619
  }
464
620
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved paths
465
621
  await mkdir(pluginDir, { recursive: true });
466
- // Phase 1.5: collision referee. Resolve the plugin's pool selector BEFORE
467
- // tree-copy so a skill present in BOTH the plugin's own skills/ tree AND
468
- // the resolved pool selector excludes from the verbatim tree-copy — the
469
- // pool-packaged copy (Phase 3) becomes the sole source. Without this, both
470
- // copies coexist at two depths inside the same skills/<name>/ dir and the
471
- // raw copy ships un-rewritten (dead) links.
472
- const selectedSkillNames = resolvePluginSkills(pluginDef, marketplaceAvailable);
473
- const collidingSkillDirs = pluginSourceExists
474
- ? resolveCollidingSkillDirs(pluginSourceDir, selectedSkillNames)
622
+ // Phase 1.4: discover the plugin's own skills ONCE (recursive; git-visible only),
623
+ // resolving each declared name. Every phase below reads this list.
624
+ const localSkills = pluginSourceExists
625
+ ? await discoverPluginLocalSkills(pluginSourceDir, logger)
475
626
  : [];
476
- for (const dirName of collidingSkillDirs) {
477
- logger.info(`warning: skill "${dirName}" is selected from the pool AND present at ` +
478
- `${toForwardSlash(safePath.relative(configDir, pluginSourceDir))}/skills/${dirName}/ — ` +
479
- `using the pool-packaged copy (dist/skills/${dirName}) and excluding the plugin-local copy from tree-copy.`);
627
+ // Phase 1.5: collision referee. Resolve the plugin's pool selector BEFORE the
628
+ // tree-copy so a skill present in BOTH the plugin's own skills/ tree AND the
629
+ // resolved pool selector is sourced from exactly one place. Both copies are now
630
+ // packager output, so the referee is no longer about "raw copy ships dead links"
631
+ // — it is about not producing the same skill twice, at two depths, in one dir.
632
+ const selectedSkillNames = resolvePluginSkills(pluginDef, marketplaceAvailable);
633
+ const { colliding: collidingSkills, packageable, conflicts } = resolveCollidingSkills(localSkills, selectedSkillNames);
634
+ if (conflicts.length > 0) {
635
+ const pluginSourceRel = toForwardSlash(safePath.relative(configDir, pluginSourceDir));
636
+ throw new Error(`Plugin '${pluginDef.name}': two DIFFERENT skills claim the same output directory.\n` +
637
+ conflicts
638
+ .map(({ skill, poolSkillFsPath }) => ` - skills/${skill.skillDirPath} holds the plugin-local skill "${skill.skillName}", ` +
639
+ `but the plugin's skills: selector also copies a pool skill into skills/${poolSkillFsPath}.`)
640
+ .join('\n') +
641
+ `\nRename the plugin-local directory under ${pluginSourceRel}/skills/, or drop the pool skill ` +
642
+ `from this plugin's skills: selector. (Only a same-NAME collision has a winner — the pool copy; ` +
643
+ `two different skills sharing one directory does not.)`);
644
+ }
645
+ // The pool copy takes over the plugin-local skill's OWN authored directory (see
646
+ // copyPoolSkills) so "a plugin-local skill ships at its authored path" holds
647
+ // whether it was packaged locally or refereed to the pool.
648
+ const poolDestOverrides = new Map(collidingSkills.map(({ skillName, skillDirPath }) => [skillName, skillDirPath]));
649
+ for (const { skillName, skillDirPath } of collidingSkills) {
650
+ logger.info(`warning: skill "${skillName}" is selected from the pool AND present at ` +
651
+ `${toForwardSlash(safePath.relative(configDir, pluginSourceDir))}/skills/${skillDirPath}/ — ` +
652
+ `using the pool-packaged copy (dist/skills/${skillNameToFsPath(skillName)}) and not packaging ` +
653
+ `the plugin-local copy. It ships at skills/${skillDirPath}, the path it was authored at.`);
654
+ }
655
+ // Phase 2a: PACKAGE each non-colliding plugin-local skill with the same packager
656
+ // that produces pool skills — one production path for skills (see
657
+ // packagePluginLocalSkills). This subsumes the old separate files: application:
658
+ // the packager owns files: semantics, so no lane re-implements them. Runs BEFORE
659
+ // the tree-copy so the tree-copy's exclusion list can be derived from what this
660
+ // actually produced.
661
+ const packagedLocalSkills = await packagePluginLocalSkills({
662
+ skills: packageable,
663
+ pluginDir,
664
+ skillsConfig,
665
+ registry,
666
+ logger,
667
+ });
668
+ const { withErrors: skillsWithErrors, issueCounts: localSkillIssueCounts } = reportPackagedSkillIssues(packagedLocalSkills, logger);
669
+ if (skillsWithErrors.length > 0) {
670
+ throw new Error(`Plugin '${pluginDef.name}': ${skillsWithErrors.length} plugin-local skill(s) emitted ` +
671
+ `post-build validation errors: ${skillsWithErrors.join(', ')}`);
480
672
  }
481
- // Phase 2: tree copy (skips .claude-plugin/, colliding pool-selected skill dirs, respects .gitignore).
673
+ const localSkillsPackaged = packagedLocalSkills.length;
674
+ // Phase 2b: tree copy of everything the other phases did NOT produce (commands,
675
+ // hooks, agents, .mcp.json, root files), skipping .claude-plugin/ and respecting
676
+ // .gitignore.
677
+ //
678
+ // The exclusion list is the set of `skills/<dir>` entries some OTHER phase
679
+ // produces: the ones the packager just wrote (Phase 2a) plus the pool-sourced
680
+ // collisions Phase 3 copies in. Both halves come from the SAME Phase 1.4
681
+ // discovery, partitioned — never from a second listing of `skills/` — which is
682
+ // what makes "excluded here" and "produced elsewhere" the same set by
683
+ // construction. Two independent filters over the same directory would let a dir
684
+ // be excluded by one and skipped by the other, and it would then ship NOWHERE,
685
+ // with no diagnostic.
686
+ //
687
+ // What remains for the tree-copy is everything under `skills/` that is NOT a
688
+ // skill: a `shared/` helper dir, `_templates/`, or the bare PARENT segment of a
689
+ // nested skill (`skills/group/` when the skill is `skills/group/nested/`). A
690
+ // directory holding a SKILL.md is never copied verbatim at any depth — that is
691
+ // exactly how eval suites, scratch files, and un-rewritten links used to ship.
692
+ const producedSkillDirs = [
693
+ ...packagedLocalSkills.map(({ skillDirPath }) => skillDirPath),
694
+ ...collidingSkills.map(({ skillDirPath }) => skillDirPath),
695
+ ];
482
696
  const treeResult = pluginSourceExists
483
697
  ? await treeCopyPlugin({
484
698
  sourceDir: pluginSourceDir,
485
699
  destDir: pluginDir,
486
- excludeSkillDirs: collidingSkillDirs,
700
+ excludeSkillDirs: producedSkillDirs,
487
701
  warn: (m) => logger.info(`warning: ${m}`),
488
702
  })
489
703
  : { commandsCopied: 0, hooksCopied: 0, agentsCopied: 0, mcpCopied: 0, filesCopied: 0 };
490
- // Phase 2.5: apply each NON-colliding tree-copied plugin-source skill's
491
- // skill-level files: mapping into the distributed skill dir (the tree-copy
492
- // is verbatim and never runs the skill-packager). Pool skills — including a
493
- // colliding skill, now pool-sourced only — already had files: baked in by
494
- // `vat skills build`, so this only touches genuinely tree-copied skills.
495
- const skillFilesCopied = pluginSourceExists
496
- ? await applyTreeCopiedSkillFiles({
497
- pluginSourceDir,
498
- pluginDir,
499
- configDir,
500
- skillsConfig,
501
- excludeSkillDirs: new Set(collidingSkillDirs),
502
- logger,
503
- })
504
- : 0;
505
704
  // Phase 3: pool-skill copy-in (from dist/skills/ via the plugin's skills: selector).
506
- const skillsCopied = await copyPoolSkills(pluginDef, marketplaceAvailable, configDir, pluginDir, logger);
705
+ const skillsCopied = await copyPoolSkills(pluginDef, marketplaceAvailable, configDir, pluginDir, poolDestOverrides, logger);
507
706
  // Phase 4: files[] mapping (may overwrite tree-copied files).
508
707
  let explicitFilesCopied = 0;
509
708
  if (pluginDef.files && pluginDef.files.length > 0) {
@@ -520,7 +719,7 @@ async function buildPlugin(input) {
520
719
  // truth that flows into both the merged plugin.json and marketplace.json.
521
720
  const authorJson = readAuthorPluginJson(pluginSourceDir);
522
721
  const pluginVersion = resolveVersion(pluginDef, authorJson, rootVersion, { warn: (message) => logger.info(`warning: ${message}`) });
523
- await writeMergedPluginJson(pluginDef, authorJson, pluginVersion, pluginDir, owner, logger);
722
+ const pluginAuthor = await writeMergedPluginJson(pluginDef, authorJson, pluginVersion, pluginDir, owner, logger);
524
723
  // Phase 6: per-plugin CHANGELOG copy. Resolves to <pluginSourceDir>/CHANGELOG.md
525
724
  // by default, or `entry.changelog` (relative to plugin source) when configured.
526
725
  // No-op when neither resolves — marketplace-level CHANGELOG (handled in
@@ -534,6 +733,7 @@ async function buildPlugin(input) {
534
733
  pluginName: pluginDef.name,
535
734
  pluginDir,
536
735
  pluginVersion,
736
+ pluginAuthor,
537
737
  skillsCopied,
538
738
  commandsCopied: treeResult.commandsCopied,
539
739
  hooksCopied: treeResult.hooksCopied,
@@ -541,7 +741,8 @@ async function buildPlugin(input) {
541
741
  mcpCopied: treeResult.mcpCopied,
542
742
  treeFilesCopied: treeResult.filesCopied,
543
743
  explicitFilesCopied,
544
- skillFilesCopied,
744
+ localSkillsPackaged,
745
+ localSkillIssueCounts,
545
746
  };
546
747
  }
547
748
  //# sourceMappingURL=build.js.map