@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 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 <path>` | Walk a directory, detect existing skills, upload as drafts. |
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 {};
@@ -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
- function titleFromContent(content, slug) {
161
- const m = content.match(/^#\s+(.+)$/m);
162
- return m?.[1]?.trim() ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
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
- const result = await apiFetch('/api/cli/ingest', {
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 <path>')
141
- .description('Walk a directory of existing skill files and create drafts in your BotDocs account for review')
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 detected without uploading')
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
- await ingest(sourcePath, { ...opts, json: program.opts().json });
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.5.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",
@@ -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 <path>` | Walk a directory, detect existing skills across all 10 ecosystems, upload as drafts. Use `--from-tool=<ecosystem>` to ingest from real on-disk locations like `~/.claude/commands/` or `.cursor/rules/`. |
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+). |