@botdocs/cli 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ import * as p from '@clack/prompts';
5
5
  import { ApiError, apiFetch, fetchRawContent, friendlyApiErrorDetail } from '../lib/api.js';
6
6
  import { complete, detectProvider, LlmError } from '../lib/llm.js';
7
7
  import { renderDiff } from '../lib/diff.js';
8
+ import { loadAuth } from '../lib/config.js';
9
+ import { fileSetHash } from '../lib/proposals.js';
8
10
  const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'ecosystem-prompts');
9
11
  function loadEditPrompt() {
10
12
  return fs.readFileSync(path.join(TEMPLATES_DIR, 'edit.md'), 'utf-8');
@@ -16,6 +18,17 @@ function parseRef(raw) {
16
18
  throw new Error(`Invalid ref: ${raw}`);
17
19
  return { username: parts[0], slug: parts[1] };
18
20
  }
21
+ /** True when the logged-in user owns this skill. A skill ref's username IS the
22
+ * author's username (the registry resolves `@user/slug` by the author's
23
+ * handle), so ownership is a case-insensitive username match. Drives the
24
+ * branch between the author's lower-friction draft loop and the cross-author
25
+ * proposal lane. */
26
+ function isOwnSkill(username) {
27
+ const me = loadAuth()?.username;
28
+ if (!me)
29
+ return false;
30
+ return me.toLowerCase() === username.toLowerCase();
31
+ }
19
32
  function fileForEcosystem(files, eco) {
20
33
  if (eco === 'claude')
21
34
  return files.find((f) => f.filename === 'claude/SKILL.md');
@@ -113,11 +126,62 @@ export async function edit(rawRef, options) {
113
126
  break;
114
127
  // else regenerate — loop
115
128
  }
116
- await apiFetch(`/api/skills/${ref.username}/${ref.slug}/draft`, {
117
- method: 'POST',
118
- auth: true,
119
- body: { ecosystem: options.ecosystem, content: revised },
120
- });
121
- console.log(`\n ✓ Pushed draft for ${rawRef} (${options.ecosystem}).`);
122
- console.log(` Visit https://botdocs.ai/@${ref.username}/${ref.slug} to publish.\n`);
129
+ // Ownership branch (design §7): the author's own skill keeps the
130
+ // lower-friction draft→publish loop; a skill owned by someone else routes
131
+ // through the proposal lane so the change lands in the author's review queue
132
+ // instead of clobbering their live files.
133
+ if (isOwnSkill(ref.username)) {
134
+ await apiFetch(`/api/skills/${ref.username}/${ref.slug}/draft`, {
135
+ method: 'POST',
136
+ auth: true,
137
+ body: { ecosystem: options.ecosystem, content: revised },
138
+ });
139
+ console.log(`\n ✓ Pushed draft for ${rawRef} (${options.ecosystem}).`);
140
+ console.log(` Visit https://botdocs.ai/@${ref.username}/${ref.slug} to publish.\n`);
141
+ return;
142
+ }
143
+ await proposeEdit(ref, manifest, target.filename, currentContent, revised);
144
+ }
145
+ /**
146
+ * Queue an edited file as a proposal for the skill's author to review.
147
+ *
148
+ * A proposal replaces the skill's ENTIRE file set, so we fetch every live file
149
+ * and swap in the revised content for the one ecosystem file we edited. The
150
+ * `basedFileHash` is computed over the UNMODIFIED live set — it's the drift
151
+ * anchor the author's accept is checked against.
152
+ */
153
+ async function proposeEdit(ref, manifest, targetFilename, targetCurrentContent, revised) {
154
+ // Build the live file set (filename + content). We already have the edited
155
+ // file's current content; fetch the rest.
156
+ const liveFiles = await Promise.all(manifest.files.map(async (f) => ({
157
+ filename: f.filename,
158
+ content: f.filename === targetFilename ? targetCurrentContent : await fetchRawContent(f.rawUrl),
159
+ })));
160
+ const basedFileHash = fileSetHash(liveFiles);
161
+ // The proposed set is the live set with the edited file's content replaced.
162
+ const proposedFiles = liveFiles.map((f, i) => ({
163
+ filename: f.filename,
164
+ content: f.filename === targetFilename ? revised : f.content,
165
+ sortOrder: i,
166
+ }));
167
+ try {
168
+ await apiFetch(`/api/skills/${ref.username}/${ref.slug}/proposals`, {
169
+ method: 'POST',
170
+ auth: true,
171
+ body: {
172
+ files: proposedFiles,
173
+ agentLabel: 'cli-edit',
174
+ basedFileHash,
175
+ },
176
+ });
177
+ }
178
+ catch (err) {
179
+ if (err instanceof ApiError) {
180
+ console.error(`\n ✗ @${ref.username}/${ref.slug}: ${friendlyApiErrorDetail(err, `@${ref.username}/${ref.slug}`)}\n`);
181
+ process.exit(1);
182
+ }
183
+ throw err;
184
+ }
185
+ console.log(`\n ✓ Proposal queued for @${ref.username}/${ref.slug} (${targetFilename}).`);
186
+ console.log(' The skill author will review it before it goes live.\n');
123
187
  }
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { render } from 'ink';
6
6
  import open from 'open';
7
- import { ApiError, apiFetch, getApiUrl } from '../lib/api.js';
7
+ import { ApiError, apiFetch, getApiUrl, friendlyFreeTierError } from '../lib/api.js';
8
8
  import { loadAuth } from '../lib/config.js';
9
9
  import { IngestSessionClient, PairingClaimError } from '../lib/ingest-session-client.js';
10
10
  import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, titleFromContent, } from '../lib/ingest-discover.js';
@@ -643,6 +643,29 @@ function reportSizeCap(err, options) {
643
643
  console.error(`\n ✗ ${detailMessage ?? err.message}\n`);
644
644
  process.exit(1);
645
645
  }
646
+ /** Print an actionable 403 skill-cap message and exit non-zero. Honors
647
+ * `--json` by emitting `{ ok: false, status: 403, error, limit, current }` so
648
+ * scripts can branch on the cap. The human line comes from the shared
649
+ * `friendlyFreeTierError` mapper so the copy stays identical to publish/install
650
+ * (honest free-limit wording, no fake checkout). */
651
+ function reportSkillCap(err, options) {
652
+ const body = err.body;
653
+ if (options.json) {
654
+ console.log(JSON.stringify({
655
+ ok: false,
656
+ status: 403,
657
+ error: 'skill_cap_exceeded',
658
+ limit: body?.limit ?? null,
659
+ current: body?.current ?? null,
660
+ }));
661
+ process.exit(1);
662
+ }
663
+ // friendlyFreeTierError returns the honest cap CTA for this code; fall back
664
+ // to the raw server message only in the (impossible) null case so we never
665
+ // print an empty line.
666
+ console.error(`\n ✗ ${friendlyFreeTierError(err) ?? err.message}\n`);
667
+ process.exit(1);
668
+ }
646
669
  async function uploadSkills(skills, options, pair) {
647
670
  // Optional: announce intent before the API call so the paired web UI
648
671
  // shows "uploading…" rows immediately. The events POST is batched
@@ -686,6 +709,14 @@ async function uploadSkills(skills, options, pair) {
686
709
  if (conflicts)
687
710
  reportConflicts(conflicts, options);
688
711
  }
712
+ // 403 skill_cap_exceeded — the whole batch was rejected because it would
713
+ // push the account over the free per-account skill limit. Surface the
714
+ // honest cap CTA (no fake checkout) and exit non-zero.
715
+ if (err instanceof ApiError &&
716
+ err.status === 403 &&
717
+ err.body?.error === 'skill_cap_exceeded') {
718
+ reportSkillCap(err, options);
719
+ }
689
720
  // 413 PAYLOAD_TOO_LARGE — server's `validateSkillCaps` rejected one of
690
721
  // the skills in the payload. The body's `detail.message` is the human
691
722
  // line ("file foo/bar.md is 71 KB, cap is 64 KB per file"); fall back
@@ -3,8 +3,8 @@ import { randomBytes } from 'node:crypto';
3
3
  import open from 'open';
4
4
  import { render } from 'ink';
5
5
  import * as p from '@clack/prompts';
6
- import { saveAuth } from '../lib/config.js';
7
- import { getApiUrl } from '../lib/api.js';
6
+ import { saveAuth, getOrCreateDeviceId } from '../lib/config.js';
7
+ import { ApiError, getApiUrl, friendlyFreeTierError } from '../lib/api.js';
8
8
  import { LoginApp } from './views/login-app.js';
9
9
  /** Total wall-clock budget for the polling loop. After this we tell the user
10
10
  * the request expired and exit 1. Mirrors the server-side state TTL. */
@@ -51,7 +51,22 @@ async function loginWithToken(token, syncLibrary) {
51
51
  process.exit(1);
52
52
  }
53
53
  if (!res.ok) {
54
- console.error(`Token validation failed (${res.status}). Try again later.`);
54
+ // Surface the standard free-tier frictions with an HONEST CTA when the
55
+ // server gates this device on the way in:
56
+ // - 403 device_cap_exceeded → "you've reached N devices…"
57
+ // - 429 session_limit_exceeded → "N active sessions already…"
58
+ // The shared mapper reads the JSON body's `error`/`limit` fields and
59
+ // returns null for anything it doesn't recognize, in which case we fall
60
+ // back to the generic "validation failed" line. Parsing is best-effort:
61
+ // a non-JSON body just falls through to the generic message.
62
+ const body = (await res.json().catch(() => undefined));
63
+ const friendly = friendlyFreeTierError(new ApiError(res.status, '', body));
64
+ if (friendly) {
65
+ console.error(friendly);
66
+ }
67
+ else {
68
+ console.error(`Token validation failed (${res.status}). Try again later.`);
69
+ }
55
70
  process.exit(1);
56
71
  }
57
72
  const user = (await res.json());
@@ -61,6 +76,10 @@ async function loginWithToken(token, syncLibrary) {
61
76
  displayName: user.displayName,
62
77
  syncLibrary,
63
78
  });
79
+ // Generate + persist the stable device identity now, so the very next
80
+ // authenticated request carries an X-Device-Id (no-op when BOTDOCS_DEVICE_ID
81
+ // is set — that override always wins and isn't persisted).
82
+ getOrCreateDeviceId();
64
83
  printSignedIn(user.username, syncLibrary);
65
84
  }
66
85
  /** Helper: register a new state with the server and return the public auth
@@ -183,6 +202,7 @@ async function loginInkInteractive(syncLibrary) {
183
202
  displayName: granted.displayName,
184
203
  syncLibrary,
185
204
  });
205
+ getOrCreateDeviceId();
186
206
  resolvedUsername = granted.username;
187
207
  status = 'success';
188
208
  rerender();
@@ -231,6 +251,7 @@ async function loginPlainInteractive(syncLibrary) {
231
251
  displayName: granted.displayName,
232
252
  syncLibrary,
233
253
  });
254
+ getOrCreateDeviceId();
234
255
  printSyncLibraryHint(syncLibrary);
235
256
  }
236
257
  /** Polls the server with exponential backoff. Returns the granted payload on
@@ -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 {};