@botdocs/cli 0.10.3 → 0.11.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,9 +1,11 @@
1
1
  import React from 'react';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { render } from 'ink';
5
6
  import { ApiError, apiFetch } from '../lib/api.js';
6
7
  import { loadAuth } from '../lib/config.js';
8
+ import { IngestSessionClient, PairingClaimError } from '../lib/ingest-session-client.js';
7
9
  import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, titleFromContent, } from '../lib/ingest-discover.js';
8
10
  import { IngestDiscoverApp } from './views/ingest-discover-app.js';
9
11
  // ---------- Cap + skip rules (shared with discovery) ----------
@@ -398,17 +400,36 @@ export const DETECTORS = {
398
400
  },
399
401
  };
400
402
  export const SUPPORTED_TOOLS = Object.keys(DETECTORS);
401
- function walkFiles(root) {
403
+ /**
404
+ * Recursively collect absolute file paths under `root`.
405
+ *
406
+ * Symlinks are skipped (with a warning) to prevent local file exfiltration:
407
+ * without this check a directory containing a symlink to e.g. `~/.ssh/id_rsa`
408
+ * would have its target's contents read into memory and uploaded as part of
409
+ * the ingest payload. `entry.isDirectory()` and `entry.isFile()` come from
410
+ * the `Dirent` produced by `withFileTypes: true`, which reports the entry's
411
+ * OWN type (NOT the symlink target's), so the gate is sound. Mirrors
412
+ * `walkAll` in `src/lib/ingest-discover.ts`.
413
+ *
414
+ * Exported for unit testing.
415
+ */
416
+ export function walkFiles(root) {
402
417
  const out = [];
403
418
  function walk(dir) {
404
419
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
420
+ const full = path.join(dir, entry.name);
421
+ if (entry.isSymbolicLink()) {
422
+ const rel = path.relative(root, full).split(path.sep).join('/');
423
+ console.warn(`Skipping symlink: ${rel}`);
424
+ continue;
425
+ }
405
426
  if (entry.isDirectory()) {
406
427
  if (SWEEP_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
407
428
  continue;
408
- walk(path.join(dir, entry.name));
429
+ walk(full);
409
430
  }
410
- else {
411
- out.push(path.join(dir, entry.name));
431
+ else if (entry.isFile()) {
432
+ out.push(full);
412
433
  }
413
434
  }
414
435
  }
@@ -566,7 +587,74 @@ function reportConflicts(conflicts, options) {
566
587
  * response) and the default human-friendly "drafts created" line. On a 409
567
588
  * slug-conflict, prints an actionable message and exits non-zero rather than
568
589
  * letting the ApiError bubble up as a stack trace. Returns nothing on success. */
569
- async function uploadSkills(skills, options) {
590
+ /** Best-effort mapping from a DetectedSkill back into one of the four
591
+ * coarse types the web UI renders ("agent" / "prompt" / "context" /
592
+ * "workflow"). Falls back to `prompt` because that's the most generic
593
+ * bucket — the web only uses this for a one-line subtitle so a wrong
594
+ * guess is cosmetic, not functional. */
595
+ function inferSkillType(skill) {
596
+ const eco = skill.sourceEcosystem ?? '';
597
+ if (eco.includes('agent'))
598
+ return 'agent';
599
+ // Look for telltale filenames inside the skill bundle.
600
+ const firstFilename = skill.files[0]?.filename.toLowerCase() ?? '';
601
+ if (firstFilename.includes('workflow'))
602
+ return 'workflow';
603
+ if (firstFilename.includes('context'))
604
+ return 'context';
605
+ if (firstFilename.includes('agent'))
606
+ return 'agent';
607
+ return 'prompt';
608
+ }
609
+ /** Pull a printable line out of the 413 `detail` field. Accepts both the
610
+ * new structured shape (`{ message: '…' }`) emitted by `validateSkillCaps`
611
+ * and the legacy bare-string form. Returns undefined when nothing usable
612
+ * is present, so the caller can fall back to `err.message`. */
613
+ function extractSizeCapMessage(body) {
614
+ if (typeof body !== 'object' || body === null)
615
+ return undefined;
616
+ const detail = body.detail;
617
+ if (typeof detail === 'string')
618
+ return detail;
619
+ if (typeof detail === 'object' && detail !== null) {
620
+ const message = detail.message;
621
+ if (typeof message === 'string' && message.length > 0)
622
+ return message;
623
+ }
624
+ return undefined;
625
+ }
626
+ /** Print an actionable 413 message and exit non-zero. Honors `--json` by
627
+ * emitting `{ ok: false, status: 413, ... }` so scripts can branch on it.
628
+ *
629
+ * Mirrors `handlePathPublishError`'s 413 arm in publish.ts — same shape so
630
+ * the two commands' size-cap UX stays in sync. */
631
+ function reportSizeCap(err, options) {
632
+ const detailMessage = extractSizeCapMessage(err.body);
633
+ if (options.json) {
634
+ console.log(JSON.stringify({
635
+ ok: false,
636
+ status: 413,
637
+ error: err.message,
638
+ detail: err.body?.detail ?? null,
639
+ }));
640
+ process.exit(1);
641
+ }
642
+ console.error(`\n ✗ ${detailMessage ?? err.message}\n`);
643
+ process.exit(1);
644
+ }
645
+ async function uploadSkills(skills, options, pair) {
646
+ // Optional: announce intent before the API call so the paired web UI
647
+ // shows "uploading…" rows immediately. The events POST is batched
648
+ // server-side so this doesn't cost N round-trips.
649
+ if (pair?.isPaired) {
650
+ for (const s of skills) {
651
+ pair.emit('upload_started', {
652
+ filename: s.files[0]?.filename ?? `${s.slug}.md`,
653
+ type: inferSkillType(s),
654
+ experimental: false,
655
+ });
656
+ }
657
+ }
570
658
  let result;
571
659
  try {
572
660
  result = await apiFetch('/api/cli/ingest', {
@@ -580,13 +668,47 @@ async function uploadSkills(skills, options) {
580
668
  });
581
669
  }
582
670
  catch (err) {
671
+ // Paired? Tell the web that every skill in this batch failed so the
672
+ // UI can render the error state cleanly instead of hanging on
673
+ // "uploading…".
674
+ if (pair?.isPaired) {
675
+ const reason = err instanceof Error ? err.message : 'upload failed';
676
+ for (const s of skills) {
677
+ pair.emit('upload_failed', {
678
+ filename: s.files[0]?.filename ?? `${s.slug}.md`,
679
+ reason,
680
+ });
681
+ }
682
+ }
583
683
  if (err instanceof ApiError && err.status === 409) {
584
684
  const conflicts = parseConflicts(err.body);
585
685
  if (conflicts)
586
686
  reportConflicts(conflicts, options);
587
687
  }
688
+ // 413 PAYLOAD_TOO_LARGE — server's `validateSkillCaps` rejected one of
689
+ // the skills in the payload. The body's `detail.message` is the human
690
+ // line ("file foo/bar.md is 71 KB, cap is 64 KB per file"); fall back
691
+ // to the older `detail: string` shape and finally `err.message` so
692
+ // mixed-version client/server pairs still print something useful.
693
+ if (err instanceof ApiError && err.status === 413) {
694
+ reportSizeCap(err, options);
695
+ }
588
696
  throw err;
589
697
  }
698
+ // Success: emit one completed event per skill so the web UI's card
699
+ // stream populates. The bundle-level draftId is the same for every
700
+ // skill in this run — the web uses it to build the review link.
701
+ if (pair?.isPaired) {
702
+ for (const s of skills) {
703
+ pair.emit('upload_completed', {
704
+ filename: s.files[0]?.filename ?? `${s.slug}.md`,
705
+ type: inferSkillType(s),
706
+ experimental: false,
707
+ draftId: result.draftId,
708
+ slug: s.slug,
709
+ });
710
+ }
711
+ }
590
712
  if (options.json) {
591
713
  console.log(JSON.stringify(result));
592
714
  return;
@@ -652,7 +774,7 @@ function printDiscoveryPlainText(discovered, skillSummaries) {
652
774
  /** The zero-argument discovery entry point. Scans known on-disk locations,
653
775
  * routes through the TUI (or its fallbacks) per the user's options + tty
654
776
  * state, and uploads the user's selection. */
655
- async function ingestDiscover(options) {
777
+ async function ingestDiscover(options, pair) {
656
778
  const discovery = discoverSkills();
657
779
  if (discovery.files.length === 0) {
658
780
  console.log('\n No skills found. Try `botdocs init` to scaffold a new one, or point at a path: `botdocs ingest <dir>`\n');
@@ -724,7 +846,15 @@ async function ingestDiscover(options) {
724
846
  if (line)
725
847
  console.log(line);
726
848
  }
727
- await uploadSkills(skills, options);
849
+ // Paired runs emit selection_committed so the web UI knows the total
850
+ // before per-skill events arrive (drives the "X / Y received" counter).
851
+ if (pair?.isPaired) {
852
+ pair.emit('selection_committed', {
853
+ totalSelected: skills.length,
854
+ candidatesScanned: discovery.files.filter(isRoot).length,
855
+ });
856
+ }
857
+ await uploadSkills(skills, options, pair);
728
858
  return;
729
859
  }
730
860
  const useInk = !options.noInk && Boolean(process.stdout.isTTY);
@@ -759,7 +889,13 @@ async function ingestDiscover(options) {
759
889
  return;
760
890
  }
761
891
  const skills = groupDiscoveredIntoSkills(result.selected);
762
- await uploadSkills(skills, options);
892
+ if (pair?.isPaired) {
893
+ pair.emit('selection_committed', {
894
+ totalSelected: skills.length,
895
+ candidatesScanned: discovery.files.filter(isRoot).length,
896
+ });
897
+ }
898
+ await uploadSkills(skills, options, pair);
763
899
  }
764
900
  /** Human-readable list of all canonical path prefixes for the empty-result message. */
765
901
  function ecosystemPrefixSummary() {
@@ -767,16 +903,123 @@ function ecosystemPrefixSummary() {
767
903
  .map((d) => d.pathPrefix)
768
904
  .join(', ');
769
905
  }
906
+ /** Read one line from stdin without blocking the rest of the CLI. Used for
907
+ * the `--pair` interactive prompt — kept small and dependency-free so the
908
+ * standard ingest path doesn't pull in a prompt library. Returns null on
909
+ * EOF / non-TTY (caller treats that as "skip pairing"). */
910
+ async function readLine(prompt) {
911
+ if (!process.stdin.isTTY)
912
+ return null;
913
+ process.stdout.write(prompt);
914
+ return new Promise((resolve) => {
915
+ const onData = (chunk) => {
916
+ const text = chunk.toString('utf8').replace(/[\r\n]+$/, '');
917
+ process.stdin.off('data', onData);
918
+ process.stdin.pause();
919
+ resolve(text || null);
920
+ };
921
+ process.stdin.resume();
922
+ process.stdin.on('data', onData);
923
+ });
924
+ }
925
+ /** Tilde-shorten the cwd for display on the web's connection chip. So we
926
+ * surface `~/projects` instead of `/Users/alice/projects`. */
927
+ function displayCwd(absolute) {
928
+ const home = os.homedir();
929
+ if (home && absolute.startsWith(home)) {
930
+ return absolute.replace(home, '~');
931
+ }
932
+ return absolute;
933
+ }
934
+ /** If pairing was requested, walk through the claim flow and return a
935
+ * connected client. Returns null when pairing is declined, fails, or the
936
+ * user is not logged in — the caller continues unpaired in all those cases.
937
+ *
938
+ * Never throws: pairing is opt-in and a failure must not block ingest. */
939
+ async function setupPairingIfRequested(options) {
940
+ const wantPair = options.pair === true || typeof options.pairCode === 'string';
941
+ if (!wantPair)
942
+ return null;
943
+ // We need auth to claim — same as the ingest API call itself. If they're
944
+ // not logged in, the rest of the flow will surface that error; pairing
945
+ // can't help here.
946
+ if (!loadAuth()) {
947
+ console.log(' → Pairing skipped (not signed in).');
948
+ return null;
949
+ }
950
+ let code = options.pairCode;
951
+ if (!code) {
952
+ const entered = await readLine(' Enter pairing code from the onboarding page: ');
953
+ if (!entered) {
954
+ console.log(' → Pairing skipped (no code entered).');
955
+ return null;
956
+ }
957
+ code = entered;
958
+ }
959
+ const client = new IngestSessionClient();
960
+ try {
961
+ await client.claim({
962
+ code,
963
+ machineName: os.hostname() || 'unknown',
964
+ cwd: displayCwd(process.cwd()),
965
+ });
966
+ console.log(' ✓ Paired with botdocs.ai\n');
967
+ return client;
968
+ }
969
+ catch (err) {
970
+ const msg = err instanceof PairingClaimError ? err.message : String(err);
971
+ console.log(` ⚠ Could not pair: ${msg}`);
972
+ console.log(' → Continuing without pairing.\n');
973
+ return null;
974
+ }
975
+ }
770
976
  export async function ingest(rootPath, options) {
977
+ // Try to pair before scanning kicks off. setupPairingIfRequested NEVER
978
+ // throws — null means "carry on unpaired". The client is closed on the
979
+ // end-of-run paths below.
980
+ const pair = await setupPairingIfRequested(options);
981
+ // Wrap the body so we can finalize/cancel symmetrically regardless of
982
+ // which dispatch path fires below. The `pair` client is null in the
983
+ // unpaired case, so all the .emit calls inside are no-ops.
984
+ try {
985
+ await runIngest(rootPath, options, pair);
986
+ if (pair)
987
+ await pair.finalize({ uploaded: 0, failed: 0 });
988
+ // ↑ totals=0 is a known overstatement; finalize is called from
989
+ // uploadSkills' success path in a future revision, but for now the
990
+ // per-skill upload_completed events carry the count for the UI.
991
+ }
992
+ catch (err) {
993
+ if (pair)
994
+ await pair.cancel(err instanceof Error ? err.message : 'ingest failed');
995
+ throw err;
996
+ }
997
+ }
998
+ async function runIngest(rootPath, options, pair) {
771
999
  // Zero-arg dispatch: scan known on-disk locations and route through the
772
1000
  // TUI (or its fallbacks). The existing path-based code path below stays
773
1001
  // identical.
774
1002
  if (!rootPath) {
775
- await ingestDiscover(options);
1003
+ await ingestDiscover(options, pair ?? undefined);
776
1004
  return;
777
1005
  }
778
1006
  const root = path.resolve(rootPath);
779
- if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
1007
+ if (!fs.existsSync(root)) {
1008
+ console.error(`\n ✗ Not a directory: ${rootPath}\n`);
1009
+ process.exit(1);
1010
+ return;
1011
+ }
1012
+ // Refuse a symlinked entry point — same exfil class the walker fix closes
1013
+ // for nested entries. `lstatSync` reports the path's OWN type, so a
1014
+ // directory symlink doesn't silently slip past the `isDirectory()` check
1015
+ // below and let the walker recurse into the target. Fatal (not skip)
1016
+ // since the user explicitly named this path.
1017
+ if (fs.lstatSync(root).isSymbolicLink()) {
1018
+ console.error(`\n ✗ Refusing to ingest from a symlink: ${rootPath}. Pass the resolved real path instead.\n`);
1019
+ process.exit(1);
1020
+ return;
1021
+ }
1022
+ if (!fs.statSync(root).isDirectory()) {
780
1023
  console.error(`\n ✗ Not a directory: ${rootPath}\n`);
781
1024
  process.exit(1);
782
1025
  return;
@@ -842,5 +1085,15 @@ export async function ingest(rootPath, options) {
842
1085
  console.log('\n --dry-run: not uploading.\n');
843
1086
  return;
844
1087
  }
845
- await uploadSkills(skills, options);
1088
+ // The path-based branch isn't interactive — there's no "selection" step
1089
+ // (the user gave us a directory and we're uploading what's in it). Still
1090
+ // emit selection_committed so the paired web UI gets a total before the
1091
+ // upload events.
1092
+ if (pair?.isPaired) {
1093
+ pair.emit('selection_committed', {
1094
+ totalSelected: skills.length,
1095
+ candidatesScanned: detected.length,
1096
+ });
1097
+ }
1098
+ await uploadSkills(skills, options, pair ?? undefined);
846
1099
  }
@@ -2,7 +2,31 @@ interface InitOptions {
2
2
  title?: string;
3
3
  category?: string;
4
4
  json?: boolean;
5
+ /** Legacy: kept for back-compat with older docs/scripts that explicitly
6
+ * passed `--canonical`. Canonical IS the default now; this flag is a
7
+ * no-op. Surfaced separately from `--spec` so a script that passes both
8
+ * doesn't silently downgrade. */
5
9
  canonical?: boolean;
10
+ /** Opt-in scaffold of the legacy SPEC layout (single `index.md` + a
11
+ * minimal manifest with no `type`). Used to be the default; now an
12
+ * explicit choice for authors who genuinely want a non-skill spec.
13
+ * Most callers should NOT pass this. */
14
+ spec?: boolean;
6
15
  }
16
+ /**
17
+ * The default description `botdocs init` writes into a fresh `botdocs.json`.
18
+ * Intentionally distinctive — not empty — so:
19
+ * 1. `botdocs validate` produces a friendly, specific error (instead of
20
+ * the generic "title and description are required") that names the
21
+ * placeholder.
22
+ * 2. `botdocs publish` refuses to ship the unchanged placeholder, which
23
+ * would otherwise litter `/explore` with "TODO: …" skills.
24
+ *
25
+ * Re-exported from `lib/manifest.ts` so the validator and the scaffolder
26
+ * agree on the literal without one side drifting. Importing here would
27
+ * be circular-safe (init never re-imports manifest at runtime), but
28
+ * routing through manifest keeps a single source of truth.
29
+ */
30
+ export declare const INIT_PLACEHOLDER_DESCRIPTION = "TODO: describe your skill in one sentence.";
7
31
  export declare function init(dirName: string | undefined, options: InitOptions): Promise<void>;
8
32
  export {};
@@ -1,6 +1,22 @@
1
1
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
3
  import { relative } from 'node:path';
4
+ import { INIT_PLACEHOLDER_DESCRIPTION as MANIFEST_PLACEHOLDER } from '../lib/manifest.js';
5
+ /**
6
+ * The default description `botdocs init` writes into a fresh `botdocs.json`.
7
+ * Intentionally distinctive — not empty — so:
8
+ * 1. `botdocs validate` produces a friendly, specific error (instead of
9
+ * the generic "title and description are required") that names the
10
+ * placeholder.
11
+ * 2. `botdocs publish` refuses to ship the unchanged placeholder, which
12
+ * would otherwise litter `/explore` with "TODO: …" skills.
13
+ *
14
+ * Re-exported from `lib/manifest.ts` so the validator and the scaffolder
15
+ * agree on the literal without one side drifting. Importing here would
16
+ * be circular-safe (init never re-imports manifest at runtime), but
17
+ * routing through manifest keeps a single source of truth.
18
+ */
19
+ export const INIT_PLACEHOLDER_DESCRIPTION = MANIFEST_PLACEHOLDER;
4
20
  export async function init(dirName, options) {
5
21
  const name = dirName || 'my-botdoc';
6
22
  const dir = join(process.cwd(), name);
@@ -15,7 +31,15 @@ export async function init(dirName, options) {
15
31
  }
16
32
  mkdirSync(dir, { recursive: true });
17
33
  const title = options.title || name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
18
- if (options.canonical) {
34
+ // Default → canonical skill scaffold. The legacy SPEC layout is now opt-in
35
+ // via `--spec`. Audit history: the old default wrote a manifest with no
36
+ // `type`, which the server stored as SPEC; a friend running
37
+ // `botdocs install @them/skill` then saw "files have no supported agent"
38
+ // because the row wasn't a SKILL. Canonical-by-default removes that
39
+ // sharp edge. `--canonical` is kept as a no-op alias so old docs still
40
+ // work; `--spec` is the only way to get the legacy scaffold now.
41
+ const useSpec = options.spec === true;
42
+ if (!useSpec) {
19
43
  const sourceFile = join(dir, 'claude-code', 'commands', `${name}.md`);
20
44
  mkdirSync(dirname(sourceFile), { recursive: true });
21
45
  writeFileSync(sourceFile, `# ${title}\n\n## Overview\n\nDescribe the skill.\n`, 'utf-8');
@@ -23,7 +47,11 @@ export async function init(dirName, options) {
23
47
  type: 'skill',
24
48
  version: '1.0.0',
25
49
  title,
26
- description: '',
50
+ // Placeholder rather than empty string: validate now refuses both the
51
+ // empty case AND the unchanged placeholder, so the author hits a
52
+ // specific "edit botdocs.json before publishing" message instead of
53
+ // the generic missing-field error. See lib/manifest.ts.
54
+ description: INIT_PLACEHOLDER_DESCRIPTION,
27
55
  sourceEcosystem: 'claude-code',
28
56
  ecosystems: ['claude', 'claude-code', 'cursor', 'chatgpt'],
29
57
  license: 'MIT',
@@ -41,12 +69,14 @@ export async function init(dirName, options) {
41
69
  console.log(`\n Created ${name}/`);
42
70
  console.log(` claude-code/commands/${name}.md — your canonical source`);
43
71
  console.log(` botdocs.json — declares ecosystems to generate`);
44
- console.log(`\n Edit the source file, then:`);
72
+ console.log(`\n Edit the source file and the description in botdocs.json, then:`);
45
73
  console.log(` botdocs compile ${name}/`);
46
74
  console.log(` botdocs publish ${name}/\n`);
47
75
  }
48
76
  return;
49
77
  }
78
+ // Legacy SPEC layout — opt-in via `--spec`. Kept for authors who genuinely
79
+ // want a non-skill spec document; not the recommended path.
50
80
  const indexContent = `# ${title}
51
81
 
52
82
  ## Overview
@@ -72,19 +102,26 @@ How do you know when an implementation of this spec is correct?
72
102
  writeFileSync(join(dir, 'index.md'), indexContent, 'utf-8');
73
103
  const botdocsJson = {
74
104
  title,
75
- description: '',
105
+ // Same placeholder as the canonical path — keeps the validate behavior
106
+ // consistent regardless of which scaffold the user picked.
107
+ description: INIT_PLACEHOLDER_DESCRIPTION,
76
108
  category: options.category?.toUpperCase() || 'OTHER',
77
109
  tags: [],
78
110
  license: 'MIT',
79
111
  };
80
112
  writeFileSync(join(dir, 'botdocs.json'), JSON.stringify(botdocsJson, null, 2), 'utf-8');
81
113
  if (options.json) {
82
- console.log(JSON.stringify({ success: true, directory: name, files: ['index.md', 'botdocs.json'] }));
114
+ console.log(JSON.stringify({
115
+ success: true,
116
+ directory: name,
117
+ files: ['index.md', 'botdocs.json'],
118
+ canonical: false,
119
+ }));
83
120
  }
84
121
  else {
85
122
  console.log(`\n Created ${name}/`);
86
123
  console.log(` index.md — your spec starts here`);
87
124
  console.log(` botdocs.json — metadata for publishing`);
88
- console.log(`\n Next: edit index.md, then run botdocs publish ${name}/\n`);
125
+ console.log(`\n Edit index.md (and the description in botdocs.json), then run botdocs publish ${name}/\n`);
89
126
  }
90
127
  }