@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.
- 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 +25 -0
- package/dist/commands/ingest.js +303 -12
- 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/commands/views/login-app.js +6 -5
- package/dist/commands/views/theme.d.ts +3 -4
- package/dist/commands/views/theme.js +3 -4
- package/dist/index.js +162 -30
- 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 -3
package/dist/commands/ingest.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
430
|
+
walk(full);
|
|
409
431
|
}
|
|
410
|
-
else {
|
|
411
|
-
out.push(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
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
|
}
|