@botdocs/cli 0.5.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 +134 -6
- package/dist/commands/delete.d.ts +23 -0
- package/dist/commands/delete.js +106 -0
- package/dist/commands/ingest.d.ts +97 -1
- package/dist/commands/ingest.js +512 -19
- 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 +26 -0
- package/dist/commands/views/ingest-discover-app.js +186 -0
- package/dist/index.js +27 -5
- package/dist/lib/auto-detect.js +19 -0
- package/dist/lib/ingest-discover.d.ts +128 -0
- package/dist/lib/ingest-discover.js +271 -0
- package/dist/lib/ref.d.ts +42 -0
- package/dist/lib/ref.js +60 -0
- package/package.json +1 -1
- package/templates/agents.md +3 -2
package/dist/commands/ingest.js
CHANGED
|
@@ -1,12 +1,189 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
4
|
+
import { render } from 'ink';
|
|
3
5
|
import { apiFetch } from '../lib/api.js';
|
|
6
|
+
import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, titleFromContent, } from '../lib/ingest-discover.js';
|
|
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
|
+
}
|
|
4
181
|
/**
|
|
5
182
|
* Single source of truth for ecosystem detection. New ecosystems should be
|
|
6
183
|
* added here — both auto-detect mode and future `--from-tool` mode consult
|
|
7
184
|
* this table.
|
|
8
185
|
*/
|
|
9
|
-
const DETECTORS = {
|
|
186
|
+
export const DETECTORS = {
|
|
10
187
|
claude: {
|
|
11
188
|
pathPrefix: 'claude/',
|
|
12
189
|
extensions: ['/SKILL.md'],
|
|
@@ -22,6 +199,14 @@ const DETECTORS = {
|
|
|
22
199
|
return parts[parts.length - 2] ?? null;
|
|
23
200
|
},
|
|
24
201
|
canonicalFilename: (slug) => `claude/${slug}/SKILL.md`,
|
|
202
|
+
// Discovery walks ~/.claude/skills recursively — files live one or two
|
|
203
|
+
// segments deep (`<slug>/SKILL.md` or `<scope>/<slug>/SKILL.md`).
|
|
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}`,
|
|
25
210
|
},
|
|
26
211
|
'claude-code': {
|
|
27
212
|
pathPrefix: 'claude-code/commands/',
|
|
@@ -29,6 +214,52 @@ const DETECTORS = {
|
|
|
29
214
|
nested: false,
|
|
30
215
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
31
216
|
canonicalFilename: (slug) => `claude-code/commands/${slug}.md`,
|
|
217
|
+
// Two locations: global ~/.claude/commands always, project .claude/commands
|
|
218
|
+
// only inside a git repo (per-repo overrides).
|
|
219
|
+
scanPaths: (homeDir, projectRoot, isGitRepo) => {
|
|
220
|
+
const paths = [path.join(homeDir, '.claude', 'commands')];
|
|
221
|
+
if (isGitRepo)
|
|
222
|
+
paths.push(path.join(projectRoot, '.claude', 'commands'));
|
|
223
|
+
return paths;
|
|
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}`,
|
|
32
263
|
},
|
|
33
264
|
cursor: {
|
|
34
265
|
pathPrefix: 'cursor/rules/',
|
|
@@ -36,6 +267,7 @@ const DETECTORS = {
|
|
|
36
267
|
nested: false,
|
|
37
268
|
slugFor: (abs) => path.basename(abs).replace(/\.mdc$/, '') || null,
|
|
38
269
|
canonicalFilename: (slug) => `cursor/rules/${slug}.mdc`,
|
|
270
|
+
scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.cursor', 'rules')] : [],
|
|
39
271
|
},
|
|
40
272
|
chatgpt: {
|
|
41
273
|
pathPrefix: 'chatgpt/',
|
|
@@ -43,6 +275,8 @@ const DETECTORS = {
|
|
|
43
275
|
nested: false,
|
|
44
276
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
45
277
|
canonicalFilename: (slug) => `chatgpt/${slug}.md`,
|
|
278
|
+
// No canonical on-disk location — ChatGPT is a manual-paste ecosystem.
|
|
279
|
+
scanPaths: () => [],
|
|
46
280
|
},
|
|
47
281
|
codex: {
|
|
48
282
|
pathPrefix: 'codex/',
|
|
@@ -50,6 +284,7 @@ const DETECTORS = {
|
|
|
50
284
|
nested: false,
|
|
51
285
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
52
286
|
canonicalFilename: (slug) => `codex/${slug}.md`,
|
|
287
|
+
scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.codex', 'skills')] : [],
|
|
53
288
|
},
|
|
54
289
|
copilot: {
|
|
55
290
|
pathPrefix: 'copilot/instructions/',
|
|
@@ -58,6 +293,7 @@ const DETECTORS = {
|
|
|
58
293
|
nested: false,
|
|
59
294
|
slugFor: (abs) => path.basename(abs).replace(/\.instructions\.md$/, '') || null,
|
|
60
295
|
canonicalFilename: (slug) => `copilot/instructions/${slug}.instructions.md`,
|
|
296
|
+
scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.github', 'instructions')] : [],
|
|
61
297
|
},
|
|
62
298
|
windsurf: {
|
|
63
299
|
pathPrefix: 'windsurf/rules/',
|
|
@@ -65,6 +301,7 @@ const DETECTORS = {
|
|
|
65
301
|
nested: false,
|
|
66
302
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
67
303
|
canonicalFilename: (slug) => `windsurf/rules/${slug}.md`,
|
|
304
|
+
scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.codeium', 'windsurf-rules')] : [],
|
|
68
305
|
},
|
|
69
306
|
gemini: {
|
|
70
307
|
pathPrefix: 'gemini/instructions/',
|
|
@@ -72,6 +309,7 @@ const DETECTORS = {
|
|
|
72
309
|
nested: false,
|
|
73
310
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
74
311
|
canonicalFilename: (slug) => `gemini/instructions/${slug}.md`,
|
|
312
|
+
scanPaths: (homeDir) => [path.join(homeDir, '.gemini', 'instructions')],
|
|
75
313
|
},
|
|
76
314
|
antigravity: {
|
|
77
315
|
pathPrefix: 'antigravity/skills/',
|
|
@@ -79,6 +317,7 @@ const DETECTORS = {
|
|
|
79
317
|
nested: false,
|
|
80
318
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
81
319
|
canonicalFilename: (slug) => `antigravity/skills/${slug}.md`,
|
|
320
|
+
scanPaths: (homeDir) => [path.join(homeDir, '.gemini', 'antigravity', 'skills')],
|
|
82
321
|
},
|
|
83
322
|
opencode: {
|
|
84
323
|
pathPrefix: 'opencode/instructions/',
|
|
@@ -86,16 +325,16 @@ const DETECTORS = {
|
|
|
86
325
|
nested: false,
|
|
87
326
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
88
327
|
canonicalFilename: (slug) => `opencode/instructions/${slug}.md`,
|
|
328
|
+
scanPaths: (homeDir) => [path.join(homeDir, '.config', 'opencode', 'instructions')],
|
|
89
329
|
},
|
|
90
330
|
};
|
|
91
331
|
export const SUPPORTED_TOOLS = Object.keys(DETECTORS);
|
|
92
|
-
const IGNORED_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.turbo']);
|
|
93
332
|
function walkFiles(root) {
|
|
94
333
|
const out = [];
|
|
95
334
|
function walk(dir) {
|
|
96
335
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
97
336
|
if (entry.isDirectory()) {
|
|
98
|
-
if (
|
|
337
|
+
if (SWEEP_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
|
|
99
338
|
continue;
|
|
100
339
|
walk(path.join(dir, entry.name));
|
|
101
340
|
}
|
|
@@ -107,6 +346,16 @@ function walkFiles(root) {
|
|
|
107
346
|
walk(root);
|
|
108
347
|
return out;
|
|
109
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
|
+
}
|
|
110
359
|
/** Does the filename end with any of the detector's declared extensions? */
|
|
111
360
|
function matchesExtension(absPath, detector) {
|
|
112
361
|
return detector.extensions.some((ext) => absPath.endsWith(ext));
|
|
@@ -130,6 +379,7 @@ function detectAuto(absPath, root, content) {
|
|
|
130
379
|
content,
|
|
131
380
|
ecosystem,
|
|
132
381
|
slug,
|
|
382
|
+
mode: safeMode(absPath),
|
|
133
383
|
};
|
|
134
384
|
}
|
|
135
385
|
return null;
|
|
@@ -155,11 +405,243 @@ function detectFromTool(absPath, root, content, ecosystem) {
|
|
|
155
405
|
content,
|
|
156
406
|
ecosystem,
|
|
157
407
|
slug,
|
|
408
|
+
mode: safeMode(absPath),
|
|
158
409
|
};
|
|
159
410
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
}
|
|
431
|
+
/** Group a list of discovered files into the `DetectedSkill[]` shape the
|
|
432
|
+
* `/api/cli/ingest` endpoint expects. Multiple discoveries with the same slug
|
|
433
|
+
* collapse into one logical skill with multiple files — same convention as
|
|
434
|
+
* the path-based ingest path uses. */
|
|
435
|
+
function groupDiscoveredIntoSkills(discovered) {
|
|
436
|
+
const grouped = new Map();
|
|
437
|
+
for (const f of discovered) {
|
|
438
|
+
if (!grouped.has(f.slug)) {
|
|
439
|
+
grouped.set(f.slug, {
|
|
440
|
+
slug: f.slug,
|
|
441
|
+
title: f.title,
|
|
442
|
+
description: '',
|
|
443
|
+
sourceEcosystem: f.ecosystem,
|
|
444
|
+
files: [],
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
grouped.get(f.slug).files.push({
|
|
448
|
+
filename: f.canonicalFilename,
|
|
449
|
+
content: f.content,
|
|
450
|
+
mode: f.mode,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return [...grouped.values()];
|
|
454
|
+
}
|
|
455
|
+
/** POST the grouped skills to the ingest endpoint. Honors `--json` (emit raw
|
|
456
|
+
* response) and the default human-friendly "drafts created" line. Returns
|
|
457
|
+
* nothing on success. */
|
|
458
|
+
async function uploadSkills(skills, options) {
|
|
459
|
+
const result = await apiFetch('/api/cli/ingest', {
|
|
460
|
+
method: 'POST',
|
|
461
|
+
auth: true,
|
|
462
|
+
body: { skills, bundle: options.bundle ? { name: options.bundle } : undefined },
|
|
463
|
+
});
|
|
464
|
+
if (options.json) {
|
|
465
|
+
console.log(JSON.stringify(result));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
console.log(`\n ✓ Drafts created. Review at:\n https://botdocs.ai${result.reviewUrl}\n`);
|
|
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
|
+
}
|
|
499
|
+
/** Render a sectioned plain-text listing of discoveries. Reused for both
|
|
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);
|
|
504
|
+
const byEcosystem = new Map();
|
|
505
|
+
for (const d of rootFiles) {
|
|
506
|
+
const list = byEcosystem.get(d.ecosystem) ?? [];
|
|
507
|
+
list.push(d);
|
|
508
|
+
byEcosystem.set(d.ecosystem, list);
|
|
509
|
+
}
|
|
510
|
+
console.log('');
|
|
511
|
+
console.log(` Found ${rootFiles.length} skill(s) across ${byEcosystem.size} tool(s):`);
|
|
512
|
+
for (const [eco, list] of byEcosystem) {
|
|
513
|
+
console.log('');
|
|
514
|
+
console.log(` ${ecosystemLabel(eco)}:`);
|
|
515
|
+
for (const f of list) {
|
|
516
|
+
const label = f.scope ? `${f.scope}/${f.slug}` : f.slug;
|
|
517
|
+
const stub = f.sizeBytes < STUB_BYTE_THRESHOLD ? ' (stub — excluded by default)' : '';
|
|
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
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
console.log('');
|
|
533
|
+
}
|
|
534
|
+
/** The zero-argument discovery entry point. Scans known on-disk locations,
|
|
535
|
+
* routes through the TUI (or its fallbacks) per the user's options + tty
|
|
536
|
+
* state, and uploads the user's selection. */
|
|
537
|
+
async function ingestDiscover(options) {
|
|
538
|
+
const discovery = discoverSkills();
|
|
539
|
+
if (discovery.files.length === 0) {
|
|
540
|
+
console.log('\n No skills found. Try `botdocs init` to scaffold a new one, or point at a path: `botdocs ingest <dir>`\n');
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// --json: emit the discovery shape (no TUI, no upload) so CI / scripts can
|
|
544
|
+
// consume it. Includes per-file metadata; content is omitted to keep the
|
|
545
|
+
// payload small — callers can re-run with a path to actually upload.
|
|
546
|
+
if (options.json) {
|
|
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
|
+
});
|
|
567
|
+
console.log(JSON.stringify({ discovered: payload, emptyEcosystems: discovery.emptyEcosystems }));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
// --dry-run + --auto: list the FILTERED set --auto would actually upload
|
|
571
|
+
// (stubs excluded). Helps the user verify before re-running without
|
|
572
|
+
// --dry-run.
|
|
573
|
+
if (options.dryRun && options.auto) {
|
|
574
|
+
const eligible = applyStubFilter(discovery.files);
|
|
575
|
+
printDiscoveryPlainText(eligible, discovery.skillSummaries);
|
|
576
|
+
console.log(' --dry-run --auto: would upload the above (stubs excluded). Not uploading.\n');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
// --dry-run alone: list ALL discoveries in plain text, never upload, never
|
|
580
|
+
// TUI. Stubs are marked inline so the user knows they'd be excluded.
|
|
581
|
+
if (options.dryRun) {
|
|
582
|
+
printDiscoveryPlainText(discovery.files, discovery.skillSummaries);
|
|
583
|
+
console.log(' --dry-run: not uploading.\n');
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
// --auto: skip the TUI and upload everything matching the defaults (stubs
|
|
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.
|
|
590
|
+
if (options.auto) {
|
|
591
|
+
const eligible = applyStubFilter(discovery.files);
|
|
592
|
+
if (eligible.length === 0) {
|
|
593
|
+
console.log('\n No skills found (all discovered files are < 100-byte stubs). Inspect with `--dry-run`.\n');
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const skills = groupDiscoveredIntoSkills(eligible);
|
|
597
|
+
console.log(`\n ✓ Uploading ${skills.length} skill(s) found by discovery (--auto):`);
|
|
598
|
+
for (const s of skills)
|
|
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
|
+
}
|
|
609
|
+
await uploadSkills(skills, options);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const useInk = !options.noInk && Boolean(process.stdout.isTTY);
|
|
613
|
+
if (!useInk) {
|
|
614
|
+
// Non-TTY / piped / --no-ink: print plain text and exit. We deliberately
|
|
615
|
+
// do NOT upload silently here — interactive consent matters. The user
|
|
616
|
+
// can re-run with `--json` for machine-readable output or with `--auto`
|
|
617
|
+
// for one-shot mode.
|
|
618
|
+
printDiscoveryPlainText(discovery.files, discovery.skillSummaries);
|
|
619
|
+
console.log(' Run with --json for machine-readable output, or in a real terminal for interactive selection.\n');
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// TUI path. The component owns the keyboard handling; we just wait for it
|
|
623
|
+
// to call `onDone`, unmount, then upload (if confirmed). The result holder
|
|
624
|
+
// is typed via the discriminated-union directly so the post-await branch
|
|
625
|
+
// narrows correctly.
|
|
626
|
+
const resultHolder = {
|
|
627
|
+
value: { kind: 'cancelled' },
|
|
628
|
+
};
|
|
629
|
+
const instance = render(React.createElement(IngestDiscoverApp, {
|
|
630
|
+
files: discovery.files,
|
|
631
|
+
skillSummaries: discovery.skillSummaries,
|
|
632
|
+
emptyEcosystems: discovery.emptyEcosystems,
|
|
633
|
+
onDone: (r) => {
|
|
634
|
+
resultHolder.value = r;
|
|
635
|
+
},
|
|
636
|
+
}));
|
|
637
|
+
await instance.waitUntilExit();
|
|
638
|
+
const result = resultHolder.value;
|
|
639
|
+
if (result.kind === 'cancelled') {
|
|
640
|
+
console.log('\n Ingest cancelled.\n');
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const skills = groupDiscoveredIntoSkills(result.selected);
|
|
644
|
+
await uploadSkills(skills, options);
|
|
163
645
|
}
|
|
164
646
|
/** Human-readable list of all canonical path prefixes for the empty-result message. */
|
|
165
647
|
function ecosystemPrefixSummary() {
|
|
@@ -168,6 +650,13 @@ function ecosystemPrefixSummary() {
|
|
|
168
650
|
.join(', ');
|
|
169
651
|
}
|
|
170
652
|
export async function ingest(rootPath, options) {
|
|
653
|
+
// Zero-arg dispatch: scan known on-disk locations and route through the
|
|
654
|
+
// TUI (or its fallbacks). The existing path-based code path below stays
|
|
655
|
+
// identical.
|
|
656
|
+
if (!rootPath) {
|
|
657
|
+
await ingestDiscover(options);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
171
660
|
const root = path.resolve(rootPath);
|
|
172
661
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
173
662
|
console.error(`\n ✗ Not a directory: ${rootPath}\n`);
|
|
@@ -180,13 +669,24 @@ export async function ingest(rootPath, options) {
|
|
|
180
669
|
return;
|
|
181
670
|
}
|
|
182
671
|
const detected = [];
|
|
672
|
+
const sweepWarnings = [];
|
|
183
673
|
for (const filePath of walkFiles(root)) {
|
|
184
674
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
185
675
|
const d = options.fromTool
|
|
186
676
|
? detectFromTool(filePath, root, content, options.fromTool)
|
|
187
677
|
: detectAuto(filePath, root, content);
|
|
188
|
-
if (d)
|
|
189
|
-
|
|
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);
|
|
190
690
|
}
|
|
191
691
|
if (detected.length === 0) {
|
|
192
692
|
if (options.fromTool) {
|
|
@@ -211,25 +711,18 @@ export async function ingest(rootPath, options) {
|
|
|
211
711
|
files: [],
|
|
212
712
|
});
|
|
213
713
|
}
|
|
214
|
-
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 });
|
|
215
715
|
}
|
|
216
716
|
const skills = [...grouped.values()];
|
|
217
717
|
console.log(`\n ✓ Found ${skills.length} skill(s):`);
|
|
218
718
|
for (const s of skills) {
|
|
219
719
|
console.log(` • ${s.slug} (${s.files.length} file(s))`);
|
|
220
720
|
}
|
|
721
|
+
for (const line of sweepWarnings)
|
|
722
|
+
console.log(line);
|
|
221
723
|
if (options.dryRun) {
|
|
222
724
|
console.log('\n --dry-run: not uploading.\n');
|
|
223
725
|
return;
|
|
224
726
|
}
|
|
225
|
-
|
|
226
|
-
method: 'POST',
|
|
227
|
-
auth: true,
|
|
228
|
-
body: { skills, bundle: options.bundle ? { name: options.bundle } : undefined },
|
|
229
|
-
});
|
|
230
|
-
if (options.json) {
|
|
231
|
-
console.log(JSON.stringify(result));
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
console.log(`\n ✓ Drafts created. Review at:\n https://botdocs.ai${result.reviewUrl}\n`);
|
|
727
|
+
await uploadSkills(skills, options);
|
|
235
728
|
}
|
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) {
|