@botdocs/cli 0.10.3 → 0.12.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/README.md +7 -5
- package/bin/botdocs.cjs +78 -0
- package/dist/commands/check-updates.d.ts +22 -0
- package/dist/commands/check-updates.js +73 -18
- package/dist/commands/edit.js +10 -2
- package/dist/commands/ingest.d.ts +25 -0
- package/dist/commands/ingest.js +303 -12
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +43 -6
- package/dist/commands/install.js +146 -5
- package/dist/commands/list.js +62 -0
- package/dist/commands/login.js +56 -2
- package/dist/commands/publish.d.ts +30 -3
- package/dist/commands/publish.js +353 -40
- package/dist/commands/sync.js +252 -40
- package/dist/commands/uninstall.js +12 -0
- package/dist/commands/validate.js +82 -8
- package/dist/commands/views/login-app.js +6 -5
- package/dist/commands/views/theme.d.ts +3 -4
- package/dist/commands/views/theme.js +3 -4
- package/dist/index.js +162 -30
- package/dist/lib/api.d.ts +55 -2
- package/dist/lib/api.js +168 -11
- package/dist/lib/auto-detect.js +70 -30
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +83 -2
- package/dist/lib/ingest-session-client.d.ts +93 -0
- package/dist/lib/ingest-session-client.js +217 -0
- package/dist/lib/lockfile.d.ts +13 -0
- package/dist/lib/manifest.d.ts +12 -0
- package/dist/lib/manifest.js +29 -2
- package/dist/lib/node-preflight.d.ts +20 -0
- package/dist/lib/node-preflight.js +11 -0
- package/dist/lib/skill-caps.d.ts +17 -0
- package/dist/lib/skill-caps.js +19 -0
- package/package.json +3 -3
package/dist/commands/publish.js
CHANGED
|
@@ -1,11 +1,51 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import AdmZip from 'adm-zip';
|
|
4
|
-
import
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import { ApiError, apiFetch, getApiUrl, friendlyApiErrorDetail } from '../lib/api.js';
|
|
5
6
|
import { parseManifest } from '../lib/manifest.js';
|
|
6
7
|
import { compile } from './compile.js';
|
|
7
8
|
import { ecosystemDestination } from '../lib/canonical.js';
|
|
8
9
|
import { isRefForm, parseRef } from '../lib/ref.js';
|
|
10
|
+
import { loadAuth } from '../lib/config.js';
|
|
11
|
+
/** Bail out early when the user isn't logged in. Without this, publish runs
|
|
12
|
+
* through file collection, manifest parsing, and the description prompt
|
|
13
|
+
* before the POST hits a 401 — wasting input and surfacing a confusing
|
|
14
|
+
* "Authentication failed" message at the end. Mirror whoami.ts's preflight:
|
|
15
|
+
* one friendly line, exit 1. JSON-mode emits the same structured payload
|
|
16
|
+
* other publish auth errors do so scripts can branch on it. */
|
|
17
|
+
function requireAuth(options) {
|
|
18
|
+
if (loadAuth())
|
|
19
|
+
return;
|
|
20
|
+
if (options.json) {
|
|
21
|
+
console.log(JSON.stringify({ ok: false, error: 'not logged in', hint: 'Run `botdocs login` first.' }));
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
console.error('\n ✗ Not logged in. Run `botdocs login` first.\n');
|
|
25
|
+
}
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
/** Canonical-skill primary-file heuristic — mirrors the server-side
|
|
29
|
+
* `pickPrimaryFile` in `apps/web/src/app/api/cli/ingest/route.ts`. Used to
|
|
30
|
+
* answer "does this multi-file payload have something we can publish?"
|
|
31
|
+
* without requiring a literal `index.md`. */
|
|
32
|
+
function hasPublishablePrimary(files) {
|
|
33
|
+
if (files.length === 0)
|
|
34
|
+
return false;
|
|
35
|
+
const basename = (filename) => {
|
|
36
|
+
const idx = filename.lastIndexOf('/');
|
|
37
|
+
return idx === -1 ? filename : filename.slice(idx + 1);
|
|
38
|
+
};
|
|
39
|
+
if (files.some((f) => /\/(SKILL|AGENT)\.md$/.test(f.filename)))
|
|
40
|
+
return true;
|
|
41
|
+
if (files.some((f) => basename(f.filename) === 'SKILL.md' || basename(f.filename) === 'AGENT.md'))
|
|
42
|
+
return true;
|
|
43
|
+
if (files.some((f) => f.filename === 'index.md'))
|
|
44
|
+
return true;
|
|
45
|
+
if (files.some((f) => f.filename.endsWith('.md') || f.filename.endsWith('.mdc')))
|
|
46
|
+
return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
9
49
|
const VALID_CATEGORIES = [
|
|
10
50
|
'KNOWLEDGE_MANAGEMENT',
|
|
11
51
|
'DEV_WORKFLOW',
|
|
@@ -16,6 +56,10 @@ const VALID_CATEGORIES = [
|
|
|
16
56
|
];
|
|
17
57
|
const VALID_LICENSES = ['MIT', 'CC_BY_4_0', 'CC_BY_SA_4_0', 'CC0', 'ALL_RIGHTS_RESERVED'];
|
|
18
58
|
export async function publish(source, options) {
|
|
59
|
+
// Preflight: bail before any file reading / description prompting if the
|
|
60
|
+
// user isn't logged in. The 401 would otherwise only surface after the POST,
|
|
61
|
+
// making large publishes feel slow and confusing.
|
|
62
|
+
requireAuth(options);
|
|
19
63
|
// Ref-form (e.g. "@user/slug" or "user/slug") → toggle the published
|
|
20
64
|
// flag via the API. Path-form continues to the existing upload flow
|
|
21
65
|
// below. Overloading by argument shape (rather than introducing
|
|
@@ -31,7 +75,19 @@ export async function publish(source, options) {
|
|
|
31
75
|
console.error(`Source not found: ${source}`);
|
|
32
76
|
process.exit(1);
|
|
33
77
|
}
|
|
34
|
-
// Determine source type and collect files
|
|
78
|
+
// Determine source type and collect files. `lstatSync` reports the path's
|
|
79
|
+
// OWN type rather than its target's, so we can refuse a symlinked entry
|
|
80
|
+
// point. Without this, `botdocs publish ./leak.md` where `leak.md` is a
|
|
81
|
+
// symlink to e.g. `~/.ssh/id_rsa` would read the target into the publish
|
|
82
|
+
// payload — the same exfil class the walker fix closes for nested
|
|
83
|
+
// entries. We reject (fatal) rather than warn-and-skip: the user
|
|
84
|
+
// explicitly named this path, so silently doing nothing would be more
|
|
85
|
+
// confusing than the error.
|
|
86
|
+
const lstat = fs.lstatSync(resolved);
|
|
87
|
+
if (lstat.isSymbolicLink()) {
|
|
88
|
+
console.error(`Refusing to publish from a symlink: ${source}. Pass the resolved real path instead.`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
35
91
|
let files;
|
|
36
92
|
const stat = fs.statSync(resolved);
|
|
37
93
|
if (stat.isDirectory()) {
|
|
@@ -49,21 +105,32 @@ export async function publish(source, options) {
|
|
|
49
105
|
console.error('No markdown files found.');
|
|
50
106
|
process.exit(1);
|
|
51
107
|
}
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
108
|
+
// Canonical skill layouts (`claude/SKILL.md`, `claude-code/commands/<slug>.md`,
|
|
109
|
+
// top-level `<slug>.md`/`.mdc`) don't have a literal `index.md`. Require
|
|
110
|
+
// "at least one publishable markdown file" instead — matching the
|
|
111
|
+
// server-side `pickPrimaryFile` heuristic and the repo CLAUDE.md guidance.
|
|
112
|
+
if (!hasPublishablePrimary(files)) {
|
|
56
113
|
if (files.length === 1) {
|
|
114
|
+
// Single file with an unusual extension — rename it so the server
|
|
115
|
+
// can still resolve a primary file. Preserves the old single-file
|
|
116
|
+
// ergonomics without re-introducing the hard `index.md` requirement.
|
|
57
117
|
files[0].filename = 'index.md';
|
|
58
118
|
}
|
|
59
119
|
else {
|
|
60
|
-
console.error('
|
|
120
|
+
console.error('No publishable markdown found (expected SKILL.md, AGENT.md, <slug>.md/.mdc, index.md, or any .md).');
|
|
61
121
|
process.exit(1);
|
|
62
122
|
}
|
|
63
123
|
}
|
|
124
|
+
// Pull type + sourceEcosystem from botdocs.json so the server stores
|
|
125
|
+
// the row as a SKILL/BUNDLE (instead of defaulting to SPEC).
|
|
126
|
+
const manifest = stat.isDirectory() ? readManifest(resolved) : null;
|
|
64
127
|
// Resolve metadata from flags
|
|
65
128
|
const title = options.title || deriveTitle(source, files);
|
|
66
|
-
|
|
129
|
+
// Description resolution: --description flag > botdocs.json `description` >
|
|
130
|
+
// empty. The manifest fallback lets `botdocs publish .` work without
|
|
131
|
+
// forcing the author to repeat the flag every time once they've put the
|
|
132
|
+
// description in their manifest.
|
|
133
|
+
const description = options.description || manifest?.description || '';
|
|
67
134
|
const category = resolveCategory(options.category);
|
|
68
135
|
const tags = options.tags
|
|
69
136
|
? options.tags
|
|
@@ -72,37 +139,168 @@ export async function publish(source, options) {
|
|
|
72
139
|
.filter(Boolean)
|
|
73
140
|
: [];
|
|
74
141
|
const license = resolveLicense(options.license);
|
|
142
|
+
const botdocType = manifest?.type ?? 'SPEC';
|
|
143
|
+
const sourceEcosystem = manifest?.sourceEcosystem ?? null;
|
|
144
|
+
// --dry-run: print what would be published and exit. Validates everything
|
|
145
|
+
// up to (but not including) the POST so authors can sanity-check the
|
|
146
|
+
// payload — title resolution, description fallback, file list, total size
|
|
147
|
+
// vs cap — before committing. Mirrors the file/total caps in
|
|
148
|
+
// apps/web/src/lib/skill-caps.ts (64KB per file, 512KB total) so the
|
|
149
|
+
// preview reflects what the server would enforce.
|
|
150
|
+
//
|
|
151
|
+
// Description is intentionally NOT required in dry-run: authors run
|
|
152
|
+
// `--dry-run` to preview the payload BEFORE filling in metadata. Surface
|
|
153
|
+
// the missing field as a placeholder in the preview rather than aborting.
|
|
154
|
+
if (options.dryRun) {
|
|
155
|
+
const previewDescription = description || '<missing — fill in before publishing>';
|
|
156
|
+
printDryRun({ title, description: previewDescription, files, sourceEcosystem, source }, options);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
75
159
|
if (!description) {
|
|
76
|
-
console.error('Description is required. Use --description "..."');
|
|
160
|
+
console.error('Description is required. Use --description "..." or set "description" in botdocs.json.');
|
|
77
161
|
process.exit(1);
|
|
78
162
|
}
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
163
|
+
// Pre-POST confirmation. Publish is immediate and pushes the skill to the
|
|
164
|
+
// public registry — first-time authors don't always know that, so we ask
|
|
165
|
+
// y/N (defaulting to N) before sending. Skipped under --yes (scripted
|
|
166
|
+
// runs that opt in) and --json (non-interactive consumers, which also
|
|
167
|
+
// gives back-compat for callers that don't have a TTY).
|
|
168
|
+
if (!options.yes && !options.json) {
|
|
169
|
+
const auth = loadAuth();
|
|
170
|
+
// requireAuth() ran first, so auth is non-null here. The fallback is
|
|
171
|
+
// defensive in case of a future refactor.
|
|
172
|
+
const username = auth?.username ?? 'me';
|
|
173
|
+
const slugGuess = derivePublishSlug(source);
|
|
174
|
+
const confirmed = await p.confirm({
|
|
175
|
+
message: `Publish to public registry as @${username}/${slugGuess}?`,
|
|
176
|
+
initialValue: false,
|
|
177
|
+
});
|
|
178
|
+
if (p.isCancel(confirmed) || confirmed !== true) {
|
|
179
|
+
console.log(' Publish cancelled.');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
84
183
|
console.log(`Publishing "${title}" (${files.length} file(s))...`);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
184
|
+
let result;
|
|
185
|
+
try {
|
|
186
|
+
result = await apiFetch('/api/botdocs', {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
auth: true,
|
|
189
|
+
body: {
|
|
190
|
+
title,
|
|
191
|
+
description,
|
|
192
|
+
category,
|
|
193
|
+
tags,
|
|
194
|
+
license,
|
|
195
|
+
files,
|
|
196
|
+
botdocType,
|
|
197
|
+
sourceEcosystem,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
handlePathPublishError(err, options);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// Belt-and-suspenders: older server responses (or any path that slips
|
|
206
|
+
// through with a relative URL) become clickable links here. Newer
|
|
207
|
+
// servers return an absolute URL already, in which case this is a
|
|
208
|
+
// no-op.
|
|
209
|
+
const absoluteUrl = result.url.startsWith('/') ? `${getApiUrl()}${result.url}` : result.url;
|
|
99
210
|
if (options.json) {
|
|
100
|
-
console.log(JSON.stringify(result));
|
|
211
|
+
console.log(JSON.stringify({ ...result, url: absoluteUrl }));
|
|
101
212
|
}
|
|
102
213
|
else {
|
|
103
|
-
console.log(`\nPublished: ${
|
|
214
|
+
console.log(`\nPublished: ${absoluteUrl}`);
|
|
104
215
|
}
|
|
105
216
|
}
|
|
217
|
+
/** Translate path-publish API errors into actionable CLI messages.
|
|
218
|
+
*
|
|
219
|
+
* 409 ALREADY_EXISTS — the slug exists live for this author. The server
|
|
220
|
+
* suggests using the ref-form publish or the edit UI; pass that through.
|
|
221
|
+
*
|
|
222
|
+
* 409 ARCHIVED_SLUG — the slug exists but was soft-deleted. The server
|
|
223
|
+
* suggests restoring via the web UI; pass that through. (We don't auto-revive
|
|
224
|
+
* here because the user might genuinely want to start fresh on a different
|
|
225
|
+
* skill that happens to share the title — surfacing the conflict and letting
|
|
226
|
+
* them decide is safer.)
|
|
227
|
+
*
|
|
228
|
+
* 413 (payload too large) — the server's `validateSkillCaps` returns a
|
|
229
|
+
* structured `detail` body ("file foo/bar.md is 71 KB, cap is 64 KB"). We
|
|
230
|
+
* print `detail` instead of the generic ApiError message so authors know
|
|
231
|
+
* exactly which file to trim.
|
|
232
|
+
*
|
|
233
|
+
* Everything else falls back to the generic ApiError message.
|
|
234
|
+
*/
|
|
235
|
+
function handlePathPublishError(err, options) {
|
|
236
|
+
if (!(err instanceof ApiError)) {
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
// The 413 body now ships structured `detail: { ..., message }`. Older
|
|
240
|
+
// server versions sent `detail: string`; we accept both shapes here so a
|
|
241
|
+
// freshly-deployed CLI keeps working against a not-yet-deployed server,
|
|
242
|
+
// and vice versa.
|
|
243
|
+
const body = err.body;
|
|
244
|
+
const code = body?.code;
|
|
245
|
+
const hint = body?.hint;
|
|
246
|
+
const detailMessage = extractDetailMessage(body?.detail);
|
|
247
|
+
if (options.json) {
|
|
248
|
+
console.log(JSON.stringify({
|
|
249
|
+
ok: false,
|
|
250
|
+
status: err.status,
|
|
251
|
+
error: err.message,
|
|
252
|
+
code: code ?? null,
|
|
253
|
+
hint: hint ?? null,
|
|
254
|
+
// Pass through the raw detail (string OR object) so scripted
|
|
255
|
+
// consumers can still read the structured fields. JSON.stringify
|
|
256
|
+
// handles both shapes cleanly.
|
|
257
|
+
detail: body?.detail ?? null,
|
|
258
|
+
}));
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
if (err.status === 409 && code === 'ALREADY_EXISTS') {
|
|
262
|
+
console.error(`\n ✗ ${err.message}`);
|
|
263
|
+
if (hint)
|
|
264
|
+
console.error(` ${hint}`);
|
|
265
|
+
console.error('');
|
|
266
|
+
}
|
|
267
|
+
else if (err.status === 409 && code === 'ARCHIVED_SLUG') {
|
|
268
|
+
console.error(`\n ✗ ${err.message}`);
|
|
269
|
+
if (hint)
|
|
270
|
+
console.error(` ${hint}`);
|
|
271
|
+
console.error('');
|
|
272
|
+
}
|
|
273
|
+
else if (err.status === 413) {
|
|
274
|
+
// Prefer the server-rendered `detail.message` ("file foo.md is 71 KB,
|
|
275
|
+
// cap is 64 KB") because the server knows the per-branch context. Fall
|
|
276
|
+
// back to:
|
|
277
|
+
// 1. a string-form `detail` (older servers)
|
|
278
|
+
// 2. the generic `err.message`
|
|
279
|
+
// so we always print something user-actionable instead of the previous
|
|
280
|
+
// `[object Object]` regression.
|
|
281
|
+
console.error(`\n ✗ ${detailMessage ?? err.message}\n`);
|
|
282
|
+
}
|
|
283
|
+
else if (err.status === 401) {
|
|
284
|
+
console.error(`\n ✗ ${err.message}\n`);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// Generic 403/429/5xx — route through the shared helper so the
|
|
288
|
+
// wording matches sync/install/edit instead of leaking ApiError's raw
|
|
289
|
+
// `message` (which is often the server's error string verbatim).
|
|
290
|
+
console.error(`\n ✗ ${friendlyApiErrorDetail(err)}\n`);
|
|
291
|
+
}
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
/** Pull a printable line out of the 413 `detail` field, accepting both the
|
|
295
|
+
* new structured shape (`{ message: '…' }`) and the legacy string form. */
|
|
296
|
+
function extractDetailMessage(detail) {
|
|
297
|
+
if (typeof detail === 'string')
|
|
298
|
+
return detail;
|
|
299
|
+
if (detail && typeof detail.message === 'string' && detail.message.length > 0) {
|
|
300
|
+
return detail.message;
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
106
304
|
/**
|
|
107
305
|
* Toggle an existing BotDoc from draft → published via the API.
|
|
108
306
|
*
|
|
@@ -113,6 +311,11 @@ export async function publish(source, options) {
|
|
|
113
311
|
* mistake is one CLI call away.
|
|
114
312
|
*/
|
|
115
313
|
async function publishRef(rawRef, options) {
|
|
314
|
+
// Preflight: same as the path-form publish. publish() already called
|
|
315
|
+
// requireAuth before dispatching here, but publishRef is also exported
|
|
316
|
+
// (and re-used by tests/other commands) — keep the guard local so the
|
|
317
|
+
// contract holds regardless of caller.
|
|
318
|
+
requireAuth(options);
|
|
116
319
|
let parsed;
|
|
117
320
|
try {
|
|
118
321
|
parsed = parseRef(rawRef);
|
|
@@ -165,13 +368,17 @@ function handlePublishToggleError(err, refLabel, options) {
|
|
|
165
368
|
console.error(`\n ✗ ${err.message}\n`);
|
|
166
369
|
}
|
|
167
370
|
else if (err.status === 403) {
|
|
371
|
+
// Ownership check is command-specific — keep the explicit "you don't
|
|
372
|
+
// own this" wording instead of the generic team-membership-lost line.
|
|
168
373
|
console.error(`\n ✗ You don't own ${refLabel}.\n`);
|
|
169
374
|
}
|
|
170
375
|
else if (err.status === 404) {
|
|
171
376
|
console.error(`\n ✗ BotDoc not found: ${refLabel}\n`);
|
|
172
377
|
}
|
|
173
378
|
else {
|
|
174
|
-
|
|
379
|
+
// 429/5xx — use the shared friendly helper for consistent wording
|
|
380
|
+
// across commands.
|
|
381
|
+
console.error(`\n ✗ ${friendlyApiErrorDetail(err)}\n`);
|
|
175
382
|
}
|
|
176
383
|
process.exit(1);
|
|
177
384
|
}
|
|
@@ -220,9 +427,38 @@ async function maybeAutoCompile(source, options) {
|
|
|
220
427
|
return !fs.existsSync(dest) || fs.statSync(dest).mtimeMs < sourceMtime;
|
|
221
428
|
});
|
|
222
429
|
if (stale) {
|
|
223
|
-
|
|
430
|
+
if (!options.json)
|
|
431
|
+
console.log(' Auto-compiling stale ecosystem files…');
|
|
224
432
|
await compile(source, {});
|
|
225
433
|
}
|
|
434
|
+
else if (!options.json) {
|
|
435
|
+
// Previously silent — users couldn't tell whether auto-compile ran or
|
|
436
|
+
// was skipped. Print a clear no-op line so the author knows their
|
|
437
|
+
// ecosystem files are already in sync with the source.
|
|
438
|
+
console.log(' Ecosystems up-to-date — skipping compile.');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** Best-effort slug derivation for the publish confirmation prompt. Mirrors
|
|
442
|
+
* the slug logic in `printDryRun` so the y/N message and the dry-run preview
|
|
443
|
+
* agree. Falls back to the source path's basename for non-directory sources.
|
|
444
|
+
*
|
|
445
|
+
* Edge case: `botdocs publish .` (or `..`, or an empty string) used to derive
|
|
446
|
+
* an empty slug, so the confirm prompt and dry-run preview both said
|
|
447
|
+
* `@user/` — confusing for first-time authors. When the literal basename is
|
|
448
|
+
* `.`/`..`/empty, fall back to the resolved-path basename so the cwd name
|
|
449
|
+
* shows up instead. Cross-platform note: `path.basename('.')` returns `'.'`
|
|
450
|
+
* on POSIX and Windows alike (verified empirically); the resolved fallback
|
|
451
|
+
* is what gives us the real directory name. */
|
|
452
|
+
function derivePublishSlug(source) {
|
|
453
|
+
const trimmed = source.trim();
|
|
454
|
+
const rawBase = path.basename(trimmed, path.extname(trimmed));
|
|
455
|
+
const base = rawBase === '' || rawBase === '.' || rawBase === '..'
|
|
456
|
+
? path.basename(path.resolve(trimmed))
|
|
457
|
+
: rawBase;
|
|
458
|
+
return base
|
|
459
|
+
.toLowerCase()
|
|
460
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
461
|
+
.replace(/^-+|-+$/g, '');
|
|
226
462
|
}
|
|
227
463
|
function collectFromFile(filePath) {
|
|
228
464
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
@@ -235,20 +471,38 @@ function collectFromDirectory(dirPath) {
|
|
|
235
471
|
files.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
236
472
|
return files;
|
|
237
473
|
}
|
|
238
|
-
|
|
239
|
-
|
|
474
|
+
/**
|
|
475
|
+
* Recursively collect publishable markdown files under `currentDir`.
|
|
476
|
+
*
|
|
477
|
+
* Symlinks are skipped (with a warning) to prevent local file exfiltration:
|
|
478
|
+
* without this check a directory containing a symlink to e.g. `~/.ssh/id_rsa`
|
|
479
|
+
* would silently upload that file's contents as a public skill. We use
|
|
480
|
+
* `withFileTypes: true` so the `Dirent` reports the entry's own type (NOT
|
|
481
|
+
* the target's), then gate on `isFile()` / `isDirectory()` explicitly so
|
|
482
|
+
* anything else (symlinks, sockets, FIFOs, character/block devices) is
|
|
483
|
+
* dropped. Mirrors `walkAll` in `src/lib/ingest-discover.ts`.
|
|
484
|
+
*
|
|
485
|
+
* Exported only for unit testing — production callers should go through
|
|
486
|
+
* `collectFromDirectory`.
|
|
487
|
+
*/
|
|
488
|
+
export function walkDirectory(rootDir, currentDir, out) {
|
|
489
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
240
490
|
for (const entry of entries) {
|
|
241
|
-
if (entry.startsWith('.'))
|
|
491
|
+
if (entry.name.startsWith('.'))
|
|
492
|
+
continue;
|
|
493
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
494
|
+
if (entry.isSymbolicLink()) {
|
|
495
|
+
const rel = path.relative(rootDir, fullPath).split(path.sep).join('/');
|
|
496
|
+
console.warn(`Skipping symlink: ${rel}`);
|
|
242
497
|
continue;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (stat.isDirectory()) {
|
|
498
|
+
}
|
|
499
|
+
if (entry.isDirectory()) {
|
|
246
500
|
walkDirectory(rootDir, fullPath, out);
|
|
247
501
|
continue;
|
|
248
502
|
}
|
|
249
|
-
if (!
|
|
503
|
+
if (!entry.isFile())
|
|
250
504
|
continue;
|
|
251
|
-
if (!entry.endsWith('.md') && !entry.endsWith('.markdown'))
|
|
505
|
+
if (!entry.name.endsWith('.md') && !entry.name.endsWith('.markdown'))
|
|
252
506
|
continue;
|
|
253
507
|
// POSIX-style relative path so the install-time prefix check
|
|
254
508
|
// (e.g. `claude/SKILL.md`) works on every platform.
|
|
@@ -328,3 +582,62 @@ function resolveLicense(input) {
|
|
|
328
582
|
};
|
|
329
583
|
return aliases[upper] ?? 'MIT';
|
|
330
584
|
}
|
|
585
|
+
/** Mirror of `apps/web/src/lib/skill-caps.ts` — kept inlined here so the CLI's
|
|
586
|
+
* dry-run preview can reflect the same per-file and per-skill caps the server
|
|
587
|
+
* will enforce, without dragging the web app into the CLI's dep graph. */
|
|
588
|
+
const PER_FILE_BYTE_CAP_PREVIEW = 64 * 1024;
|
|
589
|
+
const PER_SKILL_BYTE_CAP_PREVIEW = 512 * 1024;
|
|
590
|
+
/** Render N bytes as a short human string (`12 KB`, `512 KB`). KB only — the
|
|
591
|
+
* dry-run preview is bounded by PER_SKILL_BYTE_CAP_PREVIEW (512KB) so MB
|
|
592
|
+
* never comes up. */
|
|
593
|
+
function formatKb(bytes) {
|
|
594
|
+
const kb = bytes / 1024;
|
|
595
|
+
if (kb < 10)
|
|
596
|
+
return `${kb.toFixed(1)} KB`;
|
|
597
|
+
return `${Math.round(kb)} KB`;
|
|
598
|
+
}
|
|
599
|
+
/** Print a dry-run preview of what `publish` would upload, then exit. Suppressed
|
|
600
|
+
* under --json (emits a structured payload instead) so scripts can branch on
|
|
601
|
+
* the same shape they'd get from a real publish. */
|
|
602
|
+
function printDryRun(payload, options) {
|
|
603
|
+
const auth = loadAuth();
|
|
604
|
+
// requireAuth() ran first, so auth is non-null here. Default to a sentinel
|
|
605
|
+
// so the preview still renders cleanly if someone calls printDryRun without
|
|
606
|
+
// the preflight (e.g. a future refactor).
|
|
607
|
+
const username = auth?.username ?? 'me';
|
|
608
|
+
// Slug derivation mirrors the server: lowercase the title and collapse
|
|
609
|
+
// non-alphanumerics. The actual server-side slug is authoritative, but this
|
|
610
|
+
// gives users a close-enough preview ("am I publishing to the right
|
|
611
|
+
// place?") without an extra round-trip. Shared with the confirmation
|
|
612
|
+
// prompt's `derivePublishSlug` so both surfaces agree.
|
|
613
|
+
const slugPreview = derivePublishSlug(payload.source);
|
|
614
|
+
const fileSizes = payload.files.map((f) => ({
|
|
615
|
+
name: f.filename,
|
|
616
|
+
bytes: Buffer.byteLength(f.content, 'utf-8'),
|
|
617
|
+
}));
|
|
618
|
+
const totalBytes = fileSizes.reduce((sum, f) => sum + f.bytes, 0);
|
|
619
|
+
if (options.json) {
|
|
620
|
+
console.log(JSON.stringify({
|
|
621
|
+
dryRun: true,
|
|
622
|
+
title: payload.title,
|
|
623
|
+
description: payload.description,
|
|
624
|
+
slug: `@${username}/${slugPreview}`,
|
|
625
|
+
sourceEcosystem: payload.sourceEcosystem,
|
|
626
|
+
files: fileSizes.map((f) => ({ filename: f.name, bytes: f.bytes })),
|
|
627
|
+
totalBytes,
|
|
628
|
+
perFileCap: PER_FILE_BYTE_CAP_PREVIEW,
|
|
629
|
+
perSkillCap: PER_SKILL_BYTE_CAP_PREVIEW,
|
|
630
|
+
}));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
console.log('\n[dry-run] Would publish:');
|
|
634
|
+
console.log(` Title: ${payload.title}`);
|
|
635
|
+
console.log(` Description: ${payload.description}`);
|
|
636
|
+
console.log(` Slug: @${username}/${slugPreview}`);
|
|
637
|
+
console.log(` Files: ${payload.files.length}`);
|
|
638
|
+
for (const f of fileSizes) {
|
|
639
|
+
console.log(` - ${f.name} (${formatKb(f.bytes)})`);
|
|
640
|
+
}
|
|
641
|
+
console.log(` Total size: ${formatKb(totalBytes)} / cap ${formatKb(PER_SKILL_BYTE_CAP_PREVIEW)}`);
|
|
642
|
+
console.log('\nNothing was sent. Re-run without --dry-run to publish.\n');
|
|
643
|
+
}
|