@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
|
@@ -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
|
+
}
|
|
@@ -6,32 +6,32 @@ interface PublishOptions {
|
|
|
6
6
|
license?: string;
|
|
7
7
|
json?: boolean;
|
|
8
8
|
noCompile?: boolean;
|
|
9
|
-
/** Skip the pre-POST y/N confirmation prompt.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `--
|
|
13
|
-
* because JSON mode is for non-interactive consumers. */
|
|
9
|
+
/** Skip the pre-POST y/N confirmation prompt. The path-form publish asks
|
|
10
|
+
* "Add @<user>/<slug> to your library? [y/N]" by default so first-time
|
|
11
|
+
* authors realize it uploads immediately. `--yes` bypasses it for scripted
|
|
12
|
+
* runs; `--json` skips it implicitly because JSON mode is non-interactive. */
|
|
14
13
|
yes?: boolean;
|
|
15
14
|
/** Build the payload, print what would be published, exit 0 without POSTing.
|
|
16
15
|
* Lets authors preview title/description/file list + total size before they
|
|
17
|
-
* commit to
|
|
18
|
-
*
|
|
16
|
+
* commit to uploading. Skipped entirely on ref-form publish (flipping a
|
|
17
|
+
* skill's CLI-discoverable flag is already cheap and reversible via
|
|
18
|
+
* `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;
|
|
25
25
|
}
|
|
26
26
|
export declare function publish(source: string, options: PublishOptions): Promise<void>;
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* Make an existing skill discoverable from the CLI (install/pull/list) via the
|
|
29
|
+
* API — sets `cli_discoverable = true` server-side. The inverse of `unpublish`.
|
|
29
30
|
*
|
|
30
31
|
* Called from `publish()` when the source argument looks like a ref
|
|
31
|
-
* (`@user/slug` or `user/slug`) instead of a local path.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* mistake is one CLI call away.
|
|
32
|
+
* (`@user/slug` or `user/slug`) instead of a local path. No prompt — exposing
|
|
33
|
+
* your own skill to your own CLI is harmless, and `unpublish` hides it again in
|
|
34
|
+
* one call. Skills are never public; this only controls CLI reach.
|
|
35
35
|
*/
|
|
36
36
|
declare function publishRef(rawRef: string, options: PublishOptions): Promise<void>;
|
|
37
37
|
/**
|
|
@@ -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
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import AdmZip from 'adm-zip';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
|
-
import { ApiError, apiFetch, getApiUrl, friendlyApiErrorDetail } from '../lib/api.js';
|
|
5
|
+
import { ApiError, apiFetch, getApiUrl, friendlyApiErrorDetail, friendlyFreeTierError } from '../lib/api.js';
|
|
6
6
|
import { parseManifest } from '../lib/manifest.js';
|
|
7
7
|
import { compile } from './compile.js';
|
|
8
8
|
import { ecosystemDestination } from '../lib/canonical.js';
|
|
@@ -60,9 +60,9 @@ export async function publish(source, options) {
|
|
|
60
60
|
// user isn't logged in. The 401 would otherwise only surface after the POST,
|
|
61
61
|
// making large publishes feel slow and confusing.
|
|
62
62
|
requireAuth(options);
|
|
63
|
-
// Ref-form (e.g. "@user/slug" or "user/slug") →
|
|
64
|
-
//
|
|
65
|
-
// below. Overloading by argument shape (rather than introducing
|
|
63
|
+
// Ref-form (e.g. "@user/slug" or "user/slug") → make that existing skill
|
|
64
|
+
// discoverable from the CLI via the API. Path-form continues to the upload
|
|
65
|
+
// flow below. Overloading by argument shape (rather than introducing
|
|
66
66
|
// `botdocs publish-ref` or a `--ref` flag) keeps the verb intuitive:
|
|
67
67
|
// a user typing `botdocs publish @me/foo` doesn't have to know it's a
|
|
68
68
|
// different code path under the hood.
|
|
@@ -160,11 +160,11 @@ export async function publish(source, options) {
|
|
|
160
160
|
console.error('Description is required. Use --description "..." or set "description" in botdocs.json.');
|
|
161
161
|
process.exit(1);
|
|
162
162
|
}
|
|
163
|
-
// Pre-POST confirmation.
|
|
164
|
-
//
|
|
165
|
-
// y/N (defaulting to N) before sending. Skipped
|
|
166
|
-
// runs that opt in) and --json (non-interactive
|
|
167
|
-
// gives back-compat for callers that don't have a TTY).
|
|
163
|
+
// Pre-POST confirmation. The upload is immediate (the skill lands in your
|
|
164
|
+
// library, private, discoverable from the CLI) — first-time authors don't
|
|
165
|
+
// always know that, so we ask y/N (defaulting to N) before sending. Skipped
|
|
166
|
+
// under --yes (scripted runs that opt in) and --json (non-interactive
|
|
167
|
+
// consumers, which also gives back-compat for callers that don't have a TTY).
|
|
168
168
|
if (!options.yes && !options.json) {
|
|
169
169
|
const auth = loadAuth();
|
|
170
170
|
// requireAuth() ran first, so auth is non-null here. The fallback is
|
|
@@ -172,11 +172,11 @@ export async function publish(source, options) {
|
|
|
172
172
|
const username = auth?.username ?? 'me';
|
|
173
173
|
const slugGuess = derivePublishSlug(source);
|
|
174
174
|
const confirmed = await p.confirm({
|
|
175
|
-
message: `
|
|
175
|
+
message: `Add @${username}/${slugGuess} to your library? (private — discoverable from your CLI)`,
|
|
176
176
|
initialValue: false,
|
|
177
177
|
});
|
|
178
178
|
if (p.isCancel(confirmed) || confirmed !== true) {
|
|
179
|
-
console.log('
|
|
179
|
+
console.log(' Cancelled.');
|
|
180
180
|
return;
|
|
181
181
|
}
|
|
182
182
|
}
|
|
@@ -211,7 +211,7 @@ export async function publish(source, options) {
|
|
|
211
211
|
console.log(JSON.stringify({ ...result, url: absoluteUrl }));
|
|
212
212
|
}
|
|
213
213
|
else {
|
|
214
|
-
console.log(`\
|
|
214
|
+
console.log(`\nAdded to your library: ${absoluteUrl}`);
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
/** Translate path-publish API errors into actionable CLI messages.
|
|
@@ -284,10 +284,14 @@ function handlePathPublishError(err, options) {
|
|
|
284
284
|
console.error(`\n ✗ ${err.message}\n`);
|
|
285
285
|
}
|
|
286
286
|
else {
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
287
|
+
// Free-tier frictions (e.g. 403 skill_cap_exceeded) get an HONEST CTA
|
|
288
|
+
// from the shared mapper before the generic 403/429/5xx wording. The
|
|
289
|
+
// mapper returns null for anything it doesn't recognize, so the
|
|
290
|
+
// friendlyApiErrorDetail fallback still covers "permission denied" /
|
|
291
|
+
// "rate-limited" / server errors. Keeps the cap message identical to
|
|
292
|
+
// the one ingest/install render.
|
|
293
|
+
const friendly = friendlyFreeTierError(err);
|
|
294
|
+
console.error(`\n ✗ ${friendly ?? friendlyApiErrorDetail(err)}\n`);
|
|
291
295
|
}
|
|
292
296
|
process.exit(1);
|
|
293
297
|
}
|
|
@@ -302,13 +306,13 @@ function extractDetailMessage(detail) {
|
|
|
302
306
|
return undefined;
|
|
303
307
|
}
|
|
304
308
|
/**
|
|
305
|
-
*
|
|
309
|
+
* Make an existing skill discoverable from the CLI (install/pull/list) via the
|
|
310
|
+
* API — sets `cli_discoverable = true` server-side. The inverse of `unpublish`.
|
|
306
311
|
*
|
|
307
312
|
* Called from `publish()` when the source argument looks like a ref
|
|
308
|
-
* (`@user/slug` or `user/slug`) instead of a local path.
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* mistake is one CLI call away.
|
|
313
|
+
* (`@user/slug` or `user/slug`) instead of a local path. No prompt — exposing
|
|
314
|
+
* your own skill to your own CLI is harmless, and `unpublish` hides it again in
|
|
315
|
+
* one call. Skills are never public; this only controls CLI reach.
|
|
312
316
|
*/
|
|
313
317
|
async function publishRef(rawRef, options) {
|
|
314
318
|
// Preflight: same as the path-form publish. publish() already called
|
|
@@ -337,10 +341,10 @@ async function publishRef(rawRef, options) {
|
|
|
337
341
|
return;
|
|
338
342
|
}
|
|
339
343
|
if (options.json) {
|
|
340
|
-
console.log(JSON.stringify({ ok: true, ref: refLabel, status: '
|
|
344
|
+
console.log(JSON.stringify({ ok: true, ref: refLabel, status: 'cli-visible' }));
|
|
341
345
|
}
|
|
342
346
|
else {
|
|
343
|
-
console.log(`✓
|
|
347
|
+
console.log(`✓ ${refLabel} is now discoverable from the CLI.`);
|
|
344
348
|
}
|
|
345
349
|
}
|
|
346
350
|
/**
|
|
@@ -460,12 +464,12 @@ function derivePublishSlug(source) {
|
|
|
460
464
|
.replace(/[^a-z0-9]+/g, '-')
|
|
461
465
|
.replace(/^-+|-+$/g, '');
|
|
462
466
|
}
|
|
463
|
-
function collectFromFile(filePath) {
|
|
467
|
+
export function collectFromFile(filePath) {
|
|
464
468
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
465
469
|
const filename = path.basename(filePath);
|
|
466
470
|
return [{ filename, content, sortOrder: 0 }];
|
|
467
471
|
}
|
|
468
|
-
function collectFromDirectory(dirPath) {
|
|
472
|
+
export function collectFromDirectory(dirPath) {
|
|
469
473
|
const files = [];
|
|
470
474
|
walkDirectory(dirPath, dirPath, files);
|
|
471
475
|
files.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
@@ -3,14 +3,14 @@ interface UnpublishOptions {
|
|
|
3
3
|
json?: boolean;
|
|
4
4
|
}
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Hide a skill from the CLI (sets `cli_discoverable = false` server-side): it
|
|
7
|
+
* stays in the author's web library, but `botdocs install`/`pull`/`list` can
|
|
8
|
+
* no longer see it. The inverse of `publish @user/slug`.
|
|
8
9
|
*
|
|
9
|
-
* Idempotent server-side —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* callers don't deadlock on the prompt.
|
|
10
|
+
* Idempotent server-side — hiding an already-hidden skill is a no-op. Prompts
|
|
11
|
+
* for confirmation by default (so a stray `unpublish` doesn't quietly pull a
|
|
12
|
+
* skill out of your agents); skip with `--yes` for scripting/CI. `--json`
|
|
13
|
+
* implies `--yes` so machine-driven callers don't deadlock on the prompt.
|
|
14
14
|
*/
|
|
15
15
|
export declare function unpublish(rawRef: string, options: UnpublishOptions): Promise<void>;
|
|
16
16
|
export {};
|
|
@@ -3,14 +3,14 @@ import { apiFetch } from '../lib/api.js';
|
|
|
3
3
|
import { parseRef } from '../lib/ref.js';
|
|
4
4
|
import { handlePublishToggleError } from './publish.js';
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Hide a skill from the CLI (sets `cli_discoverable = false` server-side): it
|
|
7
|
+
* stays in the author's web library, but `botdocs install`/`pull`/`list` can
|
|
8
|
+
* no longer see it. The inverse of `publish @user/slug`.
|
|
8
9
|
*
|
|
9
|
-
* Idempotent server-side —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* callers don't deadlock on the prompt.
|
|
10
|
+
* Idempotent server-side — hiding an already-hidden skill is a no-op. Prompts
|
|
11
|
+
* for confirmation by default (so a stray `unpublish` doesn't quietly pull a
|
|
12
|
+
* skill out of your agents); skip with `--yes` for scripting/CI. `--json`
|
|
13
|
+
* implies `--yes` so machine-driven callers don't deadlock on the prompt.
|
|
14
14
|
*/
|
|
15
15
|
export async function unpublish(rawRef, options) {
|
|
16
16
|
let parsed;
|
|
@@ -25,8 +25,8 @@ export async function unpublish(rawRef, options) {
|
|
|
25
25
|
const refLabel = `@${username}/${slug}`;
|
|
26
26
|
if (!options.yes && !options.json) {
|
|
27
27
|
const confirmed = await p.confirm({
|
|
28
|
-
message: `
|
|
29
|
-
`
|
|
28
|
+
message: `Hide ${refLabel} from the CLI? It stays in your web library, but ` +
|
|
29
|
+
`the CLI won't be able to install, pull, or list it.`,
|
|
30
30
|
initialValue: false,
|
|
31
31
|
});
|
|
32
32
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
@@ -45,9 +45,9 @@ export async function unpublish(rawRef, options) {
|
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
if (options.json) {
|
|
48
|
-
console.log(JSON.stringify({ ok: true, ref: refLabel, status: '
|
|
48
|
+
console.log(JSON.stringify({ ok: true, ref: refLabel, status: 'cli-hidden' }));
|
|
49
49
|
}
|
|
50
50
|
else {
|
|
51
|
-
console.log(`✓
|
|
51
|
+
console.log(`✓ ${refLabel} is now hidden from the CLI (still in your web library).`);
|
|
52
52
|
}
|
|
53
53
|
}
|
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';
|
|
@@ -180,7 +182,7 @@ program
|
|
|
180
182
|
});
|
|
181
183
|
program
|
|
182
184
|
.command('publish <source>')
|
|
183
|
-
.description('
|
|
185
|
+
.description('Add a skill to your library — pass a local path to upload, or @user/slug to make an existing skill discoverable from the CLI')
|
|
184
186
|
.option('--title <title>', 'BotDoc title')
|
|
185
187
|
.option('--description <description>', 'BotDoc description')
|
|
186
188
|
.option('--category <category>', 'Category (knowledge_management, dev_workflow, automation, agent_config, project_scaffold, other)')
|
|
@@ -194,14 +196,14 @@ program
|
|
|
194
196
|
});
|
|
195
197
|
program
|
|
196
198
|
.command('unpublish <ref>')
|
|
197
|
-
.description('Hide a
|
|
199
|
+
.description('Hide a skill from the CLI (it stays in your web library)')
|
|
198
200
|
.option('--yes', 'Skip the confirmation prompt')
|
|
199
201
|
.action(async (ref, options) => {
|
|
200
202
|
await unpublish(ref, { ...options, json: program.opts().json });
|
|
201
203
|
});
|
|
202
204
|
program
|
|
203
205
|
.command('delete <ref>')
|
|
204
|
-
.description('Delete a
|
|
206
|
+
.description('Delete a skill — fresh skills (no installs/history) are hard-deleted with cascade; ones with history are soft-deleted (hidden, version history preserved)')
|
|
205
207
|
.option('--yes', 'Skip the confirmation prompt')
|
|
206
208
|
.action(async (ref, options) => {
|
|
207
209
|
await deleteCmd(ref, { ...options, json: program.opts().json });
|
|
@@ -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
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -105,4 +105,23 @@ export declare function fetchRawContent(rawUrl: string, options?: {
|
|
|
105
105
|
* stale-token cases are 401, which is handled upstream as "Authentication
|
|
106
106
|
* failed"). */
|
|
107
107
|
export declare function friendlyApiErrorDetail(err: ApiError, ref?: string): string;
|
|
108
|
+
/** The free-tier friction error codes the server returns in the JSON body's
|
|
109
|
+
* `error` field. Commands branch on these to print a consistent, HONEST CTA
|
|
110
|
+
* (no fake purchase flow — all accounts are free today). Kept as a union so a
|
|
111
|
+
* server-side rename breaks the mapping exactly once, here. */
|
|
112
|
+
export type FreeTierErrorCode = 'device_cap_exceeded' | 'session_limit_exceeded' | 'skill_cap_exceeded' | 'library_conflict';
|
|
113
|
+
/**
|
|
114
|
+
* Map a server error to a friendly, HONEST one-liner for the free-tier
|
|
115
|
+
* frictions (and the expired-token 401). Returns null when `err` isn't one of
|
|
116
|
+
* the handled cases, so callers fall back to `friendlyApiErrorDetail`.
|
|
117
|
+
*
|
|
118
|
+
* Honesty rule (locked product decision): there is NO purchase/upgrade flow.
|
|
119
|
+
* Messages communicate the free limit and point at "learn more" docs — they
|
|
120
|
+
* never promise a working checkout. Higher limits for teams are described as
|
|
121
|
+
* "coming".
|
|
122
|
+
*
|
|
123
|
+
* Centralized here so `install` / `create` / `sync` / `ingest` render the same
|
|
124
|
+
* vocabulary; a copy tweak is a single edit.
|
|
125
|
+
*/
|
|
126
|
+
export declare function friendlyFreeTierError(err: ApiError): string | null;
|
|
108
127
|
export {};
|
package/dist/lib/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { loadAuth, getOrCreateDeviceId } from './config.js';
|
|
2
3
|
const DEFAULT_API_URL = 'https://www.botdocs.ai';
|
|
3
4
|
/** Default per-request timeout (ms). Exposed so tests can override without
|
|
4
5
|
* mocking `setTimeout` globally, and so callers can read the value when
|
|
@@ -27,6 +28,23 @@ function isLocalhostUrl(u) {
|
|
|
27
28
|
/** Track whether we've already printed the override-warning so the CLI doesn't
|
|
28
29
|
* spam stderr across every API call in a single process run. */
|
|
29
30
|
let overrideWarnPrinted = false;
|
|
31
|
+
/** Attach the device-identity headers to an authenticated request.
|
|
32
|
+
*
|
|
33
|
+
* `X-Device-Id` is the stable per-install id (or the `BOTDOCS_DEVICE_ID`
|
|
34
|
+
* override); `X-Machine-Name` is a display-only hint the server stores on the
|
|
35
|
+
* device row. Called from the SAME choke point as the bearer so every
|
|
36
|
+
* authenticated request carries the device identity — the free-tier
|
|
37
|
+
* device/session caps depend on it. Best-effort: a failure to resolve the id
|
|
38
|
+
* (e.g. a read-only home dir) never blocks the request. */
|
|
39
|
+
function attachDeviceHeaders(headers) {
|
|
40
|
+
try {
|
|
41
|
+
headers['X-Device-Id'] = getOrCreateDeviceId();
|
|
42
|
+
headers['X-Machine-Name'] = os.hostname();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Best-effort — never fail a request because we couldn't stamp a device id.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
30
48
|
export function getApiUrl() {
|
|
31
49
|
const override = process.env.BOTDOCS_API_URL;
|
|
32
50
|
if (!override)
|
|
@@ -148,6 +166,9 @@ export async function apiFetch(path, options = {}) {
|
|
|
148
166
|
}
|
|
149
167
|
else {
|
|
150
168
|
headers['Authorization'] = `Bearer ${config.token}`;
|
|
169
|
+
// Same choke point as the bearer: stamp the device identity so the
|
|
170
|
+
// server's free-tier device/session gates see this request.
|
|
171
|
+
attachDeviceHeaders(headers);
|
|
151
172
|
}
|
|
152
173
|
}
|
|
153
174
|
// AbortController + setTimeout is the lowest-overhead way to enforce a hard
|
|
@@ -245,6 +266,7 @@ export async function fetchRawContent(rawUrl, options = {}) {
|
|
|
245
266
|
const config = loadAuth();
|
|
246
267
|
if (config?.token) {
|
|
247
268
|
headers['Authorization'] = `Bearer ${config.token}`;
|
|
269
|
+
attachDeviceHeaders(headers);
|
|
248
270
|
}
|
|
249
271
|
// Same pattern as apiFetch — AbortController + clearTimeout in finally so
|
|
250
272
|
// the timer never outlives the request and blocks process exit.
|
|
@@ -302,3 +324,72 @@ export function friendlyApiErrorDetail(err, ref) {
|
|
|
302
324
|
return err.message;
|
|
303
325
|
return `request failed (${err.status})`;
|
|
304
326
|
}
|
|
327
|
+
/** Narrow an `ApiError.body` to the free-tier error shape when it carries one
|
|
328
|
+
* of the known codes. Returns null for any other body. */
|
|
329
|
+
function asFreeTierErrorBody(body) {
|
|
330
|
+
if (typeof body !== 'object' || body === null)
|
|
331
|
+
return null;
|
|
332
|
+
const code = body.error;
|
|
333
|
+
if (code === 'device_cap_exceeded' ||
|
|
334
|
+
code === 'session_limit_exceeded' ||
|
|
335
|
+
code === 'skill_cap_exceeded' ||
|
|
336
|
+
code === 'library_conflict') {
|
|
337
|
+
return { ...body, error: code };
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Map a server error to a friendly, HONEST one-liner for the free-tier
|
|
343
|
+
* frictions (and the expired-token 401). Returns null when `err` isn't one of
|
|
344
|
+
* the handled cases, so callers fall back to `friendlyApiErrorDetail`.
|
|
345
|
+
*
|
|
346
|
+
* Honesty rule (locked product decision): there is NO purchase/upgrade flow.
|
|
347
|
+
* Messages communicate the free limit and point at "learn more" docs — they
|
|
348
|
+
* never promise a working checkout. Higher limits for teams are described as
|
|
349
|
+
* "coming".
|
|
350
|
+
*
|
|
351
|
+
* Centralized here so `install` / `create` / `sync` / `ingest` render the same
|
|
352
|
+
* vocabulary; a copy tweak is a single edit.
|
|
353
|
+
*/
|
|
354
|
+
export function friendlyFreeTierError(err) {
|
|
355
|
+
// Expired-token 401: the server already 401s an expired bearer; surface the
|
|
356
|
+
// re-login CTA. (Generic 401s are handled upstream in apiFetch with the same
|
|
357
|
+
// wording; this keeps the mapping in one place for callers that inspect the
|
|
358
|
+
// raw ApiError.)
|
|
359
|
+
if (err.status === 401) {
|
|
360
|
+
return 'Your session has expired. Run `botdocs login` to sign in again.';
|
|
361
|
+
}
|
|
362
|
+
const body = asFreeTierErrorBody(err.body);
|
|
363
|
+
if (!body)
|
|
364
|
+
return null;
|
|
365
|
+
switch (body.error) {
|
|
366
|
+
case 'device_cap_exceeded': {
|
|
367
|
+
const limit = body.limit;
|
|
368
|
+
const limitText = typeof limit === 'number' ? `${limit} devices` : 'a limited number of devices';
|
|
369
|
+
return (`Free accounts are limited to ${limitText}, and you've reached that limit. ` +
|
|
370
|
+
`Revoke a device you no longer use at https://botdocs.ai/settings, or set ` +
|
|
371
|
+
`BOTDOCS_DEVICE_ID to reuse one identity in CI. Higher limits for teams are coming — ` +
|
|
372
|
+
`learn more at https://botdocs.ai/teams.`);
|
|
373
|
+
}
|
|
374
|
+
case 'session_limit_exceeded': {
|
|
375
|
+
const limit = body.limit;
|
|
376
|
+
const limitText = typeof limit === 'number' ? `${limit} devices` : 'a limited number of devices';
|
|
377
|
+
return (`Free accounts can be active on ${limitText} at once, and you're over that limit right now. ` +
|
|
378
|
+
`Wait a few minutes for another device to go idle, or set BOTDOCS_DEVICE_ID to share one ` +
|
|
379
|
+
`identity in CI. Higher limits for teams are coming — learn more at https://botdocs.ai/teams.`);
|
|
380
|
+
}
|
|
381
|
+
case 'skill_cap_exceeded': {
|
|
382
|
+
const limit = body.limit;
|
|
383
|
+
const limitText = typeof limit === 'number' ? `${limit} skills` : 'a limited number of skills';
|
|
384
|
+
return (`Free accounts are limited to ${limitText}, and you've reached that limit. ` +
|
|
385
|
+
`Remove a skill you no longer need, or learn about higher limits for teams (coming soon) ` +
|
|
386
|
+
`at https://botdocs.ai/teams.`);
|
|
387
|
+
}
|
|
388
|
+
case 'library_conflict': {
|
|
389
|
+
const device = body.conflictingDevice;
|
|
390
|
+
const who = device ? ` from "${device}"` : '';
|
|
391
|
+
return (`Your library was changed by another device${who} since this one last synced. ` +
|
|
392
|
+
`Run \`botdocs sync\` to pull the latest, then retry.`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|