@botdocs/cli 0.10.3 → 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.
- package/README.md +7 -5
- package/bin/botdocs.cjs +78 -0
- package/dist/commands/check-updates.d.ts +22 -0
- package/dist/commands/check-updates.js +73 -18
- package/dist/commands/edit.js +10 -2
- package/dist/commands/ingest.d.ts +20 -0
- package/dist/commands/ingest.js +264 -11
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +43 -6
- package/dist/commands/install.js +146 -5
- package/dist/commands/list.js +62 -0
- package/dist/commands/login.js +56 -2
- package/dist/commands/publish.d.ts +30 -3
- package/dist/commands/publish.js +353 -40
- package/dist/commands/sync.js +252 -40
- package/dist/commands/uninstall.js +12 -0
- package/dist/commands/validate.js +82 -8
- package/dist/index.js +151 -26
- package/dist/lib/api.d.ts +55 -2
- package/dist/lib/api.js +168 -11
- package/dist/lib/auto-detect.js +70 -30
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +83 -2
- package/dist/lib/ingest-session-client.d.ts +93 -0
- package/dist/lib/ingest-session-client.js +217 -0
- package/dist/lib/lockfile.d.ts +13 -0
- package/dist/lib/manifest.d.ts +12 -0
- package/dist/lib/manifest.js +29 -2
- package/dist/lib/node-preflight.d.ts +20 -0
- package/dist/lib/node-preflight.js +11 -0
- package/dist/lib/skill-caps.d.ts +17 -0
- package/dist/lib/skill-caps.js +19 -0
- package/package.json +3 -2
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
205
|
+
const next = {
|
|
151
206
|
...entry,
|
|
152
|
-
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 {
|
|
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
|
-
|
|
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:
|
|
410
|
+
dispatch({ type: 'ERROR', ref: entry.ref, details: detail });
|
|
287
411
|
continue;
|
|
288
412
|
}
|
|
289
413
|
throw err;
|
|
290
414
|
}
|
|
291
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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);
|