@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.
- package/dist/commands/edit.js +71 -7
- 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/index.js +15 -0
- package/dist/lib/proposals.d.ts +37 -0
- package/dist/lib/proposals.js +72 -0
- package/package.json +1 -1
package/dist/commands/edit.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
*
|
package/dist/commands/publish.js
CHANGED
|
@@ -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.
|
|
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",
|