@botdocs/cli 0.6.0 → 0.8.1
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 +80 -8
- package/dist/commands/delete.d.ts +23 -0
- package/dist/commands/delete.js +106 -0
- package/dist/commands/ingest.d.ts +58 -0
- package/dist/commands/ingest.js +344 -27
- package/dist/commands/install.js +11 -0
- package/dist/commands/publish.d.ts +29 -1
- package/dist/commands/publish.js +85 -1
- package/dist/commands/unpublish.d.ts +16 -0
- package/dist/commands/unpublish.js +53 -0
- package/dist/commands/views/ingest-discover-app.d.ts +6 -1
- package/dist/commands/views/ingest-discover-app.js +56 -14
- package/dist/index.js +18 -1
- package/dist/lib/auto-detect.js +19 -0
- package/dist/lib/ingest-discover.d.ts +42 -2
- package/dist/lib/ingest-discover.js +72 -6
- package/dist/lib/ref.d.ts +42 -0
- package/dist/lib/ref.js +60 -0
- package/package.json +1 -1
- package/templates/agents.md +2 -1
package/dist/commands/ingest.js
CHANGED
|
@@ -3,8 +3,181 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { render } from 'ink';
|
|
5
5
|
import { apiFetch } from '../lib/api.js';
|
|
6
|
-
import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, titleFromContent, } from '../lib/ingest-discover.js';
|
|
6
|
+
import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, titleFromContent, } from '../lib/ingest-discover.js';
|
|
7
7
|
import { IngestDiscoverApp } from './views/ingest-discover-app.js';
|
|
8
|
+
// ---------- Cap + skip rules (shared with discovery) ----------
|
|
9
|
+
//
|
|
10
|
+
// Caps are tight by design — Claude skills routinely fit well under these.
|
|
11
|
+
// Enforced both here (CLI) and server-side (defense in depth).
|
|
12
|
+
/** Per-file size cap. Any file larger than this is skipped with a warning. */
|
|
13
|
+
export const PER_FILE_BYTE_CAP = 64 * 1024; // 64 KB
|
|
14
|
+
/** Total bytes across all files in a single skill. */
|
|
15
|
+
export const PER_SKILL_BYTE_CAP = 512 * 1024; // 512 KB
|
|
16
|
+
/** Total file count in a single skill. */
|
|
17
|
+
export const PER_SKILL_FILE_CAP = 25;
|
|
18
|
+
/** Directories the sweep recurses INTO. Anything else is silently ignored.
|
|
19
|
+
* The set is duplicated (not imported) from `walkFiles` so it stays in sync
|
|
20
|
+
* with what the path-based walker already filters out. */
|
|
21
|
+
const SWEEP_SKIP_DIRS = new Set([
|
|
22
|
+
'node_modules',
|
|
23
|
+
'.git',
|
|
24
|
+
'dist',
|
|
25
|
+
'build',
|
|
26
|
+
'.next',
|
|
27
|
+
'.turbo',
|
|
28
|
+
'__pycache__',
|
|
29
|
+
'venv',
|
|
30
|
+
'.venv',
|
|
31
|
+
]);
|
|
32
|
+
/** Filenames + globs that are uninteresting noise. `.log` and `.lock` are
|
|
33
|
+
* matched via endsWith, not regex, to keep the check cheap. */
|
|
34
|
+
function isSkippedFileName(name) {
|
|
35
|
+
if (name === '.DS_Store')
|
|
36
|
+
return true;
|
|
37
|
+
if (name.endsWith('.log'))
|
|
38
|
+
return true;
|
|
39
|
+
if (name.endsWith('.lock'))
|
|
40
|
+
return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/** Binary detection: read the first 8 KB and look for a null byte. Matches
|
|
44
|
+
* the same heuristic git uses. Cheap and good enough — we'd rather refuse a
|
|
45
|
+
* borderline file than try to base64 it. */
|
|
46
|
+
function looksBinary(absPath) {
|
|
47
|
+
const buf = Buffer.alloc(8192);
|
|
48
|
+
let fd = null;
|
|
49
|
+
try {
|
|
50
|
+
fd = fs.openSync(absPath, 'r');
|
|
51
|
+
const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
|
|
52
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
53
|
+
if (buf[i] === 0)
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Unreadable — treat as binary so we skip it with a warning rather than
|
|
60
|
+
// crashing the whole ingest.
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (fd !== null) {
|
|
65
|
+
try {
|
|
66
|
+
fs.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Walk a skill root recursively and return every non-skipped file the caller
|
|
76
|
+
* should ingest as an adjacent file. Applies dir-skip, dotfile, binary, and
|
|
77
|
+
* cap rules. Stops collecting once total caps are exceeded — remaining files
|
|
78
|
+
* land in `warnings`. The root file (e.g. SKILL.md) is excluded so the caller
|
|
79
|
+
* can handle it separately.
|
|
80
|
+
*/
|
|
81
|
+
export function sweepSkillRoot(skillRoot, rootFileAbs) {
|
|
82
|
+
const files = [];
|
|
83
|
+
const warnings = [];
|
|
84
|
+
let totalBytes = 0;
|
|
85
|
+
function walk(dir) {
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const abs = path.join(dir, entry.name);
|
|
95
|
+
if (entry.isDirectory()) {
|
|
96
|
+
if (SWEEP_SKIP_DIRS.has(entry.name))
|
|
97
|
+
continue;
|
|
98
|
+
if (entry.name.startsWith('.'))
|
|
99
|
+
continue;
|
|
100
|
+
walk(abs);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!entry.isFile())
|
|
104
|
+
continue;
|
|
105
|
+
if (abs === rootFileAbs)
|
|
106
|
+
continue;
|
|
107
|
+
if (entry.name.startsWith('.'))
|
|
108
|
+
continue;
|
|
109
|
+
if (isSkippedFileName(entry.name))
|
|
110
|
+
continue;
|
|
111
|
+
const relPath = path.relative(skillRoot, abs).split(path.sep).join('/');
|
|
112
|
+
let stat;
|
|
113
|
+
try {
|
|
114
|
+
stat = fs.statSync(abs);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
warnings.push({ relPath, reason: 'binary' });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (stat.size > PER_FILE_BYTE_CAP) {
|
|
121
|
+
warnings.push({ relPath, reason: 'oversize' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (looksBinary(abs)) {
|
|
125
|
+
warnings.push({ relPath, reason: 'binary' });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// Total-byte cap is checked after the per-file gate so a single huge
|
|
129
|
+
// file doesn't blow past the skill cap before we've read it.
|
|
130
|
+
if (totalBytes + stat.size > PER_SKILL_BYTE_CAP) {
|
|
131
|
+
warnings.push({ relPath, reason: 'cap-total-size' });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// -1 here because we count the root file in the caller's accounting,
|
|
135
|
+
// but exclude it from this sweep. Cap is "total files per skill".
|
|
136
|
+
if (files.length + 1 >= PER_SKILL_FILE_CAP) {
|
|
137
|
+
warnings.push({ relPath, reason: 'cap-file-count' });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
let content;
|
|
141
|
+
try {
|
|
142
|
+
content = fs.readFileSync(abs, 'utf-8');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
warnings.push({ relPath, reason: 'binary' });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
files.push({
|
|
149
|
+
absPath: abs,
|
|
150
|
+
relPath,
|
|
151
|
+
content,
|
|
152
|
+
mode: stat.mode & 0o777,
|
|
153
|
+
sizeBytes: stat.size,
|
|
154
|
+
});
|
|
155
|
+
totalBytes += stat.size;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
walk(skillRoot);
|
|
159
|
+
return { files, warnings };
|
|
160
|
+
}
|
|
161
|
+
/** Format a sweep warning summary line for a single skill. Empty string when
|
|
162
|
+
* there are no warnings — caller can use that to suppress the line. */
|
|
163
|
+
export function formatSweepWarnings(slug, warnings) {
|
|
164
|
+
if (warnings.length === 0)
|
|
165
|
+
return '';
|
|
166
|
+
const binary = warnings.filter((w) => w.reason === 'binary').map((w) => w.relPath);
|
|
167
|
+
const oversize = warnings.filter((w) => w.reason === 'oversize').map((w) => w.relPath);
|
|
168
|
+
const totalCap = warnings.filter((w) => w.reason === 'cap-total-size').map((w) => w.relPath);
|
|
169
|
+
const fileCap = warnings.filter((w) => w.reason === 'cap-file-count').map((w) => w.relPath);
|
|
170
|
+
const parts = [];
|
|
171
|
+
if (binary.length)
|
|
172
|
+
parts.push(`${binary.length} binary (${binary.slice(0, 3).join(', ')}${binary.length > 3 ? '…' : ''})`);
|
|
173
|
+
if (oversize.length)
|
|
174
|
+
parts.push(`${oversize.length} over 64KB (${oversize.slice(0, 3).join(', ')}${oversize.length > 3 ? '…' : ''})`);
|
|
175
|
+
if (totalCap.length)
|
|
176
|
+
parts.push(`${totalCap.length} past 512KB total cap`);
|
|
177
|
+
if (fileCap.length)
|
|
178
|
+
parts.push(`${fileCap.length} past 25-file cap`);
|
|
179
|
+
return ` ⚠ ${slug}: skipped ${warnings.length} file(s) — ${parts.join('; ')}`;
|
|
180
|
+
}
|
|
8
181
|
/**
|
|
9
182
|
* Single source of truth for ecosystem detection. New ecosystems should be
|
|
10
183
|
* added here — both auto-detect mode and future `--from-tool` mode consult
|
|
@@ -29,6 +202,11 @@ export const DETECTORS = {
|
|
|
29
202
|
// Discovery walks ~/.claude/skills recursively — files live one or two
|
|
30
203
|
// segments deep (`<slug>/SKILL.md` or `<scope>/<slug>/SKILL.md`).
|
|
31
204
|
scanPaths: (homeDir) => [path.join(homeDir, '.claude', 'skills')],
|
|
205
|
+
// Marquee case for adjacent-file sweep: real Claude skills bundle
|
|
206
|
+
// scripts/, templates/, and reference markdown next to SKILL.md.
|
|
207
|
+
includeAdjacent: true,
|
|
208
|
+
skillRoot: (abs) => path.dirname(abs),
|
|
209
|
+
canonicalAdjacentFilename: (slug, relPath) => `claude/${slug}/${relPath}`,
|
|
32
210
|
},
|
|
33
211
|
'claude-code': {
|
|
34
212
|
pathPrefix: 'claude-code/commands/',
|
|
@@ -44,6 +222,44 @@ export const DETECTORS = {
|
|
|
44
222
|
paths.push(path.join(projectRoot, '.claude', 'commands'));
|
|
45
223
|
return paths;
|
|
46
224
|
},
|
|
225
|
+
// Commands are FLAT today — every .md in ~/.claude/commands/ is its own
|
|
226
|
+
// skill, with no enclosing directory. That means there's no meaningful
|
|
227
|
+
// "skill root" to sweep: the dirname of a command is shared with all
|
|
228
|
+
// other commands, and walking it would produce false-positive adjacent
|
|
229
|
+
// files. If Anthropic ever adds a directory layout for commands we'll
|
|
230
|
+
// flip this back to `true` and add the appropriate skillRoot logic.
|
|
231
|
+
includeAdjacent: false,
|
|
232
|
+
},
|
|
233
|
+
'claude-code-agents': {
|
|
234
|
+
// Anthropic's convention today is single-file agents at
|
|
235
|
+
// `.claude/agents/<name>.md`. For BotDocs multi-file agents we expect a
|
|
236
|
+
// directory: `.claude/agents/<name>/AGENT.md` + adjacent files. The
|
|
237
|
+
// detector matches `**/AGENT.md` under `claude-code/agents/`; the slug
|
|
238
|
+
// is the parent dir of AGENT.md.
|
|
239
|
+
pathPrefix: 'claude-code/agents/',
|
|
240
|
+
extensions: ['/AGENT.md'],
|
|
241
|
+
nested: true,
|
|
242
|
+
slugFor: (abs, root) => {
|
|
243
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
244
|
+
if (!rel.endsWith('/AGENT.md'))
|
|
245
|
+
return null;
|
|
246
|
+
const parts = rel.split('/');
|
|
247
|
+
if (parts.length < 2)
|
|
248
|
+
return null;
|
|
249
|
+
return parts[parts.length - 2] ?? null;
|
|
250
|
+
},
|
|
251
|
+
canonicalFilename: (slug) => `claude-code/agents/${slug}/AGENT.md`,
|
|
252
|
+
// Mirror claude-code commands: global ~/.claude/agents always, project
|
|
253
|
+
// .claude/agents only inside a git repo.
|
|
254
|
+
scanPaths: (homeDir, projectRoot, isGitRepo) => {
|
|
255
|
+
const paths = [path.join(homeDir, '.claude', 'agents')];
|
|
256
|
+
if (isGitRepo)
|
|
257
|
+
paths.push(path.join(projectRoot, '.claude', 'agents'));
|
|
258
|
+
return paths;
|
|
259
|
+
},
|
|
260
|
+
includeAdjacent: true,
|
|
261
|
+
skillRoot: (abs) => path.dirname(abs),
|
|
262
|
+
canonicalAdjacentFilename: (slug, relPath) => `claude-code/agents/${slug}/${relPath}`,
|
|
47
263
|
},
|
|
48
264
|
cursor: {
|
|
49
265
|
pathPrefix: 'cursor/rules/',
|
|
@@ -113,13 +329,12 @@ export const DETECTORS = {
|
|
|
113
329
|
},
|
|
114
330
|
};
|
|
115
331
|
export const SUPPORTED_TOOLS = Object.keys(DETECTORS);
|
|
116
|
-
const IGNORED_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.turbo']);
|
|
117
332
|
function walkFiles(root) {
|
|
118
333
|
const out = [];
|
|
119
334
|
function walk(dir) {
|
|
120
335
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
121
336
|
if (entry.isDirectory()) {
|
|
122
|
-
if (
|
|
337
|
+
if (SWEEP_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
|
|
123
338
|
continue;
|
|
124
339
|
walk(path.join(dir, entry.name));
|
|
125
340
|
}
|
|
@@ -131,6 +346,16 @@ function walkFiles(root) {
|
|
|
131
346
|
walk(root);
|
|
132
347
|
return out;
|
|
133
348
|
}
|
|
349
|
+
/** Stat helper that masks mode to the low 9 bits (rwx for owner/group/other).
|
|
350
|
+
* Falls back to 0o644 on error so we never block ingest on a stat failure. */
|
|
351
|
+
function safeMode(absPath) {
|
|
352
|
+
try {
|
|
353
|
+
return fs.statSync(absPath).mode & 0o777;
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return 0o644;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
134
359
|
/** Does the filename end with any of the detector's declared extensions? */
|
|
135
360
|
function matchesExtension(absPath, detector) {
|
|
136
361
|
return detector.extensions.some((ext) => absPath.endsWith(ext));
|
|
@@ -154,6 +379,7 @@ function detectAuto(absPath, root, content) {
|
|
|
154
379
|
content,
|
|
155
380
|
ecosystem,
|
|
156
381
|
slug,
|
|
382
|
+
mode: safeMode(absPath),
|
|
157
383
|
};
|
|
158
384
|
}
|
|
159
385
|
return null;
|
|
@@ -179,8 +405,29 @@ function detectFromTool(absPath, root, content, ecosystem) {
|
|
|
179
405
|
content,
|
|
180
406
|
ecosystem,
|
|
181
407
|
slug,
|
|
408
|
+
mode: safeMode(absPath),
|
|
182
409
|
};
|
|
183
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* Sweep adjacent files when a detector opts in. Returns a list of
|
|
413
|
+
* `DetectedFile` for each non-skipped sibling file, plus warnings the caller
|
|
414
|
+
* surfaces inline. The root file itself is excluded — caller already has it.
|
|
415
|
+
*/
|
|
416
|
+
function adjacentFilesFor(detector, ecosystem, slug, rootFileAbs) {
|
|
417
|
+
if (!detector.includeAdjacent || !detector.skillRoot || !detector.canonicalAdjacentFilename) {
|
|
418
|
+
return { files: [], warnings: [] };
|
|
419
|
+
}
|
|
420
|
+
const root = detector.skillRoot(rootFileAbs);
|
|
421
|
+
const result = sweepSkillRoot(root, rootFileAbs);
|
|
422
|
+
const files = result.files.map((f) => ({
|
|
423
|
+
filename: detector.canonicalAdjacentFilename(slug, f.relPath),
|
|
424
|
+
content: f.content,
|
|
425
|
+
ecosystem,
|
|
426
|
+
slug,
|
|
427
|
+
mode: f.mode,
|
|
428
|
+
}));
|
|
429
|
+
return { files, warnings: result.warnings };
|
|
430
|
+
}
|
|
184
431
|
/** Group a list of discovered files into the `DetectedSkill[]` shape the
|
|
185
432
|
* `/api/cli/ingest` endpoint expects. Multiple discoveries with the same slug
|
|
186
433
|
* collapse into one logical skill with multiple files — same convention as
|
|
@@ -200,6 +447,7 @@ function groupDiscoveredIntoSkills(discovered) {
|
|
|
200
447
|
grouped.get(f.slug).files.push({
|
|
201
448
|
filename: f.canonicalFilename,
|
|
202
449
|
content: f.content,
|
|
450
|
+
mode: f.mode,
|
|
203
451
|
});
|
|
204
452
|
}
|
|
205
453
|
return [...grouped.values()];
|
|
@@ -219,24 +467,58 @@ async function uploadSkills(skills, options) {
|
|
|
219
467
|
}
|
|
220
468
|
console.log(`\n ✓ Drafts created. Review at:\n https://botdocs.ai${result.reviewUrl}\n`);
|
|
221
469
|
}
|
|
470
|
+
/** Returns true if this file is the "root" for its skill — the file the user
|
|
471
|
+
* sees as a checkbox row in the TUI / a bullet in plain-text output. Adjacent
|
|
472
|
+
* files (scripts/, templates/, etc.) ride along with the root's selection. */
|
|
473
|
+
function isRoot(file) {
|
|
474
|
+
// `isRoot` is set authoritatively at discovery time (rootFile = true,
|
|
475
|
+
// swept adjacent files = false), so there's no filename guessing here.
|
|
476
|
+
return file.isRoot;
|
|
477
|
+
}
|
|
478
|
+
/** Filter a discovery file list down to the set the `--auto` / stub filter
|
|
479
|
+
* would actually upload: only ROOT files smaller than the stub threshold are
|
|
480
|
+
* dropped. Adjacent files ride along when their root is eligible. */
|
|
481
|
+
function applyStubFilter(files) {
|
|
482
|
+
const eligibleKeys = new Set();
|
|
483
|
+
for (const f of files) {
|
|
484
|
+
if (!isRoot(f))
|
|
485
|
+
continue;
|
|
486
|
+
if (f.sizeBytes >= STUB_BYTE_THRESHOLD)
|
|
487
|
+
eligibleKeys.add(summaryKey(f));
|
|
488
|
+
}
|
|
489
|
+
return files.filter((f) => eligibleKeys.has(summaryKey(f)));
|
|
490
|
+
}
|
|
222
491
|
/** Render a sectioned plain-text listing of discoveries. Reused for both
|
|
223
|
-
* `--dry-run` output and the non-TTY fallback.
|
|
224
|
-
|
|
492
|
+
* `--dry-run` output and the non-TTY fallback. Adjacent files are not listed
|
|
493
|
+
* row-by-row — the per-skill aggregate `(SKILL.md + N)` is enough signal. */
|
|
494
|
+
function printDiscoveryPlainText(discovered, skillSummaries) {
|
|
495
|
+
const rootFiles = discovered.filter(isRoot);
|
|
225
496
|
const byEcosystem = new Map();
|
|
226
|
-
for (const d of
|
|
497
|
+
for (const d of rootFiles) {
|
|
227
498
|
const list = byEcosystem.get(d.ecosystem) ?? [];
|
|
228
499
|
list.push(d);
|
|
229
500
|
byEcosystem.set(d.ecosystem, list);
|
|
230
501
|
}
|
|
231
502
|
console.log('');
|
|
232
|
-
console.log(` Found ${
|
|
503
|
+
console.log(` Found ${rootFiles.length} skill(s) across ${byEcosystem.size} tool(s):`);
|
|
233
504
|
for (const [eco, list] of byEcosystem) {
|
|
234
505
|
console.log('');
|
|
235
506
|
console.log(` ${ecosystemLabel(eco)}:`);
|
|
236
507
|
for (const f of list) {
|
|
237
508
|
const label = f.scope ? `${f.scope}/${f.slug}` : f.slug;
|
|
238
509
|
const stub = f.sizeBytes < STUB_BYTE_THRESHOLD ? ' (stub — excluded by default)' : '';
|
|
239
|
-
|
|
510
|
+
const summary = skillSummaries?.get(summaryKey(f));
|
|
511
|
+
const details = summary && summary.totalFiles > 1
|
|
512
|
+
? `${formatBytes(summary.totalBytes)} · ${summary.totalFiles} files`
|
|
513
|
+
: `${formatBytes(f.sizeBytes)} · ${f.lineCount} lines`;
|
|
514
|
+
console.log(` • ${label} ${details}${stub}`);
|
|
515
|
+
// Render any sweep warnings as a sub-bullet under the skill — keeps
|
|
516
|
+
// the per-ecosystem section readable.
|
|
517
|
+
if (summary && summary.warnings.length > 0) {
|
|
518
|
+
const line = formatSweepWarnings(label, summary.warnings);
|
|
519
|
+
if (line)
|
|
520
|
+
console.log(` ${line.trimStart()}`);
|
|
521
|
+
}
|
|
240
522
|
}
|
|
241
523
|
}
|
|
242
524
|
console.log('');
|
|
@@ -254,16 +536,26 @@ async function ingestDiscover(options) {
|
|
|
254
536
|
// consume it. Includes per-file metadata; content is omitted to keep the
|
|
255
537
|
// payload small — callers can re-run with a path to actually upload.
|
|
256
538
|
if (options.json) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
539
|
+
// Only emit root files (one per skill). Adjacent files are aggregated
|
|
540
|
+
// into `totalFiles` / `totalBytes` so the JSON consumer sees the same
|
|
541
|
+
// shape the TUI shows.
|
|
542
|
+
const payload = discovery.files
|
|
543
|
+
.filter(isRoot)
|
|
544
|
+
.map((f) => {
|
|
545
|
+
const summary = discovery.skillSummaries.get(summaryKey(f));
|
|
546
|
+
return {
|
|
547
|
+
ecosystem: f.ecosystem,
|
|
548
|
+
slug: f.slug,
|
|
549
|
+
scope: f.scope,
|
|
550
|
+
title: f.title,
|
|
551
|
+
sourcePath: f.sourcePath,
|
|
552
|
+
sizeBytes: f.sizeBytes,
|
|
553
|
+
lineCount: f.lineCount,
|
|
554
|
+
canonicalFilename: f.canonicalFilename,
|
|
555
|
+
totalFiles: summary?.totalFiles ?? 1,
|
|
556
|
+
totalBytes: summary?.totalBytes ?? f.sizeBytes,
|
|
557
|
+
};
|
|
558
|
+
});
|
|
267
559
|
console.log(JSON.stringify({ discovered: payload, emptyEcosystems: discovery.emptyEcosystems }));
|
|
268
560
|
return;
|
|
269
561
|
}
|
|
@@ -271,22 +563,24 @@ async function ingestDiscover(options) {
|
|
|
271
563
|
// (stubs excluded). Helps the user verify before re-running without
|
|
272
564
|
// --dry-run.
|
|
273
565
|
if (options.dryRun && options.auto) {
|
|
274
|
-
const eligible = discovery.files
|
|
275
|
-
printDiscoveryPlainText(eligible);
|
|
566
|
+
const eligible = applyStubFilter(discovery.files);
|
|
567
|
+
printDiscoveryPlainText(eligible, discovery.skillSummaries);
|
|
276
568
|
console.log(' --dry-run --auto: would upload the above (stubs excluded). Not uploading.\n');
|
|
277
569
|
return;
|
|
278
570
|
}
|
|
279
571
|
// --dry-run alone: list ALL discoveries in plain text, never upload, never
|
|
280
572
|
// TUI. Stubs are marked inline so the user knows they'd be excluded.
|
|
281
573
|
if (options.dryRun) {
|
|
282
|
-
printDiscoveryPlainText(discovery.files);
|
|
574
|
+
printDiscoveryPlainText(discovery.files, discovery.skillSummaries);
|
|
283
575
|
console.log(' --dry-run: not uploading.\n');
|
|
284
576
|
return;
|
|
285
577
|
}
|
|
286
578
|
// --auto: skip the TUI and upload everything matching the defaults (stubs
|
|
287
|
-
// < STUB_BYTE_THRESHOLD bytes excluded — same rule the TUI uses).
|
|
579
|
+
// < STUB_BYTE_THRESHOLD bytes excluded — same rule the TUI uses). The
|
|
580
|
+
// stub filter looks at the root file only; adjacent files always ride
|
|
581
|
+
// along when their root is selected.
|
|
288
582
|
if (options.auto) {
|
|
289
|
-
const eligible = discovery.files
|
|
583
|
+
const eligible = applyStubFilter(discovery.files);
|
|
290
584
|
if (eligible.length === 0) {
|
|
291
585
|
console.log('\n No skills found (all discovered files are < 100-byte stubs). Inspect with `--dry-run`.\n');
|
|
292
586
|
return;
|
|
@@ -295,6 +589,15 @@ async function ingestDiscover(options) {
|
|
|
295
589
|
console.log(`\n ✓ Uploading ${skills.length} skill(s) found by discovery (--auto):`);
|
|
296
590
|
for (const s of skills)
|
|
297
591
|
console.log(` • ${s.slug} (${s.files.length} file(s))`);
|
|
592
|
+
// Surface any sweep warnings inline so the user knows what got dropped.
|
|
593
|
+
for (const [, summary] of discovery.skillSummaries) {
|
|
594
|
+
if (summary.warnings.length === 0)
|
|
595
|
+
continue;
|
|
596
|
+
const label = summary.scope ? `${summary.scope}/${summary.slug}` : summary.slug;
|
|
597
|
+
const line = formatSweepWarnings(label, summary.warnings);
|
|
598
|
+
if (line)
|
|
599
|
+
console.log(line);
|
|
600
|
+
}
|
|
298
601
|
await uploadSkills(skills, options);
|
|
299
602
|
return;
|
|
300
603
|
}
|
|
@@ -304,7 +607,7 @@ async function ingestDiscover(options) {
|
|
|
304
607
|
// do NOT upload silently here — interactive consent matters. The user
|
|
305
608
|
// can re-run with `--json` for machine-readable output or with `--auto`
|
|
306
609
|
// for one-shot mode.
|
|
307
|
-
printDiscoveryPlainText(discovery.files);
|
|
610
|
+
printDiscoveryPlainText(discovery.files, discovery.skillSummaries);
|
|
308
611
|
console.log(' Run with --json for machine-readable output, or in a real terminal for interactive selection.\n');
|
|
309
612
|
return;
|
|
310
613
|
}
|
|
@@ -317,6 +620,7 @@ async function ingestDiscover(options) {
|
|
|
317
620
|
};
|
|
318
621
|
const instance = render(React.createElement(IngestDiscoverApp, {
|
|
319
622
|
files: discovery.files,
|
|
623
|
+
skillSummaries: discovery.skillSummaries,
|
|
320
624
|
emptyEcosystems: discovery.emptyEcosystems,
|
|
321
625
|
onDone: (r) => {
|
|
322
626
|
resultHolder.value = r;
|
|
@@ -357,13 +661,24 @@ export async function ingest(rootPath, options) {
|
|
|
357
661
|
return;
|
|
358
662
|
}
|
|
359
663
|
const detected = [];
|
|
664
|
+
const sweepWarnings = [];
|
|
360
665
|
for (const filePath of walkFiles(root)) {
|
|
361
666
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
362
667
|
const d = options.fromTool
|
|
363
668
|
? detectFromTool(filePath, root, content, options.fromTool)
|
|
364
669
|
: detectAuto(filePath, root, content);
|
|
365
|
-
if (d)
|
|
366
|
-
|
|
670
|
+
if (!d)
|
|
671
|
+
continue;
|
|
672
|
+
detected.push(d);
|
|
673
|
+
// Sweep adjacent files when the detector opts in (claude SKILL.md, the
|
|
674
|
+
// new claude-code-agents AGENT.md). The root file we already pushed —
|
|
675
|
+
// this catches scripts/, templates/, reference docs, etc.
|
|
676
|
+
const detector = DETECTORS[d.ecosystem];
|
|
677
|
+
const { files: adjacent, warnings } = adjacentFilesFor(detector, d.ecosystem, d.slug, filePath);
|
|
678
|
+
detected.push(...adjacent);
|
|
679
|
+
const warningLine = formatSweepWarnings(d.slug, warnings);
|
|
680
|
+
if (warningLine)
|
|
681
|
+
sweepWarnings.push(warningLine);
|
|
367
682
|
}
|
|
368
683
|
if (detected.length === 0) {
|
|
369
684
|
if (options.fromTool) {
|
|
@@ -388,13 +703,15 @@ export async function ingest(rootPath, options) {
|
|
|
388
703
|
files: [],
|
|
389
704
|
});
|
|
390
705
|
}
|
|
391
|
-
grouped.get(f.slug).files.push({ filename: f.filename, content: f.content });
|
|
706
|
+
grouped.get(f.slug).files.push({ filename: f.filename, content: f.content, mode: f.mode });
|
|
392
707
|
}
|
|
393
708
|
const skills = [...grouped.values()];
|
|
394
709
|
console.log(`\n ✓ Found ${skills.length} skill(s):`);
|
|
395
710
|
for (const s of skills) {
|
|
396
711
|
console.log(` • ${s.slug} (${s.files.length} file(s))`);
|
|
397
712
|
}
|
|
713
|
+
for (const line of sweepWarnings)
|
|
714
|
+
console.log(line);
|
|
398
715
|
if (options.dryRun) {
|
|
399
716
|
console.log('\n --dry-run: not uploading.\n');
|
|
400
717
|
return;
|
package/dist/commands/install.js
CHANGED
|
@@ -55,6 +55,17 @@ async function downloadAndWrite(file, dest, options, projectDir) {
|
|
|
55
55
|
}
|
|
56
56
|
ensureDir(dest);
|
|
57
57
|
fs.writeFileSync(dest, content, 'utf-8');
|
|
58
|
+
// Restore the file mode the author shipped. Defaults to 0o644 when the
|
|
59
|
+
// manifest omits `mode` (older server, pre-mode-column row, etc.). We
|
|
60
|
+
// explicitly chmod even when the value is 0o644 so a previously-executable
|
|
61
|
+
// file at the dest gets re-normalized to non-executable on update.
|
|
62
|
+
try {
|
|
63
|
+
fs.chmodSync(dest, file.mode ?? 0o644);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// chmod can fail on Windows / network mounts. Don't fail the install
|
|
67
|
+
// over a permission cosmetic — the file still got written.
|
|
68
|
+
}
|
|
58
69
|
return { src: file.filename, dest, fingerprint: fingerprintFile(dest) };
|
|
59
70
|
}
|
|
60
71
|
async function installSkill(ref, manifest, options, scope) {
|
|
@@ -6,6 +6,34 @@ interface PublishOptions {
|
|
|
6
6
|
license?: string;
|
|
7
7
|
json?: boolean;
|
|
8
8
|
noCompile?: boolean;
|
|
9
|
+
/** Skip confirmation prompts. Reserved for future use — publish on a
|
|
10
|
+
* ref currently doesn't prompt, but the flag is accepted so users
|
|
11
|
+
* developing scripts get a consistent surface across publish/unpublish. */
|
|
12
|
+
yes?: boolean;
|
|
9
13
|
}
|
|
10
14
|
export declare function publish(source: string, options: PublishOptions): Promise<void>;
|
|
11
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Toggle an existing BotDoc from draft → published via the API.
|
|
17
|
+
*
|
|
18
|
+
* Called from `publish()` when the source argument looks like a ref
|
|
19
|
+
* (`@user/slug` or `user/slug`) instead of a local path. Strips the
|
|
20
|
+
* `draft` and `ingest:<id>` tags server-side. No prompt — moving from
|
|
21
|
+
* draft to published is the natural progression, and unpublishing a
|
|
22
|
+
* mistake is one CLI call away.
|
|
23
|
+
*/
|
|
24
|
+
declare function publishRef(rawRef: string, options: PublishOptions): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Map an `ApiError` from a publish/unpublish call to a friendly,
|
|
27
|
+
* actionable message. Shared by both verbs so the wording stays in sync.
|
|
28
|
+
*
|
|
29
|
+
* 401: most likely the user isn't logged in (or their saved token is
|
|
30
|
+
* stale). The api lib already produces a helpful "run `botdocs login`"
|
|
31
|
+
* hint — pass it through.
|
|
32
|
+
*
|
|
33
|
+
* 403: authenticated as someone who doesn't own this BotDoc.
|
|
34
|
+
*
|
|
35
|
+
* 404: BotDoc doesn't exist (or is owned by a different user — the API
|
|
36
|
+
* returns 404 rather than 403 to avoid leaking existence to non-authors).
|
|
37
|
+
*/
|
|
38
|
+
declare function handlePublishToggleError(err: unknown, refLabel: string, options: PublishOptions): void;
|
|
39
|
+
export { publishRef, handlePublishToggleError };
|
package/dist/commands/publish.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import AdmZip from 'adm-zip';
|
|
4
|
-
import { apiFetch } from '../lib/api.js';
|
|
4
|
+
import { ApiError, apiFetch } from '../lib/api.js';
|
|
5
5
|
import { parseManifest } from '../lib/manifest.js';
|
|
6
6
|
import { compile } from './compile.js';
|
|
7
7
|
import { ecosystemDestination } from '../lib/canonical.js';
|
|
8
|
+
import { isRefForm, parseRef } from '../lib/ref.js';
|
|
8
9
|
const VALID_CATEGORIES = [
|
|
9
10
|
'KNOWLEDGE_MANAGEMENT',
|
|
10
11
|
'DEV_WORKFLOW',
|
|
@@ -15,6 +16,16 @@ const VALID_CATEGORIES = [
|
|
|
15
16
|
];
|
|
16
17
|
const VALID_LICENSES = ['MIT', 'CC_BY_4_0', 'CC_BY_SA_4_0', 'CC0', 'ALL_RIGHTS_RESERVED'];
|
|
17
18
|
export async function publish(source, options) {
|
|
19
|
+
// Ref-form (e.g. "@user/slug" or "user/slug") → toggle the published
|
|
20
|
+
// flag via the API. Path-form continues to the existing upload flow
|
|
21
|
+
// below. Overloading by argument shape (rather than introducing
|
|
22
|
+
// `botdocs publish-ref` or a `--ref` flag) keeps the verb intuitive:
|
|
23
|
+
// a user typing `botdocs publish @me/foo` doesn't have to know it's a
|
|
24
|
+
// different code path under the hood.
|
|
25
|
+
if (isRefForm(source)) {
|
|
26
|
+
await publishRef(source, options);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
18
29
|
const resolved = path.resolve(source);
|
|
19
30
|
if (!fs.existsSync(resolved)) {
|
|
20
31
|
console.error(`Source not found: ${source}`);
|
|
@@ -92,6 +103,79 @@ export async function publish(source, options) {
|
|
|
92
103
|
console.log(`\nPublished: ${result.url}`);
|
|
93
104
|
}
|
|
94
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Toggle an existing BotDoc from draft → published via the API.
|
|
108
|
+
*
|
|
109
|
+
* Called from `publish()` when the source argument looks like a ref
|
|
110
|
+
* (`@user/slug` or `user/slug`) instead of a local path. Strips the
|
|
111
|
+
* `draft` and `ingest:<id>` tags server-side. No prompt — moving from
|
|
112
|
+
* draft to published is the natural progression, and unpublishing a
|
|
113
|
+
* mistake is one CLI call away.
|
|
114
|
+
*/
|
|
115
|
+
async function publishRef(rawRef, options) {
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parseRef(rawRef);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
const { username, slug } = parsed;
|
|
125
|
+
const refLabel = `@${username}/${slug}`;
|
|
126
|
+
try {
|
|
127
|
+
await apiFetch(`/api/botdocs/${username}/${slug}/publish`, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
auth: true,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
handlePublishToggleError(err, refLabel, options);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (options.json) {
|
|
137
|
+
console.log(JSON.stringify({ ok: true, ref: refLabel, status: 'published' }));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.log(`✓ Published ${refLabel} — visible at https://botdocs.ai/${refLabel}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Map an `ApiError` from a publish/unpublish call to a friendly,
|
|
145
|
+
* actionable message. Shared by both verbs so the wording stays in sync.
|
|
146
|
+
*
|
|
147
|
+
* 401: most likely the user isn't logged in (or their saved token is
|
|
148
|
+
* stale). The api lib already produces a helpful "run `botdocs login`"
|
|
149
|
+
* hint — pass it through.
|
|
150
|
+
*
|
|
151
|
+
* 403: authenticated as someone who doesn't own this BotDoc.
|
|
152
|
+
*
|
|
153
|
+
* 404: BotDoc doesn't exist (or is owned by a different user — the API
|
|
154
|
+
* returns 404 rather than 403 to avoid leaking existence to non-authors).
|
|
155
|
+
*/
|
|
156
|
+
function handlePublishToggleError(err, refLabel, options) {
|
|
157
|
+
if (!(err instanceof ApiError)) {
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
if (options.json) {
|
|
161
|
+
console.log(JSON.stringify({ ok: false, ref: refLabel, status: err.status, error: err.message }));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
if (err.status === 401) {
|
|
165
|
+
console.error(`\n ✗ ${err.message}\n`);
|
|
166
|
+
}
|
|
167
|
+
else if (err.status === 403) {
|
|
168
|
+
console.error(`\n ✗ You don't own ${refLabel}.\n`);
|
|
169
|
+
}
|
|
170
|
+
else if (err.status === 404) {
|
|
171
|
+
console.error(`\n ✗ BotDoc not found: ${refLabel}\n`);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
console.error(`\n ✗ ${err.message}\n`);
|
|
175
|
+
}
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
export { publishRef, handlePublishToggleError };
|
|
95
179
|
function readManifest(source) {
|
|
96
180
|
const manifestPath = path.join(source, 'botdocs.json');
|
|
97
181
|
if (!fs.existsSync(manifestPath))
|