@botdocs/cli 0.12.1 → 0.12.2

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.
@@ -44,7 +44,9 @@ export async function edit(rawRef, options) {
44
44
  let manifest;
45
45
  const refStr = `@${ref.username}/${ref.slug}`;
46
46
  try {
47
- manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`);
47
+ manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`, {
48
+ auth: 'optional',
49
+ });
48
50
  }
49
51
  catch (err) {
50
52
  if (err instanceof ApiError && err.status === 404) {
@@ -382,51 +382,6 @@ function isInstallUpToDate(prior, manifest) {
382
382
  }
383
383
  return true;
384
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
- }
430
385
  export async function install(rawRef, options) {
431
386
  let ref;
432
387
  try {
@@ -465,19 +420,15 @@ export async function install(rawRef, options) {
465
420
  }
466
421
  let manifest;
467
422
  try {
468
- manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`);
423
+ manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`, { auth: 'optional' });
469
424
  }
470
425
  catch (err) {
471
426
  if (err instanceof ApiError && err.status === 404) {
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}`);
427
+ // A 404 here also covers "PRIVATE skill you can't read" the server
428
+ // returns 404 (never 403) for private-no-access so existence never
429
+ // leaks. We don't try to suggest a close match: the discovery/search
430
+ // surface was removed (private-by-default), so there's nothing to query.
431
+ console.error(`\n ✗ Skill or bundle not found: ${refStr}\n`);
481
432
  process.exit(1);
482
433
  }
483
434
  // 410 Gone = author soft-deleted the skill upstream. Distinct from 404
@@ -269,7 +269,7 @@ async function installTeamSkill(teamSlug, skill, options, silent) {
269
269
  // version locking.)
270
270
  let manifest;
271
271
  try {
272
- manifest = await apiFetch(`/api/skills/${skill.username}/${skill.slug}/manifest`);
272
+ manifest = await apiFetch(`/api/skills/${skill.username}/${skill.slug}/manifest`, { auth: 'optional' });
273
273
  }
274
274
  catch (err) {
275
275
  if (err instanceof ApiError) {
@@ -376,7 +376,7 @@ async function runSyncCore(rawRef, targets, options, deps) {
376
376
  const { username, slug } = refToPath(entry.ref);
377
377
  let manifest;
378
378
  try {
379
- manifest = await apiFetch(`/api/skills/${username}/${slug}/manifest`);
379
+ manifest = await apiFetch(`/api/skills/${username}/${slug}/manifest`, { auth: 'optional' });
380
380
  }
381
381
  catch (err) {
382
382
  if (err instanceof ApiError) {
@@ -237,7 +237,7 @@ export function registerTeamCommands(program) {
237
237
  });
238
238
  team
239
239
  .command('push <slug> <ref>')
240
- .description('Pin a published skill to a team (WRITE+ only)')
240
+ .description('Pin one of your own published skills to a team (WRITE+ only; you can only pin skills you own)')
241
241
  .option('--version <v>', 'Pin to a specific version (omit to float to latest)')
242
242
  .action(async (slug, ref, opts) => {
243
243
  await teamPush(slug, ref, { ...opts, json: program.opts().json });
package/dist/index.js CHANGED
@@ -110,7 +110,6 @@ function genericFriendlyMessage(reason) {
110
110
  }
111
111
  import { Command } from 'commander';
112
112
  import { checkAuthFilePerms } from './lib/config.js';
113
- import { search } from './commands/search.js';
114
113
  import { publish } from './commands/publish.js';
115
114
  import { unpublish } from './commands/unpublish.js';
116
115
  import { delete_ as deleteCmd } from './commands/delete.js';
@@ -179,12 +178,6 @@ program
179
178
  .action(async (source) => {
180
179
  await validate(source, { json: program.opts().json });
181
180
  });
182
- program
183
- .command('search <query>')
184
- .description('Search for BotDocs')
185
- .action(async (query) => {
186
- await search(query, { json: program.opts().json });
187
- });
188
181
  program
189
182
  .command('publish <source>')
190
183
  .description('Publish a BotDoc — pass a local path to upload, or @user/slug to mark an existing draft live')
package/dist/lib/api.d.ts CHANGED
@@ -40,7 +40,14 @@ export declare class WaitlistError extends ApiError {
40
40
  interface FetchOptions {
41
41
  method?: string;
42
42
  body?: unknown;
43
- auth?: boolean;
43
+ /** Authorization behavior:
44
+ * - `false` (default): never attach a bearer token.
45
+ * - `true`: REQUIRE a saved token — throw `ApiError(401)` when absent.
46
+ * - `'optional'`: attach the bearer IF a token is saved, but don't throw
47
+ * when it's missing. Used by read paths (manifest/versions) that gate on
48
+ * the server side: a signed-in user reaches their PRIVATE skills, while an
49
+ * anonymous caller still fetches PUBLIC/bootstrap skills tokenlessly. */
50
+ auth?: boolean | 'optional';
44
51
  /** Per-call override of the default request timeout (ms). When the timer
45
52
  * fires we abort the underlying fetch and throw an ApiError(0, "timed out…").
46
53
  * Defaults to {@link DEFAULT_TIMEOUT_MS}. Set higher for known-slow paths
@@ -71,6 +78,14 @@ export declare function apiFetch<T>(path: string, options?: FetchOptions): Promi
71
78
  * network failures and timeouts both surface as `ApiError(0, …)` so the
72
79
  * callers don't need to special-case fetchRawContent vs apiFetch in
73
80
  * their `instanceof ApiError` catch arms.
81
+ *
82
+ * Attaches `Authorization: Bearer <token>` when one is saved so a signed-in
83
+ * user can read the raw bytes of their PRIVATE skills (the server gates
84
+ * `/api/raw` on the same predicate as the manifest). PUBLIC and platform
85
+ * bootstrap skills still resolve with no token, so first-run fetches (e.g.
86
+ * `@botdocs/cli-quickstart`) keep working unauthenticated. The TLS guard in
87
+ * getApiUrl() refuses to emit the bearer over plaintext to a non-loopback
88
+ * host — see {@link getApiUrl}.
74
89
  */
75
90
  export declare function fetchRawContent(rawUrl: string, options?: {
76
91
  timeoutMs?: number;
package/dist/lib/api.js CHANGED
@@ -131,12 +131,24 @@ export async function apiFetch(path, options = {}) {
131
131
  if (auth) {
132
132
  const config = loadAuth();
133
133
  if (!config?.token) {
134
- // Shape this as an ApiError(401) so callers that already branch on auth
135
- // failures (e.g. sync's optional team-skill path) handle it uniformly
136
- // without a separate "no token saved" code path.
137
- throw new ApiError(401, 'Not authenticated. Run `botdocs login` first.');
134
+ // `auth: 'optional'` means "attach the bearer if we have one, otherwise
135
+ // proceed anonymously" the server gates the read. Only the strict
136
+ // `auth: true` path throws when no token is saved. Shape that as an
137
+ // ApiError(401) so callers that already branch on auth failures (e.g.
138
+ // sync's optional team-skill path) handle it uniformly without a
139
+ // separate "no token saved" code path.
140
+ if (auth === 'optional') {
141
+ // No token — fall through and send the request without Authorization.
142
+ // The TLS guard in getApiUrl() above already ran, so the (absent)
143
+ // bearer can't leak over plaintext regardless.
144
+ }
145
+ else {
146
+ throw new ApiError(401, 'Not authenticated. Run `botdocs login` first.');
147
+ }
148
+ }
149
+ else {
150
+ headers['Authorization'] = `Bearer ${config.token}`;
138
151
  }
139
- headers['Authorization'] = `Bearer ${config.token}`;
140
152
  }
141
153
  // AbortController + setTimeout is the lowest-overhead way to enforce a hard
142
154
  // per-request deadline on Node's fetch — there's no built-in `timeout`
@@ -216,18 +228,31 @@ export async function apiFetch(path, options = {}) {
216
228
  * network failures and timeouts both surface as `ApiError(0, …)` so the
217
229
  * callers don't need to special-case fetchRawContent vs apiFetch in
218
230
  * their `instanceof ApiError` catch arms.
231
+ *
232
+ * Attaches `Authorization: Bearer <token>` when one is saved so a signed-in
233
+ * user can read the raw bytes of their PRIVATE skills (the server gates
234
+ * `/api/raw` on the same predicate as the manifest). PUBLIC and platform
235
+ * bootstrap skills still resolve with no token, so first-run fetches (e.g.
236
+ * `@botdocs/cli-quickstart`) keep working unauthenticated. The TLS guard in
237
+ * getApiUrl() refuses to emit the bearer over plaintext to a non-loopback
238
+ * host — see {@link getApiUrl}.
219
239
  */
220
240
  export async function fetchRawContent(rawUrl, options = {}) {
221
241
  const { timeoutMs = DEFAULT_TIMEOUT_MS } = options;
222
242
  const baseUrl = getApiUrl();
223
243
  const url = rawUrl.startsWith('http') ? rawUrl : `${baseUrl}${rawUrl}`;
244
+ const headers = {};
245
+ const config = loadAuth();
246
+ if (config?.token) {
247
+ headers['Authorization'] = `Bearer ${config.token}`;
248
+ }
224
249
  // Same pattern as apiFetch — AbortController + clearTimeout in finally so
225
250
  // the timer never outlives the request and blocks process exit.
226
251
  const controller = new AbortController();
227
252
  const timer = setTimeout(() => controller.abort(), timeoutMs);
228
253
  let response;
229
254
  try {
230
- response = await fetch(url, { signal: controller.signal });
255
+ response = await fetch(url, { headers, signal: controller.signal });
231
256
  }
232
257
  catch (err) {
233
258
  if (isAbortError(err)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botdocs/cli",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "CLI for BotDocs — author, publish, install, and sync agent skills across Claude, Claude Code, Cursor, Codex, ChatGPT, Windsurf, Copilot, Gemini, Antigravity, and OpenCode.",
5
5
  "keywords": [
6
6
  "botdocs",
@@ -1,3 +0,0 @@
1
- export declare function search(query: string, options?: {
2
- json?: boolean;
3
- }): Promise<void>;
@@ -1,58 +0,0 @@
1
- import { apiFetch } from '../lib/api.js';
2
- export async function search(query, options = {}) {
3
- if (!query.trim()) {
4
- console.error('Please provide a search query.');
5
- process.exit(1);
6
- }
7
- const encoded = encodeURIComponent(query);
8
- const data = await apiFetch(`/api/search?q=${encoded}`);
9
- if (options.json) {
10
- console.log(JSON.stringify(data, null, 2));
11
- return;
12
- }
13
- if (data.results.length === 0) {
14
- console.log(`No results for "${query}".`);
15
- return;
16
- }
17
- console.log(`${data.total} result(s) for "${query}":\n`);
18
- // Print formatted table
19
- const maxTitle = Math.min(40, Math.max(...data.results.map((r) => r.title.length)));
20
- const maxAuthor = Math.max(...data.results.map((r) => r.author.length));
21
- const header = [
22
- 'Title'.padEnd(maxTitle),
23
- 'Author'.padEnd(maxAuthor),
24
- 'Stars'.padStart(5),
25
- 'Clones'.padStart(6),
26
- 'Category',
27
- ].join(' ');
28
- const separator = '-'.repeat(header.length);
29
- console.log(header);
30
- console.log(separator);
31
- for (const result of data.results) {
32
- const title = result.title.length > maxTitle
33
- ? result.title.slice(0, maxTitle - 1) + '…'
34
- : result.title.padEnd(maxTitle);
35
- const row = [
36
- title,
37
- result.author.padEnd(maxAuthor),
38
- String(result.starCount).padStart(5),
39
- String(result.cloneCount).padStart(6),
40
- formatCategory(result.category),
41
- ].join(' ');
42
- console.log(row);
43
- }
44
- if (data.totalPages > 1) {
45
- console.log(`\nPage ${data.page}/${data.totalPages}`);
46
- }
47
- }
48
- function formatCategory(category) {
49
- const labels = {
50
- KNOWLEDGE_MANAGEMENT: 'Knowledge',
51
- DEV_WORKFLOW: 'Dev Workflow',
52
- AUTOMATION: 'Automation',
53
- AGENT_CONFIG: 'Agent Config',
54
- PROJECT_SCAFFOLD: 'Scaffold',
55
- OTHER: 'Other',
56
- };
57
- return labels[category] ?? category;
58
- }