@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
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');
|
|
@@ -44,7 +57,9 @@ export async function edit(rawRef, options) {
|
|
|
44
57
|
let manifest;
|
|
45
58
|
const refStr = `@${ref.username}/${ref.slug}`;
|
|
46
59
|
try {
|
|
47
|
-
manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest
|
|
60
|
+
manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`, {
|
|
61
|
+
auth: 'optional',
|
|
62
|
+
});
|
|
48
63
|
}
|
|
49
64
|
catch (err) {
|
|
50
65
|
if (err instanceof ApiError && err.status === 404) {
|
|
@@ -111,11 +126,62 @@ export async function edit(rawRef, options) {
|
|
|
111
126
|
break;
|
|
112
127
|
// else regenerate — loop
|
|
113
128
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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');
|
|
121
187
|
}
|
package/dist/commands/install.js
CHANGED
|
@@ -382,51 +382,6 @@ function isInstallUpToDate(prior, manifest) {
|
|
|
382
382
|
}
|
|
383
383
|
return true;
|
|
384
384
|
}
|
|
385
|
-
/** Compute the Levenshtein edit distance between two short strings. Used by
|
|
386
|
-
* the "did you mean?" suggester to filter search hits that are too far from
|
|
387
|
-
* the user's typo to be useful. Implementation is the textbook two-row DP —
|
|
388
|
-
* O(n*m) time, O(min(n,m)) space. Both inputs are slug-sized (≤ ~64 chars),
|
|
389
|
-
* so the constant factor doesn't matter. */
|
|
390
|
-
function levenshtein(a, b) {
|
|
391
|
-
if (a === b)
|
|
392
|
-
return 0;
|
|
393
|
-
if (a.length === 0)
|
|
394
|
-
return b.length;
|
|
395
|
-
if (b.length === 0)
|
|
396
|
-
return a.length;
|
|
397
|
-
let prev = new Array(b.length + 1);
|
|
398
|
-
let curr = new Array(b.length + 1);
|
|
399
|
-
for (let j = 0; j <= b.length; j++)
|
|
400
|
-
prev[j] = j;
|
|
401
|
-
for (let i = 1; i <= a.length; i++) {
|
|
402
|
-
curr[0] = i;
|
|
403
|
-
for (let j = 1; j <= b.length; j++) {
|
|
404
|
-
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
405
|
-
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
406
|
-
}
|
|
407
|
-
[prev, curr] = [curr, prev];
|
|
408
|
-
}
|
|
409
|
-
return prev[b.length];
|
|
410
|
-
}
|
|
411
|
-
/** On a 404, query the search endpoint for the user's slug fragment and
|
|
412
|
-
* return the top result as a `@user/slug` string when its slug is within
|
|
413
|
-
* Levenshtein distance 2 of the requested slug. Returns null when the
|
|
414
|
-
* search returns no results, when the top result is too far, or when
|
|
415
|
-
* `requestedRef` already equals the top match (defensive). Never throws —
|
|
416
|
-
* callers wrap in catch and treat any failure as "no suggestion". */
|
|
417
|
-
async function suggestClosestRef(requestedSlug, requestedRef) {
|
|
418
|
-
const encoded = encodeURIComponent(requestedSlug);
|
|
419
|
-
const resp = await apiFetch(`/api/search?q=${encoded}`);
|
|
420
|
-
const top = resp.results[0];
|
|
421
|
-
if (!top)
|
|
422
|
-
return null;
|
|
423
|
-
if (levenshtein(top.slug.toLowerCase(), requestedSlug.toLowerCase()) > 2)
|
|
424
|
-
return null;
|
|
425
|
-
const candidate = `@${top.author}/${top.slug}`;
|
|
426
|
-
if (candidate === requestedRef)
|
|
427
|
-
return null;
|
|
428
|
-
return candidate;
|
|
429
|
-
}
|
|
430
385
|
export async function install(rawRef, options) {
|
|
431
386
|
let ref;
|
|
432
387
|
try {
|
|
@@ -465,19 +420,15 @@ export async function install(rawRef, options) {
|
|
|
465
420
|
}
|
|
466
421
|
let manifest;
|
|
467
422
|
try {
|
|
468
|
-
manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest
|
|
423
|
+
manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`, { auth: 'optional' });
|
|
469
424
|
}
|
|
470
425
|
catch (err) {
|
|
471
426
|
if (err instanceof ApiError && err.status === 404) {
|
|
472
|
-
// 404
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
// suggestion call succeeding.
|
|
478
|
-
const suggestion = await suggestClosestRef(ref.slug, refStr).catch(() => null);
|
|
479
|
-
const suffix = suggestion ? `\n Did you mean: ${suggestion}?\n` : '\n';
|
|
480
|
-
console.error(`\n ✗ Skill or bundle not found: ${refStr}${suffix}`);
|
|
427
|
+
// A 404 here also covers "PRIVATE skill you can't read" — the server
|
|
428
|
+
// returns 404 (never 403) for private-no-access so existence never
|
|
429
|
+
// leaks. We don't try to suggest a close match: the discovery/search
|
|
430
|
+
// surface was removed (private-by-default), so there's nothing to query.
|
|
431
|
+
console.error(`\n ✗ Skill or bundle not found: ${refStr}\n`);
|
|
481
432
|
process.exit(1);
|
|
482
433
|
}
|
|
483
434
|
// 410 Gone = author soft-deleted the skill upstream. Distinct from 404
|
|
@@ -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 {};
|