@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.
- package/README.md +7 -5
- package/bin/botdocs.cjs +78 -0
- package/dist/commands/check-updates.d.ts +22 -0
- package/dist/commands/check-updates.js +73 -18
- package/dist/commands/edit.js +10 -2
- package/dist/commands/ingest.d.ts +20 -0
- package/dist/commands/ingest.js +264 -11
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +43 -6
- package/dist/commands/install.js +146 -5
- package/dist/commands/list.js +62 -0
- package/dist/commands/login.js +56 -2
- package/dist/commands/publish.d.ts +30 -3
- package/dist/commands/publish.js +353 -40
- package/dist/commands/sync.js +252 -40
- package/dist/commands/uninstall.js +12 -0
- package/dist/commands/validate.js +82 -8
- package/dist/index.js +151 -26
- package/dist/lib/api.d.ts +55 -2
- package/dist/lib/api.js +168 -11
- package/dist/lib/auto-detect.js +70 -30
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +83 -2
- package/dist/lib/ingest-session-client.d.ts +93 -0
- package/dist/lib/ingest-session-client.js +217 -0
- package/dist/lib/lockfile.d.ts +13 -0
- package/dist/lib/manifest.d.ts +12 -0
- package/dist/lib/manifest.js +29 -2
- package/dist/lib/node-preflight.d.ts +20 -0
- package/dist/lib/node-preflight.js +11 -0
- package/dist/lib/skill-caps.d.ts +17 -0
- package/dist/lib/skill-caps.js +19 -0
- package/package.json +3 -2
package/dist/commands/ingest.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
429
|
+
walk(full);
|
|
409
430
|
}
|
|
410
|
-
else {
|
|
411
|
-
out.push(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
125
|
+
console.log(`\n Edit index.md (and the description in botdocs.json), then run botdocs publish ${name}/\n`);
|
|
89
126
|
}
|
|
90
127
|
}
|