@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.
- package/dist/commands/edit.js +74 -8
- package/dist/commands/install.js +6 -55
- package/dist/commands/proposals.d.ts +69 -0
- package/dist/commands/proposals.js +556 -0
- package/dist/commands/propose.d.ts +18 -0
- package/dist/commands/propose.js +202 -0
- package/dist/commands/publish.d.ts +3 -1
- package/dist/commands/publish.js +2 -2
- package/dist/commands/sync.js +2 -2
- package/dist/commands/team.js +1 -1
- package/dist/index.js +15 -7
- package/dist/lib/api.d.ts +16 -1
- package/dist/lib/api.js +31 -6
- package/dist/lib/proposals.d.ts +37 -0
- package/dist/lib/proposals.js +72 -0
- package/package.json +1 -1
- package/dist/commands/search.d.ts +0 -3
- package/dist/commands/search.js +0 -58
|
@@ -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 {};
|