@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.
- package/dist/commands/edit.js +71 -7
- package/dist/commands/ingest.js +32 -1
- package/dist/commands/login.js +24 -3
- 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 +15 -13
- package/dist/commands/publish.js +29 -25
- package/dist/commands/unpublish.d.ts +7 -7
- package/dist/commands/unpublish.js +11 -11
- package/dist/index.js +18 -3
- package/dist/lib/api.d.ts +19 -0
- package/dist/lib/api.js +92 -1
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.js +40 -0
- package/dist/lib/library-sync.d.ts +8 -2
- package/dist/lib/library-sync.js +66 -7
- 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
|
}
|
package/dist/commands/ingest.js
CHANGED
|
@@ -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
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
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 {};
|