@botdocs/cli 0.9.1 → 0.10.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/dist/commands/install.d.ts +5 -0
- package/dist/commands/install.js +236 -5
- package/dist/index.js +1 -0
- package/dist/lib/canonical.js +27 -15
- package/dist/lib/convert.d.ts +84 -0
- package/dist/lib/convert.js +176 -0
- package/package.json +1 -1
|
@@ -7,6 +7,11 @@ interface InstallOptions {
|
|
|
7
7
|
* CI where backups are noise; default behavior backs up untracked or
|
|
8
8
|
* locally-edited files to `.botdocs-backup/<ts>/` before the overwrite. */
|
|
9
9
|
noBackup?: boolean;
|
|
10
|
+
/** Cross-install the skill into a DIFFERENT agent ecosystem than the one it
|
|
11
|
+
* was authored in. Comma-separated list of ecosystems, or `all` (every
|
|
12
|
+
* supported ecosystem except the skill's own source). Deterministic — no LLM.
|
|
13
|
+
* When absent, install writes the stored canonical files as today. */
|
|
14
|
+
to?: string;
|
|
10
15
|
}
|
|
11
16
|
export declare function install(rawRef: string, options: InstallOptions): Promise<void>;
|
|
12
17
|
export {};
|
package/dist/commands/install.js
CHANGED
|
@@ -3,9 +3,19 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
|
|
5
5
|
import { detectDestination } from '../lib/auto-detect.js';
|
|
6
|
+
import { SUPPORTED_ECOSYSTEMS } from '../lib/canonical.js';
|
|
7
|
+
import { convertSkillToEcosystem, parseFrontmatter, stripFrontmatter, } from '../lib/convert.js';
|
|
6
8
|
import { fingerprintContent, fingerprintFile, loadLockfile, upsertInstall, } from '../lib/lockfile.js';
|
|
7
9
|
import { backupFile, isLockfileOwnedAndUnchanged } from '../lib/backup.js';
|
|
8
10
|
import { syncLibrary } from '../lib/library-sync.js';
|
|
11
|
+
/** Humanize a slug into a Title Case label: `senior-review` → `Senior Review`. */
|
|
12
|
+
function humanizeSlug(slug) {
|
|
13
|
+
return slug
|
|
14
|
+
.split(/[-_]+/)
|
|
15
|
+
.filter((part) => part.length > 0)
|
|
16
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
17
|
+
.join(' ');
|
|
18
|
+
}
|
|
9
19
|
function parseRef(raw) {
|
|
10
20
|
const cleaned = raw.startsWith('@') ? raw.slice(1) : raw;
|
|
11
21
|
const parts = cleaned.split('/');
|
|
@@ -26,15 +36,20 @@ function buildContext(scope, slug, options) {
|
|
|
26
36
|
function ensureDir(filePath) {
|
|
27
37
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
28
38
|
}
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Write a single file's content to `dest`, honoring the additive no-op,
|
|
41
|
+
* backup-on-overwrite, and mode-restore rules. `src` is the logical source
|
|
42
|
+
* filename recorded in the lockfile. Shared by the manifest-download path and
|
|
43
|
+
* the `--to` cross-install path (which already has content in hand).
|
|
44
|
+
*/
|
|
45
|
+
function writeContentToDest(src, content, dest, mode, options, projectDir) {
|
|
31
46
|
if (fs.existsSync(dest) && !options.clean) {
|
|
32
47
|
const existingFp = fingerprintFile(dest);
|
|
33
48
|
const tmpFp = fingerprintContent(content);
|
|
34
49
|
if (existingFp === tmpFp) {
|
|
35
50
|
// Already present at same fingerprint — additive no-op. No backup
|
|
36
51
|
// needed: we're about to write the same bytes anyway.
|
|
37
|
-
return { src
|
|
52
|
+
return { src, dest, fingerprint: existingFp };
|
|
38
53
|
}
|
|
39
54
|
}
|
|
40
55
|
// About to overwrite. If the existing file isn't something we own and
|
|
@@ -60,13 +75,17 @@ async function downloadAndWrite(file, dest, options, projectDir) {
|
|
|
60
75
|
// explicitly chmod even when the value is 0o644 so a previously-executable
|
|
61
76
|
// file at the dest gets re-normalized to non-executable on update.
|
|
62
77
|
try {
|
|
63
|
-
fs.chmodSync(dest,
|
|
78
|
+
fs.chmodSync(dest, mode ?? 0o644);
|
|
64
79
|
}
|
|
65
80
|
catch {
|
|
66
81
|
// chmod can fail on Windows / network mounts. Don't fail the install
|
|
67
82
|
// over a permission cosmetic — the file still got written.
|
|
68
83
|
}
|
|
69
|
-
return { src
|
|
84
|
+
return { src, dest, fingerprint: fingerprintFile(dest) };
|
|
85
|
+
}
|
|
86
|
+
async function downloadAndWrite(file, dest, options, projectDir) {
|
|
87
|
+
const content = await fetchRawContent(file.rawUrl);
|
|
88
|
+
return writeContentToDest(file.filename, content, dest, file.mode, options, projectDir);
|
|
70
89
|
}
|
|
71
90
|
async function installSkill(ref, manifest, options, scope) {
|
|
72
91
|
const ctx = buildContext(scope, ref.slug, options);
|
|
@@ -114,6 +133,213 @@ async function installSkill(ref, manifest, options, scope) {
|
|
|
114
133
|
files: filesInstalled,
|
|
115
134
|
};
|
|
116
135
|
}
|
|
136
|
+
/** Per-agent paste instructions for Bucket C ecosystems. */
|
|
137
|
+
const MANUAL_INSTRUCTIONS = {
|
|
138
|
+
gemini: 'Add to ~/.gemini/GEMINI.md (or a project ./GEMINI.md), or @import it from there.',
|
|
139
|
+
chatgpt: "Paste into the custom GPT's Instructions field.",
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Parse and validate the `--to` value. `all` expands to every supported
|
|
143
|
+
* ecosystem EXCEPT `sourceEcosystem` (skip re-emitting the source as itself).
|
|
144
|
+
* A comma-separated list is validated against the known ecosystem set; unknown
|
|
145
|
+
* entries are a hard error.
|
|
146
|
+
*/
|
|
147
|
+
function resolveTargets(rawTo, sourceEcosystem) {
|
|
148
|
+
const value = rawTo.trim();
|
|
149
|
+
if (value.toLowerCase() === 'all') {
|
|
150
|
+
return SUPPORTED_ECOSYSTEMS.filter((e) => e !== sourceEcosystem);
|
|
151
|
+
}
|
|
152
|
+
const requested = value
|
|
153
|
+
.split(',')
|
|
154
|
+
.map((s) => s.trim())
|
|
155
|
+
.filter((s) => s.length > 0);
|
|
156
|
+
const unknown = requested.filter((e) => !SUPPORTED_ECOSYSTEMS.includes(e));
|
|
157
|
+
if (unknown.length > 0) {
|
|
158
|
+
console.error(`\n ✗ Unknown ecosystem(s) for --to: ${unknown.join(', ')}.\n` +
|
|
159
|
+
` Supported: ${SUPPORTED_ECOSYSTEMS.join(', ')} (or "all").\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
// De-dup while preserving order.
|
|
163
|
+
return [...new Set(requested)];
|
|
164
|
+
}
|
|
165
|
+
/** Does this filename look like a primary skill doc? Ordered by priority. */
|
|
166
|
+
function primaryDocRank(filename) {
|
|
167
|
+
const f = filename.toLowerCase();
|
|
168
|
+
if (f.endsWith('/skill.md') || f === 'skill.md')
|
|
169
|
+
return 0;
|
|
170
|
+
if (f.endsWith('/agent.md') || f === 'agent.md')
|
|
171
|
+
return 1;
|
|
172
|
+
if (f.endsWith('.md') || f.endsWith('.mdc'))
|
|
173
|
+
return 2;
|
|
174
|
+
return 3;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Download every file in a skill and distil its essence: the primary doc
|
|
178
|
+
* (name/description from frontmatter, body with frontmatter stripped) plus the
|
|
179
|
+
* remaining files as adjacent files keyed by a path relative to the primary
|
|
180
|
+
* doc's directory.
|
|
181
|
+
*/
|
|
182
|
+
async function extractEssence(skill) {
|
|
183
|
+
if (skill.files.length === 0)
|
|
184
|
+
return null;
|
|
185
|
+
// Pick the primary doc deterministically: best rank, then shortest path
|
|
186
|
+
// (a top-level SKILL.md beats a nested one), then lexicographic.
|
|
187
|
+
const sorted = [...skill.files].sort((a, b) => {
|
|
188
|
+
const ra = primaryDocRank(a.filename);
|
|
189
|
+
const rb = primaryDocRank(b.filename);
|
|
190
|
+
if (ra !== rb)
|
|
191
|
+
return ra - rb;
|
|
192
|
+
const da = a.filename.split('/').length;
|
|
193
|
+
const db = b.filename.split('/').length;
|
|
194
|
+
if (da !== db)
|
|
195
|
+
return da - db;
|
|
196
|
+
return a.filename.localeCompare(b.filename);
|
|
197
|
+
});
|
|
198
|
+
const primary = sorted[0];
|
|
199
|
+
if (primaryDocRank(primary.filename) === 3)
|
|
200
|
+
return null; // no markdown doc at all
|
|
201
|
+
const primaryRaw = await fetchRawContent(primary.rawUrl);
|
|
202
|
+
const fm = parseFrontmatter(primaryRaw);
|
|
203
|
+
// Adjacent files are everything else, made relative to the primary doc's
|
|
204
|
+
// directory so they re-nest correctly under the target's <slug>/ dir.
|
|
205
|
+
const primaryDir = primary.filename.includes('/')
|
|
206
|
+
? primary.filename.slice(0, primary.filename.lastIndexOf('/') + 1)
|
|
207
|
+
: '';
|
|
208
|
+
const adjacentFiles = [];
|
|
209
|
+
for (const file of skill.files) {
|
|
210
|
+
if (file.filename === primary.filename)
|
|
211
|
+
continue;
|
|
212
|
+
const relPath = file.filename.startsWith(primaryDir)
|
|
213
|
+
? file.filename.slice(primaryDir.length)
|
|
214
|
+
: file.filename;
|
|
215
|
+
const content = await fetchRawContent(file.rawUrl);
|
|
216
|
+
adjacentFiles.push({ relPath, content, mode: file.mode ?? 0o644 });
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
slug: skill.ref.slug,
|
|
220
|
+
primaryBody: stripFrontmatter(primaryRaw),
|
|
221
|
+
name: fm.name ?? humanizeSlug(skill.ref.slug),
|
|
222
|
+
description: fm.description ?? '',
|
|
223
|
+
adjacentFiles,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Cross-install one skill into the requested target ecosystems. Each target's
|
|
228
|
+
* converted files run through the same `detectDestination` + write path as a
|
|
229
|
+
* normal install; Bucket C (`manual`) files are collected for printing instead
|
|
230
|
+
* of written.
|
|
231
|
+
*/
|
|
232
|
+
async function crossInstallSkill(skill, targets, options, scope) {
|
|
233
|
+
const essence = await extractEssence(skill);
|
|
234
|
+
const ctx = buildContext(scope, skill.ref.slug, options);
|
|
235
|
+
const filesInstalled = [];
|
|
236
|
+
const perTarget = [];
|
|
237
|
+
for (const target of targets) {
|
|
238
|
+
const result = {
|
|
239
|
+
ecosystem: target,
|
|
240
|
+
installed: [],
|
|
241
|
+
manual: [],
|
|
242
|
+
droppedAdjacent: [],
|
|
243
|
+
};
|
|
244
|
+
perTarget.push(result);
|
|
245
|
+
if (!essence)
|
|
246
|
+
continue; // nothing to convert — skill has no markdown doc
|
|
247
|
+
const converted = convertSkillToEcosystem(target, essence);
|
|
248
|
+
result.droppedAdjacent = converted.droppedAdjacent;
|
|
249
|
+
for (const file of converted.files) {
|
|
250
|
+
const detection = detectDestination(file.filename, ctx);
|
|
251
|
+
if (detection.kind === 'skip')
|
|
252
|
+
continue;
|
|
253
|
+
if (detection.kind === 'manual') {
|
|
254
|
+
result.manual.push({ ecosystem: target, filename: file.filename, content: file.content });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const installed = writeContentToDest(file.filename, file.content, detection.dest, file.mode, options, ctx.projectDir);
|
|
258
|
+
result.installed.push(installed);
|
|
259
|
+
filesInstalled.push(installed);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const entry = {
|
|
263
|
+
ref: `@${skill.ref.username}/${skill.ref.slug}`,
|
|
264
|
+
type: 'SKILL',
|
|
265
|
+
version: skill.version,
|
|
266
|
+
installedAt: new Date().toISOString(),
|
|
267
|
+
files: filesInstalled,
|
|
268
|
+
};
|
|
269
|
+
return { entry, perTarget };
|
|
270
|
+
}
|
|
271
|
+
/** Print human-readable cross-install output for one skill (non-JSON mode). */
|
|
272
|
+
function reportCrossInstall(refStr, perTarget) {
|
|
273
|
+
for (const t of perTarget) {
|
|
274
|
+
if (t.manual.length > 0) {
|
|
275
|
+
for (const m of t.manual) {
|
|
276
|
+
console.log(`\n ${t.ecosystem}: manual paste required.`);
|
|
277
|
+
const instr = MANUAL_INSTRUCTIONS[t.ecosystem];
|
|
278
|
+
if (instr)
|
|
279
|
+
console.log(` ${instr}`);
|
|
280
|
+
console.log(`\n${m.content}\n`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
console.log(` ${refStr} → ${t.ecosystem}: ${t.installed.length} file(s)`);
|
|
285
|
+
}
|
|
286
|
+
if (t.droppedAdjacent.length > 0) {
|
|
287
|
+
console.log(` ⚠ ${t.ecosystem} is single-file — dropped ${t.droppedAdjacent.length} adjacent file(s): ${t.droppedAdjacent.join(', ')}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* `--to` flow: cross-install a stored skill (or every skill in a bundle) into
|
|
293
|
+
* one or more DIFFERENT agent ecosystems, deterministically. Mirrors the main
|
|
294
|
+
* install path's lockfile + library-sync behavior.
|
|
295
|
+
*/
|
|
296
|
+
async function crossInstall(refStr, manifest, options, scope) {
|
|
297
|
+
const skills = manifest.type === 'SKILL'
|
|
298
|
+
? [{ ref: manifest.ref, version: manifest.version, sourceEcosystem: manifest.sourceEcosystem, files: manifest.files }]
|
|
299
|
+
: manifest.skills.map((s) => ({
|
|
300
|
+
ref: s.ref,
|
|
301
|
+
version: s.version,
|
|
302
|
+
sourceEcosystem: s.sourceEcosystem,
|
|
303
|
+
files: s.files,
|
|
304
|
+
}));
|
|
305
|
+
const summaries = [];
|
|
306
|
+
const jsonInstalled = [];
|
|
307
|
+
const jsonManual = [];
|
|
308
|
+
const jsonDropped = {};
|
|
309
|
+
if (!options.json)
|
|
310
|
+
console.log(`\n ✓ Cross-installing ${refStr} → ${options.to}`);
|
|
311
|
+
for (const skill of skills) {
|
|
312
|
+
const targets = resolveTargets(options.to, skill.sourceEcosystem);
|
|
313
|
+
const { entry, perTarget } = await crossInstallSkill(skill, targets, options, scope);
|
|
314
|
+
upsertInstall(entry);
|
|
315
|
+
summaries.push(entry);
|
|
316
|
+
if (options.json) {
|
|
317
|
+
for (const t of perTarget) {
|
|
318
|
+
if (t.installed.length > 0) {
|
|
319
|
+
jsonInstalled.push({ ecosystem: t.ecosystem, files: t.installed.map((f) => f.dest) });
|
|
320
|
+
}
|
|
321
|
+
jsonManual.push(...t.manual);
|
|
322
|
+
if (t.droppedAdjacent.length > 0)
|
|
323
|
+
jsonDropped[t.ecosystem] = t.droppedAdjacent;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
reportCrossInstall(entry.ref, perTarget);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (options.json) {
|
|
331
|
+
console.log(JSON.stringify({
|
|
332
|
+
ref: refStr,
|
|
333
|
+
installed: jsonInstalled,
|
|
334
|
+
manual: jsonManual.map((m) => ({ ecosystem: m.ecosystem, content: m.content })),
|
|
335
|
+
dropped: jsonDropped,
|
|
336
|
+
}));
|
|
337
|
+
await syncLibrary();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
console.log('');
|
|
341
|
+
await syncLibrary();
|
|
342
|
+
}
|
|
117
343
|
export async function install(rawRef, options) {
|
|
118
344
|
const ref = parseRef(rawRef);
|
|
119
345
|
const refStr = `@${ref.username}/${ref.slug}`;
|
|
@@ -150,6 +376,11 @@ export async function install(rawRef, options) {
|
|
|
150
376
|
}
|
|
151
377
|
throw err;
|
|
152
378
|
}
|
|
379
|
+
// `--to`: cross-install into a different ecosystem (deterministic, no LLM).
|
|
380
|
+
if (options.to) {
|
|
381
|
+
await crossInstall(refStr, manifest, options, ref.username);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
153
384
|
const installedSummaries = [];
|
|
154
385
|
if (manifest.type === 'SKILL') {
|
|
155
386
|
const entry = await installSkill(ref, manifest, options, ref.username);
|
package/dist/index.js
CHANGED
|
@@ -111,6 +111,7 @@ program
|
|
|
111
111
|
.option('--flat', 'Skip the {scope} subdirectory in install paths (collision-prone, not recommended)')
|
|
112
112
|
.option('--clean', 'Wipe-and-reinstall instead of additive')
|
|
113
113
|
.option('--no-backup', 'Do not back up files that would be overwritten (use in CI where backups are noise)')
|
|
114
|
+
.option('--to <ecosystems>', 'Install into a DIFFERENT agent ecosystem than the source (comma-separated list, or "all"). Deterministic, no LLM.')
|
|
114
115
|
.action(async (ref, opts) => {
|
|
115
116
|
// Commander's --no-backup convention sets opts.backup = false.
|
|
116
117
|
// Translate to options.noBackup for downstream consumers.
|
package/dist/lib/canonical.js
CHANGED
|
@@ -20,11 +20,13 @@ const ECOSYSTEM_FILE_GLOB = {
|
|
|
20
20
|
'claude-code': ['claude-code/commands'],
|
|
21
21
|
cursor: ['cursor/rules'],
|
|
22
22
|
chatgpt: ['chatgpt'],
|
|
23
|
+
// Nested SKILL.md ecosystems store under <prefix>/<slug>/SKILL.md — the
|
|
24
|
+
// detection dir is the ecosystem prefix (post-#99 canonical layout).
|
|
23
25
|
codex: ['codex'],
|
|
24
26
|
copilot: ['copilot/instructions'],
|
|
25
27
|
antigravity: ['antigravity/skills'],
|
|
26
|
-
gemini: ['gemini
|
|
27
|
-
opencode: ['opencode
|
|
28
|
+
gemini: ['gemini'],
|
|
29
|
+
opencode: ['opencode'],
|
|
28
30
|
windsurf: ['windsurf/rules'],
|
|
29
31
|
};
|
|
30
32
|
function readSize(filePath) {
|
|
@@ -34,10 +36,15 @@ function readSize(filePath) {
|
|
|
34
36
|
if (stat.isFile())
|
|
35
37
|
return stat.size;
|
|
36
38
|
if (stat.isDirectory()) {
|
|
39
|
+
// Recurse so nested SKILL.md ecosystems (codex/<slug>/SKILL.md, etc.)
|
|
40
|
+
// contribute their content size, not just direct children.
|
|
37
41
|
let total = 0;
|
|
38
42
|
for (const entry of fs.readdirSync(filePath, { withFileTypes: true })) {
|
|
43
|
+
const child = path.join(filePath, entry.name);
|
|
39
44
|
if (entry.isFile())
|
|
40
|
-
total += fs.statSync(
|
|
45
|
+
total += fs.statSync(child).size;
|
|
46
|
+
else if (entry.isDirectory())
|
|
47
|
+
total += readSize(child);
|
|
41
48
|
}
|
|
42
49
|
return total;
|
|
43
50
|
}
|
|
@@ -68,25 +75,30 @@ export function ecosystemDestination(eco, slug) {
|
|
|
68
75
|
case 'chatgpt':
|
|
69
76
|
return `chatgpt/${slug}.md`;
|
|
70
77
|
case 'codex':
|
|
71
|
-
|
|
78
|
+
// Codex skills are nested SKILL.md directories at ~/.codex/skills/<slug>/
|
|
79
|
+
// (developers.openai.com/codex/skills). The canonical source mirror is
|
|
80
|
+
// codex/<slug>/SKILL.md — matches the ingest DETECTORS layout post-#99.
|
|
81
|
+
return `codex/${slug}/SKILL.md`;
|
|
72
82
|
case 'copilot':
|
|
73
83
|
// GitHub Copilot custom instructions:
|
|
74
84
|
// https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
|
|
75
85
|
return `copilot/instructions/${slug}.instructions.md`;
|
|
76
86
|
case 'antigravity':
|
|
77
|
-
// Google Antigravity skills
|
|
78
|
-
// ~/.gemini/antigravity/skills/<slug
|
|
79
|
-
//
|
|
80
|
-
return `antigravity/skills/${slug}.md`;
|
|
87
|
+
// Google Antigravity skills are nested SKILL.md directories
|
|
88
|
+
// (antigravity.google/docs/skills): ~/.gemini/antigravity/skills/<slug>/
|
|
89
|
+
// and <proj>/.agent/skills/<slug>/. Source mirror is the nested form.
|
|
90
|
+
return `antigravity/skills/${slug}/SKILL.md`;
|
|
81
91
|
case 'gemini':
|
|
82
|
-
// Gemini CLI
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
92
|
+
// Gemini CLI has no per-skill file directory — it uses hierarchical
|
|
93
|
+
// GEMINI.md context files. We keep a sensible flat source mirror at
|
|
94
|
+
// gemini/<slug>.md, but install routes it to manual paste (no on-disk
|
|
95
|
+
// skill convention to write to).
|
|
96
|
+
return `gemini/${slug}.md`;
|
|
86
97
|
case 'opencode':
|
|
87
|
-
// OpenCode
|
|
88
|
-
// ~/.config/opencode/
|
|
89
|
-
|
|
98
|
+
// OpenCode skills are nested SKILL.md directories (opencode.ai/docs/skills):
|
|
99
|
+
// ~/.config/opencode/skills/<slug>/ and <proj>/.opencode/skills/<slug>/.
|
|
100
|
+
// Source mirror is the nested form.
|
|
101
|
+
return `opencode/${slug}/SKILL.md`;
|
|
90
102
|
case 'windsurf':
|
|
91
103
|
// Windsurf (Codeium) project rules:
|
|
92
104
|
// https://docs.codeium.com/windsurf/cascade#windsurfrules — files live
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic skill → ecosystem converter (consume-time).
|
|
3
|
+
*
|
|
4
|
+
* `botdocs install @ref --to <agent>` installs a stored skill into a DIFFERENT
|
|
5
|
+
* agent ecosystem than the one it was authored in, with no LLM. This module is
|
|
6
|
+
* the pure, dependency-free core: given the essence of a skill (slug, primary
|
|
7
|
+
* body, name/description, adjacent files) it re-emits the canonical files for a
|
|
8
|
+
* target ecosystem, using the SAME target-canonical-prefixed filenames that
|
|
9
|
+
* `botdocs ingest` would have stored — so the existing install write loop +
|
|
10
|
+
* `detectDestination` route placement unchanged.
|
|
11
|
+
*
|
|
12
|
+
* Targets fall in three buckets (see the README / PR body for the model):
|
|
13
|
+
*
|
|
14
|
+
* - Bucket A — SKILL.md standard (`claude`, `codex`, `antigravity`,
|
|
15
|
+
* `opencode`): identical `<slug>/SKILL.md` (+ adjacent files) format; only
|
|
16
|
+
* the install dir differs. Re-emit the canonical SKILL.md with name/
|
|
17
|
+
* description frontmatter + carry adjacent files under the target's prefix.
|
|
18
|
+
* - Bucket B — rule/instruction reshape (`cursor`, `copilot`, `windsurf`,
|
|
19
|
+
* `claude-code`): single-file. Take the primary doc body and re-emit it with
|
|
20
|
+
* the target's activation frontmatter. Adjacent files are dropped (noted).
|
|
21
|
+
* - Bucket C — paste/manual (`gemini`, `chatgpt`): no on-disk skill
|
|
22
|
+
* convention. Emit the body at the canonical prefix; install routes these to
|
|
23
|
+
* `manual` and prints them for copy-paste.
|
|
24
|
+
*/
|
|
25
|
+
import type { Ecosystem } from './canonical.js';
|
|
26
|
+
export interface Frontmatter {
|
|
27
|
+
name?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse a leading `---\n…\n---` block and return the top-level `name` and
|
|
32
|
+
* `description` scalars. Returns `{}` when there is no leading block. Only
|
|
33
|
+
* simple `key: value` lines are honored; the first occurrence of a key wins.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseFrontmatter(content: string): Frontmatter;
|
|
36
|
+
/**
|
|
37
|
+
* Remove a leading `---\n…\n---` frontmatter block, returning just the body.
|
|
38
|
+
* Returns the original (sans BOM) when there is no leading block. Also trims
|
|
39
|
+
* any blank lines the block left at the very top.
|
|
40
|
+
*/
|
|
41
|
+
export declare function stripFrontmatter(content: string): string;
|
|
42
|
+
/** The essence of a stored skill, distilled from the downloaded manifest files. */
|
|
43
|
+
export interface SkillEssence {
|
|
44
|
+
/** Skill slug (used in canonical filenames and as a name fallback). */
|
|
45
|
+
slug: string;
|
|
46
|
+
/** Primary doc body, with any source frontmatter already stripped. */
|
|
47
|
+
primaryBody: string;
|
|
48
|
+
/** Human name for SKILL.md frontmatter (falls back to slug). */
|
|
49
|
+
name: string;
|
|
50
|
+
/** One-line description for SKILL.md frontmatter (may be empty). */
|
|
51
|
+
description: string;
|
|
52
|
+
/**
|
|
53
|
+
* Adjacent files (scripts/, templates/, reference docs) relative to the skill
|
|
54
|
+
* root, with their POSIX mode. Carried into Bucket A targets; dropped (and
|
|
55
|
+
* noted) for Bucket B/C.
|
|
56
|
+
*/
|
|
57
|
+
adjacentFiles: Array<{
|
|
58
|
+
relPath: string;
|
|
59
|
+
content: string;
|
|
60
|
+
mode: number;
|
|
61
|
+
}>;
|
|
62
|
+
}
|
|
63
|
+
/** A single file the converter produced, ready for the install write loop. */
|
|
64
|
+
export interface ConvertedFile {
|
|
65
|
+
/** Target-canonical-prefixed filename (the shape `ingest` would store). */
|
|
66
|
+
filename: string;
|
|
67
|
+
content: string;
|
|
68
|
+
/** POSIX permission bits to restore on disk. */
|
|
69
|
+
mode: number;
|
|
70
|
+
}
|
|
71
|
+
export interface ConvertResult {
|
|
72
|
+
files: ConvertedFile[];
|
|
73
|
+
/** Adjacent file relPaths that were dropped (Bucket B single-file targets). */
|
|
74
|
+
droppedAdjacent: string[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Convert a stored skill into a target ecosystem's canonical files.
|
|
78
|
+
*
|
|
79
|
+
* Produced filenames are TARGET-canonical-prefixed (the same shape `ingest`
|
|
80
|
+
* would store), so the install write loop + `detectDestination` handle on-disk
|
|
81
|
+
* placement. Bucket C files route to `manual` at install time and are printed
|
|
82
|
+
* for copy-paste rather than written.
|
|
83
|
+
*/
|
|
84
|
+
export declare function convertSkillToEcosystem(target: Ecosystem, essence: SkillEssence): ConvertResult;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic skill → ecosystem converter (consume-time).
|
|
3
|
+
*
|
|
4
|
+
* `botdocs install @ref --to <agent>` installs a stored skill into a DIFFERENT
|
|
5
|
+
* agent ecosystem than the one it was authored in, with no LLM. This module is
|
|
6
|
+
* the pure, dependency-free core: given the essence of a skill (slug, primary
|
|
7
|
+
* body, name/description, adjacent files) it re-emits the canonical files for a
|
|
8
|
+
* target ecosystem, using the SAME target-canonical-prefixed filenames that
|
|
9
|
+
* `botdocs ingest` would have stored — so the existing install write loop +
|
|
10
|
+
* `detectDestination` route placement unchanged.
|
|
11
|
+
*
|
|
12
|
+
* Targets fall in three buckets (see the README / PR body for the model):
|
|
13
|
+
*
|
|
14
|
+
* - Bucket A — SKILL.md standard (`claude`, `codex`, `antigravity`,
|
|
15
|
+
* `opencode`): identical `<slug>/SKILL.md` (+ adjacent files) format; only
|
|
16
|
+
* the install dir differs. Re-emit the canonical SKILL.md with name/
|
|
17
|
+
* description frontmatter + carry adjacent files under the target's prefix.
|
|
18
|
+
* - Bucket B — rule/instruction reshape (`cursor`, `copilot`, `windsurf`,
|
|
19
|
+
* `claude-code`): single-file. Take the primary doc body and re-emit it with
|
|
20
|
+
* the target's activation frontmatter. Adjacent files are dropped (noted).
|
|
21
|
+
* - Bucket C — paste/manual (`gemini`, `chatgpt`): no on-disk skill
|
|
22
|
+
* convention. Emit the body at the canonical prefix; install routes these to
|
|
23
|
+
* `manual` and prints them for copy-paste.
|
|
24
|
+
*/
|
|
25
|
+
/** Strip a single matching pair of surrounding single/double quotes. */
|
|
26
|
+
function unquote(value) {
|
|
27
|
+
if (value.length >= 2) {
|
|
28
|
+
const first = value[0];
|
|
29
|
+
const last = value[value.length - 1];
|
|
30
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
31
|
+
return value.slice(1, -1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
const FRONTMATTER_FENCE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
37
|
+
const FRONTMATTER_BLOCK = /^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
38
|
+
/**
|
|
39
|
+
* Parse a leading `---\n…\n---` block and return the top-level `name` and
|
|
40
|
+
* `description` scalars. Returns `{}` when there is no leading block. Only
|
|
41
|
+
* simple `key: value` lines are honored; the first occurrence of a key wins.
|
|
42
|
+
*/
|
|
43
|
+
export function parseFrontmatter(content) {
|
|
44
|
+
if (typeof content !== 'string')
|
|
45
|
+
return {};
|
|
46
|
+
const text = content.replace(/^\uFEFF/, '');
|
|
47
|
+
const match = text.match(FRONTMATTER_FENCE);
|
|
48
|
+
if (!match)
|
|
49
|
+
return {};
|
|
50
|
+
const block = match[1] ?? '';
|
|
51
|
+
const result = {};
|
|
52
|
+
for (const rawLine of block.split(/\r?\n/)) {
|
|
53
|
+
const lineMatch = rawLine.match(/^([A-Za-z0-9_-]+)[ \t]*:[ \t]*(.*)$/);
|
|
54
|
+
if (!lineMatch)
|
|
55
|
+
continue;
|
|
56
|
+
const key = lineMatch[1];
|
|
57
|
+
if (key !== 'name' && key !== 'description')
|
|
58
|
+
continue;
|
|
59
|
+
if (result[key] !== undefined)
|
|
60
|
+
continue; // first occurrence wins
|
|
61
|
+
const value = unquote(lineMatch[2].trim()).trim();
|
|
62
|
+
if (value.length > 0)
|
|
63
|
+
result[key] = value;
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Remove a leading `---\n…\n---` frontmatter block, returning just the body.
|
|
69
|
+
* Returns the original (sans BOM) when there is no leading block. Also trims
|
|
70
|
+
* any blank lines the block left at the very top.
|
|
71
|
+
*/
|
|
72
|
+
export function stripFrontmatter(content) {
|
|
73
|
+
if (typeof content !== 'string')
|
|
74
|
+
return '';
|
|
75
|
+
const text = content.replace(/^\uFEFF/, '');
|
|
76
|
+
return text.replace(FRONTMATTER_BLOCK, '').replace(/^(?:[ \t]*\r?\n)+/, '');
|
|
77
|
+
}
|
|
78
|
+
/** Default mode for generated markdown files (non-executable). */
|
|
79
|
+
const MD_MODE = 0o644;
|
|
80
|
+
/** SKILL.md-standard targets (Bucket A) and their canonical directory prefix. */
|
|
81
|
+
const BUCKET_A_PREFIX = {
|
|
82
|
+
claude: 'claude',
|
|
83
|
+
codex: 'codex',
|
|
84
|
+
antigravity: 'antigravity/skills',
|
|
85
|
+
opencode: 'opencode',
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Bucket B single-file targets: the canonical filename for the reshaped doc and
|
|
89
|
+
* the activation frontmatter the target needs to be ACTIVE (not dormant). A
|
|
90
|
+
* `null` frontmatter means "no frontmatter" (claude-code commands are a flat
|
|
91
|
+
* body).
|
|
92
|
+
*/
|
|
93
|
+
const BUCKET_B = {
|
|
94
|
+
cursor: {
|
|
95
|
+
filename: (slug) => `cursor/rules/${slug}.mdc`,
|
|
96
|
+
// Bare .mdc is dormant/Manual; alwaysApply makes the rule active.
|
|
97
|
+
frontmatter: '---\nalwaysApply: true\n---',
|
|
98
|
+
},
|
|
99
|
+
copilot: {
|
|
100
|
+
filename: (slug) => `copilot/instructions/${slug}.instructions.md`,
|
|
101
|
+
// applyTo is required for Copilot to apply the instruction file.
|
|
102
|
+
frontmatter: '---\napplyTo: "**"\n---',
|
|
103
|
+
},
|
|
104
|
+
windsurf: {
|
|
105
|
+
filename: (slug) => `windsurf/rules/${slug}.md`,
|
|
106
|
+
frontmatter: '---\ntrigger: always_on\n---',
|
|
107
|
+
},
|
|
108
|
+
'claude-code': {
|
|
109
|
+
filename: (slug) => `claude-code/commands/${slug}.md`,
|
|
110
|
+
// Slash commands are a flat command body — no frontmatter.
|
|
111
|
+
frontmatter: null,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
/** Bucket C paste/manual targets: a single body-only file at the canonical prefix. */
|
|
115
|
+
const BUCKET_C = {
|
|
116
|
+
gemini: (slug) => `gemini/${slug}.md`,
|
|
117
|
+
chatgpt: (slug) => `chatgpt/${slug}.md`,
|
|
118
|
+
};
|
|
119
|
+
/** Build the canonical SKILL.md content: name/description frontmatter + body. */
|
|
120
|
+
function buildSkillMd(essence) {
|
|
121
|
+
const name = essence.name || essence.slug;
|
|
122
|
+
const lines = ['---', `name: ${name}`, `description: ${essence.description}`, '---', '', essence.primaryBody];
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
/** Build a Bucket B reshaped file: optional activation frontmatter + body. */
|
|
126
|
+
function buildReshaped(frontmatter, body) {
|
|
127
|
+
if (frontmatter === null)
|
|
128
|
+
return body;
|
|
129
|
+
return `${frontmatter}\n\n${body}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Convert a stored skill into a target ecosystem's canonical files.
|
|
133
|
+
*
|
|
134
|
+
* Produced filenames are TARGET-canonical-prefixed (the same shape `ingest`
|
|
135
|
+
* would store), so the install write loop + `detectDestination` handle on-disk
|
|
136
|
+
* placement. Bucket C files route to `manual` at install time and are printed
|
|
137
|
+
* for copy-paste rather than written.
|
|
138
|
+
*/
|
|
139
|
+
export function convertSkillToEcosystem(target, essence) {
|
|
140
|
+
// Bucket A — SKILL.md standard. Re-emit SKILL.md + carry adjacent files.
|
|
141
|
+
const aPrefix = BUCKET_A_PREFIX[target];
|
|
142
|
+
if (aPrefix !== undefined) {
|
|
143
|
+
const base = `${aPrefix}/${essence.slug}`;
|
|
144
|
+
const files = [{ filename: `${base}/SKILL.md`, content: buildSkillMd(essence), mode: MD_MODE }];
|
|
145
|
+
for (const adj of essence.adjacentFiles) {
|
|
146
|
+
files.push({ filename: `${base}/${adj.relPath}`, content: adj.content, mode: adj.mode });
|
|
147
|
+
}
|
|
148
|
+
return { files, droppedAdjacent: [] };
|
|
149
|
+
}
|
|
150
|
+
// Bucket B — single-file reshape. Drop (and note) adjacent files.
|
|
151
|
+
const bSpec = BUCKET_B[target];
|
|
152
|
+
if (bSpec !== undefined) {
|
|
153
|
+
return {
|
|
154
|
+
files: [
|
|
155
|
+
{
|
|
156
|
+
filename: bSpec.filename(essence.slug),
|
|
157
|
+
content: buildReshaped(bSpec.frontmatter, essence.primaryBody),
|
|
158
|
+
mode: MD_MODE,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
droppedAdjacent: essence.adjacentFiles.map((f) => f.relPath),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Bucket C — paste/manual. Body only; install prints it.
|
|
165
|
+
const cFilename = BUCKET_C[target];
|
|
166
|
+
if (cFilename !== undefined) {
|
|
167
|
+
return {
|
|
168
|
+
files: [{ filename: cFilename(essence.slug), content: essence.primaryBody, mode: MD_MODE }],
|
|
169
|
+
// Adjacent files are also dropped for paste targets — note them so the
|
|
170
|
+
// user knows scripts/templates didn't carry.
|
|
171
|
+
droppedAdjacent: essence.adjacentFiles.map((f) => f.relPath),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// Exhaustive: every Ecosystem belongs to exactly one bucket.
|
|
175
|
+
throw new Error(`convertSkillToEcosystem: unsupported target ecosystem "${target}"`);
|
|
176
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botdocs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "CLI for BotDocs — author, publish, install, and sync agent skills across Claude, Claude Code, Cursor, Codex, ChatGPT, Windsurf, Copilot, Gemini, Antigravity, and OpenCode.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"botdocs",
|