@vibe-agent-toolkit/cli 0.2.0-rc.1 → 0.2.0-rc.11

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 (504) hide show
  1. package/README.md +4 -5
  2. package/dist/bin/vat +97 -28
  3. package/dist/bin/vat.js +97 -28
  4. package/dist/bin/vat.js.map +1 -1
  5. package/dist/bin.js +115 -76
  6. package/dist/bin.js.map +1 -1
  7. package/dist/command-loaders.d.ts +44 -0
  8. package/dist/command-loaders.d.ts.map +1 -0
  9. package/dist/command-loaders.js +60 -0
  10. package/dist/command-loaders.js.map +1 -0
  11. package/dist/commands/agent/build.d.ts.map +1 -1
  12. package/dist/commands/agent/build.js +2 -1
  13. package/dist/commands/agent/build.js.map +1 -1
  14. package/dist/commands/agent/import.d.ts.map +1 -1
  15. package/dist/commands/agent/import.js +3 -2
  16. package/dist/commands/agent/import.js.map +1 -1
  17. package/dist/commands/agent/index.js +1 -1
  18. package/dist/commands/agent/install-path.d.ts +36 -0
  19. package/dist/commands/agent/install-path.d.ts.map +1 -0
  20. package/dist/commands/agent/install-path.js +44 -0
  21. package/dist/commands/agent/install-path.js.map +1 -0
  22. package/dist/commands/agent/install.d.ts.map +1 -1
  23. package/dist/commands/agent/install.js +66 -13
  24. package/dist/commands/agent/install.js.map +1 -1
  25. package/dist/commands/agent/installed.d.ts.map +1 -1
  26. package/dist/commands/agent/installed.js +22 -12
  27. package/dist/commands/agent/installed.js.map +1 -1
  28. package/dist/commands/agent/list.d.ts.map +1 -1
  29. package/dist/commands/agent/list.js +2 -1
  30. package/dist/commands/agent/list.js.map +1 -1
  31. package/dist/commands/agent/run.d.ts.map +1 -1
  32. package/dist/commands/agent/run.js +2 -1
  33. package/dist/commands/agent/run.js.map +1 -1
  34. package/dist/commands/agent/uninstall.d.ts.map +1 -1
  35. package/dist/commands/agent/uninstall.js +13 -6
  36. package/dist/commands/agent/uninstall.js.map +1 -1
  37. package/dist/commands/agent/validate.d.ts.map +1 -1
  38. package/dist/commands/agent/validate.js +3 -2
  39. package/dist/commands/agent/validate.js.map +1 -1
  40. package/dist/commands/ard/emit.d.ts +165 -0
  41. package/dist/commands/ard/emit.d.ts.map +1 -0
  42. package/dist/commands/ard/emit.js +338 -0
  43. package/dist/commands/ard/emit.js.map +1 -0
  44. package/dist/commands/ard/index.d.ts +12 -0
  45. package/dist/commands/ard/index.d.ts.map +1 -0
  46. package/dist/commands/ard/index.js +84 -0
  47. package/dist/commands/ard/index.js.map +1 -0
  48. package/dist/commands/ard/surfaces.d.ts +56 -0
  49. package/dist/commands/ard/surfaces.d.ts.map +1 -0
  50. package/dist/commands/ard/surfaces.js +150 -0
  51. package/dist/commands/ard/surfaces.js.map +1 -0
  52. package/dist/commands/audit/distributed-tree.d.ts +2 -2
  53. package/dist/commands/audit/distributed-tree.d.ts.map +1 -1
  54. package/dist/commands/audit/distributed-tree.js +11 -7
  55. package/dist/commands/audit/distributed-tree.js.map +1 -1
  56. package/dist/commands/audit/git-url-clone.d.ts +1 -1
  57. package/dist/commands/audit/git-url-clone.d.ts.map +1 -1
  58. package/dist/commands/audit/git-url-clone.js.map +1 -1
  59. package/dist/commands/audit/hierarchical-output.d.ts +1 -1
  60. package/dist/commands/audit/hierarchical-output.d.ts.map +1 -1
  61. package/dist/commands/audit/hierarchical-output.js +1 -1
  62. package/dist/commands/audit/hierarchical-output.js.map +1 -1
  63. package/dist/commands/audit/provenance.d.ts.map +1 -1
  64. package/dist/commands/audit/provenance.js +4 -3
  65. package/dist/commands/audit/provenance.js.map +1 -1
  66. package/dist/commands/audit/scan-population.d.ts +158 -0
  67. package/dist/commands/audit/scan-population.d.ts.map +1 -0
  68. package/dist/commands/audit/scan-population.js +292 -0
  69. package/dist/commands/audit/scan-population.js.map +1 -0
  70. package/dist/commands/audit-settings.d.ts.map +1 -1
  71. package/dist/commands/audit-settings.js +6 -5
  72. package/dist/commands/audit-settings.js.map +1 -1
  73. package/dist/commands/audit.d.ts +98 -8
  74. package/dist/commands/audit.d.ts.map +1 -1
  75. package/dist/commands/audit.js +1150 -394
  76. package/dist/commands/audit.js.map +1 -1
  77. package/dist/commands/build.d.ts +1 -1
  78. package/dist/commands/build.d.ts.map +1 -1
  79. package/dist/commands/build.js +40 -36
  80. package/dist/commands/build.js.map +1 -1
  81. package/dist/commands/cache/cache-control.d.ts +82 -0
  82. package/dist/commands/cache/cache-control.d.ts.map +1 -0
  83. package/dist/commands/cache/cache-control.js +86 -0
  84. package/dist/commands/cache/cache-control.js.map +1 -0
  85. package/dist/commands/cache/clear.d.ts +34 -6
  86. package/dist/commands/cache/clear.d.ts.map +1 -1
  87. package/dist/commands/cache/clear.js +101 -15
  88. package/dist/commands/cache/clear.js.map +1 -1
  89. package/dist/commands/cache/index.d.ts +13 -63
  90. package/dist/commands/cache/index.d.ts.map +1 -1
  91. package/dist/commands/cache/index.js +47 -79
  92. package/dist/commands/cache/index.js.map +1 -1
  93. package/dist/commands/claude/claude-config.d.ts.map +1 -1
  94. package/dist/commands/claude/claude-config.js +6 -0
  95. package/dist/commands/claude/claude-config.js.map +1 -1
  96. package/dist/commands/claude/context.d.ts +372 -0
  97. package/dist/commands/claude/context.d.ts.map +1 -0
  98. package/dist/commands/claude/context.js +1115 -0
  99. package/dist/commands/claude/context.js.map +1 -0
  100. package/dist/commands/claude/index.d.ts +14 -0
  101. package/dist/commands/claude/index.d.ts.map +1 -1
  102. package/dist/commands/claude/index.js +22 -2
  103. package/dist/commands/claude/index.js.map +1 -1
  104. package/dist/commands/claude/marketplace/changelog-utils.d.ts +1 -1
  105. package/dist/commands/claude/marketplace/changelog-utils.d.ts.map +1 -1
  106. package/dist/commands/claude/marketplace/changelog-utils.js +1 -2
  107. package/dist/commands/claude/marketplace/changelog-utils.js.map +1 -1
  108. package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
  109. package/dist/commands/claude/marketplace/git-publish.js +40 -31
  110. package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
  111. package/dist/commands/claude/marketplace/license-utils.d.ts.map +1 -1
  112. package/dist/commands/claude/marketplace/license-utils.js +0 -1
  113. package/dist/commands/claude/marketplace/license-utils.js.map +1 -1
  114. package/dist/commands/claude/marketplace/publish-tree.d.ts.map +1 -1
  115. package/dist/commands/claude/marketplace/publish-tree.js +0 -6
  116. package/dist/commands/claude/marketplace/publish-tree.js.map +1 -1
  117. package/dist/commands/claude/marketplace/publish.d.ts.map +1 -1
  118. package/dist/commands/claude/marketplace/publish.js +2 -1
  119. package/dist/commands/claude/marketplace/publish.js.map +1 -1
  120. package/dist/commands/claude/marketplace/validate.d.ts +73 -22
  121. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  122. package/dist/commands/claude/marketplace/validate.js +407 -72
  123. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  124. package/dist/commands/claude/org/api-keys.d.ts +0 -3
  125. package/dist/commands/claude/org/api-keys.d.ts.map +1 -1
  126. package/dist/commands/claude/org/api-keys.js +2 -1
  127. package/dist/commands/claude/org/api-keys.js.map +1 -1
  128. package/dist/commands/claude/org/helpers.d.ts +54 -1
  129. package/dist/commands/claude/org/helpers.d.ts.map +1 -1
  130. package/dist/commands/claude/org/helpers.js +42 -7
  131. package/dist/commands/claude/org/helpers.js.map +1 -1
  132. package/dist/commands/claude/org/index.d.ts.map +1 -1
  133. package/dist/commands/claude/org/index.js +24 -16
  134. package/dist/commands/claude/org/index.js.map +1 -1
  135. package/dist/commands/claude/org/invites.d.ts +0 -3
  136. package/dist/commands/claude/org/invites.d.ts.map +1 -1
  137. package/dist/commands/claude/org/invites.js +3 -2
  138. package/dist/commands/claude/org/invites.js.map +1 -1
  139. package/dist/commands/claude/org/skills.d.ts +527 -0
  140. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  141. package/dist/commands/claude/org/skills.js +1414 -108
  142. package/dist/commands/claude/org/skills.js.map +1 -1
  143. package/dist/commands/claude/org/users.d.ts +0 -3
  144. package/dist/commands/claude/org/users.d.ts.map +1 -1
  145. package/dist/commands/claude/org/users.js +6 -3
  146. package/dist/commands/claude/org/users.js.map +1 -1
  147. package/dist/commands/claude/org/workspaces.d.ts +0 -3
  148. package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
  149. package/dist/commands/claude/org/workspaces.js +11 -7
  150. package/dist/commands/claude/org/workspaces.js.map +1 -1
  151. package/dist/commands/claude/plugin/build.d.ts +41 -3
  152. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  153. package/dist/commands/claude/plugin/build.js +147 -80
  154. package/dist/commands/claude/plugin/build.js.map +1 -1
  155. package/dist/commands/claude/plugin/helpers.d.ts.map +1 -1
  156. package/dist/commands/claude/plugin/helpers.js +2 -7
  157. package/dist/commands/claude/plugin/helpers.js.map +1 -1
  158. package/dist/commands/claude/plugin/install.d.ts +12 -0
  159. package/dist/commands/claude/plugin/install.d.ts.map +1 -1
  160. package/dist/commands/claude/plugin/install.js +93 -62
  161. package/dist/commands/claude/plugin/install.js.map +1 -1
  162. package/dist/commands/claude/plugin/list.d.ts.map +1 -1
  163. package/dist/commands/claude/plugin/list.js +3 -2
  164. package/dist/commands/claude/plugin/list.js.map +1 -1
  165. package/dist/commands/claude/plugin/marketplace-json.d.ts +24 -9
  166. package/dist/commands/claude/plugin/marketplace-json.d.ts.map +1 -1
  167. package/dist/commands/claude/plugin/marketplace-json.js +10 -9
  168. package/dist/commands/claude/plugin/marketplace-json.js.map +1 -1
  169. package/dist/commands/claude/plugin/plugin-changelog.d.ts.map +1 -1
  170. package/dist/commands/claude/plugin/plugin-changelog.js +0 -2
  171. package/dist/commands/claude/plugin/plugin-changelog.js.map +1 -1
  172. package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
  173. package/dist/commands/claude/plugin/plugin-files.js +3 -6
  174. package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
  175. package/dist/commands/claude/plugin/plugin-validators.d.ts.map +1 -1
  176. package/dist/commands/claude/plugin/plugin-validators.js +3 -6
  177. package/dist/commands/claude/plugin/plugin-validators.js.map +1 -1
  178. package/dist/commands/claude/plugin/tree-copy.d.ts +73 -0
  179. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  180. package/dist/commands/claude/plugin/tree-copy.js +221 -14
  181. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  182. package/dist/commands/claude/plugin/uninstall.d.ts.map +1 -1
  183. package/dist/commands/claude/plugin/uninstall.js +4 -4
  184. package/dist/commands/claude/plugin/uninstall.js.map +1 -1
  185. package/dist/commands/consistency-check.d.ts +17 -15
  186. package/dist/commands/consistency-check.d.ts.map +1 -1
  187. package/dist/commands/consistency-check.js +152 -106
  188. package/dist/commands/consistency-check.js.map +1 -1
  189. package/dist/commands/corpus/index.d.ts.map +1 -1
  190. package/dist/commands/corpus/index.js +3 -0
  191. package/dist/commands/corpus/index.js.map +1 -1
  192. package/dist/commands/corpus/report.d.ts +1 -2
  193. package/dist/commands/corpus/report.d.ts.map +1 -1
  194. package/dist/commands/corpus/report.js +1 -3
  195. package/dist/commands/corpus/report.js.map +1 -1
  196. package/dist/commands/corpus/runner.d.ts +58 -1
  197. package/dist/commands/corpus/runner.d.ts.map +1 -1
  198. package/dist/commands/corpus/runner.js +95 -41
  199. package/dist/commands/corpus/runner.js.map +1 -1
  200. package/dist/commands/corpus/scan.d.ts.map +1 -1
  201. package/dist/commands/corpus/scan.js +20 -12
  202. package/dist/commands/corpus/scan.js.map +1 -1
  203. package/dist/commands/corpus/seed.d.ts +4 -4
  204. package/dist/commands/corpus/seed.d.ts.map +1 -1
  205. package/dist/commands/corpus/seed.js +1 -3
  206. package/dist/commands/corpus/seed.js.map +1 -1
  207. package/dist/commands/doctor.d.ts +50 -0
  208. package/dist/commands/doctor.d.ts.map +1 -1
  209. package/dist/commands/doctor.js +245 -52
  210. package/dist/commands/doctor.js.map +1 -1
  211. package/dist/commands/inventory.d.ts +13 -1
  212. package/dist/commands/inventory.d.ts.map +1 -1
  213. package/dist/commands/inventory.js +121 -17
  214. package/dist/commands/inventory.js.map +1 -1
  215. package/dist/commands/mcp/collections.d.ts +1 -1
  216. package/dist/commands/mcp/collections.d.ts.map +1 -1
  217. package/dist/commands/mcp/list-collections.d.ts.map +1 -1
  218. package/dist/commands/mcp/list-collections.js +2 -1
  219. package/dist/commands/mcp/list-collections.js.map +1 -1
  220. package/dist/commands/mcp/serve.d.ts.map +1 -1
  221. package/dist/commands/mcp/serve.js +3 -2
  222. package/dist/commands/mcp/serve.js.map +1 -1
  223. package/dist/commands/okf/index.d.ts +11 -0
  224. package/dist/commands/okf/index.d.ts.map +1 -0
  225. package/dist/commands/okf/index.js +116 -0
  226. package/dist/commands/okf/index.js.map +1 -0
  227. package/dist/commands/okf/validate.d.ts +195 -0
  228. package/dist/commands/okf/validate.d.ts.map +1 -0
  229. package/dist/commands/okf/validate.js +199 -0
  230. package/dist/commands/okf/validate.js.map +1 -0
  231. package/dist/commands/phase-utils.d.ts +182 -114
  232. package/dist/commands/phase-utils.d.ts.map +1 -1
  233. package/dist/commands/phase-utils.js +154 -163
  234. package/dist/commands/phase-utils.js.map +1 -1
  235. package/dist/commands/positional-args.d.ts.map +1 -1
  236. package/dist/commands/positional-args.js +3 -2
  237. package/dist/commands/positional-args.js.map +1 -1
  238. package/dist/commands/rag/clear-command.d.ts.map +1 -1
  239. package/dist/commands/rag/clear-command.js +2 -1
  240. package/dist/commands/rag/clear-command.js.map +1 -1
  241. package/dist/commands/rag/index-command.d.ts +64 -0
  242. package/dist/commands/rag/index-command.d.ts.map +1 -1
  243. package/dist/commands/rag/index-command.js +72 -6
  244. package/dist/commands/rag/index-command.js.map +1 -1
  245. package/dist/commands/rag/index.d.ts.map +1 -1
  246. package/dist/commands/rag/index.js +23 -10
  247. package/dist/commands/rag/index.js.map +1 -1
  248. package/dist/commands/rag/query-command.d.ts.map +1 -1
  249. package/dist/commands/rag/query-command.js +2 -1
  250. package/dist/commands/rag/query-command.js.map +1 -1
  251. package/dist/commands/rag/stats-command.d.ts.map +1 -1
  252. package/dist/commands/rag/stats-command.js +2 -1
  253. package/dist/commands/rag/stats-command.js.map +1 -1
  254. package/dist/commands/resources/builtin-help.d.ts +27 -0
  255. package/dist/commands/resources/builtin-help.d.ts.map +1 -0
  256. package/dist/commands/resources/builtin-help.js +46 -0
  257. package/dist/commands/resources/builtin-help.js.map +1 -0
  258. package/dist/commands/resources/check-progress.d.ts +167 -0
  259. package/dist/commands/resources/check-progress.d.ts.map +1 -0
  260. package/dist/commands/resources/check-progress.js +250 -0
  261. package/dist/commands/resources/check-progress.js.map +1 -0
  262. package/dist/commands/resources/check-supervisor.d.ts +479 -0
  263. package/dist/commands/resources/check-supervisor.d.ts.map +1 -0
  264. package/dist/commands/resources/check-supervisor.js +587 -0
  265. package/dist/commands/resources/check-supervisor.js.map +1 -0
  266. package/dist/commands/resources/check.d.ts +620 -0
  267. package/dist/commands/resources/check.d.ts.map +1 -0
  268. package/dist/commands/resources/check.js +1452 -0
  269. package/dist/commands/resources/check.js.map +1 -0
  270. package/dist/commands/resources/index.d.ts.map +1 -1
  271. package/dist/commands/resources/index.js +430 -4
  272. package/dist/commands/resources/index.js.map +1 -1
  273. package/dist/commands/resources/query.d.ts +106 -0
  274. package/dist/commands/resources/query.d.ts.map +1 -0
  275. package/dist/commands/resources/query.js +177 -0
  276. package/dist/commands/resources/query.js.map +1 -0
  277. package/dist/commands/resources/scan.d.ts +24 -0
  278. package/dist/commands/resources/scan.d.ts.map +1 -1
  279. package/dist/commands/resources/scan.js +17 -8
  280. package/dist/commands/resources/scan.js.map +1 -1
  281. package/dist/commands/resources/validate.d.ts +42 -2
  282. package/dist/commands/resources/validate.d.ts.map +1 -1
  283. package/dist/commands/resources/validate.js +208 -55
  284. package/dist/commands/resources/validate.js.map +1 -1
  285. package/dist/commands/skill/review-checklist.d.ts +3 -2
  286. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  287. package/dist/commands/skill/review-checklist.js +40 -2
  288. package/dist/commands/skill/review-checklist.js.map +1 -1
  289. package/dist/commands/skill/review.d.ts +247 -0
  290. package/dist/commands/skill/review.d.ts.map +1 -1
  291. package/dist/commands/skill/review.js +100 -62
  292. package/dist/commands/skill/review.js.map +1 -1
  293. package/dist/commands/skill/test/configure.d.ts +56 -0
  294. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  295. package/dist/commands/skill/test/configure.js +75 -16
  296. package/dist/commands/skill/test/configure.js.map +1 -1
  297. package/dist/commands/skill/test/run.d.ts +63 -10
  298. package/dist/commands/skill/test/run.d.ts.map +1 -1
  299. package/dist/commands/skill/test/run.js +258 -77
  300. package/dist/commands/skill/test/run.js.map +1 -1
  301. package/dist/commands/skills/build.d.ts +94 -4
  302. package/dist/commands/skills/build.d.ts.map +1 -1
  303. package/dist/commands/skills/build.js +325 -97
  304. package/dist/commands/skills/build.js.map +1 -1
  305. package/dist/commands/skills/install.d.ts +2 -1
  306. package/dist/commands/skills/install.d.ts.map +1 -1
  307. package/dist/commands/skills/install.js +20 -31
  308. package/dist/commands/skills/install.js.map +1 -1
  309. package/dist/commands/skills/list.d.ts +2 -1
  310. package/dist/commands/skills/list.d.ts.map +1 -1
  311. package/dist/commands/skills/list.js +44 -26
  312. package/dist/commands/skills/list.js.map +1 -1
  313. package/dist/commands/skills/package.d.ts +1 -1
  314. package/dist/commands/skills/package.d.ts.map +1 -1
  315. package/dist/commands/skills/package.js +6 -8
  316. package/dist/commands/skills/package.js.map +1 -1
  317. package/dist/commands/skills/scope-guard.d.ts.map +1 -1
  318. package/dist/commands/skills/scope-guard.js +8 -6
  319. package/dist/commands/skills/scope-guard.js.map +1 -1
  320. package/dist/commands/skills/shared.d.ts.map +1 -1
  321. package/dist/commands/skills/shared.js +2 -4
  322. package/dist/commands/skills/shared.js.map +1 -1
  323. package/dist/commands/skills/skill-discovery.d.ts +47 -6
  324. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  325. package/dist/commands/skills/skill-discovery.js +61 -11
  326. package/dist/commands/skills/skill-discovery.js.map +1 -1
  327. package/dist/commands/skills/source-resolvers.d.ts +15 -0
  328. package/dist/commands/skills/source-resolvers.d.ts.map +1 -1
  329. package/dist/commands/skills/source-resolvers.js +25 -4
  330. package/dist/commands/skills/source-resolvers.js.map +1 -1
  331. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  332. package/dist/commands/skills/validate-command.js +11 -3
  333. package/dist/commands/skills/validate-command.js.map +1 -1
  334. package/dist/commands/skills/validate.d.ts +140 -4
  335. package/dist/commands/skills/validate.d.ts.map +1 -1
  336. package/dist/commands/skills/validate.js +224 -67
  337. package/dist/commands/skills/validate.js.map +1 -1
  338. package/dist/commands/validate.d.ts +1 -1
  339. package/dist/commands/validate.d.ts.map +1 -1
  340. package/dist/commands/validate.js +63 -27
  341. package/dist/commands/validate.js.map +1 -1
  342. package/dist/commands/verify.d.ts +119 -15
  343. package/dist/commands/verify.d.ts.map +1 -1
  344. package/dist/commands/verify.js +357 -142
  345. package/dist/commands/verify.js.map +1 -1
  346. package/dist/index.d.ts +0 -1
  347. package/dist/index.d.ts.map +1 -1
  348. package/dist/index.js +5 -1
  349. package/dist/index.js.map +1 -1
  350. package/dist/report-schemas.d.ts +45 -0
  351. package/dist/report-schemas.d.ts.map +1 -0
  352. package/dist/report-schemas.js +100 -0
  353. package/dist/report-schemas.js.map +1 -0
  354. package/dist/skill-resolution/classify.js +2 -2
  355. package/dist/skill-resolution/classify.js.map +1 -1
  356. package/dist/skill-resolution/packaging-config.d.ts +49 -5
  357. package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
  358. package/dist/skill-resolution/packaging-config.js +59 -19
  359. package/dist/skill-resolution/packaging-config.js.map +1 -1
  360. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  361. package/dist/skill-resolution/resolve-skill-reference.js +55 -57
  362. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  363. package/dist/utils/agent-discovery.d.ts.map +1 -1
  364. package/dist/utils/agent-discovery.js +22 -11
  365. package/dist/utils/agent-discovery.js.map +1 -1
  366. package/dist/utils/agent-runner.d.ts.map +1 -1
  367. package/dist/utils/agent-runner.js +7 -4
  368. package/dist/utils/agent-runner.js.map +1 -1
  369. package/dist/utils/claude-context-lens.d.ts +34 -0
  370. package/dist/utils/claude-context-lens.d.ts.map +1 -0
  371. package/dist/utils/claude-context-lens.js +39 -0
  372. package/dist/utils/claude-context-lens.js.map +1 -0
  373. package/dist/utils/command-error.d.ts +143 -20
  374. package/dist/utils/command-error.d.ts.map +1 -1
  375. package/dist/utils/command-error.js +179 -36
  376. package/dist/utils/command-error.js.map +1 -1
  377. package/dist/utils/config-loader.d.ts +2 -1
  378. package/dist/utils/config-loader.d.ts.map +1 -1
  379. package/dist/utils/config-loader.js +40 -14
  380. package/dist/utils/config-loader.js.map +1 -1
  381. package/dist/utils/corpus-target.d.ts +26 -0
  382. package/dist/utils/corpus-target.d.ts.map +1 -0
  383. package/dist/utils/corpus-target.js +72 -0
  384. package/dist/utils/corpus-target.js.map +1 -0
  385. package/dist/utils/edge-lens-evaluation.d.ts +83 -0
  386. package/dist/utils/edge-lens-evaluation.d.ts.map +1 -0
  387. package/dist/utils/edge-lens-evaluation.js +110 -0
  388. package/dist/utils/edge-lens-evaluation.js.map +1 -0
  389. package/dist/utils/help-loader.d.ts.map +1 -1
  390. package/dist/utils/help-loader.js +0 -1
  391. package/dist/utils/help-loader.js.map +1 -1
  392. package/dist/utils/issue-anchor.d.ts +2 -2
  393. package/dist/utils/issue-anchor.d.ts.map +1 -1
  394. package/dist/utils/issue-anchor.js +1 -1
  395. package/dist/utils/issue-rendering.d.ts +3 -3
  396. package/dist/utils/issue-rendering.d.ts.map +1 -1
  397. package/dist/utils/issue-rendering.js +3 -3
  398. package/dist/utils/issue-rendering.js.map +1 -1
  399. package/dist/utils/issue-severity.d.ts +12 -5
  400. package/dist/utils/issue-severity.d.ts.map +1 -1
  401. package/dist/utils/issue-severity.js +1 -1
  402. package/dist/utils/issue-severity.js.map +1 -1
  403. package/dist/utils/optional-backend.d.ts +78 -0
  404. package/dist/utils/optional-backend.d.ts.map +1 -0
  405. package/dist/utils/optional-backend.js +117 -0
  406. package/dist/utils/optional-backend.js.map +1 -0
  407. package/dist/utils/output.d.ts +26 -0
  408. package/dist/utils/output.d.ts.map +1 -1
  409. package/dist/utils/output.js +35 -0
  410. package/dist/utils/output.js.map +1 -1
  411. package/dist/utils/package-json.d.ts +24 -0
  412. package/dist/utils/package-json.d.ts.map +1 -0
  413. package/dist/utils/package-json.js +49 -0
  414. package/dist/utils/package-json.js.map +1 -0
  415. package/dist/utils/population-wiring.d.ts +100 -0
  416. package/dist/utils/population-wiring.d.ts.map +1 -0
  417. package/dist/utils/population-wiring.js +98 -0
  418. package/dist/utils/population-wiring.js.map +1 -0
  419. package/dist/utils/project-root-policy.d.ts +20 -0
  420. package/dist/utils/project-root-policy.d.ts.map +1 -1
  421. package/dist/utils/project-root-policy.js +30 -0
  422. package/dist/utils/project-root-policy.js.map +1 -1
  423. package/dist/utils/projection-lenses.d.ts +98 -0
  424. package/dist/utils/projection-lenses.d.ts.map +1 -0
  425. package/dist/utils/projection-lenses.js +203 -0
  426. package/dist/utils/projection-lenses.js.map +1 -0
  427. package/dist/utils/projection-query.d.ts +324 -0
  428. package/dist/utils/projection-query.d.ts.map +1 -0
  429. package/dist/utils/projection-query.js +357 -0
  430. package/dist/utils/projection-query.js.map +1 -0
  431. package/dist/utils/projection-store.d.ts +335 -0
  432. package/dist/utils/projection-store.d.ts.map +1 -0
  433. package/dist/utils/projection-store.js +522 -0
  434. package/dist/utils/projection-store.js.map +1 -0
  435. package/dist/utils/relation-limits.d.ts +50 -0
  436. package/dist/utils/relation-limits.d.ts.map +1 -0
  437. package/dist/utils/relation-limits.js +50 -0
  438. package/dist/utils/relation-limits.js.map +1 -0
  439. package/dist/utils/repeatable-option.d.ts +37 -0
  440. package/dist/utils/repeatable-option.d.ts.map +1 -0
  441. package/dist/utils/repeatable-option.js +39 -0
  442. package/dist/utils/repeatable-option.js.map +1 -0
  443. package/dist/utils/resource-loader.d.ts +134 -2
  444. package/dist/utils/resource-loader.d.ts.map +1 -1
  445. package/dist/utils/resource-loader.js +290 -27
  446. package/dist/utils/resource-loader.js.map +1 -1
  447. package/dist/utils/root-argv.d.ts +53 -0
  448. package/dist/utils/root-argv.d.ts.map +1 -0
  449. package/dist/utils/root-argv.js +215 -0
  450. package/dist/utils/root-argv.js.map +1 -0
  451. package/dist/utils/run-integrity.d.ts +95 -0
  452. package/dist/utils/run-integrity.d.ts.map +1 -0
  453. package/dist/utils/run-integrity.js +102 -0
  454. package/dist/utils/run-integrity.js.map +1 -0
  455. package/dist/utils/skill-discovery.d.ts.map +1 -1
  456. package/dist/utils/skill-discovery.js +2 -1
  457. package/dist/utils/skill-discovery.js.map +1 -1
  458. package/dist/utils/skill-packaging-config.d.ts +63 -1
  459. package/dist/utils/skill-packaging-config.d.ts.map +1 -1
  460. package/dist/utils/skill-packaging-config.js +56 -1
  461. package/dist/utils/skill-packaging-config.js.map +1 -1
  462. package/dist/utils/sqlite-experimental-warning.d.ts +69 -0
  463. package/dist/utils/sqlite-experimental-warning.d.ts.map +1 -0
  464. package/dist/utils/sqlite-experimental-warning.js +144 -0
  465. package/dist/utils/sqlite-experimental-warning.js.map +1 -0
  466. package/dist/utils/url-redact.d.ts.map +1 -1
  467. package/dist/utils/url-redact.js +4 -6
  468. package/dist/utils/url-redact.js.map +1 -1
  469. package/dist/utils/user-context-scanner.d.ts +8 -0
  470. package/dist/utils/user-context-scanner.d.ts.map +1 -1
  471. package/dist/utils/user-context-scanner.js +4 -2
  472. package/dist/utils/user-context-scanner.js.map +1 -1
  473. package/dist/utils/validate-help-files.d.ts.map +1 -1
  474. package/dist/utils/validate-help-files.js +12 -6
  475. package/dist/utils/validate-help-files.js.map +1 -1
  476. package/dist/utils/vat-bin-path.d.ts +36 -0
  477. package/dist/utils/vat-bin-path.d.ts.map +1 -0
  478. package/dist/utils/vat-bin-path.js +48 -0
  479. package/dist/utils/vat-bin-path.js.map +1 -0
  480. package/dist/utils/verdict-helpers.d.ts +1 -1
  481. package/dist/utils/verdict-helpers.d.ts.map +1 -1
  482. package/dist/utils/verdict-helpers.js +1 -1
  483. package/dist/utils/verdict-helpers.js.map +1 -1
  484. package/dist/version.d.ts.map +1 -1
  485. package/dist/version.js +1 -3
  486. package/dist/version.js.map +1 -1
  487. package/docs/agent.md +2 -2
  488. package/docs/audit.md +143 -39
  489. package/docs/doctor.md +7 -6
  490. package/docs/index.md +19 -11
  491. package/docs/mcp.md +2 -2
  492. package/docs/rag.md +3 -2
  493. package/docs/resources.md +13 -3
  494. package/docs/skill-test.md +10 -9
  495. package/docs/skills.md +53 -69
  496. package/package.json +36 -27
  497. package/schemas/ard-emit.json +152 -0
  498. package/schemas/okf-validate.json +170 -0
  499. package/schemas/resources-check.json +257 -0
  500. package/schemas/skill-review.json +230 -0
  501. package/dist/commands/resources/command-helpers.d.ts +0 -5
  502. package/dist/commands/resources/command-helpers.d.ts.map +0 -1
  503. package/dist/commands/resources/command-helpers.js +0 -6
  504. package/dist/commands/resources/command-helpers.js.map +0 -1
@@ -3,29 +3,708 @@
3
3
  */
4
4
  import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
5
5
  import { basename } from 'node:path';
6
- import { evalSuiteUnitPath, readDeclaredSkillName } from '@vibe-agent-toolkit/agent-skills';
7
- import { buildMultipartFormData } from '@vibe-agent-toolkit/claude-marketplace';
8
- import { normalizedTmpdir, safePath } from '@vibe-agent-toolkit/utils';
6
+ import { API_SKILL_MAX_UPLOAD_BYTES, collectNonPortableAssetReferenceIssues, collectNonPortableCommandIssues, collectUnqualifiedMcpToolIssues, declaredSkillNameIn, describeOversizeBundle, evalSuiteUnitPath, formatBytes, NEVER_UPLOADED_DIR_NAMES, readDeclaredSkillName, } from '@vibe-agent-toolkit/agent-skills';
7
+ import { ApiRequestError, ApiTransportError, buildMultipartFormData, skillVersionsPath, } from '@vibe-agent-toolkit/claude-marketplace';
8
+ import { createAllowUsageLedger, runValidationFramework } from '@vibe-agent-toolkit/schema';
9
+ import { direntKindFollowingSync, isAbsoluteAnyPlatform, isFilesystemAccessError, normalizedTmpdir, safePath, toForwardSlashAnyPlatform, } from '@vibe-agent-toolkit/utils';
9
10
  import { Command } from 'commander';
10
11
  import { resolveSkillPackagingConfig } from '../../../skill-resolution/packaging-config.js';
11
12
  import { downloadNpmPackage } from '../plugin/helpers.js';
12
- import { autopaginateSkills, executeOrgCommand } from './helpers.js';
13
+ import { autopaginateSkills, executeOrgCommand, orgCommandFailure } from './helpers.js';
13
14
  const SKILL_ID_ARG = '<skill-id>';
14
15
  const SKILL_ID_DESC = 'Skill ID (slug)';
15
16
  const DEBUG_OPT_DESC = 'Enable debug logging';
16
17
  /**
17
- * Send multipart files to the Skills API and return a normalized result.
18
+ * Read an upload response into the result this command prints, refusing a body
19
+ * that does not carry the fields the printed document promises.
20
+ *
21
+ * 🔑 The client's `<T>` is a type ASSERTION, not a check: any 2xx with a JSON
22
+ * body satisfies it. Without this, a response whose keys differ from the ones
23
+ * named here resolves happily and the operator reads `status: success` beside
24
+ * `version: null` — and `version` is precisely the value a later
25
+ * `skills versions delete` takes, so the run that "succeeded" leaves them unable
26
+ * to address what it created. The create endpoint's shape was measured against
27
+ * the live API; `POST /v1/skills/{id}/versions` was not, which is exactly why it
28
+ * must fail loudly rather than print nulls if it differs.
29
+ *
30
+ * The message names both the fields that were missing and the keys the body did
31
+ * carry, because those two lists together are the whole diagnosis when a shape
32
+ * drifts.
33
+ */
34
+ function readSkillUploadResponse(endpoint, raw, fields) {
35
+ const body = typeof raw === 'object' && raw !== null ? raw : {};
36
+ const read = (key) => {
37
+ const value = body[key];
38
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
39
+ };
40
+ const id = read(fields.id);
41
+ const displayTitle = read(fields.displayTitle);
42
+ const version = read(fields.version);
43
+ const createdAt = read(fields.createdAt);
44
+ if (id === undefined || displayTitle === undefined || version === undefined || createdAt === undefined) {
45
+ const missing = [
46
+ [fields.id, id], [fields.displayTitle, displayTitle],
47
+ [fields.version, version], [fields.createdAt, createdAt],
48
+ ].filter(([, value]) => value === undefined).map(([key]) => String(key));
49
+ const present = Object.keys(body).join(', ') || '(none)';
50
+ throw new Error(`${endpoint} returned a body with no usable ${missing.join(', ')}. Keys present: ${present}. `
51
+ + 'Refusing to report success for an upload whose identifiers cannot be read.');
52
+ }
53
+ return { id, displayTitle, version, createdAt };
54
+ }
55
+ /** Read `POST /v1/skills` — the shape measured against the live API. */
56
+ export function readCreateSkillResponse(raw) {
57
+ return readSkillUploadResponse('POST /v1/skills', raw, {
58
+ id: 'id', displayTitle: 'display_title', version: 'latest_version', createdAt: 'created_at',
59
+ });
60
+ }
61
+ /**
62
+ * Read a DELETE response into the result a delete command prints.
63
+ *
64
+ * 🔑 Same class of problem as {@link readSkillUploadResponse} — the client's
65
+ * `<T>` is an assertion, not a check — but the OPPOSITE verdict on an empty
66
+ * body, and deliberately so. The client used to reject a 2xx carrying no body
67
+ * with a parse error, which reported a 204 DELETE that SUCCEEDED as a failure;
68
+ * that is fixed, and the value now handed to this reader for such a response is
69
+ * `undefined`. Refusing it here, the way an upload response is refused, would
70
+ * reinstate the same lie one layer up — and reading `.id` off it, which is what
71
+ * the two call sites did, is a `TypeError` in place of a report.
72
+ *
73
+ * So an empty body is read as the success it is: the status already said the
74
+ * resource is gone, the id is the one this process asked for, and there is
75
+ * nothing else to learn. Measured live behaviour today is a JSON body carrying
76
+ * `type: skill_deleted`, so the empty case is LATENT rather than a live
77
+ * regression — but it is one 204 away, and a latent `TypeError` is not a
78
+ * contract.
79
+ *
80
+ * `deleted` is false only when the body affirmatively names a DIFFERENT
81
+ * outcome, which is the one case where the API is saying something this command
82
+ * must not paper over — and {@link reportDelete} is what makes the run END that
83
+ * way rather than merely say so in a field nothing reads.
84
+ *
85
+ * ⚠️ `expectedTypes` is a LIST because only one member of it is measured.
86
+ * `skill_deleted` came back from a live `DELETE /v1/skills/{id}`;
87
+ * `skill_version_deleted` is the plausible spelling for the version endpoint and
88
+ * has never been seen. A single guessed string would make every version delete
89
+ * report `deleted: false` — and now exit 1 — if the vendor spells it differently
90
+ * by one character. Accepting either keeps the check honest about what it knows:
91
+ * a body naming something that is neither is a real divergence worth failing on.
92
+ */
93
+ export function readDeleteResponse(raw, requestedId, expectedTypes) {
94
+ const body = typeof raw === 'object' && raw !== null ? raw : {};
95
+ const echoedId = body['id'];
96
+ const type = body['type'];
97
+ return {
98
+ id: typeof echoedId === 'string' && echoedId.length > 0 ? echoedId : requestedId,
99
+ deleted: typeof type === 'string' ? expectedTypes.includes(type) : true,
100
+ };
101
+ }
102
+ /** The success types measured against the live `DELETE /v1/skills/{id}`. */
103
+ export const SKILL_DELETED_TYPES = ['skill_deleted'];
104
+ /**
105
+ * The success types a version delete may answer with — see
106
+ * {@link readDeleteResponse} for why this is two and not one.
107
+ */
108
+ export const SKILL_VERSION_DELETED_TYPES = ['skill_version_deleted', 'skill_deleted'];
109
+ /**
110
+ * A delete's document, tagged as a FAILED run when the API said the thing was
111
+ * not deleted.
112
+ *
113
+ * 🔑 Without this, `deleted: false` was computed, printed under
114
+ * `status: success`, and exited 0 — the exact "the run completed and its outcome
115
+ * is wrong" shape that {@link orgCommandFailure} was added in this same change to
116
+ * fix for `--from-npm`, in the one branch that never got wired to it. A CI
117
+ * wrapper spelled `vat claude org skills delete X || fail` reported green while
118
+ * the skill was still there.
119
+ *
120
+ * The document is still PUBLISHED — the id and the verdict are what the operator
121
+ * needs — and only the exit code changes. Throwing instead would end non-zero and
122
+ * discard the report.
123
+ */
124
+ export function reportDelete(raw, requestedId, expectedTypes) {
125
+ const result = readDeleteResponse(raw, requestedId, expectedTypes);
126
+ return result.deleted ? result : orgCommandFailure(result);
127
+ }
128
+ /**
129
+ * A version delete's document — the same verdict as {@link reportDelete}, about
130
+ * a subject that can actually be identified.
131
+ *
132
+ * 🚨 **The version-delete document used to name no version at all, and its `id`
133
+ * meant two different things depending on the response.** It went through
134
+ * `reportDelete(raw, skillId, …)`, so `readDeleteResponse` returned the API's
135
+ * ECHOED id when there was a body — the VERSION identifier — and fell back to
136
+ * the requested id — the SKILL identifier — on a 204. Two runs of the same
137
+ * command therefore published `id` fields from two different namespaces, and
138
+ * neither run's document said which of a skill's versions had been destroyed.
139
+ * An audit trail of an irreversible operation that cannot name what it destroyed
140
+ * is not an audit trail.
141
+ *
142
+ * Both fields are now local knowledge, which is the whole point: this process
143
+ * asked to delete `version` of `skillId` and the API confirmed a deletion, so
144
+ * nothing about the subject's identity has to be recovered from the response
145
+ * shape. The response is still read — for the VERDICT, which is the one thing
146
+ * only the API knows.
147
+ */
148
+ export function reportVersionDelete(raw, skillId, version) {
149
+ const { deleted } = readDeleteResponse(raw, skillId, SKILL_VERSION_DELETED_TYPES);
150
+ const document = { id: skillId, version, deleted };
151
+ return deleted ? document : orgCommandFailure(document);
152
+ }
153
+ /**
154
+ * Add the versions a `--all` run destroyed to its report, preserving whatever
155
+ * verdict {@link reportDelete} reached.
156
+ *
157
+ * Unwrapping and re-wrapping rather than spreading into the tagged value: the
158
+ * failure tag sits BESIDE the document, so a naive `{ ...result, deletedVersions }`
159
+ * on a failure would produce an object carrying `orgCommandFailed` next to the
160
+ * skill's fields and publish the tag as part of the operator's document.
161
+ */
162
+ export function mergeDeleteReport(result, deletedVersions) {
163
+ const failed = 'orgCommandFailed' in result;
164
+ const document = { ...(failed ? result.document : result), deletedVersions };
165
+ return failed ? orgCommandFailure(document) : document;
166
+ }
167
+ /** Read `POST /v1/skills/{id}/versions`. */
168
+ export function readSkillVersionResponse(raw) {
169
+ return readSkillUploadResponse('POST /v1/skills/{id}/versions', raw, {
170
+ id: 'skill_id', displayTitle: 'name', version: 'version', createdAt: 'created_at',
171
+ });
172
+ }
173
+ /**
174
+ * Build the multipart request body for an upload, refusing it before anything is
175
+ * sent when THAT BODY is OVER the API's upload ceiling.
176
+ *
177
+ * 🔑 **The ceiling is INCLUSIVE, and that is measured rather than reasoned.** A
178
+ * request body of exactly {@link API_SKILL_MAX_UPLOAD_BYTES} bytes is ACCEPTED;
179
+ * one byte more is refused `413`:
180
+ *
181
+ * | raw file bytes | + framing | = body | live API |
182
+ * |---|---|---|---|
183
+ * | 31,456,735 | 545 | **31,457,280** | `status: success` |
184
+ * | 31,456,736 | 545 | 31,457,281 | `413` |
185
+ *
186
+ * So the comparison here is `>`, not `>=`. An earlier version of this code used
187
+ * `>=` on the argument that firing AT the ceiling was the conservative reading —
188
+ * an argument that was never measured, and the measurement above refutes it. It
189
+ * is not conservative: it refuses a body the API takes, and the operator has no
190
+ * way to tell that VAT and not Anthropic said no.
191
+ *
192
+ * ⚠️ **The build-time lane deliberately differs, and must not be "harmonised"
193
+ * with this one.** `checkPackagedSizeLimit` fires when FILE BYTES reach the same
194
+ * number — `>=` — because framing is never zero, so files totalling exactly the
195
+ * limit always produce a request LARGER than the limit and therefore a real 413.
196
+ * Two lanes, two measures, two rules: **file bytes `>=` refuse; request bytes `>`
197
+ * refuse.** Both are right about the quantity each can see.
198
+ *
199
+ * THE one gate every upload passes through, whichever shape it started as. The
200
+ * check used to live inside the directory-packaging step alone, so
201
+ * `skills install big-skill.zip` — the one input that is by construction a
202
+ * single large binary, and the shape the check was written for — reached the
203
+ * wire unmeasured.
204
+ *
205
+ * 🔑 **It weighs the BODY, not the sum of the file bytes.** The API measures the
206
+ * request, and the request is this buffer. Summing `file.content.length` — what
207
+ * this did — ignores the per-part framing `buildMultipartFormData` adds: a
208
+ * 51-byte boundary line, a `Content-Disposition` of 61 bytes plus the filename, a
209
+ * 42-byte content-type-and-blank-line and a 2-byte trailing CRLF, so 156 bytes
210
+ * plus the filename PER FILE, plus a 53-byte terminator. A 1,000-file bundle
211
+ * therefore carries ~180 KiB that the old measure could not see, and a bundle
212
+ * whose content sat just under the ceiling passed the pre-flight and then earned
213
+ * the 413 this check exists to prevent — 11 seconds for 30 MB, measured.
214
+ *
215
+ * No headroom constant closes that gap, and none is added: the exact number is
216
+ * available for free by building the body first and asking it how long it is.
217
+ * The body is then RETURNED, so the bytes that were weighed are the bytes that go
218
+ * out; a gate that only inspected its inputs would be one refactor away from
219
+ * measuring something the caller no longer sends.
220
+ *
221
+ * The build-time `PACKAGED_SIZE_EXCEEDS_API_LIMIT` cannot do this — there is no
222
+ * request at build time — so it weighs files on disk and says so. The two
223
+ * messages name what each measured rather than pretending to be the same number.
224
+ */
225
+ export function buildUploadBodyOrRefuse(fields, files, bundleRoot) {
226
+ const multipart = buildMultipartFormData(fields, [...files]);
227
+ // `>`, not `>=`: a body of exactly the ceiling was ACCEPTED by the live API and
228
+ // one byte more was refused 413. The two measured rows, and why the build-time
229
+ // lane keeps `>=` on a different quantity, are in this function's doc comment.
230
+ if (multipart.body.length > API_SKILL_MAX_UPLOAD_BYTES) {
231
+ const sized = files.map(f => ({ path: bundleRelativeName(f.filename, bundleRoot), bytes: f.content.length }));
232
+ const measure = { of: 'upload-request', bytes: multipart.body.length };
233
+ throw new Error(`${describeOversizeBundle(sized, measure)}. The API will refuse this upload.`);
234
+ }
235
+ return multipart;
236
+ }
237
+ /**
238
+ * An uploaded part's filename in the SAME namespace the build-time finding uses.
239
+ *
240
+ * A directory's parts are keyed `<declared-name>/<bundle-relative-path>`, because
241
+ * that is how the API roots them; `PACKAGED_SIZE_EXCEEDS_API_LIMIT` names files
242
+ * relative to the bundle root. Printing the keyed spelling here meant the path an
243
+ * author copied out of an upload refusal never matched the `link` their build had
244
+ * reported, so a `validation.allow` entry written from one message did not waive
245
+ * the other. The prefix is stripped when it is there and left alone when it is
246
+ * not — a ZIP is one part named for the archive itself, with no root to strip.
247
+ */
248
+ function bundleRelativeName(filename, bundleRoot) {
249
+ if (bundleRoot === undefined)
250
+ return filename;
251
+ // Both sides normalized before comparing. The parts are minted from
252
+ // `safePath.relative`, so they are already forward-slashed — but a prefix test
253
+ // that only holds because of where its inputs came from is one refactor from
254
+ // silently never matching on Windows.
255
+ const name = toForwardSlashAnyPlatform(filename);
256
+ const prefix = `${toForwardSlashAnyPlatform(bundleRoot)}/`;
257
+ return name.startsWith(prefix) ? name.slice(prefix.length) : name;
258
+ }
259
+ /**
260
+ * A failed request, re-thrown with the command that answers it when — and only
261
+ * when — the API said one of the specific things listed by the caller.
262
+ *
263
+ * **What is matched, exactly:** an {@link ApiRequestError} whose `statusCode` is
264
+ * 400 and whose message satisfies every pattern of one {@link
265
+ * VendorRefusalRemedy}. Both live wordings are measured, not guessed:
266
+ * `400 Skill cannot reuse an existing display_title` and
267
+ * `400 Cannot delete skill with existing versions. Delete all versions first.`
268
+ *
269
+ * **How it fails safe:** anything that does not match is returned UNTOUCHED, so
270
+ * an unrelated 400 keeps the API's exact words and gets no misleading remedy. If
271
+ * the vendor rewords a refusal, the operator loses a hint — they never gain a
272
+ * wrong one. The remedy is appended, so the vendor's sentence survives in full.
273
+ *
274
+ * One mechanism, several refusals, and each call site passes only the refusals
275
+ * that are REACHABLE from it — a second copy of this matcher per case is how the
276
+ * two would come to disagree about what "fail safe" means.
277
+ */
278
+ export function withRemedy(error, candidates) {
279
+ if (!(error instanceof ApiRequestError) || error.statusCode !== 400)
280
+ return error;
281
+ const message = error.message;
282
+ const matched = candidates.find(c => c.matches.every(pattern => pattern.test(message)));
283
+ if (matched === undefined)
284
+ return error;
285
+ return new ApiRequestError(`${message}\n${matched.remedy}`, error.statusCode, error.retryAfterHeader);
286
+ }
287
+ /**
288
+ * The display title is taken.
289
+ *
290
+ * It tells the operator how to FIND the id and does not offer to find it for
291
+ * them: `display_title` is unique only when the field is sent explicitly, so a
292
+ * workspace can hold several skills of one title and a title→id lookup matches
293
+ * none, one, or several. Appending a version to the wrong match is silent and
294
+ * destroys somebody else's skill, so the id is always the operator's to supply.
295
+ */
296
+ const DUPLICATE_TITLE_REFUSAL = {
297
+ matches: [/display_title/i, /reuse|already|exist|duplicat|unique/i],
298
+ remedy: 'This workspace already has a skill with that display title, and `install` only ever CREATES. '
299
+ + 'To ship a change to that skill, add a version to it: find its id with '
300
+ + '`vat claude org skills list`, then run '
301
+ + '`vat claude org skills versions add <skill-id> <source>`. '
302
+ + 'VAT will not turn the title into an id for you — display_title is not unique in general '
303
+ + '(the API enforces it only when the field is sent), so a title can match none, one, or '
304
+ + 'several skills. To create a genuinely separate skill instead, pass a different --title.',
305
+ };
306
+ /**
307
+ * The skill still has versions, so it cannot be deleted.
308
+ *
309
+ * `--all` leads, because it IS the one-step path: the `--all` branch of this
310
+ * command fetches every version and deletes them before deleting the skill. The
311
+ * by-hand sequence follows for an operator who wants to see what is there first.
312
+ * Naming only `versions delete` would send them round a loop they can already
313
+ * ask the CLI to run.
314
+ */
315
+ export const EXISTING_VERSIONS_REFUSAL = {
316
+ matches: [/versions?/i, /cannot delete|delete all version|existing version/i],
317
+ remedy: 'A skill cannot be deleted while it still has versions. '
318
+ + 'Re-run with `--all` — `vat claude org skills delete <skill-id> --all` deletes every '
319
+ + 'version and then the skill, in one command. To do it by hand instead: list them with '
320
+ + '`vat claude org skills versions list <skill-id>`, delete each with '
321
+ + '`vat claude org skills versions delete <skill-id> <version>`, then delete the skill.',
322
+ };
323
+ /** Only the CREATE path can earn a duplicate-title refusal — see {@link uploadSkillDir}. */
324
+ export function withDuplicateTitleRemedy(error) {
325
+ return withRemedy(error, [DUPLICATE_TITLE_REFUSAL]);
326
+ }
327
+ /**
328
+ * The version's SKILL.md declares a different `name` than the skill it is being
329
+ * added to.
330
+ *
331
+ * The server enforces name consistency per `skill_id` (measured), which is why
332
+ * `versions add` cannot silently re-root a skill's file tree and why this is the
333
+ * one refusal that endpoint can earn. The remedy names both fixes, because which
334
+ * one is right depends on what the operator meant: keep the skill and put its
335
+ * name back, or publish the renamed tree as the separate skill it now is.
336
+ *
337
+ * ⚠️ The vendor's exact wording here has NOT been measured, only the fact of the
338
+ * refusal. `withRemedy` fails safe on no match — the operator loses a hint, never
339
+ * gains a wrong one — so a guessed pattern is the conservative thing to ship.
340
+ */
341
+ export const VERSION_NAME_MISMATCH_REFUSAL = {
342
+ matches: [/\bname\b/i, /match|consistent|differ|mismatch|same skill|must be/i],
343
+ remedy: "This skill's files are rooted at the `name` its SKILL.md frontmatter declared when it was "
344
+ + 'created, and the API refuses a version that declares a different one. Either restore the '
345
+ + 'original name in this tree\'s SKILL.md and re-run, or — if the rename was deliberate — '
346
+ + 'publish the renamed tree as a NEW skill with '
347
+ + '`vat claude org skills install <source>`, which always creates.',
348
+ };
349
+ /** Only the VERSION path can earn a name-consistency refusal — see {@link uploadSkillVersionDir}. */
350
+ export function withVersionRootRemedy(error) {
351
+ return withRemedy(error, [VERSION_NAME_MISMATCH_REFUSAL]);
352
+ }
353
+ /**
354
+ * The refusals a `skills delete` run WITHOUT `--all` can earn.
355
+ *
356
+ * The empty counterpart is not a missing case, it is the answer for a run that
357
+ * already used `--all`: this command's `--all` branch deletes every version
358
+ * before the skill, so an operator who just watched it do that does not need to
359
+ * be told to run `--all` — that is exactly the loop the remedy exists to break.
360
+ * A refusal after `--all` means something the version listing did not show, and
361
+ * the vendor's own sentence is then the honest whole of what VAT knows.
18
362
  */
19
- async function sendSkillUpload(client, displayTitle, files) {
20
- const multipart = buildMultipartFormData({ display_title: displayTitle }, files);
21
- const result = await client.uploadSkill(multipart);
363
+ const DELETE_REMEDIES = [EXISTING_VERSIONS_REFUSAL];
364
+ const DELETE_REMEDIES_AFTER_ALL = [];
365
+ /** DELETE one skill, explaining the refusals this invocation could earn. */
366
+ async function deleteSkillOrExplain(client, skillId, remedies) {
367
+ try {
368
+ return await client.deleteSkill(skillId);
369
+ }
370
+ catch (error) {
371
+ throw withRemedy(error, remedies);
372
+ }
373
+ }
374
+ /**
375
+ * A failure that got NO answer from the server, re-thrown saying what was
376
+ * actually sent and what a retry would risk.
377
+ *
378
+ * ⛔ **The trigger is a RECORDED FACT, never the error's class.** This used to
379
+ * read `if (error instanceof ApiRequestError) return error;` — "a completed
380
+ * exchange always arrives as an `ApiRequestError`, so anything else must be a
381
+ * dropped connection". The first half is true and the converse is not, and the
382
+ * converse is what shipped: `buildSkillsHeaders()` throws a plain `Error` before
383
+ * a socket is ever opened, so
384
+ * `ANTHROPIC_API_KEY= vat claude org skills install <dir>` told a first-time
385
+ * operator, on top of the real "no key" message, that a connection had closed,
386
+ * that VAT had sent 6.8 KiB (the length of a buffer that never left the process),
387
+ * and that the outcome was unknown — then sent them to a recovery command that
388
+ * fails with the identical missing-key error. Three false statements and a loop.
389
+ *
390
+ * So the gate is POSITIVE and evidential: annotate only an
391
+ * {@link ApiTransportError}, which exists only once the transport has been
392
+ * reached and carries `bytesSent` read off the socket at failure time. Anything
393
+ * else — a missing credential, a bad URL, a response VAT refused to read —
394
+ * passes through with its own message untouched.
395
+ *
396
+ * Two outcomes, because the byte count decides which is TRUE:
397
+ *
398
+ * - **Nothing left the machine** (`bytesSent === 0`: DNS never resolved, the
399
+ * connection was refused, the handshake failed). The outcome is not unknown,
400
+ * it is known: nothing happened. Saying "check what exists" here would send
401
+ * an operator to look for a skill that was never sent.
402
+ * - **Some bytes went out and no status came back.** Now the outcome genuinely
403
+ * is unknown, and the part the operator cannot see is how far it got. The
404
+ * client never replays a POST — one that failed with no status may still have
405
+ * taken effect — so the retry is theirs, and it can leave two skills behind.
406
+ *
407
+ * **No threshold.** There is no constant here for "close to the ceiling" and
408
+ * there must not be: an invented margin is a number nobody can re-derive. The
409
+ * bytes sent, the body size and the ceiling are printed and the reader draws
410
+ * their own conclusion.
411
+ */
412
+ export function explainAnsweredNothing(error, bodyBytes) {
413
+ if (!(error instanceof ApiTransportError))
414
+ return error;
415
+ if (error.bytesSent === 0) {
416
+ return new Error(`${error.message}\nNo byte of the request left this machine, so nothing was created and `
417
+ + 'there is nothing to clean up. Fix the connection and re-run the same command.', { cause: error });
418
+ }
419
+ return new Error(`${error.message}\nThe connection closed before the API answered, so this is not a verdict on `
420
+ + `the upload. VAT had sent ${formatBytes(error.bytesSent)} of a ${formatBytes(bodyBytes)} `
421
+ + `request body against a ${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} ceiling; the API has `
422
+ + 'been observed to drop a connection near that ceiling instead of returning 413. Whether '
423
+ + 'anything was created is unknown — VAT never replays a POST, because one that failed with '
424
+ + 'no status may still have taken effect. Check what exists '
425
+ + '(`vat claude org skills list`, or `vat claude org skills versions list <skill-id>`) '
426
+ + 'before re-running this command.', { cause: error });
427
+ }
428
+ /**
429
+ * THE one call that crosses the wire, with both post-hoc explanations applied:
430
+ * the transport annotation (which describes the REQUEST) first, then whichever
431
+ * vendor refusals this endpoint can actually earn.
432
+ *
433
+ * Shared so the two upload endpoints cannot drift on either. Reading the
434
+ * response is deliberately OUTSIDE the try — a response-shape refusal is VAT's
435
+ * own verdict on a completed exchange and must never be dressed up as a
436
+ * transport failure.
437
+ */
438
+ async function sendUpload(send, bodyBytes, remedy) {
439
+ try {
440
+ return await send();
441
+ }
442
+ catch (error) {
443
+ throw remedy(explainAnsweredNothing(error, bodyBytes));
444
+ }
445
+ }
446
+ /**
447
+ * Resolve a `<source>` CLI argument to an absolute path.
448
+ *
449
+ * 🪤 The test used to be `source.startsWith('/')`, which is false for
450
+ * `D:\builds\skill` — so a Windows operator's absolute path was joined onto the
451
+ * working directory and reported back as `Source not found: <cwd>/D:/builds/skill`.
452
+ * {@link isAbsoluteAnyPlatform} answers for POSIX roots, drive letters and UNC
453
+ * paths on EVERY host, so the behaviour is the same wherever it runs and a
454
+ * POSIX-only CI can see the drive-letter case at all.
455
+ */
456
+ export function resolveSourceArgument(source) {
457
+ return isAbsoluteAnyPlatform(source)
458
+ ? toForwardSlashAnyPlatform(source)
459
+ : safePath.resolve(process.cwd(), source);
460
+ }
461
+ /**
462
+ * The largest single markdown member this will decompress to read.
463
+ *
464
+ * Not a limit on what may be PUBLISHED — the API's own ceiling decides that, and
465
+ * a bigger document simply goes unread rather than refused. It bounds the
466
+ * allocations this module performs on attacker-supplied metadata: `getData()`
467
+ * inflates whatever `header.size` claims, and a header can claim anything. A
468
+ * megabyte is two orders of magnitude above any real skill document.
469
+ *
470
+ * ⚠️ It is a PER-ENTRY bound. The total is bounded separately, and more
471
+ * tightly: nothing is inflated at all until the archive's declared uncompressed
472
+ * total is known to be within {@link API_SKILL_MAX_UPLOAD_BYTES}.
473
+ */
474
+ const MAX_INSPECTED_DOCUMENT_BYTES = 1024 * 1024;
475
+ /**
476
+ * What a ZIP will weigh AFTER the API expands it, read from the archive's own
477
+ * central directory — plus the name its inner SKILL.md declares, any answer key
478
+ * it carries, and the markdown members the portability checks read.
479
+ *
480
+ * 🔑 **The API expands a ZIP and applies the ceiling to the UNCOMPRESSED total,
481
+ * not to the archive.** Measured against the live API by an adopter: a
482
+ * 10,707,463-byte archive of a 47.85 MiB tree — comfortably inside the request
483
+ * ceiling as bytes on the wire — came back
484
+ * `400: Zip file uncompressed size exceeds 30MB`. So the body check every other
485
+ * shape passes through is structurally blind here: compression is exactly the
486
+ * thing that makes the measured quantity stop predicting the enforced one. A
487
+ * ZIP is therefore the one input that could pass every local gate and still be
488
+ * refused remotely, which is the same defect class as the ZIP path once
489
+ * skipping the size check altogether.
490
+ *
491
+ * ⚠️ **The boundary itself is UNMEASURED on this lane.** One refusal at 47.85
492
+ * MiB is all the evidence there is; nobody has bracketed where the uncompressed
493
+ * rule actually cuts, or confirmed it is the same constant as the request lane.
494
+ * So this refuses only on `>` — strictly the looser choice — and a bundle near
495
+ * the line is still sent for the API to judge. Do not tighten this to `>=`, or
496
+ * quote it as a measured ceiling, without bracketing it the way
497
+ * {@link API_SKILL_MAX_UPLOAD_BYTES} was bracketed.
498
+ *
499
+ * ⛔ Sizes come from entry HEADERS, which an archive can lie about. That is
500
+ * fine: a lie can only change whether VAT refuses early, never whether the API
501
+ * does.
502
+ *
503
+ * ⚠️ **Reading an entry is not free, and the docstring here used to claim it
504
+ * was** — "no zip-slip or zip-bomb surface is opened — the entries are read, not
505
+ * written" was true of the enumeration half and FALSE of the one `getData()`
506
+ * call, which inflates `header.size` bytes into memory. Worse, it ran BEFORE the
507
+ * caller's uncompressed-total gate, so the ceiling this function exists to
508
+ * enforce could not protect the inflation this function performed. Nothing is
509
+ * written to disk — that part stands — but the decompression is now ordered
510
+ * after the total is known and bounded by {@link MAX_INSPECTED_DOCUMENT_BYTES},
511
+ * so an archive declaring a 4 GiB SKILL.md is enumerated and never inflated.
512
+ */
513
+ export async function inspectZipArchive(zipPath) {
514
+ // `adm-zip` is already a runtime dependency of this package (the packager and
515
+ // the url skill-source both use it). An earlier comment here reasoned that
516
+ // reading the archive would mean "adding a dependency … for a log line" and
517
+ // declined on that basis; the premise was false, and the payoff is not a log
518
+ // line but a refusal that saves a doomed upload of up to 30 MiB.
519
+ const AdmZip = (await import('adm-zip')).default;
520
+ let entries;
521
+ try {
522
+ entries = new AdmZip(zipPath).getEntries();
523
+ }
524
+ catch (error) {
525
+ // An archive VAT cannot PARSE is not an archive VAT should block. The API
526
+ // is the authority on the upload either way, and refusing here would turn
527
+ // "our reader disagrees with your zip tool" into a failed publish. The
528
+ // caller is told why, so "checks skipped" is never silent.
529
+ //
530
+ // An archive the FILESYSTEM refuses is a different thing: the path was
531
+ // readable a moment ago (the caller holds its bytes), so a refusal here
532
+ // is not a parse disagreement and stays loud.
533
+ if (isFilesystemAccessError(error))
534
+ throw error;
535
+ return { archiveUnreadable: describeZipFailure(error) };
536
+ }
537
+ // PASS 1 — headers only. Nothing is decompressed while this runs, so both
538
+ // refusals the caller can reach are decided on metadata alone.
539
+ const files = entries.filter(entry => !entry.isDirectory);
540
+ const uncompressedBytes = files.reduce((sum, entry) => sum + entry.header.size, 0);
541
+ const neverUploaded = files
542
+ .map(entry => toForwardSlashAnyPlatform(entry.entryName))
543
+ .filter(name => name.split('/').some(segment => NEVER_UPLOADED_DIR_NAMES.has(segment)));
544
+ // PASS 2 — the entries worth inflating, and only once the total says this
545
+ // archive is not already refused. An over-ceiling archive is about to be
546
+ // rejected by the caller, so inflating anything out of it would be work spent
547
+ // on an upload that cannot happen.
548
+ if (uncompressedBytes > API_SKILL_MAX_UPLOAD_BYTES) {
549
+ return {
550
+ uncompressedBytes, declaredName: undefined, neverUploaded, documents: [], bundleRoot: undefined, unreadable: [],
551
+ };
552
+ }
553
+ const elected = electShallowestSkillMd(files);
554
+ const unreadable = [];
22
555
  return {
23
- id: result.id,
24
- displayTitle: result.display_title,
25
- version: result.latest_version,
26
- createdAt: result.created_at,
556
+ uncompressedBytes,
557
+ declaredName: declaredNameOf(elected, unreadable),
558
+ neverUploaded,
559
+ documents: inflateMarkdownMembers(files, unreadable),
560
+ bundleRoot: archiveRootOf(elected?.entryName),
561
+ unreadable,
27
562
  };
28
563
  }
564
+ /** adm-zip throws plain `Error`s; the message is all it has to say. */
565
+ function describeZipFailure(error) {
566
+ return error instanceof Error ? error.message : String(error);
567
+ }
568
+ /**
569
+ * Every markdown member of the archive, as an upload part.
570
+ *
571
+ * 🔑 This exists so the ZIP lane can run the portability family through the ONE
572
+ * function the directory lane runs it through. Until it did, `install <zip>`
573
+ * performed none of the checks `install --help` promises of "every markdown
574
+ * document in the bundle": the archive went to `sendSkillUpload` as one opaque
575
+ * part, and an operator following the documented contract got silence where they
576
+ * were promised warnings. Routing the parts back through
577
+ * {@link warnUnportableReferences} keeps ONE implementation of the checks — a
578
+ * second copy of the three collector calls is how the two lanes start disagreeing.
579
+ *
580
+ * An entry that will not inflate is SKIPPED, never fatal: this module's standing
581
+ * position is that an archive VAT cannot read is not an archive VAT should block,
582
+ * and the checks it feeds only ever warn.
583
+ */
584
+ function inflateMarkdownMembers(files, unreadable) {
585
+ const documents = [];
586
+ for (const entry of files) {
587
+ const filename = toForwardSlashAnyPlatform(entry.entryName);
588
+ if (!filename.toLowerCase().endsWith('.md'))
589
+ continue;
590
+ if (entry.header.size > MAX_INSPECTED_DOCUMENT_BYTES)
591
+ continue;
592
+ const content = inflateOrRecord(entry, unreadable);
593
+ if (content !== undefined)
594
+ documents.push({ fieldName: 'files[]', filename, content });
595
+ }
596
+ return documents;
597
+ }
598
+ /**
599
+ * One member's bytes, or `undefined` with the refusal RECORDED.
600
+ *
601
+ * `getData()` is the only decompression in this module, so it is the only thing
602
+ * that can throw here: adm-zip raises on BAD_CRC, on any compression method but
603
+ * store/deflate, and on an encrypted entry. Those used to escape and abort the
604
+ * publish on exit 2 — contradicting this module's own stance that an archive VAT
605
+ * cannot parse is not an archive VAT should block — and then, once caught, were
606
+ * dropped without a word. The member is skipped AND named.
607
+ */
608
+ function inflateOrRecord(entry, unreadable) {
609
+ try {
610
+ return entry.getData();
611
+ }
612
+ catch (error) {
613
+ // Once per member: the elected SKILL.md is asked for twice (its name, then
614
+ // its text), and one refusal is one fact.
615
+ const name = toForwardSlashAnyPlatform(entry.entryName);
616
+ if (!unreadable.some((failure) => failure.entry === name)) {
617
+ unreadable.push({ entry: name, reason: describeZipFailure(error) });
618
+ }
619
+ return undefined;
620
+ }
621
+ }
622
+ /**
623
+ * The directory the elected SKILL.md sits in, which is the archive's bundle root.
624
+ *
625
+ * `my-skill/SKILL.md` roots the bundle at `my-skill`; a SKILL.md at the archive
626
+ * root has no prefix, and neither does an archive with no SKILL.md at all. ZIP
627
+ * fixes `/` as the entry separator, so this reads the format rather than handling
628
+ * a host path.
629
+ */
630
+ function archiveRootOf(entryName) {
631
+ if (entryName === undefined)
632
+ return undefined;
633
+ const cut = toForwardSlashAnyPlatform(entryName).lastIndexOf('/');
634
+ return cut === -1 ? undefined : entryName.slice(0, cut);
635
+ }
636
+ /**
637
+ * The SKILL.md nearest the archive root, which is the one whose `name` the API
638
+ * will key the skill by.
639
+ *
640
+ * 🪤 Compared on DEPTH, with name length only as a tiebreak. It used to compare
641
+ * `entryName.length` alone, on the reasoning that "a shortest-path comparison
642
+ * needs no separator at all" — but shortest string is not shallowest path:
643
+ * `a/b/SKILL.md` (12 chars, depth 2) beats `my-skill-bundle/SKILL.md` (24
644
+ * chars, depth 1), so a bundled FIXTURE supplied the name and the operator was
645
+ * advised to `--title` it. ZIP fixes `/` as the entry separator on every
646
+ * platform, so splitting on it is not path handling — it is reading the format.
647
+ */
648
+ function electShallowestSkillMd(files) {
649
+ let elected;
650
+ for (const entry of files) {
651
+ if (basename(entry.entryName) !== 'SKILL.md')
652
+ continue;
653
+ if (elected !== undefined && !isShallower(entry.entryName, elected.entryName))
654
+ continue;
655
+ elected = entry;
656
+ }
657
+ return elected;
658
+ }
659
+ /** Fewer path segments wins; equal depth falls back to the shorter name. */
660
+ function isShallower(candidate, incumbent) {
661
+ const candidateDepth = zipEntryDepth(candidate);
662
+ const incumbentDepth = zipEntryDepth(incumbent);
663
+ return candidateDepth === incumbentDepth
664
+ ? candidate.length < incumbent.length
665
+ : candidateDepth < incumbentDepth;
666
+ }
667
+ /**
668
+ * How many segments a ZIP entry name has.
669
+ *
670
+ * Counted rather than `split('/')`-ed because the linter — correctly, for
671
+ * FILESYSTEM paths — refuses that idiom: a host path's separator depends on the
672
+ * platform. A ZIP entry name's does not; the format fixes it as `/`. Counting
673
+ * the separator states that, and needs no exemption.
674
+ */
675
+ function zipEntryDepth(entryName) {
676
+ let depth = 1;
677
+ for (const character of entryName) {
678
+ if (character === '/')
679
+ depth += 1;
680
+ }
681
+ return depth;
682
+ }
683
+ /**
684
+ * The frontmatter `name` inside the ELECTED entry — or `undefined`, including
685
+ * when that entry declares none.
686
+ *
687
+ * 🪤 It used to be `declaredSkillNameIn(…) ?? declaredName`, which KEPT the
688
+ * previous (deeper) candidate's name when a newly-elected shallower SKILL.md had
689
+ * no `name`. The elected file and the reported name then came from two different
690
+ * documents, and the divergence warning named a name no operator could find at
691
+ * the root of their archive. The election decides; whatever it holds is the
692
+ * answer, absence included.
693
+ *
694
+ * An elected SKILL.md that will not inflate costs only the name (the size total
695
+ * is already computed from headers) — see {@link inflateOrRecord}, which records
696
+ * the refusal so the operator learns why no name was read.
697
+ */
698
+ function declaredNameOf(entry, unreadable) {
699
+ if (entry === undefined || entry.header.size > MAX_INSPECTED_DOCUMENT_BYTES)
700
+ return undefined;
701
+ const content = inflateOrRecord(entry, unreadable);
702
+ return content === undefined ? undefined : declaredSkillNameIn(content.toString('utf8'));
703
+ }
704
+ async function sendSkillUpload(client, displayTitle, files, bundleRoot) {
705
+ const multipart = buildUploadBodyOrRefuse({ display_title: displayTitle }, files, bundleRoot);
706
+ return readCreateSkillResponse(await sendUpload(() => client.uploadSkill(multipart), multipart.body.length, withDuplicateTitleRemedy));
707
+ }
29
708
  /**
30
709
  * The name a SKILL.md declares, which is both the uploaded skill's display
31
710
  * title and the top-level directory the API keys it by.
@@ -37,35 +716,6 @@ function requireDeclaredName(skillMdPath) {
37
716
  }
38
717
  return declared;
39
718
  }
40
- /**
41
- * Directories never uploaded to the organization, at any depth, by NAME.
42
- *
43
- * `evals/` is the conventional home of a skill's eval suite — its answer key.
44
- * A correctly built skill directory (what this command documents as its input)
45
- * never contains one, because the packager excludes declared test input; this
46
- * is the backstop for the easy mistake of pointing the uploader at the *source*
47
- * tree instead, where the suite does live. The invariant is "a published skill
48
- * carries no answer key", not "…none when the operator remembered to build".
49
- *
50
- * The name match alone cannot uphold that invariant, because the suite's
51
- * location is the ADOPTER's to declare (`skills.config.<name>.test.evals`;
52
- * `evals/evals.json` is only the default). It stays as the unconditional
53
- * fail-safe for the case where no config is discoverable at all — a fetched
54
- * artifact, an extracted tarball, a tree outside any VAT project — where there
55
- * is no declaration to read and the convention is the only thing left to honor.
56
- * The declared location is resolved separately, in
57
- * {@link declaredTestInputPaths}, and the two are unioned.
58
- *
59
- * This lane is deliberately BROADER than the packager, which excludes exactly
60
- * `<skill-root>/evals` and never guesses from a name (see test-input.ts). Here
61
- * a mistaken input is the entire scenario and the blast radius is an org-wide
62
- * publish, so over-withholding a directory literally named `evals` is the cheap
63
- * error and it is reported rather than silent.
64
- *
65
- * `node_modules`/`.git` are development detritus that has no meaning inside a
66
- * published skill and would silently bloat the multipart payload.
67
- */
68
- const NEVER_UPLOADED_DIR_NAMES = new Set(['evals', 'node_modules', '.git']);
69
719
  /**
70
720
  * The absolute path of this skill's DECLARED eval suite, when its governing VAT
71
721
  * config declares one, as a set of paths to withhold.
@@ -87,31 +737,97 @@ const NEVER_UPLOADED_DIR_NAMES = new Set(['evals', 'node_modules', '.git']);
87
737
  * skill dir — nothing inside the tree to withhold.
88
738
  */
89
739
  async function declaredTestInputPaths(skillDir) {
90
- const config = await resolveSkillPackagingConfig(safePath.join(skillDir, 'SKILL.md'));
740
+ // `'refuse'`: an upload that cannot tell which paths are test input might
741
+ // publish an answer key. The throw is reported per skill by the install
742
+ // loop's catch (logged, and counted in its failures) — never swallowed.
743
+ const config = await resolveSkillPackagingConfig(safePath.join(skillDir, 'SKILL.md'), 'refuse');
91
744
  const declared = config?.test?.evals;
92
745
  if (declared === undefined)
93
746
  return new Set();
94
747
  const unit = evalSuiteUnitPath(safePath.resolve(skillDir), declared);
95
748
  return unit === undefined ? new Set() : new Set([unit]);
96
749
  }
750
+ /**
751
+ * Whether a directory entry is a symbolic link resolving to a DIRECTORY —
752
+ * throwing, naming the path, when the link cannot be followed at all.
753
+ *
754
+ * `Dirent.isDirectory()` is lstat-based, so it answers `false` for a link to a
755
+ * directory. That entry therefore used to fall into the FILE branch below and
756
+ * `readFileSync` threw a raw `EISDIR`: the upload died on a Node error that
757
+ * named no path and said nothing about what to do. A dangling link produced the
758
+ * same shape of failure with `ENOENT`.
759
+ *
760
+ * `statSync` follows the link, which is the same thing the build-time size walk
761
+ * does to classify one — so both lanes reach the same verdict about the same
762
+ * entry.
763
+ */
764
+ function resolvesToDirectory(entry, fullPath, relativePath) {
765
+ if (!entry.isSymbolicLink())
766
+ return false;
767
+ try {
768
+ return statSync(fullPath).isDirectory();
769
+ }
770
+ catch (error) {
771
+ const cause = error instanceof Error ? error.message : String(error);
772
+ throw new Error(`Cannot upload ${relativePath}: it is a symbolic link whose target could not be read `
773
+ + `(${cause}). Replace it with the file or directory it should point at, or remove it.`);
774
+ }
775
+ }
97
776
  /**
98
777
  * Collect the files under a skill directory that should be uploaded,
99
778
  * recursively, returning relative paths alongside what was deliberately left
100
779
  * out.
780
+ *
781
+ * 🚨 **A symlink is REFUSED, whatever it resolves to.** A multipart body has no
782
+ * way to express a link, so there are only three things this could do with one
783
+ * and two of them are wrong. Skipping it changes what gets published without the
784
+ * skill breaking until someone opens it — and the one thing this collector
785
+ * guarantees is that every withholding is reported. FOLLOWING it is data egress:
786
+ * an earlier version refused only a link resolving to a DIRECTORY, so
787
+ * `notes.md -> /etc/passwd` fell through both branches, `readFileSync` returned
788
+ * the target's bytes, and they were posted under the in-bundle name `notes.md`
789
+ * into a workspace every org member can read. Nothing in the run said a link had
790
+ * been followed: the "every withholding is reported" guarantee covers
791
+ * EXCLUSIONS, not DEREFERENCES. Any file the operator can read left the machine.
792
+ *
793
+ * The vector is not a registry tarball — node-tar 7 de-roots an absolute
794
+ * linkpath and refuses an escaping relative one, measured — but a directory
795
+ * extracted with system `tar`, which does recreate `-> /etc/passwd`, or cloned
796
+ * from an untrusted repo and handed to `install <dir>`.
797
+ *
798
+ * Refusing and naming the path is the only answer that is both complete and
799
+ * honest, and it keeps this lane on the same payload as the build-time size
800
+ * walk, which does not weigh a linked entry either.
801
+ *
802
+ * The never-uploaded NAMES are matched on a linked directory too. Those are
803
+ * never published whatever their type, and the size walk weighs a linked
804
+ * directory as zero bytes either way — so excluding one keeps both lanes on the
805
+ * same payload instead of blocking a publish over a directory neither lane
806
+ * would have sent.
101
807
  */
102
808
  function collectFiles(dir, base, testInput, collected) {
103
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- dir from CLI arg
104
809
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
105
810
  const fullPath = safePath.join(dir, entry.name);
106
811
  const relativePath = safePath.relative(base, fullPath);
812
+ const linkedDirectory = resolvesToDirectory(entry, fullPath, relativePath);
813
+ const directoryLike = entry.isDirectory() || linkedDirectory;
107
814
  if (testInput.has(safePath.resolve(fullPath))
108
- || (entry.isDirectory() && NEVER_UPLOADED_DIR_NAMES.has(entry.name))) {
815
+ || (directoryLike && NEVER_UPLOADED_DIR_NAMES.has(entry.name))) {
109
816
  collected.excluded.push(relativePath);
110
817
  continue;
111
818
  }
112
819
  if (entry.isDirectory()) {
113
820
  collectFiles(fullPath, base, testInput, collected);
114
821
  }
822
+ else if (entry.isSymbolicLink()) {
823
+ // Whatever it points at. A link to a FILE used to fall through to the
824
+ // branch below and be read through — see this function's doc comment.
825
+ throw new Error(`Cannot upload ${relativePath}: it is a symbolic link${linkedDirectory ? ' to a directory' : ''}, `
826
+ + 'which a multipart upload cannot express. VAT will not publish the bytes it points '
827
+ + 'at under this name: a link out of the skill directory would send whatever the '
828
+ + 'target holds into a workspace every org member can read. Replace it with a real '
829
+ + 'copy of the file or directory it points at, or remove it.');
830
+ }
115
831
  else {
116
832
  collected.files.push({ relativePath, absolutePath: fullPath });
117
833
  }
@@ -129,11 +845,16 @@ export async function collectSkillUploadFiles(skillDir) {
129
845
  return collected;
130
846
  }
131
847
  /**
132
- * Upload a single skill directory to the org via Skills API.
848
+ * Package a skill directory into the multipart file set the API takes.
849
+ *
850
+ * Deliberately separate from SENDING it. Creating a skill and adding a version to
851
+ * one differ only in the endpoint; the bundle, the exclusions and the size ceiling
852
+ * are identical, and a skill that packages one way when created and another way
853
+ * when updated would be the bug this split exists to prevent. Which endpoint gets
854
+ * the bundle is the caller's command, never something inferred from the workspace.
133
855
  */
134
- async function uploadSkillDir(client, skillDir, titleOverride, logger) {
856
+ async function prepareSkillUpload(skillDir, titleOverride, logger) {
135
857
  const skillMdPath = safePath.join(skillDir, 'SKILL.md');
136
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- derived from CLI arg
137
858
  if (!existsSync(skillMdPath)) {
138
859
  throw new Error(`SKILL.md not found in ${skillDir}. Is this a built skill directory?`);
139
860
  }
@@ -146,7 +867,6 @@ async function uploadSkillDir(client, skillDir, titleOverride, logger) {
146
867
  const collected = await collectSkillUploadFiles(skillDir);
147
868
  const files = [];
148
869
  for (const file of collected.files) {
149
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- collected from dir walk
150
870
  const content = readFileSync(file.absolutePath);
151
871
  files.push({
152
872
  fieldName: 'files[]',
@@ -154,30 +874,164 @@ async function uploadSkillDir(client, skillDir, titleOverride, logger) {
154
874
  content,
155
875
  });
156
876
  }
157
- const totalSize = files.reduce((sum, f) => sum + f.content.length, 0);
158
- logger.info(` ${dirName}: ${files.length} files, ${(totalSize / 1024).toFixed(1)}KB, title="${displayTitle}"`);
877
+ // Reported on the collected files, so the exclusions listed below are already
878
+ // accounted for — this is the bundle actually being sent. It is FILE BYTES and
879
+ // says so: the ceiling is enforced on the multipart body, which adds ~156 bytes
880
+ // plus the filename per part, and a reader who mistook this line for the
881
+ // measured quantity would not understand a refusal that names a larger number.
882
+ const contentBytes = files.reduce((sum, f) => sum + f.content.length, 0);
883
+ logger.info(` ${dirName}: ${files.length} ${files.length === 1 ? 'file' : 'files'}, `
884
+ + `${formatBytes(contentBytes)} of file content, title="${displayTitle}"`);
159
885
  for (const excluded of collected.excluded) {
160
886
  logger.info(` Excluded from upload: ${excluded} (never published with a skill)`);
161
887
  }
162
- return sendSkillUpload(client, displayTitle, files);
888
+ // The SAME merge `declaredTestInputPaths` read to find the eval suite — both
889
+ // resolutions are served from `loadConfigCached` and the per-root discovery
890
+ // cache, so asking twice costs one map lookup rather than a second walk-up.
891
+ // Resolved HERE rather than plumbed out of `collectSkillUploadFiles`, which is
892
+ // exported and deliberately resolves its own so no caller can hand it a config
893
+ // with the exclusion skipped.
894
+ const packaging = await resolveSkillPackagingConfig(skillMdPath, 'refuse');
895
+ warnUnportableReferences(files, dirName, packaging?.validation, logger);
896
+ return { displayTitle, files, dirName };
897
+ }
898
+ /**
899
+ * Warn about references in the bundle that cannot resolve once it is published.
900
+ *
901
+ * 🚨 **Until this existed, `install` validated NOTHING.** It weighed the request
902
+ * and sent it; every check VAT owns — the non-portable reference family, the
903
+ * unqualified MCP tool names, the non-portable command family — ran in
904
+ * `vat audit` and `vat skills build` and never on the one path where content
905
+ * leaves the machine. An adopter found the consequence directly: of 54 built
906
+ * skills, 10 referenced paths outside their own directory, one of them exec'ing
907
+ * a script under a SIBLING skill. Under the Skills API each skill is its own
908
+ * top-level tree with no siblings, so those uploaded with a green tick and could
909
+ * not possibly run. "It uploaded" is not "it works", and the publish command was
910
+ * the only lane in a position to say so before the bytes left.
911
+ *
912
+ * ⚠️ **Warns, never refuses.** These are `warning`-severity codes and the
913
+ * operator may have reasons VAT cannot see; turning a publish into an exit 2 on
914
+ * a heuristic is a worse failure than the one being reported. Blocking belongs
915
+ * to `vat skills build` and `vat audit`, which run before this and can gate CI.
916
+ *
917
+ * Costs nothing extra: it reads the buffers already collected for the upload, so
918
+ * no file is opened twice and nothing is walked again.
919
+ *
920
+ * 🚨 **The three collectors are RAW issue producers, and raw issues are not what
921
+ * an adopter agreed to see.** Everywhere else in VAT they pass through
922
+ * {@link runValidationFramework}, which applies `validation.allow` waivers and
923
+ * `validation.severity` overrides. Printed unfiltered, this lane contradicted
924
+ * VAT's own documented remedy: `MCP_TOOL_NAME_UNQUALIFIED` tells authors to
925
+ * "waive that one identifier with a `validation.allow` entry", and an author who
926
+ * did exactly that got a green `vat skills build` and then the same warning on
927
+ * every publish with nothing left to silence it with.
928
+ *
929
+ * ⚠️ The `location` is the BUNDLE-relative path, not the API-keyed
930
+ * `<declared-name>/<path>` spelling. An allow glob matches `ValidationIssue.location`,
931
+ * which is contractually project/bundle-relative, so the keyed spelling could not
932
+ * have matched an entry even once allows were applied — the same divergence
933
+ * {@link bundleRelativeName} exists to strip out of the oversize message.
934
+ *
935
+ * ⛔ The run-level ALLOW_UNUSED sweep is deliberately NOT drained here. This lane
936
+ * runs three validator families out of the whole registry, so every allow entry
937
+ * for any other code would be reported as dead — advising the author to delete
938
+ * the entry that makes their build green.
939
+ */
940
+ export function warnUnportableReferences(files, bundleRoot, validation, logger) {
941
+ const rawIssues = [];
942
+ for (const file of files) {
943
+ // Skill DOCUMENTS only. These detectors read prose and code spans; running
944
+ // them over a `.png` or a bundled `.mjs` would report on bytes no agent
945
+ // reads as instructions.
946
+ if (!file.filename.toLowerCase().endsWith('.md'))
947
+ continue;
948
+ const location = bundleRelativeName(file.filename, bundleRoot);
949
+ const content = file.content.toString('utf8');
950
+ collectNonPortableAssetReferenceIssues(content, location, rawIssues);
951
+ collectNonPortableCommandIssues(content, location, rawIssues);
952
+ collectUnqualifiedMcpToolIssues(content, location, rawIssues);
953
+ }
954
+ const { emitted: issues } = runValidationFramework(rawIssues, validation ?? {}, createAllowUsageLedger());
955
+ if (issues.length === 0)
956
+ return;
957
+ logger.warn(` ${issues.length} portability ${issues.length === 1 ? 'warning' : 'warnings'} in this bundle `
958
+ + '(uploading anyway; run `vat audit <dir>` for the full report):');
959
+ for (const issue of issues) {
960
+ logger.warn(` ${issue.location ?? ''}: ${issue.message}`);
961
+ }
962
+ }
963
+ /**
964
+ * Upload a skill directory as a NEW skill.
965
+ *
966
+ * Always creates. It does not look for an existing skill of the same title and
967
+ * quietly switch to adding a version: `display_title` is not unique in a workspace
968
+ * (the API enforces it only when the field is sent explicitly, and derives a title
969
+ * from frontmatter otherwise), so such a lookup returns 0, 1 or N matches and the
970
+ * command's effect would depend on which. Updating an existing skill is
971
+ * `skills versions add`, which takes the id outright.
972
+ */
973
+ async function uploadSkillDir(client, skillDir, titleOverride, logger) {
974
+ const { displayTitle, files, dirName } = await prepareSkillUpload(skillDir, titleOverride, logger);
975
+ return sendSkillUpload(client, displayTitle, files, dirName);
976
+ }
977
+ /**
978
+ * Upload a skill directory as a new VERSION of an existing skill.
979
+ *
980
+ * The server assigns the version identifier and promotes it to `latest_version`;
981
+ * nothing here numbers a version.
982
+ *
983
+ * ⚠️ The uploaded files are keyed under the SOURCE tree's declared name, which
984
+ * this command cannot check against the roots earlier versions used: reading
985
+ * them back would mean a second endpoint whose response shape has not been
986
+ * measured. So the root is REPORTED rather than checked here, and the log line
987
+ * below is what lets an operator see which root their files landed under.
988
+ *
989
+ * ⛔ It is NOT true that a changed name "silently re-roots" the version — this
990
+ * comment and the command's help both said so, and the measurement says
991
+ * otherwise: the SERVER enforces SKILL.md name consistency per `skill_id`, so
992
+ * publishing from a renamed tree earns a loud 400. That is a better outcome than
993
+ * the one the warning described, and it is the one refusal this endpoint can
994
+ * earn, so it gets a remedy ({@link VERSION_NAME_MISMATCH_REFUSAL}) rather than
995
+ * the bare vendor sentence it used to get.
996
+ */
997
+ async function uploadSkillVersionDir(client, skillId, skillDir, logger) {
998
+ const { files, dirName } = await prepareSkillUpload(skillDir, undefined, logger);
999
+ logger.info(` Files are keyed under "${dirName}/", taken from this tree's SKILL.md name. `
1000
+ + 'The API refuses a version whose name differs from the one this skill already has.');
1001
+ // No `display_title` field: this version belongs to a skill that already has a
1002
+ // title, and sending one here would be an attempt to rename by side effect.
1003
+ // Its absence also changes the body's length, which is why the ceiling is
1004
+ // weighed on the body this endpoint sends rather than on the one `install` builds.
1005
+ const multipart = buildUploadBodyOrRefuse({}, files, dirName);
1006
+ // The duplicate-title 400 is unreachable here — `versions add` sends no
1007
+ // `display_title` — but the NAME refusal is not: the server enforces SKILL.md
1008
+ // name consistency per skill_id, so publishing from a tree whose frontmatter
1009
+ // `name` has changed is a 400, and it is the one refusal this endpoint can
1010
+ // earn. It got no remedy at all until the reviewer who read the log line above
1011
+ // pointed out that the line describes silent behaviour the server forbids.
1012
+ return readSkillVersionResponse(await sendUpload(() => client.uploadSkillVersion(skillId, multipart), multipart.body.length, withVersionRootRemedy));
163
1013
  }
164
1014
  /**
165
1015
  * List candidate package directories in node_modules (scoped + unscoped).
1016
+ *
1017
+ * Exported for testing: this and {@link findSkillsDir} decide WHICH tree
1018
+ * `--from-npm` publishes to an org, and the whole `--from-npm` path was
1019
+ * untested — `packages/cli/src/commands/**` is coverage-excluded, so nothing in
1020
+ * any report said so.
166
1021
  */
167
- function listNodeModulePackages(nodeModulesDir) {
168
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
1022
+ export function listNodeModulePackages(nodeModulesDir) {
169
1023
  if (!existsSync(nodeModulesDir))
170
1024
  return [];
171
1025
  const results = [];
172
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
1026
+ // Followed: a workspace package under node_modules IS a symlink, and a walk
1027
+ // that tested `isDirectory()` on the link skipped every one of them.
173
1028
  for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
174
- if (!entry.isDirectory())
1029
+ if (direntKindFollowingSync(nodeModulesDir, entry) !== 'directory')
175
1030
  continue;
176
1031
  if (entry.name.startsWith('@')) {
177
1032
  const scopeDir = safePath.join(nodeModulesDir, entry.name);
178
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
179
1033
  for (const scopedEntry of readdirSync(scopeDir, { withFileTypes: true })) {
180
- if (scopedEntry.isDirectory())
1034
+ if (direntKindFollowingSync(scopeDir, scopedEntry) === 'directory')
181
1035
  results.push(safePath.join(scopeDir, scopedEntry.name));
182
1036
  }
183
1037
  }
@@ -191,20 +1045,43 @@ function listNodeModulePackages(nodeModulesDir) {
191
1045
  * Find the dist/skills/ directory in a package. Checks the package itself
192
1046
  * first, then scans node_modules for sub-packages that contain built skills.
193
1047
  */
194
- function findSkillsDir(packageDir) {
1048
+ export function findSkillsDir(packageDir) {
195
1049
  const direct = safePath.join(packageDir, 'dist', 'skills');
196
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
197
1050
  if (existsSync(direct))
198
1051
  return direct;
199
1052
  const candidates = listNodeModulePackages(safePath.join(packageDir, 'node_modules'));
200
1053
  for (const pkgDir of candidates) {
201
1054
  const candidate = safePath.join(pkgDir, 'dist', 'skills');
202
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
203
1055
  if (existsSync(candidate))
204
1056
  return candidate;
205
1057
  }
206
1058
  return undefined;
207
1059
  }
1060
+ /**
1061
+ * The batch's report, tagged as a failure when ANY skill did not publish.
1062
+ *
1063
+ * 🔑 Partial success is a failure. The old code returned this document plainly,
1064
+ * so `executeOrgCommand` stamped `status: success` on it and exited 0 — a run in
1065
+ * which all three skills were rejected printed `skillsUploaded: 0 /
1066
+ * skillsFailed: 3` under `status: success`, and a CI wrapper written as
1067
+ * `vat claude org skills install --from-npm … || fail` published nothing and
1068
+ * reported green.
1069
+ *
1070
+ * Some-succeeded is tagged the same way as none-succeeded, deliberately: the
1071
+ * workspace is now in a MIXED state that nobody asked for, which is exactly the
1072
+ * case a human has to look at. Calling it green because two of three landed is
1073
+ * the same lie, only smaller. What did land stays in the document, so the reader
1074
+ * can see how far the run got.
1075
+ */
1076
+ export function summarizeNpmInstall(source, results, errors) {
1077
+ const summary = {
1078
+ source,
1079
+ skillsUploaded: results.length,
1080
+ ...(errors.length > 0 ? { skillsFailed: errors.length, errors } : {}),
1081
+ skills: results,
1082
+ };
1083
+ return errors.length > 0 ? orgCommandFailure(summary) : summary;
1084
+ }
208
1085
  /**
209
1086
  * Upload skills from an npm package.
210
1087
  */
@@ -218,9 +1095,9 @@ async function installFromNpm(npmPackage, skillFilter, client, logger) {
218
1095
  throw new Error(`No dist/skills/ directory found in ${npmPackage}. Was the package built with vat skills build?`);
219
1096
  }
220
1097
  logger.info(`Found skills at: ${safePath.relative(packageDir, skillsDir) || 'dist/skills/'}`);
221
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
1098
+ // Followed: a built skill directory that is a link is still a skill to upload.
222
1099
  const skillDirs = readdirSync(skillsDir, { withFileTypes: true })
223
- .filter(e => e.isDirectory())
1100
+ .filter(e => direntKindFollowingSync(skillsDir, e) === 'directory')
224
1101
  .map(e => e.name);
225
1102
  if (skillDirs.length === 0) {
226
1103
  throw new Error(`No skills found in dist/skills/ of ${npmPackage}`);
@@ -246,47 +1123,247 @@ async function installFromNpm(npmPackage, skillFilter, client, logger) {
246
1123
  errors.push({ skill: skillName, error: msg });
247
1124
  }
248
1125
  }
249
- return {
250
- source: npmPackage,
251
- skillsUploaded: results.length,
252
- ...(errors.length > 0 ? { skillsFailed: errors.length, errors } : {}),
253
- skills: results,
254
- };
1126
+ return summarizeNpmInstall(npmPackage, results, errors);
255
1127
  }
256
1128
  finally {
257
1129
  rmSync(tempDir, { recursive: true, force: true });
258
1130
  }
259
1131
  }
260
1132
  /**
261
- * Upload a local skill directory or ZIP file.
1133
+ * Upload a local skill directory or ZIP file. Exported for testing.
262
1134
  */
263
- async function installFromLocal(source, titleOverride, client, logger) {
264
- const sourcePath = source.startsWith('/') ? source : safePath.join(process.cwd(), source);
265
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
1135
+ export async function installFromLocal(source, titleOverride, client, logger) {
1136
+ const sourcePath = resolveSourceArgument(source);
266
1137
  if (!existsSync(sourcePath)) {
267
1138
  throw new Error(`Source not found: ${sourcePath}`);
268
1139
  }
269
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
270
1140
  const stat = statSync(sourcePath);
271
- if (!stat.isDirectory() && sourcePath.endsWith('.zip')) {
272
- const displayTitle = titleOverride ?? basename(sourcePath, '.zip');
273
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
274
- const zipContent = readFileSync(sourcePath);
275
- const files = [{
276
- fieldName: 'files[]',
277
- filename: basename(sourcePath),
278
- content: zipContent,
279
- }];
280
- logger.info(`Uploading ZIP: ${sourcePath} (${(zipContent.length / 1024).toFixed(1)}KB)`);
281
- logger.info(`Display title: ${displayTitle}`);
282
- return sendSkillUpload(client, displayTitle, files);
1141
+ // Case-INSENSITIVE. `endsWith('.zip')` refused `MySkill.ZIP` as "not a directory
1142
+ // or .zip file" — a file Windows and macOS both consider a zip archive, and one
1143
+ // an operator has no way to read that refusal as being about capitalisation.
1144
+ if (!stat.isDirectory() && sourcePath.toLowerCase().endsWith('.zip')) {
1145
+ return installZipArchive(sourcePath, titleOverride, client, logger);
283
1146
  }
284
1147
  if (!stat.isDirectory()) {
285
1148
  throw new Error(`Source must be a directory or .zip file: ${sourcePath}`);
286
1149
  }
287
- logger.info(`Uploading skill directory: ${sourcePath}`);
1150
+ // "Packaging", not "Uploading". Everything that follows this line — reading the
1151
+ // config, collecting the file set, weighing the request — happens locally and
1152
+ // can refuse locally, and an operator who read "Uploading skill directory: X"
1153
+ // above a local size refusal was told about an upload that never started. The
1154
+ // line stays HERE rather than moving after the point of no return, because
1155
+ // naming the source before the work is what makes a failure attributable at
1156
+ // all — `--from-npm` packages several skills in a loop, and a packaging error
1157
+ // with no path above it names nothing.
1158
+ logger.info(`Packaging skill directory: ${sourcePath}`);
288
1159
  return uploadSkillDir(client, sourcePath, titleOverride, logger);
289
1160
  }
1161
+ /**
1162
+ * Publish a ZIP archive as a NEW skill.
1163
+ *
1164
+ * Split out of {@link installFromLocal} because the archive lane now has three
1165
+ * decisions of its own — an answer-key refusal, an expanded-size refusal, and a
1166
+ * title/name divergence warning — and inlining them made the dispatcher a
1167
+ * cognitively complex function whose real job (directory or archive?) was two
1168
+ * lines of it.
1169
+ */
1170
+ async function installZipArchive(sourcePath, titleOverride, client, logger) {
1171
+ // `basename(path, ext)` matches the extension exactly, so the literal '.zip'
1172
+ // leaves `.ZIP` on the title. Slice the length instead.
1173
+ const displayTitle = titleOverride ?? basename(sourcePath).slice(0, -'.zip'.length);
1174
+ const zipContent = readFileSync(sourcePath);
1175
+ const files = [{
1176
+ fieldName: 'files[]',
1177
+ filename: basename(sourcePath),
1178
+ content: zipContent,
1179
+ }];
1180
+ // A ZIP is by construction a single large binary, so this is the shape most
1181
+ // likely to be over the ceiling — and the one that reached the wire unmeasured
1182
+ // while the check lived only in the directory-packaging path. It is gated in
1183
+ // `sendSkillUpload` below, on the same measure (the multipart body) as every
1184
+ // other shape; this line reports the file's own bytes, which are smaller.
1185
+ //
1186
+ // "Preparing", not "Uploading": nothing has been sent at this point, and the
1187
+ // very next step can refuse locally. A line that claims an upload started is
1188
+ // the log telling the operator about work that never happened.
1189
+ logger.info(`Preparing ZIP: ${sourcePath} (${formatBytes(zipContent.length)} of file content)`);
1190
+ // Where the title came FROM, not just what it is. A ZIP's title is the
1191
+ // filename — `wiki-lint-v2.zip` publishes a skill called `wiki-lint-v2`,
1192
+ // which is a DIFFERENT skill from `wiki-lint` and is not refused, because
1193
+ // display_title uniqueness is enforced only when the field is sent. A
1194
+ // directory takes its title from SKILL.md instead, so the same tree zipped
1195
+ // and unzipped can publish under two names.
1196
+ //
1197
+ // ⛔ This comment used to end "VAT cannot read the SKILL.md inside the
1198
+ // archive: Node ships no ZIP reader, and adding a dependency to parse one
1199
+ // here would mean owning central-directory, zip64 and zip-slip handling for
1200
+ // a log line." The PREMISE was false — `adm-zip` is already a runtime
1201
+ // dependency of this package — and the cost/benefit was weighed against the
1202
+ // wrong benefit: the same read also answers whether the archive EXPANDS
1203
+ // past the ceiling, which is a refusal, not a log line. So the archive is
1204
+ // now read (see {@link inspectZipArchive}) and the divergence is reported
1205
+ // as a fact below, in addition to this provenance line.
1206
+ logger.info(`Display title: "${displayTitle}" (${titleOverride === undefined
1207
+ ? 'from the ZIP filename — NOT from the SKILL.md inside it; pass --title to set it'
1208
+ : 'from --title'})`);
1209
+ const inspected = await inspectZipArchive(sourcePath);
1210
+ if ('archiveUnreadable' in inspected) {
1211
+ // Degrade, and SAY so. Every check below is skipped, and a skipped check
1212
+ // that is not announced is indistinguishable from one that passed.
1213
+ logger.warn(`Could not read the archive (${inspected.archiveUnreadable}); the answer-key, expanded-size, `
1214
+ + `name and portability checks were not run on it. The API decides the upload.`);
1215
+ }
1216
+ else {
1217
+ refuseOrWarnOnArchive(inspected, displayTitle, zipContent.length, logger);
1218
+ }
1219
+ return sendSkillUpload(client, displayTitle, files);
1220
+ }
1221
+ /**
1222
+ * The archive lane's own decisions, over what {@link inspectZipArchive} read:
1223
+ * two refusals (answer key, expanded size), and the warnings the inspection
1224
+ * earns (title/name divergence, portability, members it could not inflate).
1225
+ */
1226
+ function refuseOrWarnOnArchive(inspected, displayTitle, wireBytes, logger) {
1227
+ // 🚨 REFUSED, not warned. The directory lane refuses an answer key
1228
+ // unconditionally (`NEVER_UPLOADED_DIR_NAMES`, plus whatever the config
1229
+ // declares), and until this existed the ZIP lane bypassed `collectFiles`
1230
+ // entirely and posted the archive as one opaque part — so
1231
+ // `zip -r my-skill.zip my-skill/` over a tree holding
1232
+ // `my-skill/evals/evals.json` published the answer key org-wide with a
1233
+ // green tick, while `install my-skill/` on the very same tree refused.
1234
+ // Two lanes of one command must not disagree about whether a skill's
1235
+ // answer key may be published, and a warning is a disagreement.
1236
+ //
1237
+ // This lane can only see the CONVENTIONAL names: there is no skill
1238
+ // directory to walk up from, so a config-declared suite location is
1239
+ // unreadable here. That is a narrower check than the directory lane's,
1240
+ // not a looser verdict on what it does see.
1241
+ if (inspected.neverUploaded.length > 0) {
1242
+ throw new Error(`This ZIP contains ${String(inspected.neverUploaded.length)} entr`
1243
+ + `${inspected.neverUploaded.length === 1 ? 'y' : 'ies'} that are never published with a `
1244
+ + `skill: ${inspected.neverUploaded.slice(0, 10).join(', ')}`
1245
+ + `${inspected.neverUploaded.length > 10 ? ', …' : ''}. An eval suite is the skill's ANSWER `
1246
+ + `KEY and this would publish it to everyone in the organization. Rebuild the archive from `
1247
+ + `the BUILT skill directory (\`vat skills build\`), or upload that directory directly — `
1248
+ + `\`install <dir>\` withholds these and reports each one.`);
1249
+ }
1250
+ if (inspected.uncompressedBytes > API_SKILL_MAX_UPLOAD_BYTES) {
1251
+ throw new Error(`ZIP expands to ${formatBytes(inspected.uncompressedBytes)}, over the `
1252
+ + `${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} ceiling. The API expands the archive and `
1253
+ + `weighs the UNCOMPRESSED total, so ${formatBytes(wireBytes)} on the wire does not `
1254
+ + `get this under the limit — it will refuse with "Zip file uncompressed size exceeds 30MB". `
1255
+ + `Remove files from the bundle; compressing harder cannot help.`);
1256
+ }
1257
+ // The title/name divergence this branch discloses above, now stated as a
1258
+ // FACT rather than as a caveat, because the archive can be read after
1259
+ // all. A ZIP whose inner SKILL.md declares a different name publishes a
1260
+ // skill whose title and versions disagree — and that divergence is what
1261
+ // makes a title-keyed lookup unsafe, so it is worth naming at the moment
1262
+ // it is minted rather than diagnosing later.
1263
+ if (inspected.declaredName !== undefined && inspected.declaredName !== displayTitle) {
1264
+ logger.warn(`Title/name divergence: this uploads with display title "${displayTitle}", but the `
1265
+ + `SKILL.md inside declares name "${inspected.declaredName}". Every version will carry `
1266
+ + `the declared name. Pass --title "${inspected.declaredName}" to make them agree.`);
1267
+ }
1268
+ // 🚨 The SAME function the directory lane calls, over the archive's own
1269
+ // markdown. Until this line existed, `install <zip>` ran NONE of the
1270
+ // portability checks its `--help` promises of "every markdown document in
1271
+ // the bundle" — the archive went straight to `sendSkillUpload`, so the
1272
+ // adopter finding that produced the family (10 of 54 skills referencing a
1273
+ // path outside their own tree, published green and unable to run) was
1274
+ // invisible on the lane most likely to carry a hand-built bundle.
1275
+ //
1276
+ // ⚠️ NO validation config, and that is a real narrowing, stated in the
1277
+ // help text. `validation.allow` / `validation.severity` are resolved by
1278
+ // walking up from a skill's SKILL.md to its governing project — and an
1279
+ // archive member has no path on disk to walk up from, the same reason a
1280
+ // config-declared eval-suite location is unreadable here. So a waiver that
1281
+ // silences `vat skills build` does NOT silence this lane. The checks only
1282
+ // warn, so the cost of the narrowing is noise, never a blocked publish;
1283
+ // the cost of skipping them was silence on a documented promise.
1284
+ warnUnportableReferences(inspected.documents, inspected.bundleRoot, undefined, logger);
1285
+ if (inspected.unreadable.length > 0) {
1286
+ const named = inspected.unreadable.map(({ entry, reason }) => `${entry} (${reason})`).join(', ');
1287
+ logger.warn(`${String(inspected.unreadable.length)} archive member(s) could not be inflated and were `
1288
+ + `not checked: ${named}`);
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Delete every version handed in, and keep going when one refuses.
1293
+ *
1294
+ * 🚨 **One refusal is a fact about ONE version.** This loop used to return on the
1295
+ * first one, so every version after it was never ATTEMPTED — not failed, not
1296
+ * reported, unexamined. Deleting a workspace skill requires deleting every
1297
+ * version first, so the operator was left with a skill that could not be deleted,
1298
+ * a report that named one problem out of an unknown number, and no way to tell
1299
+ * from the output which versions were still there. A transient refusal on version
1300
+ * 2 of 30 hid the state of the other 28.
1301
+ *
1302
+ * The sweep therefore ATTEMPTS all of them and reports both lists. The caller
1303
+ * decides what the outcome means: some deleted plus some failed is a run that
1304
+ * happened and went wrong (exit 1, with the record); nothing deleted at all is
1305
+ * the ending the command's help documents as exit 2.
1306
+ *
1307
+ * ⛔ It deliberately does NOT stop early on a repeated failure. A "the API is
1308
+ * clearly down, give up" heuristic is how the abandoned-versions defect reads
1309
+ * from inside: the whole value here is that every version's state is KNOWN when
1310
+ * the command ends.
1311
+ */
1312
+ export async function deleteEveryVersion(client, skillId, versions, logger) {
1313
+ const deleted = [];
1314
+ const failures = [];
1315
+ for (const version of versions) {
1316
+ try {
1317
+ await client.deleteSkillVersion(skillId, version);
1318
+ }
1319
+ catch (error) {
1320
+ const reason = error instanceof Error ? error.message : String(error);
1321
+ failures.push({ version, reason });
1322
+ logger.info(` ⚠ version ${version} was not deleted: ${reason}`);
1323
+ continue;
1324
+ }
1325
+ deleted.push(version);
1326
+ logger.info(` Deleted version ${version}`);
1327
+ }
1328
+ return { deleted, failures };
1329
+ }
1330
+ /**
1331
+ * Every version the sweep could not delete, and why — the one sentence both the
1332
+ * published report and the exit-2 throw are built from, so the two endings never
1333
+ * describe the same run differently.
1334
+ */
1335
+ export function describeVersionSweepFailures(failures) {
1336
+ return failures.map(failure => `${failure.version}: ${failure.reason}`).join('; ');
1337
+ }
1338
+ /**
1339
+ * The document a `--all` run publishes when it destroyed something and then did
1340
+ * not finish.
1341
+ *
1342
+ * 🔑 The deleted list is a RECORD, not a progress counter. Version deletion is
1343
+ * irreversible, so a failure part-way through leaves a workspace nobody can
1344
+ * reconstruct — and throwing here would end on `handleCommandError`'s exit 2
1345
+ * ("the run could not happen") and DISCARD the document. It happened, and it
1346
+ * destroyed things the operator now has no other list of. So the lists travel
1347
+ * with the failure and the run ends 1: it happened, and its outcome is wrong.
1348
+ *
1349
+ * `failedVersions` is the other half, and the half a re-run needs: what is still
1350
+ * there. Without it the operator knows what they lost and not what they still
1351
+ * have to deal with.
1352
+ */
1353
+ export function reportHalfDeleted(report) {
1354
+ const { skillId, deletedVersions, failedVersions } = report;
1355
+ return orgCommandFailure({
1356
+ id: skillId,
1357
+ deleted: false,
1358
+ deletedVersions,
1359
+ failedVersions,
1360
+ error: report.error,
1361
+ note: `${String(deletedVersions.length)} version(s) of ${skillId} were deleted and cannot be `
1362
+ + `restored; ${String(failedVersions.length)} could not be deleted and are still there. `
1363
+ + 'The skill itself still exists — the API refuses a skill that still has versions. '
1364
+ + 'Re-run `--all` once the cause is cleared: it will attempt only what is left.',
1365
+ });
1366
+ }
290
1367
  // ── Commands ───────────────────────────────────────────────────────────
291
1368
  export function createOrgSkillsCommand() {
292
1369
  const command = new Command('skills');
@@ -322,14 +1399,32 @@ Example:
322
1399
  .option('--title <title>', 'Display title override (single skill only)')
323
1400
  .option('--debug', DEBUG_OPT_DESC)
324
1401
  .action(async (source, options) => {
325
- if (!source && !options.fromNpm) {
326
- throw new Error('Provide a <source> path or use --from-npm <package>');
327
- }
328
- if (source && options.fromNpm) {
329
- throw new Error('Provide either <source> or --from-npm, not both');
330
- }
331
1402
  const commandName = options.fromNpm ? 'OrgSkillsInstallNpm' : 'OrgSkillsInstall';
332
1403
  await executeOrgCommand(commandName, options.debug, async ({ client, logger }) => {
1404
+ // INSIDE the action, like `versions add`'s own guards. Thrown from the
1405
+ // Commander handler instead, these were a floating rejection that
1406
+ // reached no catch: Node printed a raw stack trace with absolute $HOME
1407
+ // paths, wrote nothing to the stdout this command's help promises, and
1408
+ // exited 1 — which the documented contract reads as "at least one
1409
+ // error-severity finding" for a run in which nothing executed.
1410
+ if (!source && !options.fromNpm) {
1411
+ throw new Error('Provide a <source> path or use --from-npm <package>');
1412
+ }
1413
+ if (source && options.fromNpm) {
1414
+ throw new Error('Provide either <source> or --from-npm, not both');
1415
+ }
1416
+ // The other two illegal combinations, refused rather than dropped. Both
1417
+ // flags used to be accepted and silently ignored on the lane that cannot
1418
+ // honour them, which is how an operator gets a skill published under the
1419
+ // wrong title — or every skill in a package published when they named one.
1420
+ if (options.title !== undefined && options.fromNpm) {
1421
+ throw new Error('--title applies to a single skill and --from-npm can publish several, so it is '
1422
+ + 'refused here rather than silently ignored. Publish the one skill with '
1423
+ + '--skill and set its title in its SKILL.md, or upload its directory directly.');
1424
+ }
1425
+ if (options.skill !== undefined && !options.fromNpm) {
1426
+ throw new Error('--skill selects one skill inside an npm package; it applies only with --from-npm');
1427
+ }
333
1428
  if (options.fromNpm) {
334
1429
  return installFromNpm(options.fromNpm, options.skill, client, logger);
335
1430
  }
@@ -342,14 +1437,66 @@ Description:
342
1437
  Accepts a built skill directory, a ZIP file, or an npm package.
343
1438
  Requires ANTHROPIC_API_KEY (regular key, not admin key).
344
1439
 
345
- The skill uploads under the "name" its SKILL.md frontmatter declares, which
1440
+ A DIRECTORY uploads under the "name" its SKILL.md frontmatter declares, which
346
1441
  is also the default display_title.
347
1442
 
1443
+ A ZIP takes its display_title from the ZIP FILENAME, so my-skill-v2.zip
1444
+ publishes a skill titled "my-skill-v2", which is a separate skill from
1445
+ "my-skill" and is not refused as a duplicate. VAT reads the SKILL.md nearest
1446
+ the root of the archive and WARNS when the name it declares differs from that
1447
+ title, because every version the skill ever gets will carry the declared name.
1448
+ Pass --title to set the title explicitly.
1449
+
348
1450
  A skill's eval suite is its answer key and is never uploaded: whatever the
349
1451
  governing vibe-agent-toolkit.config.yaml declares as this skill's test input
350
- (skills.config.<name>.test.evals) is withheld, and so is any evals/ directory
351
- when no config is discoverable. node_modules/ and .git/ are never uploaded
352
- either. Each exclusion is reported in the output.
1452
+ (skills.config.<name>.test.evals) is withheld, and so is ANY directory named
1453
+ evals/ — unconditionally, whether or not a config was found. node_modules/ and
1454
+ .git/ are never uploaded either. Each exclusion is reported in the output.
1455
+
1456
+ A ZIP is REFUSED outright when it contains any of those three directory names,
1457
+ rather than having them withheld: the archive is posted as one part, so there
1458
+ is nothing to strip out of it. Rebuild it from the built skill directory, or
1459
+ upload that directory. (A config-declared suite in some other location is
1460
+ invisible to this lane — there is no directory to resolve a config from.)
1461
+
1462
+ Those same three directory names are also left out of the bytes weighed against
1463
+ the ceiling below, and out of the build-time PACKAGED_SIZE_EXCEEDS_API_LIMIT
1464
+ total, so \`du -sb\` on the directory will report more than VAT does.
1465
+
1466
+ A symbolic link is refused rather than followed, whatever it points at: a
1467
+ multipart body cannot express one, and reading through it would publish the
1468
+ target's bytes under an in-bundle name.
1469
+
1470
+ Before anything is sent, every markdown document in the bundle is run through
1471
+ the non-portable reference, non-portable command, and unqualified MCP tool-name
1472
+ checks — the ones that catch a skill referencing a path outside its own tree,
1473
+ which cannot resolve once the API publishes it alone. These WARN and never
1474
+ block. Run \`vat audit <dir>\` for the full report.
1475
+
1476
+ A ZIP faces the same three checks: its markdown members are read out of the
1477
+ archive and run through them, so a hand-built bundle is not the quiet lane.
1478
+ The one difference is the config. On a DIRECTORY, \`validation.allow\` /
1479
+ \`validation.severity\` from the governing vibe-agent-toolkit.config.yaml are
1480
+ honoured, so a waiver that makes \`vat skills build\` green makes this quiet
1481
+ too. On a ZIP there is no directory to resolve that config from — the same
1482
+ reason a config-declared eval-suite location is invisible here — so the checks
1483
+ run UNWAIVED and a warning you have already waived elsewhere will still print.
1484
+
1485
+ There are TWO size gates and a ZIP faces both. The REQUEST gate weighs the
1486
+ multipart body — file bytes plus about 156 bytes of framing per file — against
1487
+ the API's ${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} ceiling; it is inclusive,
1488
+ measured: a body of exactly ${API_SKILL_MAX_UPLOAD_BYTES.toLocaleString('en-US')}
1489
+ bytes is accepted and one byte more is refused 413. A directory and a ZIP both
1490
+ go through it. The API then EXPANDS a ZIP and applies the same ceiling to the
1491
+ uncompressed total, so an archive far under the request ceiling can still be
1492
+ refused — VAT reads the archive's own headers and refuses first, naming the
1493
+ expanded size, because compressing harder cannot get it under the limit.
1494
+
1495
+ Exit Codes:
1496
+ 0 - Every skill uploaded
1497
+ 1 - The run completed and at least one skill failed to upload (--from-npm
1498
+ uploads several; the ones that landed are listed under skills)
1499
+ 2 - The run could not happen: no API key, no such source, unusable input
353
1500
 
354
1501
  Examples:
355
1502
  $ vat claude org skills install dist/skills/org-admin
@@ -366,27 +1513,103 @@ Examples:
366
1513
  .option('--debug', DEBUG_OPT_DESC)
367
1514
  .action(async (skillId, options) => {
368
1515
  await executeOrgCommand('OrgSkillsDelete', options.debug, async ({ client, logger }) => {
1516
+ // Every version this run actually destroyed, accumulated as it goes.
1517
+ //
1518
+ // 🔑 It is a RECORD, not a progress counter. Version deletion is
1519
+ // irreversible and `--all` does it in a loop, so a failure part-way
1520
+ // through leaves a workspace nobody can reconstruct — and the old code
1521
+ // threw at that point, which ends on `handleCommandError`'s exit 2
1522
+ // ("the run could not happen") and DISCARDS the document. It happened,
1523
+ // and it destroyed things the operator now has no list of. So the list
1524
+ // travels with the failure, and the run ends 1: it happened, and its
1525
+ // outcome is wrong.
1526
+ const deletedVersions = [];
1527
+ const halfDeleted = (error, failedVersions) => reportHalfDeleted({
1528
+ skillId,
1529
+ deletedVersions,
1530
+ failedVersions,
1531
+ error: error instanceof Error ? error.message : String(error),
1532
+ });
369
1533
  if (options.all) {
370
- // Fetch and delete all versions first
371
- const versions = await autopaginateSkills(client, `/v1/skills/${encodeURIComponent(skillId)}/versions`);
1534
+ const versions = await autopaginateSkills(client, skillVersionsPath(skillId));
372
1535
  const versionData = versions.data;
373
- logger.info(`Deleting ${versionData.length} version(s) of ${skillId}`);
374
- for (const ver of versionData) {
375
- await client.deleteSkillVersion(skillId, ver.version);
376
- logger.info(` Deleted version ${ver.version}`);
1536
+ // "Found …", not "Deleting …": nothing has been deleted at this point
1537
+ // and the very next call can be refused. Cf. `installFromLocal`.
1538
+ logger.info(`Found ${String(versionData.length)} version(s) of ${skillId} to delete first`);
1539
+ // ATTEMPTS every version, whatever any one of them answers. See
1540
+ // `deleteEveryVersion`: returning on the first refusal left the rest
1541
+ // unexamined, and the skill undeletable with no record of what remained.
1542
+ const sweep = await deleteEveryVersion(client, skillId, versionData.map(v => v.version), logger);
1543
+ deletedVersions.push(...sweep.deleted);
1544
+ if (sweep.failures.length > 0) {
1545
+ const detail = describeVersionSweepFailures(sweep.failures);
1546
+ // Nothing was destroyed, so there is no record worth publishing and
1547
+ // "could not happen" (exit 2, via the throw) is the honest ending —
1548
+ // the same rule as before, now decided on the WHOLE sweep rather than
1549
+ // on its first refusal.
1550
+ if (deletedVersions.length === 0) {
1551
+ throw new Error(`No version of ${skillId} could be deleted, so the skill was left alone. `
1552
+ + `${String(sweep.failures.length)} attempt(s) failed — ${detail}`);
1553
+ }
1554
+ // The skill delete is not attempted: the API refuses a skill that still
1555
+ // has versions, so it would spend a round trip to be told what is
1556
+ // already known here.
1557
+ return halfDeleted(new Error(detail), sweep.failures.map(failure => failure.version));
377
1558
  }
378
1559
  }
379
1560
  logger.info(`Deleting skill: ${skillId}`);
380
- const result = await client.deleteSkill(skillId);
381
- return { id: result.id, deleted: result.type === 'skill_deleted' };
1561
+ // The vendor's refusal here names no VAT command — measured live as
1562
+ // `400 Cannot delete skill with existing versions. Delete all versions
1563
+ // first.` — so the one that answers it is appended, unless this run
1564
+ // already deleted them.
1565
+ let raw;
1566
+ try {
1567
+ raw = await deleteSkillOrExplain(client, skillId, options.all === true ? DELETE_REMEDIES_AFTER_ALL : DELETE_REMEDIES);
1568
+ }
1569
+ catch (error) {
1570
+ if (deletedVersions.length === 0)
1571
+ throw error;
1572
+ // The sweep completed — every version is gone — and the SKILL delete is
1573
+ // what failed, so nothing is left in `failedVersions`.
1574
+ return halfDeleted(error, []);
1575
+ }
1576
+ const deleted = reportDelete(raw, skillId, SKILL_DELETED_TYPES);
1577
+ return deletedVersions.length === 0
1578
+ ? deleted
1579
+ // The versions are part of what this run DID, so they belong in the
1580
+ // document either way — and the failure tag has to survive being
1581
+ // merged with them.
1582
+ : mergeDeleteReport(deleted, deletedVersions);
382
1583
  });
383
1584
  })
384
1585
  .addHelpText('after', `
385
1586
  Description:
386
1587
  Deletes a skill from the organization. Uses the Skills API (beta).
387
- Use --all to auto-delete all versions before the skill.
388
1588
  Requires ANTHROPIC_API_KEY (regular key, not admin key).
389
1589
 
1590
+ The API refuses to delete a skill that still has versions (400). Use --all to
1591
+ delete every version and then the skill in one command; without it, delete the
1592
+ versions yourself with \`vat claude org skills versions delete\` first.
1593
+
1594
+ --all deletes irreversibly and in a loop, and it ATTEMPTS EVERY VERSION even
1595
+ when one refuses — one refusal is a fact about one version, not about the ones
1596
+ after it. When the sweep ends with anything left, the versions it destroyed are
1597
+ listed under deletedVersions, the ones still there under failedVersions, and
1598
+ the run exits 1 — the run happened, and its outcome is wrong. The skill itself
1599
+ is then left alone, because the API refuses a skill that still has versions.
1600
+ It exits 2 only when nothing was deleted at all.
1601
+
1602
+ A version delete whose response is lost is replayed, and the replay is answered
1603
+ 404 because the first attempt already removed it. That 404 is read as the
1604
+ delete it is, not as a failure — the version appears under deletedVersions.
1605
+
1606
+ Exit Codes:
1607
+ 0 - The skill was deleted
1608
+ 1 - The run completed and the skill was NOT deleted: the API named a different
1609
+ outcome, or --all destroyed some versions and could not destroy the rest
1610
+ 2 - The run could not happen: no API key, the first request was refused, or
1611
+ --all deleted no version at all
1612
+
390
1613
  Example:
391
1614
  $ vat claude org skills delete skill_abc123 --all
392
1615
  `);
@@ -400,7 +1623,7 @@ Example:
400
1623
  .option('--debug', DEBUG_OPT_DESC)
401
1624
  .action(async (skillId, options) => {
402
1625
  await executeOrgCommand('OrgSkillsVersionsList', options.debug, async ({ client }) => {
403
- return autopaginateSkills(client, `/v1/skills/${encodeURIComponent(skillId)}/versions`);
1626
+ return autopaginateSkills(client, skillVersionsPath(skillId));
404
1627
  });
405
1628
  })
406
1629
  .addHelpText('after', `
@@ -420,8 +1643,7 @@ Example:
420
1643
  .action(async (skillId, version, options) => {
421
1644
  await executeOrgCommand('OrgSkillsVersionsDelete', options.debug, async ({ client, logger }) => {
422
1645
  logger.info(`Deleting version ${version} of skill ${skillId}`);
423
- const result = await client.deleteSkillVersion(skillId, version);
424
- return { id: result.id, deleted: result.type === 'skill_version_deleted' };
1646
+ return reportVersionDelete(await client.deleteSkillVersion(skillId, version), skillId, version);
425
1647
  });
426
1648
  })
427
1649
  .addHelpText('after', `
@@ -430,10 +1652,94 @@ Description:
430
1652
  All versions must be deleted before a skill can be deleted.
431
1653
  Requires ANTHROPIC_API_KEY.
432
1654
 
1655
+ Output:
1656
+ - id: the SKILL this version belonged to (always the id you passed)
1657
+ - version: the version that was destroyed (always the one you passed)
1658
+ - deleted: whether the API confirmed the deletion
1659
+
1660
+ Exit Codes:
1661
+ 0 - The version was deleted
1662
+ 1 - The run completed and the API named an outcome other than a deletion
1663
+ 2 - The run could not happen: no API key, or the request was refused
1664
+
433
1665
  Example:
434
1666
  $ vat claude org skills versions delete skill_abc123 1775007400733130
1667
+ `);
1668
+ const versionsAddCmd = new Command('add');
1669
+ versionsAddCmd
1670
+ .description('Publish a new version of an existing skill')
1671
+ .argument(SKILL_ID_ARG, SKILL_ID_DESC)
1672
+ // NOT "or ZIP file", which is what this said when it was copied from
1673
+ // `install`: the guard below refuses anything that is not a directory, so the
1674
+ // help promised an input the command rejects. Narrowing the promise rather
1675
+ // than widening the code is the honest fix — a ZIP posted to
1676
+ // POST /v1/skills/{id}/versions has never been tried against the live API, and
1677
+ // this is not the place to find out by guessing.
1678
+ .argument('<source>', 'Path to a built skill directory')
1679
+ .option('--debug', DEBUG_OPT_DESC)
1680
+ .action(async (skillId, source, options) => {
1681
+ await executeOrgCommand('OrgSkillsVersionsAdd', options.debug, async ({ client, logger }) => {
1682
+ const resolved = resolveSourceArgument(source);
1683
+ if (!existsSync(resolved))
1684
+ throw new Error(`Source not found: ${resolved}`);
1685
+ if (!statSync(resolved).isDirectory()) {
1686
+ // Name the asymmetry IN the refusal, not only in --help. `install`
1687
+ // accepts a ZIP and this verb does not, so the operator most likely to
1688
+ // hit this is the one who just read that `install` takes one — and a
1689
+ // refusal that says only "must be a directory" reads as a bug in their
1690
+ // path, not as a difference between two verbs.
1691
+ const zipHint = resolved.toLowerCase().endsWith('.zip')
1692
+ ? ' A ZIP is accepted by `vat claude org skills install`, not here:'
1693
+ + ' publish a new version from the built skill directory instead.'
1694
+ : '';
1695
+ throw new Error(`Source must be a skill directory: ${resolved}.${zipHint}`);
1696
+ }
1697
+ // "Packaging", not "Publishing" — see `installFromLocal`. Measured: this
1698
+ // line printed, then `Failed to load config: …`, and nothing had been
1699
+ // sent. The announcement now describes the step it actually precedes.
1700
+ logger.info(`Packaging new version of ${skillId} from: ${resolved}`);
1701
+ return uploadSkillVersionDir(client, skillId, resolved, logger);
1702
+ });
1703
+ })
1704
+ .addHelpText('after', `
1705
+ Description:
1706
+ Publishes the contents of a skill directory as a NEW VERSION of an existing
1707
+ skill. Uses the Skills API (beta). Requires ANTHROPIC_API_KEY.
1708
+
1709
+ This is how you ship a change to a skill you have already published. It is a
1710
+ separate command from \`install\` on purpose: \`install\` always creates a new
1711
+ skill, this always adds a version to the skill you name, and neither inspects
1712
+ the workspace to decide which it "should" do. A display title is NOT unique in
1713
+ a workspace, so resolving one to a skill can match none, one, or several — and
1714
+ a wrong match would append your version to somebody else's skill.
1715
+
1716
+ Find the id with \`vat claude org skills list\`. The API assigns the version
1717
+ identifier and makes it the skill's latest; nothing is numbered locally.
1718
+
1719
+ Takes a built skill DIRECTORY. A ZIP is accepted by \`install\`, not here.
1720
+
1721
+ The same exclusions and the same ${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} request
1722
+ ceiling as \`install\` apply: the eval suite, node_modules/ and .git/ are never
1723
+ uploaded, and a symbolic link is refused rather than followed.
1724
+
1725
+ The uploaded files are keyed under the top-level directory named by this
1726
+ tree's SKILL.md \`name\`, and that root is printed as the upload runs. The API
1727
+ enforces that name against the one this skill already has, so publishing from a
1728
+ renamed tree is refused with a 400 rather than silently re-rooting the version's
1729
+ files. VAT does not check it first — it reports the root it used and lets the
1730
+ server be the authority.
1731
+
1732
+ Exit Codes:
1733
+ 0 - The version was published
1734
+ 2 - The run could not happen: no API key, no such source, not a directory,
1735
+ over the upload ceiling, or a response the version identifier
1736
+ cannot be read from
1737
+
1738
+ Example:
1739
+ $ vat claude org skills versions add skill_abc123 dist/skills/org-admin
435
1740
  `);
436
1741
  versionsCmd.addCommand(versionsListCmd);
1742
+ versionsCmd.addCommand(versionsAddCmd);
437
1743
  versionsCmd.addCommand(versionsDeleteCmd);
438
1744
  command.addCommand(listCmd);
439
1745
  command.addCommand(installCmd);