@botdocs/cli 0.9.0 → 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.
@@ -280,12 +280,26 @@ export const DETECTORS = {
280
280
  scanPaths: () => [],
281
281
  },
282
282
  codex: {
283
+ // Codex skills are nested SKILL.md directories, mirroring claude:
284
+ // ~/.codex/skills/<slug>/SKILL.md (developers.openai.com/codex/skills).
283
285
  pathPrefix: 'codex/',
284
- extensions: ['.md'],
285
- nested: false,
286
- slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
287
- canonicalFilename: (slug) => `codex/${slug}.md`,
288
- scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.codex', 'skills')] : [],
286
+ extensions: ['/SKILL.md'],
287
+ nested: true,
288
+ slugFor: (abs, root) => {
289
+ const rel = path.relative(root, abs).split(path.sep).join('/');
290
+ if (!rel.endsWith('/SKILL.md'))
291
+ return null;
292
+ const parts = rel.split('/');
293
+ if (parts.length < 2)
294
+ return null;
295
+ return parts[parts.length - 2] ?? null;
296
+ },
297
+ canonicalFilename: (slug) => `codex/${slug}/SKILL.md`,
298
+ // Global only — Codex skills live under ~/.codex/skills/.
299
+ scanPaths: (homeDir) => [path.join(homeDir, '.codex', 'skills')],
300
+ includeAdjacent: true,
301
+ skillRoot: (abs) => path.dirname(abs),
302
+ canonicalAdjacentFilename: (slug, relPath) => `codex/${slug}/${relPath}`,
289
303
  },
290
304
  copilot: {
291
305
  pathPrefix: 'copilot/instructions/',
@@ -297,36 +311,90 @@ export const DETECTORS = {
297
311
  scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.github', 'instructions')] : [],
298
312
  },
299
313
  windsurf: {
314
+ // Windsurf reads project rules from <proj>/.windsurf/rules/<slug>.md
315
+ // (docs.windsurf.com). Flat .md rule files, project-scoped (git repo).
300
316
  pathPrefix: 'windsurf/rules/',
301
317
  extensions: ['.md'],
302
318
  nested: false,
303
319
  slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
304
320
  canonicalFilename: (slug) => `windsurf/rules/${slug}.md`,
305
- scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.codeium', 'windsurf-rules')] : [],
321
+ scanPaths: (_homeDir, projectRoot, isGitRepo) => isGitRepo ? [path.join(projectRoot, '.windsurf', 'rules')] : [],
306
322
  },
307
323
  gemini: {
324
+ // Gemini CLI has NO per-skill file directory. It uses hierarchical
325
+ // GEMINI.md context files (~/.gemini/GEMINI.md global, ./GEMINI.md
326
+ // project) — there's nothing to auto-discover and nowhere to drop a
327
+ // per-skill file. The entry stays present so the ecosystem still exists
328
+ // for compile/variants, but discovery returns nothing (like chatgpt) and
329
+ // install routes it to `manual` (see detectDestination).
308
330
  pathPrefix: 'gemini/instructions/',
309
331
  extensions: ['.md'],
310
332
  nested: false,
311
333
  slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
312
334
  canonicalFilename: (slug) => `gemini/instructions/${slug}.md`,
313
- scanPaths: (homeDir) => [path.join(homeDir, '.gemini', 'instructions')],
335
+ // No canonical on-disk location — nothing to discover.
336
+ scanPaths: () => [],
314
337
  },
315
338
  antigravity: {
339
+ // Antigravity skills are nested SKILL.md directories, mirroring claude:
340
+ // ~/.gemini/antigravity/skills/<slug>/SKILL.md (global) and
341
+ // <proj>/.agent/skills/<slug>/SKILL.md (project)
342
+ // (antigravity.google/docs/skills + Google Codelabs). Keep the existing
343
+ // `antigravity/skills/` canonical prefix, now nested-with-SKILL.md.
316
344
  pathPrefix: 'antigravity/skills/',
317
- extensions: ['.md'],
318
- nested: false,
319
- slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
320
- canonicalFilename: (slug) => `antigravity/skills/${slug}.md`,
321
- scanPaths: (homeDir) => [path.join(homeDir, '.gemini', 'antigravity', 'skills')],
345
+ extensions: ['/SKILL.md'],
346
+ nested: true,
347
+ slugFor: (abs, root) => {
348
+ const rel = path.relative(root, abs).split(path.sep).join('/');
349
+ if (!rel.endsWith('/SKILL.md'))
350
+ return null;
351
+ const parts = rel.split('/');
352
+ if (parts.length < 2)
353
+ return null;
354
+ return parts[parts.length - 2] ?? null;
355
+ },
356
+ canonicalFilename: (slug) => `antigravity/skills/${slug}/SKILL.md`,
357
+ // Global ~/.gemini/antigravity/skills always; project <proj>/.agent/skills
358
+ // only inside a git repo.
359
+ scanPaths: (homeDir, projectRoot, isGitRepo) => {
360
+ const paths = [path.join(homeDir, '.gemini', 'antigravity', 'skills')];
361
+ if (isGitRepo)
362
+ paths.push(path.join(projectRoot, '.agent', 'skills'));
363
+ return paths;
364
+ },
365
+ includeAdjacent: true,
366
+ skillRoot: (abs) => path.dirname(abs),
367
+ canonicalAdjacentFilename: (slug, relPath) => `antigravity/skills/${slug}/${relPath}`,
322
368
  },
323
369
  opencode: {
324
- pathPrefix: 'opencode/instructions/',
325
- extensions: ['.md'],
326
- nested: false,
327
- slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
328
- canonicalFilename: (slug) => `opencode/instructions/${slug}.md`,
329
- scanPaths: (homeDir) => [path.join(homeDir, '.config', 'opencode', 'instructions')],
370
+ // OpenCode skills are nested SKILL.md directories, mirroring claude:
371
+ // ~/.config/opencode/skills/<slug>/SKILL.md (global) and
372
+ // <proj>/.opencode/skills/<slug>/SKILL.md (project)
373
+ // (opencode.ai/docs/skills).
374
+ pathPrefix: 'opencode/',
375
+ extensions: ['/SKILL.md'],
376
+ nested: true,
377
+ slugFor: (abs, root) => {
378
+ const rel = path.relative(root, abs).split(path.sep).join('/');
379
+ if (!rel.endsWith('/SKILL.md'))
380
+ return null;
381
+ const parts = rel.split('/');
382
+ if (parts.length < 2)
383
+ return null;
384
+ return parts[parts.length - 2] ?? null;
385
+ },
386
+ canonicalFilename: (slug) => `opencode/${slug}/SKILL.md`,
387
+ // Global ~/.config/opencode/skills always; project <proj>/.opencode/skills
388
+ // only inside a git repo.
389
+ scanPaths: (homeDir, projectRoot, isGitRepo) => {
390
+ const paths = [path.join(homeDir, '.config', 'opencode', 'skills')];
391
+ if (isGitRepo)
392
+ paths.push(path.join(projectRoot, '.opencode', 'skills'));
393
+ return paths;
394
+ },
395
+ includeAdjacent: true,
396
+ skillRoot: (abs) => path.dirname(abs),
397
+ canonicalAdjacentFilename: (slug, relPath) => `opencode/${slug}/${relPath}`,
330
398
  },
331
399
  };
332
400
  export const SUPPORTED_TOOLS = Object.keys(DETECTORS);
@@ -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 {};
@@ -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
- async function downloadAndWrite(file, dest, options, projectDir) {
30
- const content = await fetchRawContent(file.rawUrl);
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: file.filename, dest, fingerprint: existingFp };
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, file.mode ?? 0o644);
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: file.filename, dest, fingerprint: fingerprintFile(dest) };
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
@@ -106,11 +106,12 @@ program
106
106
  });
107
107
  program
108
108
  .command('install <ref>')
109
- .description('Install a skill or bundle locally (skills go to ~/.claude/skills/, project files to .cursor/rules/, .codex/skills/, .github/instructions/, etc.)')
109
+ .description('Install a skill or bundle locally (skills go to ~/.claude/skills/, project files to .cursor/rules/, .github/instructions/, .windsurf/rules/, etc.)')
110
110
  .option('--project <dir>', 'Override the project root used for project-local files')
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.
@@ -47,10 +47,24 @@ export function detectDestination(srcRelative, ctx) {
47
47
  };
48
48
  }
49
49
  if (src.startsWith('codex/')) {
50
- return {
51
- kind: 'project',
52
- dest: path.join(ctx.projectDir, '.codex', 'skills', path.basename(src)),
53
- };
50
+ // Codex skills are nested SKILL.md directories at
51
+ // ~/.codex/skills/<slug>/SKILL.md (developers.openai.com/codex/skills).
52
+ // New canonical form: codex/<slug>/SKILL.md (+ adjacent files). Strip the
53
+ // `codex/<slug>/` prefix and keep the relpath under ~/.codex/skills/<slug>/.
54
+ //
55
+ // Backward-compat: BotDocs published before this fix stored flat
56
+ // `codex/<slug>.md`. We detect the flat form (no inner slash) and route it
57
+ // to the new nested destination ~/.codex/skills/<slug>/SKILL.md so already-
58
+ // published docs still install where Codex reads them.
59
+ const remainder = src.slice('codex/'.length);
60
+ const codexBase = path.join(ctx.homeDir, '.codex', 'skills');
61
+ if (!remainder.includes('/')) {
62
+ // Flat legacy form `codex/<slug>.md` → nested SKILL.md.
63
+ const slug = remainder.replace(/\.md$/, '');
64
+ return { kind: 'global', dest: path.join(codexBase, slug, 'SKILL.md') };
65
+ }
66
+ const finalName = remainder.replace(/^[^/]+\//, '');
67
+ return { kind: 'global', dest: path.join(codexBase, ctx.slug, finalName) };
54
68
  }
55
69
  if (src.startsWith('copilot/instructions/')) {
56
70
  // GitHub Copilot custom instructions live in .github/instructions/
@@ -61,36 +75,66 @@ export function detectDestination(srcRelative, ctx) {
61
75
  };
62
76
  }
63
77
  if (src.startsWith('windsurf/rules/')) {
64
- // Windsurf (Codeium) reads project rules from .codeium/windsurf-rules/
65
- // (https://docs.codeium.com/windsurf/cascade#windsurfrules).
78
+ // Windsurf reads project rules from <proj>/.windsurf/rules/<slug>.md
79
+ // (docs.windsurf.com). Flat .md rule, project-scoped. The canonical form
80
+ // is already flat, so basename() is the right leaf either way.
66
81
  return {
67
82
  kind: 'project',
68
- dest: path.join(ctx.projectDir, '.codeium', 'windsurf-rules', path.basename(src)),
83
+ dest: path.join(ctx.projectDir, '.windsurf', 'rules', path.basename(src)),
69
84
  };
70
85
  }
71
- if (src.startsWith('gemini/instructions/')) {
72
- // Gemini CLI reads global instructions from ~/.gemini/instructions/
73
- // (https://github.com/google-gemini/gemini-cli).
74
- return {
75
- kind: 'global',
76
- dest: path.join(ctx.homeDir, '.gemini', 'instructions', path.basename(src)),
77
- };
86
+ if (src.startsWith('gemini/')) {
87
+ // Gemini CLI has NO per-skill file directory — it uses hierarchical
88
+ // GEMINI.md context files (~/.gemini/GEMINI.md global, ./GEMINI.md
89
+ // project). There's no real path to write to, so route to `manual` (like
90
+ // chatgpt): install surfaces the content for the user to paste into their
91
+ // GEMINI.md or @import it, rather than fabricating ~/.gemini/instructions/.
92
+ return { kind: 'manual', dest: src };
78
93
  }
79
94
  if (src.startsWith('antigravity/skills/')) {
80
- // Google Antigravity reads skills from ~/.gemini/antigravity/skills/
81
- // (shares the gemini config tree).
82
- return {
83
- kind: 'global',
84
- dest: path.join(ctx.homeDir, '.gemini', 'antigravity', 'skills', path.basename(src)),
85
- };
95
+ // Antigravity skills are nested SKILL.md directories
96
+ // (antigravity.google/docs/skills). Project: <proj>/.agent/skills/<slug>/…
97
+ // Global mirror: ~/.gemini/antigravity/skills/<slug>/… We prefer the
98
+ // project destination when a project dir applies (matching cursor/codex-
99
+ // commands which default to project).
100
+ //
101
+ // New canonical form: antigravity/skills/<slug>/SKILL.md (+ adjacent).
102
+ // Strip the `antigravity/skills/<slug>/` prefix, keep the relpath.
103
+ //
104
+ // Backward-compat: docs published before this fix stored flat
105
+ // `antigravity/skills/<slug>.md`. Detect the flat form and route it to the
106
+ // new nested destination so already-published docs still install.
107
+ const remainder = src.slice('antigravity/skills/'.length);
108
+ const agentBase = path.join(ctx.projectDir, '.agent', 'skills');
109
+ if (!remainder.includes('/')) {
110
+ const slug = remainder.replace(/\.md$/, '');
111
+ return { kind: 'project', dest: path.join(agentBase, slug, 'SKILL.md') };
112
+ }
113
+ const finalName = remainder.replace(/^[^/]+\//, '');
114
+ return { kind: 'project', dest: path.join(agentBase, ctx.slug, finalName) };
86
115
  }
87
- if (src.startsWith('opencode/instructions/')) {
88
- // OpenCode (SST) reads instructions from ~/.config/opencode/instructions/
89
- // (https://github.com/sst/opencode).
90
- return {
91
- kind: 'global',
92
- dest: path.join(ctx.homeDir, '.config', 'opencode', 'instructions', path.basename(src)),
93
- };
116
+ if (src.startsWith('opencode/')) {
117
+ // OpenCode skills are nested SKILL.md directories (opencode.ai/docs/skills).
118
+ // Project: <proj>/.opencode/skills/<slug>/… Global mirror:
119
+ // ~/.config/opencode/skills/<slug>/… We prefer the project destination
120
+ // when a project dir applies (matching cursor/codex-commands).
121
+ //
122
+ // New canonical form: opencode/<slug>/SKILL.md (+ adjacent). Strip the
123
+ // `opencode/<slug>/` prefix, keep the relpath.
124
+ //
125
+ // Backward-compat: docs published before this fix stored flat
126
+ // `opencode/instructions/<slug>.md`. Detect that legacy prefix and route
127
+ // it to the new nested destination so already-published docs still install.
128
+ const opencodeBase = path.join(ctx.projectDir, '.opencode', 'skills');
129
+ if (src.startsWith('opencode/instructions/')) {
130
+ const slug = path.basename(src).replace(/\.md$/, '');
131
+ return { kind: 'project', dest: path.join(opencodeBase, slug, 'SKILL.md') };
132
+ }
133
+ const remainder = src.slice('opencode/'.length);
134
+ const finalName = remainder.includes('/')
135
+ ? remainder.replace(/^[^/]+\//, '')
136
+ : remainder;
137
+ return { kind: 'project', dest: path.join(opencodeBase, ctx.slug, finalName) };
94
138
  }
95
139
  if (src.startsWith('chatgpt/')) {
96
140
  return { kind: 'manual', dest: src };
@@ -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/instructions'],
27
- opencode: ['opencode/instructions'],
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(path.join(filePath, entry.name)).size;
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
- return `codex/${slug}.md`;
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 live under the gemini config tree at
78
- // ~/.gemini/antigravity/skills/<slug>.md — see install destination in
79
- // `auto-detect.ts`. The source mirror is `antigravity/skills/<slug>.md`.
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 reads markdown instructions from ~/.gemini/instructions/
83
- // (https://github.com/google-gemini/gemini-cli). Source path mirrors
84
- // the install destination.
85
- return `gemini/instructions/${slug}.md`;
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 (SST) reads markdown instructions from
88
- // ~/.config/opencode/instructions/ (https://github.com/sst/opencode).
89
- return `opencode/instructions/${slug}.md`;
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.9.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",