@botdocs/cli 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -37
- package/dist/commands/backups.d.ts +4 -0
- package/dist/commands/backups.js +291 -0
- package/dist/commands/edit.js +16 -8
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +21 -3
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +240 -75
- package/dist/commands/publish.js +53 -16
- package/dist/commands/sync.d.ts +16 -0
- package/dist/commands/sync.js +337 -25
- package/dist/commands/team.d.ts +2 -0
- package/dist/commands/team.js +251 -0
- package/dist/commands/undo.d.ts +19 -0
- package/dist/commands/undo.js +88 -0
- package/dist/commands/views/conflict-prompt.d.ts +24 -0
- package/dist/commands/views/conflict-prompt.js +19 -0
- package/dist/commands/views/login-app.d.ts +30 -0
- package/dist/commands/views/login-app.js +57 -0
- package/dist/commands/views/sync-app.d.ts +27 -0
- package/dist/commands/views/sync-app.js +147 -0
- package/dist/commands/views/sync-state.d.ts +84 -0
- package/dist/commands/views/sync-state.js +93 -0
- package/dist/commands/views/theme.d.ts +16 -0
- package/dist/commands/views/theme.js +16 -0
- package/dist/commands/whoami.js +13 -13
- package/dist/index.js +44 -38
- package/dist/lib/api.d.ts +2 -3
- package/dist/lib/api.js +14 -7
- package/dist/lib/auto-detect.js +46 -0
- package/dist/lib/backup.d.ts +121 -0
- package/dist/lib/backup.js +387 -0
- package/dist/lib/canonical.d.ts +1 -1
- package/dist/lib/canonical.js +43 -1
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.js +18 -9
- package/dist/lib/lockfile.d.ts +9 -0
- package/dist/lib/prompts.d.ts +10 -0
- package/dist/lib/prompts.js +36 -12
- package/package.json +27 -7
- package/templates/agents.md +60 -47
- package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
- package/templates/ecosystem-prompts/compile-copilot.md +14 -0
- package/templates/ecosystem-prompts/compile-gemini.md +14 -0
- package/templates/ecosystem-prompts/compile-opencode.md +13 -0
- package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
- package/dist/commands/check-updates.test.d.ts +0 -1
- package/dist/commands/check-updates.test.js +0 -128
- package/dist/commands/clone.d.ts +0 -3
- package/dist/commands/clone.js +0 -70
- package/dist/commands/compile.test.d.ts +0 -1
- package/dist/commands/compile.test.js +0 -110
- package/dist/commands/diff.d.ts +0 -3
- package/dist/commands/diff.js +0 -65
- package/dist/commands/edit.test.d.ts +0 -1
- package/dist/commands/edit.test.js +0 -102
- package/dist/commands/endorse.d.ts +0 -7
- package/dist/commands/endorse.js +0 -70
- package/dist/commands/ingest.test.d.ts +0 -1
- package/dist/commands/ingest.test.js +0 -109
- package/dist/commands/install.test.d.ts +0 -1
- package/dist/commands/install.test.js +0 -253
- package/dist/commands/list.test.d.ts +0 -1
- package/dist/commands/list.test.js +0 -51
- package/dist/commands/publish.test.d.ts +0 -1
- package/dist/commands/publish.test.js +0 -76
- package/dist/commands/pull.d.ts +0 -3
- package/dist/commands/pull.js +0 -78
- package/dist/commands/sync.test.d.ts +0 -1
- package/dist/commands/sync.test.js +0 -263
- package/dist/commands/uninstall.test.d.ts +0 -1
- package/dist/commands/uninstall.test.js +0 -67
- package/dist/lib/auto-detect.test.d.ts +0 -1
- package/dist/lib/auto-detect.test.js +0 -58
- package/dist/lib/canonical.test.d.ts +0 -1
- package/dist/lib/canonical.test.js +0 -48
- package/dist/lib/diff.test.d.ts +0 -1
- package/dist/lib/diff.test.js +0 -28
- package/dist/lib/library-sync.test.d.ts +0 -1
- package/dist/lib/library-sync.test.js +0 -63
- package/dist/lib/llm.test.d.ts +0 -1
- package/dist/lib/llm.test.js +0 -72
- package/dist/lib/lockfile.test.d.ts +0 -1
- package/dist/lib/lockfile.test.js +0 -99
- package/dist/lib/manifest.test.d.ts +0 -1
- package/dist/lib/manifest.test.js +0 -72
- package/dist/lib/shell-hook.test.d.ts +0 -1
- package/dist/lib/shell-hook.test.js +0 -68
- package/dist/test-utils.d.ts +0 -43
- package/dist/test-utils.js +0 -101
|
@@ -1,263 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,67 +0,0 @@
|
|
|
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 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { detectDestination } from './auto-detect.js';
|
|
3
|
-
const ctx = {
|
|
4
|
-
scope: 'teamco',
|
|
5
|
-
slug: 'code-review',
|
|
6
|
-
homeDir: '/home/u',
|
|
7
|
-
projectDir: '/work/proj',
|
|
8
|
-
flatScope: false,
|
|
9
|
-
};
|
|
10
|
-
describe('detectDestination', () => {
|
|
11
|
-
it('routes claude/SKILL.md to ~/.claude/skills/{scope}/{slug}/SKILL.md', () => {
|
|
12
|
-
expect(detectDestination('claude/SKILL.md', ctx)).toEqual({
|
|
13
|
-
kind: 'global',
|
|
14
|
-
dest: '/home/u/.claude/skills/teamco/code-review/SKILL.md',
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
it('routes nested claude/<name>/SKILL.md keeping the inner filename', () => {
|
|
18
|
-
expect(detectDestination('claude/code-review/SKILL.md', ctx)).toEqual({
|
|
19
|
-
kind: 'global',
|
|
20
|
-
dest: '/home/u/.claude/skills/teamco/code-review/SKILL.md',
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
it('routes cursor rules to project local', () => {
|
|
24
|
-
expect(detectDestination('cursor/rules/style-guide.mdc', ctx)).toEqual({
|
|
25
|
-
kind: 'project',
|
|
26
|
-
dest: '/work/proj/.cursor/rules/style-guide.mdc',
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
it('routes claude-code commands to project local', () => {
|
|
30
|
-
expect(detectDestination('claude-code/commands/code-review.md', ctx)).toEqual({
|
|
31
|
-
kind: 'project',
|
|
32
|
-
dest: '/work/proj/.claude/commands/code-review.md',
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
it('routes chatgpt files to "manual" with a path hint', () => {
|
|
36
|
-
expect(detectDestination('chatgpt/code-review.md', ctx)).toEqual({
|
|
37
|
-
kind: 'manual',
|
|
38
|
-
dest: 'chatgpt/code-review.md',
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
it('honors flatScope by skipping the scope subdir', () => {
|
|
42
|
-
const flat = { ...ctx, flatScope: true };
|
|
43
|
-
expect(detectDestination('claude/SKILL.md', flat).dest).toBe('/home/u/.claude/skills/code-review/SKILL.md');
|
|
44
|
-
});
|
|
45
|
-
it('returns "skip" for unknown ecosystem files', () => {
|
|
46
|
-
expect(detectDestination('docs/notes.md', ctx).kind).toBe('skip');
|
|
47
|
-
});
|
|
48
|
-
it('handles claude/<inner>/<deeper>/SKILL.md without losing the leaf filename', () => {
|
|
49
|
-
// Documents the current behavior: the regex strips only the first inner
|
|
50
|
-
// segment. If we ever support arbitrary nesting, this test must update.
|
|
51
|
-
const result = detectDestination('claude/alpha/beta/SKILL.md', ctx);
|
|
52
|
-
expect(result.kind).toBe('global');
|
|
53
|
-
// Confirms that the leaf "SKILL.md" survives the inner-segment strip; we
|
|
54
|
-
// don't lose it. The current implementation produces "beta/SKILL.md" as
|
|
55
|
-
// the leaf — pin that behavior so we know what we have.
|
|
56
|
-
expect(result.dest.endsWith('SKILL.md')).toBe(true);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { autoDetectSourceEcosystem, ecosystemDestination } from './canonical.js';
|
|
6
|
-
describe('autoDetectSourceEcosystem', () => {
|
|
7
|
-
let tmp;
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'canon-'));
|
|
10
|
-
});
|
|
11
|
-
afterEach(() => {
|
|
12
|
-
fs.rmSync(tmp, { recursive: true, force: true });
|
|
13
|
-
});
|
|
14
|
-
function write(rel, body) {
|
|
15
|
-
const full = path.join(tmp, rel);
|
|
16
|
-
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
17
|
-
fs.writeFileSync(full, body);
|
|
18
|
-
}
|
|
19
|
-
it('picks the ecosystem with the longest content', () => {
|
|
20
|
-
write('claude/SKILL.md', 'short');
|
|
21
|
-
write('claude-code/commands/x.md', 'a much longer body that beats Claude in size');
|
|
22
|
-
write('cursor/rules/x.mdc', 'tiny');
|
|
23
|
-
expect(autoDetectSourceEcosystem(tmp)).toBe('claude-code');
|
|
24
|
-
});
|
|
25
|
-
it('tie-breaks alphabetically when sizes are equal', () => {
|
|
26
|
-
write('claude/SKILL.md', 'samesize');
|
|
27
|
-
write('cursor/rules/x.mdc', 'samesize');
|
|
28
|
-
expect(autoDetectSourceEcosystem(tmp)).toBe('claude');
|
|
29
|
-
});
|
|
30
|
-
it('returns null when no ecosystem files are present', () => {
|
|
31
|
-
write('README.md', 'just docs');
|
|
32
|
-
expect(autoDetectSourceEcosystem(tmp)).toBeNull();
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
describe('ecosystemDestination', () => {
|
|
36
|
-
it('returns the conventional file path for each ecosystem', () => {
|
|
37
|
-
const cases = [
|
|
38
|
-
['claude', 'claude/SKILL.md'],
|
|
39
|
-
['claude-code', 'claude-code/commands/code-review.md'],
|
|
40
|
-
['cursor', 'cursor/rules/code-review.mdc'],
|
|
41
|
-
['chatgpt', 'chatgpt/code-review.md'],
|
|
42
|
-
['codex', 'codex/code-review.md'],
|
|
43
|
-
];
|
|
44
|
-
for (const [eco, expected] of cases) {
|
|
45
|
-
expect(ecosystemDestination(eco, 'code-review')).toBe(expected);
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
});
|
package/dist/lib/diff.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/lib/diff.test.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { renderDiff, hasChanges } from './diff.js';
|
|
3
|
-
describe('renderDiff', () => {
|
|
4
|
-
it('marks added lines with + and removed lines with -', () => {
|
|
5
|
-
const out = renderDiff('one\ntwo\nthree\n', 'one\ntwo-modified\nthree\n');
|
|
6
|
-
expect(out).toContain('-two');
|
|
7
|
-
expect(out).toContain('+two-modified');
|
|
8
|
-
});
|
|
9
|
-
it('returns "no changes" when inputs are identical', () => {
|
|
10
|
-
expect(renderDiff('a\nb\n', 'a\nb\n')).toMatch(/no changes/i);
|
|
11
|
-
});
|
|
12
|
-
it('truncates context blocks longer than 4 lines with an ellipsis', () => {
|
|
13
|
-
const before = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nMODIFY\nl10\nl11\nl12\nl13\n';
|
|
14
|
-
const after = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nCHANGED\nl10\nl11\nl12\nl13\n';
|
|
15
|
-
const out = renderDiff(before, after);
|
|
16
|
-
// The unchanged context blocks before and after the modification should
|
|
17
|
-
// include the literal "..." truncation marker.
|
|
18
|
-
expect(out).toContain('...');
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
describe('hasChanges', () => {
|
|
22
|
-
it('returns false when normalized contents match', () => {
|
|
23
|
-
expect(hasChanges('a\r\nb\r\n', 'a\nb\n')).toBe(false);
|
|
24
|
-
});
|
|
25
|
-
it('returns true when contents differ', () => {
|
|
26
|
-
expect(hasChanges('a\nb\n', 'a\nc\n')).toBe(true);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,63 +0,0 @@
|
|
|
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 { mockFetch } from '../test-utils.js';
|
|
6
|
-
import { saveLockfile } from './lockfile.js';
|
|
7
|
-
import { saveAuth } from './config.js';
|
|
8
|
-
import { syncLibrary } from './library-sync.js';
|
|
9
|
-
describe('syncLibrary', () => {
|
|
10
|
-
let restoreFetch = () => { };
|
|
11
|
-
const origHome = os.homedir;
|
|
12
|
-
let homeTmp;
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'libsync-'));
|
|
15
|
-
os.homedir = () => homeTmp;
|
|
16
|
-
process.env.BOTDOCS_API_URL = 'http://test.local';
|
|
17
|
-
});
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
restoreFetch();
|
|
20
|
-
fs.rmSync(homeTmp, { recursive: true, force: true });
|
|
21
|
-
os.homedir = origHome;
|
|
22
|
-
vi.restoreAllMocks();
|
|
23
|
-
});
|
|
24
|
-
it('is a no-op when sync_library is not enabled', async () => {
|
|
25
|
-
saveAuth({ githubToken: 't', username: 'u', displayName: 'U' });
|
|
26
|
-
const fm = mockFetch([]);
|
|
27
|
-
restoreFetch = fm.restore;
|
|
28
|
-
await syncLibrary();
|
|
29
|
-
expect(fm.calls).toHaveLength(0);
|
|
30
|
-
});
|
|
31
|
-
it('POSTs sanitized lockfile (refs + versions only) when enabled', async () => {
|
|
32
|
-
saveAuth({ githubToken: 't', username: 'u', displayName: 'U', syncLibrary: true });
|
|
33
|
-
saveLockfile({
|
|
34
|
-
version: 1,
|
|
35
|
-
installs: [{
|
|
36
|
-
ref: '@a/b',
|
|
37
|
-
type: 'SKILL',
|
|
38
|
-
version: '1.0.0',
|
|
39
|
-
installedAt: 't',
|
|
40
|
-
files: [{ src: 's', dest: '/some/path', fingerprint: 'fp' }],
|
|
41
|
-
}],
|
|
42
|
-
});
|
|
43
|
-
const fm = mockFetch([
|
|
44
|
-
{ method: 'POST', url: '/api/library/lockfile-sync', response: { body: { ok: true } } },
|
|
45
|
-
]);
|
|
46
|
-
restoreFetch = fm.restore;
|
|
47
|
-
await syncLibrary();
|
|
48
|
-
expect(fm.calls).toHaveLength(1);
|
|
49
|
-
const body = fm.calls[0].body;
|
|
50
|
-
expect(body.installs[0]).toEqual({ ref: '@a/b', type: 'SKILL', version: '1.0.0' });
|
|
51
|
-
// Sanitization: no files / fingerprints / dest
|
|
52
|
-
expect(body.installs[0].files).toBeUndefined();
|
|
53
|
-
});
|
|
54
|
-
it('swallows errors so install/sync flows never break on telemetry failures', async () => {
|
|
55
|
-
saveAuth({ githubToken: 't', username: 'u', displayName: 'U', syncLibrary: true });
|
|
56
|
-
saveLockfile({ version: 1, installs: [] });
|
|
57
|
-
const fm = mockFetch([
|
|
58
|
-
{ method: 'POST', url: '/api/library/lockfile-sync', response: { status: 500, body: { error: 'boom' } } },
|
|
59
|
-
]);
|
|
60
|
-
restoreFetch = fm.restore;
|
|
61
|
-
await expect(syncLibrary()).resolves.toBeUndefined();
|
|
62
|
-
});
|
|
63
|
-
});
|
package/dist/lib/llm.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/lib/llm.test.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
const anthropicCreate = vi.fn();
|
|
3
|
-
vi.mock('@anthropic-ai/sdk', () => ({
|
|
4
|
-
default: class {
|
|
5
|
-
messages = { create: anthropicCreate };
|
|
6
|
-
},
|
|
7
|
-
}));
|
|
8
|
-
const openaiCreate = vi.fn();
|
|
9
|
-
vi.mock('openai', () => ({
|
|
10
|
-
default: class {
|
|
11
|
-
chat = { completions: { create: openaiCreate } };
|
|
12
|
-
},
|
|
13
|
-
}));
|
|
14
|
-
import { detectProvider, complete, LlmError } from './llm.js';
|
|
15
|
-
describe('detectProvider', () => {
|
|
16
|
-
const env = { ...process.env };
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
delete process.env.BOTDOCS_ANTHROPIC_KEY;
|
|
19
|
-
delete process.env.BOTDOCS_OPENAI_KEY;
|
|
20
|
-
});
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
process.env = { ...env };
|
|
23
|
-
});
|
|
24
|
-
it('prefers Anthropic when both keys are set', () => {
|
|
25
|
-
process.env.BOTDOCS_ANTHROPIC_KEY = 'a';
|
|
26
|
-
process.env.BOTDOCS_OPENAI_KEY = 'o';
|
|
27
|
-
expect(detectProvider().provider).toBe('anthropic');
|
|
28
|
-
});
|
|
29
|
-
it('falls back to OpenAI when only OpenAI key is set', () => {
|
|
30
|
-
process.env.BOTDOCS_OPENAI_KEY = 'o';
|
|
31
|
-
expect(detectProvider().provider).toBe('openai');
|
|
32
|
-
});
|
|
33
|
-
it('throws LlmError when no key is set', () => {
|
|
34
|
-
expect(() => detectProvider()).toThrow(LlmError);
|
|
35
|
-
});
|
|
36
|
-
it('honors --key-env override', () => {
|
|
37
|
-
process.env.BOTDOCS_ANTHROPIC_KEY = 'a';
|
|
38
|
-
process.env.BOTDOCS_OPENAI_KEY = 'o';
|
|
39
|
-
expect(detectProvider({ keyEnv: 'BOTDOCS_OPENAI_KEY' }).provider).toBe('openai');
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
describe('complete', () => {
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
vi.clearAllMocks();
|
|
45
|
-
process.env.BOTDOCS_ANTHROPIC_KEY = 'a';
|
|
46
|
-
delete process.env.BOTDOCS_OPENAI_KEY;
|
|
47
|
-
});
|
|
48
|
-
it('calls Anthropic with the system + user prompt and returns text', async () => {
|
|
49
|
-
anthropicCreate.mockResolvedValue({
|
|
50
|
-
content: [{ type: 'text', text: 'compiled output' }],
|
|
51
|
-
usage: { input_tokens: 100, output_tokens: 50 },
|
|
52
|
-
});
|
|
53
|
-
const result = await complete({ system: 'sys', prompt: 'usr' });
|
|
54
|
-
expect(result.text).toBe('compiled output');
|
|
55
|
-
expect(result.usage).toEqual({ inputTokens: 100, outputTokens: 50 });
|
|
56
|
-
expect(anthropicCreate).toHaveBeenCalledWith(expect.objectContaining({
|
|
57
|
-
system: 'sys',
|
|
58
|
-
messages: [{ role: 'user', content: 'usr' }],
|
|
59
|
-
}));
|
|
60
|
-
});
|
|
61
|
-
it('calls OpenAI when configured', async () => {
|
|
62
|
-
delete process.env.BOTDOCS_ANTHROPIC_KEY;
|
|
63
|
-
process.env.BOTDOCS_OPENAI_KEY = 'o';
|
|
64
|
-
openaiCreate.mockResolvedValue({
|
|
65
|
-
choices: [{ message: { content: 'openai output' } }],
|
|
66
|
-
usage: { prompt_tokens: 80, completion_tokens: 40 },
|
|
67
|
-
});
|
|
68
|
-
const result = await complete({ system: 'sys', prompt: 'usr' });
|
|
69
|
-
expect(result.text).toBe('openai output');
|
|
70
|
-
expect(result.usage).toEqual({ inputTokens: 80, outputTokens: 40 });
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|