@botdocs/cli 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -2
- package/dist/commands/check-updates.d.ts +6 -0
- package/dist/commands/check-updates.js +77 -0
- package/dist/commands/check-updates.test.d.ts +1 -0
- package/dist/commands/check-updates.test.js +128 -0
- package/dist/commands/compile.d.ts +9 -0
- package/dist/commands/compile.js +93 -0
- package/dist/commands/compile.test.d.ts +1 -0
- package/dist/commands/compile.test.js +110 -0
- package/dist/commands/edit.d.ts +7 -0
- package/dist/commands/edit.js +105 -0
- package/dist/commands/edit.test.d.ts +1 -0
- package/dist/commands/edit.test.js +102 -0
- package/dist/commands/ingest.d.ts +7 -0
- package/dist/commands/ingest.js +101 -0
- package/dist/commands/ingest.test.d.ts +1 -0
- package/dist/commands/ingest.test.js +109 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +34 -1
- package/dist/commands/install-instructions.d.ts +8 -0
- package/dist/commands/install-instructions.js +88 -0
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +143 -0
- package/dist/commands/install.test.d.ts +1 -0
- package/dist/commands/install.test.js +253 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.js +35 -0
- package/dist/commands/list.test.d.ts +1 -0
- package/dist/commands/list.test.js +51 -0
- package/dist/commands/login.d.ts +5 -1
- package/dist/commands/login.js +10 -7
- package/dist/commands/publish.d.ts +1 -0
- package/dist/commands/publish.js +37 -0
- package/dist/commands/publish.test.d.ts +1 -0
- package/dist/commands/publish.test.js +76 -0
- package/dist/commands/sync.d.ts +7 -0
- package/dist/commands/sync.js +161 -0
- package/dist/commands/sync.test.d.ts +1 -0
- package/dist/commands/sync.test.js +263 -0
- package/dist/commands/uninstall.d.ts +5 -0
- package/dist/commands/uninstall.js +31 -0
- package/dist/commands/uninstall.test.d.ts +1 -0
- package/dist/commands/uninstall.test.js +67 -0
- package/dist/commands/validate.js +20 -5
- package/dist/index.js +86 -2
- package/dist/lib/auto-detect.d.ts +13 -0
- package/dist/lib/auto-detect.js +34 -0
- package/dist/lib/auto-detect.test.d.ts +1 -0
- package/dist/lib/auto-detect.test.js +58 -0
- package/dist/lib/canonical.d.ts +5 -0
- package/dist/lib/canonical.js +68 -0
- package/dist/lib/canonical.test.d.ts +1 -0
- package/dist/lib/canonical.test.js +48 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/diff.d.ts +2 -0
- package/dist/lib/diff.js +36 -0
- package/dist/lib/diff.test.d.ts +1 -0
- package/dist/lib/diff.test.js +28 -0
- package/dist/lib/library-sync.d.ts +8 -0
- package/dist/lib/library-sync.js +30 -0
- package/dist/lib/library-sync.test.d.ts +1 -0
- package/dist/lib/library-sync.test.js +63 -0
- package/dist/lib/llm.d.ts +26 -0
- package/dist/lib/llm.js +61 -0
- package/dist/lib/llm.test.d.ts +1 -0
- package/dist/lib/llm.test.js +72 -0
- package/dist/lib/lockfile.d.ts +30 -0
- package/dist/lib/lockfile.js +70 -0
- package/dist/lib/lockfile.test.d.ts +1 -0
- package/dist/lib/lockfile.test.js +99 -0
- package/dist/lib/manifest.d.ts +18 -0
- package/dist/lib/manifest.js +77 -0
- package/dist/lib/manifest.test.d.ts +1 -0
- package/dist/lib/manifest.test.js +72 -0
- package/dist/lib/prompts.d.ts +5 -0
- package/dist/lib/prompts.js +26 -0
- package/dist/lib/shell-hook.d.ts +12 -0
- package/dist/lib/shell-hook.js +80 -0
- package/dist/lib/shell-hook.test.d.ts +1 -0
- package/dist/lib/shell-hook.test.js +68 -0
- package/dist/test-utils.d.ts +43 -0
- package/dist/test-utils.js +101 -0
- package/package.json +8 -2
- package/templates/agents.md +126 -0
- package/templates/ecosystem-prompts/compile-chatgpt.md +9 -0
- package/templates/ecosystem-prompts/compile-claude-code.md +11 -0
- package/templates/ecosystem-prompts/compile-claude.md +20 -0
- package/templates/ecosystem-prompts/compile-codex.md +8 -0
- package/templates/ecosystem-prompts/compile-cursor.md +11 -0
- 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
|
|
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
|
-
| `
|
|
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,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,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
|
+
});
|