@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 CHANGED
@@ -10,6 +10,8 @@ publish your own.
10
10
 
11
11
  ## Install
12
12
 
13
+ **Requires Node.js 20+.** [Don't have it? Install from nodejs.org →](https://nodejs.org)
14
+
13
15
  ```bash
14
16
  # one-off
15
17
  npx @botdocs/cli <command>
@@ -26,8 +28,6 @@ After a global install the binary is just `botdocs`:
26
28
  botdocs --help
27
29
  ```
28
30
 
29
- Requires Node.js 20 or newer.
30
-
31
31
  ## Quick start
32
32
 
33
33
  ```bash
@@ -50,7 +50,7 @@ botdocs publish my-skill/
50
50
 
51
51
  | Command | Purpose |
52
52
  |---|---|
53
- | `init [name]` | Scaffold a new skill directory (`--canonical` for a multi-ecosystem skill). |
53
+ | `init [name]` | Scaffold a new skill directory (canonical multi-ecosystem layout by default; `--spec` for the legacy SPEC scaffold). |
54
54
  | `compile <path>` | Generate per-ecosystem skill drafts from a canonical source (BYOK). |
55
55
  | `edit <ref>` | LLM-assisted revision of a published skill ecosystem file (BYOK). |
56
56
  | `validate <source>` | Pre-publish structural check on a directory or file. |
@@ -164,8 +164,10 @@ your machine.
164
164
  ```bash
165
165
  export BOTDOCS_ANTHROPIC_KEY=sk-ant-... # or BOTDOCS_OPENAI_KEY=sk-...
166
166
 
167
- botdocs init my-skill --canonical # scaffolds claude-code source
168
- # + ecosystems list in botdocs.json
167
+ botdocs init my-skill # canonical scaffold by default:
168
+ # claude-code source + ecosystems list
169
+ # in botdocs.json
170
+
169
171
  # edit claude-code/commands/my-skill.md
170
172
 
171
173
  botdocs compile my-skill/ # generates claude/SKILL.md,
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * CommonJS preflight shim.
6
+ *
7
+ * This file is the actual `bin` entry — `package.json#bin.botdocs` points
8
+ * here, not at the ESM `dist/index.js`. The shim's only job is to gate the
9
+ * Node version BEFORE the ESM entry is loaded.
10
+ *
11
+ * Why this exists:
12
+ * The previous preflight at the top of `src/index.ts` was structurally
13
+ * defeated by ESM import hoisting. `import { checkNodeVersion } from …`
14
+ * syntactically runs before the `{ … process.exit(1) }` block, but Node
15
+ * evaluates the WHOLE module graph (every `import` in `index.ts` AND
16
+ * every transitively-imported file) before any top-level statement
17
+ * executes. On Node 16/18 that means a `SyntaxError` from any modern-
18
+ * syntax dep (commander, Ink, the openai SDK, etc.) fires first, and
19
+ * the user never sees our friendly "install Node 20" line.
20
+ *
21
+ * A CJS shim sidesteps the problem entirely: CommonJS evaluates this
22
+ * file synchronously, top-to-bottom, with NO module graph eagerly
23
+ * loaded. We can read `process.versions.node`, print a stderr line,
24
+ * and exit cleanly before requiring `dist/index.js`. The only globals
25
+ * used here (`process`, `String.prototype.split`, `parseInt`) exist on
26
+ * every Node version, including the legacy ones we're trying to reject.
27
+ *
28
+ * Keep this file LANGUAGE-MINIMAL — no optional chaining, no nullish
29
+ * coalescing, no `const` at top level (technically fine, but staying
30
+ * conservative makes the shim robust against any future runtime quirk
31
+ * on a very old Node). The downstream ESM code can use whatever it wants.
32
+ */
33
+
34
+ var MIN_NODE_MAJOR = 20;
35
+
36
+ function preflight() {
37
+ var version = process.versions && process.versions.node ? String(process.versions.node) : '';
38
+ var firstSegment = version.split('.')[0] || '0';
39
+ var major = parseInt(firstSegment, 10);
40
+ if (!isFinite(major) || major < MIN_NODE_MAJOR) {
41
+ process.stderr.write(
42
+ 'BotDocs CLI requires Node.js ' +
43
+ MIN_NODE_MAJOR +
44
+ " or newer. You're on Node " +
45
+ version +
46
+ '. Install from https://nodejs.org\n',
47
+ );
48
+ process.exit(1);
49
+ }
50
+ }
51
+
52
+ preflight();
53
+
54
+ // Hand off to the ESM entry. `require()` of an ES module works in Node 22+
55
+ // via `require(esm)`, but for portability across Node 20.x we use a
56
+ // dynamic import — `import()` is available in CJS on every Node ≥ 13.2.
57
+ // The promise rejection is funneled through the same process-wide
58
+ // unhandledRejection handler that `dist/index.js` installs once it loads,
59
+ // so we don't need a local catch here.
60
+ //
61
+ // `__dirname` is set in CJS; we resolve the ESM entry relative to this
62
+ // file so the shim works whether it's invoked from the local checkout
63
+ // (`packages/cli/bin/botdocs.cjs` next to `packages/cli/dist/index.js`)
64
+ // or from a published install (`node_modules/@botdocs/cli/bin/...` next
65
+ // to `node_modules/@botdocs/cli/dist/...`).
66
+ var path = require('path');
67
+ var url = require('url');
68
+ var entry = path.join(__dirname, '..', 'dist', 'index.js');
69
+ import(url.pathToFileURL(entry).href).catch(function (err) {
70
+ // Should be unreachable in practice — dist/index.js installs its own
71
+ // unhandledRejection handler the moment it's loaded. This catch only
72
+ // fires if loading the module itself fails (e.g. missing dist/, which
73
+ // would only happen for a broken install).
74
+ process.stderr.write(
75
+ 'Failed to load BotDocs CLI entry: ' + (err && err.message ? err.message : String(err)) + '\n',
76
+ );
77
+ process.exit(1);
78
+ });
@@ -2,5 +2,27 @@ interface CheckUpdatesOptions {
2
2
  quiet?: boolean;
3
3
  json?: boolean;
4
4
  }
5
+ export interface UpdateResult {
6
+ /** When false, the server didn't actually check anything — the user
7
+ * hasn't opted in via `botdocs login --sync-library` so there's no
8
+ * snapshot to diff against. Older servers omit this field; the CLI
9
+ * defaults to the legacy "trusted result" interpretation in that case
10
+ * (so a fresh CLI against an older server doesn't regress). */
11
+ enabled?: boolean;
12
+ total: number;
13
+ updates: Array<{
14
+ ref: string;
15
+ from: string;
16
+ to: string;
17
+ }>;
18
+ removed: Array<{
19
+ ref: string;
20
+ reason: string;
21
+ }>;
22
+ }
23
+ /** Fetch the current update result, served from the cache when fresh. Shared
24
+ * with `botdocs list --outdated` so the two commands don't duplicate
25
+ * fingerprint + cache + fetch logic. */
26
+ export declare function fetchUpdates(): Promise<UpdateResult>;
5
27
  export declare function checkUpdates(options: CheckUpdatesOptions): Promise<void>;
6
28
  export {};
@@ -3,17 +3,24 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { apiFetch } from '../lib/api.js';
6
+ import { loadAuth } from '../lib/config.js';
6
7
  import { loadLockfile } from '../lib/lockfile.js';
7
8
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
8
9
  function cachePath() {
9
10
  return path.join(os.homedir(), '.botdocs', 'check-updates-cache.json');
10
11
  }
11
- /** Hash of (ref, version) tuples invalidates the cache when the user's
12
- * installed set changes (e.g. new install, version bump after sync). */
12
+ /** Hash of (ref, version) tuples PLUS the current logged-in username
13
+ * invalidates the cache both when the user's installed set changes (e.g.
14
+ * new install, version bump after sync) AND when the user switches
15
+ * accounts on a shared machine via `botdocs login`. The "anon" sentinel
16
+ * keeps the fingerprint stable across unauthenticated runs (no auth file)
17
+ * so the cache still works for users who haven't logged in. */
13
18
  function lockfileFingerprint() {
14
19
  const lf = loadLockfile();
20
+ const auth = loadAuth();
21
+ const username = auth?.username ?? 'anon';
15
22
  const summary = lf.installs.map((i) => `${i.ref}@${i.version}`).sort().join('\n');
16
- return createHash('sha256').update(summary).digest('hex').slice(0, 16);
23
+ return createHash('sha256').update(`${username}\n${summary}`).digest('hex').slice(0, 16);
17
24
  }
18
25
  function loadCache(currentFingerprint) {
19
26
  const p = cachePath();
@@ -36,22 +43,25 @@ function saveCache(fingerprint, result) {
36
43
  fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
37
44
  fs.writeFileSync(cachePath(), JSON.stringify({ cachedAt: new Date().toISOString(), fingerprint, result }, null, 2));
38
45
  }
39
- export async function checkUpdates(options) {
46
+ /** Fetch the current update result, served from the cache when fresh. Shared
47
+ * with `botdocs list --outdated` so the two commands don't duplicate
48
+ * fingerprint + cache + fetch logic. */
49
+ export async function fetchUpdates() {
40
50
  const fingerprint = lockfileFingerprint();
41
51
  const cached = loadCache(fingerprint);
42
- let result;
43
- if (cached) {
44
- result = cached;
45
- }
46
- else {
47
- // Server reads from user_library.lockfile (set by syncLibrary helper).
48
- // No body — auth-gated endpoint trusts the server-side snapshot, not the wire.
49
- result = await apiFetch('/api/library/check-updates', {
50
- method: 'POST',
51
- auth: true,
52
- });
53
- saveCache(fingerprint, result);
54
- }
52
+ if (cached)
53
+ return cached;
54
+ // Server reads from user_library.lockfile (set by syncLibrary helper).
55
+ // No body — auth-gated endpoint trusts the server-side snapshot, not the wire.
56
+ const result = await apiFetch('/api/library/check-updates', {
57
+ method: 'POST',
58
+ auth: true,
59
+ });
60
+ saveCache(fingerprint, result);
61
+ return result;
62
+ }
63
+ export async function checkUpdates(options) {
64
+ const result = await fetchUpdates();
55
65
  if (options.json) {
56
66
  console.log(JSON.stringify(result));
57
67
  return;
@@ -63,6 +73,19 @@ export async function checkUpdates(options) {
63
73
  return;
64
74
  }
65
75
  if (result.total === 0) {
76
+ // When the server reports `enabled: false`, it didn't actually compare
77
+ // anything — the user never opted in to library-sync, so there's no
78
+ // server-side lockfile snapshot to diff against. Don't lie with "all up
79
+ // to date"; surface the opt-in path. `auth.syncLibrary` is also checked
80
+ // as a belt-and-suspenders for older servers that don't return the
81
+ // flag (the local flag is set by `botdocs login --sync-library`).
82
+ const auth = loadAuth();
83
+ const enabled = result.enabled !== false && (auth?.syncLibrary === true || !auth);
84
+ if (!enabled && auth) {
85
+ console.log('\n No library snapshot to check.');
86
+ console.log(' Run `botdocs login --sync-library` to enable check-updates.\n');
87
+ return;
88
+ }
66
89
  console.log('\n All installed skills + bundles are up to date.\n');
67
90
  return;
68
91
  }
@@ -73,5 +96,37 @@ export async function checkUpdates(options) {
73
96
  for (const r of result.removed) {
74
97
  console.log(` ⌀ ${r.ref}: ${r.reason}`);
75
98
  }
76
- console.log('\n Run `botdocs sync` to apply.\n');
99
+ // Branch the suffix on what's actionable. `botdocs sync` does NOT remove
100
+ // entries for removed-upstream refs (it just skips them), so telling the
101
+ // user "sync to apply" when their only diff is removals would do nothing.
102
+ // Surface specific uninstall commands when the list is small enough to
103
+ // print inline.
104
+ const hasUpdates = result.updates.length > 0;
105
+ const hasRemovals = result.removed.length > 0;
106
+ console.log('');
107
+ if (hasUpdates && hasRemovals) {
108
+ console.log(' Run `botdocs sync` to apply updates.');
109
+ if (result.removed.length <= 3) {
110
+ for (const r of result.removed) {
111
+ console.log(` Run \`botdocs uninstall ${r.ref}\` to remove the stale skill.`);
112
+ }
113
+ }
114
+ else {
115
+ console.log(' Run `botdocs uninstall <ref>` to remove stale skills.');
116
+ }
117
+ }
118
+ else if (hasUpdates) {
119
+ console.log(' Run `botdocs sync` to apply.');
120
+ }
121
+ else if (hasRemovals) {
122
+ if (result.removed.length <= 3) {
123
+ for (const r of result.removed) {
124
+ console.log(` Run \`botdocs uninstall ${r.ref}\` to clean up.`);
125
+ }
126
+ }
127
+ else {
128
+ console.log(' Run `botdocs uninstall <ref>` to clean up.');
129
+ }
130
+ }
131
+ console.log('');
77
132
  }
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import * as p from '@clack/prompts';
5
- import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
5
+ import { ApiError, apiFetch, fetchRawContent, friendlyApiErrorDetail } from '../lib/api.js';
6
6
  import { complete, detectProvider, LlmError } from '../lib/llm.js';
7
7
  import { renderDiff } from '../lib/diff.js';
8
8
  const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'ecosystem-prompts');
@@ -42,12 +42,20 @@ export async function edit(rawRef, options) {
42
42
  throw err;
43
43
  }
44
44
  let manifest;
45
+ const refStr = `@${ref.username}/${ref.slug}`;
45
46
  try {
46
47
  manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`);
47
48
  }
48
49
  catch (err) {
49
50
  if (err instanceof ApiError && err.status === 404) {
50
- console.error(`\n ✗ Skill not found: @${ref.username}/${ref.slug}\n`);
51
+ console.error(`\n ✗ Skill not found: ${refStr}\n`);
52
+ process.exit(1);
53
+ }
54
+ // Route 403/429/5xx through the shared helper so users get the same
55
+ // wording sync/install give (lost team access, rate-limited, server
56
+ // error) instead of a raw ApiError stack.
57
+ if (err instanceof ApiError) {
58
+ console.error(`\n ✗ ${refStr}: ${friendlyApiErrorDetail(err, refStr)}\n`);
51
59
  process.exit(1);
52
60
  }
53
61
  throw err;
@@ -15,6 +15,12 @@ interface IngestOptions {
15
15
  * are never replaced — they surface as a blocking 409 either way. Set via
16
16
  * `--force`. */
17
17
  force?: boolean;
18
+ /** Pair this ingest run with the web onboarding step so the user can
19
+ * watch progress in their browser. Set via `--pair`. If `--pair-code` is
20
+ * also provided we skip the interactive prompt. */
21
+ pair?: boolean;
22
+ /** Pre-supplied pairing code (BD-XXXXX). Implies `--pair`. */
23
+ pairCode?: string;
18
24
  }
19
25
  /** Per-file size cap. Any file larger than this is skipped with a warning. */
20
26
  export declare const PER_FILE_BYTE_CAP: number;
@@ -105,5 +111,19 @@ export interface EcosystemDetector {
105
111
  */
106
112
  export declare const DETECTORS: Record<string, EcosystemDetector>;
107
113
  export declare const SUPPORTED_TOOLS: readonly string[];
114
+ /**
115
+ * Recursively collect absolute file paths under `root`.
116
+ *
117
+ * Symlinks are skipped (with a warning) to prevent local file exfiltration:
118
+ * without this check a directory containing a symlink to e.g. `~/.ssh/id_rsa`
119
+ * would have its target's contents read into memory and uploaded as part of
120
+ * the ingest payload. `entry.isDirectory()` and `entry.isFile()` come from
121
+ * the `Dirent` produced by `withFileTypes: true`, which reports the entry's
122
+ * OWN type (NOT the symlink target's), so the gate is sound. Mirrors
123
+ * `walkAll` in `src/lib/ingest-discover.ts`.
124
+ *
125
+ * Exported for unit testing.
126
+ */
127
+ export declare function walkFiles(root: string): string[];
108
128
  export declare function ingest(rootPath: string | undefined, options: IngestOptions): Promise<void>;
109
129
  export {};