@botdocs/cli 0.5.0 → 0.6.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 +57 -1
- package/dist/commands/ingest.d.ts +39 -1
- package/dist/commands/ingest.js +182 -14
- package/dist/commands/views/ingest-discover-app.d.ts +21 -0
- package/dist/commands/views/ingest-discover-app.js +130 -0
- package/dist/index.js +9 -4
- package/dist/lib/ingest-discover.d.ts +95 -0
- package/dist/lib/ingest-discover.js +207 -0
- package/package.json +1 -1
- package/templates/agents.md +1 -1
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ botdocs publish my-skill/
|
|
|
60
60
|
| `sync [ref]` | Check installed skills/bundles for updates and apply. |
|
|
61
61
|
| `uninstall <ref>` | Remove an installed skill or bundle. |
|
|
62
62
|
| `list` | Show installed skills and bundles. |
|
|
63
|
-
| `ingest
|
|
63
|
+
| `ingest [path]` | Scan your system (or a directory), detect existing skills, upload as drafts. |
|
|
64
64
|
| `team list` / `show` / `create` / `add` / `remove` / `push` / `unpush` | Manage teams: shared skill libraries for your org. |
|
|
65
65
|
| `undo` | Restore the most recent backup run (reversible). |
|
|
66
66
|
| `backups list` / `restore` / `diff` / `clear` | Browse, restore, diff, and prune backup runs. |
|
|
@@ -316,6 +316,62 @@ The upload always uses the canonical BotDocs filename, so when someone
|
|
|
316
316
|
else `botdocs install`s the resulting skill it lands in the right
|
|
317
317
|
on-disk location.
|
|
318
318
|
|
|
319
|
+
### Zero-argument discovery mode
|
|
320
|
+
|
|
321
|
+
Run `botdocs ingest` (no path) and the CLI scans your machine across
|
|
322
|
+
every known on-disk location for the nine supported ecosystems:
|
|
323
|
+
|
|
324
|
+
- `~/.claude/commands/` and `<repo>/.claude/commands/` (Claude Code)
|
|
325
|
+
- `~/.claude/skills/**/SKILL.md` (Claude skills, nested by scope)
|
|
326
|
+
- `<repo>/.cursor/rules/` (Cursor)
|
|
327
|
+
- `<repo>/.codex/skills/` (Codex)
|
|
328
|
+
- `<repo>/.github/instructions/` (GitHub Copilot)
|
|
329
|
+
- `<repo>/.codeium/windsurf-rules/` (Windsurf)
|
|
330
|
+
- `~/.gemini/instructions/` (Gemini CLI)
|
|
331
|
+
- `~/.gemini/antigravity/skills/` (Antigravity)
|
|
332
|
+
- `~/.config/opencode/instructions/` (OpenCode)
|
|
333
|
+
|
|
334
|
+
Project-scoped scans (Cursor, Codex, Copilot, Windsurf, the project
|
|
335
|
+
flavor of Claude Code) only run when the current directory is inside
|
|
336
|
+
a git repo.
|
|
337
|
+
|
|
338
|
+
A scan opens an interactive Ink TUI sectioned by ecosystem:
|
|
339
|
+
|
|
340
|
+
```text
|
|
341
|
+
BotDocs ingest
|
|
342
|
+
Found 7 skills across 4 tools:
|
|
343
|
+
|
|
344
|
+
Claude Code:
|
|
345
|
+
[x] scx-pr-craft 2.1 KB · 67 lines
|
|
346
|
+
[x] pr-review-craft 1.8 KB · 54 lines
|
|
347
|
+
[ ] generic-test-cmd 0.1 KB · 4 lines ← unchecked (< 100 bytes)
|
|
348
|
+
|
|
349
|
+
Claude skills:
|
|
350
|
+
[x] code-review 4.2 KB · 142 lines
|
|
351
|
+
|
|
352
|
+
Cursor rules:
|
|
353
|
+
[x] typescript-strict 0.8 KB · 25 lines
|
|
354
|
+
|
|
355
|
+
Gemini CLI:
|
|
356
|
+
[x] research-protocol 2.4 KB · 81 lines
|
|
357
|
+
|
|
358
|
+
↑/↓ navigate · space toggle · a select all · n select none · enter confirm · q cancel
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Useful flags:
|
|
362
|
+
|
|
363
|
+
- `--auto` — skip the TUI and upload everything discovery finds
|
|
364
|
+
(with the < 100-byte stub filter applied).
|
|
365
|
+
- `--dry-run` — list what discovery found in plain text; don't upload.
|
|
366
|
+
- `--json` — emit the discovery as JSON; don't upload.
|
|
367
|
+
- `--no-ink` — disable the TUI on TTYs (for screen readers or simple
|
|
368
|
+
terminals). Falls back to plain-text listing without uploading;
|
|
369
|
+
combine with `--auto` if you want one-shot ingestion.
|
|
370
|
+
|
|
371
|
+
When the discovery returns nothing the CLI prints a hint pointing at
|
|
372
|
+
`botdocs init` (to scaffold a new skill) or `botdocs ingest <dir>`
|
|
373
|
+
(to ingest from a specific path).
|
|
374
|
+
|
|
319
375
|
## Development
|
|
320
376
|
|
|
321
377
|
```bash
|
|
@@ -2,8 +2,46 @@ interface IngestOptions {
|
|
|
2
2
|
bundle?: string;
|
|
3
3
|
dryRun?: boolean;
|
|
4
4
|
json?: boolean;
|
|
5
|
+
/** Force every file in the path to belong to a single ecosystem, instead of
|
|
6
|
+
* relying on the canonical-layout auto-detect. Set via `--from-tool=<x>`. */
|
|
5
7
|
fromTool?: string;
|
|
8
|
+
/** Skip the TUI and ingest everything discovery finds (with the default
|
|
9
|
+
* stub filter applied). Set via `--auto`. */
|
|
10
|
+
auto?: boolean;
|
|
11
|
+
/** Force the plain-text rendering path — disables the Ink TUI. Mirrors the
|
|
12
|
+
* `--no-ink` flag on `login` and `sync`. */
|
|
13
|
+
noInk?: boolean;
|
|
6
14
|
}
|
|
15
|
+
export interface EcosystemDetector {
|
|
16
|
+
/** Path prefix the file's root-relative path must start with for auto-detect mode. */
|
|
17
|
+
pathPrefix: string;
|
|
18
|
+
/** Extension suffixes the filename must match. Tested via endsWith. */
|
|
19
|
+
extensions: string[];
|
|
20
|
+
/** Whether this ecosystem uses a nested SKILL.md layout (claude) vs flat.
|
|
21
|
+
* Discovery uses this to decide whether to recurse the scan directories. */
|
|
22
|
+
nested: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Given an absolute file path and the source root, return the slug or null
|
|
25
|
+
* if the file doesn't match this ecosystem's layout (e.g. wrong nesting).
|
|
26
|
+
*/
|
|
27
|
+
slugFor: (absPath: string, root: string) => string | null;
|
|
28
|
+
/** Canonical filename inside a BotDoc directory, given the slug. */
|
|
29
|
+
canonicalFilename: (slug: string) => string;
|
|
30
|
+
/**
|
|
31
|
+
* Absolute directories to scan during zero-arg discovery mode. Receives the
|
|
32
|
+
* user's home dir, the current project root (cwd), and whether the project
|
|
33
|
+
* root is inside a git repo. Project-scoped paths should return `[]` when
|
|
34
|
+
* `isGitRepo` is false. Return `[]` for ecosystems that have no canonical
|
|
35
|
+
* on-disk location (e.g. chatgpt — manual-paste only).
|
|
36
|
+
*/
|
|
37
|
+
scanPaths: (homeDir: string, projectRoot: string, isGitRepo: boolean) => string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Single source of truth for ecosystem detection. New ecosystems should be
|
|
41
|
+
* added here — both auto-detect mode and future `--from-tool` mode consult
|
|
42
|
+
* this table.
|
|
43
|
+
*/
|
|
44
|
+
export declare const DETECTORS: Record<string, EcosystemDetector>;
|
|
7
45
|
export declare const SUPPORTED_TOOLS: readonly string[];
|
|
8
|
-
export declare function ingest(rootPath: string, options: IngestOptions): Promise<void>;
|
|
46
|
+
export declare function ingest(rootPath: string | undefined, options: IngestOptions): Promise<void>;
|
|
9
47
|
export {};
|
package/dist/commands/ingest.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
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, titleFromContent, } from '../lib/ingest-discover.js';
|
|
7
|
+
import { IngestDiscoverApp } from './views/ingest-discover-app.js';
|
|
4
8
|
/**
|
|
5
9
|
* Single source of truth for ecosystem detection. New ecosystems should be
|
|
6
10
|
* added here — both auto-detect mode and future `--from-tool` mode consult
|
|
7
11
|
* this table.
|
|
8
12
|
*/
|
|
9
|
-
const DETECTORS = {
|
|
13
|
+
export const DETECTORS = {
|
|
10
14
|
claude: {
|
|
11
15
|
pathPrefix: 'claude/',
|
|
12
16
|
extensions: ['/SKILL.md'],
|
|
@@ -22,6 +26,9 @@ const DETECTORS = {
|
|
|
22
26
|
return parts[parts.length - 2] ?? null;
|
|
23
27
|
},
|
|
24
28
|
canonicalFilename: (slug) => `claude/${slug}/SKILL.md`,
|
|
29
|
+
// Discovery walks ~/.claude/skills recursively — files live one or two
|
|
30
|
+
// segments deep (`<slug>/SKILL.md` or `<scope>/<slug>/SKILL.md`).
|
|
31
|
+
scanPaths: (homeDir) => [path.join(homeDir, '.claude', 'skills')],
|
|
25
32
|
},
|
|
26
33
|
'claude-code': {
|
|
27
34
|
pathPrefix: 'claude-code/commands/',
|
|
@@ -29,6 +36,14 @@ const DETECTORS = {
|
|
|
29
36
|
nested: false,
|
|
30
37
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
31
38
|
canonicalFilename: (slug) => `claude-code/commands/${slug}.md`,
|
|
39
|
+
// Two locations: global ~/.claude/commands always, project .claude/commands
|
|
40
|
+
// only inside a git repo (per-repo overrides).
|
|
41
|
+
scanPaths: (homeDir, projectRoot, isGitRepo) => {
|
|
42
|
+
const paths = [path.join(homeDir, '.claude', 'commands')];
|
|
43
|
+
if (isGitRepo)
|
|
44
|
+
paths.push(path.join(projectRoot, '.claude', 'commands'));
|
|
45
|
+
return paths;
|
|
46
|
+
},
|
|
32
47
|
},
|
|
33
48
|
cursor: {
|
|
34
49
|
pathPrefix: 'cursor/rules/',
|
|
@@ -36,6 +51,7 @@ const DETECTORS = {
|
|
|
36
51
|
nested: false,
|
|
37
52
|
slugFor: (abs) => path.basename(abs).replace(/\.mdc$/, '') || null,
|
|
38
53
|
canonicalFilename: (slug) => `cursor/rules/${slug}.mdc`,
|
|
54
|
+
scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.cursor', 'rules')] : [],
|
|
39
55
|
},
|
|
40
56
|
chatgpt: {
|
|
41
57
|
pathPrefix: 'chatgpt/',
|
|
@@ -43,6 +59,8 @@ const DETECTORS = {
|
|
|
43
59
|
nested: false,
|
|
44
60
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
45
61
|
canonicalFilename: (slug) => `chatgpt/${slug}.md`,
|
|
62
|
+
// No canonical on-disk location — ChatGPT is a manual-paste ecosystem.
|
|
63
|
+
scanPaths: () => [],
|
|
46
64
|
},
|
|
47
65
|
codex: {
|
|
48
66
|
pathPrefix: 'codex/',
|
|
@@ -50,6 +68,7 @@ const DETECTORS = {
|
|
|
50
68
|
nested: false,
|
|
51
69
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
52
70
|
canonicalFilename: (slug) => `codex/${slug}.md`,
|
|
71
|
+
scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.codex', 'skills')] : [],
|
|
53
72
|
},
|
|
54
73
|
copilot: {
|
|
55
74
|
pathPrefix: 'copilot/instructions/',
|
|
@@ -58,6 +77,7 @@ const DETECTORS = {
|
|
|
58
77
|
nested: false,
|
|
59
78
|
slugFor: (abs) => path.basename(abs).replace(/\.instructions\.md$/, '') || null,
|
|
60
79
|
canonicalFilename: (slug) => `copilot/instructions/${slug}.instructions.md`,
|
|
80
|
+
scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.github', 'instructions')] : [],
|
|
61
81
|
},
|
|
62
82
|
windsurf: {
|
|
63
83
|
pathPrefix: 'windsurf/rules/',
|
|
@@ -65,6 +85,7 @@ const DETECTORS = {
|
|
|
65
85
|
nested: false,
|
|
66
86
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
67
87
|
canonicalFilename: (slug) => `windsurf/rules/${slug}.md`,
|
|
88
|
+
scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.codeium', 'windsurf-rules')] : [],
|
|
68
89
|
},
|
|
69
90
|
gemini: {
|
|
70
91
|
pathPrefix: 'gemini/instructions/',
|
|
@@ -72,6 +93,7 @@ const DETECTORS = {
|
|
|
72
93
|
nested: false,
|
|
73
94
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
74
95
|
canonicalFilename: (slug) => `gemini/instructions/${slug}.md`,
|
|
96
|
+
scanPaths: (homeDir) => [path.join(homeDir, '.gemini', 'instructions')],
|
|
75
97
|
},
|
|
76
98
|
antigravity: {
|
|
77
99
|
pathPrefix: 'antigravity/skills/',
|
|
@@ -79,6 +101,7 @@ const DETECTORS = {
|
|
|
79
101
|
nested: false,
|
|
80
102
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
81
103
|
canonicalFilename: (slug) => `antigravity/skills/${slug}.md`,
|
|
104
|
+
scanPaths: (homeDir) => [path.join(homeDir, '.gemini', 'antigravity', 'skills')],
|
|
82
105
|
},
|
|
83
106
|
opencode: {
|
|
84
107
|
pathPrefix: 'opencode/instructions/',
|
|
@@ -86,6 +109,7 @@ const DETECTORS = {
|
|
|
86
109
|
nested: false,
|
|
87
110
|
slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
|
|
88
111
|
canonicalFilename: (slug) => `opencode/instructions/${slug}.md`,
|
|
112
|
+
scanPaths: (homeDir) => [path.join(homeDir, '.config', 'opencode', 'instructions')],
|
|
89
113
|
},
|
|
90
114
|
};
|
|
91
115
|
export const SUPPORTED_TOOLS = Object.keys(DETECTORS);
|
|
@@ -157,9 +181,155 @@ function detectFromTool(absPath, root, content, ecosystem) {
|
|
|
157
181
|
slug,
|
|
158
182
|
};
|
|
159
183
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
184
|
+
/** Group a list of discovered files into the `DetectedSkill[]` shape the
|
|
185
|
+
* `/api/cli/ingest` endpoint expects. Multiple discoveries with the same slug
|
|
186
|
+
* collapse into one logical skill with multiple files — same convention as
|
|
187
|
+
* the path-based ingest path uses. */
|
|
188
|
+
function groupDiscoveredIntoSkills(discovered) {
|
|
189
|
+
const grouped = new Map();
|
|
190
|
+
for (const f of discovered) {
|
|
191
|
+
if (!grouped.has(f.slug)) {
|
|
192
|
+
grouped.set(f.slug, {
|
|
193
|
+
slug: f.slug,
|
|
194
|
+
title: f.title,
|
|
195
|
+
description: '',
|
|
196
|
+
sourceEcosystem: f.ecosystem,
|
|
197
|
+
files: [],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
grouped.get(f.slug).files.push({
|
|
201
|
+
filename: f.canonicalFilename,
|
|
202
|
+
content: f.content,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return [...grouped.values()];
|
|
206
|
+
}
|
|
207
|
+
/** POST the grouped skills to the ingest endpoint. Honors `--json` (emit raw
|
|
208
|
+
* response) and the default human-friendly "drafts created" line. Returns
|
|
209
|
+
* nothing on success. */
|
|
210
|
+
async function uploadSkills(skills, options) {
|
|
211
|
+
const result = await apiFetch('/api/cli/ingest', {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
auth: true,
|
|
214
|
+
body: { skills, bundle: options.bundle ? { name: options.bundle } : undefined },
|
|
215
|
+
});
|
|
216
|
+
if (options.json) {
|
|
217
|
+
console.log(JSON.stringify(result));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
console.log(`\n ✓ Drafts created. Review at:\n https://botdocs.ai${result.reviewUrl}\n`);
|
|
221
|
+
}
|
|
222
|
+
/** Render a sectioned plain-text listing of discoveries. Reused for both
|
|
223
|
+
* `--dry-run` output and the non-TTY fallback. */
|
|
224
|
+
function printDiscoveryPlainText(discovered) {
|
|
225
|
+
const byEcosystem = new Map();
|
|
226
|
+
for (const d of discovered) {
|
|
227
|
+
const list = byEcosystem.get(d.ecosystem) ?? [];
|
|
228
|
+
list.push(d);
|
|
229
|
+
byEcosystem.set(d.ecosystem, list);
|
|
230
|
+
}
|
|
231
|
+
console.log('');
|
|
232
|
+
console.log(` Found ${discovered.length} skill(s) across ${byEcosystem.size} tool(s):`);
|
|
233
|
+
for (const [eco, list] of byEcosystem) {
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log(` ${ecosystemLabel(eco)}:`);
|
|
236
|
+
for (const f of list) {
|
|
237
|
+
const label = f.scope ? `${f.scope}/${f.slug}` : f.slug;
|
|
238
|
+
const stub = f.sizeBytes < STUB_BYTE_THRESHOLD ? ' (stub — excluded by default)' : '';
|
|
239
|
+
console.log(` • ${label} ${formatBytes(f.sizeBytes)} · ${f.lineCount} lines${stub}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
console.log('');
|
|
243
|
+
}
|
|
244
|
+
/** The zero-argument discovery entry point. Scans known on-disk locations,
|
|
245
|
+
* routes through the TUI (or its fallbacks) per the user's options + tty
|
|
246
|
+
* state, and uploads the user's selection. */
|
|
247
|
+
async function ingestDiscover(options) {
|
|
248
|
+
const discovery = discoverSkills();
|
|
249
|
+
if (discovery.files.length === 0) {
|
|
250
|
+
console.log('\n No skills found. Try `botdocs init` to scaffold a new one, or point at a path: `botdocs ingest <dir>`\n');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// --json: emit the discovery shape (no TUI, no upload) so CI / scripts can
|
|
254
|
+
// consume it. Includes per-file metadata; content is omitted to keep the
|
|
255
|
+
// payload small — callers can re-run with a path to actually upload.
|
|
256
|
+
if (options.json) {
|
|
257
|
+
const payload = discovery.files.map((f) => ({
|
|
258
|
+
ecosystem: f.ecosystem,
|
|
259
|
+
slug: f.slug,
|
|
260
|
+
scope: f.scope,
|
|
261
|
+
title: f.title,
|
|
262
|
+
sourcePath: f.sourcePath,
|
|
263
|
+
sizeBytes: f.sizeBytes,
|
|
264
|
+
lineCount: f.lineCount,
|
|
265
|
+
canonicalFilename: f.canonicalFilename,
|
|
266
|
+
}));
|
|
267
|
+
console.log(JSON.stringify({ discovered: payload, emptyEcosystems: discovery.emptyEcosystems }));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// --dry-run + --auto: list the FILTERED set --auto would actually upload
|
|
271
|
+
// (stubs excluded). Helps the user verify before re-running without
|
|
272
|
+
// --dry-run.
|
|
273
|
+
if (options.dryRun && options.auto) {
|
|
274
|
+
const eligible = discovery.files.filter((f) => f.sizeBytes >= STUB_BYTE_THRESHOLD);
|
|
275
|
+
printDiscoveryPlainText(eligible);
|
|
276
|
+
console.log(' --dry-run --auto: would upload the above (stubs excluded). Not uploading.\n');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// --dry-run alone: list ALL discoveries in plain text, never upload, never
|
|
280
|
+
// TUI. Stubs are marked inline so the user knows they'd be excluded.
|
|
281
|
+
if (options.dryRun) {
|
|
282
|
+
printDiscoveryPlainText(discovery.files);
|
|
283
|
+
console.log(' --dry-run: not uploading.\n');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// --auto: skip the TUI and upload everything matching the defaults (stubs
|
|
287
|
+
// < STUB_BYTE_THRESHOLD bytes excluded — same rule the TUI uses).
|
|
288
|
+
if (options.auto) {
|
|
289
|
+
const eligible = discovery.files.filter((f) => f.sizeBytes >= STUB_BYTE_THRESHOLD);
|
|
290
|
+
if (eligible.length === 0) {
|
|
291
|
+
console.log('\n No skills found (all discovered files are < 100-byte stubs). Inspect with `--dry-run`.\n');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const skills = groupDiscoveredIntoSkills(eligible);
|
|
295
|
+
console.log(`\n ✓ Uploading ${skills.length} skill(s) found by discovery (--auto):`);
|
|
296
|
+
for (const s of skills)
|
|
297
|
+
console.log(` • ${s.slug} (${s.files.length} file(s))`);
|
|
298
|
+
await uploadSkills(skills, options);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const useInk = !options.noInk && Boolean(process.stdout.isTTY);
|
|
302
|
+
if (!useInk) {
|
|
303
|
+
// Non-TTY / piped / --no-ink: print plain text and exit. We deliberately
|
|
304
|
+
// do NOT upload silently here — interactive consent matters. The user
|
|
305
|
+
// can re-run with `--json` for machine-readable output or with `--auto`
|
|
306
|
+
// for one-shot mode.
|
|
307
|
+
printDiscoveryPlainText(discovery.files);
|
|
308
|
+
console.log(' Run with --json for machine-readable output, or in a real terminal for interactive selection.\n');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// TUI path. The component owns the keyboard handling; we just wait for it
|
|
312
|
+
// to call `onDone`, unmount, then upload (if confirmed). The result holder
|
|
313
|
+
// is typed via the discriminated-union directly so the post-await branch
|
|
314
|
+
// narrows correctly.
|
|
315
|
+
const resultHolder = {
|
|
316
|
+
value: { kind: 'cancelled' },
|
|
317
|
+
};
|
|
318
|
+
const instance = render(React.createElement(IngestDiscoverApp, {
|
|
319
|
+
files: discovery.files,
|
|
320
|
+
emptyEcosystems: discovery.emptyEcosystems,
|
|
321
|
+
onDone: (r) => {
|
|
322
|
+
resultHolder.value = r;
|
|
323
|
+
},
|
|
324
|
+
}));
|
|
325
|
+
await instance.waitUntilExit();
|
|
326
|
+
const result = resultHolder.value;
|
|
327
|
+
if (result.kind === 'cancelled') {
|
|
328
|
+
console.log('\n Ingest cancelled.\n');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const skills = groupDiscoveredIntoSkills(result.selected);
|
|
332
|
+
await uploadSkills(skills, options);
|
|
163
333
|
}
|
|
164
334
|
/** Human-readable list of all canonical path prefixes for the empty-result message. */
|
|
165
335
|
function ecosystemPrefixSummary() {
|
|
@@ -168,6 +338,13 @@ function ecosystemPrefixSummary() {
|
|
|
168
338
|
.join(', ');
|
|
169
339
|
}
|
|
170
340
|
export async function ingest(rootPath, options) {
|
|
341
|
+
// Zero-arg dispatch: scan known on-disk locations and route through the
|
|
342
|
+
// TUI (or its fallbacks). The existing path-based code path below stays
|
|
343
|
+
// identical.
|
|
344
|
+
if (!rootPath) {
|
|
345
|
+
await ingestDiscover(options);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
171
348
|
const root = path.resolve(rootPath);
|
|
172
349
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
173
350
|
console.error(`\n ✗ Not a directory: ${rootPath}\n`);
|
|
@@ -222,14 +399,5 @@ export async function ingest(rootPath, options) {
|
|
|
222
399
|
console.log('\n --dry-run: not uploading.\n');
|
|
223
400
|
return;
|
|
224
401
|
}
|
|
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`);
|
|
402
|
+
await uploadSkills(skills, options);
|
|
235
403
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type DiscoveredSkillFile, type DiscoveryEcosystem } from '../../lib/ingest-discover.js';
|
|
3
|
+
/** The outcome of one TUI session. `null` selections means the user cancelled
|
|
4
|
+
* via `q`/Esc; an empty array means they hit enter with everything unchecked
|
|
5
|
+
* (the runner stays on screen — the parent never receives an empty array). */
|
|
6
|
+
export type IngestDiscoverResult = {
|
|
7
|
+
kind: 'confirmed';
|
|
8
|
+
selected: DiscoveredSkillFile[];
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'cancelled';
|
|
11
|
+
};
|
|
12
|
+
export interface IngestDiscoverAppProps {
|
|
13
|
+
files: DiscoveredSkillFile[];
|
|
14
|
+
/** Ecosystems we scanned but found nothing for. Rendered as a single
|
|
15
|
+
* trailing "Other tools: ..." line so the user can see we looked. */
|
|
16
|
+
emptyEcosystems: DiscoveryEcosystem[];
|
|
17
|
+
/** Called with the user's choice when they confirm or cancel. The parent
|
|
18
|
+
* is responsible for unmounting on receipt. */
|
|
19
|
+
onDone: (result: IngestDiscoverResult) => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function IngestDiscoverApp(props: IngestDiscoverAppProps): React.ReactElement;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
|
+
import { theme } from './theme.js';
|
|
5
|
+
import { ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, } from '../../lib/ingest-discover.js';
|
|
6
|
+
/** Group files by ecosystem (preserving order) and emit a flat row list with
|
|
7
|
+
* one section header per ecosystem followed by its files. Empty ecosystems
|
|
8
|
+
* are surfaced separately as a trailing line, not in the row list. */
|
|
9
|
+
function buildRows(files) {
|
|
10
|
+
const rows = [];
|
|
11
|
+
let lastEcosystem = null;
|
|
12
|
+
files.forEach((file, index) => {
|
|
13
|
+
if (file.ecosystem !== lastEcosystem) {
|
|
14
|
+
rows.push({ kind: 'header', ecosystem: file.ecosystem });
|
|
15
|
+
lastEcosystem = file.ecosystem;
|
|
16
|
+
}
|
|
17
|
+
rows.push({ kind: 'file', index, file });
|
|
18
|
+
});
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
21
|
+
/** Default-checked rule: a file is checked iff it's >= STUB_BYTE_THRESHOLD.
|
|
22
|
+
* Returned as a Set of indices for O(1) toggle. */
|
|
23
|
+
function defaultChecked(files) {
|
|
24
|
+
const out = new Set();
|
|
25
|
+
files.forEach((f, i) => {
|
|
26
|
+
if (f.sizeBytes >= STUB_BYTE_THRESHOLD)
|
|
27
|
+
out.add(i);
|
|
28
|
+
});
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** Find the first selectable (file) row in the flattened list. Used to seed
|
|
32
|
+
* the cursor — we never park it on a header. */
|
|
33
|
+
function firstSelectableIndex(rows) {
|
|
34
|
+
return rows.findIndex((r) => r.kind === 'file');
|
|
35
|
+
}
|
|
36
|
+
/** Move the cursor by `delta` (±1) and skip past header rows. Caller guarantees
|
|
37
|
+
* `rows` contains at least one file row. */
|
|
38
|
+
function moveCursor(rows, current, delta) {
|
|
39
|
+
const max = rows.length - 1;
|
|
40
|
+
let next = current + delta;
|
|
41
|
+
while (next >= 0 && next <= max) {
|
|
42
|
+
if (rows[next]?.kind === 'file')
|
|
43
|
+
return next;
|
|
44
|
+
next += delta;
|
|
45
|
+
}
|
|
46
|
+
// Off the end — stay where we were.
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
49
|
+
/** Render a single file row. The checkbox uses `[x]`/`[ ]` so the output is
|
|
50
|
+
* legible in plain-text snapshots; the active row gets a leading `▶`. */
|
|
51
|
+
function FileRow({ file, checked, active, }) {
|
|
52
|
+
const label = file.scope ? `${file.scope}/${file.slug}` : file.slug;
|
|
53
|
+
const details = `${formatBytes(file.sizeBytes)} · ${file.lineCount} lines`;
|
|
54
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: active ? theme.cyan : undefined, children: active ? '▶' : ' ' }) }), _jsxs(Text, { color: active ? theme.cyan : undefined, children: [checked ? '[x] ' : '[ ] ', label] }), _jsx(Text, { color: theme.zinc, children: ` ${details}` })] }));
|
|
55
|
+
}
|
|
56
|
+
export function IngestDiscoverApp(props) {
|
|
57
|
+
const { files, emptyEcosystems, onDone } = props;
|
|
58
|
+
const { exit } = useApp();
|
|
59
|
+
const rows = useMemo(() => buildRows(files), [files]);
|
|
60
|
+
const [cursor, setCursor] = useState(() => firstSelectableIndex(rows));
|
|
61
|
+
const [checked, setChecked] = useState(() => defaultChecked(files));
|
|
62
|
+
// When the user hits enter with zero selections we don't unmount — we just
|
|
63
|
+
// surface a hint and let them keep toggling. This flag drives that hint.
|
|
64
|
+
const [emptyConfirm, setEmptyConfirm] = useState(false);
|
|
65
|
+
useInput((input, key) => {
|
|
66
|
+
if (key.upArrow) {
|
|
67
|
+
setCursor((c) => moveCursor(rows, c, -1));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (key.downArrow) {
|
|
71
|
+
setCursor((c) => moveCursor(rows, c, 1));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (input === ' ') {
|
|
75
|
+
const row = rows[cursor];
|
|
76
|
+
if (row?.kind !== 'file')
|
|
77
|
+
return;
|
|
78
|
+
const next = new Set(checked);
|
|
79
|
+
if (next.has(row.index))
|
|
80
|
+
next.delete(row.index);
|
|
81
|
+
else
|
|
82
|
+
next.add(row.index);
|
|
83
|
+
setChecked(next);
|
|
84
|
+
setEmptyConfirm(false);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (input === 'a' || input === 'A') {
|
|
88
|
+
const next = new Set();
|
|
89
|
+
files.forEach((_, i) => next.add(i));
|
|
90
|
+
setChecked(next);
|
|
91
|
+
setEmptyConfirm(false);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (input === 'n' || input === 'N') {
|
|
95
|
+
setChecked(new Set());
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (key.return) {
|
|
99
|
+
if (checked.size === 0) {
|
|
100
|
+
setEmptyConfirm(true);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const selected = files.filter((_, i) => checked.has(i));
|
|
104
|
+
onDone({ kind: 'confirmed', selected });
|
|
105
|
+
exit();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (input === 'q' || input === 'Q' || key.escape) {
|
|
109
|
+
onDone({ kind: 'cancelled' });
|
|
110
|
+
exit();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
// Empty discovery — render a helpful empty state and exit on any key. This
|
|
115
|
+
// is reachable when discovery returned files but they were all filtered out
|
|
116
|
+
// somehow (defensive — the caller currently bails before mounting us).
|
|
117
|
+
if (files.length === 0) {
|
|
118
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: _jsx(Text, { children: "No skills found in any known location." }) }));
|
|
119
|
+
}
|
|
120
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { bold: true, color: theme.cyan, children: "BotDocs ingest" }), _jsxs(Text, { color: theme.zinc, children: ["Found ", files.length, " skill", files.length === 1 ? '' : 's', " across", ' ', countEcosystems(files), " tool", countEcosystems(files) === 1 ? '' : 's', ":"] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: rows.map((row, i) => {
|
|
121
|
+
if (row.kind === 'header') {
|
|
122
|
+
return (_jsx(Box, { marginTop: i === 0 ? 0 : 1, children: _jsxs(Text, { bold: true, color: theme.violet, children: [ecosystemLabel(row.ecosystem), ":"] }) }, `h-${row.ecosystem}`));
|
|
123
|
+
}
|
|
124
|
+
return (_jsx(FileRow, { file: row.file, checked: checked.has(row.index), active: i === cursor }, `f-${row.index}`));
|
|
125
|
+
}) }), emptyEcosystems.length > 0 ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.zinc, children: ["Other tools: 0 skills found (", emptyEcosystems.map(ecosystemLabel).join(', '), ")"] }) })) : null, emptyConfirm ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.amber, children: "Nothing selected. Use space to toggle, then enter." }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.zinc, children: "\u2191/\u2193 navigate \u00B7 space toggle \u00B7 a select all \u00B7 n select none \u00B7 enter confirm \u00B7 q cancel" }) })] }));
|
|
126
|
+
}
|
|
127
|
+
/** Count distinct ecosystems in the discovery — used in the header line. */
|
|
128
|
+
function countEcosystems(files) {
|
|
129
|
+
return new Set(files.map((f) => f.ecosystem)).size;
|
|
130
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -137,13 +137,18 @@ program
|
|
|
137
137
|
await checkUpdates({ ...opts, json: program.opts().json });
|
|
138
138
|
});
|
|
139
139
|
program
|
|
140
|
-
.command('ingest
|
|
141
|
-
.description('
|
|
140
|
+
.command('ingest [path]')
|
|
141
|
+
.description('Scan your system for skills and ingest them as drafts (or ingest a specific directory)')
|
|
142
142
|
.option('--bundle <name>', 'Group all detected skills into a single bundle draft')
|
|
143
|
-
.option('--dry-run', 'Show what would be
|
|
143
|
+
.option('--dry-run', 'Show what would be ingested without uploading')
|
|
144
144
|
.option('--from-tool <ecosystem>', `Treat every file in the path as belonging to a single ecosystem (one of: ${INGEST_SUPPORTED_TOOLS.join(', ')}). Useful when ingesting directly from ~/.claude/commands/, .cursor/rules/, etc.`)
|
|
145
|
+
.option('--auto', 'Skip the interactive selection and ingest everything discovery finds (zero-arg mode only)')
|
|
146
|
+
.option('--no-ink', 'Disable the interactive TUI; use plain output (zero-arg mode only)')
|
|
145
147
|
.action(async (sourcePath, opts) => {
|
|
146
|
-
|
|
148
|
+
// Commander's --no-ink convention sets opts.ink = false; flip to noInk
|
|
149
|
+
// so downstream consumers don't have to handle the inverted boolean.
|
|
150
|
+
const { ink, ...rest } = opts;
|
|
151
|
+
await ingest(sourcePath, { ...rest, noInk: ink === false, json: program.opts().json });
|
|
147
152
|
});
|
|
148
153
|
program
|
|
149
154
|
.command('compile <path>')
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** The set of ecosystems discovery can find on-disk. ChatGPT is intentionally
|
|
2
|
+
* omitted — it has no canonical install location (manual-paste in the install
|
|
3
|
+
* flow). The string identifiers mirror the canonical-path prefixes used by
|
|
4
|
+
* the publish/install machinery so converting back is a string operation. */
|
|
5
|
+
export type DiscoveryEcosystem = 'claude-code' | 'claude' | 'cursor' | 'codex' | 'copilot' | 'windsurf' | 'gemini' | 'antigravity' | 'opencode';
|
|
6
|
+
export interface DiscoveredSkillFile {
|
|
7
|
+
/** The ecosystem this file was discovered under. Drives canonical-filename
|
|
8
|
+
* mapping when we POST to /api/cli/ingest. */
|
|
9
|
+
ecosystem: DiscoveryEcosystem;
|
|
10
|
+
/** Absolute path on disk. */
|
|
11
|
+
sourcePath: string;
|
|
12
|
+
/** Slug derived from the filename (ext stripped, dir-extracted for nested
|
|
13
|
+
* SKILL.md). For `~/.claude/skills/<scope>/<slug>/SKILL.md`, the slug is
|
|
14
|
+
* `<slug>` (the leaf directory). */
|
|
15
|
+
slug: string;
|
|
16
|
+
/** Optional scope segment for nested ecosystems (currently only
|
|
17
|
+
* `claude` SKILL.md trees — `~/.claude/skills/<scope>/<slug>/SKILL.md`). */
|
|
18
|
+
scope?: string;
|
|
19
|
+
/** First H1 from the file contents, or a Title-Cased slug fallback. */
|
|
20
|
+
title: string;
|
|
21
|
+
/** File size in bytes. Used to default-uncheck tiny stub files. */
|
|
22
|
+
sizeBytes: number;
|
|
23
|
+
/** Line count of the file contents. Surfaced in the TUI alongside size. */
|
|
24
|
+
lineCount: number;
|
|
25
|
+
/** Raw file contents, kept in memory through the upload step. */
|
|
26
|
+
content: string;
|
|
27
|
+
/** Canonical filename for the upload payload — e.g.
|
|
28
|
+
* `claude-code/commands/foo.md`, `claude/<slug>/SKILL.md`, etc. */
|
|
29
|
+
canonicalFilename: string;
|
|
30
|
+
}
|
|
31
|
+
/** One row in the per-ecosystem scan table. Each row describes a single
|
|
32
|
+
* absolute directory to scan + how to convert each discovered file into a
|
|
33
|
+
* `DiscoveredSkillFile`. Derived from the shared `DETECTORS` table — see
|
|
34
|
+
* `buildDetectors` below for the fan-out logic. */
|
|
35
|
+
export interface DetectorRow {
|
|
36
|
+
ecosystem: DiscoveryEcosystem;
|
|
37
|
+
/** `'global'` paths live under HOME and always scan regardless of repo
|
|
38
|
+
* state; `'project'` paths live under CWD and only appear when CWD is a git
|
|
39
|
+
* repo (since they're per-repo settings). */
|
|
40
|
+
kind: 'global' | 'project';
|
|
41
|
+
/** Absolute directory to scan. */
|
|
42
|
+
dir: string;
|
|
43
|
+
/** When true, walk the directory tree recursively (used for claude skills
|
|
44
|
+
* which live one or two scope-segments deep). When false, only direct
|
|
45
|
+
* children are considered. Mirrors `DETECTORS.<ecosystem>.nested`. */
|
|
46
|
+
recursive: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Tiny H1 extractor — the same regex `ingest.ts` uses today. Falls back to a
|
|
49
|
+
* Title-Cased slug when no H1 is present. */
|
|
50
|
+
export declare function titleFromContent(content: string, slug: string): string;
|
|
51
|
+
/** Walk up from `start` looking for a `.git` directory. Returns true if any
|
|
52
|
+
* ancestor is a git repo. Used to gate project-scoped scans (cursor, codex,
|
|
53
|
+
* copilot, windsurf) which only make sense for repos. */
|
|
54
|
+
export declare function isGitRepo(start: string): boolean;
|
|
55
|
+
/** Build the per-row scan table for a given home + cwd by fanning out the
|
|
56
|
+
* shared `DETECTORS.<ecosystem>.scanPaths()` results. Project rows are
|
|
57
|
+
* naturally absent when `cwd` is outside a git repo because each project
|
|
58
|
+
* detector returns `[]` for that case.
|
|
59
|
+
*
|
|
60
|
+
* Split out as its own exported function so tests can assert the row shape
|
|
61
|
+
* without spinning up a fixture tree on disk. */
|
|
62
|
+
export declare function buildDetectors(homeDir: string, cwd: string): DetectorRow[];
|
|
63
|
+
export interface DiscoveryResult {
|
|
64
|
+
/** All discovered files, flattened across ecosystems. */
|
|
65
|
+
files: DiscoveredSkillFile[];
|
|
66
|
+
/** Ecosystems we attempted to scan but found nothing for. Used by the TUI
|
|
67
|
+
* to render a "Other tools: 0 skills found (...)" trailing line. */
|
|
68
|
+
emptyEcosystems: DiscoveryEcosystem[];
|
|
69
|
+
/** True when CWD is inside a git repo. The TUI uses this only for messaging
|
|
70
|
+
* — the gate itself is applied inside `buildDetectors`. */
|
|
71
|
+
inGitRepo: boolean;
|
|
72
|
+
}
|
|
73
|
+
export interface DiscoveryOptions {
|
|
74
|
+
/** Override $HOME. Defaults to `os.homedir()`. */
|
|
75
|
+
homeDir?: string;
|
|
76
|
+
/** Override cwd. Defaults to `process.cwd()`. */
|
|
77
|
+
cwd?: string;
|
|
78
|
+
}
|
|
79
|
+
/** Scan all known on-disk locations and return a flat list of discoveries.
|
|
80
|
+
* Reads file contents synchronously — discovery is one-shot and typically
|
|
81
|
+
* touches a handful of small files, so the simplicity wins over async sprawl.
|
|
82
|
+
*
|
|
83
|
+
* Single source of truth for which ecosystems we know about is the shared
|
|
84
|
+
* `DETECTORS` table in `commands/ingest.ts`. Each detector's `scanPaths`
|
|
85
|
+
* decides where to look on disk, `slugFor` extracts the slug, and
|
|
86
|
+
* `canonicalFilename` produces the upload-shaped filename. */
|
|
87
|
+
export declare function discoverSkills(options?: DiscoveryOptions): DiscoveryResult;
|
|
88
|
+
/** Human-readable label per ecosystem for the TUI section header. */
|
|
89
|
+
export declare function ecosystemLabel(ecosystem: DiscoveryEcosystem): string;
|
|
90
|
+
/** Format a byte count as "0.1 KB" / "2.4 KB" etc. — matches the spec sample.
|
|
91
|
+
* Single decimal place keeps narrow rows aligned in the TUI. */
|
|
92
|
+
export declare function formatBytes(n: number): string;
|
|
93
|
+
/** Files smaller than this are unchecked by default (likely stub files the
|
|
94
|
+
* user hasn't filled in yet). 100 bytes ≈ a one-line H1 plus a blank line. */
|
|
95
|
+
export declare const STUB_BYTE_THRESHOLD = 100;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DETECTORS } from '../commands/ingest.js';
|
|
5
|
+
/** Tiny H1 extractor — the same regex `ingest.ts` uses today. Falls back to a
|
|
6
|
+
* Title-Cased slug when no H1 is present. */
|
|
7
|
+
export function titleFromContent(content, slug) {
|
|
8
|
+
const m = content.match(/^#\s+(.+)$/m);
|
|
9
|
+
return m?.[1]?.trim() ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
10
|
+
}
|
|
11
|
+
/** Walk up from `start` looking for a `.git` directory. Returns true if any
|
|
12
|
+
* ancestor is a git repo. Used to gate project-scoped scans (cursor, codex,
|
|
13
|
+
* copilot, windsurf) which only make sense for repos. */
|
|
14
|
+
export function isGitRepo(start) {
|
|
15
|
+
let dir = start;
|
|
16
|
+
// Guard against an infinite loop on platforms where dirname(/) === /
|
|
17
|
+
for (let i = 0; i < 64; i++) {
|
|
18
|
+
const candidate = path.join(dir, '.git');
|
|
19
|
+
try {
|
|
20
|
+
if (fs.existsSync(candidate))
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Permission denied on some ancestor — treat as not-a-repo and keep walking
|
|
25
|
+
}
|
|
26
|
+
const parent = path.dirname(dir);
|
|
27
|
+
if (parent === dir)
|
|
28
|
+
return false;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
/** Build the per-row scan table for a given home + cwd by fanning out the
|
|
34
|
+
* shared `DETECTORS.<ecosystem>.scanPaths()` results. Project rows are
|
|
35
|
+
* naturally absent when `cwd` is outside a git repo because each project
|
|
36
|
+
* detector returns `[]` for that case.
|
|
37
|
+
*
|
|
38
|
+
* Split out as its own exported function so tests can assert the row shape
|
|
39
|
+
* without spinning up a fixture tree on disk. */
|
|
40
|
+
export function buildDetectors(homeDir, cwd) {
|
|
41
|
+
const inRepo = isGitRepo(cwd);
|
|
42
|
+
const rows = [];
|
|
43
|
+
for (const [ecosystem, detector] of Object.entries(DETECTORS)) {
|
|
44
|
+
const paths = detector.scanPaths(homeDir, cwd, inRepo);
|
|
45
|
+
if (paths.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
// Anything under homeDir is "global"; anything else is "project". This
|
|
48
|
+
// mirrors how each detector's scanPaths was authored — global paths use
|
|
49
|
+
// homeDir, project paths use projectRoot.
|
|
50
|
+
for (const dir of paths) {
|
|
51
|
+
const kind = dir.startsWith(homeDir) ? 'global' : 'project';
|
|
52
|
+
rows.push({
|
|
53
|
+
ecosystem: ecosystem,
|
|
54
|
+
kind,
|
|
55
|
+
dir,
|
|
56
|
+
recursive: detector.nested,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
|
62
|
+
/** Recursively walk a directory collecting absolute paths. Returns an empty
|
|
63
|
+
* list if the directory doesn't exist. Caller decides how to filter results. */
|
|
64
|
+
function walkAll(dir, recursive) {
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const abs = path.join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
if (!recursive)
|
|
77
|
+
continue;
|
|
78
|
+
// Recurse but skip noisy dirs (node_modules etc. are unlikely under tool
|
|
79
|
+
// config trees but cheap to defend against).
|
|
80
|
+
if (entry.name === 'node_modules' || entry.name === '.git')
|
|
81
|
+
continue;
|
|
82
|
+
out.push(...walkAll(abs, true));
|
|
83
|
+
}
|
|
84
|
+
else if (entry.isFile()) {
|
|
85
|
+
out.push(abs);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** For nested ecosystems, the scope is the directory segment immediately
|
|
91
|
+
* under the scan directory that contains the slug directory. For unscoped
|
|
92
|
+
* layouts (`<scanDir>/<slug>/SKILL.md`), the parent of the slug dir IS the
|
|
93
|
+
* scan dir and there's no scope. Returns undefined when no scope applies. */
|
|
94
|
+
function scopeForNested(absPath, scanDir) {
|
|
95
|
+
// absPath looks like `<scanDir>/.../<scope?>/<slug>/SKILL.md`. The slug dir
|
|
96
|
+
// is the parent; the scope dir (if any) is the grandparent.
|
|
97
|
+
const slugDir = path.dirname(absPath);
|
|
98
|
+
const candidateScopeDir = path.dirname(slugDir);
|
|
99
|
+
if (candidateScopeDir === scanDir)
|
|
100
|
+
return undefined;
|
|
101
|
+
return path.basename(candidateScopeDir);
|
|
102
|
+
}
|
|
103
|
+
/** Scan all known on-disk locations and return a flat list of discoveries.
|
|
104
|
+
* Reads file contents synchronously — discovery is one-shot and typically
|
|
105
|
+
* touches a handful of small files, so the simplicity wins over async sprawl.
|
|
106
|
+
*
|
|
107
|
+
* Single source of truth for which ecosystems we know about is the shared
|
|
108
|
+
* `DETECTORS` table in `commands/ingest.ts`. Each detector's `scanPaths`
|
|
109
|
+
* decides where to look on disk, `slugFor` extracts the slug, and
|
|
110
|
+
* `canonicalFilename` produces the upload-shaped filename. */
|
|
111
|
+
export function discoverSkills(options = {}) {
|
|
112
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
113
|
+
const cwd = options.cwd ?? process.cwd();
|
|
114
|
+
const rows = buildDetectors(homeDir, cwd);
|
|
115
|
+
const files = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const empty = new Set();
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
const detector = DETECTORS[row.ecosystem];
|
|
120
|
+
const candidates = walkAll(row.dir, row.recursive);
|
|
121
|
+
// Filter to files this detector would accept. We pass the scan dir as the
|
|
122
|
+
// "root" so `slugFor` can do path-relative work — same convention the
|
|
123
|
+
// path-based ingest path uses, with `scanDir` standing in for the source
|
|
124
|
+
// tree root.
|
|
125
|
+
const matched = candidates.filter((abs) => detector.extensions.some((ext) => abs.endsWith(ext)));
|
|
126
|
+
if (matched.length === 0) {
|
|
127
|
+
if (!seen.has(row.ecosystem))
|
|
128
|
+
empty.add(row.ecosystem);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const abs of matched) {
|
|
132
|
+
const slug = detector.slugFor(abs, row.dir);
|
|
133
|
+
if (!slug)
|
|
134
|
+
continue;
|
|
135
|
+
let content;
|
|
136
|
+
try {
|
|
137
|
+
content = fs.readFileSync(abs, 'utf-8');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Unreadable file — skip silently. Discovery should never throw on
|
|
141
|
+
// permission errors; users with unusual setups still get the rest.
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const scope = detector.nested ? scopeForNested(abs, row.dir) : undefined;
|
|
145
|
+
const sizeBytes = Buffer.byteLength(content, 'utf-8');
|
|
146
|
+
const lineCount = content.length === 0 ? 0 : content.split(/\r?\n/).length;
|
|
147
|
+
files.push({
|
|
148
|
+
ecosystem: row.ecosystem,
|
|
149
|
+
sourcePath: abs,
|
|
150
|
+
slug,
|
|
151
|
+
scope,
|
|
152
|
+
title: titleFromContent(content, slug),
|
|
153
|
+
sizeBytes,
|
|
154
|
+
lineCount,
|
|
155
|
+
content,
|
|
156
|
+
canonicalFilename: detector.canonicalFilename(slug),
|
|
157
|
+
});
|
|
158
|
+
seen.add(row.ecosystem);
|
|
159
|
+
empty.delete(row.ecosystem);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Sort stable: by ecosystem, then by display label (scope/slug) so the TUI
|
|
163
|
+
// and dry-run output are deterministic across runs.
|
|
164
|
+
files.sort((a, b) => {
|
|
165
|
+
if (a.ecosystem !== b.ecosystem)
|
|
166
|
+
return a.ecosystem.localeCompare(b.ecosystem);
|
|
167
|
+
const aLabel = a.scope ? `${a.scope}/${a.slug}` : a.slug;
|
|
168
|
+
const bLabel = b.scope ? `${b.scope}/${b.slug}` : b.slug;
|
|
169
|
+
return aLabel.localeCompare(bLabel);
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
files,
|
|
173
|
+
emptyEcosystems: [...empty].sort(),
|
|
174
|
+
inGitRepo: isGitRepo(cwd),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Human-readable label per ecosystem for the TUI section header. */
|
|
178
|
+
export function ecosystemLabel(ecosystem) {
|
|
179
|
+
switch (ecosystem) {
|
|
180
|
+
case 'claude-code':
|
|
181
|
+
return 'Claude Code';
|
|
182
|
+
case 'claude':
|
|
183
|
+
return 'Claude skills';
|
|
184
|
+
case 'cursor':
|
|
185
|
+
return 'Cursor rules';
|
|
186
|
+
case 'codex':
|
|
187
|
+
return 'Codex';
|
|
188
|
+
case 'copilot':
|
|
189
|
+
return 'GitHub Copilot';
|
|
190
|
+
case 'windsurf':
|
|
191
|
+
return 'Windsurf';
|
|
192
|
+
case 'gemini':
|
|
193
|
+
return 'Gemini CLI';
|
|
194
|
+
case 'antigravity':
|
|
195
|
+
return 'Antigravity';
|
|
196
|
+
case 'opencode':
|
|
197
|
+
return 'OpenCode';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Format a byte count as "0.1 KB" / "2.4 KB" etc. — matches the spec sample.
|
|
201
|
+
* Single decimal place keeps narrow rows aligned in the TUI. */
|
|
202
|
+
export function formatBytes(n) {
|
|
203
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
204
|
+
}
|
|
205
|
+
/** Files smaller than this are unchecked by default (likely stub files the
|
|
206
|
+
* user hasn't filled in yet). 100 bytes ≈ a one-line H1 plus a blank line. */
|
|
207
|
+
export const STUB_BYTE_THRESHOLD = 100;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botdocs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "CLI for BotDocs — author, publish, install, and sync agent skills across Claude, Claude Code, Cursor, Codex, ChatGPT, Windsurf, Copilot, Gemini, Antigravity, and OpenCode.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"botdocs",
|
package/templates/agents.md
CHANGED
|
@@ -129,7 +129,7 @@ the user and recommend they set one.
|
|
|
129
129
|
| `backups list / restore / diff / clear` | Browse and manage backup runs. |
|
|
130
130
|
| `compile <path>` | Generate per-ecosystem drafts from a canonical source (BYOK). |
|
|
131
131
|
| `edit <ref>` | LLM-assisted revision of a published skill ecosystem file (BYOK). |
|
|
132
|
-
| `ingest
|
|
132
|
+
| `ingest [path]` | Scan your system (zero-arg) or a directory, detect existing skills across all 10 ecosystems, upload as drafts. Use `--from-tool=<ecosystem>` for real on-disk locations like `~/.claude/commands/`; pass `--auto` to skip the interactive selection. |
|
|
133
133
|
| `team list` | List teams you belong to. |
|
|
134
134
|
| `team show <slug>` | Members + pinned skills for a team. |
|
|
135
135
|
| `team push <slug> <ref>` | Pin a skill to a team (WRITE+). |
|