@botdocs/cli 0.10.2 → 0.11.0

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.
@@ -3,14 +3,15 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { render } from 'ink';
6
- import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
6
+ import { ApiError, apiFetch, fetchRawContent, friendlyApiErrorDetail } from '../lib/api.js';
7
7
  import { loadLockfile, fingerprintFile, fingerprintContent, upsertInstall, } from '../lib/lockfile.js';
8
8
  import { renderDiff, hasChanges } from '../lib/diff.js';
9
9
  import { promptConflict, confirmOverwrite, promptCleanUpdate } from '../lib/prompts.js';
10
10
  import { syncLibrary } from '../lib/library-sync.js';
11
11
  import { detectDestination } from '../lib/auto-detect.js';
12
- import { backupDestination, backupFile, isLockfileOwnedAndUnchanged } from '../lib/backup.js';
12
+ import { backupDestination, backupFile, isLockfileOwnedAndUnchanged, } from '../lib/backup.js';
13
13
  import { SyncApp } from './views/sync-app.js';
14
+ import { parseRef } from '../lib/ref.js';
14
15
  /** Backup the existing content at `dest` before sync overwrites it. Skipped
15
16
  * entirely if `--no-backup` is set or the file is "ours and unchanged" per
16
17
  * the lockfile. Under `--dry-run`, prints the would-be backup path but does
@@ -49,13 +50,54 @@ async function syncSkill(entry, manifest, deps) {
49
50
  let updatedCount = 0;
50
51
  let skippedCount = 0;
51
52
  let conflicted = false;
53
+ // Track which file paths actually changed during this sync — surfaced in
54
+ // the live view's "details" so the user can see WHAT updated, not just
55
+ // that something did. Naming sources via `installedFile.src` (the
56
+ // upstream-relative path) is consistent across destinations.
57
+ const updatedFiles = [];
58
+ // Track which file paths were explicitly skipped on conflict so the
59
+ // lockfile entry can record them. Distinct from `updatedFiles` so callers
60
+ // can render "updated: …" and "skipped: …" separately.
61
+ const skippedFiles = [];
52
62
  const newFiles = [];
53
63
  for (const installedFile of entry.files) {
54
64
  const upstream = manifest.files.find((f) => f.filename === installedFile.src);
55
65
  if (!upstream) {
56
- // Upstream removed this file delete the local copy and drop the lockfile entry.
57
- if (fs.existsSync(installedFile.dest))
58
- fs.unlinkSync(installedFile.dest);
66
+ // Upstream removed this file. If the local copy is lockfile-owned-and-
67
+ // unchanged, quietly unlink — we wrote those bytes and the user hasn't
68
+ // touched them, so there's nothing to preserve. If the file has been
69
+ // locally edited (or is untracked at this path), back it up first so
70
+ // the README's "never silently clobber" promise holds for deletions
71
+ // too. --no-backup respects the user's opt-out.
72
+ if (fs.existsSync(installedFile.dest)) {
73
+ const ownedAndClean = isLockfileOwnedAndUnchanged(installedFile.dest);
74
+ if (!ownedAndClean && !options.noBackup) {
75
+ if (options.dryRun) {
76
+ if (!silent && !options.json) {
77
+ const projectDir = process.cwd();
78
+ const wouldDest = backupDestination(installedFile.dest, projectDir);
79
+ const relSrc = path.relative(projectDir, installedFile.dest);
80
+ const relDest = path.relative(projectDir, wouldDest);
81
+ console.log(` ⚠ Would back up ${relSrc} → ${relDest} (upstream removed)`);
82
+ }
83
+ }
84
+ else {
85
+ const result = backupFile(installedFile.dest, process.cwd());
86
+ if (!silent && !options.json) {
87
+ if (result.ok) {
88
+ const relSrc = path.relative(process.cwd(), installedFile.dest);
89
+ const relDest = path.relative(process.cwd(), result.dest);
90
+ console.log(` ⚠ ${relSrc}: backed up to ${relDest} before removing (had local edits)`);
91
+ }
92
+ else {
93
+ console.log(` ⚠ Could not back up ${installedFile.dest}: ${result.error} — proceeding with removal.`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ if (!options.dryRun)
99
+ fs.unlinkSync(installedFile.dest);
100
+ }
59
101
  if (!silent) {
60
102
  console.log(` ⌀ ${entry.ref}: removed ${installedFile.src} (no longer in upstream)`);
61
103
  }
@@ -69,6 +111,7 @@ async function syncSkill(entry, manifest, deps) {
69
111
  const fp = fingerprintFile(installedFile.dest);
70
112
  newFiles.push({ ...installedFile, fingerprint: fp });
71
113
  updatedCount++;
114
+ updatedFiles.push(installedFile.src);
72
115
  continue;
73
116
  }
74
117
  const localContent = fs.readFileSync(installedFile.dest, 'utf-8');
@@ -109,10 +152,12 @@ async function syncSkill(entry, manifest, deps) {
109
152
  const fp = options.dryRun ? installedFile.fingerprint : fingerprintFile(installedFile.dest);
110
153
  newFiles.push({ ...installedFile, fingerprint: fp });
111
154
  updatedCount++;
155
+ updatedFiles.push(installedFile.src);
112
156
  }
113
157
  else {
114
158
  newFiles.push(installedFile);
115
159
  skippedCount++;
160
+ skippedFiles.push(installedFile.src);
116
161
  }
117
162
  continue;
118
163
  }
@@ -128,6 +173,7 @@ async function syncSkill(entry, manifest, deps) {
128
173
  if (choice === 'skip' || choice === 'keep') {
129
174
  newFiles.push(installedFile);
130
175
  skippedCount++;
176
+ skippedFiles.push(installedFile.src);
131
177
  continue;
132
178
  }
133
179
  // 'overwrite': the user accepted. Backup-on-overwrite always runs unless
@@ -139,23 +185,50 @@ async function syncSkill(entry, manifest, deps) {
139
185
  const fp = options.dryRun ? installedFile.fingerprint : fingerprintFile(installedFile.dest);
140
186
  newFiles.push({ ...installedFile, fingerprint: fp });
141
187
  updatedCount++;
188
+ updatedFiles.push(installedFile.src);
142
189
  }
143
190
  if (!options.dryRun) {
144
- // Only bump the lockfile version when no files were skipped — otherwise
145
- // the local copy still represents the old version on at least one file.
146
- const canBumpVersion = skippedCount === 0;
191
+ // Always bump the lockfile to the upstream version when anything moved.
192
+ // Previously we held the entry's version at the old value whenever any
193
+ // file was skipped which caused the same files to be flagged on every
194
+ // subsequent sync (a confusing "still updated" loop). Instead, bump the
195
+ // version and record a `partial: true` marker with the list of skipped
196
+ // file `src`s. The next sync sees the bumped version, only re-resolves
197
+ // files whose upstream content still differs (clean files won't), and
198
+ // surfaces the partial state in the summary line.
147
199
  const filesShrunk = newFiles.length < entry.files.length;
148
- const shouldWrite = updatedCount > 0 || filesShrunk || (canBumpVersion && manifest.version !== entry.version);
200
+ const versionChanged = manifest.version !== entry.version;
201
+ const partialStateChanged = Boolean(entry.partial) !== (skippedCount > 0) ||
202
+ (entry.skippedFiles ?? []).slice().sort().join('|') !== skippedFiles.slice().sort().join('|');
203
+ const shouldWrite = updatedCount > 0 || filesShrunk || versionChanged || partialStateChanged;
149
204
  if (shouldWrite) {
150
- upsertInstall({
205
+ const next = {
151
206
  ...entry,
152
- version: canBumpVersion ? manifest.version : entry.version,
207
+ version: manifest.version,
153
208
  files: newFiles,
154
209
  installedAt: new Date().toISOString(),
155
- });
210
+ };
211
+ if (skippedCount > 0) {
212
+ next.partial = true;
213
+ next.skippedFiles = skippedFiles;
214
+ }
215
+ else {
216
+ // Clean run — strip the markers so a previously-partial entry
217
+ // can be displayed as up-to-date again. Setting to undefined
218
+ // keeps the lockfile JSON small (omitted fields are ignored).
219
+ delete next.partial;
220
+ delete next.skippedFiles;
221
+ }
222
+ upsertInstall(next);
156
223
  }
157
224
  }
158
- return { updated: updatedCount > 0, skipped: skippedCount, conflicted };
225
+ return {
226
+ updated: updatedCount > 0,
227
+ skipped: skippedCount,
228
+ conflicted,
229
+ updatedFiles,
230
+ skippedFiles,
231
+ };
159
232
  }
160
233
  function refToPath(ref) {
161
234
  const cleaned = ref.startsWith('@') ? ref.slice(1) : ref;
@@ -200,6 +273,12 @@ async function installTeamSkill(teamSlug, skill, options, silent) {
200
273
  }
201
274
  catch (err) {
202
275
  if (err instanceof ApiError) {
276
+ // 410 = team-pinned skill was removed by its author. Surface the
277
+ // uninstall hint so the user knows why a previously-installable
278
+ // team skill won't sync anymore.
279
+ if (err.status === 410) {
280
+ console.error(`\n ⌀ ${ref}: removed by author — run \`botdocs uninstall ${ref}\` to clean up local files.\n`);
281
+ }
203
282
  return { ref, status: 'skipped' };
204
283
  }
205
284
  throw err;
@@ -249,6 +328,42 @@ async function installTeamSkill(teamSlug, skill, options, silent) {
249
328
  });
250
329
  return { ref, status: existing ? 'updated' : 'installed' };
251
330
  }
331
+ /** Reconcile a single bundle entry against its upstream manifest. The contained
332
+ * skills are NOT installed here (the personal-sync loop handles those via
333
+ * their own top-level lockfile entries); we only diff the bundle's recorded
334
+ * composition + version against upstream and update the bundle row. */
335
+ async function syncBundle(entry, manifest, options) {
336
+ const upstreamRefs = manifest.skills.map((s) => `@${s.ref.username}/${s.ref.slug}`);
337
+ const upstreamSet = new Set(upstreamRefs);
338
+ const localSet = new Set(entry.skills ?? []);
339
+ const added = upstreamRefs.filter((r) => !localSet.has(r));
340
+ const removed = (entry.skills ?? []).filter((r) => !upstreamSet.has(r));
341
+ const compositionChanged = added.length > 0 || removed.length > 0;
342
+ const versionChanged = entry.version !== manifest.version;
343
+ if (!compositionChanged && !versionChanged) {
344
+ return { status: 'up-to-date', changed: false };
345
+ }
346
+ // Build a human-readable details string. Keep the form predictable for
347
+ // test assertions: "added X, Y" / "removed X" / version-delta when only
348
+ // the version moved. Combine forms with " (...)" wrapping.
349
+ const parts = [];
350
+ if (versionChanged)
351
+ parts.push(`${entry.version} → ${manifest.version}`);
352
+ if (added.length > 0)
353
+ parts.push(`added ${added.join(', ')}`);
354
+ if (removed.length > 0)
355
+ parts.push(`removed ${removed.join(', ')}`);
356
+ const details = parts.join(', ');
357
+ if (!options.dryRun) {
358
+ upsertInstall({
359
+ ...entry,
360
+ version: manifest.version,
361
+ skills: upstreamRefs,
362
+ installedAt: new Date().toISOString(),
363
+ });
364
+ }
365
+ return { status: 'updated', details, changed: true };
366
+ }
252
367
  /** The core sync work, decoupled from presentation. Takes a dispatch + awaiter
253
368
  * pair so the Ink view and the plain-text path can share one loop. The function
254
369
  * is structured so the live view can flicker rows through their states in real
@@ -257,19 +372,11 @@ async function runSyncCore(rawRef, targets, options, deps) {
257
372
  const { dispatch, awaitConflictChoice, silent } = deps;
258
373
  const summary = [];
259
374
  for (const entry of targets) {
260
- if (entry.type !== 'SKILL')
261
- continue;
262
375
  dispatch({ type: 'CHECKING', ref: entry.ref });
263
376
  const { username, slug } = refToPath(entry.ref);
264
377
  let manifest;
265
378
  try {
266
- const resp = await apiFetch(`/api/skills/${username}/${slug}/manifest`);
267
- if (resp.type !== 'SKILL') {
268
- // Bundles inside the sync flow are intentionally skipped — handled at
269
- // install time. Mirror the existing behavior: silently continue.
270
- continue;
271
- }
272
- manifest = resp;
379
+ manifest = await apiFetch(`/api/skills/${username}/${slug}/manifest`);
273
380
  }
274
381
  catch (err) {
275
382
  if (err instanceof ApiError) {
@@ -282,33 +389,111 @@ async function runSyncCore(rawRef, targets, options, deps) {
282
389
  dispatch({ type: 'ERROR', ref: entry.ref, details: 'not authenticated' });
283
390
  process.exit(1);
284
391
  }
392
+ // 410 Gone = author soft-deleted the skill upstream. We get a
393
+ // structured body from the manifest route ({error, code: 'GONE',
394
+ // hint}). Surface the uninstall hint instead of "api error 410" so
395
+ // users know how to recover their machine.
396
+ if (err.status === 410) {
397
+ console.error(`\n ⌀ ${entry.ref}: removed by author — run \`botdocs uninstall ${entry.ref}\` to clean up local files.\n`);
398
+ summary.push({ ref: entry.ref, status: 'skipped' });
399
+ dispatch({ type: 'ERROR', ref: entry.ref, details: 'removed by author' });
400
+ continue;
401
+ }
402
+ // Other 4xx/5xx — render a friendly one-liner per status class so
403
+ // users see "permission denied" / "rate-limited" instead of the bare
404
+ // "api error 403" string. Keep the wording short — it lands in the
405
+ // ERROR row's details column where horizontal space is tight.
406
+ // Passing the ref lets the helper emit `botdocs uninstall <ref>` for
407
+ // 404s — mirrors the 410 hint above.
408
+ const detail = friendlyApiErrorDetail(err, entry.ref);
285
409
  summary.push({ ref: entry.ref, status: 'skipped' });
286
- dispatch({ type: 'ERROR', ref: entry.ref, details: `api error ${err.status}` });
410
+ dispatch({ type: 'ERROR', ref: entry.ref, details: detail });
287
411
  continue;
288
412
  }
289
413
  throw err;
290
414
  }
291
- const { updated, skipped } = await syncSkill(entry, manifest, {
415
+ // Bundle entry: reconcile composition + version against upstream. Contained
416
+ // skills sync via their own top-level lockfile entries, so we only update
417
+ // the bundle row here. A bundle whose lockfile says SKILL but upstream
418
+ // returned BUNDLE (or vice versa) is dropped — that's a republish-as-
419
+ // different-type situation the user has to resolve via uninstall/install.
420
+ if (entry.type === 'BUNDLE') {
421
+ if (manifest.type !== 'BUNDLE') {
422
+ // Upstream changed types — surface as skipped, don't try to apply.
423
+ summary.push({
424
+ ref: entry.ref,
425
+ status: 'skipped',
426
+ details: 'upstream is no longer a bundle — uninstall and reinstall',
427
+ });
428
+ dispatch({ type: 'SKIPPED', ref: entry.ref, details: 'type changed upstream' });
429
+ continue;
430
+ }
431
+ const bundleResult = await syncBundle(entry, manifest, options);
432
+ summary.push({ ref: entry.ref, status: bundleResult.status, details: bundleResult.details });
433
+ if (bundleResult.status === 'updated') {
434
+ dispatch({ type: 'UPDATED', ref: entry.ref, details: bundleResult.details ?? 'updated' });
435
+ }
436
+ else {
437
+ dispatch({ type: 'UP_TO_DATE', ref: entry.ref });
438
+ }
439
+ continue;
440
+ }
441
+ // Skill entry: server may have changed it to a bundle (rare, only via
442
+ // unpublish-and-reupload). Treat as skipped, same recovery as above.
443
+ if (manifest.type !== 'SKILL') {
444
+ summary.push({
445
+ ref: entry.ref,
446
+ status: 'skipped',
447
+ details: 'upstream is now a bundle — uninstall and reinstall',
448
+ });
449
+ dispatch({ type: 'SKIPPED', ref: entry.ref, details: 'type changed upstream' });
450
+ continue;
451
+ }
452
+ const { updated, skipped, updatedFiles, skippedFiles } = await syncSkill(entry, manifest, {
292
453
  options,
293
454
  awaitConflictChoice,
294
455
  silent,
295
456
  });
296
457
  const status = updated ? 'updated' : skipped > 0 ? 'skipped' : 'up-to-date';
297
- summary.push({ ref: entry.ref, status });
298
458
  if (status === 'updated') {
299
- dispatch({
300
- type: 'UPDATED',
301
- ref: entry.ref,
302
- details: entry.version !== manifest.version
303
- ? `${entry.version} ${manifest.version}`
304
- : 'updated',
305
- });
459
+ // Detail prioritization: a version bump is the headline info — show
460
+ // the delta. When content changed but the upstream version didn't
461
+ // (rare, but happens for republishes), surface the file list so the
462
+ // user can see WHAT moved. Cap at 3 explicit filenames before
463
+ // collapsing to a count to keep the line readable. When `partial`
464
+ // (some files updated, others skipped on conflict), append a suffix
465
+ // so the user sees the version bumped AND that conflicts remain.
466
+ let details;
467
+ if (entry.version !== manifest.version) {
468
+ details = `${entry.version} → ${manifest.version}`;
469
+ }
470
+ else if (updatedFiles.length > 0 && updatedFiles.length <= 3) {
471
+ details = `updated: ${updatedFiles.join(', ')}`;
472
+ }
473
+ else if (updatedFiles.length > 0) {
474
+ details = `${updatedFiles.length} file(s) updated`;
475
+ }
476
+ else {
477
+ details = 'updated';
478
+ }
479
+ if (skipped > 0) {
480
+ details += ` (${skipped} file${skipped === 1 ? '' : 's'} skipped — conflict)`;
481
+ }
482
+ summary.push({ ref: entry.ref, status, details });
483
+ dispatch({ type: 'UPDATED', ref: entry.ref, details });
306
484
  }
307
485
  else if (status === 'up-to-date') {
486
+ summary.push({ ref: entry.ref, status });
308
487
  dispatch({ type: 'UP_TO_DATE', ref: entry.ref });
309
488
  }
310
489
  else {
311
- dispatch({ type: 'SKIPPED', ref: entry.ref, details: 'changes skipped' });
490
+ // Pure-skipped: no files moved, 1 conflict. Surface the same
491
+ // shape so the user can see how many were left alone.
492
+ const details = skippedFiles.length > 0 && skippedFiles.length <= 3
493
+ ? `${skippedFiles.length} file${skippedFiles.length === 1 ? '' : 's'} skipped — conflict`
494
+ : 'changes skipped';
495
+ summary.push({ ref: entry.ref, status, details });
496
+ dispatch({ type: 'SKIPPED', ref: entry.ref, details });
312
497
  }
313
498
  }
314
499
  // After per-target sync, also pull team-pinned skills (unless --ref-specific).
@@ -372,10 +557,27 @@ function plainAwaitConflictChoice(ref, file) {
372
557
  })();
373
558
  }
374
559
  export async function sync(rawRef, options) {
560
+ // Validate the ref shape when one was passed. Without this, a typo like
561
+ // `botdocs sync foo` would silently fall through to "nothing installed to
562
+ // sync" (the lockfile filter finds no match), which masks the actual
563
+ // mistake. Validating up front gives the user the same clean ✗ line that
564
+ // install/uninstall use for malformed refs.
565
+ if (rawRef) {
566
+ try {
567
+ parseRef(rawRef);
568
+ }
569
+ catch {
570
+ console.error(`\n ✗ Invalid ref: ${rawRef} — expected @user/slug (e.g. @yourname/my-skill)\n`);
571
+ process.exit(1);
572
+ }
573
+ }
375
574
  const lf = loadLockfile();
575
+ // Pre-P3 this filtered to SKILL only — bundle composition was never
576
+ // reconciled. Now both skill and bundle entries flow through runSyncCore;
577
+ // the loop branches by type to call syncSkill vs syncBundle.
376
578
  const targets = rawRef
377
579
  ? lf.installs.filter((i) => i.ref === rawRef)
378
- : lf.installs.filter((i) => i.type === 'SKILL');
580
+ : lf.installs;
379
581
  const useInk = !options.noInk && Boolean(process.stdout.isTTY) && !options.json;
380
582
  if (targets.length === 0 && !rawRef) {
381
583
  // Even with nothing installed, pull team-pinned skills.
@@ -405,7 +607,11 @@ export async function sync(rawRef, options) {
405
607
  return;
406
608
  }
407
609
  if (targets.length === 0) {
408
- console.log('\n nothing installed to sync\n');
610
+ // rawRef was provided but no lockfile entry matches it — distinguish
611
+ // from the no-arg case so the user sees the actual problem ("you asked
612
+ // to sync something you don't have installed") instead of the generic
613
+ // "nothing installed".
614
+ console.log(`\n ${rawRef} isn't installed. Run \`botdocs list\` to see what is.\n`);
409
615
  await syncLibrary();
410
616
  return;
411
617
  }
@@ -451,12 +657,18 @@ export async function sync(rawRef, options) {
451
657
  });
452
658
  console.log('');
453
659
  for (const s of summary) {
454
- if (s.status === 'up-to-date')
660
+ if (s.status === 'up-to-date') {
455
661
  console.log(` ✓ ${s.ref}: up to date`);
456
- else if (s.status === 'updated')
457
- console.log(` ✓ ${s.ref}: updated`);
458
- else
459
- console.log(` ⌀ ${s.ref}: changes skipped`);
662
+ }
663
+ else if (s.status === 'updated') {
664
+ // Surface the same details string the Ink view shows — either the
665
+ // version delta or the per-file change list — so users on the plain
666
+ // path can also see WHAT updated.
667
+ console.log(` ✓ ${s.ref}: ${s.details ?? 'updated'}`);
668
+ }
669
+ else {
670
+ console.log(` ⌀ ${s.ref}: ${s.details ?? 'changes skipped'}`);
671
+ }
460
672
  }
461
673
  for (const r of teamResults) {
462
674
  if (r.status === 'installed')
@@ -1,7 +1,19 @@
1
1
  import fs from 'node:fs';
2
2
  import { loadLockfile, removeInstall } from '../lib/lockfile.js';
3
3
  import { syncLibrary } from '../lib/library-sync.js';
4
+ import { parseRef } from '../lib/ref.js';
4
5
  export async function uninstall(rawRef, options) {
6
+ // Validate ref shape up front so a typo (`botdocs uninstall foo`) prints a
7
+ // friendly error rather than slipping through to the lockfile lookup and
8
+ // bottoming out at the generic "Not installed" message — which would be
9
+ // misleading because the ref is malformed, not just missing.
10
+ try {
11
+ parseRef(rawRef);
12
+ }
13
+ catch {
14
+ console.error(`\n ✗ Invalid ref: ${rawRef} — expected @user/slug (e.g. @yourname/my-skill)\n`);
15
+ process.exit(1);
16
+ }
5
17
  const lf = loadLockfile();
6
18
  const entry = lf.installs.find((i) => i.ref === rawRef);
7
19
  if (!entry) {
@@ -1,6 +1,33 @@
1
1
  import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
2
- import { join, extname } from 'path';
2
+ import { join, relative, sep } from 'path';
3
3
  import { parseManifest, ManifestError } from '../lib/manifest.js';
4
+ import { stripFrontmatter } from '../lib/convert.js';
5
+ import { PER_FILE_BYTE_CAP, PER_SKILL_BYTE_CAP, PER_SKILL_FILE_CAP, formatKB, } from '../lib/skill-caps.js';
6
+ /** Recursively collect markdown files under `dir`, returning POSIX-style paths
7
+ * relative to `dir`. Skips dotfiles/dotdirs (same rule publish.ts uses). */
8
+ function collectMarkdownFiles(dir) {
9
+ const out = [];
10
+ walk(dir, dir, out);
11
+ return out;
12
+ }
13
+ function walk(rootDir, currentDir, out) {
14
+ for (const entry of readdirSync(currentDir)) {
15
+ if (entry.startsWith('.'))
16
+ continue;
17
+ const fullPath = join(currentDir, entry);
18
+ const stat = statSync(fullPath);
19
+ if (stat.isDirectory()) {
20
+ walk(rootDir, fullPath, out);
21
+ continue;
22
+ }
23
+ if (!stat.isFile())
24
+ continue;
25
+ if (!entry.endsWith('.md') && !entry.endsWith('.markdown'))
26
+ continue;
27
+ // POSIX-style relative path so cross-platform output is consistent.
28
+ out.push(relative(rootDir, fullPath).split(sep).join('/'));
29
+ }
30
+ }
4
31
  export async function validate(source, options) {
5
32
  const errors = [];
6
33
  const stat = statSync(source, { throwIfNoEntry: false });
@@ -12,13 +39,14 @@ export async function validate(source, options) {
12
39
  let baseDir;
13
40
  if (stat.isDirectory()) {
14
41
  baseDir = source;
15
- files = readdirSync(source).filter((f) => (extname(f) === '.md' || extname(f) === '.markdown') && statSync(join(source, f)).isFile());
42
+ // Recurse so canonical layouts (`claude/SKILL.md`,
43
+ // `claude-code/commands/<slug>.md`) and other nested skill structures
44
+ // count. Top-level-only would miss the canonical scaffold and report
45
+ // "No markdown files found" on a freshly-initialized skill.
46
+ files = collectMarkdownFiles(source);
16
47
  if (files.length === 0) {
17
48
  errors.push({ file: source, message: 'No markdown files found', severity: 'error' });
18
49
  }
19
- if (!files.includes('index.md')) {
20
- errors.push({ file: 'index.md', message: 'Missing index.md (required entry point)', severity: 'error' });
21
- }
22
50
  if (existsSync(join(source, 'botdocs.json'))) {
23
51
  let raw;
24
52
  try {
@@ -32,7 +60,11 @@ export async function validate(source, options) {
32
60
  try {
33
61
  const parsed = parseManifest(raw);
34
62
  if (!parsed.description) {
35
- errors.push({ file: 'botdocs.json', message: 'Missing description', severity: 'warning' });
63
+ // Upgraded from warning error so authors hit this at
64
+ // `validate` time instead of being surprised by a hard
65
+ // error from `publish` later. Publish requires a
66
+ // description; validate must mirror that contract.
67
+ errors.push({ file: 'botdocs.json', message: 'Missing description', severity: 'error' });
36
68
  }
37
69
  }
38
70
  catch (err) {
@@ -53,18 +85,60 @@ export async function validate(source, options) {
53
85
  baseDir = '.';
54
86
  files = [source];
55
87
  }
88
+ // Enforce server-side caps locally so authors hit "your skill is too big"
89
+ // at `validate` time instead of getting a 413 back from publish. Cap
90
+ // constants live in lib/skill-caps.ts and MUST stay in sync with
91
+ // apps/web/src/lib/skill-caps.ts.
92
+ let totalBytes = 0;
56
93
  for (const file of files) {
57
94
  const filePath = stat.isDirectory() ? join(baseDir, file) : file;
58
95
  const content = readFileSync(filePath, 'utf-8');
96
+ const fileBytes = Buffer.byteLength(content, 'utf-8');
97
+ totalBytes += fileBytes;
98
+ if (fileBytes > PER_FILE_BYTE_CAP) {
99
+ errors.push({
100
+ file,
101
+ message: `File is ${formatKB(fileBytes)} (cap: ${formatKB(PER_FILE_BYTE_CAP)} per file)`,
102
+ severity: 'error',
103
+ });
104
+ }
59
105
  if (content.trim().length === 0) {
60
106
  errors.push({ file, message: 'File is empty', severity: 'error' });
61
107
  continue;
62
108
  }
109
+ // Strip YAML frontmatter before measuring body. A SKILL.md with a 200-
110
+ // char frontmatter block and nothing under it would otherwise pass
111
+ // length checks while shipping no actual content. Catching it here
112
+ // turns a silent registry pollution into a friendly local error.
113
+ const body = stripFrontmatter(content).trim();
114
+ if (body.length <= 100) {
115
+ errors.push({
116
+ file,
117
+ message: 'Skill body is empty after frontmatter — add content under the frontmatter.',
118
+ severity: 'error',
119
+ });
120
+ continue;
121
+ }
63
122
  if (!content.match(/^#\s+/m)) {
64
123
  errors.push({ file, message: 'No markdown heading found', severity: 'warning' });
65
124
  }
66
- if (content.length < 100) {
67
- errors.push({ file, message: 'Content is very short (< 100 chars)', severity: 'warning' });
125
+ }
126
+ // Aggregate caps: total skill size + file count. Only meaningful in
127
+ // directory mode — a single-file invocation is checked file-by-file above.
128
+ if (stat.isDirectory()) {
129
+ if (files.length > PER_SKILL_FILE_CAP) {
130
+ errors.push({
131
+ file: source,
132
+ message: `Skill has ${files.length} files (cap: ${PER_SKILL_FILE_CAP} per skill)`,
133
+ severity: 'error',
134
+ });
135
+ }
136
+ if (totalBytes > PER_SKILL_BYTE_CAP) {
137
+ errors.push({
138
+ file: source,
139
+ message: `Skill total size is ${formatKB(totalBytes)} (cap: ${formatKB(PER_SKILL_BYTE_CAP)} per skill)`,
140
+ severity: 'error',
141
+ });
68
142
  }
69
143
  }
70
144
  outputResults(errors, options.json);