@botdocs/cli 0.12.2 → 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.
@@ -5,6 +5,8 @@ import * as p from '@clack/prompts';
5
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
+ import { loadAuth } from '../lib/config.js';
9
+ import { fileSetHash } from '../lib/proposals.js';
8
10
  const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'ecosystem-prompts');
9
11
  function loadEditPrompt() {
10
12
  return fs.readFileSync(path.join(TEMPLATES_DIR, 'edit.md'), 'utf-8');
@@ -16,6 +18,17 @@ function parseRef(raw) {
16
18
  throw new Error(`Invalid ref: ${raw}`);
17
19
  return { username: parts[0], slug: parts[1] };
18
20
  }
21
+ /** True when the logged-in user owns this skill. A skill ref's username IS the
22
+ * author's username (the registry resolves `@user/slug` by the author's
23
+ * handle), so ownership is a case-insensitive username match. Drives the
24
+ * branch between the author's lower-friction draft loop and the cross-author
25
+ * proposal lane. */
26
+ function isOwnSkill(username) {
27
+ const me = loadAuth()?.username;
28
+ if (!me)
29
+ return false;
30
+ return me.toLowerCase() === username.toLowerCase();
31
+ }
19
32
  function fileForEcosystem(files, eco) {
20
33
  if (eco === 'claude')
21
34
  return files.find((f) => f.filename === 'claude/SKILL.md');
@@ -113,11 +126,62 @@ export async function edit(rawRef, options) {
113
126
  break;
114
127
  // else regenerate — loop
115
128
  }
116
- await apiFetch(`/api/skills/${ref.username}/${ref.slug}/draft`, {
117
- method: 'POST',
118
- auth: true,
119
- body: { ecosystem: options.ecosystem, content: revised },
120
- });
121
- console.log(`\n ✓ Pushed draft for ${rawRef} (${options.ecosystem}).`);
122
- console.log(` Visit https://botdocs.ai/@${ref.username}/${ref.slug} to publish.\n`);
129
+ // Ownership branch (design §7): the author's own skill keeps the
130
+ // lower-friction draft→publish loop; a skill owned by someone else routes
131
+ // through the proposal lane so the change lands in the author's review queue
132
+ // instead of clobbering their live files.
133
+ if (isOwnSkill(ref.username)) {
134
+ await apiFetch(`/api/skills/${ref.username}/${ref.slug}/draft`, {
135
+ method: 'POST',
136
+ auth: true,
137
+ body: { ecosystem: options.ecosystem, content: revised },
138
+ });
139
+ console.log(`\n ✓ Pushed draft for ${rawRef} (${options.ecosystem}).`);
140
+ console.log(` Visit https://botdocs.ai/@${ref.username}/${ref.slug} to publish.\n`);
141
+ return;
142
+ }
143
+ await proposeEdit(ref, manifest, target.filename, currentContent, revised);
144
+ }
145
+ /**
146
+ * Queue an edited file as a proposal for the skill's author to review.
147
+ *
148
+ * A proposal replaces the skill's ENTIRE file set, so we fetch every live file
149
+ * and swap in the revised content for the one ecosystem file we edited. The
150
+ * `basedFileHash` is computed over the UNMODIFIED live set — it's the drift
151
+ * anchor the author's accept is checked against.
152
+ */
153
+ async function proposeEdit(ref, manifest, targetFilename, targetCurrentContent, revised) {
154
+ // Build the live file set (filename + content). We already have the edited
155
+ // file's current content; fetch the rest.
156
+ const liveFiles = await Promise.all(manifest.files.map(async (f) => ({
157
+ filename: f.filename,
158
+ content: f.filename === targetFilename ? targetCurrentContent : await fetchRawContent(f.rawUrl),
159
+ })));
160
+ const basedFileHash = fileSetHash(liveFiles);
161
+ // The proposed set is the live set with the edited file's content replaced.
162
+ const proposedFiles = liveFiles.map((f, i) => ({
163
+ filename: f.filename,
164
+ content: f.filename === targetFilename ? revised : f.content,
165
+ sortOrder: i,
166
+ }));
167
+ try {
168
+ await apiFetch(`/api/skills/${ref.username}/${ref.slug}/proposals`, {
169
+ method: 'POST',
170
+ auth: true,
171
+ body: {
172
+ files: proposedFiles,
173
+ agentLabel: 'cli-edit',
174
+ basedFileHash,
175
+ },
176
+ });
177
+ }
178
+ catch (err) {
179
+ if (err instanceof ApiError) {
180
+ console.error(`\n ✗ @${ref.username}/${ref.slug}: ${friendlyApiErrorDetail(err, `@${ref.username}/${ref.slug}`)}\n`);
181
+ process.exit(1);
182
+ }
183
+ throw err;
184
+ }
185
+ console.log(`\n ✓ Proposal queued for @${ref.username}/${ref.slug} (${targetFilename}).`);
186
+ console.log(' The skill author will review it before it goes live.\n');
123
187
  }
@@ -0,0 +1,69 @@
1
+ import { Command } from 'commander';
2
+ interface ProposalsListOptions {
3
+ /** Filter by status: OPEN (default) | ACCEPTED | REJECTED | all. */
4
+ status?: string;
5
+ json?: boolean;
6
+ }
7
+ interface AcceptOptions {
8
+ /** The proposal id to accept. Required. */
9
+ id?: string;
10
+ json?: boolean;
11
+ }
12
+ interface SynthesizeOptions {
13
+ /** Changelog describing the merge, shown to the reviewing author. */
14
+ message?: string;
15
+ /** Self-reported agent identity (e.g. "nightly-synth"). UNTRUSTED server-side. */
16
+ agentLabel?: string;
17
+ /** Skip the interactive confirm (for cron / non-interactive use). */
18
+ yes?: boolean;
19
+ /** Env var holding the LLM key (passed through to `complete`). */
20
+ keyEnv?: string;
21
+ json?: boolean;
22
+ }
23
+ /**
24
+ * `botdocs proposals @user/slug [--status OPEN|ACCEPTED|REJECTED|all]` — list
25
+ * the proposals queued against a skill as a table (or `--json`).
26
+ *
27
+ * Authors see full metadata; non-author readers see the redacted status view
28
+ * (Q5: visibility without the ability to accept/reject).
29
+ */
30
+ export declare function proposals(rawRef: string, options: ProposalsListOptions): Promise<void>;
31
+ /**
32
+ * `botdocs proposals accept @user/slug --id X` — accept a proposal, publishing
33
+ * it as a new version. Author-only (the server 403s non-authors).
34
+ *
35
+ * Fetches the current version first so we can send `basedOnCurrentVersion` —
36
+ * the optimistic-concurrency anchor. If the skill moved since (a 409
37
+ * stale-base), we tell the user to re-review rather than silently clobbering.
38
+ */
39
+ export declare function proposalsAccept(rawRef: string, options: AcceptOptions): Promise<void>;
40
+ /**
41
+ * `botdocs proposals synthesize @user/slug` — merge a skill's OPEN proposals
42
+ * into ONE synthesis proposal for the author to review.
43
+ *
44
+ * Runs with a SYNTHESIZER token (a low-privilege principal the author minted,
45
+ * scoped to this skill). The synthesizer can ONLY read the skill's proposals
46
+ * and create a synthesis — it can never accept, publish, or edit. The author
47
+ * still reviews and approves the merge through the normal accept path.
48
+ *
49
+ * Steps:
50
+ * 1. List OPEN proposals (permitted for a scoped synthesizer).
51
+ * 2. Fetch each OPEN proposal's full contents + the skill's live file set
52
+ * (the DETAIL endpoint returns both `files` and `currentFiles`).
53
+ * 3. Merge the live set + the N proposed sets into one file set via the LLM.
54
+ * 4. Preview the merge (live vs merged) and confirm unless `--yes`.
55
+ * 5. POST the synthesis with basedFileHash = fileSetHash(live files).
56
+ *
57
+ * A 409 `duplicate_synthesis` is treated as a NO-OP SUCCESS (exit 0): the same
58
+ * merge already exists OPEN, so an end-of-day cron re-running this is safe.
59
+ */
60
+ export declare function proposalsSynthesize(rawRef: string, options: SynthesizeOptions): Promise<void>;
61
+ /**
62
+ * Register the `proposals` command (list, default) and its `accept`
63
+ * subcommand on the program. Done in a registrar helper (like `team`/`backups`)
64
+ * so the nested `accept` subcommand lives in this file rather than inline in
65
+ * index.ts — keeping index.ts's top-level surface clean and the quickstart
66
+ * drift-check parser happy (it lists only the parent verb `proposals`).
67
+ */
68
+ export declare function registerProposalsCommands(program: Command): void;
69
+ export {};
@@ -0,0 +1,556 @@
1
+ import * as p from '@clack/prompts';
2
+ import { ApiError, apiFetch, friendlyApiErrorDetail, getApiUrl } from '../lib/api.js';
3
+ import { loadAuth } from '../lib/config.js';
4
+ import { parseRef } from '../lib/ref.js';
5
+ import { fileSetHash } from '../lib/proposals.js';
6
+ import { complete, detectProvider, LlmError } from '../lib/llm.js';
7
+ import { renderDiff } from '../lib/diff.js';
8
+ const VALID_STATUSES = new Set(['OPEN', 'ACCEPTED', 'REJECTED', 'all']);
9
+ /** System prompt for the merge. The model receives the live file set and N
10
+ * proposed change-sets and must return ONE coherent merged file set as JSON. */
11
+ const SYNTHESIZE_SYSTEM = `You merge multiple proposed edits to an agent-skill file set into ONE coherent result.
12
+
13
+ You are given:
14
+ 1. The CURRENT (live) file set — the base every proposal was derived from.
15
+ 2. N PROPOSALS, each a full proposed file set (an alternative version of the same skill).
16
+
17
+ Produce a SINGLE merged file set that incorporates the intent of every proposal
18
+ without conflicting edits. Preserve content none of the proposals touched. When
19
+ two proposals change the same region differently, reconcile them sensibly rather
20
+ than dropping one. Do not invent files that no proposal introduced.
21
+
22
+ Output ONLY a JSON object of this exact shape, no prose, no code fences:
23
+ {"files":[{"filename":"<path>","content":"<full file content>"}, ...]}
24
+
25
+ Every filename present in the CURRENT set (or added by a proposal) must appear
26
+ exactly once in "files" with its full final content.`;
27
+ /** Bail before any API call if the user isn't logged in. The LIST + accept
28
+ * endpoints both require auth (admitted for list, author for accept). */
29
+ function requireAuth(options) {
30
+ if (loadAuth())
31
+ return;
32
+ if (options.json) {
33
+ console.log(JSON.stringify({ ok: false, error: 'not logged in', hint: 'Run `botdocs login` first.' }));
34
+ }
35
+ else {
36
+ console.error('\n ✗ Not logged in. Run `botdocs login` first.\n');
37
+ }
38
+ process.exit(1);
39
+ }
40
+ /**
41
+ * `botdocs proposals @user/slug [--status OPEN|ACCEPTED|REJECTED|all]` — list
42
+ * the proposals queued against a skill as a table (or `--json`).
43
+ *
44
+ * Authors see full metadata; non-author readers see the redacted status view
45
+ * (Q5: visibility without the ability to accept/reject).
46
+ */
47
+ export async function proposals(rawRef, options) {
48
+ requireAuth(options);
49
+ let ref;
50
+ try {
51
+ ref = parseRef(rawRef);
52
+ }
53
+ catch (err) {
54
+ console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`);
55
+ process.exit(1);
56
+ }
57
+ const refStr = `@${ref.username}/${ref.slug}`;
58
+ const status = options.status;
59
+ if (status && !VALID_STATUSES.has(status)) {
60
+ console.error(`\n ✗ Invalid --status "${status}". Use OPEN, ACCEPTED, REJECTED, or all.\n`);
61
+ process.exit(1);
62
+ }
63
+ const query = status ? `?status=${encodeURIComponent(status)}` : '';
64
+ let data;
65
+ try {
66
+ data = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/proposals${query}`, { auth: true });
67
+ }
68
+ catch (err) {
69
+ handleListError(err, refStr, options);
70
+ return;
71
+ }
72
+ if (options.json) {
73
+ console.log(JSON.stringify(data));
74
+ return;
75
+ }
76
+ if (data.proposals.length === 0) {
77
+ const label = status && status !== 'all' ? status.toLowerCase() : 'open';
78
+ console.log(`\n No ${label} proposals for ${refStr}.\n`);
79
+ return;
80
+ }
81
+ console.log(`\n Proposals for ${refStr} (current v${data.currentVersion}):\n`);
82
+ printProposalsTable(data.proposals);
83
+ if (data.isAuthor) {
84
+ console.log(`\n Accept one with: botdocs proposals accept ${refStr} --id <id>\n`);
85
+ }
86
+ else {
87
+ console.log('');
88
+ }
89
+ }
90
+ /** Render the proposals as an aligned table in `botdocs search`/`list` style. */
91
+ function printProposalsTable(rows) {
92
+ const idWidth = Math.max(2, ...rows.map((r) => r.id.length));
93
+ const fromWidth = Math.max(4, ...rows.map((r) => proposerLabel(r).length));
94
+ const statusWidth = Math.max(6, ...rows.map((r) => r.status.length));
95
+ const header = [
96
+ 'ID'.padEnd(idWidth),
97
+ 'From'.padEnd(fromWidth),
98
+ 'Status'.padEnd(statusWidth),
99
+ 'Base'.padStart(5),
100
+ 'Files',
101
+ ].join(' ');
102
+ console.log(` ${header}`);
103
+ console.log(` ${'-'.repeat(header.length)}`);
104
+ for (const r of rows) {
105
+ const row = [
106
+ r.id.padEnd(idWidth),
107
+ proposerLabel(r).padEnd(fromWidth),
108
+ r.status.padEnd(statusWidth),
109
+ `v${r.baseVersion}`.padStart(5),
110
+ String(r.fileNames.length),
111
+ ].join(' ');
112
+ console.log(` ${row}`);
113
+ }
114
+ }
115
+ /** A short, server-derived "From" label. `trust` is authoritative (set
116
+ * server-side from source + author identity); the self-reported `agentLabel`
117
+ * is appended only as a hint, never as the trust signal itself. */
118
+ function proposerLabel(r) {
119
+ const base = r.trust === 'author' ? 'you' : r.trust === 'synthesis' ? 'synthesis' : 'agent';
120
+ if (r.agentLabel && r.trust === 'agent')
121
+ return `${base} (${r.agentLabel})`;
122
+ return base;
123
+ }
124
+ /**
125
+ * `botdocs proposals accept @user/slug --id X` — accept a proposal, publishing
126
+ * it as a new version. Author-only (the server 403s non-authors).
127
+ *
128
+ * Fetches the current version first so we can send `basedOnCurrentVersion` —
129
+ * the optimistic-concurrency anchor. If the skill moved since (a 409
130
+ * stale-base), we tell the user to re-review rather than silently clobbering.
131
+ */
132
+ export async function proposalsAccept(rawRef, options) {
133
+ requireAuth(options);
134
+ let ref;
135
+ try {
136
+ ref = parseRef(rawRef);
137
+ }
138
+ catch (err) {
139
+ console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`);
140
+ process.exit(1);
141
+ }
142
+ const refStr = `@${ref.username}/${ref.slug}`;
143
+ const id = options.id;
144
+ if (!id) {
145
+ console.error('\n ✗ --id <proposalId> is required. Run `botdocs proposals ' + refStr + '` to list ids.\n');
146
+ process.exit(1);
147
+ }
148
+ // Read the current version so accept can send the stale-base anchor.
149
+ let currentVersion;
150
+ try {
151
+ const list = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/proposals?status=all`, { auth: true });
152
+ currentVersion = list.currentVersion;
153
+ }
154
+ catch (err) {
155
+ handleListError(err, refStr, options);
156
+ return;
157
+ }
158
+ let result;
159
+ try {
160
+ result = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/proposals/${id}`, {
161
+ method: 'PATCH',
162
+ auth: true,
163
+ body: { decision: 'accept', basedOnCurrentVersion: currentVersion },
164
+ });
165
+ }
166
+ catch (err) {
167
+ handleAcceptError(err, refStr, id, options);
168
+ return;
169
+ }
170
+ if (options.json) {
171
+ console.log(JSON.stringify({ ok: true, ref: refStr, id, version: result.version }));
172
+ return;
173
+ }
174
+ console.log(`\n ✓ Accepted proposal ${id} — published v${result.version} of ${refStr}.\n`);
175
+ }
176
+ /**
177
+ * `botdocs proposals synthesize @user/slug` — merge a skill's OPEN proposals
178
+ * into ONE synthesis proposal for the author to review.
179
+ *
180
+ * Runs with a SYNTHESIZER token (a low-privilege principal the author minted,
181
+ * scoped to this skill). The synthesizer can ONLY read the skill's proposals
182
+ * and create a synthesis — it can never accept, publish, or edit. The author
183
+ * still reviews and approves the merge through the normal accept path.
184
+ *
185
+ * Steps:
186
+ * 1. List OPEN proposals (permitted for a scoped synthesizer).
187
+ * 2. Fetch each OPEN proposal's full contents + the skill's live file set
188
+ * (the DETAIL endpoint returns both `files` and `currentFiles`).
189
+ * 3. Merge the live set + the N proposed sets into one file set via the LLM.
190
+ * 4. Preview the merge (live vs merged) and confirm unless `--yes`.
191
+ * 5. POST the synthesis with basedFileHash = fileSetHash(live files).
192
+ *
193
+ * A 409 `duplicate_synthesis` is treated as a NO-OP SUCCESS (exit 0): the same
194
+ * merge already exists OPEN, so an end-of-day cron re-running this is safe.
195
+ */
196
+ export async function proposalsSynthesize(rawRef, options) {
197
+ requireAuth(options);
198
+ let ref;
199
+ try {
200
+ ref = parseRef(rawRef);
201
+ }
202
+ catch (err) {
203
+ console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`);
204
+ process.exit(1);
205
+ }
206
+ const refStr = `@${ref.username}/${ref.slug}`;
207
+ // Fail fast if no LLM key is configured — the merge can't run without it.
208
+ try {
209
+ detectProvider({ keyEnv: options.keyEnv });
210
+ }
211
+ catch (err) {
212
+ if (err instanceof LlmError) {
213
+ console.error(`\n ✗ ${err.message}\n`);
214
+ process.exit(1);
215
+ }
216
+ throw err;
217
+ }
218
+ const base = `/api/skills/${ref.username}/${ref.slug}/proposals`;
219
+ // 1. List OPEN proposals (permitted for a scoped synthesizer).
220
+ let list;
221
+ try {
222
+ list = await apiFetch(`${base}?status=OPEN`, { auth: true });
223
+ }
224
+ catch (err) {
225
+ handleSynthesizeError(err, refStr, options);
226
+ return;
227
+ }
228
+ const openProposals = list.proposals.filter((pr) => pr.status === 'OPEN');
229
+ if (openProposals.length === 0) {
230
+ if (options.json) {
231
+ console.log(JSON.stringify({ ok: true, ref: refStr, skipped: 'no_open_proposals' }));
232
+ }
233
+ else {
234
+ console.log(`\n No open proposals to synthesize for ${refStr}. Nothing to do.\n`);
235
+ }
236
+ return;
237
+ }
238
+ if (openProposals.length === 1) {
239
+ // A "merge" of one proposal is just that proposal — synthesizing adds no
240
+ // value and would only create a duplicate of the existing OPEN proposal.
241
+ if (options.json) {
242
+ console.log(JSON.stringify({ ok: true, ref: refStr, skipped: 'single_open_proposal' }));
243
+ }
244
+ else {
245
+ console.log(`\n Only one open proposal for ${refStr}; nothing to merge. ` +
246
+ `The author can review it directly.\n`);
247
+ }
248
+ return;
249
+ }
250
+ // 2. Fetch each OPEN proposal's full contents + the live file set.
251
+ const sourceProposalIds = openProposals.map((pr) => pr.id);
252
+ let details;
253
+ try {
254
+ details = await Promise.all(sourceProposalIds.map((id) => apiFetch(`${base}/${encodeURIComponent(id)}`, { auth: true })));
255
+ }
256
+ catch (err) {
257
+ handleSynthesizeError(err, refStr, options);
258
+ return;
259
+ }
260
+ // The live file set (merge base) is identical across DETAIL responses; take
261
+ // the first. basedFileHash anchors the synthesis to this exact base — the
262
+ // server's accept-time drift guard checks against it.
263
+ const liveFiles = details[0].currentFiles.map((f) => ({
264
+ filename: f.filename,
265
+ content: f.content,
266
+ }));
267
+ const basedFileHash = fileSetHash(liveFiles);
268
+ if (!options.json) {
269
+ console.log(`\n Merging ${openProposals.length} open proposals for ${refStr} (live v${list.currentVersion})…`);
270
+ }
271
+ // 3. Merge client-side via the LLM.
272
+ let merged;
273
+ try {
274
+ merged = await mergeProposals(liveFiles, details, options.keyEnv);
275
+ }
276
+ catch (err) {
277
+ if (err instanceof LlmError) {
278
+ console.error(`\n ✗ ${err.message}\n`);
279
+ process.exit(1);
280
+ }
281
+ throw err;
282
+ }
283
+ // 4. Preview + confirm (unless --yes / --json non-interactive).
284
+ if (!options.json) {
285
+ printMergePreview(liveFiles, merged);
286
+ }
287
+ if (!options.yes && !options.json) {
288
+ const choice = await p.confirm({
289
+ message: `Submit this merge as a synthesis proposal for ${refStr}?`,
290
+ });
291
+ if (p.isCancel(choice) || !choice) {
292
+ console.log('\n Cancelled. No synthesis submitted.\n');
293
+ return;
294
+ }
295
+ }
296
+ // 5. POST the synthesis.
297
+ let result;
298
+ try {
299
+ result = await apiFetch(`${base}/synthesize`, {
300
+ method: 'POST',
301
+ auth: true,
302
+ body: {
303
+ sourceProposalIds,
304
+ files: merged.map((f, i) => ({
305
+ filename: f.filename,
306
+ content: f.content,
307
+ sortOrder: i,
308
+ })),
309
+ changelog: options.message,
310
+ agentLabel: options.agentLabel,
311
+ basedFileHash,
312
+ },
313
+ });
314
+ }
315
+ catch (err) {
316
+ handleSynthesizeError(err, refStr, options);
317
+ return;
318
+ }
319
+ // The author reviews + approves the synthesis — the synthesizer cannot
320
+ // publish anything itself. Print a review link (the author's #proposals tab).
321
+ const reviewUrl = `${getApiUrl()}/${refStr}`;
322
+ if (options.json) {
323
+ console.log(JSON.stringify({
324
+ ok: true,
325
+ ref: refStr,
326
+ proposalId: result.proposalId,
327
+ status: result.status,
328
+ sourceProposalIds,
329
+ url: reviewUrl,
330
+ }));
331
+ return;
332
+ }
333
+ console.log(`\n ✓ Synthesis proposal queued for ${refStr} (id ${result.proposalId}).`);
334
+ console.log(` It merges ${sourceProposalIds.length} proposals into one.`);
335
+ console.log(` The author reviews and approves it before anything goes live: ${reviewUrl}\n`);
336
+ }
337
+ /** Build the merge prompt from the live set + the proposal details, call the
338
+ * LLM, and parse its JSON output into a typed merged file set.
339
+ *
340
+ * Throws {@link LlmError} on an empty/unparseable response so the caller can
341
+ * surface a clean message rather than POSTing garbage. */
342
+ async function mergeProposals(liveFiles, details, keyEnv) {
343
+ const prompt = buildMergePrompt(liveFiles, details);
344
+ const resp = await complete({ system: SYNTHESIZE_SYSTEM, prompt, keyEnv });
345
+ return parseMergedFiles(resp.text);
346
+ }
347
+ /** Render the live file set + each proposal's proposed file set into the user
348
+ * prompt the merge model reads. */
349
+ function buildMergePrompt(liveFiles, details) {
350
+ const sections = [];
351
+ sections.push('CURRENT (live) FILE SET:');
352
+ for (const f of liveFiles) {
353
+ sections.push(`--- FILE: ${f.filename} ---\n${f.content}`);
354
+ }
355
+ details.forEach((d, idx) => {
356
+ sections.push(`\nPROPOSAL ${idx + 1} (id ${d.proposal.id}):`);
357
+ if (d.proposal.changelog)
358
+ sections.push(`Changelog: ${d.proposal.changelog}`);
359
+ for (const f of d.files) {
360
+ sections.push(`--- FILE: ${f.filename} ---\n${f.content}`);
361
+ }
362
+ });
363
+ return sections.join('\n');
364
+ }
365
+ /** Parse the LLM's JSON merge output. Tolerates an accidental ```json fence by
366
+ * extracting the first {...} block. Throws LlmError on anything that isn't a
367
+ * well-formed `{ files: [{filename, content}] }`. */
368
+ function parseMergedFiles(text) {
369
+ const trimmed = text.trim();
370
+ const start = trimmed.indexOf('{');
371
+ const end = trimmed.lastIndexOf('}');
372
+ if (start === -1 || end === -1 || end < start) {
373
+ throw new LlmError('The merge model did not return JSON. Try again.');
374
+ }
375
+ let parsed;
376
+ try {
377
+ parsed = JSON.parse(trimmed.slice(start, end + 1));
378
+ }
379
+ catch {
380
+ throw new LlmError('The merge model returned invalid JSON. Try again.');
381
+ }
382
+ const filesRaw = parsed.files;
383
+ if (!Array.isArray(filesRaw) || filesRaw.length === 0) {
384
+ throw new LlmError('The merge model returned no files. Try again.');
385
+ }
386
+ const out = [];
387
+ for (const f of filesRaw) {
388
+ if (typeof f !== 'object' || f === null) {
389
+ throw new LlmError('The merge model returned a malformed file entry. Try again.');
390
+ }
391
+ const { filename, content } = f;
392
+ if (typeof filename !== 'string' || typeof content !== 'string') {
393
+ throw new LlmError('The merge model returned a file without filename/content. Try again.');
394
+ }
395
+ out.push({ filename, content });
396
+ }
397
+ return out;
398
+ }
399
+ /** Print a per-file diff of the live set vs the merged set, so the operator can
400
+ * see exactly what the synthesis would change before submitting. */
401
+ function printMergePreview(liveFiles, merged) {
402
+ const liveByName = new Map(liveFiles.map((f) => [f.filename, f.content]));
403
+ for (const f of merged) {
404
+ console.log(`\n ${f.filename}:`);
405
+ console.log(renderDiff(liveByName.get(f.filename) ?? '', f.content));
406
+ }
407
+ }
408
+ /** Map a synthesize API error to a friendly, cron-safe CLI message.
409
+ *
410
+ * 409 `duplicate_synthesis` — the same merge already exists OPEN. Treated as a
411
+ * NO-OP SUCCESS (exit 0) so a re-running cron doesn't fail.
412
+ * 409 `source_not_open` — a source proposal was decided since we listed it.
413
+ * Re-run to pick up the new queue state.
414
+ * 401/403 — the token isn't a synthesizer scoped to this skill.
415
+ * 404 — skill not found / unreadable. */
416
+ function handleSynthesizeError(err, refStr, options) {
417
+ if (!(err instanceof ApiError)) {
418
+ throw err;
419
+ }
420
+ const body = err.body;
421
+ // 409 duplicate — idempotent no-op success. Exit 0 so a cron is safe.
422
+ if (err.status === 409 && body?.error === 'duplicate_synthesis') {
423
+ if (options.json) {
424
+ console.log(JSON.stringify({
425
+ ok: true,
426
+ ref: refStr,
427
+ skipped: 'duplicate_synthesis',
428
+ existingProposalId: body.existingProposalId ?? null,
429
+ }));
430
+ }
431
+ else {
432
+ console.log(`\n An identical synthesis is already open for ${refStr}` +
433
+ (body.existingProposalId ? ` (id ${body.existingProposalId})` : '') +
434
+ `. Nothing to do.\n`);
435
+ }
436
+ return; // exit 0
437
+ }
438
+ if (options.json) {
439
+ console.log(JSON.stringify({
440
+ ok: false,
441
+ ref: refStr,
442
+ status: err.status,
443
+ error: body?.error ?? err.message,
444
+ message: body?.message ?? null,
445
+ }));
446
+ process.exit(1);
447
+ }
448
+ if (err.status === 401 || err.status === 403) {
449
+ console.error(`\n ✗ This command needs a synthesizer token scoped to ${refStr}.` +
450
+ `\n The skill author can mint one at ${getApiUrl()}/settings/tokens.\n`);
451
+ }
452
+ else if (err.status === 404) {
453
+ console.error(`\n ✗ Skill not found: ${refStr}\n`);
454
+ }
455
+ else if (err.status === 409 && body?.error === 'source_not_open') {
456
+ console.error(`\n ✗ One or more proposals were decided since they were listed.` +
457
+ `\n Re-run \`botdocs proposals synthesize ${refStr}\` to pick up the current queue.\n`);
458
+ }
459
+ else if (err.status === 413) {
460
+ console.error(`\n ✗ The merged file set is too large for ${refStr}. Trim and try again.\n`);
461
+ }
462
+ else {
463
+ console.error(`\n ✗ ${refStr}: ${friendlyApiErrorDetail(err, refStr)}\n`);
464
+ }
465
+ process.exit(1);
466
+ }
467
+ /** Map a LIST API error to a friendly message (shared by list + accept's
468
+ * version pre-fetch). */
469
+ function handleListError(err, refStr, options) {
470
+ if (!(err instanceof ApiError)) {
471
+ throw err;
472
+ }
473
+ if (options.json) {
474
+ console.log(JSON.stringify({ ok: false, ref: refStr, status: err.status, error: err.message }));
475
+ process.exit(1);
476
+ }
477
+ if (err.status === 404) {
478
+ console.error(`\n ✗ Skill not found: ${refStr}\n`);
479
+ }
480
+ else if (err.status === 401) {
481
+ console.error(`\n ✗ ${err.message}\n`);
482
+ }
483
+ else {
484
+ console.error(`\n ✗ ${refStr}: ${friendlyApiErrorDetail(err, refStr)}\n`);
485
+ }
486
+ process.exit(1);
487
+ }
488
+ /** Map an accept API error to a friendly message.
489
+ *
490
+ * 403 — you don't own this skill (only the author can accept).
491
+ * 404 — the proposal or skill doesn't exist.
492
+ * 409 — a conflict: the proposal was already decided, or the skill moved while
493
+ * you reviewed (stale base). Tell the user to re-list / re-review. */
494
+ function handleAcceptError(err, refStr, id, options) {
495
+ if (!(err instanceof ApiError)) {
496
+ throw err;
497
+ }
498
+ const body = err.body;
499
+ if (options.json) {
500
+ console.log(JSON.stringify({ ok: false, ref: refStr, id, status: err.status, error: err.message }));
501
+ process.exit(1);
502
+ }
503
+ if (err.status === 403) {
504
+ console.error(`\n ✗ Only the author of ${refStr} can accept proposals.\n`);
505
+ }
506
+ else if (err.status === 404) {
507
+ console.error(`\n ✗ Proposal ${id} not found for ${refStr}.\n`);
508
+ }
509
+ else if (err.status === 409) {
510
+ // Already decided, or the skill moved since the base was read. Either way
511
+ // the safe next step is to re-list and re-review.
512
+ const detail = body?.error ?? 'this proposal can no longer be accepted as-is';
513
+ console.error(`\n ✗ ${detail}.`);
514
+ console.error(` Run \`botdocs proposals ${refStr}\` to see the current state.\n`);
515
+ }
516
+ else if (err.status === 401) {
517
+ console.error(`\n ✗ ${err.message}\n`);
518
+ }
519
+ else {
520
+ console.error(`\n ✗ ${refStr}: ${friendlyApiErrorDetail(err, refStr)}\n`);
521
+ }
522
+ process.exit(1);
523
+ }
524
+ /**
525
+ * Register the `proposals` command (list, default) and its `accept`
526
+ * subcommand on the program. Done in a registrar helper (like `team`/`backups`)
527
+ * so the nested `accept` subcommand lives in this file rather than inline in
528
+ * index.ts — keeping index.ts's top-level surface clean and the quickstart
529
+ * drift-check parser happy (it lists only the parent verb `proposals`).
530
+ */
531
+ export function registerProposalsCommands(program) {
532
+ const proposalsCmd = program
533
+ .command('proposals <ref>')
534
+ .description('List proposals queued against a skill (author sees full metadata)')
535
+ .option('--status <status>', 'Filter by status: OPEN (default), ACCEPTED, REJECTED, or all')
536
+ .action(async (ref, opts) => {
537
+ await proposals(ref, { ...opts, json: program.opts().json });
538
+ });
539
+ proposalsCmd
540
+ .command('accept <ref>')
541
+ .description('Accept a proposal, publishing it as a new version (author-only)')
542
+ .requiredOption('--id <proposalId>', 'The proposal id to accept')
543
+ .action(async (ref, opts) => {
544
+ await proposalsAccept(ref, { ...opts, json: program.opts().json });
545
+ });
546
+ proposalsCmd
547
+ .command('synthesize <ref>')
548
+ .description("Merge a skill's open proposals into one synthesis for the author to review")
549
+ .option('--message <message>', 'Changelog describing the merge')
550
+ .option('--agent-label <label>', 'Self-reported agent identity (untrusted)')
551
+ .option('--key-env <name>', 'Env var holding the LLM API key (e.g. BOTDOCS_ANTHROPIC_KEY)')
552
+ .option('-y, --yes', 'Skip the confirmation prompt (for cron / non-interactive use)')
553
+ .action(async (ref, opts) => {
554
+ await proposalsSynthesize(ref, { ...opts, json: program.opts().json });
555
+ });
556
+ }
@@ -0,0 +1,18 @@
1
+ interface ProposeOptions {
2
+ /** Proposal changelog — what this change does, for the reviewing author. */
3
+ message?: string;
4
+ /** Self-reported agent identity (e.g. "hermes-v2"). UNTRUSTED server-side. */
5
+ agentLabel?: string;
6
+ json?: boolean;
7
+ }
8
+ /**
9
+ * `botdocs propose @user/slug [path]` — queue a change to a skill for the
10
+ * author to review, instead of publishing directly.
11
+ *
12
+ * Collects the local files (directory or single markdown file, default cwd),
13
+ * computes `basedFileHash` over the skill's CURRENT live file set (the base
14
+ * the author will diff against), and POSTs the proposal. The author reviews +
15
+ * accepts/rejects from the Proposals tab or `botdocs proposals accept`.
16
+ */
17
+ export declare function propose(rawRef: string, pathArg: string | undefined, options: ProposeOptions): Promise<void>;
18
+ export {};
@@ -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);
package/dist/index.js CHANGED
@@ -126,6 +126,8 @@ import { ingest, SUPPORTED_TOOLS as INGEST_SUPPORTED_TOOLS } from './commands/in
126
126
  import { checkUpdates } from './commands/check-updates.js';
127
127
  import { compile } from './commands/compile.js';
128
128
  import { edit } from './commands/edit.js';
129
+ import { propose } from './commands/propose.js';
130
+ import { registerProposalsCommands } from './commands/proposals.js';
129
131
  import { registerTeamCommands } from './commands/team.js';
130
132
  import { registerBackupCommands } from './commands/backups.js';
131
133
  import { undo } from './commands/undo.js';
@@ -325,6 +327,19 @@ program
325
327
  .action(async (ref, opts) => {
326
328
  await edit(ref, { ...opts, json: program.opts().json });
327
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);
328
343
  registerTeamCommands(program);
329
344
  registerBackupCommands(program);
330
345
  program
@@ -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.2",
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",