@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
|
@@ -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
|
+
}
|
|
@@ -18,7 +18,7 @@ interface PublishOptions {
|
|
|
18
18
|
* draft → published flag is already cheap and reversible via `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;
|
|
@@ -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
|
@@ -460,12 +460,12 @@ function derivePublishSlug(source) {
|
|
|
460
460
|
.replace(/[^a-z0-9]+/g, '-')
|
|
461
461
|
.replace(/^-+|-+$/g, '');
|
|
462
462
|
}
|
|
463
|
-
function collectFromFile(filePath) {
|
|
463
|
+
export function collectFromFile(filePath) {
|
|
464
464
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
465
465
|
const filename = path.basename(filePath);
|
|
466
466
|
return [{ filename, content, sortOrder: 0 }];
|
|
467
467
|
}
|
|
468
|
-
function collectFromDirectory(dirPath) {
|
|
468
|
+
export function collectFromDirectory(dirPath) {
|
|
469
469
|
const files = [];
|
|
470
470
|
walkDirectory(dirPath, dirPath, files);
|
|
471
471
|
files.sort((a, b) => a.sortOrder - b.sortOrder);
|
package/dist/commands/sync.js
CHANGED
|
@@ -269,7 +269,7 @@ async function installTeamSkill(teamSlug, skill, options, silent) {
|
|
|
269
269
|
// version locking.)
|
|
270
270
|
let manifest;
|
|
271
271
|
try {
|
|
272
|
-
manifest = await apiFetch(`/api/skills/${skill.username}/${skill.slug}/manifest
|
|
272
|
+
manifest = await apiFetch(`/api/skills/${skill.username}/${skill.slug}/manifest`, { auth: 'optional' });
|
|
273
273
|
}
|
|
274
274
|
catch (err) {
|
|
275
275
|
if (err instanceof ApiError) {
|
|
@@ -376,7 +376,7 @@ async function runSyncCore(rawRef, targets, options, deps) {
|
|
|
376
376
|
const { username, slug } = refToPath(entry.ref);
|
|
377
377
|
let manifest;
|
|
378
378
|
try {
|
|
379
|
-
manifest = await apiFetch(`/api/skills/${username}/${slug}/manifest
|
|
379
|
+
manifest = await apiFetch(`/api/skills/${username}/${slug}/manifest`, { auth: 'optional' });
|
|
380
380
|
}
|
|
381
381
|
catch (err) {
|
|
382
382
|
if (err instanceof ApiError) {
|
package/dist/commands/team.js
CHANGED
|
@@ -237,7 +237,7 @@ export function registerTeamCommands(program) {
|
|
|
237
237
|
});
|
|
238
238
|
team
|
|
239
239
|
.command('push <slug> <ref>')
|
|
240
|
-
.description('Pin
|
|
240
|
+
.description('Pin one of your own published skills to a team (WRITE+ only; you can only pin skills you own)')
|
|
241
241
|
.option('--version <v>', 'Pin to a specific version (omit to float to latest)')
|
|
242
242
|
.action(async (slug, ref, opts) => {
|
|
243
243
|
await teamPush(slug, ref, { ...opts, json: program.opts().json });
|
package/dist/index.js
CHANGED
|
@@ -110,7 +110,6 @@ function genericFriendlyMessage(reason) {
|
|
|
110
110
|
}
|
|
111
111
|
import { Command } from 'commander';
|
|
112
112
|
import { checkAuthFilePerms } from './lib/config.js';
|
|
113
|
-
import { search } from './commands/search.js';
|
|
114
113
|
import { publish } from './commands/publish.js';
|
|
115
114
|
import { unpublish } from './commands/unpublish.js';
|
|
116
115
|
import { delete_ as deleteCmd } from './commands/delete.js';
|
|
@@ -127,6 +126,8 @@ import { ingest, SUPPORTED_TOOLS as INGEST_SUPPORTED_TOOLS } from './commands/in
|
|
|
127
126
|
import { checkUpdates } from './commands/check-updates.js';
|
|
128
127
|
import { compile } from './commands/compile.js';
|
|
129
128
|
import { edit } from './commands/edit.js';
|
|
129
|
+
import { propose } from './commands/propose.js';
|
|
130
|
+
import { registerProposalsCommands } from './commands/proposals.js';
|
|
130
131
|
import { registerTeamCommands } from './commands/team.js';
|
|
131
132
|
import { registerBackupCommands } from './commands/backups.js';
|
|
132
133
|
import { undo } from './commands/undo.js';
|
|
@@ -179,12 +180,6 @@ program
|
|
|
179
180
|
.action(async (source) => {
|
|
180
181
|
await validate(source, { json: program.opts().json });
|
|
181
182
|
});
|
|
182
|
-
program
|
|
183
|
-
.command('search <query>')
|
|
184
|
-
.description('Search for BotDocs')
|
|
185
|
-
.action(async (query) => {
|
|
186
|
-
await search(query, { json: program.opts().json });
|
|
187
|
-
});
|
|
188
183
|
program
|
|
189
184
|
.command('publish <source>')
|
|
190
185
|
.description('Publish a BotDoc — pass a local path to upload, or @user/slug to mark an existing draft live')
|
|
@@ -332,6 +327,19 @@ program
|
|
|
332
327
|
.action(async (ref, opts) => {
|
|
333
328
|
await edit(ref, { ...opts, json: program.opts().json });
|
|
334
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);
|
|
335
343
|
registerTeamCommands(program);
|
|
336
344
|
registerBackupCommands(program);
|
|
337
345
|
program
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -40,7 +40,14 @@ export declare class WaitlistError extends ApiError {
|
|
|
40
40
|
interface FetchOptions {
|
|
41
41
|
method?: string;
|
|
42
42
|
body?: unknown;
|
|
43
|
-
|
|
43
|
+
/** Authorization behavior:
|
|
44
|
+
* - `false` (default): never attach a bearer token.
|
|
45
|
+
* - `true`: REQUIRE a saved token — throw `ApiError(401)` when absent.
|
|
46
|
+
* - `'optional'`: attach the bearer IF a token is saved, but don't throw
|
|
47
|
+
* when it's missing. Used by read paths (manifest/versions) that gate on
|
|
48
|
+
* the server side: a signed-in user reaches their PRIVATE skills, while an
|
|
49
|
+
* anonymous caller still fetches PUBLIC/bootstrap skills tokenlessly. */
|
|
50
|
+
auth?: boolean | 'optional';
|
|
44
51
|
/** Per-call override of the default request timeout (ms). When the timer
|
|
45
52
|
* fires we abort the underlying fetch and throw an ApiError(0, "timed out…").
|
|
46
53
|
* Defaults to {@link DEFAULT_TIMEOUT_MS}. Set higher for known-slow paths
|
|
@@ -71,6 +78,14 @@ export declare function apiFetch<T>(path: string, options?: FetchOptions): Promi
|
|
|
71
78
|
* network failures and timeouts both surface as `ApiError(0, …)` so the
|
|
72
79
|
* callers don't need to special-case fetchRawContent vs apiFetch in
|
|
73
80
|
* their `instanceof ApiError` catch arms.
|
|
81
|
+
*
|
|
82
|
+
* Attaches `Authorization: Bearer <token>` when one is saved so a signed-in
|
|
83
|
+
* user can read the raw bytes of their PRIVATE skills (the server gates
|
|
84
|
+
* `/api/raw` on the same predicate as the manifest). PUBLIC and platform
|
|
85
|
+
* bootstrap skills still resolve with no token, so first-run fetches (e.g.
|
|
86
|
+
* `@botdocs/cli-quickstart`) keep working unauthenticated. The TLS guard in
|
|
87
|
+
* getApiUrl() refuses to emit the bearer over plaintext to a non-loopback
|
|
88
|
+
* host — see {@link getApiUrl}.
|
|
74
89
|
*/
|
|
75
90
|
export declare function fetchRawContent(rawUrl: string, options?: {
|
|
76
91
|
timeoutMs?: number;
|
package/dist/lib/api.js
CHANGED
|
@@ -131,12 +131,24 @@ export async function apiFetch(path, options = {}) {
|
|
|
131
131
|
if (auth) {
|
|
132
132
|
const config = loadAuth();
|
|
133
133
|
if (!config?.token) {
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
134
|
+
// `auth: 'optional'` means "attach the bearer if we have one, otherwise
|
|
135
|
+
// proceed anonymously" — the server gates the read. Only the strict
|
|
136
|
+
// `auth: true` path throws when no token is saved. Shape that as an
|
|
137
|
+
// ApiError(401) so callers that already branch on auth failures (e.g.
|
|
138
|
+
// sync's optional team-skill path) handle it uniformly without a
|
|
139
|
+
// separate "no token saved" code path.
|
|
140
|
+
if (auth === 'optional') {
|
|
141
|
+
// No token — fall through and send the request without Authorization.
|
|
142
|
+
// The TLS guard in getApiUrl() above already ran, so the (absent)
|
|
143
|
+
// bearer can't leak over plaintext regardless.
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
throw new ApiError(401, 'Not authenticated. Run `botdocs login` first.');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
headers['Authorization'] = `Bearer ${config.token}`;
|
|
138
151
|
}
|
|
139
|
-
headers['Authorization'] = `Bearer ${config.token}`;
|
|
140
152
|
}
|
|
141
153
|
// AbortController + setTimeout is the lowest-overhead way to enforce a hard
|
|
142
154
|
// per-request deadline on Node's fetch — there's no built-in `timeout`
|
|
@@ -216,18 +228,31 @@ export async function apiFetch(path, options = {}) {
|
|
|
216
228
|
* network failures and timeouts both surface as `ApiError(0, …)` so the
|
|
217
229
|
* callers don't need to special-case fetchRawContent vs apiFetch in
|
|
218
230
|
* their `instanceof ApiError` catch arms.
|
|
231
|
+
*
|
|
232
|
+
* Attaches `Authorization: Bearer <token>` when one is saved so a signed-in
|
|
233
|
+
* user can read the raw bytes of their PRIVATE skills (the server gates
|
|
234
|
+
* `/api/raw` on the same predicate as the manifest). PUBLIC and platform
|
|
235
|
+
* bootstrap skills still resolve with no token, so first-run fetches (e.g.
|
|
236
|
+
* `@botdocs/cli-quickstart`) keep working unauthenticated. The TLS guard in
|
|
237
|
+
* getApiUrl() refuses to emit the bearer over plaintext to a non-loopback
|
|
238
|
+
* host — see {@link getApiUrl}.
|
|
219
239
|
*/
|
|
220
240
|
export async function fetchRawContent(rawUrl, options = {}) {
|
|
221
241
|
const { timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
222
242
|
const baseUrl = getApiUrl();
|
|
223
243
|
const url = rawUrl.startsWith('http') ? rawUrl : `${baseUrl}${rawUrl}`;
|
|
244
|
+
const headers = {};
|
|
245
|
+
const config = loadAuth();
|
|
246
|
+
if (config?.token) {
|
|
247
|
+
headers['Authorization'] = `Bearer ${config.token}`;
|
|
248
|
+
}
|
|
224
249
|
// Same pattern as apiFetch — AbortController + clearTimeout in finally so
|
|
225
250
|
// the timer never outlives the request and blocks process exit.
|
|
226
251
|
const controller = new AbortController();
|
|
227
252
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
228
253
|
let response;
|
|
229
254
|
try {
|
|
230
|
-
response = await fetch(url, { signal: controller.signal });
|
|
255
|
+
response = await fetch(url, { headers, signal: controller.signal });
|
|
231
256
|
}
|
|
232
257
|
catch (err) {
|
|
233
258
|
if (isAbortError(err)) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** A file for hashing — ONLY filename + content participate. */
|
|
2
|
+
export interface HashableFile {
|
|
3
|
+
filename: string;
|
|
4
|
+
content: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Canonical file-set hash. Mirrors the server's `fileSetHash` exactly so the
|
|
8
|
+
* `basedFileHash` the CLI sends matches what the server would compute over the
|
|
9
|
+
* same live file set. Sort is by filename ascending using the same comparison
|
|
10
|
+
* the server uses (`<` / `>` on the raw filename string).
|
|
11
|
+
*/
|
|
12
|
+
export declare function fileSetHash(files: ReadonlyArray<HashableFile>): string;
|
|
13
|
+
/**
|
|
14
|
+
* Canonical SYNTHESIS idempotency hash. DISTINCT from {@link fileSetHash}
|
|
15
|
+
* (which is the drift anchor / `basedFileHash`). The server keys the
|
|
16
|
+
* partial-unique index `botdoc_proposals_synthesis_hash_uq` on this value so
|
|
17
|
+
* the same OPEN synthesis (same sources + same merged content) can't be
|
|
18
|
+
* created twice — the CLI computes it before POSTing only so the value is
|
|
19
|
+
* stable client-side; the server recomputes + enforces it.
|
|
20
|
+
*
|
|
21
|
+
* MUST match `apps/web/src/lib/proposals.ts#sourceHash` byte-for-byte:
|
|
22
|
+
*
|
|
23
|
+
* sha256-hex( JSON.stringify({
|
|
24
|
+
* sources: [...new Set(synthesizedFrom)].sort(),
|
|
25
|
+
* files: [...mergedFiles]
|
|
26
|
+
* .sort by filename ascending (same `<`/`>` compare as fileSetHash)
|
|
27
|
+
* .map(f => [f.filename, sha256hex(f.content)]),
|
|
28
|
+
* }) )
|
|
29
|
+
*
|
|
30
|
+
* Notes (identical exclusions to fileSetHash):
|
|
31
|
+
* - `sources` is deduplicated + lexicographically sorted, so source-id
|
|
32
|
+
* ordering / duplicates don't change the hash.
|
|
33
|
+
* - `files` pairs each filename with the sha256 of its content (not the raw
|
|
34
|
+
* body) to keep the JSON small.
|
|
35
|
+
* - `mode`/`sortOrder` are NOT part of the hash.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sourceHash(synthesizedFrom: ReadonlyArray<string>, mergedFiles: ReadonlyArray<HashableFile>): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proposals.ts — shared helpers for the CLI proposal lane.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing piece here is {@link fileSetHash}: the canonical file-set
|
|
5
|
+
* hash that the server computes in `apps/web/src/lib/proposals.ts#fileSetHash`.
|
|
6
|
+
* The two MUST agree byte-for-byte — the server uses it as the drift anchor
|
|
7
|
+
* (`basedFileHash`) the CLI sends on every `propose`. Any divergence (sort
|
|
8
|
+
* order, framing byte, included fields) would make the server reject or
|
|
9
|
+
* mis-detect drift for every proposal.
|
|
10
|
+
*
|
|
11
|
+
* Canonical spec (identical on both sides):
|
|
12
|
+
* - sha256, hex digest
|
|
13
|
+
* - over each file sorted by filename ascending (JS default string compare)
|
|
14
|
+
* - of: filename + "\u0000" + content + "\u0000"
|
|
15
|
+
* - `mode` and `sortOrder` are NOT part of the hash.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
/** NUL framing byte between/after each (filename, content) pair. Defined once
|
|
19
|
+
* so the intent is obvious and it can never be mistaken for a space. */
|
|
20
|
+
const NUL = '\u0000';
|
|
21
|
+
/**
|
|
22
|
+
* Canonical file-set hash. Mirrors the server's `fileSetHash` exactly so the
|
|
23
|
+
* `basedFileHash` the CLI sends matches what the server would compute over the
|
|
24
|
+
* same live file set. Sort is by filename ascending using the same comparison
|
|
25
|
+
* the server uses (`<` / `>` on the raw filename string).
|
|
26
|
+
*/
|
|
27
|
+
export function fileSetHash(files) {
|
|
28
|
+
const sorted = [...files].sort((a, b) => a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0);
|
|
29
|
+
const hash = createHash('sha256');
|
|
30
|
+
for (const f of sorted) {
|
|
31
|
+
hash.update(f.filename);
|
|
32
|
+
hash.update(NUL);
|
|
33
|
+
hash.update(f.content);
|
|
34
|
+
hash.update(NUL);
|
|
35
|
+
}
|
|
36
|
+
return hash.digest('hex');
|
|
37
|
+
}
|
|
38
|
+
/** sha256-hex of a single string (a file's content). Helper for {@link sourceHash}. */
|
|
39
|
+
function sha256Hex(value) {
|
|
40
|
+
return createHash('sha256').update(value).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Canonical SYNTHESIS idempotency hash. DISTINCT from {@link fileSetHash}
|
|
44
|
+
* (which is the drift anchor / `basedFileHash`). The server keys the
|
|
45
|
+
* partial-unique index `botdoc_proposals_synthesis_hash_uq` on this value so
|
|
46
|
+
* the same OPEN synthesis (same sources + same merged content) can't be
|
|
47
|
+
* created twice — the CLI computes it before POSTing only so the value is
|
|
48
|
+
* stable client-side; the server recomputes + enforces it.
|
|
49
|
+
*
|
|
50
|
+
* MUST match `apps/web/src/lib/proposals.ts#sourceHash` byte-for-byte:
|
|
51
|
+
*
|
|
52
|
+
* sha256-hex( JSON.stringify({
|
|
53
|
+
* sources: [...new Set(synthesizedFrom)].sort(),
|
|
54
|
+
* files: [...mergedFiles]
|
|
55
|
+
* .sort by filename ascending (same `<`/`>` compare as fileSetHash)
|
|
56
|
+
* .map(f => [f.filename, sha256hex(f.content)]),
|
|
57
|
+
* }) )
|
|
58
|
+
*
|
|
59
|
+
* Notes (identical exclusions to fileSetHash):
|
|
60
|
+
* - `sources` is deduplicated + lexicographically sorted, so source-id
|
|
61
|
+
* ordering / duplicates don't change the hash.
|
|
62
|
+
* - `files` pairs each filename with the sha256 of its content (not the raw
|
|
63
|
+
* body) to keep the JSON small.
|
|
64
|
+
* - `mode`/`sortOrder` are NOT part of the hash.
|
|
65
|
+
*/
|
|
66
|
+
export function sourceHash(synthesizedFrom, mergedFiles) {
|
|
67
|
+
const sources = [...new Set(synthesizedFrom)].sort();
|
|
68
|
+
const files = [...mergedFiles]
|
|
69
|
+
.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0))
|
|
70
|
+
.map((f) => [f.filename, sha256Hex(f.content)]);
|
|
71
|
+
return sha256Hex(JSON.stringify({ sources, files }));
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botdocs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "CLI for BotDocs — author, publish, install, and sync agent skills across Claude, Claude Code, Cursor, Codex, ChatGPT, Windsurf, Copilot, Gemini, Antigravity, and OpenCode.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"botdocs",
|
package/dist/commands/search.js
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { apiFetch } from '../lib/api.js';
|
|
2
|
-
export async function search(query, options = {}) {
|
|
3
|
-
if (!query.trim()) {
|
|
4
|
-
console.error('Please provide a search query.');
|
|
5
|
-
process.exit(1);
|
|
6
|
-
}
|
|
7
|
-
const encoded = encodeURIComponent(query);
|
|
8
|
-
const data = await apiFetch(`/api/search?q=${encoded}`);
|
|
9
|
-
if (options.json) {
|
|
10
|
-
console.log(JSON.stringify(data, null, 2));
|
|
11
|
-
return;
|
|
12
|
-
}
|
|
13
|
-
if (data.results.length === 0) {
|
|
14
|
-
console.log(`No results for "${query}".`);
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
console.log(`${data.total} result(s) for "${query}":\n`);
|
|
18
|
-
// Print formatted table
|
|
19
|
-
const maxTitle = Math.min(40, Math.max(...data.results.map((r) => r.title.length)));
|
|
20
|
-
const maxAuthor = Math.max(...data.results.map((r) => r.author.length));
|
|
21
|
-
const header = [
|
|
22
|
-
'Title'.padEnd(maxTitle),
|
|
23
|
-
'Author'.padEnd(maxAuthor),
|
|
24
|
-
'Stars'.padStart(5),
|
|
25
|
-
'Clones'.padStart(6),
|
|
26
|
-
'Category',
|
|
27
|
-
].join(' ');
|
|
28
|
-
const separator = '-'.repeat(header.length);
|
|
29
|
-
console.log(header);
|
|
30
|
-
console.log(separator);
|
|
31
|
-
for (const result of data.results) {
|
|
32
|
-
const title = result.title.length > maxTitle
|
|
33
|
-
? result.title.slice(0, maxTitle - 1) + '…'
|
|
34
|
-
: result.title.padEnd(maxTitle);
|
|
35
|
-
const row = [
|
|
36
|
-
title,
|
|
37
|
-
result.author.padEnd(maxAuthor),
|
|
38
|
-
String(result.starCount).padStart(5),
|
|
39
|
-
String(result.cloneCount).padStart(6),
|
|
40
|
-
formatCategory(result.category),
|
|
41
|
-
].join(' ');
|
|
42
|
-
console.log(row);
|
|
43
|
-
}
|
|
44
|
-
if (data.totalPages > 1) {
|
|
45
|
-
console.log(`\nPage ${data.page}/${data.totalPages}`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function formatCategory(category) {
|
|
49
|
-
const labels = {
|
|
50
|
-
KNOWLEDGE_MANAGEMENT: 'Knowledge',
|
|
51
|
-
DEV_WORKFLOW: 'Dev Workflow',
|
|
52
|
-
AUTOMATION: 'Automation',
|
|
53
|
-
AGENT_CONFIG: 'Agent Config',
|
|
54
|
-
PROJECT_SCAFFOLD: 'Scaffold',
|
|
55
|
-
OTHER: 'Other',
|
|
56
|
-
};
|
|
57
|
-
return labels[category] ?? category;
|
|
58
|
-
}
|