@botdocs/cli 0.6.0 → 0.8.1

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.
@@ -3,8 +3,181 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { render } from 'ink';
5
5
  import { apiFetch } from '../lib/api.js';
6
- import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, titleFromContent, } from '../lib/ingest-discover.js';
6
+ import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, titleFromContent, } from '../lib/ingest-discover.js';
7
7
  import { IngestDiscoverApp } from './views/ingest-discover-app.js';
8
+ // ---------- Cap + skip rules (shared with discovery) ----------
9
+ //
10
+ // Caps are tight by design — Claude skills routinely fit well under these.
11
+ // Enforced both here (CLI) and server-side (defense in depth).
12
+ /** Per-file size cap. Any file larger than this is skipped with a warning. */
13
+ export const PER_FILE_BYTE_CAP = 64 * 1024; // 64 KB
14
+ /** Total bytes across all files in a single skill. */
15
+ export const PER_SKILL_BYTE_CAP = 512 * 1024; // 512 KB
16
+ /** Total file count in a single skill. */
17
+ export const PER_SKILL_FILE_CAP = 25;
18
+ /** Directories the sweep recurses INTO. Anything else is silently ignored.
19
+ * The set is duplicated (not imported) from `walkFiles` so it stays in sync
20
+ * with what the path-based walker already filters out. */
21
+ const SWEEP_SKIP_DIRS = new Set([
22
+ 'node_modules',
23
+ '.git',
24
+ 'dist',
25
+ 'build',
26
+ '.next',
27
+ '.turbo',
28
+ '__pycache__',
29
+ 'venv',
30
+ '.venv',
31
+ ]);
32
+ /** Filenames + globs that are uninteresting noise. `.log` and `.lock` are
33
+ * matched via endsWith, not regex, to keep the check cheap. */
34
+ function isSkippedFileName(name) {
35
+ if (name === '.DS_Store')
36
+ return true;
37
+ if (name.endsWith('.log'))
38
+ return true;
39
+ if (name.endsWith('.lock'))
40
+ return true;
41
+ return false;
42
+ }
43
+ /** Binary detection: read the first 8 KB and look for a null byte. Matches
44
+ * the same heuristic git uses. Cheap and good enough — we'd rather refuse a
45
+ * borderline file than try to base64 it. */
46
+ function looksBinary(absPath) {
47
+ const buf = Buffer.alloc(8192);
48
+ let fd = null;
49
+ try {
50
+ fd = fs.openSync(absPath, 'r');
51
+ const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
52
+ for (let i = 0; i < bytesRead; i++) {
53
+ if (buf[i] === 0)
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ catch {
59
+ // Unreadable — treat as binary so we skip it with a warning rather than
60
+ // crashing the whole ingest.
61
+ return true;
62
+ }
63
+ finally {
64
+ if (fd !== null) {
65
+ try {
66
+ fs.closeSync(fd);
67
+ }
68
+ catch {
69
+ /* ignore */
70
+ }
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Walk a skill root recursively and return every non-skipped file the caller
76
+ * should ingest as an adjacent file. Applies dir-skip, dotfile, binary, and
77
+ * cap rules. Stops collecting once total caps are exceeded — remaining files
78
+ * land in `warnings`. The root file (e.g. SKILL.md) is excluded so the caller
79
+ * can handle it separately.
80
+ */
81
+ export function sweepSkillRoot(skillRoot, rootFileAbs) {
82
+ const files = [];
83
+ const warnings = [];
84
+ let totalBytes = 0;
85
+ function walk(dir) {
86
+ let entries;
87
+ try {
88
+ entries = fs.readdirSync(dir, { withFileTypes: true });
89
+ }
90
+ catch {
91
+ return;
92
+ }
93
+ for (const entry of entries) {
94
+ const abs = path.join(dir, entry.name);
95
+ if (entry.isDirectory()) {
96
+ if (SWEEP_SKIP_DIRS.has(entry.name))
97
+ continue;
98
+ if (entry.name.startsWith('.'))
99
+ continue;
100
+ walk(abs);
101
+ continue;
102
+ }
103
+ if (!entry.isFile())
104
+ continue;
105
+ if (abs === rootFileAbs)
106
+ continue;
107
+ if (entry.name.startsWith('.'))
108
+ continue;
109
+ if (isSkippedFileName(entry.name))
110
+ continue;
111
+ const relPath = path.relative(skillRoot, abs).split(path.sep).join('/');
112
+ let stat;
113
+ try {
114
+ stat = fs.statSync(abs);
115
+ }
116
+ catch {
117
+ warnings.push({ relPath, reason: 'binary' });
118
+ continue;
119
+ }
120
+ if (stat.size > PER_FILE_BYTE_CAP) {
121
+ warnings.push({ relPath, reason: 'oversize' });
122
+ continue;
123
+ }
124
+ if (looksBinary(abs)) {
125
+ warnings.push({ relPath, reason: 'binary' });
126
+ continue;
127
+ }
128
+ // Total-byte cap is checked after the per-file gate so a single huge
129
+ // file doesn't blow past the skill cap before we've read it.
130
+ if (totalBytes + stat.size > PER_SKILL_BYTE_CAP) {
131
+ warnings.push({ relPath, reason: 'cap-total-size' });
132
+ continue;
133
+ }
134
+ // -1 here because we count the root file in the caller's accounting,
135
+ // but exclude it from this sweep. Cap is "total files per skill".
136
+ if (files.length + 1 >= PER_SKILL_FILE_CAP) {
137
+ warnings.push({ relPath, reason: 'cap-file-count' });
138
+ continue;
139
+ }
140
+ let content;
141
+ try {
142
+ content = fs.readFileSync(abs, 'utf-8');
143
+ }
144
+ catch {
145
+ warnings.push({ relPath, reason: 'binary' });
146
+ continue;
147
+ }
148
+ files.push({
149
+ absPath: abs,
150
+ relPath,
151
+ content,
152
+ mode: stat.mode & 0o777,
153
+ sizeBytes: stat.size,
154
+ });
155
+ totalBytes += stat.size;
156
+ }
157
+ }
158
+ walk(skillRoot);
159
+ return { files, warnings };
160
+ }
161
+ /** Format a sweep warning summary line for a single skill. Empty string when
162
+ * there are no warnings — caller can use that to suppress the line. */
163
+ export function formatSweepWarnings(slug, warnings) {
164
+ if (warnings.length === 0)
165
+ return '';
166
+ const binary = warnings.filter((w) => w.reason === 'binary').map((w) => w.relPath);
167
+ const oversize = warnings.filter((w) => w.reason === 'oversize').map((w) => w.relPath);
168
+ const totalCap = warnings.filter((w) => w.reason === 'cap-total-size').map((w) => w.relPath);
169
+ const fileCap = warnings.filter((w) => w.reason === 'cap-file-count').map((w) => w.relPath);
170
+ const parts = [];
171
+ if (binary.length)
172
+ parts.push(`${binary.length} binary (${binary.slice(0, 3).join(', ')}${binary.length > 3 ? '…' : ''})`);
173
+ if (oversize.length)
174
+ parts.push(`${oversize.length} over 64KB (${oversize.slice(0, 3).join(', ')}${oversize.length > 3 ? '…' : ''})`);
175
+ if (totalCap.length)
176
+ parts.push(`${totalCap.length} past 512KB total cap`);
177
+ if (fileCap.length)
178
+ parts.push(`${fileCap.length} past 25-file cap`);
179
+ return ` ⚠ ${slug}: skipped ${warnings.length} file(s) — ${parts.join('; ')}`;
180
+ }
8
181
  /**
9
182
  * Single source of truth for ecosystem detection. New ecosystems should be
10
183
  * added here — both auto-detect mode and future `--from-tool` mode consult
@@ -29,6 +202,11 @@ export const DETECTORS = {
29
202
  // Discovery walks ~/.claude/skills recursively — files live one or two
30
203
  // segments deep (`<slug>/SKILL.md` or `<scope>/<slug>/SKILL.md`).
31
204
  scanPaths: (homeDir) => [path.join(homeDir, '.claude', 'skills')],
205
+ // Marquee case for adjacent-file sweep: real Claude skills bundle
206
+ // scripts/, templates/, and reference markdown next to SKILL.md.
207
+ includeAdjacent: true,
208
+ skillRoot: (abs) => path.dirname(abs),
209
+ canonicalAdjacentFilename: (slug, relPath) => `claude/${slug}/${relPath}`,
32
210
  },
33
211
  'claude-code': {
34
212
  pathPrefix: 'claude-code/commands/',
@@ -44,6 +222,44 @@ export const DETECTORS = {
44
222
  paths.push(path.join(projectRoot, '.claude', 'commands'));
45
223
  return paths;
46
224
  },
225
+ // Commands are FLAT today — every .md in ~/.claude/commands/ is its own
226
+ // skill, with no enclosing directory. That means there's no meaningful
227
+ // "skill root" to sweep: the dirname of a command is shared with all
228
+ // other commands, and walking it would produce false-positive adjacent
229
+ // files. If Anthropic ever adds a directory layout for commands we'll
230
+ // flip this back to `true` and add the appropriate skillRoot logic.
231
+ includeAdjacent: false,
232
+ },
233
+ 'claude-code-agents': {
234
+ // Anthropic's convention today is single-file agents at
235
+ // `.claude/agents/<name>.md`. For BotDocs multi-file agents we expect a
236
+ // directory: `.claude/agents/<name>/AGENT.md` + adjacent files. The
237
+ // detector matches `**/AGENT.md` under `claude-code/agents/`; the slug
238
+ // is the parent dir of AGENT.md.
239
+ pathPrefix: 'claude-code/agents/',
240
+ extensions: ['/AGENT.md'],
241
+ nested: true,
242
+ slugFor: (abs, root) => {
243
+ const rel = path.relative(root, abs).split(path.sep).join('/');
244
+ if (!rel.endsWith('/AGENT.md'))
245
+ return null;
246
+ const parts = rel.split('/');
247
+ if (parts.length < 2)
248
+ return null;
249
+ return parts[parts.length - 2] ?? null;
250
+ },
251
+ canonicalFilename: (slug) => `claude-code/agents/${slug}/AGENT.md`,
252
+ // Mirror claude-code commands: global ~/.claude/agents always, project
253
+ // .claude/agents only inside a git repo.
254
+ scanPaths: (homeDir, projectRoot, isGitRepo) => {
255
+ const paths = [path.join(homeDir, '.claude', 'agents')];
256
+ if (isGitRepo)
257
+ paths.push(path.join(projectRoot, '.claude', 'agents'));
258
+ return paths;
259
+ },
260
+ includeAdjacent: true,
261
+ skillRoot: (abs) => path.dirname(abs),
262
+ canonicalAdjacentFilename: (slug, relPath) => `claude-code/agents/${slug}/${relPath}`,
47
263
  },
48
264
  cursor: {
49
265
  pathPrefix: 'cursor/rules/',
@@ -113,13 +329,12 @@ export const DETECTORS = {
113
329
  },
114
330
  };
115
331
  export const SUPPORTED_TOOLS = Object.keys(DETECTORS);
116
- const IGNORED_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.turbo']);
117
332
  function walkFiles(root) {
118
333
  const out = [];
119
334
  function walk(dir) {
120
335
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
121
336
  if (entry.isDirectory()) {
122
- if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.'))
337
+ if (SWEEP_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
123
338
  continue;
124
339
  walk(path.join(dir, entry.name));
125
340
  }
@@ -131,6 +346,16 @@ function walkFiles(root) {
131
346
  walk(root);
132
347
  return out;
133
348
  }
349
+ /** Stat helper that masks mode to the low 9 bits (rwx for owner/group/other).
350
+ * Falls back to 0o644 on error so we never block ingest on a stat failure. */
351
+ function safeMode(absPath) {
352
+ try {
353
+ return fs.statSync(absPath).mode & 0o777;
354
+ }
355
+ catch {
356
+ return 0o644;
357
+ }
358
+ }
134
359
  /** Does the filename end with any of the detector's declared extensions? */
135
360
  function matchesExtension(absPath, detector) {
136
361
  return detector.extensions.some((ext) => absPath.endsWith(ext));
@@ -154,6 +379,7 @@ function detectAuto(absPath, root, content) {
154
379
  content,
155
380
  ecosystem,
156
381
  slug,
382
+ mode: safeMode(absPath),
157
383
  };
158
384
  }
159
385
  return null;
@@ -179,8 +405,29 @@ function detectFromTool(absPath, root, content, ecosystem) {
179
405
  content,
180
406
  ecosystem,
181
407
  slug,
408
+ mode: safeMode(absPath),
182
409
  };
183
410
  }
411
+ /**
412
+ * Sweep adjacent files when a detector opts in. Returns a list of
413
+ * `DetectedFile` for each non-skipped sibling file, plus warnings the caller
414
+ * surfaces inline. The root file itself is excluded — caller already has it.
415
+ */
416
+ function adjacentFilesFor(detector, ecosystem, slug, rootFileAbs) {
417
+ if (!detector.includeAdjacent || !detector.skillRoot || !detector.canonicalAdjacentFilename) {
418
+ return { files: [], warnings: [] };
419
+ }
420
+ const root = detector.skillRoot(rootFileAbs);
421
+ const result = sweepSkillRoot(root, rootFileAbs);
422
+ const files = result.files.map((f) => ({
423
+ filename: detector.canonicalAdjacentFilename(slug, f.relPath),
424
+ content: f.content,
425
+ ecosystem,
426
+ slug,
427
+ mode: f.mode,
428
+ }));
429
+ return { files, warnings: result.warnings };
430
+ }
184
431
  /** Group a list of discovered files into the `DetectedSkill[]` shape the
185
432
  * `/api/cli/ingest` endpoint expects. Multiple discoveries with the same slug
186
433
  * collapse into one logical skill with multiple files — same convention as
@@ -200,6 +447,7 @@ function groupDiscoveredIntoSkills(discovered) {
200
447
  grouped.get(f.slug).files.push({
201
448
  filename: f.canonicalFilename,
202
449
  content: f.content,
450
+ mode: f.mode,
203
451
  });
204
452
  }
205
453
  return [...grouped.values()];
@@ -219,24 +467,58 @@ async function uploadSkills(skills, options) {
219
467
  }
220
468
  console.log(`\n ✓ Drafts created. Review at:\n https://botdocs.ai${result.reviewUrl}\n`);
221
469
  }
470
+ /** Returns true if this file is the "root" for its skill — the file the user
471
+ * sees as a checkbox row in the TUI / a bullet in plain-text output. Adjacent
472
+ * files (scripts/, templates/, etc.) ride along with the root's selection. */
473
+ function isRoot(file) {
474
+ // `isRoot` is set authoritatively at discovery time (rootFile = true,
475
+ // swept adjacent files = false), so there's no filename guessing here.
476
+ return file.isRoot;
477
+ }
478
+ /** Filter a discovery file list down to the set the `--auto` / stub filter
479
+ * would actually upload: only ROOT files smaller than the stub threshold are
480
+ * dropped. Adjacent files ride along when their root is eligible. */
481
+ function applyStubFilter(files) {
482
+ const eligibleKeys = new Set();
483
+ for (const f of files) {
484
+ if (!isRoot(f))
485
+ continue;
486
+ if (f.sizeBytes >= STUB_BYTE_THRESHOLD)
487
+ eligibleKeys.add(summaryKey(f));
488
+ }
489
+ return files.filter((f) => eligibleKeys.has(summaryKey(f)));
490
+ }
222
491
  /** Render a sectioned plain-text listing of discoveries. Reused for both
223
- * `--dry-run` output and the non-TTY fallback. */
224
- function printDiscoveryPlainText(discovered) {
492
+ * `--dry-run` output and the non-TTY fallback. Adjacent files are not listed
493
+ * row-by-row — the per-skill aggregate `(SKILL.md + N)` is enough signal. */
494
+ function printDiscoveryPlainText(discovered, skillSummaries) {
495
+ const rootFiles = discovered.filter(isRoot);
225
496
  const byEcosystem = new Map();
226
- for (const d of discovered) {
497
+ for (const d of rootFiles) {
227
498
  const list = byEcosystem.get(d.ecosystem) ?? [];
228
499
  list.push(d);
229
500
  byEcosystem.set(d.ecosystem, list);
230
501
  }
231
502
  console.log('');
232
- console.log(` Found ${discovered.length} skill(s) across ${byEcosystem.size} tool(s):`);
503
+ console.log(` Found ${rootFiles.length} skill(s) across ${byEcosystem.size} tool(s):`);
233
504
  for (const [eco, list] of byEcosystem) {
234
505
  console.log('');
235
506
  console.log(` ${ecosystemLabel(eco)}:`);
236
507
  for (const f of list) {
237
508
  const label = f.scope ? `${f.scope}/${f.slug}` : f.slug;
238
509
  const stub = f.sizeBytes < STUB_BYTE_THRESHOLD ? ' (stub — excluded by default)' : '';
239
- console.log(` • ${label} ${formatBytes(f.sizeBytes)} · ${f.lineCount} lines${stub}`);
510
+ const summary = skillSummaries?.get(summaryKey(f));
511
+ const details = summary && summary.totalFiles > 1
512
+ ? `${formatBytes(summary.totalBytes)} · ${summary.totalFiles} files`
513
+ : `${formatBytes(f.sizeBytes)} · ${f.lineCount} lines`;
514
+ console.log(` • ${label} ${details}${stub}`);
515
+ // Render any sweep warnings as a sub-bullet under the skill — keeps
516
+ // the per-ecosystem section readable.
517
+ if (summary && summary.warnings.length > 0) {
518
+ const line = formatSweepWarnings(label, summary.warnings);
519
+ if (line)
520
+ console.log(` ${line.trimStart()}`);
521
+ }
240
522
  }
241
523
  }
242
524
  console.log('');
@@ -254,16 +536,26 @@ async function ingestDiscover(options) {
254
536
  // consume it. Includes per-file metadata; content is omitted to keep the
255
537
  // payload small — callers can re-run with a path to actually upload.
256
538
  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
- }));
539
+ // Only emit root files (one per skill). Adjacent files are aggregated
540
+ // into `totalFiles` / `totalBytes` so the JSON consumer sees the same
541
+ // shape the TUI shows.
542
+ const payload = discovery.files
543
+ .filter(isRoot)
544
+ .map((f) => {
545
+ const summary = discovery.skillSummaries.get(summaryKey(f));
546
+ return {
547
+ ecosystem: f.ecosystem,
548
+ slug: f.slug,
549
+ scope: f.scope,
550
+ title: f.title,
551
+ sourcePath: f.sourcePath,
552
+ sizeBytes: f.sizeBytes,
553
+ lineCount: f.lineCount,
554
+ canonicalFilename: f.canonicalFilename,
555
+ totalFiles: summary?.totalFiles ?? 1,
556
+ totalBytes: summary?.totalBytes ?? f.sizeBytes,
557
+ };
558
+ });
267
559
  console.log(JSON.stringify({ discovered: payload, emptyEcosystems: discovery.emptyEcosystems }));
268
560
  return;
269
561
  }
@@ -271,22 +563,24 @@ async function ingestDiscover(options) {
271
563
  // (stubs excluded). Helps the user verify before re-running without
272
564
  // --dry-run.
273
565
  if (options.dryRun && options.auto) {
274
- const eligible = discovery.files.filter((f) => f.sizeBytes >= STUB_BYTE_THRESHOLD);
275
- printDiscoveryPlainText(eligible);
566
+ const eligible = applyStubFilter(discovery.files);
567
+ printDiscoveryPlainText(eligible, discovery.skillSummaries);
276
568
  console.log(' --dry-run --auto: would upload the above (stubs excluded). Not uploading.\n');
277
569
  return;
278
570
  }
279
571
  // --dry-run alone: list ALL discoveries in plain text, never upload, never
280
572
  // TUI. Stubs are marked inline so the user knows they'd be excluded.
281
573
  if (options.dryRun) {
282
- printDiscoveryPlainText(discovery.files);
574
+ printDiscoveryPlainText(discovery.files, discovery.skillSummaries);
283
575
  console.log(' --dry-run: not uploading.\n');
284
576
  return;
285
577
  }
286
578
  // --auto: skip the TUI and upload everything matching the defaults (stubs
287
- // < STUB_BYTE_THRESHOLD bytes excluded — same rule the TUI uses).
579
+ // < STUB_BYTE_THRESHOLD bytes excluded — same rule the TUI uses). The
580
+ // stub filter looks at the root file only; adjacent files always ride
581
+ // along when their root is selected.
288
582
  if (options.auto) {
289
- const eligible = discovery.files.filter((f) => f.sizeBytes >= STUB_BYTE_THRESHOLD);
583
+ const eligible = applyStubFilter(discovery.files);
290
584
  if (eligible.length === 0) {
291
585
  console.log('\n No skills found (all discovered files are < 100-byte stubs). Inspect with `--dry-run`.\n');
292
586
  return;
@@ -295,6 +589,15 @@ async function ingestDiscover(options) {
295
589
  console.log(`\n ✓ Uploading ${skills.length} skill(s) found by discovery (--auto):`);
296
590
  for (const s of skills)
297
591
  console.log(` • ${s.slug} (${s.files.length} file(s))`);
592
+ // Surface any sweep warnings inline so the user knows what got dropped.
593
+ for (const [, summary] of discovery.skillSummaries) {
594
+ if (summary.warnings.length === 0)
595
+ continue;
596
+ const label = summary.scope ? `${summary.scope}/${summary.slug}` : summary.slug;
597
+ const line = formatSweepWarnings(label, summary.warnings);
598
+ if (line)
599
+ console.log(line);
600
+ }
298
601
  await uploadSkills(skills, options);
299
602
  return;
300
603
  }
@@ -304,7 +607,7 @@ async function ingestDiscover(options) {
304
607
  // do NOT upload silently here — interactive consent matters. The user
305
608
  // can re-run with `--json` for machine-readable output or with `--auto`
306
609
  // for one-shot mode.
307
- printDiscoveryPlainText(discovery.files);
610
+ printDiscoveryPlainText(discovery.files, discovery.skillSummaries);
308
611
  console.log(' Run with --json for machine-readable output, or in a real terminal for interactive selection.\n');
309
612
  return;
310
613
  }
@@ -317,6 +620,7 @@ async function ingestDiscover(options) {
317
620
  };
318
621
  const instance = render(React.createElement(IngestDiscoverApp, {
319
622
  files: discovery.files,
623
+ skillSummaries: discovery.skillSummaries,
320
624
  emptyEcosystems: discovery.emptyEcosystems,
321
625
  onDone: (r) => {
322
626
  resultHolder.value = r;
@@ -357,13 +661,24 @@ export async function ingest(rootPath, options) {
357
661
  return;
358
662
  }
359
663
  const detected = [];
664
+ const sweepWarnings = [];
360
665
  for (const filePath of walkFiles(root)) {
361
666
  const content = fs.readFileSync(filePath, 'utf-8');
362
667
  const d = options.fromTool
363
668
  ? detectFromTool(filePath, root, content, options.fromTool)
364
669
  : detectAuto(filePath, root, content);
365
- if (d)
366
- detected.push(d);
670
+ if (!d)
671
+ continue;
672
+ detected.push(d);
673
+ // Sweep adjacent files when the detector opts in (claude SKILL.md, the
674
+ // new claude-code-agents AGENT.md). The root file we already pushed —
675
+ // this catches scripts/, templates/, reference docs, etc.
676
+ const detector = DETECTORS[d.ecosystem];
677
+ const { files: adjacent, warnings } = adjacentFilesFor(detector, d.ecosystem, d.slug, filePath);
678
+ detected.push(...adjacent);
679
+ const warningLine = formatSweepWarnings(d.slug, warnings);
680
+ if (warningLine)
681
+ sweepWarnings.push(warningLine);
367
682
  }
368
683
  if (detected.length === 0) {
369
684
  if (options.fromTool) {
@@ -388,13 +703,15 @@ export async function ingest(rootPath, options) {
388
703
  files: [],
389
704
  });
390
705
  }
391
- grouped.get(f.slug).files.push({ filename: f.filename, content: f.content });
706
+ grouped.get(f.slug).files.push({ filename: f.filename, content: f.content, mode: f.mode });
392
707
  }
393
708
  const skills = [...grouped.values()];
394
709
  console.log(`\n ✓ Found ${skills.length} skill(s):`);
395
710
  for (const s of skills) {
396
711
  console.log(` • ${s.slug} (${s.files.length} file(s))`);
397
712
  }
713
+ for (const line of sweepWarnings)
714
+ console.log(line);
398
715
  if (options.dryRun) {
399
716
  console.log('\n --dry-run: not uploading.\n');
400
717
  return;
@@ -55,6 +55,17 @@ async function downloadAndWrite(file, dest, options, projectDir) {
55
55
  }
56
56
  ensureDir(dest);
57
57
  fs.writeFileSync(dest, content, 'utf-8');
58
+ // Restore the file mode the author shipped. Defaults to 0o644 when the
59
+ // manifest omits `mode` (older server, pre-mode-column row, etc.). We
60
+ // explicitly chmod even when the value is 0o644 so a previously-executable
61
+ // file at the dest gets re-normalized to non-executable on update.
62
+ try {
63
+ fs.chmodSync(dest, file.mode ?? 0o644);
64
+ }
65
+ catch {
66
+ // chmod can fail on Windows / network mounts. Don't fail the install
67
+ // over a permission cosmetic — the file still got written.
68
+ }
58
69
  return { src: file.filename, dest, fingerprint: fingerprintFile(dest) };
59
70
  }
60
71
  async function installSkill(ref, manifest, options, scope) {
@@ -6,6 +6,34 @@ interface PublishOptions {
6
6
  license?: string;
7
7
  json?: boolean;
8
8
  noCompile?: boolean;
9
+ /** Skip confirmation prompts. Reserved for future use — publish on a
10
+ * ref currently doesn't prompt, but the flag is accepted so users
11
+ * developing scripts get a consistent surface across publish/unpublish. */
12
+ yes?: boolean;
9
13
  }
10
14
  export declare function publish(source: string, options: PublishOptions): Promise<void>;
11
- export {};
15
+ /**
16
+ * Toggle an existing BotDoc from draft → published via the API.
17
+ *
18
+ * Called from `publish()` when the source argument looks like a ref
19
+ * (`@user/slug` or `user/slug`) instead of a local path. Strips the
20
+ * `draft` and `ingest:<id>` tags server-side. No prompt — moving from
21
+ * draft to published is the natural progression, and unpublishing a
22
+ * mistake is one CLI call away.
23
+ */
24
+ declare function publishRef(rawRef: string, options: PublishOptions): Promise<void>;
25
+ /**
26
+ * Map an `ApiError` from a publish/unpublish call to a friendly,
27
+ * actionable message. Shared by both verbs so the wording stays in sync.
28
+ *
29
+ * 401: most likely the user isn't logged in (or their saved token is
30
+ * stale). The api lib already produces a helpful "run `botdocs login`"
31
+ * hint — pass it through.
32
+ *
33
+ * 403: authenticated as someone who doesn't own this BotDoc.
34
+ *
35
+ * 404: BotDoc doesn't exist (or is owned by a different user — the API
36
+ * returns 404 rather than 403 to avoid leaking existence to non-authors).
37
+ */
38
+ declare function handlePublishToggleError(err: unknown, refLabel: string, options: PublishOptions): void;
39
+ export { publishRef, handlePublishToggleError };
@@ -1,10 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import AdmZip from 'adm-zip';
4
- import { apiFetch } from '../lib/api.js';
4
+ import { ApiError, apiFetch } from '../lib/api.js';
5
5
  import { parseManifest } from '../lib/manifest.js';
6
6
  import { compile } from './compile.js';
7
7
  import { ecosystemDestination } from '../lib/canonical.js';
8
+ import { isRefForm, parseRef } from '../lib/ref.js';
8
9
  const VALID_CATEGORIES = [
9
10
  'KNOWLEDGE_MANAGEMENT',
10
11
  'DEV_WORKFLOW',
@@ -15,6 +16,16 @@ const VALID_CATEGORIES = [
15
16
  ];
16
17
  const VALID_LICENSES = ['MIT', 'CC_BY_4_0', 'CC_BY_SA_4_0', 'CC0', 'ALL_RIGHTS_RESERVED'];
17
18
  export async function publish(source, options) {
19
+ // Ref-form (e.g. "@user/slug" or "user/slug") → toggle the published
20
+ // flag via the API. Path-form continues to the existing upload flow
21
+ // below. Overloading by argument shape (rather than introducing
22
+ // `botdocs publish-ref` or a `--ref` flag) keeps the verb intuitive:
23
+ // a user typing `botdocs publish @me/foo` doesn't have to know it's a
24
+ // different code path under the hood.
25
+ if (isRefForm(source)) {
26
+ await publishRef(source, options);
27
+ return;
28
+ }
18
29
  const resolved = path.resolve(source);
19
30
  if (!fs.existsSync(resolved)) {
20
31
  console.error(`Source not found: ${source}`);
@@ -92,6 +103,79 @@ export async function publish(source, options) {
92
103
  console.log(`\nPublished: ${result.url}`);
93
104
  }
94
105
  }
106
+ /**
107
+ * Toggle an existing BotDoc from draft → published via the API.
108
+ *
109
+ * Called from `publish()` when the source argument looks like a ref
110
+ * (`@user/slug` or `user/slug`) instead of a local path. Strips the
111
+ * `draft` and `ingest:<id>` tags server-side. No prompt — moving from
112
+ * draft to published is the natural progression, and unpublishing a
113
+ * mistake is one CLI call away.
114
+ */
115
+ async function publishRef(rawRef, options) {
116
+ let parsed;
117
+ try {
118
+ parsed = parseRef(rawRef);
119
+ }
120
+ catch (err) {
121
+ console.error(err instanceof Error ? err.message : String(err));
122
+ process.exit(1);
123
+ }
124
+ const { username, slug } = parsed;
125
+ const refLabel = `@${username}/${slug}`;
126
+ try {
127
+ await apiFetch(`/api/botdocs/${username}/${slug}/publish`, {
128
+ method: 'POST',
129
+ auth: true,
130
+ });
131
+ }
132
+ catch (err) {
133
+ handlePublishToggleError(err, refLabel, options);
134
+ return;
135
+ }
136
+ if (options.json) {
137
+ console.log(JSON.stringify({ ok: true, ref: refLabel, status: 'published' }));
138
+ }
139
+ else {
140
+ console.log(`✓ Published ${refLabel} — visible at https://botdocs.ai/${refLabel}`);
141
+ }
142
+ }
143
+ /**
144
+ * Map an `ApiError` from a publish/unpublish call to a friendly,
145
+ * actionable message. Shared by both verbs so the wording stays in sync.
146
+ *
147
+ * 401: most likely the user isn't logged in (or their saved token is
148
+ * stale). The api lib already produces a helpful "run `botdocs login`"
149
+ * hint — pass it through.
150
+ *
151
+ * 403: authenticated as someone who doesn't own this BotDoc.
152
+ *
153
+ * 404: BotDoc doesn't exist (or is owned by a different user — the API
154
+ * returns 404 rather than 403 to avoid leaking existence to non-authors).
155
+ */
156
+ function handlePublishToggleError(err, refLabel, options) {
157
+ if (!(err instanceof ApiError)) {
158
+ throw err;
159
+ }
160
+ if (options.json) {
161
+ console.log(JSON.stringify({ ok: false, ref: refLabel, status: err.status, error: err.message }));
162
+ process.exit(1);
163
+ }
164
+ if (err.status === 401) {
165
+ console.error(`\n ✗ ${err.message}\n`);
166
+ }
167
+ else if (err.status === 403) {
168
+ console.error(`\n ✗ You don't own ${refLabel}.\n`);
169
+ }
170
+ else if (err.status === 404) {
171
+ console.error(`\n ✗ BotDoc not found: ${refLabel}\n`);
172
+ }
173
+ else {
174
+ console.error(`\n ✗ ${err.message}\n`);
175
+ }
176
+ process.exit(1);
177
+ }
178
+ export { publishRef, handlePublishToggleError };
95
179
  function readManifest(source) {
96
180
  const manifestPath = path.join(source, 'botdocs.json');
97
181
  if (!fs.existsSync(manifestPath))