@botdocs/cli 0.2.0 → 0.3.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.
Files changed (90) hide show
  1. package/README.md +119 -2
  2. package/dist/commands/check-updates.d.ts +6 -0
  3. package/dist/commands/check-updates.js +77 -0
  4. package/dist/commands/check-updates.test.d.ts +1 -0
  5. package/dist/commands/check-updates.test.js +128 -0
  6. package/dist/commands/compile.d.ts +9 -0
  7. package/dist/commands/compile.js +93 -0
  8. package/dist/commands/compile.test.d.ts +1 -0
  9. package/dist/commands/compile.test.js +110 -0
  10. package/dist/commands/edit.d.ts +7 -0
  11. package/dist/commands/edit.js +105 -0
  12. package/dist/commands/edit.test.d.ts +1 -0
  13. package/dist/commands/edit.test.js +102 -0
  14. package/dist/commands/ingest.d.ts +7 -0
  15. package/dist/commands/ingest.js +101 -0
  16. package/dist/commands/ingest.test.d.ts +1 -0
  17. package/dist/commands/ingest.test.js +109 -0
  18. package/dist/commands/init.d.ts +1 -0
  19. package/dist/commands/init.js +34 -1
  20. package/dist/commands/install-instructions.d.ts +8 -0
  21. package/dist/commands/install-instructions.js +88 -0
  22. package/dist/commands/install.d.ts +8 -0
  23. package/dist/commands/install.js +143 -0
  24. package/dist/commands/install.test.d.ts +1 -0
  25. package/dist/commands/install.test.js +253 -0
  26. package/dist/commands/list.d.ts +6 -0
  27. package/dist/commands/list.js +35 -0
  28. package/dist/commands/list.test.d.ts +1 -0
  29. package/dist/commands/list.test.js +51 -0
  30. package/dist/commands/login.d.ts +5 -1
  31. package/dist/commands/login.js +8 -1
  32. package/dist/commands/publish.d.ts +1 -0
  33. package/dist/commands/publish.js +37 -0
  34. package/dist/commands/publish.test.d.ts +1 -0
  35. package/dist/commands/publish.test.js +76 -0
  36. package/dist/commands/sync.d.ts +7 -0
  37. package/dist/commands/sync.js +161 -0
  38. package/dist/commands/sync.test.d.ts +1 -0
  39. package/dist/commands/sync.test.js +263 -0
  40. package/dist/commands/uninstall.d.ts +5 -0
  41. package/dist/commands/uninstall.js +31 -0
  42. package/dist/commands/uninstall.test.d.ts +1 -0
  43. package/dist/commands/uninstall.test.js +67 -0
  44. package/dist/commands/validate.js +20 -5
  45. package/dist/index.js +86 -2
  46. package/dist/lib/auto-detect.d.ts +13 -0
  47. package/dist/lib/auto-detect.js +34 -0
  48. package/dist/lib/auto-detect.test.d.ts +1 -0
  49. package/dist/lib/auto-detect.test.js +58 -0
  50. package/dist/lib/canonical.d.ts +5 -0
  51. package/dist/lib/canonical.js +68 -0
  52. package/dist/lib/canonical.test.d.ts +1 -0
  53. package/dist/lib/canonical.test.js +48 -0
  54. package/dist/lib/config.d.ts +1 -0
  55. package/dist/lib/diff.d.ts +2 -0
  56. package/dist/lib/diff.js +36 -0
  57. package/dist/lib/diff.test.d.ts +1 -0
  58. package/dist/lib/diff.test.js +28 -0
  59. package/dist/lib/library-sync.d.ts +8 -0
  60. package/dist/lib/library-sync.js +30 -0
  61. package/dist/lib/library-sync.test.d.ts +1 -0
  62. package/dist/lib/library-sync.test.js +63 -0
  63. package/dist/lib/llm.d.ts +26 -0
  64. package/dist/lib/llm.js +61 -0
  65. package/dist/lib/llm.test.d.ts +1 -0
  66. package/dist/lib/llm.test.js +72 -0
  67. package/dist/lib/lockfile.d.ts +30 -0
  68. package/dist/lib/lockfile.js +70 -0
  69. package/dist/lib/lockfile.test.d.ts +1 -0
  70. package/dist/lib/lockfile.test.js +99 -0
  71. package/dist/lib/manifest.d.ts +18 -0
  72. package/dist/lib/manifest.js +77 -0
  73. package/dist/lib/manifest.test.d.ts +1 -0
  74. package/dist/lib/manifest.test.js +72 -0
  75. package/dist/lib/prompts.d.ts +5 -0
  76. package/dist/lib/prompts.js +26 -0
  77. package/dist/lib/shell-hook.d.ts +12 -0
  78. package/dist/lib/shell-hook.js +80 -0
  79. package/dist/lib/shell-hook.test.d.ts +1 -0
  80. package/dist/lib/shell-hook.test.js +68 -0
  81. package/dist/test-utils.d.ts +43 -0
  82. package/dist/test-utils.js +101 -0
  83. package/package.json +8 -2
  84. package/templates/agents.md +126 -0
  85. package/templates/ecosystem-prompts/compile-chatgpt.md +9 -0
  86. package/templates/ecosystem-prompts/compile-claude-code.md +11 -0
  87. package/templates/ecosystem-prompts/compile-claude.md +20 -0
  88. package/templates/ecosystem-prompts/compile-codex.md +8 -0
  89. package/templates/ecosystem-prompts/compile-cursor.md +11 -0
  90. package/templates/ecosystem-prompts/edit.md +12 -0
package/README.md CHANGED
@@ -54,15 +54,24 @@ botdocs endorse @alice/agent-router --rating positive \
54
54
 
55
55
  | Command | Purpose |
56
56
  |---|---|
57
- | `init [name]` | Scaffold a new BotDoc directory with an `index.md` template. |
57
+ | `init [name]` | Scaffold a new BotDoc directory (`--canonical` for a multi-ecosystem skill). |
58
+ | `compile <path>` | Generate per-ecosystem skill drafts from a canonical source (BYOK). |
59
+ | `edit <ref>` | LLM-assisted revision of a published skill ecosystem file (BYOK). |
58
60
  | `validate <source>` | Pre-publish structural check on a directory or file. |
59
61
  | `clone <user/slug>` | Download every file in a BotDoc to a local directory. |
60
62
  | `search <query>` | Search the public registry. |
61
63
  | `publish <source>` | Publish from a file, directory, or zip archive. |
62
64
  | `diff <user/slug>` | Preview remote changes before pulling. |
63
65
  | `pull <user/slug>` | Update a previously-cloned BotDoc. |
66
+ | `install <ref>` | Install a skill or bundle (auto-detects destinations). |
67
+ | `sync [ref]` | Check installed skills/bundles for updates and apply. |
68
+ | `uninstall <ref>` | Remove an installed skill or bundle. |
69
+ | `list` | Show installed skills and bundles. |
70
+ | `ingest <path>` | Walk a directory, detect existing skills, upload as drafts. |
64
71
  | `endorse <user/slug>` | Rate a BotDoc after you've built from it (requires a prior clone). |
65
- | `login` | Authenticate via the GitHub device-code flow. |
72
+ | `check-updates` | Check installed refs for available updates (1h cached). |
73
+ | `install-instructions [target]` | Write/refresh `AGENTS.md` (or install a shell hook with `--shell-hook`). |
74
+ | `login` | Authenticate via the GitHub device-code flow (`--sync-library` enables `/library`). |
66
75
  | `whoami` | Show the currently authenticated user. |
67
76
 
68
77
  Every command accepts `--json` for machine-readable output.
@@ -78,6 +87,114 @@ Run `botdocs <command> --help` for full flags on any command.
78
87
  Auth is stored at `~/.botdocs/auth.json` after `botdocs login`. Delete it
79
88
  to log out.
80
89
 
90
+ ## Teaching agents to use this CLI
91
+
92
+ Run once in any project to drop a managed `AGENTS.md` block that teaches
93
+ Claude Code, Cursor, Codex, and Copilot how to invoke the CLI:
94
+
95
+ ```bash
96
+ botdocs install-instructions
97
+ ```
98
+
99
+ If `AGENTS.md` already exists with markers from a prior run, the block is
100
+ refreshed in place. Use `--print` to dump the snippet to stdout instead of
101
+ writing.
102
+
103
+ ## Library + update notifications
104
+
105
+ Enable the personalized Library page at https://botdocs.ai/library:
106
+
107
+ ```bash
108
+ botdocs login --sync-library
109
+ ```
110
+
111
+ After install/sync/uninstall, the CLI uploads a sanitized snapshot
112
+ (refs + versions only — never file contents) so the web library can
113
+ show what you have, what your team has, and what's new. Privacy-conscious
114
+ users can leave the flag off; the CLI no-ops silently and the page shows
115
+ an onboarding banner with the command.
116
+
117
+ Set up a shell hook so new terminals show pending update notices:
118
+
119
+ ```bash
120
+ botdocs install-instructions --shell-hook
121
+ ```
122
+
123
+ Detects your shell (zsh/bash/fish), writes a marker-delimited block to
124
+ the rc file, and runs `botdocs check-updates --quiet` on shell open.
125
+ Silent when there's nothing to report. Remove with
126
+ `--remove-shell-hook` when you want it gone.
127
+
128
+ Or check manually anytime:
129
+
130
+ ```bash
131
+ botdocs check-updates # full list of pending updates
132
+ botdocs check-updates --quiet # one-liner only when updates pending
133
+ ```
134
+
135
+ `check-updates` caches the response for 1 hour so opening many terminals
136
+ in quick succession doesn't hammer the API.
137
+
138
+ ## Multi-ecosystem authoring (BYOK)
139
+
140
+ Authors write a skill once in their preferred ecosystem (Claude Code,
141
+ Cursor, etc.) and use their own LLM key to generate the other formats
142
+ locally. The server never runs inference — your file content stays on
143
+ your machine.
144
+
145
+ ```bash
146
+ export BOTDOCS_ANTHROPIC_KEY=sk-ant-... # or BOTDOCS_OPENAI_KEY=sk-...
147
+
148
+ botdocs init my-skill --canonical # scaffolds claude-code source
149
+ # + ecosystems list in botdocs.json
150
+ # edit claude-code/commands/my-skill.md
151
+
152
+ botdocs compile my-skill/ # generates claude/SKILL.md,
153
+ # cursor/rules/my-skill.mdc, etc.
154
+
155
+ botdocs publish my-skill/ # auto-compiles if stale; --no-compile to skip
156
+ ```
157
+
158
+ To revise a published file later via the LLM:
159
+
160
+ ```bash
161
+ botdocs edit @you/my-skill --ecosystem cursor
162
+ # enter your change in plain English
163
+ # review the diff, accept to push as a draft
164
+ # visit botdocs.ai/@you/my-skill to publish
165
+ ```
166
+
167
+ `compile` and `edit` both prefer `BOTDOCS_ANTHROPIC_KEY` (Claude
168
+ Haiku) when set, otherwise fall back to `BOTDOCS_OPENAI_KEY`
169
+ (GPT-4o mini). Use `--key-env <NAME>` to point at a different env var.
170
+
171
+ ## Skills + bundles
172
+
173
+ Skills are bundles of files that ship to specific destinations on disk
174
+ (Claude skills, Cursor rules, Claude Code commands). A bundle is a
175
+ curated playlist of skills for an org or team. Two key flows:
176
+
177
+ **Install a team's bundle** — files land in the right places automatically:
178
+
179
+ ```bash
180
+ botdocs install @teamco/eng-skills
181
+ ```
182
+
183
+ **Stay current** — walks the lockfile, applies clean updates, prompts
184
+ on conflicts (skip or overwrite with double confirm):
185
+
186
+ ```bash
187
+ botdocs sync
188
+ ```
189
+
190
+ `botdocs list` shows what you have installed; `botdocs uninstall <ref>`
191
+ removes it.
192
+
193
+ Authors who want to share their existing collection of skills run
194
+ `botdocs ingest <path>` — the CLI walks the directory, detects each
195
+ skill, and uploads them as drafts in your BotDocs account for review
196
+ before publishing.
197
+
81
198
  ## Endorsing
82
199
 
83
200
  Endorsements are reserved for builders who actually used the spec — the
@@ -0,0 +1,6 @@
1
+ interface CheckUpdatesOptions {
2
+ quiet?: boolean;
3
+ json?: boolean;
4
+ }
5
+ export declare function checkUpdates(options: CheckUpdatesOptions): Promise<void>;
6
+ export {};
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { apiFetch } from '../lib/api.js';
6
+ import { loadLockfile } from '../lib/lockfile.js';
7
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
8
+ function cachePath() {
9
+ return path.join(os.homedir(), '.botdocs', 'check-updates-cache.json');
10
+ }
11
+ /** Hash of (ref, version) tuples — invalidates the cache when the user's
12
+ * installed set changes (e.g. new install, version bump after sync). */
13
+ function lockfileFingerprint() {
14
+ const lf = loadLockfile();
15
+ const summary = lf.installs.map((i) => `${i.ref}@${i.version}`).sort().join('\n');
16
+ return createHash('sha256').update(summary).digest('hex').slice(0, 16);
17
+ }
18
+ function loadCache(currentFingerprint) {
19
+ const p = cachePath();
20
+ if (!fs.existsSync(p))
21
+ return null;
22
+ try {
23
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
24
+ if (raw.fingerprint !== currentFingerprint)
25
+ return null;
26
+ const age = Date.now() - new Date(raw.cachedAt).getTime();
27
+ if (age > CACHE_TTL_MS)
28
+ return null;
29
+ return raw.result;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function saveCache(fingerprint, result) {
36
+ fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
37
+ fs.writeFileSync(cachePath(), JSON.stringify({ cachedAt: new Date().toISOString(), fingerprint, result }, null, 2));
38
+ }
39
+ export async function checkUpdates(options) {
40
+ const fingerprint = lockfileFingerprint();
41
+ const cached = loadCache(fingerprint);
42
+ let result;
43
+ if (cached) {
44
+ result = cached;
45
+ }
46
+ else {
47
+ // Server reads from user_library.lockfile (set by syncLibrary helper).
48
+ // No body — auth-gated endpoint trusts the server-side snapshot, not the wire.
49
+ result = await apiFetch('/api/library/check-updates', {
50
+ method: 'POST',
51
+ auth: true,
52
+ });
53
+ saveCache(fingerprint, result);
54
+ }
55
+ if (options.json) {
56
+ console.log(JSON.stringify(result));
57
+ return;
58
+ }
59
+ if (options.quiet) {
60
+ if (result.updates.length > 0) {
61
+ console.log(`\x1b[2mbotdocs: ${result.updates.length} update(s) available — run \`botdocs sync\`\x1b[0m`);
62
+ }
63
+ return;
64
+ }
65
+ if (result.total === 0) {
66
+ console.log('\n All installed skills + bundles are up to date.\n');
67
+ return;
68
+ }
69
+ console.log('');
70
+ for (const u of result.updates) {
71
+ console.log(` ▸ ${u.ref}: ${u.from} → ${u.to}`);
72
+ }
73
+ for (const r of result.removed) {
74
+ console.log(` ⌀ ${r.ref}: ${r.reason}`);
75
+ }
76
+ console.log('\n Run `botdocs sync` to apply.\n');
77
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { checkUpdates } from './check-updates.js';
7
+ import { captureConsole, mockFetch } from '../test-utils.js';
8
+ import { saveLockfile } from '../lib/lockfile.js';
9
+ import { saveAuth } from '../lib/config.js';
10
+ describe('check-updates', () => {
11
+ let captured;
12
+ let restoreFetch = () => { };
13
+ const origHome = os.homedir;
14
+ let homeTmp;
15
+ beforeEach(() => {
16
+ homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-'));
17
+ os.homedir = () => homeTmp;
18
+ process.env.BOTDOCS_API_URL = 'http://test.local';
19
+ captured = captureConsole();
20
+ saveAuth({ githubToken: 't', username: 'u', displayName: 'U' });
21
+ });
22
+ afterEach(() => {
23
+ restoreFetch();
24
+ captured.restore();
25
+ fs.rmSync(homeTmp, { recursive: true, force: true });
26
+ os.homedir = origHome;
27
+ vi.restoreAllMocks();
28
+ });
29
+ it('--quiet prints a one-liner when there are updates', async () => {
30
+ saveLockfile({
31
+ version: 1,
32
+ installs: [{ ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] }],
33
+ });
34
+ const fm = mockFetch([
35
+ {
36
+ method: 'POST',
37
+ url: '/api/library/check-updates',
38
+ response: { body: { total: 1, updates: [{ ref: '@a/b', from: '1.0.0', to: '2.0.0' }], removed: [] } },
39
+ },
40
+ ]);
41
+ restoreFetch = fm.restore;
42
+ await checkUpdates({ quiet: true });
43
+ expect(captured.stdout.join('\n')).toMatch(/1 update/);
44
+ expect(captured.stdout.join('\n')).toMatch(/botdocs sync/);
45
+ });
46
+ it('--quiet prints nothing when up-to-date', async () => {
47
+ saveLockfile({ version: 1, installs: [] });
48
+ const fm = mockFetch([
49
+ { method: 'POST', url: '/api/library/check-updates', response: { body: { total: 0, updates: [], removed: [] } } },
50
+ ]);
51
+ restoreFetch = fm.restore;
52
+ await checkUpdates({ quiet: true });
53
+ expect(captured.stdout.join('\n').trim()).toBe('');
54
+ });
55
+ it('full mode prints a list of updates', async () => {
56
+ saveLockfile({
57
+ version: 1,
58
+ installs: [{ ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] }],
59
+ });
60
+ const fm = mockFetch([
61
+ {
62
+ method: 'POST',
63
+ url: '/api/library/check-updates',
64
+ response: { body: { total: 1, updates: [{ ref: '@a/b', from: '1.0.0', to: '2.0.0' }], removed: [] } },
65
+ },
66
+ ]);
67
+ restoreFetch = fm.restore;
68
+ await checkUpdates({});
69
+ expect(captured.stdout.join('\n')).toMatch(/@a\/b/);
70
+ expect(captured.stdout.join('\n')).toMatch(/1\.0\.0.*2\.0\.0/);
71
+ });
72
+ it('uses the cache when within TTL', async () => {
73
+ saveLockfile({
74
+ version: 1,
75
+ installs: [{ ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] }],
76
+ });
77
+ fs.mkdirSync(path.join(homeTmp, '.botdocs'), { recursive: true });
78
+ fs.writeFileSync(path.join(homeTmp, '.botdocs', 'check-updates-cache.json'), JSON.stringify({
79
+ cachedAt: new Date().toISOString(),
80
+ fingerprint: createHash('sha256')
81
+ .update(['@a/b@1.0.0'].sort().join('\n'))
82
+ .digest('hex')
83
+ .slice(0, 16),
84
+ result: { total: 0, updates: [], removed: [] },
85
+ }));
86
+ const fm = mockFetch([]);
87
+ restoreFetch = fm.restore;
88
+ await checkUpdates({ quiet: true });
89
+ expect(fm.calls).toHaveLength(0);
90
+ });
91
+ it('invalidates cache when the lockfile contents change', async () => {
92
+ // Pre-populate cache for an OLDER lockfile state
93
+ saveLockfile({
94
+ version: 1,
95
+ installs: [{ ref: '@a/old', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] }],
96
+ });
97
+ fs.mkdirSync(path.join(homeTmp, '.botdocs'), { recursive: true });
98
+ // Cache was written when only @a/old was installed
99
+ const staleFingerprint = createHash('sha256')
100
+ .update(['@a/old@1.0.0'].sort().join('\n'))
101
+ .digest('hex')
102
+ .slice(0, 16);
103
+ fs.writeFileSync(path.join(homeTmp, '.botdocs', 'check-updates-cache.json'), JSON.stringify({
104
+ cachedAt: new Date().toISOString(),
105
+ fingerprint: staleFingerprint,
106
+ result: { total: 0, updates: [], removed: [] },
107
+ }));
108
+ // Now the lockfile changed — user installed @a/new
109
+ saveLockfile({
110
+ version: 1,
111
+ installs: [
112
+ { ref: '@a/old', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
113
+ { ref: '@a/new', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
114
+ ],
115
+ });
116
+ // The cache is now stale (fingerprint mismatch); fetch happens
117
+ const fm = mockFetch([
118
+ {
119
+ method: 'POST',
120
+ url: '/api/library/check-updates',
121
+ response: { body: { total: 0, updates: [], removed: [] } },
122
+ },
123
+ ]);
124
+ restoreFetch = fm.restore;
125
+ await checkUpdates({ quiet: true });
126
+ expect(fm.calls).toHaveLength(1);
127
+ });
128
+ });
@@ -0,0 +1,9 @@
1
+ interface CompileOptions {
2
+ ecosystems?: string;
3
+ source?: string;
4
+ regenerate?: string;
5
+ keyEnv?: string;
6
+ json?: boolean;
7
+ }
8
+ export declare function compile(skillPath: string, options: CompileOptions): Promise<void>;
9
+ export {};
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { complete, detectProvider, LlmError } from '../lib/llm.js';
5
+ import { autoDetectSourceEcosystem, ecosystemDestination, readSourceContent, SUPPORTED_ECOSYSTEMS, } from '../lib/canonical.js';
6
+ import { parseManifest } from '../lib/manifest.js';
7
+ const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'ecosystem-prompts');
8
+ function loadPromptTemplate(eco) {
9
+ return fs.readFileSync(path.join(TEMPLATES_DIR, `compile-${eco}.md`), 'utf-8');
10
+ }
11
+ function ensureDir(filePath) {
12
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
13
+ }
14
+ function ensureEcosystem(value, source) {
15
+ if (!SUPPORTED_ECOSYSTEMS.includes(value)) {
16
+ console.error(`\n ✗ Unsupported ecosystem "${value}" in ${source}. Supported: ${SUPPORTED_ECOSYSTEMS.join(', ')}\n`);
17
+ process.exit(1);
18
+ }
19
+ return value;
20
+ }
21
+ export async function compile(skillPath, options) {
22
+ const root = path.resolve(skillPath);
23
+ const manifestPath = path.join(root, 'botdocs.json');
24
+ if (!fs.existsSync(manifestPath)) {
25
+ console.error(`\n ✗ No botdocs.json found at ${root}\n`);
26
+ process.exit(1);
27
+ }
28
+ const manifestRaw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
29
+ const manifest = parseManifest(manifestRaw);
30
+ let sourceEco;
31
+ if (manifest.sourceEcosystem) {
32
+ sourceEco = ensureEcosystem(manifest.sourceEcosystem, 'botdocs.json sourceEcosystem');
33
+ }
34
+ else {
35
+ const detected = autoDetectSourceEcosystem(root);
36
+ if (!detected) {
37
+ console.error('\n ✗ Could not detect source ecosystem. Add `sourceEcosystem` to botdocs.json.\n');
38
+ process.exit(1);
39
+ }
40
+ sourceEco = detected;
41
+ }
42
+ const declaredEcosystems = manifest.ecosystems ?? [...SUPPORTED_ECOSYSTEMS];
43
+ let targets = declaredEcosystems.filter((e) => SUPPORTED_ECOSYSTEMS.includes(e));
44
+ if (options.ecosystems) {
45
+ const subset = options.ecosystems.split(',').map((s) => ensureEcosystem(s.trim(), '--ecosystems'));
46
+ targets = targets.filter((e) => subset.includes(e));
47
+ }
48
+ if (options.regenerate) {
49
+ targets = [ensureEcosystem(options.regenerate, '--regenerate')];
50
+ }
51
+ // Don't regenerate the source ecosystem — that's the input
52
+ targets = targets.filter((e) => e !== sourceEco);
53
+ let providerInfo;
54
+ try {
55
+ providerInfo = detectProvider({ keyEnv: options.keyEnv });
56
+ }
57
+ catch (err) {
58
+ if (err instanceof LlmError) {
59
+ console.error(`\n ✗ ${err.message}\n`);
60
+ process.exit(1);
61
+ }
62
+ throw err;
63
+ }
64
+ const slug = path.basename(root);
65
+ const sourceContent = readSourceContent(root, sourceEco, slug);
66
+ console.log(`\n ✓ Source: ${ecosystemDestination(sourceEco, slug)} (${providerInfo.provider})`);
67
+ console.log(` ✓ Generating drafts for: ${targets.join(', ')}`);
68
+ let totalIn = 0;
69
+ let totalOut = 0;
70
+ const written = [];
71
+ for (const target of targets) {
72
+ const system = loadPromptTemplate(target);
73
+ const prompt = `Skill title: ${manifest.title}\nSkill description: ${manifest.description}\n\nSource (${sourceEco}):\n\n${sourceContent}`;
74
+ const resp = await complete({ system, prompt, keyEnv: options.keyEnv });
75
+ totalIn += resp.usage.inputTokens;
76
+ totalOut += resp.usage.outputTokens;
77
+ const dest = path.join(root, ecosystemDestination(target, slug));
78
+ ensureDir(dest);
79
+ fs.writeFileSync(dest, resp.text, 'utf-8');
80
+ written.push(ecosystemDestination(target, slug));
81
+ console.log(` ↳ ${ecosystemDestination(target, slug)}`);
82
+ }
83
+ if (options.json) {
84
+ console.log(JSON.stringify({
85
+ written,
86
+ usage: { inputTokens: totalIn, outputTokens: totalOut },
87
+ provider: providerInfo.provider,
88
+ }));
89
+ return;
90
+ }
91
+ console.log(`\n Cost: ${totalIn} input + ${totalOut} output tokens (your ${providerInfo.provider} key)`);
92
+ console.log(' Review the generated files before running `botdocs publish`.\n');
93
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { compile } from './compile.js';
5
+ import { captureConsole, withTempDir } from '../test-utils.js';
6
+ vi.mock('../lib/llm.js', () => ({
7
+ complete: vi.fn(),
8
+ detectProvider: vi.fn(() => ({ provider: 'anthropic', keyEnv: 'BOTDOCS_ANTHROPIC_KEY' })),
9
+ LlmError: class extends Error {
10
+ },
11
+ }));
12
+ import * as llm from '../lib/llm.js';
13
+ describe('compile', () => {
14
+ let tmp;
15
+ let captured;
16
+ beforeEach(() => {
17
+ tmp = withTempDir();
18
+ captured = captureConsole();
19
+ process.env.BOTDOCS_ANTHROPIC_KEY = 'test-key';
20
+ });
21
+ afterEach(() => {
22
+ captured.restore();
23
+ tmp.cleanup();
24
+ vi.restoreAllMocks();
25
+ delete process.env.BOTDOCS_ANTHROPIC_KEY;
26
+ });
27
+ function setupSkill() {
28
+ const root = path.join(tmp.dir, 'my-skill');
29
+ fs.mkdirSync(path.join(root, 'claude-code', 'commands'), { recursive: true });
30
+ fs.writeFileSync(path.join(root, 'botdocs.json'), JSON.stringify({
31
+ type: 'skill',
32
+ version: '1.0.0',
33
+ title: 'Code Review',
34
+ description: 'Reviews PRs',
35
+ sourceEcosystem: 'claude-code',
36
+ ecosystems: ['claude', 'claude-code', 'cursor'],
37
+ }));
38
+ fs.writeFileSync(path.join(root, 'claude-code', 'commands', 'my-skill.md'), '# Code Review\n\nReview the PR.');
39
+ return root;
40
+ }
41
+ it('generates per-ecosystem files (excluding the source ecosystem)', async () => {
42
+ vi.mocked(llm.complete).mockResolvedValue({
43
+ text: 'GENERATED OUTPUT',
44
+ usage: { inputTokens: 100, outputTokens: 50 },
45
+ provider: 'anthropic',
46
+ });
47
+ const root = setupSkill();
48
+ await compile(root, {});
49
+ expect(fs.existsSync(path.join(root, 'claude', 'SKILL.md'))).toBe(true);
50
+ expect(fs.existsSync(path.join(root, 'cursor', 'rules', 'my-skill.mdc'))).toBe(true);
51
+ // source-ecosystem file is untouched
52
+ expect(fs.readFileSync(path.join(root, 'claude-code', 'commands', 'my-skill.md'), 'utf-8'))
53
+ .toBe('# Code Review\n\nReview the PR.');
54
+ expect(llm.complete).toHaveBeenCalledTimes(2); // claude + cursor
55
+ });
56
+ it('--ecosystems flag subsets the generated set', async () => {
57
+ vi.mocked(llm.complete).mockResolvedValue({
58
+ text: 'OUT',
59
+ usage: { inputTokens: 10, outputTokens: 5 },
60
+ provider: 'anthropic',
61
+ });
62
+ const root = setupSkill();
63
+ await compile(root, { ecosystems: 'cursor' });
64
+ expect(fs.existsSync(path.join(root, 'cursor', 'rules', 'my-skill.mdc'))).toBe(true);
65
+ expect(fs.existsSync(path.join(root, 'claude', 'SKILL.md'))).toBe(false);
66
+ expect(llm.complete).toHaveBeenCalledTimes(1);
67
+ });
68
+ it('errors when no API key is set', async () => {
69
+ delete process.env.BOTDOCS_ANTHROPIC_KEY;
70
+ vi.mocked(llm.detectProvider).mockImplementation(() => {
71
+ throw new llm.LlmError('No LLM key');
72
+ });
73
+ const root = setupSkill();
74
+ await expect(compile(root, {})).rejects.toThrow();
75
+ });
76
+ it('exits 1 when sourceEcosystem in botdocs.json is unsupported', async () => {
77
+ vi.mocked(llm.complete).mockResolvedValue({
78
+ text: 'X',
79
+ usage: { inputTokens: 1, outputTokens: 1 },
80
+ provider: 'anthropic',
81
+ });
82
+ const root = path.join(tmp.dir, 'bad-source');
83
+ fs.mkdirSync(root, { recursive: true });
84
+ fs.writeFileSync(path.join(root, 'botdocs.json'), JSON.stringify({
85
+ type: 'skill',
86
+ version: '1.0.0',
87
+ title: 'X',
88
+ description: 'Y',
89
+ sourceEcosystem: 'claude-codee', // typo
90
+ ecosystems: ['claude'],
91
+ }));
92
+ await expect(compile(root, {})).rejects.toThrow();
93
+ expect(captured.stderr.join('\n')).toMatch(/Unsupported ecosystem.*claude-codee/);
94
+ });
95
+ it('exits 1 when --regenerate target is unsupported', async () => {
96
+ const root = path.join(tmp.dir, 'good');
97
+ fs.mkdirSync(path.join(root, 'claude-code', 'commands'), { recursive: true });
98
+ fs.writeFileSync(path.join(root, 'botdocs.json'), JSON.stringify({
99
+ type: 'skill',
100
+ version: '1.0.0',
101
+ title: 'X',
102
+ description: 'Y',
103
+ sourceEcosystem: 'claude-code',
104
+ ecosystems: ['claude', 'claude-code'],
105
+ }));
106
+ fs.writeFileSync(path.join(root, 'claude-code', 'commands', 'good.md'), '# Body');
107
+ await expect(compile(root, { regenerate: 'cursor-but-typo' })).rejects.toThrow();
108
+ expect(captured.stderr.join('\n')).toMatch(/Unsupported ecosystem.*cursor-but-typo/);
109
+ });
110
+ });
@@ -0,0 +1,7 @@
1
+ interface EditOptions {
2
+ ecosystem: string;
3
+ keyEnv?: string;
4
+ json?: boolean;
5
+ }
6
+ export declare function edit(rawRef: string, options: EditOptions): Promise<void>;
7
+ export {};