@botdocs/cli 0.10.3 → 0.12.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,12 @@
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
- import { ApiError, apiFetch } from '../lib/api.js';
6
+ import open from 'open';
7
+ import { ApiError, apiFetch, getApiUrl } from '../lib/api.js';
6
8
  import { loadAuth } from '../lib/config.js';
9
+ import { IngestSessionClient, PairingClaimError } from '../lib/ingest-session-client.js';
7
10
  import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, titleFromContent, } from '../lib/ingest-discover.js';
8
11
  import { IngestDiscoverApp } from './views/ingest-discover-app.js';
9
12
  // ---------- Cap + skip rules (shared with discovery) ----------
@@ -398,17 +401,36 @@ export const DETECTORS = {
398
401
  },
399
402
  };
400
403
  export const SUPPORTED_TOOLS = Object.keys(DETECTORS);
401
- function walkFiles(root) {
404
+ /**
405
+ * Recursively collect absolute file paths under `root`.
406
+ *
407
+ * Symlinks are skipped (with a warning) to prevent local file exfiltration:
408
+ * without this check a directory containing a symlink to e.g. `~/.ssh/id_rsa`
409
+ * would have its target's contents read into memory and uploaded as part of
410
+ * the ingest payload. `entry.isDirectory()` and `entry.isFile()` come from
411
+ * the `Dirent` produced by `withFileTypes: true`, which reports the entry's
412
+ * OWN type (NOT the symlink target's), so the gate is sound. Mirrors
413
+ * `walkAll` in `src/lib/ingest-discover.ts`.
414
+ *
415
+ * Exported for unit testing.
416
+ */
417
+ export function walkFiles(root) {
402
418
  const out = [];
403
419
  function walk(dir) {
404
420
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
421
+ const full = path.join(dir, entry.name);
422
+ if (entry.isSymbolicLink()) {
423
+ const rel = path.relative(root, full).split(path.sep).join('/');
424
+ console.warn(`Skipping symlink: ${rel}`);
425
+ continue;
426
+ }
405
427
  if (entry.isDirectory()) {
406
428
  if (SWEEP_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
407
429
  continue;
408
- walk(path.join(dir, entry.name));
430
+ walk(full);
409
431
  }
410
- else {
411
- out.push(path.join(dir, entry.name));
432
+ else if (entry.isFile()) {
433
+ out.push(full);
412
434
  }
413
435
  }
414
436
  }
@@ -566,7 +588,74 @@ function reportConflicts(conflicts, options) {
566
588
  * response) and the default human-friendly "drafts created" line. On a 409
567
589
  * slug-conflict, prints an actionable message and exits non-zero rather than
568
590
  * letting the ApiError bubble up as a stack trace. Returns nothing on success. */
569
- async function uploadSkills(skills, options) {
591
+ /** Best-effort mapping from a DetectedSkill back into one of the four
592
+ * coarse types the web UI renders ("agent" / "prompt" / "context" /
593
+ * "workflow"). Falls back to `prompt` because that's the most generic
594
+ * bucket — the web only uses this for a one-line subtitle so a wrong
595
+ * guess is cosmetic, not functional. */
596
+ function inferSkillType(skill) {
597
+ const eco = skill.sourceEcosystem ?? '';
598
+ if (eco.includes('agent'))
599
+ return 'agent';
600
+ // Look for telltale filenames inside the skill bundle.
601
+ const firstFilename = skill.files[0]?.filename.toLowerCase() ?? '';
602
+ if (firstFilename.includes('workflow'))
603
+ return 'workflow';
604
+ if (firstFilename.includes('context'))
605
+ return 'context';
606
+ if (firstFilename.includes('agent'))
607
+ return 'agent';
608
+ return 'prompt';
609
+ }
610
+ /** Pull a printable line out of the 413 `detail` field. Accepts both the
611
+ * new structured shape (`{ message: '…' }`) emitted by `validateSkillCaps`
612
+ * and the legacy bare-string form. Returns undefined when nothing usable
613
+ * is present, so the caller can fall back to `err.message`. */
614
+ function extractSizeCapMessage(body) {
615
+ if (typeof body !== 'object' || body === null)
616
+ return undefined;
617
+ const detail = body.detail;
618
+ if (typeof detail === 'string')
619
+ return detail;
620
+ if (typeof detail === 'object' && detail !== null) {
621
+ const message = detail.message;
622
+ if (typeof message === 'string' && message.length > 0)
623
+ return message;
624
+ }
625
+ return undefined;
626
+ }
627
+ /** Print an actionable 413 message and exit non-zero. Honors `--json` by
628
+ * emitting `{ ok: false, status: 413, ... }` so scripts can branch on it.
629
+ *
630
+ * Mirrors `handlePathPublishError`'s 413 arm in publish.ts — same shape so
631
+ * the two commands' size-cap UX stays in sync. */
632
+ function reportSizeCap(err, options) {
633
+ const detailMessage = extractSizeCapMessage(err.body);
634
+ if (options.json) {
635
+ console.log(JSON.stringify({
636
+ ok: false,
637
+ status: 413,
638
+ error: err.message,
639
+ detail: err.body?.detail ?? null,
640
+ }));
641
+ process.exit(1);
642
+ }
643
+ console.error(`\n ✗ ${detailMessage ?? err.message}\n`);
644
+ process.exit(1);
645
+ }
646
+ async function uploadSkills(skills, options, pair) {
647
+ // Optional: announce intent before the API call so the paired web UI
648
+ // shows "uploading…" rows immediately. The events POST is batched
649
+ // server-side so this doesn't cost N round-trips.
650
+ if (pair?.isPaired) {
651
+ for (const s of skills) {
652
+ pair.emit('upload_started', {
653
+ filename: s.files[0]?.filename ?? `${s.slug}.md`,
654
+ type: inferSkillType(s),
655
+ experimental: false,
656
+ });
657
+ }
658
+ }
570
659
  let result;
571
660
  try {
572
661
  result = await apiFetch('/api/cli/ingest', {
@@ -580,13 +669,47 @@ async function uploadSkills(skills, options) {
580
669
  });
581
670
  }
582
671
  catch (err) {
672
+ // Paired? Tell the web that every skill in this batch failed so the
673
+ // UI can render the error state cleanly instead of hanging on
674
+ // "uploading…".
675
+ if (pair?.isPaired) {
676
+ const reason = err instanceof Error ? err.message : 'upload failed';
677
+ for (const s of skills) {
678
+ pair.emit('upload_failed', {
679
+ filename: s.files[0]?.filename ?? `${s.slug}.md`,
680
+ reason,
681
+ });
682
+ }
683
+ }
583
684
  if (err instanceof ApiError && err.status === 409) {
584
685
  const conflicts = parseConflicts(err.body);
585
686
  if (conflicts)
586
687
  reportConflicts(conflicts, options);
587
688
  }
689
+ // 413 PAYLOAD_TOO_LARGE — server's `validateSkillCaps` rejected one of
690
+ // the skills in the payload. The body's `detail.message` is the human
691
+ // line ("file foo/bar.md is 71 KB, cap is 64 KB per file"); fall back
692
+ // to the older `detail: string` shape and finally `err.message` so
693
+ // mixed-version client/server pairs still print something useful.
694
+ if (err instanceof ApiError && err.status === 413) {
695
+ reportSizeCap(err, options);
696
+ }
588
697
  throw err;
589
698
  }
699
+ // Success: emit one completed event per skill so the web UI's card
700
+ // stream populates. The bundle-level draftId is the same for every
701
+ // skill in this run — the web uses it to build the review link.
702
+ if (pair?.isPaired) {
703
+ for (const s of skills) {
704
+ pair.emit('upload_completed', {
705
+ filename: s.files[0]?.filename ?? `${s.slug}.md`,
706
+ type: inferSkillType(s),
707
+ experimental: false,
708
+ draftId: result.draftId,
709
+ slug: s.slug,
710
+ });
711
+ }
712
+ }
590
713
  if (options.json) {
591
714
  console.log(JSON.stringify(result));
592
715
  return;
@@ -652,7 +775,7 @@ function printDiscoveryPlainText(discovered, skillSummaries) {
652
775
  /** The zero-argument discovery entry point. Scans known on-disk locations,
653
776
  * routes through the TUI (or its fallbacks) per the user's options + tty
654
777
  * state, and uploads the user's selection. */
655
- async function ingestDiscover(options) {
778
+ async function ingestDiscover(options, pair) {
656
779
  const discovery = discoverSkills();
657
780
  if (discovery.files.length === 0) {
658
781
  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 +847,15 @@ async function ingestDiscover(options) {
724
847
  if (line)
725
848
  console.log(line);
726
849
  }
727
- await uploadSkills(skills, options);
850
+ // Paired runs emit selection_committed so the web UI knows the total
851
+ // before per-skill events arrive (drives the "X / Y received" counter).
852
+ if (pair?.isPaired) {
853
+ pair.emit('selection_committed', {
854
+ totalSelected: skills.length,
855
+ candidatesScanned: discovery.files.filter(isRoot).length,
856
+ });
857
+ }
858
+ await uploadSkills(skills, options, pair);
728
859
  return;
729
860
  }
730
861
  const useInk = !options.noInk && Boolean(process.stdout.isTTY);
@@ -759,7 +890,13 @@ async function ingestDiscover(options) {
759
890
  return;
760
891
  }
761
892
  const skills = groupDiscoveredIntoSkills(result.selected);
762
- await uploadSkills(skills, options);
893
+ if (pair?.isPaired) {
894
+ pair.emit('selection_committed', {
895
+ totalSelected: skills.length,
896
+ candidatesScanned: discovery.files.filter(isRoot).length,
897
+ });
898
+ }
899
+ await uploadSkills(skills, options, pair);
763
900
  }
764
901
  /** Human-readable list of all canonical path prefixes for the empty-result message. */
765
902
  function ecosystemPrefixSummary() {
@@ -767,16 +904,160 @@ function ecosystemPrefixSummary() {
767
904
  .map((d) => d.pathPrefix)
768
905
  .join(', ');
769
906
  }
907
+ /** Read one line from stdin without blocking the rest of the CLI. Used for
908
+ * the `--pair` interactive prompt — kept small and dependency-free so the
909
+ * standard ingest path doesn't pull in a prompt library. Returns null on
910
+ * EOF / non-TTY (caller treats that as "skip pairing"). */
911
+ async function readLine(prompt) {
912
+ if (!process.stdin.isTTY)
913
+ return null;
914
+ process.stdout.write(prompt);
915
+ return new Promise((resolve) => {
916
+ const onData = (chunk) => {
917
+ const text = chunk.toString('utf8').replace(/[\r\n]+$/, '');
918
+ process.stdin.off('data', onData);
919
+ process.stdin.pause();
920
+ resolve(text || null);
921
+ };
922
+ process.stdin.resume();
923
+ process.stdin.on('data', onData);
924
+ });
925
+ }
926
+ /** Tilde-shorten the cwd for display on the web's connection chip. So we
927
+ * surface `~/projects` instead of `/Users/alice/projects`. */
928
+ function displayCwd(absolute) {
929
+ const home = os.homedir();
930
+ if (home && absolute.startsWith(home)) {
931
+ return absolute.replace(home, '~');
932
+ }
933
+ return absolute;
934
+ }
935
+ /** If pairing was requested, walk through the claim flow and return a
936
+ * connected client. Returns null when pairing is declined, fails, or the
937
+ * user is not logged in — the caller continues unpaired in all those cases.
938
+ *
939
+ * Never throws: pairing is opt-in and a failure must not block ingest. */
940
+ async function setupPairingIfRequested(options) {
941
+ const wantPair = options.pair === true || typeof options.pairCode === 'string';
942
+ if (!wantPair)
943
+ return null;
944
+ // We need auth to claim — same as the ingest API call itself. If they're
945
+ // not logged in, the rest of the flow will surface that error; pairing
946
+ // can't help here.
947
+ if (!loadAuth()) {
948
+ console.log(' → Pairing skipped (not signed in).');
949
+ return null;
950
+ }
951
+ let code = options.pairCode;
952
+ if (!code) {
953
+ // Auto-open the pairing page in the user's default browser unless they
954
+ // opted out via --no-browser. The page mints (or reuses) the BD-XXXXX
955
+ // code server-side, so by the time the browser tab loads, the code is
956
+ // already on screen — no waiting, no second click.
957
+ //
958
+ // We open BEFORE printing the prompt so the order of events feels right
959
+ // ("browser opens, look there, paste here"). If `open()` fails — common
960
+ // in headless / SSH / CI environments — we swallow the error and fall
961
+ // back to the printed URL. The CLI must never become unusable because
962
+ // we couldn't reach a graphical browser.
963
+ const pairUrl = `${getApiUrl()}/pair`;
964
+ const shouldOpen = options.noBrowser !== true;
965
+ let browserOpened = false;
966
+ if (shouldOpen) {
967
+ try {
968
+ await open(pairUrl);
969
+ browserOpened = true;
970
+ }
971
+ catch {
972
+ /* swallow — fall back to the printed URL below */
973
+ }
974
+ }
975
+ console.log('');
976
+ if (browserOpened) {
977
+ console.log(` ✓ Opened ${pairUrl} in your browser.`);
978
+ console.log(' Copy the BD-XXXXX code shown there and paste it here.');
979
+ }
980
+ else {
981
+ console.log(` Get your pairing code at ${pairUrl}`);
982
+ if (!shouldOpen) {
983
+ console.log(' (--no-browser is set; visit the URL above manually.)');
984
+ }
985
+ else {
986
+ console.log(" (Browser open failed — open the URL above manually.)");
987
+ }
988
+ }
989
+ console.log('');
990
+ const entered = await readLine(' Pairing code: ');
991
+ if (!entered) {
992
+ console.log(' → Pairing skipped (no code entered).');
993
+ return null;
994
+ }
995
+ code = entered;
996
+ }
997
+ const client = new IngestSessionClient();
998
+ try {
999
+ await client.claim({
1000
+ code,
1001
+ machineName: os.hostname() || 'unknown',
1002
+ cwd: displayCwd(process.cwd()),
1003
+ });
1004
+ console.log(' ✓ Paired with botdocs.ai\n');
1005
+ return client;
1006
+ }
1007
+ catch (err) {
1008
+ const msg = err instanceof PairingClaimError ? err.message : String(err);
1009
+ console.log(` ⚠ Could not pair: ${msg}`);
1010
+ console.log(' → Continuing without pairing.\n');
1011
+ return null;
1012
+ }
1013
+ }
770
1014
  export async function ingest(rootPath, options) {
1015
+ // Try to pair before scanning kicks off. setupPairingIfRequested NEVER
1016
+ // throws — null means "carry on unpaired". The client is closed on the
1017
+ // end-of-run paths below.
1018
+ const pair = await setupPairingIfRequested(options);
1019
+ // Wrap the body so we can finalize/cancel symmetrically regardless of
1020
+ // which dispatch path fires below. The `pair` client is null in the
1021
+ // unpaired case, so all the .emit calls inside are no-ops.
1022
+ try {
1023
+ await runIngest(rootPath, options, pair);
1024
+ if (pair)
1025
+ await pair.finalize({ uploaded: 0, failed: 0 });
1026
+ // ↑ totals=0 is a known overstatement; finalize is called from
1027
+ // uploadSkills' success path in a future revision, but for now the
1028
+ // per-skill upload_completed events carry the count for the UI.
1029
+ }
1030
+ catch (err) {
1031
+ if (pair)
1032
+ await pair.cancel(err instanceof Error ? err.message : 'ingest failed');
1033
+ throw err;
1034
+ }
1035
+ }
1036
+ async function runIngest(rootPath, options, pair) {
771
1037
  // Zero-arg dispatch: scan known on-disk locations and route through the
772
1038
  // TUI (or its fallbacks). The existing path-based code path below stays
773
1039
  // identical.
774
1040
  if (!rootPath) {
775
- await ingestDiscover(options);
1041
+ await ingestDiscover(options, pair ?? undefined);
776
1042
  return;
777
1043
  }
778
1044
  const root = path.resolve(rootPath);
779
- if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
1045
+ if (!fs.existsSync(root)) {
1046
+ console.error(`\n ✗ Not a directory: ${rootPath}\n`);
1047
+ process.exit(1);
1048
+ return;
1049
+ }
1050
+ // Refuse a symlinked entry point — same exfil class the walker fix closes
1051
+ // for nested entries. `lstatSync` reports the path's OWN type, so a
1052
+ // directory symlink doesn't silently slip past the `isDirectory()` check
1053
+ // below and let the walker recurse into the target. Fatal (not skip)
1054
+ // since the user explicitly named this path.
1055
+ if (fs.lstatSync(root).isSymbolicLink()) {
1056
+ console.error(`\n ✗ Refusing to ingest from a symlink: ${rootPath}. Pass the resolved real path instead.\n`);
1057
+ process.exit(1);
1058
+ return;
1059
+ }
1060
+ if (!fs.statSync(root).isDirectory()) {
780
1061
  console.error(`\n ✗ Not a directory: ${rootPath}\n`);
781
1062
  process.exit(1);
782
1063
  return;
@@ -842,5 +1123,15 @@ export async function ingest(rootPath, options) {
842
1123
  console.log('\n --dry-run: not uploading.\n');
843
1124
  return;
844
1125
  }
845
- await uploadSkills(skills, options);
1126
+ // The path-based branch isn't interactive — there's no "selection" step
1127
+ // (the user gave us a directory and we're uploading what's in it). Still
1128
+ // emit selection_committed so the paired web UI gets a total before the
1129
+ // upload events.
1130
+ if (pair?.isPaired) {
1131
+ pair.emit('selection_committed', {
1132
+ totalSelected: skills.length,
1133
+ candidatesScanned: detected.length,
1134
+ });
1135
+ }
1136
+ await uploadSkills(skills, options, pair ?? undefined);
846
1137
  }
@@ -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
  }