@botdocs/cli 0.6.0 → 0.8.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 +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 +352 -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 +70 -14
- package/dist/index.js +18 -1
- package/dist/lib/auto-detect.js +19 -0
- package/dist/lib/ingest-discover.d.ts +35 -2
- package/dist/lib/ingest-discover.js +70 -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,66 @@ 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
|
+
const cf = file.canonicalFilename;
|
|
475
|
+
// Nested ecosystems use /SKILL.md or /AGENT.md as the root marker.
|
|
476
|
+
if (cf.endsWith('/SKILL.md') || cf.endsWith('/AGENT.md'))
|
|
477
|
+
return true;
|
|
478
|
+
// Flat ecosystems have no adjacent sweep today, so every file IS a root.
|
|
479
|
+
// Detect by checking that the canonical filename matches the simple
|
|
480
|
+
// `<prefix>/<slug>.<ext>` shape with no extra path segment.
|
|
481
|
+
// (A claude-code-agents single-file would also match here — but we only
|
|
482
|
+
// produce AGENT.md as the root for nested layouts, so flat .md under
|
|
483
|
+
// claude-code/agents/ is treated as a root.)
|
|
484
|
+
return !cf.includes('/scripts/') && !cf.includes('/templates/') && !cf.includes('/reference/');
|
|
485
|
+
}
|
|
486
|
+
/** Filter a discovery file list down to the set the `--auto` / stub filter
|
|
487
|
+
* would actually upload: only ROOT files smaller than the stub threshold are
|
|
488
|
+
* dropped. Adjacent files ride along when their root is eligible. */
|
|
489
|
+
function applyStubFilter(files) {
|
|
490
|
+
const eligibleKeys = new Set();
|
|
491
|
+
for (const f of files) {
|
|
492
|
+
if (!isRoot(f))
|
|
493
|
+
continue;
|
|
494
|
+
if (f.sizeBytes >= STUB_BYTE_THRESHOLD)
|
|
495
|
+
eligibleKeys.add(summaryKey(f));
|
|
496
|
+
}
|
|
497
|
+
return files.filter((f) => eligibleKeys.has(summaryKey(f)));
|
|
498
|
+
}
|
|
222
499
|
/** Render a sectioned plain-text listing of discoveries. Reused for both
|
|
223
|
-
* `--dry-run` output and the non-TTY fallback.
|
|
224
|
-
|
|
500
|
+
* `--dry-run` output and the non-TTY fallback. Adjacent files are not listed
|
|
501
|
+
* row-by-row — the per-skill aggregate `(SKILL.md + N)` is enough signal. */
|
|
502
|
+
function printDiscoveryPlainText(discovered, skillSummaries) {
|
|
503
|
+
const rootFiles = discovered.filter(isRoot);
|
|
225
504
|
const byEcosystem = new Map();
|
|
226
|
-
for (const d of
|
|
505
|
+
for (const d of rootFiles) {
|
|
227
506
|
const list = byEcosystem.get(d.ecosystem) ?? [];
|
|
228
507
|
list.push(d);
|
|
229
508
|
byEcosystem.set(d.ecosystem, list);
|
|
230
509
|
}
|
|
231
510
|
console.log('');
|
|
232
|
-
console.log(` Found ${
|
|
511
|
+
console.log(` Found ${rootFiles.length} skill(s) across ${byEcosystem.size} tool(s):`);
|
|
233
512
|
for (const [eco, list] of byEcosystem) {
|
|
234
513
|
console.log('');
|
|
235
514
|
console.log(` ${ecosystemLabel(eco)}:`);
|
|
236
515
|
for (const f of list) {
|
|
237
516
|
const label = f.scope ? `${f.scope}/${f.slug}` : f.slug;
|
|
238
517
|
const stub = f.sizeBytes < STUB_BYTE_THRESHOLD ? ' (stub — excluded by default)' : '';
|
|
239
|
-
|
|
518
|
+
const summary = skillSummaries?.get(summaryKey(f));
|
|
519
|
+
const details = summary && summary.totalFiles > 1
|
|
520
|
+
? `${formatBytes(summary.totalBytes)} · ${summary.totalFiles} files`
|
|
521
|
+
: `${formatBytes(f.sizeBytes)} · ${f.lineCount} lines`;
|
|
522
|
+
console.log(` • ${label} ${details}${stub}`);
|
|
523
|
+
// Render any sweep warnings as a sub-bullet under the skill — keeps
|
|
524
|
+
// the per-ecosystem section readable.
|
|
525
|
+
if (summary && summary.warnings.length > 0) {
|
|
526
|
+
const line = formatSweepWarnings(label, summary.warnings);
|
|
527
|
+
if (line)
|
|
528
|
+
console.log(` ${line.trimStart()}`);
|
|
529
|
+
}
|
|
240
530
|
}
|
|
241
531
|
}
|
|
242
532
|
console.log('');
|
|
@@ -254,16 +544,26 @@ async function ingestDiscover(options) {
|
|
|
254
544
|
// consume it. Includes per-file metadata; content is omitted to keep the
|
|
255
545
|
// payload small — callers can re-run with a path to actually upload.
|
|
256
546
|
if (options.json) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
547
|
+
// Only emit root files (one per skill). Adjacent files are aggregated
|
|
548
|
+
// into `totalFiles` / `totalBytes` so the JSON consumer sees the same
|
|
549
|
+
// shape the TUI shows.
|
|
550
|
+
const payload = discovery.files
|
|
551
|
+
.filter(isRoot)
|
|
552
|
+
.map((f) => {
|
|
553
|
+
const summary = discovery.skillSummaries.get(summaryKey(f));
|
|
554
|
+
return {
|
|
555
|
+
ecosystem: f.ecosystem,
|
|
556
|
+
slug: f.slug,
|
|
557
|
+
scope: f.scope,
|
|
558
|
+
title: f.title,
|
|
559
|
+
sourcePath: f.sourcePath,
|
|
560
|
+
sizeBytes: f.sizeBytes,
|
|
561
|
+
lineCount: f.lineCount,
|
|
562
|
+
canonicalFilename: f.canonicalFilename,
|
|
563
|
+
totalFiles: summary?.totalFiles ?? 1,
|
|
564
|
+
totalBytes: summary?.totalBytes ?? f.sizeBytes,
|
|
565
|
+
};
|
|
566
|
+
});
|
|
267
567
|
console.log(JSON.stringify({ discovered: payload, emptyEcosystems: discovery.emptyEcosystems }));
|
|
268
568
|
return;
|
|
269
569
|
}
|
|
@@ -271,22 +571,24 @@ async function ingestDiscover(options) {
|
|
|
271
571
|
// (stubs excluded). Helps the user verify before re-running without
|
|
272
572
|
// --dry-run.
|
|
273
573
|
if (options.dryRun && options.auto) {
|
|
274
|
-
const eligible = discovery.files
|
|
275
|
-
printDiscoveryPlainText(eligible);
|
|
574
|
+
const eligible = applyStubFilter(discovery.files);
|
|
575
|
+
printDiscoveryPlainText(eligible, discovery.skillSummaries);
|
|
276
576
|
console.log(' --dry-run --auto: would upload the above (stubs excluded). Not uploading.\n');
|
|
277
577
|
return;
|
|
278
578
|
}
|
|
279
579
|
// --dry-run alone: list ALL discoveries in plain text, never upload, never
|
|
280
580
|
// TUI. Stubs are marked inline so the user knows they'd be excluded.
|
|
281
581
|
if (options.dryRun) {
|
|
282
|
-
printDiscoveryPlainText(discovery.files);
|
|
582
|
+
printDiscoveryPlainText(discovery.files, discovery.skillSummaries);
|
|
283
583
|
console.log(' --dry-run: not uploading.\n');
|
|
284
584
|
return;
|
|
285
585
|
}
|
|
286
586
|
// --auto: skip the TUI and upload everything matching the defaults (stubs
|
|
287
|
-
// < STUB_BYTE_THRESHOLD bytes excluded — same rule the TUI uses).
|
|
587
|
+
// < STUB_BYTE_THRESHOLD bytes excluded — same rule the TUI uses). The
|
|
588
|
+
// stub filter looks at the root file only; adjacent files always ride
|
|
589
|
+
// along when their root is selected.
|
|
288
590
|
if (options.auto) {
|
|
289
|
-
const eligible = discovery.files
|
|
591
|
+
const eligible = applyStubFilter(discovery.files);
|
|
290
592
|
if (eligible.length === 0) {
|
|
291
593
|
console.log('\n No skills found (all discovered files are < 100-byte stubs). Inspect with `--dry-run`.\n');
|
|
292
594
|
return;
|
|
@@ -295,6 +597,15 @@ async function ingestDiscover(options) {
|
|
|
295
597
|
console.log(`\n ✓ Uploading ${skills.length} skill(s) found by discovery (--auto):`);
|
|
296
598
|
for (const s of skills)
|
|
297
599
|
console.log(` • ${s.slug} (${s.files.length} file(s))`);
|
|
600
|
+
// Surface any sweep warnings inline so the user knows what got dropped.
|
|
601
|
+
for (const [, summary] of discovery.skillSummaries) {
|
|
602
|
+
if (summary.warnings.length === 0)
|
|
603
|
+
continue;
|
|
604
|
+
const label = summary.scope ? `${summary.scope}/${summary.slug}` : summary.slug;
|
|
605
|
+
const line = formatSweepWarnings(label, summary.warnings);
|
|
606
|
+
if (line)
|
|
607
|
+
console.log(line);
|
|
608
|
+
}
|
|
298
609
|
await uploadSkills(skills, options);
|
|
299
610
|
return;
|
|
300
611
|
}
|
|
@@ -304,7 +615,7 @@ async function ingestDiscover(options) {
|
|
|
304
615
|
// do NOT upload silently here — interactive consent matters. The user
|
|
305
616
|
// can re-run with `--json` for machine-readable output or with `--auto`
|
|
306
617
|
// for one-shot mode.
|
|
307
|
-
printDiscoveryPlainText(discovery.files);
|
|
618
|
+
printDiscoveryPlainText(discovery.files, discovery.skillSummaries);
|
|
308
619
|
console.log(' Run with --json for machine-readable output, or in a real terminal for interactive selection.\n');
|
|
309
620
|
return;
|
|
310
621
|
}
|
|
@@ -317,6 +628,7 @@ async function ingestDiscover(options) {
|
|
|
317
628
|
};
|
|
318
629
|
const instance = render(React.createElement(IngestDiscoverApp, {
|
|
319
630
|
files: discovery.files,
|
|
631
|
+
skillSummaries: discovery.skillSummaries,
|
|
320
632
|
emptyEcosystems: discovery.emptyEcosystems,
|
|
321
633
|
onDone: (r) => {
|
|
322
634
|
resultHolder.value = r;
|
|
@@ -357,13 +669,24 @@ export async function ingest(rootPath, options) {
|
|
|
357
669
|
return;
|
|
358
670
|
}
|
|
359
671
|
const detected = [];
|
|
672
|
+
const sweepWarnings = [];
|
|
360
673
|
for (const filePath of walkFiles(root)) {
|
|
361
674
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
362
675
|
const d = options.fromTool
|
|
363
676
|
? detectFromTool(filePath, root, content, options.fromTool)
|
|
364
677
|
: detectAuto(filePath, root, content);
|
|
365
|
-
if (d)
|
|
366
|
-
|
|
678
|
+
if (!d)
|
|
679
|
+
continue;
|
|
680
|
+
detected.push(d);
|
|
681
|
+
// Sweep adjacent files when the detector opts in (claude SKILL.md, the
|
|
682
|
+
// new claude-code-agents AGENT.md). The root file we already pushed —
|
|
683
|
+
// this catches scripts/, templates/, reference docs, etc.
|
|
684
|
+
const detector = DETECTORS[d.ecosystem];
|
|
685
|
+
const { files: adjacent, warnings } = adjacentFilesFor(detector, d.ecosystem, d.slug, filePath);
|
|
686
|
+
detected.push(...adjacent);
|
|
687
|
+
const warningLine = formatSweepWarnings(d.slug, warnings);
|
|
688
|
+
if (warningLine)
|
|
689
|
+
sweepWarnings.push(warningLine);
|
|
367
690
|
}
|
|
368
691
|
if (detected.length === 0) {
|
|
369
692
|
if (options.fromTool) {
|
|
@@ -388,13 +711,15 @@ export async function ingest(rootPath, options) {
|
|
|
388
711
|
files: [],
|
|
389
712
|
});
|
|
390
713
|
}
|
|
391
|
-
grouped.get(f.slug).files.push({ filename: f.filename, content: f.content });
|
|
714
|
+
grouped.get(f.slug).files.push({ filename: f.filename, content: f.content, mode: f.mode });
|
|
392
715
|
}
|
|
393
716
|
const skills = [...grouped.values()];
|
|
394
717
|
console.log(`\n ✓ Found ${skills.length} skill(s):`);
|
|
395
718
|
for (const s of skills) {
|
|
396
719
|
console.log(` • ${s.slug} (${s.files.length} file(s))`);
|
|
397
720
|
}
|
|
721
|
+
for (const line of sweepWarnings)
|
|
722
|
+
console.log(line);
|
|
398
723
|
if (options.dryRun) {
|
|
399
724
|
console.log('\n --dry-run: not uploading.\n');
|
|
400
725
|
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 };
|