@selfagency/beans-mcp 0.1.1 → 0.1.2

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 (44) hide show
  1. package/{dist/beans-mcp-server.cjs → beans-mcp-server.cjs} +8 -4
  2. package/{dist/index.cjs → index.cjs} +8 -4
  3. package/{dist/index.d.ts → index.d.ts} +2 -1
  4. package/{dist/index.js → index.js} +8 -4
  5. package/package.json +27 -64
  6. package/.beans.yml +0 -6
  7. package/.claude/settings.local.json +0 -18
  8. package/.editorconfig +0 -13
  9. package/.github/workflows/release.yml +0 -235
  10. package/.github/workflows/test.yml +0 -80
  11. package/.husky/pre-commit +0 -1
  12. package/.nvmrc +0 -1
  13. package/.oxfmtrc.json +0 -11
  14. package/.oxlintrc.json +0 -37
  15. package/.vscode/settings.json +0 -3
  16. package/CHANGELOG.md +0 -140
  17. package/CONTRIBUTING.md +0 -139
  18. package/dist/README.md +0 -307
  19. package/dist/beans-mcp-server.cjs.map +0 -1
  20. package/dist/index.cjs.map +0 -1
  21. package/dist/index.js.map +0 -1
  22. package/dist/package.json +0 -43
  23. package/pnpm-workspace.yaml +0 -2
  24. package/scripts/release.js +0 -433
  25. package/scripts/write-dist-package.js +0 -53
  26. package/src/cli.ts +0 -14
  27. package/src/index.ts +0 -21
  28. package/src/internal/graphql.ts +0 -33
  29. package/src/internal/queryHelpers.ts +0 -157
  30. package/src/server/BeansMcpServer.ts +0 -600
  31. package/src/server/backend.ts +0 -358
  32. package/src/test/BeansMcpServer.test.ts +0 -514
  33. package/src/test/handlers.unit.test.ts +0 -184
  34. package/src/test/parseCliArgs.test.ts +0 -69
  35. package/src/test/protocol.e2e.test.ts +0 -884
  36. package/src/test/queryHelpers.test.ts +0 -524
  37. package/src/test/startBeansMcpServer.test.ts +0 -146
  38. package/src/test/tools-integration.test.ts +0 -912
  39. package/src/test/utils.test.ts +0 -80
  40. package/src/types.ts +0 -46
  41. package/src/utils.ts +0 -20
  42. package/tsconfig.json +0 -24
  43. package/tsup.config.ts +0 -42
  44. package/vitest.config.ts +0 -18
@@ -1,514 +0,0 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import {
4
- MutableBackend,
5
- createBeansMcpServer,
6
- parseCliArgs,
7
- resolveWorkspaceFromRoots,
8
- } from '../server/BeansMcpServer';
9
- import type { BackendInterface } from '../server/backend';
10
- import type { BeanRecord } from '../types';
11
-
12
- describe('parseCliArgs', () => {
13
- it('should parse positional workspace root', () => {
14
- const args = ['/path/to/workspace'];
15
- const result = parseCliArgs(args);
16
- expect(result.workspaceRoot).toBe('/path/to/workspace');
17
- });
18
-
19
- it('should parse --workspace-root flag', () => {
20
- const args = ['--workspace-root', '/custom/path'];
21
- const result = parseCliArgs(args);
22
- expect(result.workspaceRoot).toBe('/custom/path');
23
- });
24
-
25
- it('should allow both positional and --workspace-root (flag overwrites positional)', () => {
26
- const args = ['/positional', '--workspace-root', '/flag'];
27
- const result = parseCliArgs(args);
28
- // Flag comes after positional, so it overwrites
29
- expect(result.workspaceRoot).toBe('/flag');
30
- });
31
-
32
- it('should parse --cli-path flag', () => {
33
- const args = ['--cli-path', '/usr/bin/beans'];
34
- const result = parseCliArgs(args);
35
- expect(result.cliPath).toBe('/usr/bin/beans');
36
- });
37
-
38
- it('should use default cli-path', () => {
39
- const args = [];
40
- const result = parseCliArgs(args);
41
- expect(result.cliPath).toBe('beans');
42
- });
43
-
44
- it('should parse --port flag', () => {
45
- const args = ['--port', '8080'];
46
- const result = parseCliArgs(args);
47
- expect(result.port).toBe(8080);
48
- });
49
-
50
- it('should use default port', () => {
51
- const args = [];
52
- const result = parseCliArgs(args);
53
- expect(result.port).toBe(39173);
54
- });
55
-
56
- it('should parse --log-dir flag', () => {
57
- const args = ['--log-dir', '/var/log'];
58
- const result = parseCliArgs(args);
59
- expect(result.logDir).toBe('/var/log');
60
- });
61
-
62
- it('should handle combined flags', () => {
63
- const args = ['/workspace', '--cli-path', '/usr/bin/beans', '--port', '9000', '--log-dir', '/tmp/logs'];
64
- const result = parseCliArgs(args);
65
- expect(result.workspaceRoot).toBe('/workspace');
66
- expect(result.cliPath).toBe('/usr/bin/beans');
67
- expect(result.port).toBe(9000);
68
- expect(result.logDir).toBe('/tmp/logs');
69
- });
70
-
71
- it('should reject suspicious CLI paths with shell metacharacters', () => {
72
- const dangerous = ['--cli-path', 'beans; rm -rf /'];
73
- expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
74
- });
75
-
76
- it('should reject CLI paths with pipes', () => {
77
- const dangerous = ['--cli-path', 'beans | cat /etc/passwd'];
78
- expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
79
- });
80
-
81
- it('should reject CLI paths with redirects', () => {
82
- const dangerous = ['--cli-path', 'beans > /etc/passwd'];
83
- expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
84
- });
85
-
86
- it('should reject CLI paths with backticks', () => {
87
- const dangerous = ['--cli-path', '`rm -rf /`'];
88
- expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
89
- });
90
-
91
- it('should reject CLI paths with dollar expansion', () => {
92
- const dangerous = ['--cli-path', '$(whoami)'];
93
- expect(() => parseCliArgs(dangerous)).toThrow('Invalid CLI path');
94
- });
95
-
96
- it('should allow safe CLI paths with slashes and dashes', () => {
97
- const safe = ['--cli-path', '/usr/local/bin/beans-cli'];
98
- const result = parseCliArgs(safe);
99
- expect(result.cliPath).toBe('/usr/local/bin/beans-cli');
100
- });
101
- });
102
-
103
- describe('createBeansMcpServer', () => {
104
- const mockBackend: BackendInterface = {
105
- init: vi.fn(async () => ({ initialized: true })),
106
- list: vi.fn(async () => []),
107
- create: vi.fn(async input => ({
108
- id: 'bean1',
109
- slug: 'bean1',
110
- path: 'bean1.md',
111
- title: input.title,
112
- body: '',
113
- status: input.status || 'draft',
114
- type: input.type,
115
- })),
116
- update: vi.fn(async () => ({
117
- id: 'bean1',
118
- slug: 'bean1',
119
- path: 'bean1.md',
120
- title: 'Updated',
121
- body: '',
122
- status: 'todo',
123
- type: 'task',
124
- })),
125
- delete: vi.fn(async () => ({ deleted: true })),
126
- openConfig: vi.fn(async () => ({ configPath: '/config', content: '{}' })),
127
- graphqlSchema: vi.fn(async () => 'schema'),
128
- readOutputLog: vi.fn(async () => ({
129
- path: '/log',
130
- content: 'log',
131
- linesReturned: 0,
132
- })),
133
- readBeanFile: vi.fn(async () => ({ path: '/file', content: 'content' })),
134
- editBeanFile: vi.fn(async () => ({ path: '/file', bytes: 10 })),
135
- createBeanFile: vi.fn(async () => ({
136
- path: '/file',
137
- bytes: 10,
138
- created: true,
139
- })),
140
- deleteBeanFile: vi.fn(async () => ({ path: '/file', deleted: true })),
141
- };
142
-
143
- it('should create an MCP server instance', async () => {
144
- const { server, backend } = await createBeansMcpServer({
145
- workspaceRoot: '/test',
146
- backend: mockBackend,
147
- });
148
-
149
- expect(server).toBeDefined();
150
- expect(backend).toBeDefined();
151
- });
152
-
153
- it('should use provided backend implementation', async () => {
154
- const { backend } = await createBeansMcpServer({
155
- workspaceRoot: '/test',
156
- backend: mockBackend,
157
- });
158
-
159
- expect(backend).toBe(mockBackend);
160
- });
161
-
162
- it('should set server name from options', async () => {
163
- const { server } = await createBeansMcpServer({
164
- workspaceRoot: '/test',
165
- name: 'custom-server',
166
- backend: mockBackend,
167
- });
168
-
169
- expect(server).toBeDefined();
170
- });
171
-
172
- it('should set server version from options', async () => {
173
- const { server } = await createBeansMcpServer({
174
- workspaceRoot: '/test',
175
- version: '2.0.0',
176
- backend: mockBackend,
177
- });
178
-
179
- expect(server).toBeDefined();
180
- });
181
-
182
- it('should accept logDir option', async () => {
183
- const { server } = await createBeansMcpServer({
184
- workspaceRoot: '/test',
185
- logDir: '/var/log',
186
- backend: mockBackend,
187
- });
188
-
189
- expect(server).toBeDefined();
190
- });
191
-
192
- it('should accept cliPath option', async () => {
193
- const { server: _server } = await createBeansMcpServer({
194
- workspaceRoot: '/test',
195
- cliPath: '/usr/bin/beans',
196
- backend: mockBackend,
197
- });
198
-
199
- expect(_server).toBeDefined();
200
- });
201
-
202
- it('should handle empty list operation', async () => {
203
- const { backend } = await createBeansMcpServer({
204
- workspaceRoot: '/test',
205
- backend: mockBackend,
206
- });
207
-
208
- (mockBackend.list as any).mockImplementationOnce(async () => []);
209
- const result = await backend.list();
210
- expect(result).toEqual([]);
211
- });
212
-
213
- it('should handle list with beans', async () => {
214
- const mockBeans: BeanRecord[] = [
215
- {
216
- id: 'bean1',
217
- slug: 'bean1',
218
- path: 'bean1.md',
219
- title: 'Test Bean',
220
- body: 'Content',
221
- status: 'todo',
222
- type: 'task',
223
- },
224
- ];
225
-
226
- const { backend } = await createBeansMcpServer({
227
- workspaceRoot: '/test',
228
- backend: mockBackend,
229
- });
230
-
231
- (mockBackend.list as any).mockImplementationOnce(async () => mockBeans);
232
- const result = await backend.list();
233
- expect(result).toHaveLength(1);
234
- expect(result[0].id).toBe('bean1');
235
- });
236
-
237
- it('should call backend init with prefix', async () => {
238
- const { backend } = await createBeansMcpServer({
239
- workspaceRoot: '/test',
240
- backend: mockBackend,
241
- });
242
-
243
- await backend.init('TEST');
244
- expect((mockBackend.init as any).mock.calls.length).toBeGreaterThan(0);
245
- });
246
-
247
- it('should create beans with required fields', async () => {
248
- const { backend } = await createBeansMcpServer({
249
- workspaceRoot: '/test',
250
- backend: mockBackend,
251
- });
252
-
253
- const result = await backend.create({
254
- title: 'New Bean',
255
- type: 'task',
256
- });
257
-
258
- expect(result.id).toBe('bean1');
259
- expect(result.title).toBe('New Bean');
260
- });
261
-
262
- it('should create beans with optional status', async () => {
263
- const { backend } = await createBeansMcpServer({
264
- workspaceRoot: '/test',
265
- backend: mockBackend,
266
- });
267
-
268
- await backend.create({
269
- title: 'New Bean',
270
- type: 'feature',
271
- status: 'in-progress',
272
- });
273
-
274
- expect((mockBackend.create as any).mock.calls.length).toBeGreaterThan(0);
275
- });
276
-
277
- it('should create beans with optional priority', async () => {
278
- const { backend } = await createBeansMcpServer({
279
- workspaceRoot: '/test',
280
- backend: mockBackend,
281
- });
282
-
283
- await backend.create({
284
- title: 'New Bean',
285
- type: 'bug',
286
- priority: 'high',
287
- });
288
-
289
- expect((mockBackend.create as any).mock.calls.length).toBeGreaterThan(0);
290
- });
291
-
292
- it('should handle bean updates', async () => {
293
- const { backend } = await createBeansMcpServer({
294
- workspaceRoot: '/test',
295
- backend: mockBackend,
296
- });
297
-
298
- const result = await backend.update('bean1', {
299
- status: 'completed',
300
- });
301
-
302
- expect(result.status).toBe('todo');
303
- });
304
-
305
- it('should delete beans', async () => {
306
- const { backend } = await createBeansMcpServer({
307
- workspaceRoot: '/test',
308
- backend: mockBackend,
309
- });
310
-
311
- const result = await backend.delete('bean1');
312
- expect(result).toEqual({ deleted: true });
313
- });
314
-
315
- it('should open config', async () => {
316
- const { backend } = await createBeansMcpServer({
317
- workspaceRoot: '/test',
318
- backend: mockBackend,
319
- });
320
-
321
- const result = await backend.openConfig();
322
- expect(result).toEqual({ configPath: '/config', content: '{}' });
323
- });
324
-
325
- it('should get GraphQL schema', async () => {
326
- const { backend } = await createBeansMcpServer({
327
- workspaceRoot: '/test',
328
- backend: mockBackend,
329
- });
330
-
331
- const result = await backend.graphqlSchema();
332
- expect(result).toBe('schema');
333
- });
334
-
335
- it('should read output log', async () => {
336
- const { backend } = await createBeansMcpServer({
337
- workspaceRoot: '/test',
338
- backend: mockBackend,
339
- });
340
-
341
- const result = await backend.readOutputLog();
342
- expect(result.path).toBe('/log');
343
- });
344
-
345
- it('should read bean file', async () => {
346
- const { backend } = await createBeansMcpServer({
347
- workspaceRoot: '/test',
348
- backend: mockBackend,
349
- });
350
-
351
- const result = await backend.readBeanFile('test.md');
352
- expect(result.content).toBe('content');
353
- });
354
-
355
- it('should edit bean file', async () => {
356
- const { backend } = await createBeansMcpServer({
357
- workspaceRoot: '/test',
358
- backend: mockBackend,
359
- });
360
-
361
- const result = await backend.editBeanFile('test.md', 'new content');
362
- expect(result.bytes).toBe(10);
363
- });
364
-
365
- it('should create bean file', async () => {
366
- const { backend } = await createBeansMcpServer({
367
- workspaceRoot: '/test',
368
- backend: mockBackend,
369
- });
370
-
371
- const result = await backend.createBeanFile('test.md', 'content');
372
- expect(result.created).toBe(true);
373
- });
374
-
375
- it('should delete bean file', async () => {
376
- const { backend } = await createBeansMcpServer({
377
- workspaceRoot: '/test',
378
- backend: mockBackend,
379
- });
380
-
381
- const result = await backend.deleteBeanFile('test.md');
382
- expect(result.path).toBe('/file');
383
- });
384
- });
385
-
386
- // ---------------------------------------------------------------------------
387
- // MutableBackend
388
- // ---------------------------------------------------------------------------
389
-
390
- describe('MutableBackend', () => {
391
- function makeInner(overrides: Partial<BackendInterface> = {}): BackendInterface {
392
- return {
393
- init: vi.fn(async () => ({ initialized: true })),
394
- list: vi.fn(async () => []),
395
- create: vi.fn(async input => ({
396
- id: 'b1',
397
- slug: 'b1',
398
- path: 'b1.md',
399
- title: input.title,
400
- body: '',
401
- status: 'draft',
402
- type: input.type,
403
- })),
404
- update: vi.fn(async () => ({
405
- id: 'b1',
406
- slug: 'b1',
407
- path: 'b1.md',
408
- title: 'T',
409
- body: '',
410
- status: 'todo',
411
- type: 'task',
412
- })),
413
- delete: vi.fn(async () => ({ deleted: true })),
414
- openConfig: vi.fn(async () => ({ configPath: '/cfg', content: '{}' })),
415
- graphqlSchema: vi.fn(async () => 'schema'),
416
- readOutputLog: vi.fn(async () => ({ path: '/log', content: 'log', linesReturned: 0 })),
417
- readBeanFile: vi.fn(async () => ({ path: '/f', content: 'c' })),
418
- editBeanFile: vi.fn(async () => ({ path: '/f', bytes: 1 })),
419
- createBeanFile: vi.fn(async () => ({ path: '/f', bytes: 1, created: true })),
420
- deleteBeanFile: vi.fn(async () => ({ path: '/f', deleted: true })),
421
- ...overrides,
422
- };
423
- }
424
-
425
- it('delegates every method to the inner backend', async () => {
426
- const inner = makeInner();
427
- const m = new MutableBackend(inner);
428
-
429
- await m.init('pfx');
430
- expect(inner.init).toHaveBeenCalledWith('pfx');
431
-
432
- await m.list({ status: ['todo'] });
433
- expect(inner.list).toHaveBeenCalledWith({ status: ['todo'] });
434
-
435
- await m.create({ title: 'T', type: 'task' });
436
- expect(inner.create).toHaveBeenCalled();
437
-
438
- await m.update('b1', { status: 'done' });
439
- expect(inner.update).toHaveBeenCalledWith('b1', { status: 'done' });
440
-
441
- await m.delete('b1');
442
- expect(inner.delete).toHaveBeenCalledWith('b1');
443
-
444
- await m.openConfig();
445
- expect(inner.openConfig).toHaveBeenCalled();
446
-
447
- await m.graphqlSchema();
448
- expect(inner.graphqlSchema).toHaveBeenCalled();
449
-
450
- await m.readOutputLog({ lines: 5 });
451
- expect(inner.readOutputLog).toHaveBeenCalledWith({ lines: 5 });
452
-
453
- await m.readBeanFile('a.md');
454
- expect(inner.readBeanFile).toHaveBeenCalledWith('a.md');
455
-
456
- await m.editBeanFile('a.md', 'content');
457
- expect(inner.editBeanFile).toHaveBeenCalledWith('a.md', 'content');
458
-
459
- await m.createBeanFile('a.md', 'content', { overwrite: true });
460
- expect(inner.createBeanFile).toHaveBeenCalledWith('a.md', 'content', { overwrite: true });
461
-
462
- await m.deleteBeanFile('a.md');
463
- expect(inner.deleteBeanFile).toHaveBeenCalledWith('a.md');
464
- });
465
-
466
- it('setInner swaps the delegate so subsequent calls go to the new backend', async () => {
467
- const inner1 = makeInner();
468
- const inner2 = makeInner();
469
- const m = new MutableBackend(inner1);
470
-
471
- await m.list();
472
- expect(inner1.list).toHaveBeenCalledTimes(1);
473
- expect(inner2.list).not.toHaveBeenCalled();
474
-
475
- m.setInner(inner2);
476
- await m.list();
477
- expect(inner1.list).toHaveBeenCalledTimes(1); // not called again
478
- expect(inner2.list).toHaveBeenCalledTimes(1);
479
- });
480
- });
481
-
482
- // ---------------------------------------------------------------------------
483
- // resolveWorkspaceFromRoots
484
- // ---------------------------------------------------------------------------
485
-
486
- describe('resolveWorkspaceFromRoots', () => {
487
- function mockServer(listRootsImpl: () => Promise<{ roots: { uri: string }[] }>): McpServer {
488
- return { server: { listRoots: listRootsImpl } } as unknown as McpServer;
489
- }
490
-
491
- it('returns the pathname of the first file:// root', async () => {
492
- const server = mockServer(async () => ({ roots: [{ uri: 'file:///my/project' }] }));
493
- expect(await resolveWorkspaceFromRoots(server)).toBe('/my/project');
494
- });
495
-
496
- it('skips non-file:// URIs and returns the first file:// one', async () => {
497
- const server = mockServer(async () => ({
498
- roots: [{ uri: 'https://example.com' }, { uri: 'file:///local/path' }],
499
- }));
500
- expect(await resolveWorkspaceFromRoots(server)).toBe('/local/path');
501
- });
502
-
503
- it('returns null when no file:// roots are present', async () => {
504
- const server = mockServer(async () => ({ roots: [] }));
505
- expect(await resolveWorkspaceFromRoots(server)).toBeNull();
506
- });
507
-
508
- it('returns null when listRoots throws', async () => {
509
- const server = mockServer(async () => {
510
- throw new Error('no roots capability');
511
- });
512
- expect(await resolveWorkspaceFromRoots(server)).toBeNull();
513
- });
514
- });
@@ -1,184 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import {
3
- beanFileHandler,
4
- createHandler,
5
- deleteHandler,
6
- editHandler,
7
- getBeanById,
8
- initHandler,
9
- outputHandler,
10
- queryHandler,
11
- reopenHandler,
12
- viewHandler,
13
- } from '../server/BeansMcpServer';
14
-
15
- const sampleBean = {
16
- id: 'b1',
17
- slug: 'b1',
18
- path: '.beans/b1.md',
19
- title: 'B1',
20
- body: 'body',
21
- status: 'completed',
22
- type: 'task',
23
- };
24
-
25
- function makeBackend(overrides: Partial<any> = {}) {
26
- return {
27
- list: vi.fn(async () => [sampleBean, { ...sampleBean, id: 'b2', status: 'draft' }]),
28
- init: vi.fn(async (p?: string) => ({ ok: true, prefix: p })),
29
- create: vi.fn(async (input: any) => ({
30
- ...sampleBean,
31
- ...input,
32
- id: 'new',
33
- })),
34
- update: vi.fn(async (id: string, updates: any) => ({
35
- ...sampleBean,
36
- id,
37
- ...updates,
38
- })),
39
- delete: vi.fn(async (id: string) => ({ ok: true, id })),
40
- openConfig: vi.fn(async () => ({ configPath: '.beans.yml', content: 'x' })),
41
- graphqlSchema: vi.fn(async () => ''),
42
- readOutputLog: vi.fn(async ({ lines }: any) => ({
43
- path: 'p',
44
- content: 'log',
45
- linesReturned: lines ?? 0,
46
- })),
47
- readBeanFile: vi.fn(async (path: string) => ({ path, content: 'x' })),
48
- editBeanFile: vi.fn(async (path: string, content: string) => ({
49
- path,
50
- bytes: Buffer.byteLength(content, 'utf8'),
51
- })),
52
- createBeanFile: vi.fn(async (path: string, content: string, opts: any) => ({
53
- path,
54
- bytes: Buffer.byteLength(content, 'utf8'),
55
- created: true,
56
- })),
57
- deleteBeanFile: vi.fn(async (path: string) => ({ path, deleted: true })),
58
- ...overrides,
59
- };
60
- }
61
-
62
- describe('Handlers (unit)', () => {
63
- it('getBeanById returns bean when found', async () => {
64
- const backend = makeBackend();
65
- const b = await getBeanById(backend, 'b1');
66
- expect(b.id).toBe('b1');
67
- });
68
-
69
- it('getBeanById throws when not found', async () => {
70
- const backend = makeBackend({ list: vi.fn(async () => []) });
71
- await expect(getBeanById(backend, 'missing')).rejects.toThrow(/Bean not found/);
72
- });
73
-
74
- it('initHandler calls backend.init and wraps result', async () => {
75
- const backend = makeBackend();
76
- const res = await initHandler(backend)({ prefix: 'pfx' });
77
- expect(backend.init).toHaveBeenCalledWith('pfx');
78
- expect(res.structuredContent).toBeDefined();
79
- });
80
-
81
- it('viewHandler returns bean structured content', async () => {
82
- const backend = makeBackend();
83
- const res = await viewHandler(backend)({ beanId: 'b1' });
84
- expect(res.structuredContent.bean.id).toBe('b1');
85
- });
86
-
87
- it('createHandler delegates to backend.create', async () => {
88
- const backend = makeBackend();
89
- const res = await createHandler(backend)({ title: 'T', type: 't' });
90
- expect(backend.create).toHaveBeenCalled();
91
- expect(res.structuredContent.bean.id).toBe('new');
92
- });
93
-
94
- it('editHandler delegates to backend.update', async () => {
95
- const backend = makeBackend();
96
- const res = await editHandler(backend)({ beanId: 'b1', status: 'todo' });
97
- expect(backend.update).toHaveBeenCalledWith('b1', { status: 'todo' });
98
- expect(res.structuredContent.bean.status).toBe('todo');
99
- });
100
-
101
- it('reopenHandler throws if current status mismatches', async () => {
102
- const backend = makeBackend();
103
- await expect(
104
- reopenHandler(backend)({
105
- beanId: 'b1',
106
- requiredCurrentStatus: 'scrapped',
107
- targetStatus: 'todo',
108
- }),
109
- ).rejects.toThrow(/is not scrapped/);
110
- });
111
-
112
- it('reopenHandler updates when status matches', async () => {
113
- const backend = makeBackend();
114
- const res = await reopenHandler(backend)({
115
- beanId: 'b1',
116
- requiredCurrentStatus: 'completed',
117
- targetStatus: 'todo',
118
- });
119
- expect(backend.update).toHaveBeenCalled();
120
- expect(res.structuredContent.bean.status).toBe('todo');
121
- });
122
-
123
- it('deleteHandler enforces draft/scrapped unless force', async () => {
124
- const backend = makeBackend();
125
- await expect(deleteHandler(backend)({ beanId: 'b1', force: false })).rejects.toThrow(
126
- /Only draft and scrapped beans are deletable/,
127
- );
128
- const res = await deleteHandler(backend)({ beanId: 'b1', force: true });
129
- expect(backend.delete).toHaveBeenCalledWith('b1');
130
- });
131
-
132
- it('beanFileHandler routes operations', async () => {
133
- const backend = makeBackend();
134
- const _read = await beanFileHandler(backend)({
135
- operation: 'read',
136
- path: 'p',
137
- });
138
- expect(backend.readBeanFile).toHaveBeenCalledWith('p');
139
- const _edit = await beanFileHandler(backend)({
140
- operation: 'edit',
141
- path: 'p',
142
- content: 'c',
143
- });
144
- expect(backend.editBeanFile).toHaveBeenCalledWith('p', 'c');
145
- const _create = await beanFileHandler(backend)({
146
- operation: 'create',
147
- path: 'p',
148
- content: 'c',
149
- overwrite: true,
150
- });
151
- expect(backend.createBeanFile).toHaveBeenCalled();
152
- const _del = await beanFileHandler(backend)({
153
- operation: 'delete',
154
- path: 'p',
155
- });
156
- expect(backend.deleteBeanFile).toHaveBeenCalledWith('p');
157
- });
158
-
159
- it('beanFileHandler throws on unsupported operation', async () => {
160
- const backend = makeBackend();
161
- await expect(beanFileHandler(backend)({ operation: 'noop' as 'read', path: 'p' })).rejects.toThrow(
162
- 'Unsupported operation',
163
- );
164
- });
165
-
166
- it('outputHandler read and show', async () => {
167
- const backend = makeBackend();
168
- const _r = await outputHandler(backend)({ operation: 'read', lines: 10 });
169
- expect(backend.readOutputLog).toHaveBeenCalled();
170
- const s = await outputHandler(backend)({ operation: 'show' });
171
- if ('message' in s.structuredContent) {
172
- expect(s.structuredContent.message).toMatch(/When using VS Code UI/);
173
- } else {
174
- throw new Error('expected message in structuredContent');
175
- }
176
- });
177
-
178
- it('queryHandler delegates to handleQueryOperation', async () => {
179
- const backend = makeBackend();
180
- const res = await queryHandler(backend)({ operation: 'refresh' });
181
- // handleQueryOperation returns value directly; ensure promise resolves
182
- expect(res).toBeDefined();
183
- });
184
- });