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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/commands/audit.d.ts.map +1 -1
  2. package/dist/commands/audit.js +146 -12
  3. package/dist/commands/audit.js.map +1 -1
  4. package/dist/commands/claude/claude-config.d.ts.map +1 -1
  5. package/dist/commands/claude/claude-config.js +6 -0
  6. package/dist/commands/claude/claude-config.js.map +1 -1
  7. package/dist/commands/claude/org/helpers.d.ts +59 -1
  8. package/dist/commands/claude/org/helpers.d.ts.map +1 -1
  9. package/dist/commands/claude/org/helpers.js +58 -7
  10. package/dist/commands/claude/org/helpers.js.map +1 -1
  11. package/dist/commands/claude/org/index.d.ts.map +1 -1
  12. package/dist/commands/claude/org/index.js +24 -16
  13. package/dist/commands/claude/org/index.js.map +1 -1
  14. package/dist/commands/claude/org/skills.d.ts +428 -0
  15. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  16. package/dist/commands/claude/org/skills.js +1189 -91
  17. package/dist/commands/claude/org/skills.js.map +1 -1
  18. package/dist/commands/claude/org/users.d.ts.map +1 -1
  19. package/dist/commands/claude/org/users.js +3 -1
  20. package/dist/commands/claude/org/users.js.map +1 -1
  21. package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
  22. package/dist/commands/claude/org/workspaces.js +5 -2
  23. package/dist/commands/claude/org/workspaces.js.map +1 -1
  24. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  25. package/dist/commands/skill/review-checklist.js +35 -0
  26. package/dist/commands/skill/review-checklist.js.map +1 -1
  27. package/dist/commands/skill/test/configure.d.ts +37 -0
  28. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  29. package/dist/commands/skill/test/configure.js +57 -12
  30. package/dist/commands/skill/test/configure.js.map +1 -1
  31. package/dist/utils/config-loader.d.ts.map +1 -1
  32. package/dist/utils/config-loader.js +22 -6
  33. package/dist/utils/config-loader.js.map +1 -1
  34. package/package.json +15 -15
@@ -3,29 +3,621 @@
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 SKILL.md this will decompress to read a `name` out of.
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 one
466
+ * allocation 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.md.
469
+ */
470
+ const MAX_INSPECTED_SKILL_MD_BYTES = 1024 * 1024;
471
+ /**
472
+ * What a ZIP will weigh AFTER the API expands it, read from the archive's own
473
+ * central directory — plus the name its inner SKILL.md declares, and any answer
474
+ * key it carries.
475
+ *
476
+ * 🔑 **The API expands a ZIP and applies the ceiling to the UNCOMPRESSED total,
477
+ * not to the archive.** Measured against the live API by an adopter: a
478
+ * 10,707,463-byte archive of a 47.85 MiB tree — comfortably inside the request
479
+ * ceiling as bytes on the wire — came back
480
+ * `400: Zip file uncompressed size exceeds 30MB`. So the body check every other
481
+ * shape passes through is structurally blind here: compression is exactly the
482
+ * thing that makes the measured quantity stop predicting the enforced one. A
483
+ * ZIP is therefore the one input that could pass every local gate and still be
484
+ * refused remotely, which is the same defect class as the ZIP path once
485
+ * skipping the size check altogether.
486
+ *
487
+ * ⚠️ **The boundary itself is UNMEASURED on this lane.** One refusal at 47.85
488
+ * MiB is all the evidence there is; nobody has bracketed where the uncompressed
489
+ * rule actually cuts, or confirmed it is the same constant as the request lane.
490
+ * So this refuses only on `>` — strictly the looser choice — and a bundle near
491
+ * the line is still sent for the API to judge. Do not tighten this to `>=`, or
492
+ * quote it as a measured ceiling, without bracketing it the way
493
+ * {@link API_SKILL_MAX_UPLOAD_BYTES} was bracketed.
494
+ *
495
+ * ⛔ Sizes come from entry HEADERS, which an archive can lie about. That is
496
+ * fine: a lie can only change whether VAT refuses early, never whether the API
497
+ * does.
498
+ *
499
+ * ⚠️ **Reading an entry is not free, and the docstring here used to claim it
500
+ * was** — "no zip-slip or zip-bomb surface is opened — the entries are read, not
501
+ * written" was true of the enumeration half and FALSE of the one `getData()`
502
+ * call, which inflates `header.size` bytes into memory. Worse, it ran BEFORE the
503
+ * caller's uncompressed-total gate, so the ceiling this function exists to
504
+ * enforce could not protect the inflation this function performed. Nothing is
505
+ * written to disk — that part stands — but the decompression is now ordered
506
+ * after the total is known and bounded by {@link MAX_INSPECTED_SKILL_MD_BYTES},
507
+ * so an archive declaring a 4 GiB SKILL.md is enumerated and never inflated.
508
+ */
509
+ export async function inspectZipArchive(zipPath) {
510
+ // `adm-zip` is already a runtime dependency of this package (the packager and
511
+ // the url skill-source both use it). An earlier comment here reasoned that
512
+ // reading the archive would mean "adding a dependency … for a log line" and
513
+ // declined on that basis; the premise was false, and the payoff is not a log
514
+ // line but a refusal that saves a doomed upload of up to 30 MiB.
515
+ const AdmZip = (await import('adm-zip')).default;
516
+ let entries;
517
+ try {
518
+ entries = new AdmZip(zipPath).getEntries();
519
+ }
520
+ catch {
521
+ // An archive VAT cannot parse is not an archive VAT should block. The API
522
+ // is the authority on the upload either way, and refusing here would turn
523
+ // "our reader disagrees with your zip tool" into a failed publish.
524
+ return undefined;
525
+ }
526
+ // PASS 1 — headers only. Nothing is decompressed while this runs, so both
527
+ // refusals the caller can reach are decided on metadata alone.
528
+ const files = entries.filter(entry => !entry.isDirectory);
529
+ const uncompressedBytes = files.reduce((sum, entry) => sum + entry.header.size, 0);
530
+ const neverUploaded = files
531
+ .map(entry => toForwardSlash(entry.entryName))
532
+ .filter(name => name.split('/').some(segment => NEVER_UPLOADED_DIR_NAMES.has(segment)));
533
+ // PASS 2 — the ONE entry worth inflating, and only once the total says this
534
+ // archive is not already refused. An over-ceiling archive is about to be
535
+ // rejected by the caller, so inflating anything out of it would be work spent
536
+ // on an upload that cannot happen.
537
+ const declaredName = uncompressedBytes > API_SKILL_MAX_UPLOAD_BYTES
538
+ ? undefined
539
+ : declaredNameOf(electShallowestSkillMd(files));
540
+ return { uncompressedBytes, declaredName, neverUploaded };
541
+ }
542
+ /**
543
+ * The SKILL.md nearest the archive root, which is the one whose `name` the API
544
+ * will key the skill by.
545
+ *
546
+ * 🪤 Compared on DEPTH, with name length only as a tiebreak. It used to compare
547
+ * `entryName.length` alone, on the reasoning that "a shortest-path comparison
548
+ * needs no separator at all" — but shortest string is not shallowest path:
549
+ * `a/b/SKILL.md` (12 chars, depth 2) beats `my-skill-bundle/SKILL.md` (24
550
+ * chars, depth 1), so a bundled FIXTURE supplied the name and the operator was
551
+ * advised to `--title` it. ZIP fixes `/` as the entry separator on every
552
+ * platform, so splitting on it is not path handling — it is reading the format.
553
+ */
554
+ function electShallowestSkillMd(files) {
555
+ let elected;
556
+ for (const entry of files) {
557
+ if (basename(entry.entryName) !== 'SKILL.md')
558
+ continue;
559
+ if (elected !== undefined && !isShallower(entry.entryName, elected.entryName))
560
+ continue;
561
+ elected = entry;
562
+ }
563
+ return elected;
564
+ }
565
+ /** Fewer path segments wins; equal depth falls back to the shorter name. */
566
+ function isShallower(candidate, incumbent) {
567
+ const candidateDepth = zipEntryDepth(candidate);
568
+ const incumbentDepth = zipEntryDepth(incumbent);
569
+ return candidateDepth === incumbentDepth
570
+ ? candidate.length < incumbent.length
571
+ : candidateDepth < incumbentDepth;
572
+ }
573
+ /**
574
+ * How many segments a ZIP entry name has.
575
+ *
576
+ * Counted rather than `split('/')`-ed because the linter — correctly, for
577
+ * FILESYSTEM paths — refuses that idiom: a host path's separator depends on the
578
+ * platform. A ZIP entry name's does not; the format fixes it as `/`. Counting
579
+ * the separator states that, and needs no exemption.
580
+ */
581
+ function zipEntryDepth(entryName) {
582
+ let depth = 1;
583
+ for (const character of entryName) {
584
+ if (character === '/')
585
+ depth += 1;
586
+ }
587
+ return depth;
588
+ }
589
+ /**
590
+ * The frontmatter `name` inside the ELECTED entry — or `undefined`, including
591
+ * when that entry declares none.
592
+ *
593
+ * 🪤 It used to be `declaredSkillNameIn(…) ?? declaredName`, which KEPT the
594
+ * previous (deeper) candidate's name when a newly-elected shallower SKILL.md had
595
+ * no `name`. The elected file and the reported name then came from two different
596
+ * documents, and the divergence warning named a name no operator could find at
597
+ * the root of their archive. The election decides; whatever it holds is the
598
+ * answer, absence included.
599
+ *
600
+ * `getData()` is the only decompression in this module, so it is the only thing
601
+ * that can throw here: adm-zip raises on BAD_CRC, on any compression method but
602
+ * store/deflate, and on an encrypted entry. Those used to escape and abort the
603
+ * publish on exit 2 — contradicting this module's own stance that an archive VAT
604
+ * cannot parse is not an archive VAT should block. The size total is already
605
+ * computed from headers, so failing here costs only the name.
606
+ */
607
+ function declaredNameOf(entry) {
608
+ if (entry === undefined || entry.header.size > MAX_INSPECTED_SKILL_MD_BYTES)
609
+ return undefined;
610
+ try {
611
+ return declaredSkillNameIn(entry.getData().toString('utf8'));
612
+ }
613
+ catch {
614
+ return undefined;
615
+ }
616
+ }
617
+ async function sendSkillUpload(client, displayTitle, files, bundleRoot) {
618
+ const multipart = buildUploadBodyOrRefuse({ display_title: displayTitle }, files, bundleRoot);
619
+ return readCreateSkillResponse(await sendUpload(() => client.uploadSkill(multipart), multipart.body.length, withDuplicateTitleRemedy));
620
+ }
29
621
  /**
30
622
  * The name a SKILL.md declares, which is both the uploaded skill's display
31
623
  * title and the top-level directory the API keys it by.
@@ -37,35 +629,6 @@ function requireDeclaredName(skillMdPath) {
37
629
  }
38
630
  return declared;
39
631
  }
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
632
  /**
70
633
  * The absolute path of this skill's DECLARED eval suite, when its governing VAT
71
634
  * config declares one, as a set of paths to withhold.
@@ -94,24 +657,89 @@ async function declaredTestInputPaths(skillDir) {
94
657
  const unit = evalSuiteUnitPath(safePath.resolve(skillDir), declared);
95
658
  return unit === undefined ? new Set() : new Set([unit]);
96
659
  }
660
+ /**
661
+ * Whether a directory entry is a symbolic link resolving to a DIRECTORY —
662
+ * throwing, naming the path, when the link cannot be followed at all.
663
+ *
664
+ * `Dirent.isDirectory()` is lstat-based, so it answers `false` for a link to a
665
+ * directory. That entry therefore used to fall into the FILE branch below and
666
+ * `readFileSync` threw a raw `EISDIR`: the upload died on a Node error that
667
+ * named no path and said nothing about what to do. A dangling link produced the
668
+ * same shape of failure with `ENOENT`.
669
+ *
670
+ * `statSync` follows the link, which is the same thing the build-time size walk
671
+ * does to classify one — so both lanes reach the same verdict about the same
672
+ * entry.
673
+ */
674
+ function resolvesToDirectory(entry, fullPath, relativePath) {
675
+ if (!entry.isSymbolicLink())
676
+ return false;
677
+ try {
678
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- collected from dir walk
679
+ return statSync(fullPath).isDirectory();
680
+ }
681
+ catch (error) {
682
+ const cause = error instanceof Error ? error.message : String(error);
683
+ throw new Error(`Cannot upload ${relativePath}: it is a symbolic link whose target could not be read `
684
+ + `(${cause}). Replace it with the file or directory it should point at, or remove it.`);
685
+ }
686
+ }
97
687
  /**
98
688
  * Collect the files under a skill directory that should be uploaded,
99
689
  * recursively, returning relative paths alongside what was deliberately left
100
690
  * out.
691
+ *
692
+ * 🚨 **A symlink is REFUSED, whatever it resolves to.** A multipart body has no
693
+ * way to express a link, so there are only three things this could do with one
694
+ * and two of them are wrong. Skipping it changes what gets published without the
695
+ * skill breaking until someone opens it — and the one thing this collector
696
+ * guarantees is that every withholding is reported. FOLLOWING it is data egress:
697
+ * an earlier version refused only a link resolving to a DIRECTORY, so
698
+ * `notes.md -> /etc/passwd` fell through both branches, `readFileSync` returned
699
+ * the target's bytes, and they were posted under the in-bundle name `notes.md`
700
+ * into a workspace every org member can read. Nothing in the run said a link had
701
+ * been followed: the "every withholding is reported" guarantee covers
702
+ * EXCLUSIONS, not DEREFERENCES. Any file the operator can read left the machine.
703
+ *
704
+ * The vector is not a registry tarball — node-tar 7 de-roots an absolute
705
+ * linkpath and refuses an escaping relative one, measured — but a directory
706
+ * extracted with system `tar`, which does recreate `-> /etc/passwd`, or cloned
707
+ * from an untrusted repo and handed to `install <dir>`.
708
+ *
709
+ * Refusing and naming the path is the only answer that is both complete and
710
+ * honest, and it keeps this lane on the same payload as the build-time size
711
+ * walk, which does not weigh a linked entry either.
712
+ *
713
+ * The never-uploaded NAMES are matched on a linked directory too. Those are
714
+ * never published whatever their type, and the size walk weighs a linked
715
+ * directory as zero bytes either way — so excluding one keeps both lanes on the
716
+ * same payload instead of blocking a publish over a directory neither lane
717
+ * would have sent.
101
718
  */
102
719
  function collectFiles(dir, base, testInput, collected) {
103
720
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- dir from CLI arg
104
721
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
105
722
  const fullPath = safePath.join(dir, entry.name);
106
723
  const relativePath = safePath.relative(base, fullPath);
724
+ const linkedDirectory = resolvesToDirectory(entry, fullPath, relativePath);
725
+ const directoryLike = entry.isDirectory() || linkedDirectory;
107
726
  if (testInput.has(safePath.resolve(fullPath))
108
- || (entry.isDirectory() && NEVER_UPLOADED_DIR_NAMES.has(entry.name))) {
727
+ || (directoryLike && NEVER_UPLOADED_DIR_NAMES.has(entry.name))) {
109
728
  collected.excluded.push(relativePath);
110
729
  continue;
111
730
  }
112
731
  if (entry.isDirectory()) {
113
732
  collectFiles(fullPath, base, testInput, collected);
114
733
  }
734
+ else if (entry.isSymbolicLink()) {
735
+ // Whatever it points at. A link to a FILE used to fall through to the
736
+ // branch below and be read through — see this function's doc comment.
737
+ throw new Error(`Cannot upload ${relativePath}: it is a symbolic link${linkedDirectory ? ' to a directory' : ''}, `
738
+ + 'which a multipart upload cannot express. VAT will not publish the bytes it points '
739
+ + 'at under this name: a link out of the skill directory would send whatever the '
740
+ + 'target holds into a workspace every org member can read. Replace it with a real '
741
+ + 'copy of the file or directory it points at, or remove it.');
742
+ }
115
743
  else {
116
744
  collected.files.push({ relativePath, absolutePath: fullPath });
117
745
  }
@@ -129,9 +757,15 @@ export async function collectSkillUploadFiles(skillDir) {
129
757
  return collected;
130
758
  }
131
759
  /**
132
- * Upload a single skill directory to the org via Skills API.
760
+ * Package a skill directory into the multipart file set the API takes.
761
+ *
762
+ * Deliberately separate from SENDING it. Creating a skill and adding a version to
763
+ * one differ only in the endpoint; the bundle, the exclusions and the size ceiling
764
+ * are identical, and a skill that packages one way when created and another way
765
+ * when updated would be the bug this split exists to prevent. Which endpoint gets
766
+ * the bundle is the caller's command, never something inferred from the workspace.
133
767
  */
134
- async function uploadSkillDir(client, skillDir, titleOverride, logger) {
768
+ async function prepareSkillUpload(skillDir, titleOverride, logger) {
135
769
  const skillMdPath = safePath.join(skillDir, 'SKILL.md');
136
770
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- derived from CLI arg
137
771
  if (!existsSync(skillMdPath)) {
@@ -154,17 +788,152 @@ async function uploadSkillDir(client, skillDir, titleOverride, logger) {
154
788
  content,
155
789
  });
156
790
  }
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}"`);
791
+ // Reported on the collected files, so the exclusions listed below are already
792
+ // accounted for — this is the bundle actually being sent. It is FILE BYTES and
793
+ // says so: the ceiling is enforced on the multipart body, which adds ~156 bytes
794
+ // plus the filename per part, and a reader who mistook this line for the
795
+ // measured quantity would not understand a refusal that names a larger number.
796
+ const contentBytes = files.reduce((sum, f) => sum + f.content.length, 0);
797
+ logger.info(` ${dirName}: ${files.length} ${files.length === 1 ? 'file' : 'files'}, `
798
+ + `${formatBytes(contentBytes)} of file content, title="${displayTitle}"`);
159
799
  for (const excluded of collected.excluded) {
160
800
  logger.info(` Excluded from upload: ${excluded} (never published with a skill)`);
161
801
  }
162
- return sendSkillUpload(client, displayTitle, files);
802
+ // The SAME merge `declaredTestInputPaths` read to find the eval suite — both
803
+ // resolutions are served from `loadConfigCached` and the per-root discovery
804
+ // cache, so asking twice costs one map lookup rather than a second walk-up.
805
+ // Resolved HERE rather than plumbed out of `collectSkillUploadFiles`, which is
806
+ // exported and deliberately resolves its own so no caller can hand it a config
807
+ // with the exclusion skipped.
808
+ const packaging = await resolveSkillPackagingConfig(skillMdPath);
809
+ warnUnportableReferences(files, dirName, packaging?.validation, logger);
810
+ return { displayTitle, files, dirName };
811
+ }
812
+ /**
813
+ * Warn about references in the bundle that cannot resolve once it is published.
814
+ *
815
+ * 🚨 **Until this existed, `install` validated NOTHING.** It weighed the request
816
+ * and sent it; every check VAT owns — the non-portable reference family, the
817
+ * unqualified MCP tool names, the non-portable command family — ran in
818
+ * `vat audit` and `vat skills build` and never on the one path where content
819
+ * leaves the machine. An adopter found the consequence directly: of 54 built
820
+ * skills, 10 referenced paths outside their own directory, one of them exec'ing
821
+ * a script under a SIBLING skill. Under the Skills API each skill is its own
822
+ * top-level tree with no siblings, so those uploaded with a green tick and could
823
+ * not possibly run. "It uploaded" is not "it works", and the publish command was
824
+ * the only lane in a position to say so before the bytes left.
825
+ *
826
+ * ⚠️ **Warns, never refuses.** These are `warning`-severity codes and the
827
+ * operator may have reasons VAT cannot see; turning a publish into an exit 2 on
828
+ * a heuristic is a worse failure than the one being reported. Blocking belongs
829
+ * to `vat skills build` and `vat audit`, which run before this and can gate CI.
830
+ *
831
+ * Costs nothing extra: it reads the buffers already collected for the upload, so
832
+ * no file is opened twice and nothing is walked again.
833
+ *
834
+ * 🚨 **The three collectors are RAW issue producers, and raw issues are not what
835
+ * an adopter agreed to see.** Everywhere else in VAT they pass through
836
+ * {@link runValidationFramework}, which applies `validation.allow` waivers and
837
+ * `validation.severity` overrides. Printed unfiltered, this lane contradicted
838
+ * VAT's own documented remedy: `MCP_TOOL_NAME_UNQUALIFIED` tells authors to
839
+ * "waive that one identifier with a `validation.allow` entry", and an author who
840
+ * did exactly that got a green `vat skills build` and then the same warning on
841
+ * every publish with nothing left to silence it with.
842
+ *
843
+ * ⚠️ The `location` is the BUNDLE-relative path, not the API-keyed
844
+ * `<declared-name>/<path>` spelling. An allow glob matches `ValidationIssue.location`,
845
+ * which is contractually project/bundle-relative, so the keyed spelling could not
846
+ * have matched an entry even once allows were applied — the same divergence
847
+ * {@link bundleRelativeName} exists to strip out of the oversize message.
848
+ *
849
+ * ⛔ The run-level ALLOW_UNUSED sweep is deliberately NOT drained here. This lane
850
+ * runs three validator families out of the whole registry, so every allow entry
851
+ * for any other code would be reported as dead — advising the author to delete
852
+ * the entry that makes their build green.
853
+ */
854
+ export function warnUnportableReferences(files, bundleRoot, validation, logger) {
855
+ const rawIssues = [];
856
+ for (const file of files) {
857
+ // Skill DOCUMENTS only. These detectors read prose and code spans; running
858
+ // them over a `.png` or a bundled `.mjs` would report on bytes no agent
859
+ // reads as instructions.
860
+ if (!file.filename.toLowerCase().endsWith('.md'))
861
+ continue;
862
+ const location = bundleRelativeName(file.filename, bundleRoot);
863
+ const content = file.content.toString('utf8');
864
+ collectNonPortableAssetReferenceIssues(content, location, rawIssues);
865
+ collectNonPortableCommandIssues(content, location, rawIssues);
866
+ collectUnqualifiedMcpToolIssues(content, location, rawIssues);
867
+ }
868
+ const { emitted: issues } = runValidationFramework(rawIssues, validation ?? {}, createAllowUsageLedger());
869
+ if (issues.length === 0)
870
+ return;
871
+ logger.warn(` ${issues.length} portability ${issues.length === 1 ? 'warning' : 'warnings'} in this bundle `
872
+ + '(uploading anyway; run `vat audit <dir>` for the full report):');
873
+ for (const issue of issues) {
874
+ logger.warn(` ${issue.location ?? ''}: ${issue.message}`);
875
+ }
876
+ }
877
+ /**
878
+ * Upload a skill directory as a NEW skill.
879
+ *
880
+ * Always creates. It does not look for an existing skill of the same title and
881
+ * quietly switch to adding a version: `display_title` is not unique in a workspace
882
+ * (the API enforces it only when the field is sent explicitly, and derives a title
883
+ * from frontmatter otherwise), so such a lookup returns 0, 1 or N matches and the
884
+ * command's effect would depend on which. Updating an existing skill is
885
+ * `skills versions add`, which takes the id outright.
886
+ */
887
+ async function uploadSkillDir(client, skillDir, titleOverride, logger) {
888
+ const { displayTitle, files, dirName } = await prepareSkillUpload(skillDir, titleOverride, logger);
889
+ return sendSkillUpload(client, displayTitle, files, dirName);
890
+ }
891
+ /**
892
+ * Upload a skill directory as a new VERSION of an existing skill.
893
+ *
894
+ * The server assigns the version identifier and promotes it to `latest_version`;
895
+ * nothing here numbers a version.
896
+ *
897
+ * ⚠️ The uploaded files are keyed under the SOURCE tree's declared name, which
898
+ * this command cannot check against the roots earlier versions used: reading
899
+ * them back would mean a second endpoint whose response shape has not been
900
+ * measured. So the root is REPORTED rather than checked here, and the log line
901
+ * below is what lets an operator see which root their files landed under.
902
+ *
903
+ * ⛔ It is NOT true that a changed name "silently re-roots" the version — this
904
+ * comment and the command's help both said so, and the measurement says
905
+ * otherwise: the SERVER enforces SKILL.md name consistency per `skill_id`, so
906
+ * publishing from a renamed tree earns a loud 400. That is a better outcome than
907
+ * the one the warning described, and it is the one refusal this endpoint can
908
+ * earn, so it gets a remedy ({@link VERSION_NAME_MISMATCH_REFUSAL}) rather than
909
+ * the bare vendor sentence it used to get.
910
+ */
911
+ async function uploadSkillVersionDir(client, skillId, skillDir, logger) {
912
+ const { files, dirName } = await prepareSkillUpload(skillDir, undefined, logger);
913
+ logger.info(` Files are keyed under "${dirName}/", taken from this tree's SKILL.md name. `
914
+ + 'The API refuses a version whose name differs from the one this skill already has.');
915
+ // No `display_title` field: this version belongs to a skill that already has a
916
+ // title, and sending one here would be an attempt to rename by side effect.
917
+ // Its absence also changes the body's length, which is why the ceiling is
918
+ // weighed on the body this endpoint sends rather than on the one `install` builds.
919
+ const multipart = buildUploadBodyOrRefuse({}, files, dirName);
920
+ // The duplicate-title 400 is unreachable here — `versions add` sends no
921
+ // `display_title` — but the NAME refusal is not: the server enforces SKILL.md
922
+ // name consistency per skill_id, so publishing from a tree whose frontmatter
923
+ // `name` has changed is a 400, and it is the one refusal this endpoint can
924
+ // earn. It got no remedy at all until the reviewer who read the log line above
925
+ // pointed out that the line describes silent behaviour the server forbids.
926
+ return readSkillVersionResponse(await sendUpload(() => client.uploadSkillVersion(skillId, multipart), multipart.body.length, withVersionRootRemedy));
163
927
  }
164
928
  /**
165
929
  * List candidate package directories in node_modules (scoped + unscoped).
930
+ *
931
+ * Exported for testing: this and {@link findSkillsDir} decide WHICH tree
932
+ * `--from-npm` publishes to an org, and the whole `--from-npm` path was
933
+ * untested — `packages/cli/src/commands/**` is coverage-excluded, so nothing in
934
+ * any report said so.
166
935
  */
167
- function listNodeModulePackages(nodeModulesDir) {
936
+ export function listNodeModulePackages(nodeModulesDir) {
168
937
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
169
938
  if (!existsSync(nodeModulesDir))
170
939
  return [];
@@ -191,7 +960,7 @@ function listNodeModulePackages(nodeModulesDir) {
191
960
  * Find the dist/skills/ directory in a package. Checks the package itself
192
961
  * first, then scans node_modules for sub-packages that contain built skills.
193
962
  */
194
- function findSkillsDir(packageDir) {
963
+ export function findSkillsDir(packageDir) {
195
964
  const direct = safePath.join(packageDir, 'dist', 'skills');
196
965
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- constructed from temp dir
197
966
  if (existsSync(direct))
@@ -205,6 +974,31 @@ function findSkillsDir(packageDir) {
205
974
  }
206
975
  return undefined;
207
976
  }
977
+ /**
978
+ * The batch's report, tagged as a failure when ANY skill did not publish.
979
+ *
980
+ * 🔑 Partial success is a failure. The old code returned this document plainly,
981
+ * so `executeOrgCommand` stamped `status: success` on it and exited 0 — a run in
982
+ * which all three skills were rejected printed `skillsUploaded: 0 /
983
+ * skillsFailed: 3` under `status: success`, and a CI wrapper written as
984
+ * `vat claude org skills install --from-npm … || fail` published nothing and
985
+ * reported green.
986
+ *
987
+ * Some-succeeded is tagged the same way as none-succeeded, deliberately: the
988
+ * workspace is now in a MIXED state that nobody asked for, which is exactly the
989
+ * case a human has to look at. Calling it green because two of three landed is
990
+ * the same lie, only smaller. What did land stays in the document, so the reader
991
+ * can see how far the run got.
992
+ */
993
+ export function summarizeNpmInstall(source, results, errors) {
994
+ const summary = {
995
+ source,
996
+ skillsUploaded: results.length,
997
+ ...(errors.length > 0 ? { skillsFailed: errors.length, errors } : {}),
998
+ skills: results,
999
+ };
1000
+ return errors.length > 0 ? orgCommandFailure(summary) : summary;
1001
+ }
208
1002
  /**
209
1003
  * Upload skills from an npm package.
210
1004
  */
@@ -246,47 +1040,138 @@ async function installFromNpm(npmPackage, skillFilter, client, logger) {
246
1040
  errors.push({ skill: skillName, error: msg });
247
1041
  }
248
1042
  }
249
- return {
250
- source: npmPackage,
251
- skillsUploaded: results.length,
252
- ...(errors.length > 0 ? { skillsFailed: errors.length, errors } : {}),
253
- skills: results,
254
- };
1043
+ return summarizeNpmInstall(npmPackage, results, errors);
255
1044
  }
256
1045
  finally {
257
1046
  rmSync(tempDir, { recursive: true, force: true });
258
1047
  }
259
1048
  }
260
1049
  /**
261
- * Upload a local skill directory or ZIP file.
1050
+ * Upload a local skill directory or ZIP file. Exported for testing.
262
1051
  */
263
- async function installFromLocal(source, titleOverride, client, logger) {
264
- const sourcePath = source.startsWith('/') ? source : safePath.join(process.cwd(), source);
1052
+ export async function installFromLocal(source, titleOverride, client, logger) {
1053
+ const sourcePath = resolveSourceArgument(source);
265
1054
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
266
1055
  if (!existsSync(sourcePath)) {
267
1056
  throw new Error(`Source not found: ${sourcePath}`);
268
1057
  }
269
1058
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
270
1059
  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);
1060
+ // Case-INSENSITIVE. `endsWith('.zip')` refused `MySkill.ZIP` as "not a directory
1061
+ // or .zip file" — a file Windows and macOS both consider a zip archive, and one
1062
+ // an operator has no way to read that refusal as being about capitalisation.
1063
+ if (!stat.isDirectory() && sourcePath.toLowerCase().endsWith('.zip')) {
1064
+ return installZipArchive(sourcePath, titleOverride, client, logger);
283
1065
  }
284
1066
  if (!stat.isDirectory()) {
285
1067
  throw new Error(`Source must be a directory or .zip file: ${sourcePath}`);
286
1068
  }
287
- logger.info(`Uploading skill directory: ${sourcePath}`);
1069
+ // "Packaging", not "Uploading". Everything that follows this line — reading the
1070
+ // config, collecting the file set, weighing the request — happens locally and
1071
+ // can refuse locally, and an operator who read "Uploading skill directory: X"
1072
+ // above a local size refusal was told about an upload that never started. The
1073
+ // line stays HERE rather than moving after the point of no return, because
1074
+ // naming the source before the work is what makes a failure attributable at
1075
+ // all — `--from-npm` packages several skills in a loop, and a packaging error
1076
+ // with no path above it names nothing.
1077
+ logger.info(`Packaging skill directory: ${sourcePath}`);
288
1078
  return uploadSkillDir(client, sourcePath, titleOverride, logger);
289
1079
  }
1080
+ /**
1081
+ * Publish a ZIP archive as a NEW skill.
1082
+ *
1083
+ * Split out of {@link installFromLocal} because the archive lane now has three
1084
+ * decisions of its own — an answer-key refusal, an expanded-size refusal, and a
1085
+ * title/name divergence warning — and inlining them made the dispatcher a
1086
+ * cognitively complex function whose real job (directory or archive?) was two
1087
+ * lines of it.
1088
+ */
1089
+ async function installZipArchive(sourcePath, titleOverride, client, logger) {
1090
+ // `basename(path, ext)` matches the extension exactly, so the literal '.zip'
1091
+ // leaves `.ZIP` on the title. Slice the length instead.
1092
+ const displayTitle = titleOverride ?? basename(sourcePath).slice(0, -'.zip'.length);
1093
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
1094
+ const zipContent = readFileSync(sourcePath);
1095
+ const files = [{
1096
+ fieldName: 'files[]',
1097
+ filename: basename(sourcePath),
1098
+ content: zipContent,
1099
+ }];
1100
+ // A ZIP is by construction a single large binary, so this is the shape most
1101
+ // likely to be over the ceiling — and the one that reached the wire unmeasured
1102
+ // while the check lived only in the directory-packaging path. It is gated in
1103
+ // `sendSkillUpload` below, on the same measure (the multipart body) as every
1104
+ // other shape; this line reports the file's own bytes, which are smaller.
1105
+ //
1106
+ // "Preparing", not "Uploading": nothing has been sent at this point, and the
1107
+ // very next step can refuse locally. A line that claims an upload started is
1108
+ // the log telling the operator about work that never happened.
1109
+ logger.info(`Preparing ZIP: ${sourcePath} (${formatBytes(zipContent.length)} of file content)`);
1110
+ // Where the title came FROM, not just what it is. A ZIP's title is the
1111
+ // filename — `wiki-lint-v2.zip` publishes a skill called `wiki-lint-v2`,
1112
+ // which is a DIFFERENT skill from `wiki-lint` and is not refused, because
1113
+ // display_title uniqueness is enforced only when the field is sent. A
1114
+ // directory takes its title from SKILL.md instead, so the same tree zipped
1115
+ // and unzipped can publish under two names.
1116
+ //
1117
+ // ⛔ This comment used to end "VAT cannot read the SKILL.md inside the
1118
+ // archive: Node ships no ZIP reader, and adding a dependency to parse one
1119
+ // here would mean owning central-directory, zip64 and zip-slip handling for
1120
+ // a log line." The PREMISE was false — `adm-zip` is already a runtime
1121
+ // dependency of this package — and the cost/benefit was weighed against the
1122
+ // wrong benefit: the same read also answers whether the archive EXPANDS
1123
+ // past the ceiling, which is a refusal, not a log line. So the archive is
1124
+ // now read (see {@link inspectZipArchive}) and the divergence is reported
1125
+ // as a fact below, in addition to this provenance line.
1126
+ logger.info(`Display title: "${displayTitle}" (${titleOverride === undefined
1127
+ ? 'from the ZIP filename — NOT from the SKILL.md inside it; pass --title to set it'
1128
+ : 'from --title'})`);
1129
+ const inspected = await inspectZipArchive(sourcePath);
1130
+ if (inspected !== undefined) {
1131
+ // 🚨 REFUSED, not warned. The directory lane refuses an answer key
1132
+ // unconditionally (`NEVER_UPLOADED_DIR_NAMES`, plus whatever the config
1133
+ // declares), and until this existed the ZIP lane bypassed `collectFiles`
1134
+ // entirely and posted the archive as one opaque part — so
1135
+ // `zip -r my-skill.zip my-skill/` over a tree holding
1136
+ // `my-skill/evals/evals.json` published the answer key org-wide with a
1137
+ // green tick, while `install my-skill/` on the very same tree refused.
1138
+ // Two lanes of one command must not disagree about whether a skill's
1139
+ // answer key may be published, and a warning is a disagreement.
1140
+ //
1141
+ // This lane can only see the CONVENTIONAL names: there is no skill
1142
+ // directory to walk up from, so a config-declared suite location is
1143
+ // unreadable here. That is a narrower check than the directory lane's,
1144
+ // not a looser verdict on what it does see.
1145
+ if (inspected.neverUploaded.length > 0) {
1146
+ throw new Error(`This ZIP contains ${String(inspected.neverUploaded.length)} entr`
1147
+ + `${inspected.neverUploaded.length === 1 ? 'y' : 'ies'} that are never published with a `
1148
+ + `skill: ${inspected.neverUploaded.slice(0, 10).join(', ')}`
1149
+ + `${inspected.neverUploaded.length > 10 ? ', …' : ''}. An eval suite is the skill's ANSWER `
1150
+ + `KEY and this would publish it to everyone in the organization. Rebuild the archive from `
1151
+ + `the BUILT skill directory (\`vat skills build\`), or upload that directory directly — `
1152
+ + `\`install <dir>\` withholds these and reports each one.`);
1153
+ }
1154
+ if (inspected.uncompressedBytes > API_SKILL_MAX_UPLOAD_BYTES) {
1155
+ throw new Error(`ZIP expands to ${formatBytes(inspected.uncompressedBytes)}, over the `
1156
+ + `${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} ceiling. The API expands the archive and `
1157
+ + `weighs the UNCOMPRESSED total, so ${formatBytes(zipContent.length)} on the wire does not `
1158
+ + `get this under the limit — it will refuse with "Zip file uncompressed size exceeds 30MB". `
1159
+ + `Remove files from the bundle; compressing harder cannot help.`);
1160
+ }
1161
+ // The title/name divergence this branch discloses above, now stated as a
1162
+ // FACT rather than as a caveat, because the archive can be read after
1163
+ // all. A ZIP whose inner SKILL.md declares a different name publishes a
1164
+ // skill whose title and versions disagree — and that divergence is what
1165
+ // makes a title-keyed lookup unsafe, so it is worth naming at the moment
1166
+ // it is minted rather than diagnosing later.
1167
+ if (inspected.declaredName !== undefined && inspected.declaredName !== displayTitle) {
1168
+ logger.warn(`Title/name divergence: this uploads with display title "${displayTitle}", but the `
1169
+ + `SKILL.md inside declares name "${inspected.declaredName}". Every version will carry `
1170
+ + `the declared name. Pass --title "${inspected.declaredName}" to make them agree.`);
1171
+ }
1172
+ }
1173
+ return sendSkillUpload(client, displayTitle, files);
1174
+ }
290
1175
  // ── Commands ───────────────────────────────────────────────────────────
291
1176
  export function createOrgSkillsCommand() {
292
1177
  const command = new Command('skills');
@@ -322,14 +1207,32 @@ Example:
322
1207
  .option('--title <title>', 'Display title override (single skill only)')
323
1208
  .option('--debug', DEBUG_OPT_DESC)
324
1209
  .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
1210
  const commandName = options.fromNpm ? 'OrgSkillsInstallNpm' : 'OrgSkillsInstall';
332
1211
  await executeOrgCommand(commandName, options.debug, async ({ client, logger }) => {
1212
+ // INSIDE the action, like `versions add`'s own guards. Thrown from the
1213
+ // Commander handler instead, these were a floating rejection that
1214
+ // reached no catch: Node printed a raw stack trace with absolute $HOME
1215
+ // paths, wrote nothing to the stdout this command's help promises, and
1216
+ // exited 1 — which the documented contract reads as "at least one
1217
+ // error-severity finding" for a run in which nothing executed.
1218
+ if (!source && !options.fromNpm) {
1219
+ throw new Error('Provide a <source> path or use --from-npm <package>');
1220
+ }
1221
+ if (source && options.fromNpm) {
1222
+ throw new Error('Provide either <source> or --from-npm, not both');
1223
+ }
1224
+ // The other two illegal combinations, refused rather than dropped. Both
1225
+ // flags used to be accepted and silently ignored on the lane that cannot
1226
+ // honour them, which is how an operator gets a skill published under the
1227
+ // wrong title — or every skill in a package published when they named one.
1228
+ if (options.title !== undefined && options.fromNpm) {
1229
+ throw new Error('--title applies to a single skill and --from-npm can publish several, so it is '
1230
+ + 'refused here rather than silently ignored. Publish the one skill with '
1231
+ + '--skill and set its title in its SKILL.md, or upload its directory directly.');
1232
+ }
1233
+ if (options.skill !== undefined && !options.fromNpm) {
1234
+ throw new Error('--skill selects one skill inside an npm package; it applies only with --from-npm');
1235
+ }
333
1236
  if (options.fromNpm) {
334
1237
  return installFromNpm(options.fromNpm, options.skill, client, logger);
335
1238
  }
@@ -342,14 +1245,59 @@ Description:
342
1245
  Accepts a built skill directory, a ZIP file, or an npm package.
343
1246
  Requires ANTHROPIC_API_KEY (regular key, not admin key).
344
1247
 
345
- The skill uploads under the "name" its SKILL.md frontmatter declares, which
1248
+ A DIRECTORY uploads under the "name" its SKILL.md frontmatter declares, which
346
1249
  is also the default display_title.
347
1250
 
1251
+ A ZIP takes its display_title from the ZIP FILENAME, so my-skill-v2.zip
1252
+ publishes a skill titled "my-skill-v2", which is a separate skill from
1253
+ "my-skill" and is not refused as a duplicate. VAT reads the SKILL.md nearest
1254
+ the root of the archive and WARNS when the name it declares differs from that
1255
+ title, because every version the skill ever gets will carry the declared name.
1256
+ Pass --title to set the title explicitly.
1257
+
348
1258
  A skill's eval suite is its answer key and is never uploaded: whatever the
349
1259
  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.
1260
+ (skills.config.<name>.test.evals) is withheld, and so is ANY directory named
1261
+ evals/ — unconditionally, whether or not a config was found. node_modules/ and
1262
+ .git/ are never uploaded either. Each exclusion is reported in the output.
1263
+
1264
+ A ZIP is REFUSED outright when it contains any of those three directory names,
1265
+ rather than having them withheld: the archive is posted as one part, so there
1266
+ is nothing to strip out of it. Rebuild it from the built skill directory, or
1267
+ upload that directory. (A config-declared suite in some other location is
1268
+ invisible to this lane — there is no directory to resolve a config from.)
1269
+
1270
+ Those same three directory names are also left out of the bytes weighed against
1271
+ the ceiling below, and out of the build-time PACKAGED_SIZE_EXCEEDS_API_LIMIT
1272
+ total, so \`du -sb\` on the directory will report more than VAT does.
1273
+
1274
+ A symbolic link is refused rather than followed, whatever it points at: a
1275
+ multipart body cannot express one, and reading through it would publish the
1276
+ target's bytes under an in-bundle name.
1277
+
1278
+ Before anything is sent, every markdown document in the bundle is run through
1279
+ the non-portable reference, non-portable command, and unqualified MCP tool-name
1280
+ checks — the ones that catch a skill referencing a path outside its own tree,
1281
+ which cannot resolve once the API publishes it alone. These WARN and never
1282
+ block, and \`validation.allow\` / \`validation.severity\` from the governing
1283
+ config are honoured, so a waiver that makes \`vat skills build\` green makes
1284
+ this quiet too. Run \`vat audit <dir>\` for the full report.
1285
+
1286
+ There are TWO size gates and a ZIP faces both. The REQUEST gate weighs the
1287
+ multipart body — file bytes plus about 156 bytes of framing per file — against
1288
+ the API's ${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} ceiling; it is inclusive,
1289
+ measured: a body of exactly ${API_SKILL_MAX_UPLOAD_BYTES.toLocaleString('en-US')}
1290
+ bytes is accepted and one byte more is refused 413. A directory and a ZIP both
1291
+ go through it. The API then EXPANDS a ZIP and applies the same ceiling to the
1292
+ uncompressed total, so an archive far under the request ceiling can still be
1293
+ refused — VAT reads the archive's own headers and refuses first, naming the
1294
+ expanded size, because compressing harder cannot get it under the limit.
1295
+
1296
+ Exit Codes:
1297
+ 0 - Every skill uploaded
1298
+ 1 - The run completed and at least one skill failed to upload (--from-npm
1299
+ uploads several; the ones that landed are listed under skills)
1300
+ 2 - The run could not happen: no API key, no such source, unusable input
353
1301
 
354
1302
  Examples:
355
1303
  $ vat claude org skills install dist/skills/org-admin
@@ -366,27 +1314,92 @@ Examples:
366
1314
  .option('--debug', DEBUG_OPT_DESC)
367
1315
  .action(async (skillId, options) => {
368
1316
  await executeOrgCommand('OrgSkillsDelete', options.debug, async ({ client, logger }) => {
1317
+ // Every version this run actually destroyed, accumulated as it goes.
1318
+ //
1319
+ // 🔑 It is a RECORD, not a progress counter. Version deletion is
1320
+ // irreversible and `--all` does it in a loop, so a failure part-way
1321
+ // through leaves a workspace nobody can reconstruct — and the old code
1322
+ // threw at that point, which ends on `handleCommandError`'s exit 2
1323
+ // ("the run could not happen") and DISCARDS the document. It happened,
1324
+ // and it destroyed things the operator now has no list of. So the list
1325
+ // travels with the failure, and the run ends 1: it happened, and its
1326
+ // outcome is wrong.
1327
+ const deletedVersions = [];
1328
+ const halfDeleted = (error) => orgCommandFailure({
1329
+ id: skillId,
1330
+ deleted: false,
1331
+ deletedVersions,
1332
+ error: error instanceof Error ? error.message : String(error),
1333
+ note: `${String(deletedVersions.length)} version(s) of ${skillId} were deleted before this `
1334
+ + 'failed and cannot be restored. The skill itself still exists. Re-run '
1335
+ + '`--all` once the cause is cleared; the versions listed here are already gone.',
1336
+ });
369
1337
  if (options.all) {
370
- // Fetch and delete all versions first
371
- const versions = await autopaginateSkills(client, `/v1/skills/${encodeURIComponent(skillId)}/versions`);
1338
+ const versions = await autopaginateSkills(client, skillVersionsPath(skillId));
372
1339
  const versionData = versions.data;
373
- logger.info(`Deleting ${versionData.length} version(s) of ${skillId}`);
1340
+ // "Found …", not "Deleting …": nothing has been deleted at this point
1341
+ // and the very next call can be refused. Cf. `installFromLocal`.
1342
+ logger.info(`Found ${String(versionData.length)} version(s) of ${skillId} to delete first`);
374
1343
  for (const ver of versionData) {
375
- await client.deleteSkillVersion(skillId, ver.version);
1344
+ try {
1345
+ await client.deleteSkillVersion(skillId, ver.version);
1346
+ }
1347
+ catch (error) {
1348
+ // Only after at least one version is gone is there a report worth
1349
+ // publishing. Before that nothing happened, and "could not happen"
1350
+ // (exit 2, via the throw) is the honest ending.
1351
+ if (deletedVersions.length === 0)
1352
+ throw error;
1353
+ logger.info(` ⚠ version ${ver.version} was not deleted`);
1354
+ return halfDeleted(error);
1355
+ }
1356
+ deletedVersions.push(ver.version);
376
1357
  logger.info(` Deleted version ${ver.version}`);
377
1358
  }
378
1359
  }
379
1360
  logger.info(`Deleting skill: ${skillId}`);
380
- const result = await client.deleteSkill(skillId);
381
- return { id: result.id, deleted: result.type === 'skill_deleted' };
1361
+ // The vendor's refusal here names no VAT command — measured live as
1362
+ // `400 Cannot delete skill with existing versions. Delete all versions
1363
+ // first.` — so the one that answers it is appended, unless this run
1364
+ // already deleted them.
1365
+ let raw;
1366
+ try {
1367
+ raw = await deleteSkillOrExplain(client, skillId, options.all === true ? DELETE_REMEDIES_AFTER_ALL : DELETE_REMEDIES);
1368
+ }
1369
+ catch (error) {
1370
+ if (deletedVersions.length === 0)
1371
+ throw error;
1372
+ return halfDeleted(error);
1373
+ }
1374
+ const deleted = reportDelete(raw, skillId, SKILL_DELETED_TYPES);
1375
+ return deletedVersions.length === 0
1376
+ ? deleted
1377
+ // The versions are part of what this run DID, so they belong in the
1378
+ // document either way — and the failure tag has to survive being
1379
+ // merged with them.
1380
+ : mergeDeleteReport(deleted, deletedVersions);
382
1381
  });
383
1382
  })
384
1383
  .addHelpText('after', `
385
1384
  Description:
386
1385
  Deletes a skill from the organization. Uses the Skills API (beta).
387
- Use --all to auto-delete all versions before the skill.
388
1386
  Requires ANTHROPIC_API_KEY (regular key, not admin key).
389
1387
 
1388
+ The API refuses to delete a skill that still has versions (400). Use --all to
1389
+ delete every version and then the skill in one command; without it, delete the
1390
+ versions yourself with \`vat claude org skills versions delete\` first.
1391
+
1392
+ --all deletes irreversibly and in a loop. If it fails part-way through, the
1393
+ versions it already destroyed are listed under deletedVersions in the output
1394
+ and the run exits 1 — the run happened, and its outcome is wrong. It exits 2
1395
+ only when nothing was deleted at all.
1396
+
1397
+ Exit Codes:
1398
+ 0 - The skill was deleted
1399
+ 1 - The run completed and the skill was NOT deleted: the API named a different
1400
+ outcome, or --all destroyed some versions and then failed
1401
+ 2 - The run could not happen: no API key, or the first request was refused
1402
+
390
1403
  Example:
391
1404
  $ vat claude org skills delete skill_abc123 --all
392
1405
  `);
@@ -400,7 +1413,7 @@ Example:
400
1413
  .option('--debug', DEBUG_OPT_DESC)
401
1414
  .action(async (skillId, options) => {
402
1415
  await executeOrgCommand('OrgSkillsVersionsList', options.debug, async ({ client }) => {
403
- return autopaginateSkills(client, `/v1/skills/${encodeURIComponent(skillId)}/versions`);
1416
+ return autopaginateSkills(client, skillVersionsPath(skillId));
404
1417
  });
405
1418
  })
406
1419
  .addHelpText('after', `
@@ -420,8 +1433,7 @@ Example:
420
1433
  .action(async (skillId, version, options) => {
421
1434
  await executeOrgCommand('OrgSkillsVersionsDelete', options.debug, async ({ client, logger }) => {
422
1435
  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' };
1436
+ return reportVersionDelete(await client.deleteSkillVersion(skillId, version), skillId, version);
425
1437
  });
426
1438
  })
427
1439
  .addHelpText('after', `
@@ -430,10 +1442,96 @@ Description:
430
1442
  All versions must be deleted before a skill can be deleted.
431
1443
  Requires ANTHROPIC_API_KEY.
432
1444
 
1445
+ Output:
1446
+ - id: the SKILL this version belonged to (always the id you passed)
1447
+ - version: the version that was destroyed (always the one you passed)
1448
+ - deleted: whether the API confirmed the deletion
1449
+
1450
+ Exit Codes:
1451
+ 0 - The version was deleted
1452
+ 1 - The run completed and the API named an outcome other than a deletion
1453
+ 2 - The run could not happen: no API key, or the request was refused
1454
+
433
1455
  Example:
434
1456
  $ vat claude org skills versions delete skill_abc123 1775007400733130
1457
+ `);
1458
+ const versionsAddCmd = new Command('add');
1459
+ versionsAddCmd
1460
+ .description('Publish a new version of an existing skill')
1461
+ .argument(SKILL_ID_ARG, SKILL_ID_DESC)
1462
+ // NOT "or ZIP file", which is what this said when it was copied from
1463
+ // `install`: the guard below refuses anything that is not a directory, so the
1464
+ // help promised an input the command rejects. Narrowing the promise rather
1465
+ // than widening the code is the honest fix — a ZIP posted to
1466
+ // POST /v1/skills/{id}/versions has never been tried against the live API, and
1467
+ // this is not the place to find out by guessing.
1468
+ .argument('<source>', 'Path to a built skill directory')
1469
+ .option('--debug', DEBUG_OPT_DESC)
1470
+ .action(async (skillId, source, options) => {
1471
+ await executeOrgCommand('OrgSkillsVersionsAdd', options.debug, async ({ client, logger }) => {
1472
+ const resolved = resolveSourceArgument(source);
1473
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
1474
+ if (!existsSync(resolved))
1475
+ throw new Error(`Source not found: ${resolved}`);
1476
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path from CLI arg
1477
+ if (!statSync(resolved).isDirectory()) {
1478
+ // Name the asymmetry IN the refusal, not only in --help. `install`
1479
+ // accepts a ZIP and this verb does not, so the operator most likely to
1480
+ // hit this is the one who just read that `install` takes one — and a
1481
+ // refusal that says only "must be a directory" reads as a bug in their
1482
+ // path, not as a difference between two verbs.
1483
+ const zipHint = resolved.toLowerCase().endsWith('.zip')
1484
+ ? ' A ZIP is accepted by `vat claude org skills install`, not here:'
1485
+ + ' publish a new version from the built skill directory instead.'
1486
+ : '';
1487
+ throw new Error(`Source must be a skill directory: ${resolved}.${zipHint}`);
1488
+ }
1489
+ // "Packaging", not "Publishing" — see `installFromLocal`. Measured: this
1490
+ // line printed, then `Failed to load config: …`, and nothing had been
1491
+ // sent. The announcement now describes the step it actually precedes.
1492
+ logger.info(`Packaging new version of ${skillId} from: ${resolved}`);
1493
+ return uploadSkillVersionDir(client, skillId, resolved, logger);
1494
+ });
1495
+ })
1496
+ .addHelpText('after', `
1497
+ Description:
1498
+ Publishes the contents of a skill directory as a NEW VERSION of an existing
1499
+ skill. Uses the Skills API (beta). Requires ANTHROPIC_API_KEY.
1500
+
1501
+ This is how you ship a change to a skill you have already published. It is a
1502
+ separate command from \`install\` on purpose: \`install\` always creates a new
1503
+ skill, this always adds a version to the skill you name, and neither inspects
1504
+ the workspace to decide which it "should" do. A display title is NOT unique in
1505
+ a workspace, so resolving one to a skill can match none, one, or several — and
1506
+ a wrong match would append your version to somebody else's skill.
1507
+
1508
+ Find the id with \`vat claude org skills list\`. The API assigns the version
1509
+ identifier and makes it the skill's latest; nothing is numbered locally.
1510
+
1511
+ Takes a built skill DIRECTORY. A ZIP is accepted by \`install\`, not here.
1512
+
1513
+ The same exclusions and the same ${formatBytes(API_SKILL_MAX_UPLOAD_BYTES)} request
1514
+ ceiling as \`install\` apply: the eval suite, node_modules/ and .git/ are never
1515
+ uploaded, and a symbolic link is refused rather than followed.
1516
+
1517
+ The uploaded files are keyed under the top-level directory named by this
1518
+ tree's SKILL.md \`name\`, and that root is printed as the upload runs. The API
1519
+ enforces that name against the one this skill already has, so publishing from a
1520
+ renamed tree is refused with a 400 rather than silently re-rooting the version's
1521
+ files. VAT does not check it first — it reports the root it used and lets the
1522
+ server be the authority.
1523
+
1524
+ Exit Codes:
1525
+ 0 - The version was published
1526
+ 2 - The run could not happen: no API key, no such source, not a directory,
1527
+ over the upload ceiling, or a response the version identifier
1528
+ cannot be read from
1529
+
1530
+ Example:
1531
+ $ vat claude org skills versions add skill_abc123 dist/skills/org-admin
435
1532
  `);
436
1533
  versionsCmd.addCommand(versionsListCmd);
1534
+ versionsCmd.addCommand(versionsAddCmd);
437
1535
  versionsCmd.addCommand(versionsDeleteCmd);
438
1536
  command.addCommand(listCmd);
439
1537
  command.addCommand(installCmd);