@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
@@ -0,0 +1,7 @@
1
+ interface SyncOptions {
2
+ yes?: boolean;
3
+ dryRun?: boolean;
4
+ json?: boolean;
5
+ }
6
+ export declare function sync(rawRef: string | undefined, options: SyncOptions): Promise<void>;
7
+ export {};
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
4
+ import { loadLockfile, fingerprintFile, upsertInstall } from '../lib/lockfile.js';
5
+ import { renderDiff, hasChanges } from '../lib/diff.js';
6
+ import { promptConflict, confirmOverwrite, promptCleanUpdate } from '../lib/prompts.js';
7
+ import { syncLibrary } from '../lib/library-sync.js';
8
+ async function syncSkill(entry, manifest, options) {
9
+ let updatedCount = 0;
10
+ let skippedCount = 0;
11
+ const newFiles = [];
12
+ for (const installedFile of entry.files) {
13
+ const upstream = manifest.files.find((f) => f.filename === installedFile.src);
14
+ if (!upstream) {
15
+ // Upstream removed this file — delete the local copy and drop the lockfile entry.
16
+ if (fs.existsSync(installedFile.dest))
17
+ fs.unlinkSync(installedFile.dest);
18
+ console.log(` ⌀ ${entry.ref}: removed ${installedFile.src} (no longer in upstream)`);
19
+ // Don't push to newFiles — let it drop from the lockfile.
20
+ continue;
21
+ }
22
+ const upstreamContent = await fetchRawContent(upstream.rawUrl);
23
+ if (!fs.existsSync(installedFile.dest)) {
24
+ fs.mkdirSync(path.dirname(installedFile.dest), { recursive: true });
25
+ fs.writeFileSync(installedFile.dest, upstreamContent, 'utf-8');
26
+ const fp = fingerprintFile(installedFile.dest);
27
+ newFiles.push({ ...installedFile, fingerprint: fp });
28
+ updatedCount++;
29
+ continue;
30
+ }
31
+ const localContent = fs.readFileSync(installedFile.dest, 'utf-8');
32
+ const localFp = fingerprintFile(installedFile.dest);
33
+ if (!hasChanges(localContent, upstreamContent)) {
34
+ newFiles.push({ ...installedFile, fingerprint: localFp });
35
+ continue;
36
+ }
37
+ const localUnchangedSinceLockfile = localFp === installedFile.fingerprint;
38
+ if (localUnchangedSinceLockfile) {
39
+ let choice;
40
+ if (options.yes) {
41
+ choice = 'apply';
42
+ }
43
+ else {
44
+ choice = await promptCleanUpdate(`${entry.ref}:${installedFile.src}`);
45
+ while (choice === 'diff') {
46
+ console.log(renderDiff(localContent, upstreamContent));
47
+ choice = await promptCleanUpdate(`${entry.ref}:${installedFile.src}`);
48
+ }
49
+ }
50
+ if (choice === 'apply') {
51
+ if (!options.dryRun) {
52
+ fs.writeFileSync(installedFile.dest, upstreamContent, 'utf-8');
53
+ }
54
+ const fp = options.dryRun ? installedFile.fingerprint : fingerprintFile(installedFile.dest);
55
+ newFiles.push({ ...installedFile, fingerprint: fp });
56
+ updatedCount++;
57
+ }
58
+ else {
59
+ newFiles.push(installedFile);
60
+ skippedCount++;
61
+ }
62
+ continue;
63
+ }
64
+ // Conflict: local has been edited.
65
+ console.log(renderDiff(localContent, upstreamContent));
66
+ const conflict = await promptConflict(`${entry.ref}:${installedFile.src}`);
67
+ if (conflict === 'skip') {
68
+ newFiles.push(installedFile);
69
+ skippedCount++;
70
+ continue;
71
+ }
72
+ const sure = await confirmOverwrite(`${entry.ref}:${installedFile.src}`);
73
+ if (!sure) {
74
+ newFiles.push(installedFile);
75
+ skippedCount++;
76
+ continue;
77
+ }
78
+ if (!options.dryRun) {
79
+ fs.writeFileSync(installedFile.dest, upstreamContent, 'utf-8');
80
+ }
81
+ const fp = options.dryRun ? installedFile.fingerprint : fingerprintFile(installedFile.dest);
82
+ newFiles.push({ ...installedFile, fingerprint: fp });
83
+ updatedCount++;
84
+ }
85
+ if (!options.dryRun) {
86
+ // Only bump the lockfile version when no files were skipped — otherwise
87
+ // the local copy still represents the old version on at least one file.
88
+ const canBumpVersion = skippedCount === 0;
89
+ const filesShrunk = newFiles.length < entry.files.length;
90
+ const shouldWrite = updatedCount > 0 || filesShrunk || (canBumpVersion && manifest.version !== entry.version);
91
+ if (shouldWrite) {
92
+ upsertInstall({
93
+ ...entry,
94
+ version: canBumpVersion ? manifest.version : entry.version,
95
+ files: newFiles,
96
+ installedAt: new Date().toISOString(),
97
+ });
98
+ }
99
+ }
100
+ return { updated: updatedCount > 0, skipped: skippedCount };
101
+ }
102
+ function refToPath(ref) {
103
+ const cleaned = ref.startsWith('@') ? ref.slice(1) : ref;
104
+ const [username, slug] = cleaned.split('/');
105
+ if (!username || !slug)
106
+ throw new Error(`Invalid ref in lockfile: ${ref}`);
107
+ return { username, slug };
108
+ }
109
+ export async function sync(rawRef, options) {
110
+ const lf = loadLockfile();
111
+ const targets = rawRef
112
+ ? lf.installs.filter((i) => i.ref === rawRef)
113
+ : lf.installs.filter((i) => i.type === 'SKILL');
114
+ if (targets.length === 0) {
115
+ console.log('\n nothing installed to sync\n');
116
+ await syncLibrary();
117
+ return;
118
+ }
119
+ const summary = [];
120
+ for (const entry of targets) {
121
+ if (entry.type !== 'SKILL')
122
+ continue;
123
+ const { username, slug } = refToPath(entry.ref);
124
+ let manifest;
125
+ try {
126
+ const resp = await apiFetch(`/api/skills/${username}/${slug}/manifest`);
127
+ if (resp.type !== 'SKILL')
128
+ continue;
129
+ manifest = resp;
130
+ }
131
+ catch (err) {
132
+ if (err instanceof ApiError) {
133
+ if (err.status === 401) {
134
+ console.error('\n ✗ Not authenticated. Run `botdocs login` first.\n');
135
+ process.exit(1);
136
+ }
137
+ summary.push({ ref: entry.ref, status: 'skipped' });
138
+ continue;
139
+ }
140
+ throw err;
141
+ }
142
+ const { updated, skipped } = await syncSkill(entry, manifest, options);
143
+ summary.push({ ref: entry.ref, status: updated ? 'updated' : skipped > 0 ? 'skipped' : 'up-to-date' });
144
+ }
145
+ if (options.json) {
146
+ console.log(JSON.stringify({ summary }));
147
+ await syncLibrary();
148
+ return;
149
+ }
150
+ console.log('');
151
+ for (const s of summary) {
152
+ if (s.status === 'up-to-date')
153
+ console.log(` ✓ ${s.ref}: up to date`);
154
+ else if (s.status === 'updated')
155
+ console.log(` ✓ ${s.ref}: updated`);
156
+ else
157
+ console.log(` ⌀ ${s.ref}: changes skipped`);
158
+ }
159
+ console.log('');
160
+ await syncLibrary();
161
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,263 @@
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 { sync } from './sync.js';
6
+ import { captureConsole, mockFetch, withTempDir } from '../test-utils.js';
7
+ import { loadLockfile, saveLockfile } from '../lib/lockfile.js';
8
+ vi.mock('../lib/prompts.js', () => ({
9
+ promptCleanUpdate: vi.fn(),
10
+ promptConflict: vi.fn(),
11
+ confirmOverwrite: vi.fn(),
12
+ }));
13
+ vi.mock('../lib/library-sync.js', () => ({
14
+ syncLibrary: vi.fn(),
15
+ }));
16
+ import * as prompts from '../lib/prompts.js';
17
+ import * as librarySync from '../lib/library-sync.js';
18
+ describe('sync', () => {
19
+ let tmp;
20
+ let captured;
21
+ let restoreFetch = () => { };
22
+ const origHome = os.homedir;
23
+ let homeTmp;
24
+ beforeEach(() => {
25
+ tmp = withTempDir();
26
+ homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-home-'));
27
+ os.homedir = () => homeTmp;
28
+ process.env.BOTDOCS_API_URL = 'http://test.local';
29
+ captured = captureConsole();
30
+ });
31
+ afterEach(() => {
32
+ restoreFetch();
33
+ captured.restore();
34
+ tmp.cleanup();
35
+ fs.rmSync(homeTmp, { recursive: true, force: true });
36
+ os.homedir = origHome;
37
+ vi.resetAllMocks();
38
+ });
39
+ it('reports up-to-date when fingerprints match upstream', async () => {
40
+ const dest = path.join(homeTmp, '.claude', 'skills', 'a', 'x', 'SKILL.md');
41
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
42
+ fs.writeFileSync(dest, 'body', 'utf-8');
43
+ const { fingerprintFile } = await import('../lib/lockfile.js');
44
+ const fp = fingerprintFile(dest);
45
+ saveLockfile({
46
+ version: 1,
47
+ installs: [{ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [{ src: 'claude/SKILL.md', dest, fingerprint: fp }] }],
48
+ });
49
+ const fm = mockFetch([
50
+ {
51
+ url: '/api/skills/a/x/manifest',
52
+ response: {
53
+ body: {
54
+ ref: { username: 'a', slug: 'x' },
55
+ type: 'SKILL',
56
+ version: '1.0.0',
57
+ sourceEcosystem: null,
58
+ files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/x' }],
59
+ },
60
+ },
61
+ },
62
+ { url: '/raw/x', response: { body: 'body', contentType: 'text/plain' } },
63
+ ]);
64
+ restoreFetch = fm.restore;
65
+ await sync(undefined, { yes: true });
66
+ expect(captured.stdout.join('\n')).toMatch(/up to date/i);
67
+ });
68
+ it('overwrites local when upstream changed and double-confirm passes', async () => {
69
+ const dest = path.join(homeTmp, '.claude', 'skills', 'a', 'x', 'SKILL.md');
70
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
71
+ fs.writeFileSync(dest, 'local edits here\n', 'utf-8');
72
+ saveLockfile({
73
+ version: 1,
74
+ installs: [{
75
+ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't',
76
+ files: [{ src: 'claude/SKILL.md', dest, fingerprint: 'OLD_FINGERPRINT_THAT_DIFFERS' }],
77
+ }],
78
+ });
79
+ const fm = mockFetch([
80
+ {
81
+ url: '/api/skills/a/x/manifest',
82
+ response: {
83
+ body: {
84
+ ref: { username: 'a', slug: 'x' },
85
+ type: 'SKILL',
86
+ version: '1.1.0',
87
+ sourceEcosystem: null,
88
+ files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/x' }],
89
+ },
90
+ },
91
+ },
92
+ { url: '/raw/x', response: { body: 'upstream new content\n', contentType: 'text/plain' } },
93
+ ]);
94
+ restoreFetch = fm.restore;
95
+ vi.mocked(prompts.promptConflict).mockResolvedValue('overwrite');
96
+ vi.mocked(prompts.confirmOverwrite).mockResolvedValue(true);
97
+ await sync(undefined, {});
98
+ expect(fs.readFileSync(dest, 'utf-8')).toBe('upstream new content\n');
99
+ expect(loadLockfile().installs[0].version).toBe('1.1.0');
100
+ });
101
+ it('skips local when user picks skip', async () => {
102
+ const dest = path.join(homeTmp, '.claude', 'skills', 'a', 'x', 'SKILL.md');
103
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
104
+ fs.writeFileSync(dest, 'local edits\n', 'utf-8');
105
+ saveLockfile({
106
+ version: 1,
107
+ installs: [{
108
+ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't',
109
+ files: [{ src: 'claude/SKILL.md', dest, fingerprint: 'OLD' }],
110
+ }],
111
+ });
112
+ const fm = mockFetch([
113
+ {
114
+ url: '/api/skills/a/x/manifest',
115
+ response: {
116
+ body: {
117
+ ref: { username: 'a', slug: 'x' },
118
+ type: 'SKILL',
119
+ version: '1.1.0',
120
+ sourceEcosystem: null,
121
+ files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/x' }],
122
+ },
123
+ },
124
+ },
125
+ { url: '/raw/x', response: { body: 'upstream', contentType: 'text/plain' } },
126
+ ]);
127
+ restoreFetch = fm.restore;
128
+ vi.mocked(prompts.promptConflict).mockResolvedValue('skip');
129
+ await sync(undefined, {});
130
+ expect(fs.readFileSync(dest, 'utf-8')).toBe('local edits\n');
131
+ expect(loadLockfile().installs[0].version).toBe('1.0.0');
132
+ });
133
+ it('overwrite with explicit double-confirm cancellation skips the file', async () => {
134
+ const dest = path.join(homeTmp, '.claude', 'skills', 'a', 'x', 'SKILL.md');
135
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
136
+ fs.writeFileSync(dest, 'local\n', 'utf-8');
137
+ saveLockfile({
138
+ version: 1,
139
+ installs: [{ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [{ src: 'claude/SKILL.md', dest, fingerprint: 'OLD' }] }],
140
+ });
141
+ const fm = mockFetch([
142
+ {
143
+ url: '/api/skills/a/x/manifest',
144
+ response: {
145
+ body: { ref: { username: 'a', slug: 'x' }, type: 'SKILL', version: '1.1.0', sourceEcosystem: null, files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/x' }] },
146
+ },
147
+ },
148
+ { url: '/raw/x', response: { body: 'upstream', contentType: 'text/plain' } },
149
+ ]);
150
+ restoreFetch = fm.restore;
151
+ vi.mocked(prompts.promptConflict).mockResolvedValue('overwrite');
152
+ vi.mocked(prompts.confirmOverwrite).mockResolvedValue(false);
153
+ await sync(undefined, {});
154
+ expect(fs.readFileSync(dest, 'utf-8')).toBe('local\n');
155
+ });
156
+ it('removes local files that no longer exist upstream and drops the lockfile entry', async () => {
157
+ const dest = path.join(homeTmp, '.claude', 'skills', 'a', 'x', 'OLD.md');
158
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
159
+ fs.writeFileSync(dest, 'old', 'utf-8');
160
+ const { fingerprintFile } = await import('../lib/lockfile.js');
161
+ const fp = fingerprintFile(dest);
162
+ saveLockfile({
163
+ version: 1,
164
+ installs: [{
165
+ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't',
166
+ files: [{ src: 'claude/OLD.md', dest, fingerprint: fp }],
167
+ }],
168
+ });
169
+ const fm = mockFetch([
170
+ {
171
+ url: '/api/skills/a/x/manifest',
172
+ response: {
173
+ body: {
174
+ ref: { username: 'a', slug: 'x' },
175
+ type: 'SKILL',
176
+ version: '1.1.0',
177
+ sourceEcosystem: null,
178
+ // OLD.md no longer in upstream — manifest has zero files
179
+ files: [],
180
+ },
181
+ },
182
+ },
183
+ ]);
184
+ restoreFetch = fm.restore;
185
+ await sync(undefined, { yes: true });
186
+ expect(fs.existsSync(dest)).toBe(false);
187
+ // The lockfile entry's files array should now be empty
188
+ expect(loadLockfile().installs[0].files).toHaveLength(0);
189
+ });
190
+ it('exits 1 with a login hint when the manifest endpoint returns 401', async () => {
191
+ saveLockfile({
192
+ version: 1,
193
+ installs: [{ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] }],
194
+ });
195
+ const fm = mockFetch([
196
+ {
197
+ url: '/api/skills/a/x/manifest',
198
+ response: { status: 401, body: { error: 'Unauthorized' } },
199
+ },
200
+ ]);
201
+ restoreFetch = fm.restore;
202
+ await expect(sync(undefined, {})).rejects.toThrow();
203
+ expect(captured.stderr.join('\n')).toMatch(/botdocs login/);
204
+ });
205
+ it('--dry-run does not write to disk on conflict overwrite', async () => {
206
+ const dest = path.join(homeTmp, '.claude', 'skills', 'a', 'x', 'SKILL.md');
207
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
208
+ fs.writeFileSync(dest, 'local edits\n', 'utf-8');
209
+ saveLockfile({
210
+ version: 1,
211
+ installs: [{
212
+ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't',
213
+ files: [{ src: 'claude/SKILL.md', dest, fingerprint: 'OLD' }],
214
+ }],
215
+ });
216
+ const fm = mockFetch([
217
+ {
218
+ url: '/api/skills/a/x/manifest',
219
+ response: {
220
+ body: { ref: { username: 'a', slug: 'x' }, type: 'SKILL', version: '1.1.0', sourceEcosystem: null, files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/x' }] },
221
+ },
222
+ },
223
+ { url: '/raw/x', response: { body: 'upstream', contentType: 'text/plain' } },
224
+ ]);
225
+ restoreFetch = fm.restore;
226
+ vi.mocked(prompts.promptConflict).mockResolvedValue('overwrite');
227
+ vi.mocked(prompts.confirmOverwrite).mockResolvedValue(true);
228
+ await sync(undefined, { dryRun: true });
229
+ // File on disk should be unchanged
230
+ expect(fs.readFileSync(dest, 'utf-8')).toBe('local edits\n');
231
+ // Lockfile version should NOT have been bumped (dry-run = no upsert)
232
+ expect(loadLockfile().installs[0].version).toBe('1.0.0');
233
+ });
234
+ it('invokes syncLibrary after a successful sync', async () => {
235
+ const dest = path.join(homeTmp, '.claude', 'skills', 'a', 'x', 'SKILL.md');
236
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
237
+ fs.writeFileSync(dest, 'body', 'utf-8');
238
+ const { fingerprintFile } = await import('../lib/lockfile.js');
239
+ const fp = fingerprintFile(dest);
240
+ saveLockfile({
241
+ version: 1,
242
+ installs: [{ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [{ src: 'claude/SKILL.md', dest, fingerprint: fp }] }],
243
+ });
244
+ const fm = mockFetch([
245
+ {
246
+ url: '/api/skills/a/x/manifest',
247
+ response: {
248
+ body: {
249
+ ref: { username: 'a', slug: 'x' },
250
+ type: 'SKILL',
251
+ version: '1.0.0',
252
+ sourceEcosystem: null,
253
+ files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/x' }],
254
+ },
255
+ },
256
+ },
257
+ { url: '/raw/x', response: { body: 'body', contentType: 'text/plain' } },
258
+ ]);
259
+ restoreFetch = fm.restore;
260
+ await sync(undefined, { yes: true });
261
+ expect(librarySync.syncLibrary).toHaveBeenCalledTimes(1);
262
+ });
263
+ });
@@ -0,0 +1,5 @@
1
+ interface UninstallOptions {
2
+ json?: boolean;
3
+ }
4
+ export declare function uninstall(rawRef: string, options: UninstallOptions): Promise<void>;
5
+ export {};
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import { loadLockfile, removeInstall } from '../lib/lockfile.js';
3
+ import { syncLibrary } from '../lib/library-sync.js';
4
+ export async function uninstall(rawRef, options) {
5
+ const lf = loadLockfile();
6
+ const entry = lf.installs.find((i) => i.ref === rawRef);
7
+ if (!entry) {
8
+ console.error(`\n ✗ Not installed: ${rawRef}\n`);
9
+ process.exit(1);
10
+ }
11
+ const refsToRemove = entry.type === 'BUNDLE'
12
+ ? [entry.ref, ...(entry.skills ?? [])]
13
+ : [entry.ref];
14
+ for (const ref of refsToRemove) {
15
+ const e = lf.installs.find((i) => i.ref === ref);
16
+ if (!e)
17
+ continue;
18
+ for (const f of e.files) {
19
+ if (fs.existsSync(f.dest))
20
+ fs.unlinkSync(f.dest);
21
+ }
22
+ removeInstall(ref);
23
+ }
24
+ if (options.json) {
25
+ console.log(JSON.stringify({ uninstalled: refsToRemove }));
26
+ await syncLibrary();
27
+ return;
28
+ }
29
+ console.log(`\n ✓ Uninstalled ${rawRef}\n`);
30
+ await syncLibrary();
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
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 { uninstall } from './uninstall.js';
6
+ import { captureConsole } from '../test-utils.js';
7
+ import { saveLockfile, loadLockfile } from '../lib/lockfile.js';
8
+ vi.mock('../lib/library-sync.js', () => ({
9
+ syncLibrary: vi.fn(),
10
+ }));
11
+ import * as librarySync from '../lib/library-sync.js';
12
+ describe('uninstall', () => {
13
+ let captured;
14
+ const origHome = os.homedir;
15
+ let homeTmp;
16
+ beforeEach(() => {
17
+ homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uninstall-'));
18
+ os.homedir = () => homeTmp;
19
+ captured = captureConsole();
20
+ vi.mocked(librarySync.syncLibrary).mockClear();
21
+ });
22
+ afterEach(() => {
23
+ captured.restore();
24
+ fs.rmSync(homeTmp, { recursive: true, force: true });
25
+ os.homedir = origHome;
26
+ });
27
+ it('removes the lockfile entry and deletes installed files', async () => {
28
+ const fileDest = path.join(homeTmp, 'fake-installed.md');
29
+ fs.writeFileSync(fileDest, 'body', 'utf-8');
30
+ saveLockfile({
31
+ version: 1,
32
+ installs: [
33
+ { ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [{ src: 's', dest: fileDest, fingerprint: 'f' }] },
34
+ ],
35
+ });
36
+ await uninstall('@a/x', {});
37
+ expect(fs.existsSync(fileDest)).toBe(false);
38
+ expect(loadLockfile().installs).toHaveLength(0);
39
+ });
40
+ it('uninstalls a bundle and all its child skills', async () => {
41
+ saveLockfile({
42
+ version: 1,
43
+ installs: [
44
+ { ref: '@a/eng', type: 'BUNDLE', version: '1.0.0', installedAt: 't', files: [], skills: ['@a/cr'] },
45
+ { ref: '@a/cr', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
46
+ ],
47
+ });
48
+ await uninstall('@a/eng', {});
49
+ expect(loadLockfile().installs).toHaveLength(0);
50
+ });
51
+ it('errors when ref is not installed', async () => {
52
+ saveLockfile({ version: 1, installs: [] });
53
+ await expect(uninstall('@a/nope', {})).rejects.toThrow();
54
+ });
55
+ it('invokes syncLibrary after a successful uninstall', async () => {
56
+ const fileDest = path.join(homeTmp, 'fake-installed.md');
57
+ fs.writeFileSync(fileDest, 'body', 'utf-8');
58
+ saveLockfile({
59
+ version: 1,
60
+ installs: [
61
+ { ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [{ src: 's', dest: fileDest, fingerprint: 'f' }] },
62
+ ],
63
+ });
64
+ await uninstall('@a/x', {});
65
+ expect(librarySync.syncLibrary).toHaveBeenCalledTimes(1);
66
+ });
67
+ });
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
2
2
  import { join, extname } from 'path';
3
+ import { parseManifest, ManifestError } from '../lib/manifest.js';
3
4
  export async function validate(source, options) {
4
5
  const errors = [];
5
6
  const stat = statSync(source, { throwIfNoEntry: false });
@@ -19,15 +20,29 @@ export async function validate(source, options) {
19
20
  errors.push({ file: 'index.md', message: 'Missing index.md (required entry point)', severity: 'error' });
20
21
  }
21
22
  if (existsSync(join(source, 'botdocs.json'))) {
23
+ let raw;
22
24
  try {
23
- const meta = JSON.parse(readFileSync(join(source, 'botdocs.json'), 'utf-8'));
24
- if (!meta.title)
25
- errors.push({ file: 'botdocs.json', message: 'Missing title', severity: 'error' });
26
- if (!meta.description)
27
- errors.push({ file: 'botdocs.json', message: 'Missing description', severity: 'warning' });
25
+ raw = JSON.parse(readFileSync(join(source, 'botdocs.json'), 'utf-8'));
28
26
  }
29
27
  catch {
30
28
  errors.push({ file: 'botdocs.json', message: 'Invalid JSON', severity: 'error' });
29
+ raw = null;
30
+ }
31
+ if (raw !== null) {
32
+ try {
33
+ const parsed = parseManifest(raw);
34
+ if (!parsed.description) {
35
+ errors.push({ file: 'botdocs.json', message: 'Missing description', severity: 'warning' });
36
+ }
37
+ }
38
+ catch (err) {
39
+ if (err instanceof ManifestError) {
40
+ errors.push({ file: 'botdocs.json', message: err.message, severity: 'error' });
41
+ }
42
+ else {
43
+ throw err; // unexpected error type; let it bubble so the user sees a real stack
44
+ }
45
+ }
31
46
  }
32
47
  }
33
48
  else {