@botdocs/cli 0.12.1 → 0.13.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.
@@ -0,0 +1,202 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ApiError, apiFetch, fetchRawContent, friendlyApiErrorDetail, getApiUrl } from '../lib/api.js';
4
+ import { loadAuth } from '../lib/config.js';
5
+ import { parseRef } from '../lib/ref.js';
6
+ import { fileSetHash } from '../lib/proposals.js';
7
+ import { collectFromDirectory, collectFromFile } from './publish.js';
8
+ /** Bail before any file reading if the user isn't logged in. Mirrors
9
+ * publish.ts's preflight: one friendly line, exit 1. Proposing requires auth
10
+ * (the server attributes the proposal to the caller). */
11
+ function requireAuth(options) {
12
+ if (loadAuth())
13
+ return;
14
+ if (options.json) {
15
+ console.log(JSON.stringify({ ok: false, error: 'not logged in', hint: 'Run `botdocs login` first.' }));
16
+ }
17
+ else {
18
+ console.error('\n ✗ Not logged in. Run `botdocs login` first.\n');
19
+ }
20
+ process.exit(1);
21
+ }
22
+ /** True when the logged-in user owns this ref. A skill ref's username IS the
23
+ * author's username (the registry resolves `@user/slug` by the author's
24
+ * handle), so ownership is a case-insensitive username match. Used only to
25
+ * tailor the success copy — the server is the authority on what the proposal
26
+ * actually does (an owner proposing still gets a proposal row, not a publish).
27
+ */
28
+ function isOwnRef(username) {
29
+ const me = loadAuth()?.username;
30
+ if (!me)
31
+ return false;
32
+ return me.toLowerCase() === username.toLowerCase();
33
+ }
34
+ /** Pull the live published file set (filename + content) so we can compute the
35
+ * canonical basedFileHash the server uses for drift detection. The manifest
36
+ * lists files by rawUrl; we fetch each body. Returns null on a non-SKILL
37
+ * manifest (proposals only apply to skills). */
38
+ async function fetchLiveFileSet(username, slug) {
39
+ const manifest = await apiFetch(`/api/skills/${username}/${slug}/manifest`);
40
+ if (manifest.type !== 'SKILL' || !manifest.files)
41
+ return null;
42
+ const files = await Promise.all(manifest.files.map(async (f) => ({
43
+ filename: f.filename,
44
+ content: await fetchRawContent(f.rawUrl),
45
+ })));
46
+ return files;
47
+ }
48
+ /**
49
+ * `botdocs propose @user/slug [path]` — queue a change to a skill for the
50
+ * author to review, instead of publishing directly.
51
+ *
52
+ * Collects the local files (directory or single markdown file, default cwd),
53
+ * computes `basedFileHash` over the skill's CURRENT live file set (the base
54
+ * the author will diff against), and POSTs the proposal. The author reviews +
55
+ * accepts/rejects from the Proposals tab or `botdocs proposals accept`.
56
+ */
57
+ export async function propose(rawRef, pathArg, options) {
58
+ requireAuth(options);
59
+ let ref;
60
+ try {
61
+ ref = parseRef(rawRef);
62
+ }
63
+ catch (err) {
64
+ console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`);
65
+ process.exit(1);
66
+ }
67
+ const refStr = `@${ref.username}/${ref.slug}`;
68
+ // Collect the proposed files from the local path (default: cwd).
69
+ const source = pathArg ?? '.';
70
+ const resolved = path.resolve(source);
71
+ if (!fs.existsSync(resolved)) {
72
+ console.error(`\n ✗ Source not found: ${source}\n`);
73
+ process.exit(1);
74
+ }
75
+ let files;
76
+ const stat = fs.statSync(resolved);
77
+ if (stat.isDirectory()) {
78
+ files = collectFromDirectory(resolved);
79
+ }
80
+ else {
81
+ files = collectFromFile(resolved);
82
+ }
83
+ if (files.length === 0) {
84
+ console.error('\n ✗ No markdown files found to propose.\n');
85
+ process.exit(1);
86
+ }
87
+ if (!files.some((f) => f.filename.endsWith('.md') || f.filename.endsWith('.mdc'))) {
88
+ console.error('\n ✗ A proposal needs at least one markdown file (.md or .mdc).\n');
89
+ process.exit(1);
90
+ }
91
+ // Fetch the live file set so basedFileHash anchors the proposal to the exact
92
+ // base the author will see. A drift between this and what the server stores
93
+ // becomes the "skill moved while you reviewed" guard on accept.
94
+ let liveFiles;
95
+ try {
96
+ liveFiles = await fetchLiveFileSet(ref.username, ref.slug);
97
+ }
98
+ catch (err) {
99
+ if (err instanceof ApiError && err.status === 404) {
100
+ console.error(`\n ✗ Skill not found: ${refStr}\n`);
101
+ process.exit(1);
102
+ }
103
+ if (err instanceof ApiError) {
104
+ console.error(`\n ✗ ${refStr}: ${friendlyApiErrorDetail(err, refStr)}\n`);
105
+ process.exit(1);
106
+ }
107
+ throw err;
108
+ }
109
+ if (liveFiles === null) {
110
+ console.error('\n ✗ Proposals only apply to skills (not bundles).\n');
111
+ process.exit(1);
112
+ }
113
+ const basedFileHash = fileSetHash(liveFiles);
114
+ let result;
115
+ try {
116
+ result = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/proposals`, {
117
+ method: 'POST',
118
+ auth: true,
119
+ body: {
120
+ files: files.map((f) => ({
121
+ filename: f.filename,
122
+ content: f.content,
123
+ sortOrder: f.sortOrder,
124
+ })),
125
+ changelog: options.message,
126
+ agentLabel: options.agentLabel,
127
+ basedFileHash,
128
+ },
129
+ });
130
+ }
131
+ catch (err) {
132
+ handleProposeError(err, refStr, options);
133
+ return;
134
+ }
135
+ const own = isOwnRef(ref.username);
136
+ // The #proposals deep link only renders for the author, so we only print it
137
+ // when the proposer owns the skill. A non-author operator gets the plain
138
+ // "queued for review" line with no dead link.
139
+ const reviewUrl = own
140
+ ? `${getApiUrl()}/${refStr}#proposals`
141
+ : `${getApiUrl()}/${refStr}`;
142
+ if (options.json) {
143
+ console.log(JSON.stringify({
144
+ ok: true,
145
+ ref: refStr,
146
+ proposalId: result.proposalId,
147
+ status: result.status,
148
+ baseVersion: result.baseVersion,
149
+ url: reviewUrl,
150
+ }));
151
+ return;
152
+ }
153
+ console.log(`\n ✓ Proposal queued for ${refStr} (id ${result.proposalId}).`);
154
+ if (own) {
155
+ console.log(` Review it at ${reviewUrl}\n`);
156
+ }
157
+ else {
158
+ console.log(` The skill author will review it. ${reviewUrl}\n`);
159
+ }
160
+ }
161
+ /** Map a propose API error to a friendly, actionable CLI message.
162
+ *
163
+ * 404 — skill not found (or unreadable; the server collapses both to 404).
164
+ * 413 — proposal too large.
165
+ * 429 — per-skill OPEN cap or per-proposer/day cap; the server sends a
166
+ * structured `{ error, message }` we surface.
167
+ * Everything else routes through the shared helper. */
168
+ function handleProposeError(err, refStr, options) {
169
+ if (!(err instanceof ApiError)) {
170
+ throw err;
171
+ }
172
+ const body = err.body;
173
+ if (options.json) {
174
+ console.log(JSON.stringify({
175
+ ok: false,
176
+ ref: refStr,
177
+ status: err.status,
178
+ error: err.message,
179
+ message: body?.message ?? null,
180
+ }));
181
+ process.exit(1);
182
+ }
183
+ if (err.status === 404) {
184
+ console.error(`\n ✗ Skill not found: ${refStr}\n`);
185
+ }
186
+ else if (err.status === 413) {
187
+ console.error(`\n ✗ Proposal too large for ${refStr}. Trim the files and try again.\n`);
188
+ }
189
+ else if (err.status === 429) {
190
+ // The review queue is full or you've hit the per-day proposal limit. The
191
+ // server's structured message is the most specific; fall back to a generic
192
+ // line.
193
+ console.error(`\n ✗ ${body?.message ?? 'Proposal rate limit reached — try again later.'}\n`);
194
+ }
195
+ else if (err.status === 401) {
196
+ console.error(`\n ✗ ${err.message}\n`);
197
+ }
198
+ else {
199
+ console.error(`\n ✗ ${refStr}: ${friendlyApiErrorDetail(err, refStr)}\n`);
200
+ }
201
+ process.exit(1);
202
+ }
@@ -18,7 +18,7 @@ interface PublishOptions {
18
18
  * draft → published flag is already cheap and reversible via `unpublish`). */
19
19
  dryRun?: boolean;
20
20
  }
21
- interface FileEntry {
21
+ export interface FileEntry {
22
22
  filename: string;
23
23
  content: string;
24
24
  sortOrder: number;
@@ -49,6 +49,8 @@ declare function publishRef(rawRef: string, options: PublishOptions): Promise<vo
49
49
  */
50
50
  declare function handlePublishToggleError(err: unknown, refLabel: string, options: PublishOptions): void;
51
51
  export { publishRef, handlePublishToggleError };
52
+ export declare function collectFromFile(filePath: string): FileEntry[];
53
+ export declare function collectFromDirectory(dirPath: string): FileEntry[];
52
54
  /**
53
55
  * Recursively collect publishable markdown files under `currentDir`.
54
56
  *
@@ -460,12 +460,12 @@ function derivePublishSlug(source) {
460
460
  .replace(/[^a-z0-9]+/g, '-')
461
461
  .replace(/^-+|-+$/g, '');
462
462
  }
463
- function collectFromFile(filePath) {
463
+ export function collectFromFile(filePath) {
464
464
  const content = fs.readFileSync(filePath, 'utf-8');
465
465
  const filename = path.basename(filePath);
466
466
  return [{ filename, content, sortOrder: 0 }];
467
467
  }
468
- function collectFromDirectory(dirPath) {
468
+ export function collectFromDirectory(dirPath) {
469
469
  const files = [];
470
470
  walkDirectory(dirPath, dirPath, files);
471
471
  files.sort((a, b) => a.sortOrder - b.sortOrder);
@@ -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';
@@ -127,6 +126,8 @@ import { ingest, SUPPORTED_TOOLS as INGEST_SUPPORTED_TOOLS } from './commands/in
127
126
  import { checkUpdates } from './commands/check-updates.js';
128
127
  import { compile } from './commands/compile.js';
129
128
  import { edit } from './commands/edit.js';
129
+ import { propose } from './commands/propose.js';
130
+ import { registerProposalsCommands } from './commands/proposals.js';
130
131
  import { registerTeamCommands } from './commands/team.js';
131
132
  import { registerBackupCommands } from './commands/backups.js';
132
133
  import { undo } from './commands/undo.js';
@@ -179,12 +180,6 @@ program
179
180
  .action(async (source) => {
180
181
  await validate(source, { json: program.opts().json });
181
182
  });
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
183
  program
189
184
  .command('publish <source>')
190
185
  .description('Publish a BotDoc — pass a local path to upload, or @user/slug to mark an existing draft live')
@@ -332,6 +327,19 @@ program
332
327
  .action(async (ref, opts) => {
333
328
  await edit(ref, { ...opts, json: program.opts().json });
334
329
  });
330
+ program
331
+ .command('propose <ref> [path]')
332
+ .description('Queue a change to a skill for its author to review (instead of publishing directly)')
333
+ .option('--message <message>', 'Changelog describing the proposed change')
334
+ .option('--agent-label <label>', 'Self-reported agent identity (e.g. hermes-v2); shown to the author as unverified')
335
+ .action(async (ref, pathArg, opts) => {
336
+ await propose(ref, pathArg, { ...opts, json: program.opts().json });
337
+ });
338
+ // `proposals` (list, default) + its `accept` subcommand. Registered via a
339
+ // helper (like team/backups) so the nested `accept` lives in proposals.ts —
340
+ // `botdocs proposals @user/slug` lists; `botdocs proposals accept @user/slug
341
+ // --id X` accepts.
342
+ registerProposalsCommands(program);
335
343
  registerTeamCommands(program);
336
344
  registerBackupCommands(program);
337
345
  program
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)) {
@@ -0,0 +1,37 @@
1
+ /** A file for hashing — ONLY filename + content participate. */
2
+ export interface HashableFile {
3
+ filename: string;
4
+ content: string;
5
+ }
6
+ /**
7
+ * Canonical file-set hash. Mirrors the server's `fileSetHash` exactly so the
8
+ * `basedFileHash` the CLI sends matches what the server would compute over the
9
+ * same live file set. Sort is by filename ascending using the same comparison
10
+ * the server uses (`<` / `>` on the raw filename string).
11
+ */
12
+ export declare function fileSetHash(files: ReadonlyArray<HashableFile>): string;
13
+ /**
14
+ * Canonical SYNTHESIS idempotency hash. DISTINCT from {@link fileSetHash}
15
+ * (which is the drift anchor / `basedFileHash`). The server keys the
16
+ * partial-unique index `botdoc_proposals_synthesis_hash_uq` on this value so
17
+ * the same OPEN synthesis (same sources + same merged content) can't be
18
+ * created twice — the CLI computes it before POSTing only so the value is
19
+ * stable client-side; the server recomputes + enforces it.
20
+ *
21
+ * MUST match `apps/web/src/lib/proposals.ts#sourceHash` byte-for-byte:
22
+ *
23
+ * sha256-hex( JSON.stringify({
24
+ * sources: [...new Set(synthesizedFrom)].sort(),
25
+ * files: [...mergedFiles]
26
+ * .sort by filename ascending (same `<`/`>` compare as fileSetHash)
27
+ * .map(f => [f.filename, sha256hex(f.content)]),
28
+ * }) )
29
+ *
30
+ * Notes (identical exclusions to fileSetHash):
31
+ * - `sources` is deduplicated + lexicographically sorted, so source-id
32
+ * ordering / duplicates don't change the hash.
33
+ * - `files` pairs each filename with the sha256 of its content (not the raw
34
+ * body) to keep the JSON small.
35
+ * - `mode`/`sortOrder` are NOT part of the hash.
36
+ */
37
+ export declare function sourceHash(synthesizedFrom: ReadonlyArray<string>, mergedFiles: ReadonlyArray<HashableFile>): string;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * proposals.ts — shared helpers for the CLI proposal lane.
3
+ *
4
+ * The load-bearing piece here is {@link fileSetHash}: the canonical file-set
5
+ * hash that the server computes in `apps/web/src/lib/proposals.ts#fileSetHash`.
6
+ * The two MUST agree byte-for-byte — the server uses it as the drift anchor
7
+ * (`basedFileHash`) the CLI sends on every `propose`. Any divergence (sort
8
+ * order, framing byte, included fields) would make the server reject or
9
+ * mis-detect drift for every proposal.
10
+ *
11
+ * Canonical spec (identical on both sides):
12
+ * - sha256, hex digest
13
+ * - over each file sorted by filename ascending (JS default string compare)
14
+ * - of: filename + "\u0000" + content + "\u0000"
15
+ * - `mode` and `sortOrder` are NOT part of the hash.
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+ /** NUL framing byte between/after each (filename, content) pair. Defined once
19
+ * so the intent is obvious and it can never be mistaken for a space. */
20
+ const NUL = '\u0000';
21
+ /**
22
+ * Canonical file-set hash. Mirrors the server's `fileSetHash` exactly so the
23
+ * `basedFileHash` the CLI sends matches what the server would compute over the
24
+ * same live file set. Sort is by filename ascending using the same comparison
25
+ * the server uses (`<` / `>` on the raw filename string).
26
+ */
27
+ export function fileSetHash(files) {
28
+ const sorted = [...files].sort((a, b) => a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0);
29
+ const hash = createHash('sha256');
30
+ for (const f of sorted) {
31
+ hash.update(f.filename);
32
+ hash.update(NUL);
33
+ hash.update(f.content);
34
+ hash.update(NUL);
35
+ }
36
+ return hash.digest('hex');
37
+ }
38
+ /** sha256-hex of a single string (a file's content). Helper for {@link sourceHash}. */
39
+ function sha256Hex(value) {
40
+ return createHash('sha256').update(value).digest('hex');
41
+ }
42
+ /**
43
+ * Canonical SYNTHESIS idempotency hash. DISTINCT from {@link fileSetHash}
44
+ * (which is the drift anchor / `basedFileHash`). The server keys the
45
+ * partial-unique index `botdoc_proposals_synthesis_hash_uq` on this value so
46
+ * the same OPEN synthesis (same sources + same merged content) can't be
47
+ * created twice — the CLI computes it before POSTing only so the value is
48
+ * stable client-side; the server recomputes + enforces it.
49
+ *
50
+ * MUST match `apps/web/src/lib/proposals.ts#sourceHash` byte-for-byte:
51
+ *
52
+ * sha256-hex( JSON.stringify({
53
+ * sources: [...new Set(synthesizedFrom)].sort(),
54
+ * files: [...mergedFiles]
55
+ * .sort by filename ascending (same `<`/`>` compare as fileSetHash)
56
+ * .map(f => [f.filename, sha256hex(f.content)]),
57
+ * }) )
58
+ *
59
+ * Notes (identical exclusions to fileSetHash):
60
+ * - `sources` is deduplicated + lexicographically sorted, so source-id
61
+ * ordering / duplicates don't change the hash.
62
+ * - `files` pairs each filename with the sha256 of its content (not the raw
63
+ * body) to keep the JSON small.
64
+ * - `mode`/`sortOrder` are NOT part of the hash.
65
+ */
66
+ export function sourceHash(synthesizedFrom, mergedFiles) {
67
+ const sources = [...new Set(synthesizedFrom)].sort();
68
+ const files = [...mergedFiles]
69
+ .sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0))
70
+ .map((f) => [f.filename, sha256Hex(f.content)]);
71
+ return sha256Hex(JSON.stringify({ sources, files }));
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botdocs/cli",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
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
- }