@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.
@@ -1,11 +1,51 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import AdmZip from 'adm-zip';
4
- import { ApiError, apiFetch } from '../lib/api.js';
4
+ import * as p from '@clack/prompts';
5
+ import { ApiError, apiFetch, getApiUrl, friendlyApiErrorDetail } from '../lib/api.js';
5
6
  import { parseManifest } from '../lib/manifest.js';
6
7
  import { compile } from './compile.js';
7
8
  import { ecosystemDestination } from '../lib/canonical.js';
8
9
  import { isRefForm, parseRef } from '../lib/ref.js';
10
+ import { loadAuth } from '../lib/config.js';
11
+ /** Bail out early when the user isn't logged in. Without this, publish runs
12
+ * through file collection, manifest parsing, and the description prompt
13
+ * before the POST hits a 401 — wasting input and surfacing a confusing
14
+ * "Authentication failed" message at the end. Mirror whoami.ts's preflight:
15
+ * one friendly line, exit 1. JSON-mode emits the same structured payload
16
+ * other publish auth errors do so scripts can branch on it. */
17
+ function requireAuth(options) {
18
+ if (loadAuth())
19
+ return;
20
+ if (options.json) {
21
+ console.log(JSON.stringify({ ok: false, error: 'not logged in', hint: 'Run `botdocs login` first.' }));
22
+ }
23
+ else {
24
+ console.error('\n ✗ Not logged in. Run `botdocs login` first.\n');
25
+ }
26
+ process.exit(1);
27
+ }
28
+ /** Canonical-skill primary-file heuristic — mirrors the server-side
29
+ * `pickPrimaryFile` in `apps/web/src/app/api/cli/ingest/route.ts`. Used to
30
+ * answer "does this multi-file payload have something we can publish?"
31
+ * without requiring a literal `index.md`. */
32
+ function hasPublishablePrimary(files) {
33
+ if (files.length === 0)
34
+ return false;
35
+ const basename = (filename) => {
36
+ const idx = filename.lastIndexOf('/');
37
+ return idx === -1 ? filename : filename.slice(idx + 1);
38
+ };
39
+ if (files.some((f) => /\/(SKILL|AGENT)\.md$/.test(f.filename)))
40
+ return true;
41
+ if (files.some((f) => basename(f.filename) === 'SKILL.md' || basename(f.filename) === 'AGENT.md'))
42
+ return true;
43
+ if (files.some((f) => f.filename === 'index.md'))
44
+ return true;
45
+ if (files.some((f) => f.filename.endsWith('.md') || f.filename.endsWith('.mdc')))
46
+ return true;
47
+ return false;
48
+ }
9
49
  const VALID_CATEGORIES = [
10
50
  'KNOWLEDGE_MANAGEMENT',
11
51
  'DEV_WORKFLOW',
@@ -16,6 +56,10 @@ const VALID_CATEGORIES = [
16
56
  ];
17
57
  const VALID_LICENSES = ['MIT', 'CC_BY_4_0', 'CC_BY_SA_4_0', 'CC0', 'ALL_RIGHTS_RESERVED'];
18
58
  export async function publish(source, options) {
59
+ // Preflight: bail before any file reading / description prompting if the
60
+ // user isn't logged in. The 401 would otherwise only surface after the POST,
61
+ // making large publishes feel slow and confusing.
62
+ requireAuth(options);
19
63
  // Ref-form (e.g. "@user/slug" or "user/slug") → toggle the published
20
64
  // flag via the API. Path-form continues to the existing upload flow
21
65
  // below. Overloading by argument shape (rather than introducing
@@ -31,7 +75,19 @@ export async function publish(source, options) {
31
75
  console.error(`Source not found: ${source}`);
32
76
  process.exit(1);
33
77
  }
34
- // Determine source type and collect files
78
+ // Determine source type and collect files. `lstatSync` reports the path's
79
+ // OWN type rather than its target's, so we can refuse a symlinked entry
80
+ // point. Without this, `botdocs publish ./leak.md` where `leak.md` is a
81
+ // symlink to e.g. `~/.ssh/id_rsa` would read the target into the publish
82
+ // payload — the same exfil class the walker fix closes for nested
83
+ // entries. We reject (fatal) rather than warn-and-skip: the user
84
+ // explicitly named this path, so silently doing nothing would be more
85
+ // confusing than the error.
86
+ const lstat = fs.lstatSync(resolved);
87
+ if (lstat.isSymbolicLink()) {
88
+ console.error(`Refusing to publish from a symlink: ${source}. Pass the resolved real path instead.`);
89
+ process.exit(1);
90
+ }
35
91
  let files;
36
92
  const stat = fs.statSync(resolved);
37
93
  if (stat.isDirectory()) {
@@ -49,21 +105,32 @@ export async function publish(source, options) {
49
105
  console.error('No markdown files found.');
50
106
  process.exit(1);
51
107
  }
52
- // Ensure index.md exists
53
- const hasIndex = files.some((f) => f.filename === 'index.md');
54
- if (!hasIndex) {
55
- // For single file, rename to index.md
108
+ // Canonical skill layouts (`claude/SKILL.md`, `claude-code/commands/<slug>.md`,
109
+ // top-level `<slug>.md`/`.mdc`) don't have a literal `index.md`. Require
110
+ // "at least one publishable markdown file" instead — matching the
111
+ // server-side `pickPrimaryFile` heuristic and the repo CLAUDE.md guidance.
112
+ if (!hasPublishablePrimary(files)) {
56
113
  if (files.length === 1) {
114
+ // Single file with an unusual extension — rename it so the server
115
+ // can still resolve a primary file. Preserves the old single-file
116
+ // ergonomics without re-introducing the hard `index.md` requirement.
57
117
  files[0].filename = 'index.md';
58
118
  }
59
119
  else {
60
- console.error('Multi-file BotDocs must include an index.md file.');
120
+ console.error('No publishable markdown found (expected SKILL.md, AGENT.md, <slug>.md/.mdc, index.md, or any .md).');
61
121
  process.exit(1);
62
122
  }
63
123
  }
124
+ // Pull type + sourceEcosystem from botdocs.json so the server stores
125
+ // the row as a SKILL/BUNDLE (instead of defaulting to SPEC).
126
+ const manifest = stat.isDirectory() ? readManifest(resolved) : null;
64
127
  // Resolve metadata from flags
65
128
  const title = options.title || deriveTitle(source, files);
66
- const description = options.description || '';
129
+ // Description resolution: --description flag > botdocs.json `description` >
130
+ // empty. The manifest fallback lets `botdocs publish .` work without
131
+ // forcing the author to repeat the flag every time once they've put the
132
+ // description in their manifest.
133
+ const description = options.description || manifest?.description || '';
67
134
  const category = resolveCategory(options.category);
68
135
  const tags = options.tags
69
136
  ? options.tags
@@ -72,37 +139,168 @@ export async function publish(source, options) {
72
139
  .filter(Boolean)
73
140
  : [];
74
141
  const license = resolveLicense(options.license);
142
+ const botdocType = manifest?.type ?? 'SPEC';
143
+ const sourceEcosystem = manifest?.sourceEcosystem ?? null;
144
+ // --dry-run: print what would be published and exit. Validates everything
145
+ // up to (but not including) the POST so authors can sanity-check the
146
+ // payload — title resolution, description fallback, file list, total size
147
+ // vs cap — before committing. Mirrors the file/total caps in
148
+ // apps/web/src/lib/skill-caps.ts (64KB per file, 512KB total) so the
149
+ // preview reflects what the server would enforce.
150
+ //
151
+ // Description is intentionally NOT required in dry-run: authors run
152
+ // `--dry-run` to preview the payload BEFORE filling in metadata. Surface
153
+ // the missing field as a placeholder in the preview rather than aborting.
154
+ if (options.dryRun) {
155
+ const previewDescription = description || '<missing — fill in before publishing>';
156
+ printDryRun({ title, description: previewDescription, files, sourceEcosystem, source }, options);
157
+ return;
158
+ }
75
159
  if (!description) {
76
- console.error('Description is required. Use --description "..."');
160
+ console.error('Description is required. Use --description "..." or set "description" in botdocs.json.');
77
161
  process.exit(1);
78
162
  }
79
- // Pull type + sourceEcosystem from botdocs.json so the server stores
80
- // the row as a SKILL/BUNDLE (instead of defaulting to SPEC).
81
- const manifest = stat.isDirectory() ? readManifest(resolved) : null;
82
- const botdocType = manifest?.type ?? 'SPEC';
83
- const sourceEcosystem = manifest?.sourceEcosystem ?? null;
163
+ // Pre-POST confirmation. Publish is immediate and pushes the skill to the
164
+ // public registry first-time authors don't always know that, so we ask
165
+ // y/N (defaulting to N) before sending. Skipped under --yes (scripted
166
+ // runs that opt in) and --json (non-interactive consumers, which also
167
+ // gives back-compat for callers that don't have a TTY).
168
+ if (!options.yes && !options.json) {
169
+ const auth = loadAuth();
170
+ // requireAuth() ran first, so auth is non-null here. The fallback is
171
+ // defensive in case of a future refactor.
172
+ const username = auth?.username ?? 'me';
173
+ const slugGuess = derivePublishSlug(source);
174
+ const confirmed = await p.confirm({
175
+ message: `Publish to public registry as @${username}/${slugGuess}?`,
176
+ initialValue: false,
177
+ });
178
+ if (p.isCancel(confirmed) || confirmed !== true) {
179
+ console.log(' Publish cancelled.');
180
+ return;
181
+ }
182
+ }
84
183
  console.log(`Publishing "${title}" (${files.length} file(s))...`);
85
- const result = await apiFetch('/api/botdocs', {
86
- method: 'POST',
87
- auth: true,
88
- body: {
89
- title,
90
- description,
91
- category,
92
- tags,
93
- license,
94
- files,
95
- botdocType,
96
- sourceEcosystem,
97
- },
98
- });
184
+ let result;
185
+ try {
186
+ result = await apiFetch('/api/botdocs', {
187
+ method: 'POST',
188
+ auth: true,
189
+ body: {
190
+ title,
191
+ description,
192
+ category,
193
+ tags,
194
+ license,
195
+ files,
196
+ botdocType,
197
+ sourceEcosystem,
198
+ },
199
+ });
200
+ }
201
+ catch (err) {
202
+ handlePathPublishError(err, options);
203
+ return;
204
+ }
205
+ // Belt-and-suspenders: older server responses (or any path that slips
206
+ // through with a relative URL) become clickable links here. Newer
207
+ // servers return an absolute URL already, in which case this is a
208
+ // no-op.
209
+ const absoluteUrl = result.url.startsWith('/') ? `${getApiUrl()}${result.url}` : result.url;
99
210
  if (options.json) {
100
- console.log(JSON.stringify(result));
211
+ console.log(JSON.stringify({ ...result, url: absoluteUrl }));
101
212
  }
102
213
  else {
103
- console.log(`\nPublished: ${result.url}`);
214
+ console.log(`\nPublished: ${absoluteUrl}`);
104
215
  }
105
216
  }
217
+ /** Translate path-publish API errors into actionable CLI messages.
218
+ *
219
+ * 409 ALREADY_EXISTS — the slug exists live for this author. The server
220
+ * suggests using the ref-form publish or the edit UI; pass that through.
221
+ *
222
+ * 409 ARCHIVED_SLUG — the slug exists but was soft-deleted. The server
223
+ * suggests restoring via the web UI; pass that through. (We don't auto-revive
224
+ * here because the user might genuinely want to start fresh on a different
225
+ * skill that happens to share the title — surfacing the conflict and letting
226
+ * them decide is safer.)
227
+ *
228
+ * 413 (payload too large) — the server's `validateSkillCaps` returns a
229
+ * structured `detail` body ("file foo/bar.md is 71 KB, cap is 64 KB"). We
230
+ * print `detail` instead of the generic ApiError message so authors know
231
+ * exactly which file to trim.
232
+ *
233
+ * Everything else falls back to the generic ApiError message.
234
+ */
235
+ function handlePathPublishError(err, options) {
236
+ if (!(err instanceof ApiError)) {
237
+ throw err;
238
+ }
239
+ // The 413 body now ships structured `detail: { ..., message }`. Older
240
+ // server versions sent `detail: string`; we accept both shapes here so a
241
+ // freshly-deployed CLI keeps working against a not-yet-deployed server,
242
+ // and vice versa.
243
+ const body = err.body;
244
+ const code = body?.code;
245
+ const hint = body?.hint;
246
+ const detailMessage = extractDetailMessage(body?.detail);
247
+ if (options.json) {
248
+ console.log(JSON.stringify({
249
+ ok: false,
250
+ status: err.status,
251
+ error: err.message,
252
+ code: code ?? null,
253
+ hint: hint ?? null,
254
+ // Pass through the raw detail (string OR object) so scripted
255
+ // consumers can still read the structured fields. JSON.stringify
256
+ // handles both shapes cleanly.
257
+ detail: body?.detail ?? null,
258
+ }));
259
+ process.exit(1);
260
+ }
261
+ if (err.status === 409 && code === 'ALREADY_EXISTS') {
262
+ console.error(`\n ✗ ${err.message}`);
263
+ if (hint)
264
+ console.error(` ${hint}`);
265
+ console.error('');
266
+ }
267
+ else if (err.status === 409 && code === 'ARCHIVED_SLUG') {
268
+ console.error(`\n ✗ ${err.message}`);
269
+ if (hint)
270
+ console.error(` ${hint}`);
271
+ console.error('');
272
+ }
273
+ else if (err.status === 413) {
274
+ // Prefer the server-rendered `detail.message` ("file foo.md is 71 KB,
275
+ // cap is 64 KB") because the server knows the per-branch context. Fall
276
+ // back to:
277
+ // 1. a string-form `detail` (older servers)
278
+ // 2. the generic `err.message`
279
+ // so we always print something user-actionable instead of the previous
280
+ // `[object Object]` regression.
281
+ console.error(`\n ✗ ${detailMessage ?? err.message}\n`);
282
+ }
283
+ else if (err.status === 401) {
284
+ console.error(`\n ✗ ${err.message}\n`);
285
+ }
286
+ else {
287
+ // Generic 403/429/5xx — route through the shared helper so the
288
+ // wording matches sync/install/edit instead of leaking ApiError's raw
289
+ // `message` (which is often the server's error string verbatim).
290
+ console.error(`\n ✗ ${friendlyApiErrorDetail(err)}\n`);
291
+ }
292
+ process.exit(1);
293
+ }
294
+ /** Pull a printable line out of the 413 `detail` field, accepting both the
295
+ * new structured shape (`{ message: '…' }`) and the legacy string form. */
296
+ function extractDetailMessage(detail) {
297
+ if (typeof detail === 'string')
298
+ return detail;
299
+ if (detail && typeof detail.message === 'string' && detail.message.length > 0) {
300
+ return detail.message;
301
+ }
302
+ return undefined;
303
+ }
106
304
  /**
107
305
  * Toggle an existing BotDoc from draft → published via the API.
108
306
  *
@@ -113,6 +311,11 @@ export async function publish(source, options) {
113
311
  * mistake is one CLI call away.
114
312
  */
115
313
  async function publishRef(rawRef, options) {
314
+ // Preflight: same as the path-form publish. publish() already called
315
+ // requireAuth before dispatching here, but publishRef is also exported
316
+ // (and re-used by tests/other commands) — keep the guard local so the
317
+ // contract holds regardless of caller.
318
+ requireAuth(options);
116
319
  let parsed;
117
320
  try {
118
321
  parsed = parseRef(rawRef);
@@ -165,13 +368,17 @@ function handlePublishToggleError(err, refLabel, options) {
165
368
  console.error(`\n ✗ ${err.message}\n`);
166
369
  }
167
370
  else if (err.status === 403) {
371
+ // Ownership check is command-specific — keep the explicit "you don't
372
+ // own this" wording instead of the generic team-membership-lost line.
168
373
  console.error(`\n ✗ You don't own ${refLabel}.\n`);
169
374
  }
170
375
  else if (err.status === 404) {
171
376
  console.error(`\n ✗ BotDoc not found: ${refLabel}\n`);
172
377
  }
173
378
  else {
174
- console.error(`\n ✗ ${err.message}\n`);
379
+ // 429/5xx — use the shared friendly helper for consistent wording
380
+ // across commands.
381
+ console.error(`\n ✗ ${friendlyApiErrorDetail(err)}\n`);
175
382
  }
176
383
  process.exit(1);
177
384
  }
@@ -220,9 +427,38 @@ async function maybeAutoCompile(source, options) {
220
427
  return !fs.existsSync(dest) || fs.statSync(dest).mtimeMs < sourceMtime;
221
428
  });
222
429
  if (stale) {
223
- console.log(' Auto-compiling stale ecosystem files…');
430
+ if (!options.json)
431
+ console.log(' Auto-compiling stale ecosystem files…');
224
432
  await compile(source, {});
225
433
  }
434
+ else if (!options.json) {
435
+ // Previously silent — users couldn't tell whether auto-compile ran or
436
+ // was skipped. Print a clear no-op line so the author knows their
437
+ // ecosystem files are already in sync with the source.
438
+ console.log(' Ecosystems up-to-date — skipping compile.');
439
+ }
440
+ }
441
+ /** Best-effort slug derivation for the publish confirmation prompt. Mirrors
442
+ * the slug logic in `printDryRun` so the y/N message and the dry-run preview
443
+ * agree. Falls back to the source path's basename for non-directory sources.
444
+ *
445
+ * Edge case: `botdocs publish .` (or `..`, or an empty string) used to derive
446
+ * an empty slug, so the confirm prompt and dry-run preview both said
447
+ * `@user/` — confusing for first-time authors. When the literal basename is
448
+ * `.`/`..`/empty, fall back to the resolved-path basename so the cwd name
449
+ * shows up instead. Cross-platform note: `path.basename('.')` returns `'.'`
450
+ * on POSIX and Windows alike (verified empirically); the resolved fallback
451
+ * is what gives us the real directory name. */
452
+ function derivePublishSlug(source) {
453
+ const trimmed = source.trim();
454
+ const rawBase = path.basename(trimmed, path.extname(trimmed));
455
+ const base = rawBase === '' || rawBase === '.' || rawBase === '..'
456
+ ? path.basename(path.resolve(trimmed))
457
+ : rawBase;
458
+ return base
459
+ .toLowerCase()
460
+ .replace(/[^a-z0-9]+/g, '-')
461
+ .replace(/^-+|-+$/g, '');
226
462
  }
227
463
  function collectFromFile(filePath) {
228
464
  const content = fs.readFileSync(filePath, 'utf-8');
@@ -235,20 +471,38 @@ function collectFromDirectory(dirPath) {
235
471
  files.sort((a, b) => a.sortOrder - b.sortOrder);
236
472
  return files;
237
473
  }
238
- function walkDirectory(rootDir, currentDir, out) {
239
- const entries = fs.readdirSync(currentDir);
474
+ /**
475
+ * Recursively collect publishable markdown files under `currentDir`.
476
+ *
477
+ * Symlinks are skipped (with a warning) to prevent local file exfiltration:
478
+ * without this check a directory containing a symlink to e.g. `~/.ssh/id_rsa`
479
+ * would silently upload that file's contents as a public skill. We use
480
+ * `withFileTypes: true` so the `Dirent` reports the entry's own type (NOT
481
+ * the target's), then gate on `isFile()` / `isDirectory()` explicitly so
482
+ * anything else (symlinks, sockets, FIFOs, character/block devices) is
483
+ * dropped. Mirrors `walkAll` in `src/lib/ingest-discover.ts`.
484
+ *
485
+ * Exported only for unit testing — production callers should go through
486
+ * `collectFromDirectory`.
487
+ */
488
+ export function walkDirectory(rootDir, currentDir, out) {
489
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
240
490
  for (const entry of entries) {
241
- if (entry.startsWith('.'))
491
+ if (entry.name.startsWith('.'))
492
+ continue;
493
+ const fullPath = path.join(currentDir, entry.name);
494
+ if (entry.isSymbolicLink()) {
495
+ const rel = path.relative(rootDir, fullPath).split(path.sep).join('/');
496
+ console.warn(`Skipping symlink: ${rel}`);
242
497
  continue;
243
- const fullPath = path.join(currentDir, entry);
244
- const stat = fs.statSync(fullPath);
245
- if (stat.isDirectory()) {
498
+ }
499
+ if (entry.isDirectory()) {
246
500
  walkDirectory(rootDir, fullPath, out);
247
501
  continue;
248
502
  }
249
- if (!stat.isFile())
503
+ if (!entry.isFile())
250
504
  continue;
251
- if (!entry.endsWith('.md') && !entry.endsWith('.markdown'))
505
+ if (!entry.name.endsWith('.md') && !entry.name.endsWith('.markdown'))
252
506
  continue;
253
507
  // POSIX-style relative path so the install-time prefix check
254
508
  // (e.g. `claude/SKILL.md`) works on every platform.
@@ -328,3 +582,62 @@ function resolveLicense(input) {
328
582
  };
329
583
  return aliases[upper] ?? 'MIT';
330
584
  }
585
+ /** Mirror of `apps/web/src/lib/skill-caps.ts` — kept inlined here so the CLI's
586
+ * dry-run preview can reflect the same per-file and per-skill caps the server
587
+ * will enforce, without dragging the web app into the CLI's dep graph. */
588
+ const PER_FILE_BYTE_CAP_PREVIEW = 64 * 1024;
589
+ const PER_SKILL_BYTE_CAP_PREVIEW = 512 * 1024;
590
+ /** Render N bytes as a short human string (`12 KB`, `512 KB`). KB only — the
591
+ * dry-run preview is bounded by PER_SKILL_BYTE_CAP_PREVIEW (512KB) so MB
592
+ * never comes up. */
593
+ function formatKb(bytes) {
594
+ const kb = bytes / 1024;
595
+ if (kb < 10)
596
+ return `${kb.toFixed(1)} KB`;
597
+ return `${Math.round(kb)} KB`;
598
+ }
599
+ /** Print a dry-run preview of what `publish` would upload, then exit. Suppressed
600
+ * under --json (emits a structured payload instead) so scripts can branch on
601
+ * the same shape they'd get from a real publish. */
602
+ function printDryRun(payload, options) {
603
+ const auth = loadAuth();
604
+ // requireAuth() ran first, so auth is non-null here. Default to a sentinel
605
+ // so the preview still renders cleanly if someone calls printDryRun without
606
+ // the preflight (e.g. a future refactor).
607
+ const username = auth?.username ?? 'me';
608
+ // Slug derivation mirrors the server: lowercase the title and collapse
609
+ // non-alphanumerics. The actual server-side slug is authoritative, but this
610
+ // gives users a close-enough preview ("am I publishing to the right
611
+ // place?") without an extra round-trip. Shared with the confirmation
612
+ // prompt's `derivePublishSlug` so both surfaces agree.
613
+ const slugPreview = derivePublishSlug(payload.source);
614
+ const fileSizes = payload.files.map((f) => ({
615
+ name: f.filename,
616
+ bytes: Buffer.byteLength(f.content, 'utf-8'),
617
+ }));
618
+ const totalBytes = fileSizes.reduce((sum, f) => sum + f.bytes, 0);
619
+ if (options.json) {
620
+ console.log(JSON.stringify({
621
+ dryRun: true,
622
+ title: payload.title,
623
+ description: payload.description,
624
+ slug: `@${username}/${slugPreview}`,
625
+ sourceEcosystem: payload.sourceEcosystem,
626
+ files: fileSizes.map((f) => ({ filename: f.name, bytes: f.bytes })),
627
+ totalBytes,
628
+ perFileCap: PER_FILE_BYTE_CAP_PREVIEW,
629
+ perSkillCap: PER_SKILL_BYTE_CAP_PREVIEW,
630
+ }));
631
+ return;
632
+ }
633
+ console.log('\n[dry-run] Would publish:');
634
+ console.log(` Title: ${payload.title}`);
635
+ console.log(` Description: ${payload.description}`);
636
+ console.log(` Slug: @${username}/${slugPreview}`);
637
+ console.log(` Files: ${payload.files.length}`);
638
+ for (const f of fileSizes) {
639
+ console.log(` - ${f.name} (${formatKb(f.bytes)})`);
640
+ }
641
+ console.log(` Total size: ${formatKb(totalBytes)} / cap ${formatKb(PER_SKILL_BYTE_CAP_PREVIEW)}`);
642
+ console.log('\nNothing was sent. Re-run without --dry-run to publish.\n');
643
+ }