@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.
@@ -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 (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.'))
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
- 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());
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
- detected.push(d);
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
- 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`);
727
+ await uploadSkills(skills, options);
235
728
  }
@@ -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) {