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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +4 -4
  2. package/dist/bin.js +41 -158
  3. package/dist/bin.js.map +1 -1
  4. package/dist/command-loaders.d.ts +2 -2
  5. package/dist/command-loaders.d.ts.map +1 -1
  6. package/dist/command-loaders.js +4 -2
  7. package/dist/command-loaders.js.map +1 -1
  8. package/dist/commands/ard/emit.d.ts +104 -0
  9. package/dist/commands/ard/emit.d.ts.map +1 -0
  10. package/dist/commands/ard/emit.js +223 -0
  11. package/dist/commands/ard/emit.js.map +1 -0
  12. package/dist/commands/ard/index.d.ts +12 -0
  13. package/dist/commands/ard/index.d.ts.map +1 -0
  14. package/dist/commands/ard/index.js +79 -0
  15. package/dist/commands/ard/index.js.map +1 -0
  16. package/dist/commands/ard/surfaces.d.ts +56 -0
  17. package/dist/commands/ard/surfaces.d.ts.map +1 -0
  18. package/dist/commands/ard/surfaces.js +150 -0
  19. package/dist/commands/ard/surfaces.js.map +1 -0
  20. package/dist/commands/audit.d.ts +75 -8
  21. package/dist/commands/audit.d.ts.map +1 -1
  22. package/dist/commands/audit.js +726 -127
  23. package/dist/commands/audit.js.map +1 -1
  24. package/dist/commands/claude/budget.d.ts +13 -0
  25. package/dist/commands/claude/budget.d.ts.map +1 -1
  26. package/dist/commands/claude/budget.js +80 -28
  27. package/dist/commands/claude/budget.js.map +1 -1
  28. package/dist/commands/claude/claude-config.d.ts.map +1 -1
  29. package/dist/commands/claude/claude-config.js +6 -0
  30. package/dist/commands/claude/claude-config.js.map +1 -1
  31. package/dist/commands/claude/context.js +1 -1
  32. package/dist/commands/claude/context.js.map +1 -1
  33. package/dist/commands/claude/marketplace/validate.d.ts +55 -22
  34. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  35. package/dist/commands/claude/marketplace/validate.js +380 -75
  36. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  37. package/dist/commands/claude/org/helpers.d.ts +59 -1
  38. package/dist/commands/claude/org/helpers.d.ts.map +1 -1
  39. package/dist/commands/claude/org/helpers.js +58 -7
  40. package/dist/commands/claude/org/helpers.js.map +1 -1
  41. package/dist/commands/claude/org/index.d.ts.map +1 -1
  42. package/dist/commands/claude/org/index.js +24 -16
  43. package/dist/commands/claude/org/index.js.map +1 -1
  44. package/dist/commands/claude/org/skills.d.ts +509 -0
  45. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  46. package/dist/commands/claude/org/skills.js +1364 -94
  47. package/dist/commands/claude/org/skills.js.map +1 -1
  48. package/dist/commands/claude/org/users.d.ts.map +1 -1
  49. package/dist/commands/claude/org/users.js +3 -1
  50. package/dist/commands/claude/org/users.js.map +1 -1
  51. package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
  52. package/dist/commands/claude/org/workspaces.js +5 -2
  53. package/dist/commands/claude/org/workspaces.js.map +1 -1
  54. package/dist/commands/claude/plugin/build.d.ts +8 -0
  55. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  56. package/dist/commands/claude/plugin/build.js +16 -2
  57. package/dist/commands/claude/plugin/build.js.map +1 -1
  58. package/dist/commands/claude/plugin/tree-copy.d.ts +72 -0
  59. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  60. package/dist/commands/claude/plugin/tree-copy.js +215 -10
  61. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  62. package/dist/commands/corpus/index.d.ts.map +1 -1
  63. package/dist/commands/corpus/index.js +3 -0
  64. package/dist/commands/corpus/index.js.map +1 -1
  65. package/dist/commands/corpus/report.d.ts +0 -1
  66. package/dist/commands/corpus/report.d.ts.map +1 -1
  67. package/dist/commands/corpus/report.js +0 -1
  68. package/dist/commands/corpus/report.js.map +1 -1
  69. package/dist/commands/corpus/runner.d.ts +58 -1
  70. package/dist/commands/corpus/runner.d.ts.map +1 -1
  71. package/dist/commands/corpus/runner.js +90 -15
  72. package/dist/commands/corpus/runner.js.map +1 -1
  73. package/dist/commands/corpus/scan.d.ts.map +1 -1
  74. package/dist/commands/corpus/scan.js +0 -1
  75. package/dist/commands/corpus/scan.js.map +1 -1
  76. package/dist/commands/doctor.d.ts +14 -0
  77. package/dist/commands/doctor.d.ts.map +1 -1
  78. package/dist/commands/doctor.js +96 -18
  79. package/dist/commands/doctor.js.map +1 -1
  80. package/dist/commands/okf/index.d.ts +11 -0
  81. package/dist/commands/okf/index.d.ts.map +1 -0
  82. package/dist/commands/okf/index.js +102 -0
  83. package/dist/commands/okf/index.js.map +1 -0
  84. package/dist/commands/okf/validate.d.ts +82 -0
  85. package/dist/commands/okf/validate.d.ts.map +1 -0
  86. package/dist/commands/okf/validate.js +157 -0
  87. package/dist/commands/okf/validate.js.map +1 -0
  88. package/dist/commands/phase-utils.d.ts +19 -3
  89. package/dist/commands/phase-utils.d.ts.map +1 -1
  90. package/dist/commands/phase-utils.js +26 -5
  91. package/dist/commands/phase-utils.js.map +1 -1
  92. package/dist/commands/rag/index-command.d.ts +27 -2
  93. package/dist/commands/rag/index-command.d.ts.map +1 -1
  94. package/dist/commands/rag/index-command.js +44 -7
  95. package/dist/commands/rag/index-command.js.map +1 -1
  96. package/dist/commands/rag/index.d.ts.map +1 -1
  97. package/dist/commands/rag/index.js +2 -0
  98. package/dist/commands/rag/index.js.map +1 -1
  99. package/dist/commands/resources/check-progress.d.ts +3 -0
  100. package/dist/commands/resources/check-progress.d.ts.map +1 -1
  101. package/dist/commands/resources/check-progress.js +6 -0
  102. package/dist/commands/resources/check-progress.js.map +1 -1
  103. package/dist/commands/resources/check.d.ts +33 -4
  104. package/dist/commands/resources/check.d.ts.map +1 -1
  105. package/dist/commands/resources/check.js +131 -44
  106. package/dist/commands/resources/check.js.map +1 -1
  107. package/dist/commands/resources/index.d.ts.map +1 -1
  108. package/dist/commands/resources/index.js +41 -9
  109. package/dist/commands/resources/index.js.map +1 -1
  110. package/dist/commands/resources/query.d.ts +18 -1
  111. package/dist/commands/resources/query.d.ts.map +1 -1
  112. package/dist/commands/resources/query.js +29 -4
  113. package/dist/commands/resources/query.js.map +1 -1
  114. package/dist/commands/resources/validate.d.ts +29 -0
  115. package/dist/commands/resources/validate.d.ts.map +1 -1
  116. package/dist/commands/resources/validate.js +116 -13
  117. package/dist/commands/resources/validate.js.map +1 -1
  118. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  119. package/dist/commands/skill/review-checklist.js +35 -0
  120. package/dist/commands/skill/review-checklist.js.map +1 -1
  121. package/dist/commands/skill/review.d.ts.map +1 -1
  122. package/dist/commands/skill/review.js +9 -2
  123. package/dist/commands/skill/review.js.map +1 -1
  124. package/dist/commands/skill/test/configure.d.ts +37 -0
  125. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  126. package/dist/commands/skill/test/configure.js +57 -12
  127. package/dist/commands/skill/test/configure.js.map +1 -1
  128. package/dist/commands/skill/test/run.d.ts.map +1 -1
  129. package/dist/commands/skill/test/run.js +21 -6
  130. package/dist/commands/skill/test/run.js.map +1 -1
  131. package/dist/commands/skills/build.d.ts.map +1 -1
  132. package/dist/commands/skills/build.js +16 -3
  133. package/dist/commands/skills/build.js.map +1 -1
  134. package/dist/commands/skills/list.d.ts +2 -1
  135. package/dist/commands/skills/list.d.ts.map +1 -1
  136. package/dist/commands/skills/list.js +31 -9
  137. package/dist/commands/skills/list.js.map +1 -1
  138. package/dist/commands/skills/skill-discovery.d.ts +40 -1
  139. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  140. package/dist/commands/skills/skill-discovery.js +32 -3
  141. package/dist/commands/skills/skill-discovery.js.map +1 -1
  142. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  143. package/dist/commands/skills/validate-command.js +11 -3
  144. package/dist/commands/skills/validate-command.js.map +1 -1
  145. package/dist/commands/skills/validate.d.ts +66 -7
  146. package/dist/commands/skills/validate.d.ts.map +1 -1
  147. package/dist/commands/skills/validate.js +133 -29
  148. package/dist/commands/skills/validate.js.map +1 -1
  149. package/dist/commands/verify.d.ts +94 -3
  150. package/dist/commands/verify.d.ts.map +1 -1
  151. package/dist/commands/verify.js +192 -45
  152. package/dist/commands/verify.js.map +1 -1
  153. package/dist/skill-resolution/packaging-config.d.ts +49 -5
  154. package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
  155. package/dist/skill-resolution/packaging-config.js +59 -19
  156. package/dist/skill-resolution/packaging-config.js.map +1 -1
  157. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  158. package/dist/skill-resolution/resolve-skill-reference.js +6 -3
  159. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  160. package/dist/utils/command-error.d.ts +31 -0
  161. package/dist/utils/command-error.d.ts.map +1 -1
  162. package/dist/utils/command-error.js +45 -0
  163. package/dist/utils/command-error.js.map +1 -1
  164. package/dist/utils/config-loader.d.ts.map +1 -1
  165. package/dist/utils/config-loader.js +22 -6
  166. package/dist/utils/config-loader.js.map +1 -1
  167. package/dist/utils/edge-lens-evaluation.d.ts +83 -0
  168. package/dist/utils/edge-lens-evaluation.d.ts.map +1 -0
  169. package/dist/utils/edge-lens-evaluation.js +110 -0
  170. package/dist/utils/edge-lens-evaluation.js.map +1 -0
  171. package/dist/utils/optional-backend.d.ts +5 -4
  172. package/dist/utils/optional-backend.d.ts.map +1 -1
  173. package/dist/utils/optional-backend.js +5 -4
  174. package/dist/utils/optional-backend.js.map +1 -1
  175. package/dist/utils/project-root-policy.d.ts +20 -0
  176. package/dist/utils/project-root-policy.d.ts.map +1 -1
  177. package/dist/utils/project-root-policy.js +32 -0
  178. package/dist/utils/project-root-policy.js.map +1 -1
  179. package/dist/utils/projection-query.d.ts +41 -4
  180. package/dist/utils/projection-query.d.ts.map +1 -1
  181. package/dist/utils/projection-query.js +44 -10
  182. package/dist/utils/projection-query.js.map +1 -1
  183. package/dist/utils/projection-store.d.ts +41 -15
  184. package/dist/utils/projection-store.d.ts.map +1 -1
  185. package/dist/utils/projection-store.js +65 -18
  186. package/dist/utils/projection-store.js.map +1 -1
  187. package/dist/utils/repeatable-option.d.ts +37 -0
  188. package/dist/utils/repeatable-option.d.ts.map +1 -0
  189. package/dist/utils/repeatable-option.js +39 -0
  190. package/dist/utils/repeatable-option.js.map +1 -0
  191. package/dist/utils/resource-loader.d.ts +4 -3
  192. package/dist/utils/resource-loader.d.ts.map +1 -1
  193. package/dist/utils/resource-loader.js +61 -30
  194. package/dist/utils/resource-loader.js.map +1 -1
  195. package/dist/utils/root-argv.d.ts +53 -0
  196. package/dist/utils/root-argv.d.ts.map +1 -0
  197. package/dist/utils/root-argv.js +215 -0
  198. package/dist/utils/root-argv.js.map +1 -0
  199. package/dist/utils/run-integrity.d.ts +95 -0
  200. package/dist/utils/run-integrity.d.ts.map +1 -0
  201. package/dist/utils/run-integrity.js +102 -0
  202. package/dist/utils/run-integrity.js.map +1 -0
  203. package/dist/utils/sqlite-experimental-warning.d.ts +69 -0
  204. package/dist/utils/sqlite-experimental-warning.d.ts.map +1 -0
  205. package/dist/utils/sqlite-experimental-warning.js +144 -0
  206. package/dist/utils/sqlite-experimental-warning.js.map +1 -0
  207. package/dist/utils/user-context-scanner.d.ts +8 -0
  208. package/dist/utils/user-context-scanner.d.ts.map +1 -1
  209. package/dist/utils/user-context-scanner.js +4 -0
  210. package/dist/utils/user-context-scanner.js.map +1 -1
  211. package/dist/utils/validate-help-files.d.ts.map +1 -1
  212. package/dist/utils/validate-help-files.js +9 -2
  213. package/dist/utils/validate-help-files.js.map +1 -1
  214. package/docs/audit.md +120 -7
  215. package/docs/doctor.md +6 -6
  216. package/docs/index.md +1 -1
  217. package/docs/rag.md +3 -1
  218. package/docs/resources.md +5 -1
  219. package/docs/skills.md +47 -62
  220. package/package.json +18 -18
@@ -3,29 +3,679 @@
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 { isAbsoluteAnyPlatform, normalizedTmpdir, safePath, toForwardSlash, } 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.
18
33
  */
19
- async function sendSkillUpload(client, displayTitle, files) {
20
- const multipart = buildMultipartFormData({ display_title: displayTitle }, files);
21
- const result = await client.uploadSkill(multipart);
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'];
22
97
  return {
23
- id: result.id,
24
- displayTitle: result.display_title,
25
- version: result.latest_version,
26
- createdAt: result.created_at,
98
+ id: typeof echoedId === 'string' && echoedId.length > 0 ? echoedId : requestedId,
99
+ deleted: typeof type === 'string' ? expectedTypes.includes(type) : true,
27
100
  };
28
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 = toForwardSlash(filename);
256
+ const prefix = `${toForwardSlash(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.
362
+ */
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
+ ? toForwardSlash(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 {
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.
528
+ return undefined;
529
+ }
530
+ // PASS 1 — headers only. Nothing is decompressed while this runs, so both
531
+ // refusals the caller can reach are decided on metadata alone.
532
+ const files = entries.filter(entry => !entry.isDirectory);
533
+ const uncompressedBytes = files.reduce((sum, entry) => sum + entry.header.size, 0);
534
+ const neverUploaded = files
535
+ .map(entry => toForwardSlash(entry.entryName))
536
+ .filter(name => name.split('/').some(segment => NEVER_UPLOADED_DIR_NAMES.has(segment)));
537
+ // PASS 2 — the entries worth inflating, and only once the total says this
538
+ // archive is not already refused. An over-ceiling archive is about to be
539
+ // rejected by the caller, so inflating anything out of it would be work spent
540
+ // on an upload that cannot happen.
541
+ if (uncompressedBytes > API_SKILL_MAX_UPLOAD_BYTES) {
542
+ return { uncompressedBytes, declaredName: undefined, neverUploaded, documents: [], bundleRoot: undefined };
543
+ }
544
+ const elected = electShallowestSkillMd(files);
545
+ return {
546
+ uncompressedBytes,
547
+ declaredName: declaredNameOf(elected),
548
+ neverUploaded,
549
+ documents: inflateMarkdownMembers(files),
550
+ bundleRoot: archiveRootOf(elected?.entryName),
551
+ };
552
+ }
553
+ /**
554
+ * Every markdown member of the archive, as an upload part.
555
+ *
556
+ * 🔑 This exists so the ZIP lane can run the portability family through the ONE
557
+ * function the directory lane runs it through. Until it did, `install <zip>`
558
+ * performed none of the checks `install --help` promises of "every markdown
559
+ * document in the bundle": the archive went to `sendSkillUpload` as one opaque
560
+ * part, and an operator following the documented contract got silence where they
561
+ * were promised warnings. Routing the parts back through
562
+ * {@link warnUnportableReferences} keeps ONE implementation of the checks — a
563
+ * second copy of the three collector calls is how the two lanes start disagreeing.
564
+ *
565
+ * An entry that will not inflate is SKIPPED, never fatal: this module's standing
566
+ * position is that an archive VAT cannot read is not an archive VAT should block,
567
+ * and the checks it feeds only ever warn.
568
+ */
569
+ function inflateMarkdownMembers(files) {
570
+ const documents = [];
571
+ for (const entry of files) {
572
+ const filename = toForwardSlash(entry.entryName);
573
+ if (!filename.toLowerCase().endsWith('.md'))
574
+ continue;
575
+ if (entry.header.size > MAX_INSPECTED_DOCUMENT_BYTES)
576
+ continue;
577
+ try {
578
+ documents.push({ fieldName: 'files[]', filename, content: entry.getData() });
579
+ }
580
+ catch {
581
+ continue;
582
+ }
583
+ }
584
+ return documents;
585
+ }
586
+ /**
587
+ * The directory the elected SKILL.md sits in, which is the archive's bundle root.
588
+ *
589
+ * `my-skill/SKILL.md` roots the bundle at `my-skill`; a SKILL.md at the archive
590
+ * root has no prefix, and neither does an archive with no SKILL.md at all. ZIP
591
+ * fixes `/` as the entry separator, so this reads the format rather than handling
592
+ * a host path.
593
+ */
594
+ function archiveRootOf(entryName) {
595
+ if (entryName === undefined)
596
+ return undefined;
597
+ const cut = toForwardSlash(entryName).lastIndexOf('/');
598
+ return cut === -1 ? undefined : entryName.slice(0, cut);
599
+ }
600
+ /**
601
+ * The SKILL.md nearest the archive root, which is the one whose `name` the API
602
+ * will key the skill by.
603
+ *
604
+ * 🪤 Compared on DEPTH, with name length only as a tiebreak. It used to compare
605
+ * `entryName.length` alone, on the reasoning that "a shortest-path comparison
606
+ * needs no separator at all" — but shortest string is not shallowest path:
607
+ * `a/b/SKILL.md` (12 chars, depth 2) beats `my-skill-bundle/SKILL.md` (24
608
+ * chars, depth 1), so a bundled FIXTURE supplied the name and the operator was
609
+ * advised to `--title` it. ZIP fixes `/` as the entry separator on every
610
+ * platform, so splitting on it is not path handling — it is reading the format.
611
+ */
612
+ function electShallowestSkillMd(files) {
613
+ let elected;
614
+ for (const entry of files) {
615
+ if (basename(entry.entryName) !== 'SKILL.md')
616
+ continue;
617
+ if (elected !== undefined && !isShallower(entry.entryName, elected.entryName))
618
+ continue;
619
+ elected = entry;
620
+ }
621
+ return elected;
622
+ }
623
+ /** Fewer path segments wins; equal depth falls back to the shorter name. */
624
+ function isShallower(candidate, incumbent) {
625
+ const candidateDepth = zipEntryDepth(candidate);
626
+ const incumbentDepth = zipEntryDepth(incumbent);
627
+ return candidateDepth === incumbentDepth
628
+ ? candidate.length < incumbent.length
629
+ : candidateDepth < incumbentDepth;
630
+ }
631
+ /**
632
+ * How many segments a ZIP entry name has.
633
+ *
634
+ * Counted rather than `split('/')`-ed because the linter — correctly, for
635
+ * FILESYSTEM paths — refuses that idiom: a host path's separator depends on the
636
+ * platform. A ZIP entry name's does not; the format fixes it as `/`. Counting
637
+ * the separator states that, and needs no exemption.
638
+ */
639
+ function zipEntryDepth(entryName) {
640
+ let depth = 1;
641
+ for (const character of entryName) {
642
+ if (character === '/')
643
+ depth += 1;
644
+ }
645
+ return depth;
646
+ }
647
+ /**
648
+ * The frontmatter `name` inside the ELECTED entry — or `undefined`, including
649
+ * when that entry declares none.
650
+ *
651
+ * 🪤 It used to be `declaredSkillNameIn(…) ?? declaredName`, which KEPT the
652
+ * previous (deeper) candidate's name when a newly-elected shallower SKILL.md had
653
+ * no `name`. The elected file and the reported name then came from two different
654
+ * documents, and the divergence warning named a name no operator could find at
655
+ * the root of their archive. The election decides; whatever it holds is the
656
+ * answer, absence included.
657
+ *
658
+ * `getData()` is the only decompression in this module, so it is the only thing
659
+ * that can throw here: adm-zip raises on BAD_CRC, on any compression method but
660
+ * store/deflate, and on an encrypted entry. Those used to escape and abort the
661
+ * publish on exit 2 — contradicting this module's own stance that an archive VAT
662
+ * cannot parse is not an archive VAT should block. The size total is already
663
+ * computed from headers, so failing here costs only the name.
664
+ */
665
+ function declaredNameOf(entry) {
666
+ if (entry === undefined || entry.header.size > MAX_INSPECTED_DOCUMENT_BYTES)
667
+ return undefined;
668
+ try {
669
+ return declaredSkillNameIn(entry.getData().toString('utf8'));
670
+ }
671
+ catch {
672
+ return undefined;
673
+ }
674
+ }
675
+ async function sendSkillUpload(client, displayTitle, files, bundleRoot) {
676
+ const multipart = buildUploadBodyOrRefuse({ display_title: displayTitle }, files, bundleRoot);
677
+ return readCreateSkillResponse(await sendUpload(() => client.uploadSkill(multipart), multipart.body.length, withDuplicateTitleRemedy));
678
+ }
29
679
  /**
30
680
  * The name a SKILL.md declares, which is both the uploaded skill's display
31
681
  * title and the top-level directory the API keys it by.
@@ -37,35 +687,6 @@ function requireDeclaredName(skillMdPath) {
37
687
  }
38
688
  return declared;
39
689
  }
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
690
  /**
70
691
  * The absolute path of this skill's DECLARED eval suite, when its governing VAT
71
692
  * config declares one, as a set of paths to withhold.
@@ -87,31 +708,99 @@ const NEVER_UPLOADED_DIR_NAMES = new Set(['evals', 'node_modules', '.git']);
87
708
  * skill dir — nothing inside the tree to withhold.
88
709
  */
89
710
  async function declaredTestInputPaths(skillDir) {
90
- const config = await resolveSkillPackagingConfig(safePath.join(skillDir, 'SKILL.md'));
711
+ // `'refuse'`: an upload that cannot tell which paths are test input might
712
+ // publish an answer key. The throw is reported per skill by the install
713
+ // loop's catch (logged, and counted in its failures) — never swallowed.
714
+ const config = await resolveSkillPackagingConfig(safePath.join(skillDir, 'SKILL.md'), 'refuse');
91
715
  const declared = config?.test?.evals;
92
716
  if (declared === undefined)
93
717
  return new Set();
94
718
  const unit = evalSuiteUnitPath(safePath.resolve(skillDir), declared);
95
719
  return unit === undefined ? new Set() : new Set([unit]);
96
720
  }
721
+ /**
722
+ * Whether a directory entry is a symbolic link resolving to a DIRECTORY —
723
+ * throwing, naming the path, when the link cannot be followed at all.
724
+ *
725
+ * `Dirent.isDirectory()` is lstat-based, so it answers `false` for a link to a
726
+ * directory. That entry therefore used to fall into the FILE branch below and
727
+ * `readFileSync` threw a raw `EISDIR`: the upload died on a Node error that
728
+ * named no path and said nothing about what to do. A dangling link produced the
729
+ * same shape of failure with `ENOENT`.
730
+ *
731
+ * `statSync` follows the link, which is the same thing the build-time size walk
732
+ * does to classify one — so both lanes reach the same verdict about the same
733
+ * entry.
734
+ */
735
+ function resolvesToDirectory(entry, fullPath, relativePath) {
736
+ if (!entry.isSymbolicLink())
737
+ return false;
738
+ try {
739
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- collected from dir walk
740
+ return statSync(fullPath).isDirectory();
741
+ }
742
+ catch (error) {
743
+ const cause = error instanceof Error ? error.message : String(error);
744
+ throw new Error(`Cannot upload ${relativePath}: it is a symbolic link whose target could not be read `
745
+ + `(${cause}). Replace it with the file or directory it should point at, or remove it.`);
746
+ }
747
+ }
97
748
  /**
98
749
  * Collect the files under a skill directory that should be uploaded,
99
750
  * recursively, returning relative paths alongside what was deliberately left
100
751
  * out.
752
+ *
753
+ * 🚨 **A symlink is REFUSED, whatever it resolves to.** A multipart body has no
754
+ * way to express a link, so there are only three things this could do with one
755
+ * and two of them are wrong. Skipping it changes what gets published without the
756
+ * skill breaking until someone opens it — and the one thing this collector
757
+ * guarantees is that every withholding is reported. FOLLOWING it is data egress:
758
+ * an earlier version refused only a link resolving to a DIRECTORY, so
759
+ * `notes.md -> /etc/passwd` fell through both branches, `readFileSync` returned
760
+ * the target's bytes, and they were posted under the in-bundle name `notes.md`
761
+ * into a workspace every org member can read. Nothing in the run said a link had
762
+ * been followed: the "every withholding is reported" guarantee covers
763
+ * EXCLUSIONS, not DEREFERENCES. Any file the operator can read left the machine.
764
+ *
765
+ * The vector is not a registry tarball — node-tar 7 de-roots an absolute
766
+ * linkpath and refuses an escaping relative one, measured — but a directory
767
+ * extracted with system `tar`, which does recreate `-> /etc/passwd`, or cloned
768
+ * from an untrusted repo and handed to `install <dir>`.
769
+ *
770
+ * Refusing and naming the path is the only answer that is both complete and
771
+ * honest, and it keeps this lane on the same payload as the build-time size
772
+ * walk, which does not weigh a linked entry either.
773
+ *
774
+ * The never-uploaded NAMES are matched on a linked directory too. Those are
775
+ * never published whatever their type, and the size walk weighs a linked
776
+ * directory as zero bytes either way — so excluding one keeps both lanes on the
777
+ * same payload instead of blocking a publish over a directory neither lane
778
+ * would have sent.
101
779
  */
102
780
  function collectFiles(dir, base, testInput, collected) {
103
781
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- dir from CLI arg
104
782
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
105
783
  const fullPath = safePath.join(dir, entry.name);
106
784
  const relativePath = safePath.relative(base, fullPath);
785
+ const linkedDirectory = resolvesToDirectory(entry, fullPath, relativePath);
786
+ const directoryLike = entry.isDirectory() || linkedDirectory;
107
787
  if (testInput.has(safePath.resolve(fullPath))
108
- || (entry.isDirectory() && NEVER_UPLOADED_DIR_NAMES.has(entry.name))) {
788
+ || (directoryLike && NEVER_UPLOADED_DIR_NAMES.has(entry.name))) {
109
789
  collected.excluded.push(relativePath);
110
790
  continue;
111
791
  }
112
792
  if (entry.isDirectory()) {
113
793
  collectFiles(fullPath, base, testInput, collected);
114
794
  }
795
+ else if (entry.isSymbolicLink()) {
796
+ // Whatever it points at. A link to a FILE used to fall through to the
797
+ // branch below and be read through — see this function's doc comment.
798
+ throw new Error(`Cannot upload ${relativePath}: it is a symbolic link${linkedDirectory ? ' to a directory' : ''}, `
799
+ + 'which a multipart upload cannot express. VAT will not publish the bytes it points '
800
+ + 'at under this name: a link out of the skill directory would send whatever the '
801
+ + 'target holds into a workspace every org member can read. Replace it with a real '
802
+ + 'copy of the file or directory it points at, or remove it.');
803
+ }
115
804
  else {
116
805
  collected.files.push({ relativePath, absolutePath: fullPath });
117
806
  }
@@ -129,9 +818,15 @@ export async function collectSkillUploadFiles(skillDir) {
129
818
  return collected;
130
819
  }
131
820
  /**
132
- * Upload a single skill directory to the org via Skills API.
821
+ * Package a skill directory into the multipart file set the API takes.
822
+ *
823
+ * Deliberately separate from SENDING it. Creating a skill and adding a version to
824
+ * one differ only in the endpoint; the bundle, the exclusions and the size ceiling
825
+ * are identical, and a skill that packages one way when created and another way
826
+ * when updated would be the bug this split exists to prevent. Which endpoint gets
827
+ * the bundle is the caller's command, never something inferred from the workspace.
133
828
  */
134
- async function uploadSkillDir(client, skillDir, titleOverride, logger) {
829
+ async function prepareSkillUpload(skillDir, titleOverride, logger) {
135
830
  const skillMdPath = safePath.join(skillDir, 'SKILL.md');
136
831
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- derived from CLI arg
137
832
  if (!existsSync(skillMdPath)) {
@@ -154,17 +849,152 @@ async function uploadSkillDir(client, skillDir, titleOverride, logger) {
154
849
  content,
155
850
  });
156
851
  }
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}"`);
852
+ // Reported on the collected files, so the exclusions listed below are already
853
+ // accounted for — this is the bundle actually being sent. It is FILE BYTES and
854
+ // says so: the ceiling is enforced on the multipart body, which adds ~156 bytes
855
+ // plus the filename per part, and a reader who mistook this line for the
856
+ // measured quantity would not understand a refusal that names a larger number.
857
+ const contentBytes = files.reduce((sum, f) => sum + f.content.length, 0);
858
+ logger.info(` ${dirName}: ${files.length} ${files.length === 1 ? 'file' : 'files'}, `
859
+ + `${formatBytes(contentBytes)} of file content, title="${displayTitle}"`);
159
860
  for (const excluded of collected.excluded) {
160
861
  logger.info(` Excluded from upload: ${excluded} (never published with a skill)`);
161
862
  }
162
- return sendSkillUpload(client, displayTitle, files);
863
+ // The SAME merge `declaredTestInputPaths` read to find the eval suite — both
864
+ // resolutions are served from `loadConfigCached` and the per-root discovery
865
+ // cache, so asking twice costs one map lookup rather than a second walk-up.
866
+ // Resolved HERE rather than plumbed out of `collectSkillUploadFiles`, which is
867
+ // exported and deliberately resolves its own so no caller can hand it a config
868
+ // with the exclusion skipped.
869
+ const packaging = await resolveSkillPackagingConfig(skillMdPath, 'refuse');
870
+ warnUnportableReferences(files, dirName, packaging?.validation, logger);
871
+ return { displayTitle, files, dirName };
872
+ }
873
+ /**
874
+ * Warn about references in the bundle that cannot resolve once it is published.
875
+ *
876
+ * 🚨 **Until this existed, `install` validated NOTHING.** It weighed the request
877
+ * and sent it; every check VAT owns — the non-portable reference family, the
878
+ * unqualified MCP tool names, the non-portable command family — ran in
879
+ * `vat audit` and `vat skills build` and never on the one path where content
880
+ * leaves the machine. An adopter found the consequence directly: of 54 built
881
+ * skills, 10 referenced paths outside their own directory, one of them exec'ing
882
+ * a script under a SIBLING skill. Under the Skills API each skill is its own
883
+ * top-level tree with no siblings, so those uploaded with a green tick and could
884
+ * not possibly run. "It uploaded" is not "it works", and the publish command was
885
+ * the only lane in a position to say so before the bytes left.
886
+ *
887
+ * ⚠️ **Warns, never refuses.** These are `warning`-severity codes and the
888
+ * operator may have reasons VAT cannot see; turning a publish into an exit 2 on
889
+ * a heuristic is a worse failure than the one being reported. Blocking belongs
890
+ * to `vat skills build` and `vat audit`, which run before this and can gate CI.
891
+ *
892
+ * Costs nothing extra: it reads the buffers already collected for the upload, so
893
+ * no file is opened twice and nothing is walked again.
894
+ *
895
+ * 🚨 **The three collectors are RAW issue producers, and raw issues are not what
896
+ * an adopter agreed to see.** Everywhere else in VAT they pass through
897
+ * {@link runValidationFramework}, which applies `validation.allow` waivers and
898
+ * `validation.severity` overrides. Printed unfiltered, this lane contradicted
899
+ * VAT's own documented remedy: `MCP_TOOL_NAME_UNQUALIFIED` tells authors to
900
+ * "waive that one identifier with a `validation.allow` entry", and an author who
901
+ * did exactly that got a green `vat skills build` and then the same warning on
902
+ * every publish with nothing left to silence it with.
903
+ *
904
+ * ⚠️ The `location` is the BUNDLE-relative path, not the API-keyed
905
+ * `<declared-name>/<path>` spelling. An allow glob matches `ValidationIssue.location`,
906
+ * which is contractually project/bundle-relative, so the keyed spelling could not
907
+ * have matched an entry even once allows were applied — the same divergence
908
+ * {@link bundleRelativeName} exists to strip out of the oversize message.
909
+ *
910
+ * ⛔ The run-level ALLOW_UNUSED sweep is deliberately NOT drained here. This lane
911
+ * runs three validator families out of the whole registry, so every allow entry
912
+ * for any other code would be reported as dead — advising the author to delete
913
+ * the entry that makes their build green.
914
+ */
915
+ export function warnUnportableReferences(files, bundleRoot, validation, logger) {
916
+ const rawIssues = [];
917
+ for (const file of files) {
918
+ // Skill DOCUMENTS only. These detectors read prose and code spans; running
919
+ // them over a `.png` or a bundled `.mjs` would report on bytes no agent
920
+ // reads as instructions.
921
+ if (!file.filename.toLowerCase().endsWith('.md'))
922
+ continue;
923
+ const location = bundleRelativeName(file.filename, bundleRoot);
924
+ const content = file.content.toString('utf8');
925
+ collectNonPortableAssetReferenceIssues(content, location, rawIssues);
926
+ collectNonPortableCommandIssues(content, location, rawIssues);
927
+ collectUnqualifiedMcpToolIssues(content, location, rawIssues);
928
+ }
929
+ const { emitted: issues } = runValidationFramework(rawIssues, validation ?? {}, createAllowUsageLedger());
930
+ if (issues.length === 0)
931
+ return;
932
+ logger.warn(` ${issues.length} portability ${issues.length === 1 ? 'warning' : 'warnings'} in this bundle `
933
+ + '(uploading anyway; run `vat audit <dir>` for the full report):');
934
+ for (const issue of issues) {
935
+ logger.warn(` ${issue.location ?? ''}: ${issue.message}`);
936
+ }
937
+ }
938
+ /**
939
+ * Upload a skill directory as a NEW skill.
940
+ *
941
+ * Always creates. It does not look for an existing skill of the same title and
942
+ * quietly switch to adding a version: `display_title` is not unique in a workspace
943
+ * (the API enforces it only when the field is sent explicitly, and derives a title
944
+ * from frontmatter otherwise), so such a lookup returns 0, 1 or N matches and the
945
+ * command's effect would depend on which. Updating an existing skill is
946
+ * `skills versions add`, which takes the id outright.
947
+ */
948
+ async function uploadSkillDir(client, skillDir, titleOverride, logger) {
949
+ const { displayTitle, files, dirName } = await prepareSkillUpload(skillDir, titleOverride, logger);
950
+ return sendSkillUpload(client, displayTitle, files, dirName);
951
+ }
952
+ /**
953
+ * Upload a skill directory as a new VERSION of an existing skill.
954
+ *
955
+ * The server assigns the version identifier and promotes it to `latest_version`;
956
+ * nothing here numbers a version.
957
+ *
958
+ * ⚠️ The uploaded files are keyed under the SOURCE tree's declared name, which
959
+ * this command cannot check against the roots earlier versions used: reading
960
+ * them back would mean a second endpoint whose response shape has not been
961
+ * measured. So the root is REPORTED rather than checked here, and the log line
962
+ * below is what lets an operator see which root their files landed under.
963
+ *
964
+ * ⛔ It is NOT true that a changed name "silently re-roots" the version — this
965
+ * comment and the command's help both said so, and the measurement says
966
+ * otherwise: the SERVER enforces SKILL.md name consistency per `skill_id`, so
967
+ * publishing from a renamed tree earns a loud 400. That is a better outcome than
968
+ * the one the warning described, and it is the one refusal this endpoint can
969
+ * earn, so it gets a remedy ({@link VERSION_NAME_MISMATCH_REFUSAL}) rather than
970
+ * the bare vendor sentence it used to get.
971
+ */
972
+ async function uploadSkillVersionDir(client, skillId, skillDir, logger) {
973
+ const { files, dirName } = await prepareSkillUpload(skillDir, undefined, logger);
974
+ logger.info(` Files are keyed under "${dirName}/", taken from this tree's SKILL.md name. `
975
+ + 'The API refuses a version whose name differs from the one this skill already has.');
976
+ // No `display_title` field: this version belongs to a skill that already has a
977
+ // title, and sending one here would be an attempt to rename by side effect.
978
+ // Its absence also changes the body's length, which is why the ceiling is
979
+ // weighed on the body this endpoint sends rather than on the one `install` builds.
980
+ const multipart = buildUploadBodyOrRefuse({}, files, dirName);
981
+ // The duplicate-title 400 is unreachable here — `versions add` sends no
982
+ // `display_title` — but the NAME refusal is not: the server enforces SKILL.md
983
+ // name consistency per skill_id, so publishing from a tree whose frontmatter
984
+ // `name` has changed is a 400, and it is the one refusal this endpoint can
985
+ // earn. It got no remedy at all until the reviewer who read the log line above
986
+ // pointed out that the line describes silent behaviour the server forbids.
987
+ return readSkillVersionResponse(await sendUpload(() => client.uploadSkillVersion(skillId, multipart), multipart.body.length, withVersionRootRemedy));
163
988
  }
164
989
  /**
165
990
  * List candidate package directories in node_modules (scoped + unscoped).
991
+ *
992
+ * Exported for testing: this and {@link findSkillsDir} decide WHICH tree
993
+ * `--from-npm` publishes to an org, and the whole `--from-npm` path was
994
+ * untested — `packages/cli/src/commands/**` is coverage-excluded, so nothing in
995
+ * any report said so.
166
996
  */
167
- function listNodeModulePackages(nodeModulesDir) {
997
+ export function listNodeModulePackages(nodeModulesDir) {
168
998
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
169
999
  if (!existsSync(nodeModulesDir))
170
1000
  return [];
@@ -191,7 +1021,7 @@ function listNodeModulePackages(nodeModulesDir) {
191
1021
  * Find the dist/skills/ directory in a package. Checks the package itself
192
1022
  * first, then scans node_modules for sub-packages that contain built skills.
193
1023
  */
194
- function findSkillsDir(packageDir) {
1024
+ export function findSkillsDir(packageDir) {
195
1025
  const direct = safePath.join(packageDir, 'dist', 'skills');
196
1026
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
197
1027
  if (existsSync(direct))
@@ -205,6 +1035,31 @@ function findSkillsDir(packageDir) {
205
1035
  }
206
1036
  return undefined;
207
1037
  }
1038
+ /**
1039
+ * The batch's report, tagged as a failure when ANY skill did not publish.
1040
+ *
1041
+ * 🔑 Partial success is a failure. The old code returned this document plainly,
1042
+ * so `executeOrgCommand` stamped `status: success` on it and exited 0 — a run in
1043
+ * which all three skills were rejected printed `skillsUploaded: 0 /
1044
+ * skillsFailed: 3` under `status: success`, and a CI wrapper written as
1045
+ * `vat claude org skills install --from-npm … || fail` published nothing and
1046
+ * reported green.
1047
+ *
1048
+ * Some-succeeded is tagged the same way as none-succeeded, deliberately: the
1049
+ * workspace is now in a MIXED state that nobody asked for, which is exactly the
1050
+ * case a human has to look at. Calling it green because two of three landed is
1051
+ * the same lie, only smaller. What did land stays in the document, so the reader
1052
+ * can see how far the run got.
1053
+ */
1054
+ export function summarizeNpmInstall(source, results, errors) {
1055
+ const summary = {
1056
+ source,
1057
+ skillsUploaded: results.length,
1058
+ ...(errors.length > 0 ? { skillsFailed: errors.length, errors } : {}),
1059
+ skills: results,
1060
+ };
1061
+ return errors.length > 0 ? orgCommandFailure(summary) : summary;
1062
+ }
208
1063
  /**
209
1064
  * Upload skills from an npm package.
210
1065
  */
@@ -246,47 +1101,231 @@ async function installFromNpm(npmPackage, skillFilter, client, logger) {
246
1101
  errors.push({ skill: skillName, error: msg });
247
1102
  }
248
1103
  }
249
- return {
250
- source: npmPackage,
251
- skillsUploaded: results.length,
252
- ...(errors.length > 0 ? { skillsFailed: errors.length, errors } : {}),
253
- skills: results,
254
- };
1104
+ return summarizeNpmInstall(npmPackage, results, errors);
255
1105
  }
256
1106
  finally {
257
1107
  rmSync(tempDir, { recursive: true, force: true });
258
1108
  }
259
1109
  }
260
1110
  /**
261
- * Upload a local skill directory or ZIP file.
1111
+ * Upload a local skill directory or ZIP file. Exported for testing.
262
1112
  */
263
- async function installFromLocal(source, titleOverride, client, logger) {
264
- const sourcePath = source.startsWith('/') ? source : safePath.join(process.cwd(), source);
1113
+ export async function installFromLocal(source, titleOverride, client, logger) {
1114
+ const sourcePath = resolveSourceArgument(source);
265
1115
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
266
1116
  if (!existsSync(sourcePath)) {
267
1117
  throw new Error(`Source not found: ${sourcePath}`);
268
1118
  }
269
1119
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
270
1120
  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);
1121
+ // Case-INSENSITIVE. `endsWith('.zip')` refused `MySkill.ZIP` as "not a directory
1122
+ // or .zip file" — a file Windows and macOS both consider a zip archive, and one
1123
+ // an operator has no way to read that refusal as being about capitalisation.
1124
+ if (!stat.isDirectory() && sourcePath.toLowerCase().endsWith('.zip')) {
1125
+ return installZipArchive(sourcePath, titleOverride, client, logger);
283
1126
  }
284
1127
  if (!stat.isDirectory()) {
285
1128
  throw new Error(`Source must be a directory or .zip file: ${sourcePath}`);
286
1129
  }
287
- logger.info(`Uploading skill directory: ${sourcePath}`);
1130
+ // "Packaging", not "Uploading". Everything that follows this line — reading the
1131
+ // config, collecting the file set, weighing the request — happens locally and
1132
+ // can refuse locally, and an operator who read "Uploading skill directory: X"
1133
+ // above a local size refusal was told about an upload that never started. The
1134
+ // line stays HERE rather than moving after the point of no return, because
1135
+ // naming the source before the work is what makes a failure attributable at
1136
+ // all — `--from-npm` packages several skills in a loop, and a packaging error
1137
+ // with no path above it names nothing.
1138
+ logger.info(`Packaging skill directory: ${sourcePath}`);
288
1139
  return uploadSkillDir(client, sourcePath, titleOverride, logger);
289
1140
  }
1141
+ /**
1142
+ * Publish a ZIP archive as a NEW skill.
1143
+ *
1144
+ * Split out of {@link installFromLocal} because the archive lane now has three
1145
+ * decisions of its own — an answer-key refusal, an expanded-size refusal, and a
1146
+ * title/name divergence warning — and inlining them made the dispatcher a
1147
+ * cognitively complex function whose real job (directory or archive?) was two
1148
+ * lines of it.
1149
+ */
1150
+ async function installZipArchive(sourcePath, titleOverride, client, logger) {
1151
+ // `basename(path, ext)` matches the extension exactly, so the literal '.zip'
1152
+ // leaves `.ZIP` on the title. Slice the length instead.
1153
+ const displayTitle = titleOverride ?? basename(sourcePath).slice(0, -'.zip'.length);
1154
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
1155
+ const zipContent = readFileSync(sourcePath);
1156
+ const files = [{
1157
+ fieldName: 'files[]',
1158
+ filename: basename(sourcePath),
1159
+ content: zipContent,
1160
+ }];
1161
+ // A ZIP is by construction a single large binary, so this is the shape most
1162
+ // likely to be over the ceiling — and the one that reached the wire unmeasured
1163
+ // while the check lived only in the directory-packaging path. It is gated in
1164
+ // `sendSkillUpload` below, on the same measure (the multipart body) as every
1165
+ // other shape; this line reports the file's own bytes, which are smaller.
1166
+ //
1167
+ // "Preparing", not "Uploading": nothing has been sent at this point, and the
1168
+ // very next step can refuse locally. A line that claims an upload started is
1169
+ // the log telling the operator about work that never happened.
1170
+ logger.info(`Preparing ZIP: ${sourcePath} (${formatBytes(zipContent.length)} of file content)`);
1171
+ // Where the title came FROM, not just what it is. A ZIP's title is the
1172
+ // filename — `wiki-lint-v2.zip` publishes a skill called `wiki-lint-v2`,
1173
+ // which is a DIFFERENT skill from `wiki-lint` and is not refused, because
1174
+ // display_title uniqueness is enforced only when the field is sent. A
1175
+ // directory takes its title from SKILL.md instead, so the same tree zipped
1176
+ // and unzipped can publish under two names.
1177
+ //
1178
+ // ⛔ This comment used to end "VAT cannot read the SKILL.md inside the
1179
+ // archive: Node ships no ZIP reader, and adding a dependency to parse one
1180
+ // here would mean owning central-directory, zip64 and zip-slip handling for
1181
+ // a log line." The PREMISE was false — `adm-zip` is already a runtime
1182
+ // dependency of this package — and the cost/benefit was weighed against the
1183
+ // wrong benefit: the same read also answers whether the archive EXPANDS
1184
+ // past the ceiling, which is a refusal, not a log line. So the archive is
1185
+ // now read (see {@link inspectZipArchive}) and the divergence is reported
1186
+ // as a fact below, in addition to this provenance line.
1187
+ logger.info(`Display title: "${displayTitle}" (${titleOverride === undefined
1188
+ ? 'from the ZIP filename — NOT from the SKILL.md inside it; pass --title to set it'
1189
+ : 'from --title'})`);
1190
+ const inspected = await inspectZipArchive(sourcePath);
1191
+ if (inspected !== undefined) {
1192
+ // 🚨 REFUSED, not warned. The directory lane refuses an answer key
1193
+ // unconditionally (`NEVER_UPLOADED_DIR_NAMES`, plus whatever the config
1194
+ // declares), and until this existed the ZIP lane bypassed `collectFiles`
1195
+ // entirely and posted the archive as one opaque part — so
1196
+ // `zip -r my-skill.zip my-skill/` over a tree holding
1197
+ // `my-skill/evals/evals.json` published the answer key org-wide with a
1198
+ // green tick, while `install my-skill/` on the very same tree refused.
1199
+ // Two lanes of one command must not disagree about whether a skill's
1200
+ // answer key may be published, and a warning is a disagreement.
1201
+ //
1202
+ // This lane can only see the CONVENTIONAL names: there is no skill
1203
+ // directory to walk up from, so a config-declared suite location is
1204
+ // unreadable here. That is a narrower check than the directory lane's,
1205
+ // not a looser verdict on what it does see.
1206
+ if (inspected.neverUploaded.length > 0) {
1207
+ throw new Error(`This ZIP contains ${String(inspected.neverUploaded.length)} entr`
1208
+ + `${inspected.neverUploaded.length === 1 ? 'y' : 'ies'} that are never published with a `
1209
+ + `skill: ${inspected.neverUploaded.slice(0, 10).join(', ')}`
1210
+ + `${inspected.neverUploaded.length > 10 ? ', …' : ''}. An eval suite is the skill's ANSWER `
1211
+ + `KEY and this would publish it to everyone in the organization. Rebuild the archive from `
1212
+ + `the BUILT skill directory (\`vat skills build\`), or upload that directory directly — `
1213
+ + `\`install <dir>\` withholds these and reports each one.`);
1214
+ }
1215
+ if (inspected.uncompressedBytes > API_SKILL_MAX_UPLOAD_BYTES) {
1216
+ throw new Error(`ZIP expands to ${formatBytes(inspected.uncompressedBytes)}, over the `
1217
+ + `${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} ceiling. The API expands the archive and `
1218
+ + `weighs the UNCOMPRESSED total, so ${formatBytes(zipContent.length)} on the wire does not `
1219
+ + `get this under the limit — it will refuse with "Zip file uncompressed size exceeds 30MB". `
1220
+ + `Remove files from the bundle; compressing harder cannot help.`);
1221
+ }
1222
+ // The title/name divergence this branch discloses above, now stated as a
1223
+ // FACT rather than as a caveat, because the archive can be read after
1224
+ // all. A ZIP whose inner SKILL.md declares a different name publishes a
1225
+ // skill whose title and versions disagree — and that divergence is what
1226
+ // makes a title-keyed lookup unsafe, so it is worth naming at the moment
1227
+ // it is minted rather than diagnosing later.
1228
+ if (inspected.declaredName !== undefined && inspected.declaredName !== displayTitle) {
1229
+ logger.warn(`Title/name divergence: this uploads with display title "${displayTitle}", but the `
1230
+ + `SKILL.md inside declares name "${inspected.declaredName}". Every version will carry `
1231
+ + `the declared name. Pass --title "${inspected.declaredName}" to make them agree.`);
1232
+ }
1233
+ // 🚨 The SAME function the directory lane calls, over the archive's own
1234
+ // markdown. Until this line existed, `install <zip>` ran NONE of the
1235
+ // portability checks its `--help` promises of "every markdown document in
1236
+ // the bundle" — the archive went straight to `sendSkillUpload`, so the
1237
+ // adopter finding that produced the family (10 of 54 skills referencing a
1238
+ // path outside their own tree, published green and unable to run) was
1239
+ // invisible on the lane most likely to carry a hand-built bundle.
1240
+ //
1241
+ // ⚠️ NO validation config, and that is a real narrowing, stated in the
1242
+ // help text. `validation.allow` / `validation.severity` are resolved by
1243
+ // walking up from a skill's SKILL.md to its governing project — and an
1244
+ // archive member has no path on disk to walk up from, the same reason a
1245
+ // config-declared eval-suite location is unreadable here. So a waiver that
1246
+ // silences `vat skills build` does NOT silence this lane. The checks only
1247
+ // warn, so the cost of the narrowing is noise, never a blocked publish;
1248
+ // the cost of skipping them was silence on a documented promise.
1249
+ warnUnportableReferences(inspected.documents, inspected.bundleRoot, undefined, logger);
1250
+ }
1251
+ return sendSkillUpload(client, displayTitle, files);
1252
+ }
1253
+ /**
1254
+ * Delete every version handed in, and keep going when one refuses.
1255
+ *
1256
+ * 🚨 **One refusal is a fact about ONE version.** This loop used to return on the
1257
+ * first one, so every version after it was never ATTEMPTED — not failed, not
1258
+ * reported, unexamined. Deleting a workspace skill requires deleting every
1259
+ * version first, so the operator was left with a skill that could not be deleted,
1260
+ * a report that named one problem out of an unknown number, and no way to tell
1261
+ * from the output which versions were still there. A transient refusal on version
1262
+ * 2 of 30 hid the state of the other 28.
1263
+ *
1264
+ * The sweep therefore ATTEMPTS all of them and reports both lists. The caller
1265
+ * decides what the outcome means: some deleted plus some failed is a run that
1266
+ * happened and went wrong (exit 1, with the record); nothing deleted at all is
1267
+ * the ending the command's help documents as exit 2.
1268
+ *
1269
+ * ⛔ It deliberately does NOT stop early on a repeated failure. A "the API is
1270
+ * clearly down, give up" heuristic is how the abandoned-versions defect reads
1271
+ * from inside: the whole value here is that every version's state is KNOWN when
1272
+ * the command ends.
1273
+ */
1274
+ export async function deleteEveryVersion(client, skillId, versions, logger) {
1275
+ const deleted = [];
1276
+ const failures = [];
1277
+ for (const version of versions) {
1278
+ try {
1279
+ await client.deleteSkillVersion(skillId, version);
1280
+ }
1281
+ catch (error) {
1282
+ const reason = error instanceof Error ? error.message : String(error);
1283
+ failures.push({ version, reason });
1284
+ logger.info(` ⚠ version ${version} was not deleted: ${reason}`);
1285
+ continue;
1286
+ }
1287
+ deleted.push(version);
1288
+ logger.info(` Deleted version ${version}`);
1289
+ }
1290
+ return { deleted, failures };
1291
+ }
1292
+ /**
1293
+ * Every version the sweep could not delete, and why — the one sentence both the
1294
+ * published report and the exit-2 throw are built from, so the two endings never
1295
+ * describe the same run differently.
1296
+ */
1297
+ export function describeVersionSweepFailures(failures) {
1298
+ return failures.map(failure => `${failure.version}: ${failure.reason}`).join('; ');
1299
+ }
1300
+ /**
1301
+ * The document a `--all` run publishes when it destroyed something and then did
1302
+ * not finish.
1303
+ *
1304
+ * 🔑 The deleted list is a RECORD, not a progress counter. Version deletion is
1305
+ * irreversible, so a failure part-way through leaves a workspace nobody can
1306
+ * reconstruct — and throwing here would end on `handleCommandError`'s exit 2
1307
+ * ("the run could not happen") and DISCARD the document. It happened, and it
1308
+ * destroyed things the operator now has no other list of. So the lists travel
1309
+ * with the failure and the run ends 1: it happened, and its outcome is wrong.
1310
+ *
1311
+ * `failedVersions` is the other half, and the half a re-run needs: what is still
1312
+ * there. Without it the operator knows what they lost and not what they still
1313
+ * have to deal with.
1314
+ */
1315
+ export function reportHalfDeleted(report) {
1316
+ const { skillId, deletedVersions, failedVersions } = report;
1317
+ return orgCommandFailure({
1318
+ id: skillId,
1319
+ deleted: false,
1320
+ deletedVersions,
1321
+ failedVersions,
1322
+ error: report.error,
1323
+ note: `${String(deletedVersions.length)} version(s) of ${skillId} were deleted and cannot be `
1324
+ + `restored; ${String(failedVersions.length)} could not be deleted and are still there. `
1325
+ + 'The skill itself still exists — the API refuses a skill that still has versions. '
1326
+ + 'Re-run `--all` once the cause is cleared: it will attempt only what is left.',
1327
+ });
1328
+ }
290
1329
  // ── Commands ───────────────────────────────────────────────────────────
291
1330
  export function createOrgSkillsCommand() {
292
1331
  const command = new Command('skills');
@@ -322,14 +1361,32 @@ Example:
322
1361
  .option('--title <title>', 'Display title override (single skill only)')
323
1362
  .option('--debug', DEBUG_OPT_DESC)
324
1363
  .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
1364
  const commandName = options.fromNpm ? 'OrgSkillsInstallNpm' : 'OrgSkillsInstall';
332
1365
  await executeOrgCommand(commandName, options.debug, async ({ client, logger }) => {
1366
+ // INSIDE the action, like `versions add`'s own guards. Thrown from the
1367
+ // Commander handler instead, these were a floating rejection that
1368
+ // reached no catch: Node printed a raw stack trace with absolute $HOME
1369
+ // paths, wrote nothing to the stdout this command's help promises, and
1370
+ // exited 1 — which the documented contract reads as "at least one
1371
+ // error-severity finding" for a run in which nothing executed.
1372
+ if (!source && !options.fromNpm) {
1373
+ throw new Error('Provide a <source> path or use --from-npm <package>');
1374
+ }
1375
+ if (source && options.fromNpm) {
1376
+ throw new Error('Provide either <source> or --from-npm, not both');
1377
+ }
1378
+ // The other two illegal combinations, refused rather than dropped. Both
1379
+ // flags used to be accepted and silently ignored on the lane that cannot
1380
+ // honour them, which is how an operator gets a skill published under the
1381
+ // wrong title — or every skill in a package published when they named one.
1382
+ if (options.title !== undefined && options.fromNpm) {
1383
+ throw new Error('--title applies to a single skill and --from-npm can publish several, so it is '
1384
+ + 'refused here rather than silently ignored. Publish the one skill with '
1385
+ + '--skill and set its title in its SKILL.md, or upload its directory directly.');
1386
+ }
1387
+ if (options.skill !== undefined && !options.fromNpm) {
1388
+ throw new Error('--skill selects one skill inside an npm package; it applies only with --from-npm');
1389
+ }
333
1390
  if (options.fromNpm) {
334
1391
  return installFromNpm(options.fromNpm, options.skill, client, logger);
335
1392
  }
@@ -342,14 +1399,66 @@ Description:
342
1399
  Accepts a built skill directory, a ZIP file, or an npm package.
343
1400
  Requires ANTHROPIC_API_KEY (regular key, not admin key).
344
1401
 
345
- The skill uploads under the "name" its SKILL.md frontmatter declares, which
1402
+ A DIRECTORY uploads under the "name" its SKILL.md frontmatter declares, which
346
1403
  is also the default display_title.
347
1404
 
1405
+ A ZIP takes its display_title from the ZIP FILENAME, so my-skill-v2.zip
1406
+ publishes a skill titled "my-skill-v2", which is a separate skill from
1407
+ "my-skill" and is not refused as a duplicate. VAT reads the SKILL.md nearest
1408
+ the root of the archive and WARNS when the name it declares differs from that
1409
+ title, because every version the skill ever gets will carry the declared name.
1410
+ Pass --title to set the title explicitly.
1411
+
348
1412
  A skill's eval suite is its answer key and is never uploaded: whatever the
349
1413
  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.
1414
+ (skills.config.<name>.test.evals) is withheld, and so is ANY directory named
1415
+ evals/ — unconditionally, whether or not a config was found. node_modules/ and
1416
+ .git/ are never uploaded either. Each exclusion is reported in the output.
1417
+
1418
+ A ZIP is REFUSED outright when it contains any of those three directory names,
1419
+ rather than having them withheld: the archive is posted as one part, so there
1420
+ is nothing to strip out of it. Rebuild it from the built skill directory, or
1421
+ upload that directory. (A config-declared suite in some other location is
1422
+ invisible to this lane — there is no directory to resolve a config from.)
1423
+
1424
+ Those same three directory names are also left out of the bytes weighed against
1425
+ the ceiling below, and out of the build-time PACKAGED_SIZE_EXCEEDS_API_LIMIT
1426
+ total, so \`du -sb\` on the directory will report more than VAT does.
1427
+
1428
+ A symbolic link is refused rather than followed, whatever it points at: a
1429
+ multipart body cannot express one, and reading through it would publish the
1430
+ target's bytes under an in-bundle name.
1431
+
1432
+ Before anything is sent, every markdown document in the bundle is run through
1433
+ the non-portable reference, non-portable command, and unqualified MCP tool-name
1434
+ checks — the ones that catch a skill referencing a path outside its own tree,
1435
+ which cannot resolve once the API publishes it alone. These WARN and never
1436
+ block. Run \`vat audit <dir>\` for the full report.
1437
+
1438
+ A ZIP faces the same three checks: its markdown members are read out of the
1439
+ archive and run through them, so a hand-built bundle is not the quiet lane.
1440
+ The one difference is the config. On a DIRECTORY, \`validation.allow\` /
1441
+ \`validation.severity\` from the governing vibe-agent-toolkit.config.yaml are
1442
+ honoured, so a waiver that makes \`vat skills build\` green makes this quiet
1443
+ too. On a ZIP there is no directory to resolve that config from — the same
1444
+ reason a config-declared eval-suite location is invisible here — so the checks
1445
+ run UNWAIVED and a warning you have already waived elsewhere will still print.
1446
+
1447
+ There are TWO size gates and a ZIP faces both. The REQUEST gate weighs the
1448
+ multipart body — file bytes plus about 156 bytes of framing per file — against
1449
+ the API's ${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} ceiling; it is inclusive,
1450
+ measured: a body of exactly ${API_SKILL_MAX_UPLOAD_BYTES.toLocaleString('en-US')}
1451
+ bytes is accepted and one byte more is refused 413. A directory and a ZIP both
1452
+ go through it. The API then EXPANDS a ZIP and applies the same ceiling to the
1453
+ uncompressed total, so an archive far under the request ceiling can still be
1454
+ refused — VAT reads the archive's own headers and refuses first, naming the
1455
+ expanded size, because compressing harder cannot get it under the limit.
1456
+
1457
+ Exit Codes:
1458
+ 0 - Every skill uploaded
1459
+ 1 - The run completed and at least one skill failed to upload (--from-npm
1460
+ uploads several; the ones that landed are listed under skills)
1461
+ 2 - The run could not happen: no API key, no such source, unusable input
353
1462
 
354
1463
  Examples:
355
1464
  $ vat claude org skills install dist/skills/org-admin
@@ -366,27 +1475,103 @@ Examples:
366
1475
  .option('--debug', DEBUG_OPT_DESC)
367
1476
  .action(async (skillId, options) => {
368
1477
  await executeOrgCommand('OrgSkillsDelete', options.debug, async ({ client, logger }) => {
1478
+ // Every version this run actually destroyed, accumulated as it goes.
1479
+ //
1480
+ // 🔑 It is a RECORD, not a progress counter. Version deletion is
1481
+ // irreversible and `--all` does it in a loop, so a failure part-way
1482
+ // through leaves a workspace nobody can reconstruct — and the old code
1483
+ // threw at that point, which ends on `handleCommandError`'s exit 2
1484
+ // ("the run could not happen") and DISCARDS the document. It happened,
1485
+ // and it destroyed things the operator now has no list of. So the list
1486
+ // travels with the failure, and the run ends 1: it happened, and its
1487
+ // outcome is wrong.
1488
+ const deletedVersions = [];
1489
+ const halfDeleted = (error, failedVersions) => reportHalfDeleted({
1490
+ skillId,
1491
+ deletedVersions,
1492
+ failedVersions,
1493
+ error: error instanceof Error ? error.message : String(error),
1494
+ });
369
1495
  if (options.all) {
370
- // Fetch and delete all versions first
371
- const versions = await autopaginateSkills(client, `/v1/skills/${encodeURIComponent(skillId)}/versions`);
1496
+ const versions = await autopaginateSkills(client, skillVersionsPath(skillId));
372
1497
  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}`);
1498
+ // "Found …", not "Deleting …": nothing has been deleted at this point
1499
+ // and the very next call can be refused. Cf. `installFromLocal`.
1500
+ logger.info(`Found ${String(versionData.length)} version(s) of ${skillId} to delete first`);
1501
+ // ATTEMPTS every version, whatever any one of them answers. See
1502
+ // `deleteEveryVersion`: returning on the first refusal left the rest
1503
+ // unexamined, and the skill undeletable with no record of what remained.
1504
+ const sweep = await deleteEveryVersion(client, skillId, versionData.map(v => v.version), logger);
1505
+ deletedVersions.push(...sweep.deleted);
1506
+ if (sweep.failures.length > 0) {
1507
+ const detail = describeVersionSweepFailures(sweep.failures);
1508
+ // Nothing was destroyed, so there is no record worth publishing and
1509
+ // "could not happen" (exit 2, via the throw) is the honest ending —
1510
+ // the same rule as before, now decided on the WHOLE sweep rather than
1511
+ // on its first refusal.
1512
+ if (deletedVersions.length === 0) {
1513
+ throw new Error(`No version of ${skillId} could be deleted, so the skill was left alone. `
1514
+ + `${String(sweep.failures.length)} attempt(s) failed — ${detail}`);
1515
+ }
1516
+ // The skill delete is not attempted: the API refuses a skill that still
1517
+ // has versions, so it would spend a round trip to be told what is
1518
+ // already known here.
1519
+ return halfDeleted(new Error(detail), sweep.failures.map(failure => failure.version));
377
1520
  }
378
1521
  }
379
1522
  logger.info(`Deleting skill: ${skillId}`);
380
- const result = await client.deleteSkill(skillId);
381
- return { id: result.id, deleted: result.type === 'skill_deleted' };
1523
+ // The vendor's refusal here names no VAT command — measured live as
1524
+ // `400 Cannot delete skill with existing versions. Delete all versions
1525
+ // first.` — so the one that answers it is appended, unless this run
1526
+ // already deleted them.
1527
+ let raw;
1528
+ try {
1529
+ raw = await deleteSkillOrExplain(client, skillId, options.all === true ? DELETE_REMEDIES_AFTER_ALL : DELETE_REMEDIES);
1530
+ }
1531
+ catch (error) {
1532
+ if (deletedVersions.length === 0)
1533
+ throw error;
1534
+ // The sweep completed — every version is gone — and the SKILL delete is
1535
+ // what failed, so nothing is left in `failedVersions`.
1536
+ return halfDeleted(error, []);
1537
+ }
1538
+ const deleted = reportDelete(raw, skillId, SKILL_DELETED_TYPES);
1539
+ return deletedVersions.length === 0
1540
+ ? deleted
1541
+ // The versions are part of what this run DID, so they belong in the
1542
+ // document either way — and the failure tag has to survive being
1543
+ // merged with them.
1544
+ : mergeDeleteReport(deleted, deletedVersions);
382
1545
  });
383
1546
  })
384
1547
  .addHelpText('after', `
385
1548
  Description:
386
1549
  Deletes a skill from the organization. Uses the Skills API (beta).
387
- Use --all to auto-delete all versions before the skill.
388
1550
  Requires ANTHROPIC_API_KEY (regular key, not admin key).
389
1551
 
1552
+ The API refuses to delete a skill that still has versions (400). Use --all to
1553
+ delete every version and then the skill in one command; without it, delete the
1554
+ versions yourself with \`vat claude org skills versions delete\` first.
1555
+
1556
+ --all deletes irreversibly and in a loop, and it ATTEMPTS EVERY VERSION even
1557
+ when one refuses — one refusal is a fact about one version, not about the ones
1558
+ after it. When the sweep ends with anything left, the versions it destroyed are
1559
+ listed under deletedVersions, the ones still there under failedVersions, and
1560
+ the run exits 1 — the run happened, and its outcome is wrong. The skill itself
1561
+ is then left alone, because the API refuses a skill that still has versions.
1562
+ It exits 2 only when nothing was deleted at all.
1563
+
1564
+ A version delete whose response is lost is replayed, and the replay is answered
1565
+ 404 because the first attempt already removed it. That 404 is read as the
1566
+ delete it is, not as a failure — the version appears under deletedVersions.
1567
+
1568
+ Exit Codes:
1569
+ 0 - The skill was deleted
1570
+ 1 - The run completed and the skill was NOT deleted: the API named a different
1571
+ outcome, or --all destroyed some versions and could not destroy the rest
1572
+ 2 - The run could not happen: no API key, the first request was refused, or
1573
+ --all deleted no version at all
1574
+
390
1575
  Example:
391
1576
  $ vat claude org skills delete skill_abc123 --all
392
1577
  `);
@@ -400,7 +1585,7 @@ Example:
400
1585
  .option('--debug', DEBUG_OPT_DESC)
401
1586
  .action(async (skillId, options) => {
402
1587
  await executeOrgCommand('OrgSkillsVersionsList', options.debug, async ({ client }) => {
403
- return autopaginateSkills(client, `/v1/skills/${encodeURIComponent(skillId)}/versions`);
1588
+ return autopaginateSkills(client, skillVersionsPath(skillId));
404
1589
  });
405
1590
  })
406
1591
  .addHelpText('after', `
@@ -420,8 +1605,7 @@ Example:
420
1605
  .action(async (skillId, version, options) => {
421
1606
  await executeOrgCommand('OrgSkillsVersionsDelete', options.debug, async ({ client, logger }) => {
422
1607
  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' };
1608
+ return reportVersionDelete(await client.deleteSkillVersion(skillId, version), skillId, version);
425
1609
  });
426
1610
  })
427
1611
  .addHelpText('after', `
@@ -430,10 +1614,96 @@ Description:
430
1614
  All versions must be deleted before a skill can be deleted.
431
1615
  Requires ANTHROPIC_API_KEY.
432
1616
 
1617
+ Output:
1618
+ - id: the SKILL this version belonged to (always the id you passed)
1619
+ - version: the version that was destroyed (always the one you passed)
1620
+ - deleted: whether the API confirmed the deletion
1621
+
1622
+ Exit Codes:
1623
+ 0 - The version was deleted
1624
+ 1 - The run completed and the API named an outcome other than a deletion
1625
+ 2 - The run could not happen: no API key, or the request was refused
1626
+
433
1627
  Example:
434
1628
  $ vat claude org skills versions delete skill_abc123 1775007400733130
1629
+ `);
1630
+ const versionsAddCmd = new Command('add');
1631
+ versionsAddCmd
1632
+ .description('Publish a new version of an existing skill')
1633
+ .argument(SKILL_ID_ARG, SKILL_ID_DESC)
1634
+ // NOT "or ZIP file", which is what this said when it was copied from
1635
+ // `install`: the guard below refuses anything that is not a directory, so the
1636
+ // help promised an input the command rejects. Narrowing the promise rather
1637
+ // than widening the code is the honest fix — a ZIP posted to
1638
+ // POST /v1/skills/{id}/versions has never been tried against the live API, and
1639
+ // this is not the place to find out by guessing.
1640
+ .argument('<source>', 'Path to a built skill directory')
1641
+ .option('--debug', DEBUG_OPT_DESC)
1642
+ .action(async (skillId, source, options) => {
1643
+ await executeOrgCommand('OrgSkillsVersionsAdd', options.debug, async ({ client, logger }) => {
1644
+ const resolved = resolveSourceArgument(source);
1645
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
1646
+ if (!existsSync(resolved))
1647
+ throw new Error(`Source not found: ${resolved}`);
1648
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
1649
+ if (!statSync(resolved).isDirectory()) {
1650
+ // Name the asymmetry IN the refusal, not only in --help. `install`
1651
+ // accepts a ZIP and this verb does not, so the operator most likely to
1652
+ // hit this is the one who just read that `install` takes one — and a
1653
+ // refusal that says only "must be a directory" reads as a bug in their
1654
+ // path, not as a difference between two verbs.
1655
+ const zipHint = resolved.toLowerCase().endsWith('.zip')
1656
+ ? ' A ZIP is accepted by `vat claude org skills install`, not here:'
1657
+ + ' publish a new version from the built skill directory instead.'
1658
+ : '';
1659
+ throw new Error(`Source must be a skill directory: ${resolved}.${zipHint}`);
1660
+ }
1661
+ // "Packaging", not "Publishing" — see `installFromLocal`. Measured: this
1662
+ // line printed, then `Failed to load config: …`, and nothing had been
1663
+ // sent. The announcement now describes the step it actually precedes.
1664
+ logger.info(`Packaging new version of ${skillId} from: ${resolved}`);
1665
+ return uploadSkillVersionDir(client, skillId, resolved, logger);
1666
+ });
1667
+ })
1668
+ .addHelpText('after', `
1669
+ Description:
1670
+ Publishes the contents of a skill directory as a NEW VERSION of an existing
1671
+ skill. Uses the Skills API (beta). Requires ANTHROPIC_API_KEY.
1672
+
1673
+ This is how you ship a change to a skill you have already published. It is a
1674
+ separate command from \`install\` on purpose: \`install\` always creates a new
1675
+ skill, this always adds a version to the skill you name, and neither inspects
1676
+ the workspace to decide which it "should" do. A display title is NOT unique in
1677
+ a workspace, so resolving one to a skill can match none, one, or several — and
1678
+ a wrong match would append your version to somebody else's skill.
1679
+
1680
+ Find the id with \`vat claude org skills list\`. The API assigns the version
1681
+ identifier and makes it the skill's latest; nothing is numbered locally.
1682
+
1683
+ Takes a built skill DIRECTORY. A ZIP is accepted by \`install\`, not here.
1684
+
1685
+ The same exclusions and the same ${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} request
1686
+ ceiling as \`install\` apply: the eval suite, node_modules/ and .git/ are never
1687
+ uploaded, and a symbolic link is refused rather than followed.
1688
+
1689
+ The uploaded files are keyed under the top-level directory named by this
1690
+ tree's SKILL.md \`name\`, and that root is printed as the upload runs. The API
1691
+ enforces that name against the one this skill already has, so publishing from a
1692
+ renamed tree is refused with a 400 rather than silently re-rooting the version's
1693
+ files. VAT does not check it first — it reports the root it used and lets the
1694
+ server be the authority.
1695
+
1696
+ Exit Codes:
1697
+ 0 - The version was published
1698
+ 2 - The run could not happen: no API key, no such source, not a directory,
1699
+ over the upload ceiling, or a response the version identifier
1700
+ cannot be read from
1701
+
1702
+ Example:
1703
+ $ vat claude org skills versions add skill_abc123 dist/skills/org-admin
435
1704
  `);
436
1705
  versionsCmd.addCommand(versionsListCmd);
1706
+ versionsCmd.addCommand(versionsAddCmd);
437
1707
  versionsCmd.addCommand(versionsDeleteCmd);
438
1708
  command.addCommand(listCmd);
439
1709
  command.addCommand(installCmd);