@botdocs/cli 0.10.3 → 0.12.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,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
4
+ import { ApiError, apiFetch, fetchRawContent, friendlyApiErrorDetail } from '../lib/api.js';
5
5
  import { detectDestination } from '../lib/auto-detect.js';
6
6
  import { SUPPORTED_ECOSYSTEMS } from '../lib/canonical.js';
7
7
  import { convertSkillToEcosystem, parseFrontmatter, stripFrontmatter, } from '../lib/convert.js';
@@ -96,10 +96,19 @@ async function installSkill(ref, manifest, options, scope) {
96
96
  if (detection.kind === 'skip')
97
97
  continue;
98
98
  if (detection.kind === 'manual') {
99
- // Print for user paste (ChatGPT case). Suppressed under --json so output stays parseable.
99
+ // Print for user paste (ChatGPT/Gemini case). Suppressed under --json so
100
+ // output stays parseable. We also surface the per-ecosystem instruction
101
+ // from MANUAL_INSTRUCTIONS so a non-dev knows what to DO with the dumped
102
+ // text (paste into Custom GPT, add to GEMINI.md, etc.) rather than
103
+ // seeing a wall of markdown followed by "✓ Installed".
100
104
  if (!options.json) {
101
105
  const content = await fetchRawContent(file.rawUrl);
102
- console.log(`\n Manual paste required for ${file.filename}:\n${content}\n`);
106
+ const ecosystem = file.filename.split('/')[0];
107
+ const instr = MANUAL_INSTRUCTIONS[ecosystem];
108
+ console.log(`\n Manual paste required for ${ecosystem}:${instr ? ` ${instr}` : ''}`);
109
+ console.log(' ---');
110
+ console.log(content);
111
+ console.log(' ---\n');
103
112
  }
104
113
  // A manual paste prompt counts as "we surfaced this file to the user",
105
114
  // so don't trigger the no-installable-files warning below.
@@ -340,8 +349,97 @@ async function crossInstall(refStr, manifest, options, scope) {
340
349
  console.log('');
341
350
  await syncLibrary();
342
351
  }
352
+ /** Compare a prior install entry against an incoming SKILL manifest. Returns
353
+ * true only when nothing has drifted: same version, same set of manifest
354
+ * filenames, and every tracked file still on disk with a matching
355
+ * fingerprint. Any local edit (fingerprint mismatch), missing file, or
356
+ * upstream-added file makes the install non-idempotent and we fall through
357
+ * to the normal install path. */
358
+ function isInstallUpToDate(prior, manifest) {
359
+ if (prior.version !== manifest.version)
360
+ return false;
361
+ // Resolve each manifest filename to the destination the prior install
362
+ // recorded for it. If the manifest adds a file the prior install didn't
363
+ // have (or vice versa), we're not up-to-date.
364
+ const priorBySrc = new Map(prior.files.map((f) => [f.src, f]));
365
+ for (const manifestFile of manifest.files) {
366
+ const tracked = priorBySrc.get(manifestFile.filename);
367
+ if (!tracked)
368
+ return false;
369
+ if (!fs.existsSync(tracked.dest))
370
+ return false;
371
+ if (fingerprintFile(tracked.dest) !== tracked.fingerprint)
372
+ return false;
373
+ }
374
+ // Allow prior to have MORE files than the manifest (skipped detections)
375
+ // only when the extras aren't in the manifest. The loop above already
376
+ // confirmed every manifest file is tracked; check there are no manifest
377
+ // entries the prior install dropped.
378
+ if (manifest.files.length !== prior.files.filter((f) => priorBySrc.has(f.src)).length) {
379
+ // Shouldn't be possible given the map construction, but defend against
380
+ // future schema drift.
381
+ return false;
382
+ }
383
+ return true;
384
+ }
385
+ /** Compute the Levenshtein edit distance between two short strings. Used by
386
+ * the "did you mean?" suggester to filter search hits that are too far from
387
+ * the user's typo to be useful. Implementation is the textbook two-row DP —
388
+ * O(n*m) time, O(min(n,m)) space. Both inputs are slug-sized (≤ ~64 chars),
389
+ * so the constant factor doesn't matter. */
390
+ function levenshtein(a, b) {
391
+ if (a === b)
392
+ return 0;
393
+ if (a.length === 0)
394
+ return b.length;
395
+ if (b.length === 0)
396
+ return a.length;
397
+ let prev = new Array(b.length + 1);
398
+ let curr = new Array(b.length + 1);
399
+ for (let j = 0; j <= b.length; j++)
400
+ prev[j] = j;
401
+ for (let i = 1; i <= a.length; i++) {
402
+ curr[0] = i;
403
+ for (let j = 1; j <= b.length; j++) {
404
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
405
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
406
+ }
407
+ [prev, curr] = [curr, prev];
408
+ }
409
+ return prev[b.length];
410
+ }
411
+ /** On a 404, query the search endpoint for the user's slug fragment and
412
+ * return the top result as a `@user/slug` string when its slug is within
413
+ * Levenshtein distance 2 of the requested slug. Returns null when the
414
+ * search returns no results, when the top result is too far, or when
415
+ * `requestedRef` already equals the top match (defensive). Never throws —
416
+ * callers wrap in catch and treat any failure as "no suggestion". */
417
+ async function suggestClosestRef(requestedSlug, requestedRef) {
418
+ const encoded = encodeURIComponent(requestedSlug);
419
+ const resp = await apiFetch(`/api/search?q=${encoded}`);
420
+ const top = resp.results[0];
421
+ if (!top)
422
+ return null;
423
+ if (levenshtein(top.slug.toLowerCase(), requestedSlug.toLowerCase()) > 2)
424
+ return null;
425
+ const candidate = `@${top.author}/${top.slug}`;
426
+ if (candidate === requestedRef)
427
+ return null;
428
+ return candidate;
429
+ }
343
430
  export async function install(rawRef, options) {
344
- const ref = parseRef(rawRef);
431
+ let ref;
432
+ try {
433
+ ref = parseRef(rawRef);
434
+ }
435
+ catch {
436
+ // parseRef throws `Error: Invalid ref: foo (expected @user/slug)` on
437
+ // malformed input — uncaught it stack-trips through commander. Catch it
438
+ // here and surface the same `✗ ` formatting other CLI errors use so a
439
+ // typo like `botdocs install foo` lands at one clean line, not a stack.
440
+ console.error(`\n ✗ Invalid ref: ${rawRef} — expected @user/slug (e.g. @yourname/my-skill)\n`);
441
+ process.exit(1);
442
+ }
345
443
  const refStr = `@${ref.username}/${ref.slug}`;
346
444
  if (options.clean) {
347
445
  const lf = loadLockfile();
@@ -371,11 +469,49 @@ export async function install(rawRef, options) {
371
469
  }
372
470
  catch (err) {
373
471
  if (err instanceof ApiError && err.status === 404) {
374
- console.error(`\n ✗ Skill or bundle not found: ${refStr}\n`);
472
+ // 404 dead-ends are common when users mistype a slug. Ask the search
473
+ // endpoint for the slug-fragment and surface the top close match
474
+ // (Levenshtein distance ≤ 2) so the user can quickly correct.
475
+ // Network errors from the suggestion lookup are swallowed — the 404
476
+ // itself is the primary signal and we shouldn't gate it on the
477
+ // suggestion call succeeding.
478
+ const suggestion = await suggestClosestRef(ref.slug, refStr).catch(() => null);
479
+ const suffix = suggestion ? `\n Did you mean: ${suggestion}?\n` : '\n';
480
+ console.error(`\n ✗ Skill or bundle not found: ${refStr}${suffix}`);
481
+ process.exit(1);
482
+ }
483
+ // 410 Gone = author soft-deleted the skill upstream. Distinct from 404
484
+ // ("never existed") so users know they had it installed and can clean up.
485
+ if (err instanceof ApiError && err.status === 410) {
486
+ console.error(`\n ⌀ ${refStr}: removed by author — run \`botdocs uninstall ${refStr}\` to clean up local files.\n`);
487
+ process.exit(1);
488
+ }
489
+ // Generic 403/429/5xx — route through the shared friendly-detail helper
490
+ // so the error vocabulary stays consistent across commands instead of
491
+ // leaking a raw ApiError stack to stderr.
492
+ if (err instanceof ApiError) {
493
+ console.error(`\n ✗ ${refStr}: ${friendlyApiErrorDetail(err, refStr)}\n`);
375
494
  process.exit(1);
376
495
  }
377
496
  throw err;
378
497
  }
498
+ // Already-at-version short-circuit. Re-running install on a skill at the
499
+ // current version with all file fingerprints matching does nothing visible
500
+ // today — the user can't tell whether anything changed. Compare the
501
+ // manifest version + each tracked file's fingerprint to the lockfile entry;
502
+ // when nothing has drifted, print a clear no-op line and skip the work.
503
+ // Skipped under --json and --clean (both want the full path), and only
504
+ // applies when a prior install exists. Bundles re-resolve per-skill so
505
+ // the short-circuit fires at the skill level inside the loop below.
506
+ if (!options.clean && !options.json && !options.to && manifest.type === 'SKILL') {
507
+ const lf = loadLockfile();
508
+ const prior = lf.installs.find((i) => i.ref === refStr);
509
+ if (prior && isInstallUpToDate(prior, manifest)) {
510
+ console.log(`\n ✓ ${refStr} already at v${manifest.version} (no changes)\n`);
511
+ await syncLibrary();
512
+ return;
513
+ }
514
+ }
379
515
  // `--to`: cross-install into a different ecosystem (deterministic, no LLM).
380
516
  if (options.to) {
381
517
  await crossInstall(refStr, manifest, options, ref.username);
@@ -417,6 +553,11 @@ export async function install(rawRef, options) {
417
553
  continue;
418
554
  console.log(` ${entry.ref}: ${entry.files.length} file(s)`);
419
555
  }
556
+ // Dim sync hint — only on the success path, not on errors. Suppressed when
557
+ // --json is set (JSON mode is for scripting and shouldn't carry banner
558
+ // noise). ANSI dim (\x1b[2m) degrades to plain text on terminals that
559
+ // don't support it; harmless when redirected to a file.
560
+ console.log('\x1b[2m → Updates? Run `botdocs sync` (or `botdocs install-instructions --shell-hook` for auto-notify)\x1b[0m');
420
561
  console.log('');
421
562
  await syncLibrary();
422
563
  }
@@ -1,6 +1,68 @@
1
1
  import { loadLockfile } from '../lib/lockfile.js';
2
+ import { fetchUpdates } from './check-updates.js';
3
+ /** Filter the lockfile down to the subset that has an available update or has
4
+ * been removed upstream, per the cached check-updates result. Returns the
5
+ * (refs, result) tuple so the caller can render a structured summary that
6
+ * distinguishes "outdated" from "gone". */
7
+ function filterOutdated(installs, result) {
8
+ const updateMap = new Map();
9
+ for (const u of result.updates)
10
+ updateMap.set(u.ref, { from: u.from, to: u.to });
11
+ const removedRefs = new Set(result.removed.map((r) => r.ref));
12
+ const outdated = installs.filter((i) => updateMap.has(i.ref) || removedRefs.has(i.ref));
13
+ return { outdated, removedRefs, updateMap };
14
+ }
2
15
  export async function list(options) {
3
16
  const lf = loadLockfile();
17
+ if (options.outdated) {
18
+ // Empty lockfile short-circuit — no point hitting the server when there's
19
+ // nothing to compare against.
20
+ if (lf.installs.length === 0) {
21
+ if (options.json) {
22
+ console.log(JSON.stringify({ outdated: [], removed: [] }));
23
+ return;
24
+ }
25
+ console.log('\n nothing installed yet — try `botdocs install @user/slug`\n');
26
+ return;
27
+ }
28
+ const result = await fetchUpdates();
29
+ const { outdated, removedRefs, updateMap } = filterOutdated(lf.installs, result);
30
+ if (options.json) {
31
+ // Emit a structured shape: every outdated install with its delta + a
32
+ // boolean flag for the removed-upstream case. Easier for scripts than
33
+ // re-correlating against the raw check-updates output.
34
+ const shaped = outdated.map((i) => {
35
+ const update = updateMap.get(i.ref);
36
+ return {
37
+ ref: i.ref,
38
+ type: i.type,
39
+ version: i.version,
40
+ latest: update?.to ?? null,
41
+ removed: removedRefs.has(i.ref),
42
+ };
43
+ });
44
+ console.log(JSON.stringify({ outdated: shaped }));
45
+ return;
46
+ }
47
+ if (outdated.length === 0) {
48
+ console.log('\n Everything is up to date.\n');
49
+ return;
50
+ }
51
+ console.log('');
52
+ for (const entry of outdated) {
53
+ const update = updateMap.get(entry.ref);
54
+ if (update) {
55
+ console.log(` ▸ ${entry.ref}: ${update.from} → ${update.to}`);
56
+ }
57
+ else {
58
+ // Removed upstream — surface the same ⌀ glyph check-updates uses so
59
+ // users learn one visual language for "gone".
60
+ console.log(` ⌀ ${entry.ref}: removed upstream`);
61
+ }
62
+ }
63
+ console.log('');
64
+ return;
65
+ }
4
66
  if (options.json) {
5
67
  console.log(JSON.stringify({ installs: lf.installs }));
6
68
  return;
@@ -75,6 +75,30 @@ async function initLoginState(baseUrl) {
75
75
  if (!initRes.ok) {
76
76
  throw new Error(`Failed to start login (HTTP ${initRes.status}).`);
77
77
  }
78
+ // P3 #14 (DOCUMENTATION-ONLY trade-off, not a code fix in this PR):
79
+ //
80
+ // The `state` value rides in the query string on the browser-side
81
+ // authorize URL. Query-string parameters land in:
82
+ // - the browser's address bar (visible to anyone over the user's
83
+ // shoulder + saved in browser history)
84
+ // - any Referer header on links the user clicks from /cli-auth
85
+ // - server-side access logs at every proxy between the user and the
86
+ // Vercel edge
87
+ //
88
+ // The cli-auth state IS effectively a single-use, time-boxed (10 min)
89
+ // bearer for the device-grant flow. Leaking it would let an observer
90
+ // race the user to call /grant. We accept this exposure today because:
91
+ // 1. The /cli-auth page strips Referer on the click that ultimately
92
+ // lands the grant (the button submits a POST, not an <a href>),
93
+ // so the worst leak is the address bar + browser history.
94
+ // 2. The state is single-use and short-lived; replay is bounded.
95
+ // 3. The CLI binds the state to the polling channel — an attacker
96
+ // who steals it would need to win a poll race with our own CLI.
97
+ //
98
+ // A future revision should switch this to a POST-based handoff with the
99
+ // state carried in the URL hash fragment (#state=...), which browsers
100
+ // never send to the server and never write to history. That refactor is
101
+ // out of scope for the P3 bucket — tracked as a separate item.
78
102
  return {
79
103
  state,
80
104
  authUrl: `${baseUrl}/cli-auth?state=${state}`,
@@ -210,14 +234,44 @@ async function loginPlainInteractive(syncLibrary) {
210
234
  printSyncLibraryHint(syncLibrary);
211
235
  }
212
236
  /** Polls the server with exponential backoff. Returns the granted payload on
213
- * success or null if the loop ran out of budget (matching the server's TTL). */
237
+ * success or null if the loop ran out of budget (matching the server's TTL).
238
+ *
239
+ * Network errors (DNS hiccup, WiFi blip, server briefly unreachable) are
240
+ * caught and treated the same as transient HTTP 5xx: we keep polling. An
241
+ * uncaught throw here would crash Ink mid-render and leave the terminal in
242
+ * raw mode. We surface a single dimmed "retrying…" hint after three
243
+ * consecutive failures so the user knows their network is the bottleneck
244
+ * rather than assuming the CLI hung. */
214
245
  async function pollUntilGranted(baseUrl, state) {
215
246
  const deadline = Date.now() + POLL_TIMEOUT_MS;
216
247
  let delay = POLL_INITIAL_DELAY_MS;
248
+ let consecutiveFailures = 0;
217
249
  while (Date.now() < deadline) {
218
250
  await sleep(delay);
219
251
  delay = Math.min(delay * POLL_BACKOFF_FACTOR, POLL_MAX_DELAY_MS);
220
- const res = await fetch(`${baseUrl}/api/cli/auth/poll?state=${encodeURIComponent(state)}`, { headers: { Accept: 'application/json' } });
252
+ let res;
253
+ try {
254
+ // P3 #14: the poll endpoint also carries `state` in the query string.
255
+ // See the comment in `initLoginState` for the full trade-off rationale
256
+ // and the planned migration to a hash-fragment + POST handoff.
257
+ res = await fetch(`${baseUrl}/api/cli/auth/poll?state=${encodeURIComponent(state)}`, { headers: { Accept: 'application/json' } });
258
+ }
259
+ catch {
260
+ // Network-level failure — same disposition as a 5xx: keep polling
261
+ // within the 10-minute budget. Note we intentionally don't surface a
262
+ // visible error to Ink to avoid mid-render churn; the deadline check
263
+ // remains the source of truth for "give up and exit".
264
+ consecutiveFailures += 1;
265
+ if (consecutiveFailures >= 3 && process.stderr.isTTY) {
266
+ // Dimmed retry hint goes to stderr so it doesn't disturb Ink's
267
+ // stdout-rendered frame buffer. Only printed once per streak.
268
+ if (consecutiveFailures === 3) {
269
+ process.stderr.write('\n (network hiccup, retrying…)\n');
270
+ }
271
+ }
272
+ continue;
273
+ }
274
+ consecutiveFailures = 0;
221
275
  if (res.status === 410) {
222
276
  return null;
223
277
  }
@@ -6,10 +6,22 @@ interface PublishOptions {
6
6
  license?: string;
7
7
  json?: boolean;
8
8
  noCompile?: boolean;
9
- /** Skip confirmation prompts. Reserved for future use publish on a
10
- * ref currently doesn't prompt, but the flag is accepted so users
11
- * developing scripts get a consistent surface across publish/unpublish. */
9
+ /** Skip the pre-POST y/N confirmation prompt. First-time authors don't
10
+ * always realize `publish` is immediate, so the path-form publish asks
11
+ * "Publish to public registry as @<user>/<slug>? [y/N]" by default.
12
+ * `--yes` bypasses it for scripted runs; `--json` skips it implicitly
13
+ * because JSON mode is for non-interactive consumers. */
12
14
  yes?: boolean;
15
+ /** Build the payload, print what would be published, exit 0 without POSTing.
16
+ * Lets authors preview title/description/file list + total size before they
17
+ * commit to publishing. Skipped entirely on ref-form publish (toggling a
18
+ * draft → published flag is already cheap and reversible via `unpublish`). */
19
+ dryRun?: boolean;
20
+ }
21
+ interface FileEntry {
22
+ filename: string;
23
+ content: string;
24
+ sortOrder: number;
13
25
  }
14
26
  export declare function publish(source: string, options: PublishOptions): Promise<void>;
15
27
  /**
@@ -37,3 +49,18 @@ declare function publishRef(rawRef: string, options: PublishOptions): Promise<vo
37
49
  */
38
50
  declare function handlePublishToggleError(err: unknown, refLabel: string, options: PublishOptions): void;
39
51
  export { publishRef, handlePublishToggleError };
52
+ /**
53
+ * Recursively collect publishable markdown files under `currentDir`.
54
+ *
55
+ * Symlinks are skipped (with a warning) to prevent local file exfiltration:
56
+ * without this check a directory containing a symlink to e.g. `~/.ssh/id_rsa`
57
+ * would silently upload that file's contents as a public skill. We use
58
+ * `withFileTypes: true` so the `Dirent` reports the entry's own type (NOT
59
+ * the target's), then gate on `isFile()` / `isDirectory()` explicitly so
60
+ * anything else (symlinks, sockets, FIFOs, character/block devices) is
61
+ * dropped. Mirrors `walkAll` in `src/lib/ingest-discover.ts`.
62
+ *
63
+ * Exported only for unit testing — production callers should go through
64
+ * `collectFromDirectory`.
65
+ */
66
+ export declare function walkDirectory(rootDir: string, currentDir: string, out: FileEntry[]): void;