@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.
@@ -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
- await apiFetch(`/api/skills/${ref.username}/${ref.slug}/draft`, {
115
- method: 'POST',
116
- auth: true,
117
- body: { ecosystem: options.ecosystem, content: revised },
118
- });
119
- console.log(`\n ✓ Pushed draft for ${rawRef} (${options.ecosystem}).`);
120
- console.log(` Visit https://botdocs.ai/@${ref.username}/${ref.slug} to publish.\n`);
129
+ // Ownership branch (design §7): the author's own skill keeps the
130
+ // lower-friction draft→publish loop; a skill owned by someone else routes
131
+ // through the proposal lane so the change lands in the author's review queue
132
+ // instead of clobbering their live files.
133
+ if (isOwnSkill(ref.username)) {
134
+ await apiFetch(`/api/skills/${ref.username}/${ref.slug}/draft`, {
135
+ method: 'POST',
136
+ auth: true,
137
+ body: { ecosystem: options.ecosystem, content: revised },
138
+ });
139
+ console.log(`\n ✓ Pushed draft for ${rawRef} (${options.ecosystem}).`);
140
+ console.log(` Visit https://botdocs.ai/@${ref.username}/${ref.slug} to publish.\n`);
141
+ return;
142
+ }
143
+ await proposeEdit(ref, manifest, target.filename, currentContent, revised);
144
+ }
145
+ /**
146
+ * Queue an edited file as a proposal for the skill's author to review.
147
+ *
148
+ * A proposal replaces the skill's ENTIRE file set, so we fetch every live file
149
+ * and swap in the revised content for the one ecosystem file we edited. The
150
+ * `basedFileHash` is computed over the UNMODIFIED live set — it's the drift
151
+ * anchor the author's accept is checked against.
152
+ */
153
+ async function proposeEdit(ref, manifest, targetFilename, targetCurrentContent, revised) {
154
+ // Build the live file set (filename + content). We already have the edited
155
+ // file's current content; fetch the rest.
156
+ const liveFiles = await Promise.all(manifest.files.map(async (f) => ({
157
+ filename: f.filename,
158
+ content: f.filename === targetFilename ? targetCurrentContent : await fetchRawContent(f.rawUrl),
159
+ })));
160
+ const basedFileHash = fileSetHash(liveFiles);
161
+ // The proposed set is the live set with the edited file's content replaced.
162
+ const proposedFiles = liveFiles.map((f, i) => ({
163
+ filename: f.filename,
164
+ content: f.filename === targetFilename ? revised : f.content,
165
+ sortOrder: i,
166
+ }));
167
+ try {
168
+ await apiFetch(`/api/skills/${ref.username}/${ref.slug}/proposals`, {
169
+ method: 'POST',
170
+ auth: true,
171
+ body: {
172
+ files: proposedFiles,
173
+ agentLabel: 'cli-edit',
174
+ basedFileHash,
175
+ },
176
+ });
177
+ }
178
+ catch (err) {
179
+ if (err instanceof ApiError) {
180
+ console.error(`\n ✗ @${ref.username}/${ref.slug}: ${friendlyApiErrorDetail(err, `@${ref.username}/${ref.slug}`)}\n`);
181
+ process.exit(1);
182
+ }
183
+ throw err;
184
+ }
185
+ console.log(`\n ✓ Proposal queued for @${ref.username}/${ref.slug} (${targetFilename}).`);
186
+ console.log(' The skill author will review it before it goes live.\n');
121
187
  }
@@ -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 dead-ends are common when users mistype a slug. Ask the search
473
- // endpoint for the slug-fragment and surface the top close match
474
- // (Levenshtein distance 2) so the user can quickly correct.
475
- // Network errors from the suggestion lookup are swallowed — the 404
476
- // itself is the primary signal and we shouldn't gate it on the
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 {};