@abarcenas/ai-workflow-template 1.45.0 → 1.46.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 (47) hide show
  1. package/.agents/config/README.md +43 -0
  2. package/.agents/config/domain.md +37 -0
  3. package/.agents/instructions/learned-knowledge.instructions.md +110 -0
  4. package/.agents/instructions/memory-bank.instructions.md +11 -0
  5. package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
  6. package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
  7. package/.agents/skills/domain-modeling/SKILL.md +74 -0
  8. package/.agents/skills/grill-me/SKILL.md +7 -0
  9. package/.agents/skills/grill-with-docs/SKILL.md +7 -0
  10. package/.agents/skills/grilling/SKILL.md +28 -0
  11. package/.agents/skills/prototype/LOGIC.md +67 -0
  12. package/.agents/skills/prototype/SKILL.md +26 -0
  13. package/.agents/skills/prototype/UI.md +112 -0
  14. package/.github/workflows/npm-publish.yml +10 -0
  15. package/.github/workflows/pr-test.yml +31 -0
  16. package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -3
  17. package/.opencode/agents/scope.agent.md +299 -0
  18. package/AGENTS.md +42 -3
  19. package/README.md +5 -2
  20. package/package.json +1 -1
  21. package/scripts/bump-version.test.js +238 -0
  22. package/scripts/mcp/playwright-mcp-launcher.js +55 -29
  23. package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
  24. package/scripts/mcp-memory-server.js +93 -76
  25. package/scripts/mcp-memory-server.test.js +398 -0
  26. package/scripts/memory-cli.test.js +297 -0
  27. package/scripts/memory-index.js +119 -31
  28. package/scripts/memory-index.test.js +1173 -0
  29. package/scripts/normalize-memory.js +26 -1
  30. package/scripts/normalize-memory.test.js +753 -0
  31. package/scripts/setup/discover.test.js +124 -2
  32. package/scripts/setup/hooks.test.js +188 -1
  33. package/scripts/setup/husky-init.test.js +311 -0
  34. package/scripts/setup/index.js +35 -5
  35. package/scripts/setup/index.test.js +279 -0
  36. package/scripts/setup/knowledgebase.test.js +100 -0
  37. package/scripts/setup/skills-phase.js +568 -0
  38. package/scripts/setup/skills-phase.test.js +1274 -0
  39. package/scripts/setup/sync-phase.js +6 -1
  40. package/scripts/setup/sync-phase.test.js +180 -2
  41. package/scripts/setup/ui.test.js +579 -0
  42. package/scripts/setup/utils.test.js +586 -0
  43. package/scripts/sync-unit.test.js +1342 -0
  44. package/scripts/sync.js +540 -246
  45. package/scripts/sync.test.js +283 -0
  46. package/scripts/vocab-sync.test.js +88 -0
  47. package/skills-lock.json +90 -0
@@ -0,0 +1,398 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // mcp-memory-server.test.js — Unit tests for the memory-bank MCP server
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // The stdio bootstrap is direct-run guarded, so importing the module in tests
6
+ // does not open a transport. The MCP SDK and the index engine are mocked:
7
+ // tests exercise the exported `listTools` / `handleToolCall` handlers, the
8
+ // formatting of every tool response, error propagation, and the start-up
9
+ // (`main` / `bootstrap`) paths — including the fatal path.
10
+ //
11
+ // Covered scenarios:
12
+ // 1. listTools exposes all five tools with schemas
13
+ // 2. memory_search formats ranked results
14
+ // 3. memory_search defaults (topK=5, no source filter)
15
+ // 4. memory_search honours explicit topK/sourceFile
16
+ // 5. memory_search empty index message
17
+ // 6. memory_search error → isError response
18
+ // 7. memory_rebuild formats counts and stats
19
+ // 8. memory_update with changed files + stats
20
+ // 9. memory_update with no changes
21
+ // 10. memory_get returns content
22
+ // 11. memory_get missing file → available-files hint
23
+ // 12. memory_stats formats the index statistics
24
+ // 13. unknown tool → caught error response
25
+ // 14. main() connects a stdio transport and logs start-up
26
+ // 15. bootstrap() exits 1 when the transport fails
27
+ // 16. direct run auto-bootstraps; empty argv[1] does not
28
+ // ═════════════════════════════════════════════════════════════════════════════
29
+
30
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
31
+ import { fileURLToPath } from 'url'
32
+
33
+ const sdk = vi.hoisted(() => ({ connect: vi.fn() }))
34
+
35
+ vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
36
+ Server: class {
37
+ setRequestHandler() {}
38
+ connect(transport) {
39
+ return sdk.connect(transport)
40
+ }
41
+ },
42
+ }))
43
+
44
+ vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
45
+ StdioServerTransport: class {
46
+ constructor() {
47
+ this.kind = 'stdio'
48
+ }
49
+ },
50
+ }))
51
+
52
+ vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
53
+ CallToolRequestSchema: 'call-tool',
54
+ ListToolsRequestSchema: 'list-tools',
55
+ }))
56
+
57
+ vi.mock('./memory-index.js', () => ({
58
+ search: vi.fn(),
59
+ rebuildIndex: vi.fn(),
60
+ incrementalUpdate: vi.fn(),
61
+ getFile: vi.fn(),
62
+ getStats: vi.fn(),
63
+ }))
64
+
65
+ /**
66
+ * Builds an MCP tool-call request as the SDK delivers it.
67
+ *
68
+ * @param {string} name
69
+ * @param {object} [args]
70
+ * @returns {{params: {name: string, arguments: object}}}
71
+ */
72
+ function buildRequest(name, args = {}) {
73
+ return { params: { name, arguments: args } }
74
+ }
75
+
76
+ const sampleResults = [
77
+ {
78
+ score: 0.91,
79
+ source_file: 'activeContext',
80
+ content: 'Current focus is the coverage push.',
81
+ },
82
+ {
83
+ score: 0.72,
84
+ source_file: 'progress',
85
+ content: 'All five scripts now have tests.',
86
+ },
87
+ ]
88
+
89
+ describe('mcp-memory-server', () => {
90
+ let mod
91
+ let index
92
+ let consoleErrorSpy
93
+ let exitSpy
94
+ let originalArgv1
95
+
96
+ beforeEach(async () => {
97
+ vi.resetModules()
98
+ vi.clearAllMocks()
99
+ originalArgv1 = process.argv[1]
100
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
101
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
102
+ mod = await import('./mcp-memory-server.js')
103
+ index = await import('./memory-index.js')
104
+ })
105
+
106
+ afterEach(() => {
107
+ process.argv[1] = originalArgv1
108
+ consoleErrorSpy.mockRestore()
109
+ exitSpy.mockRestore()
110
+ })
111
+
112
+ // ── 1. listTools ─────────────────────────────────────────────────────────
113
+
114
+ it('listTools: exposes the five memory-bank tools with schemas', async () => {
115
+ const result = await mod.listTools()
116
+
117
+ expect(result.tools.map((tool) => tool.name)).toEqual([
118
+ 'memory_search',
119
+ 'memory_rebuild',
120
+ 'memory_update',
121
+ 'memory_get',
122
+ 'memory_stats',
123
+ ])
124
+
125
+ const search = result.tools.find((tool) => tool.name === 'memory_search')
126
+ expect(search.inputSchema.required).toEqual(['query'])
127
+ expect(search.inputSchema.properties.topK.default).toBe(5)
128
+
129
+ const get = result.tools.find((tool) => tool.name === 'memory_get')
130
+ expect(get.inputSchema.required).toEqual(['file'])
131
+ expect(get.inputSchema.properties.file.enum).toContain('activeContext')
132
+ expect(get.inputSchema.properties.file.enum).toContain(
133
+ 'playwright-mcp-configuration',
134
+ )
135
+ })
136
+
137
+ // ── 2. memory_search formatting ──────────────────────────────────────────
138
+
139
+ it('memory_search: formats ranked results with scores and source files', async () => {
140
+ index.search.mockResolvedValue(sampleResults)
141
+
142
+ const response = await mod.handleToolCall(
143
+ buildRequest('memory_search', { query: 'what is the focus?' }),
144
+ )
145
+
146
+ expect(index.search).toHaveBeenCalledWith('what is the focus?', {
147
+ topK: 5,
148
+ sourceFile: undefined,
149
+ })
150
+
151
+ const text = response.content[0].text
152
+ expect(text).toContain('### Result 1 (score: 0.91)')
153
+ expect(text).toContain('**File:** `activeContext`')
154
+ expect(text).toContain('Current focus is the coverage push.')
155
+ expect(text).toContain('### Result 2 (score: 0.72)')
156
+ expect(text).toContain('\n\n---\n\n')
157
+ expect(response.isError).toBeUndefined()
158
+ })
159
+
160
+ // ── 3/4. memory_search options ───────────────────────────────────────────
161
+
162
+ it('memory_search: defaults topK to 5 when omitted', async () => {
163
+ index.search.mockResolvedValue([])
164
+
165
+ await mod.handleToolCall(buildRequest('memory_search', { query: 'x' }))
166
+
167
+ expect(index.search).toHaveBeenCalledWith('x', {
168
+ topK: 5,
169
+ sourceFile: undefined,
170
+ })
171
+ })
172
+
173
+ it('memory_search: forwards explicit topK and sourceFile', async () => {
174
+ index.search.mockResolvedValue(sampleResults)
175
+
176
+ await mod.handleToolCall(
177
+ buildRequest('memory_search', {
178
+ query: 'focus',
179
+ topK: 2,
180
+ sourceFile: 'activeContext',
181
+ }),
182
+ )
183
+
184
+ expect(index.search).toHaveBeenCalledWith('focus', {
185
+ topK: 2,
186
+ sourceFile: 'activeContext',
187
+ })
188
+ })
189
+
190
+ // ── 5. Empty results ─────────────────────────────────────────────────────
191
+
192
+ it('memory_search: returns a rebuild hint when the index is empty', async () => {
193
+ index.search.mockResolvedValue([])
194
+
195
+ const response = await mod.handleToolCall(
196
+ buildRequest('memory_search', { query: 'nothing' }),
197
+ )
198
+
199
+ expect(response.content[0].text).toBe(
200
+ 'No results found. The index may be empty. Try running memory_rebuild first.',
201
+ )
202
+ expect(response.isError).toBeUndefined()
203
+ })
204
+
205
+ // ── 6. Error propagation ─────────────────────────────────────────────────
206
+
207
+ it('memory_search: converts engine errors into an isError response', async () => {
208
+ index.search.mockRejectedValue(new Error('vector store offline'))
209
+
210
+ const response = await mod.handleToolCall(
211
+ buildRequest('memory_search', { query: 'x' }),
212
+ )
213
+
214
+ expect(response.isError).toBe(true)
215
+ expect(response.content[0].text).toBe('Error: vector store offline')
216
+ })
217
+
218
+ // ── 7. memory_rebuild ────────────────────────────────────────────────────
219
+
220
+ it('memory_rebuild: formats indexed/skipped counts and stats', async () => {
221
+ index.rebuildIndex.mockResolvedValue({
222
+ indexed: 12,
223
+ skipped: 3,
224
+ stats: { totalVectors: 42, totalFiles: 8, dbSize: '512 kB' },
225
+ })
226
+
227
+ const response = await mod.handleToolCall(
228
+ buildRequest('memory_rebuild', {}),
229
+ )
230
+
231
+ const text = response.content[0].text
232
+ expect(text).toContain('Index rebuilt successfully.')
233
+ expect(text).toContain('- Chunks indexed: 12')
234
+ expect(text).toContain('- Chunks skipped (unchanged): 3')
235
+ expect(text).toContain('- Total vectors: 42')
236
+ expect(text).toContain('- Total files: 8')
237
+ expect(text).toContain('- DB size: 512 kB')
238
+ expect(response.isError).toBeUndefined()
239
+ })
240
+
241
+ // ── 8. memory_update with changes ────────────────────────────────────────
242
+
243
+ it('memory_update: lists changed files and the new vector total', async () => {
244
+ index.incrementalUpdate.mockResolvedValue({
245
+ indexed: 4,
246
+ skipped: 1,
247
+ changed: ['activeContext.md', 'progress.md'],
248
+ })
249
+ index.getStats.mockReturnValue({ totalVectors: 100 })
250
+
251
+ const response = await mod.handleToolCall(
252
+ buildRequest('memory_update', {}),
253
+ )
254
+
255
+ const text = response.content[0].text
256
+ expect(text).toContain('Index updated incrementally.')
257
+ expect(text).toContain('- Chunks re-indexed: 4')
258
+ expect(text).toContain('- Chunks cached (unchanged): 1')
259
+ expect(text).toContain('- Changed files: activeContext.md, progress.md')
260
+ expect(text).toContain('- Total vectors: 100')
261
+ expect(response.isError).toBeUndefined()
262
+ })
263
+
264
+ // ── 9. memory_update with no changes ────────────────────────────────────
265
+
266
+ it('memory_update: reports "No files changed." when nothing changed', async () => {
267
+ index.incrementalUpdate.mockResolvedValue({
268
+ indexed: 0,
269
+ skipped: 7,
270
+ changed: [],
271
+ })
272
+ index.getStats.mockReturnValue({ totalVectors: 100 })
273
+
274
+ const response = await mod.handleToolCall(
275
+ buildRequest('memory_update', {}),
276
+ )
277
+
278
+ expect(response.content[0].text).toContain('- No files changed.')
279
+ expect(response.content[0].text).not.toContain('- Changed files:')
280
+ })
281
+
282
+ // ── 10. memory_get ───────────────────────────────────────────────────────
283
+
284
+ it('memory_get: returns the full file content', async () => {
285
+ index.getFile.mockReturnValue('# Active Context\n\nWorking.')
286
+
287
+ const response = await mod.handleToolCall(
288
+ buildRequest('memory_get', { file: 'activeContext' }),
289
+ )
290
+
291
+ expect(index.getFile).toHaveBeenCalledWith('activeContext')
292
+ expect(response.content[0].text).toBe('# Active Context\n\nWorking.')
293
+ expect(response.isError).toBeUndefined()
294
+ })
295
+
296
+ // ── 11. memory_get missing file ─────────────────────────────────────────
297
+
298
+ it('memory_get: lists available files when the file does not exist', async () => {
299
+ index.getFile.mockReturnValue(undefined)
300
+
301
+ const response = await mod.handleToolCall(
302
+ buildRequest('memory_get', { file: 'nope' }),
303
+ )
304
+
305
+ const text = response.content[0].text
306
+ expect(text).toContain('File not found: nope.md.')
307
+ expect(text).toContain('Available files: activeContext,')
308
+ expect(response.isError).toBeUndefined()
309
+ })
310
+
311
+ // ── 12. memory_stats ─────────────────────────────────────────────────────
312
+
313
+ it('memory_stats: formats vector, file, and size statistics', async () => {
314
+ index.getStats.mockReturnValue({
315
+ totalVectors: 321,
316
+ totalFiles: 17,
317
+ dbSize: '1.2 MB',
318
+ })
319
+
320
+ const response = await mod.handleToolCall(
321
+ buildRequest('memory_stats', {}),
322
+ )
323
+
324
+ const text = response.content[0].text
325
+ expect(text).toContain('**Memory Index Stats**')
326
+ expect(text).toContain('- Total vectors: 321')
327
+ expect(text).toContain('- Total files indexed: 17')
328
+ expect(text).toContain('- Database size: 1.2 MB')
329
+ })
330
+
331
+ // ── 13. Unknown tool ─────────────────────────────────────────────────────
332
+
333
+ it('unknown tool: returns a caught error response', async () => {
334
+ const response = await mod.handleToolCall(
335
+ buildRequest('memory_bogus', {}),
336
+ )
337
+
338
+ expect(response.isError).toBe(true)
339
+ expect(response.content[0].text).toBe('Error: Unknown tool: memory_bogus')
340
+ })
341
+
342
+ // ── 14. main() ───────────────────────────────────────────────────────────
343
+
344
+ it('main: connects a stdio transport and logs start-up', async () => {
345
+ sdk.connect.mockResolvedValue(undefined)
346
+
347
+ await mod.main()
348
+
349
+ expect(sdk.connect).toHaveBeenCalledWith(
350
+ expect.objectContaining({ kind: 'stdio' }),
351
+ )
352
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
353
+ '[memory-bank-mcp] Server started via stdio transport',
354
+ )
355
+ })
356
+
357
+ // ── 15. bootstrap() fatal path ───────────────────────────────────────────
358
+
359
+ it('bootstrap: logs the fatal error and exits 1 when connect fails', async () => {
360
+ const err = new Error('transport exploded')
361
+ sdk.connect.mockRejectedValue(err)
362
+
363
+ await mod.bootstrap()
364
+
365
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
366
+ '[memory-bank-mcp] Fatal error:',
367
+ err,
368
+ )
369
+ expect(exitSpy).toHaveBeenCalledWith(1)
370
+ })
371
+
372
+ // ── 16. Direct-run guard ─────────────────────────────────────────────────
373
+
374
+ it('direct run: auto-bootstraps when argv[1] is this module', async () => {
375
+ const modulePath = fileURLToPath(
376
+ new URL('./mcp-memory-server.js', import.meta.url),
377
+ )
378
+ process.argv[1] = modulePath
379
+ sdk.connect.mockResolvedValue(undefined)
380
+ vi.resetModules()
381
+
382
+ await import('./mcp-memory-server.js')
383
+
384
+ await vi.waitFor(() => expect(sdk.connect).toHaveBeenCalledTimes(1))
385
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
386
+ '[memory-bank-mcp] Server started via stdio transport',
387
+ )
388
+ })
389
+
390
+ it('imported with an empty argv[1]: does not start the server', async () => {
391
+ process.argv[1] = ''
392
+ vi.resetModules()
393
+
394
+ await import('./mcp-memory-server.js')
395
+
396
+ expect(sdk.connect).not.toHaveBeenCalled()
397
+ })
398
+ })
@@ -0,0 +1,297 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // memory-cli.test.js — Unit tests for the manual memory-index CLI
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // The CLI is a top-level script: it reads process.argv, dispatches one command,
6
+ // and always closes the DB in `finally`. Tests mock ./memory-index.js, set
7
+ // argv, and re-import the module per scenario. `process.exit` is stubbed so
8
+ // the assertions can inspect the requested exit code without terminating.
9
+ //
10
+ // Covered scenarios:
11
+ // 1. search <query> → JSON results
12
+ // 2. search defaults → topK=5, no file filter
13
+ // 3. search --topK/--file → flags parsed and forwarded
14
+ // 4. search with non-numeric --topK → falls back to 5
15
+ // 5. search with no results → empty result + rebuild hint
16
+ // 6. search without a query → usage + exit 1
17
+ // 7. rebuild → status ok + result fields
18
+ // 8. update → status ok + result fields
19
+ // 9. get <fileId> → raw content
20
+ // 10. get without a fileId → usage + exit 1
21
+ // 11. get unknown file → error + exit 1
22
+ // 12. stats → JSON stats
23
+ // 13. unknown command → usage + exit 1
24
+ // 14. no command → usage + exit 1
25
+ // 15. engine failure → `Error: …` + exit 1
26
+ // 16. flags at end of argv → treated as positional arguments
27
+ // 17. closeDB → always called (finally)
28
+ // ═════════════════════════════════════════════════════════════════════════════
29
+
30
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
31
+
32
+ const index = vi.hoisted(() => ({
33
+ search: vi.fn(),
34
+ rebuildIndex: vi.fn(),
35
+ incrementalUpdate: vi.fn(),
36
+ getFile: vi.fn(),
37
+ getStats: vi.fn(),
38
+ closeDB: vi.fn(),
39
+ }))
40
+
41
+ vi.mock('./memory-index.js', () => index)
42
+
43
+ describe('memory-cli.js', () => {
44
+ let logSpy
45
+ let errorSpy
46
+ let exitSpy
47
+ let originalArgv
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks()
51
+ originalArgv = process.argv
52
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
53
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
54
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
55
+ })
56
+
57
+ afterEach(() => {
58
+ process.argv = originalArgv
59
+ logSpy.mockRestore()
60
+ errorSpy.mockRestore()
61
+ exitSpy.mockRestore()
62
+ })
63
+
64
+ /**
65
+ * Runs the CLI with the given arguments and waits for `closeDB` (the
66
+ * `finally` block), which marks command completion.
67
+ *
68
+ * @param {string[]} argv - Arguments after the script name.
69
+ * @returns {Promise<void>}
70
+ */
71
+ async function runCli(argv) {
72
+ process.argv = ['node', 'scripts/memory-cli.js', ...argv]
73
+ vi.resetModules()
74
+ await import('./memory-cli.js')
75
+ await vi.waitFor(() => expect(index.closeDB).toHaveBeenCalled())
76
+ }
77
+
78
+ /**
79
+ * Returns the parsed first console.log payload.
80
+ *
81
+ * @returns {object}
82
+ */
83
+ function loggedJson() {
84
+ return JSON.parse(logSpy.mock.calls[0][0])
85
+ }
86
+
87
+ // ── search ───────────────────────────────────────────────────────────────
88
+
89
+ it('search: prints the results as JSON', async () => {
90
+ const results = [
91
+ { source_file: 'activeContext', content: 'focus', score: 0.9 },
92
+ ]
93
+ index.search.mockResolvedValue(results)
94
+
95
+ await runCli(['search', 'focus'])
96
+
97
+ expect(index.search).toHaveBeenCalledWith('focus', {
98
+ topK: 5,
99
+ sourceFile: undefined,
100
+ })
101
+ expect(loggedJson()).toEqual({ results })
102
+ })
103
+
104
+ it('search: joins multiple positional words into one query', async () => {
105
+ index.search.mockResolvedValue([])
106
+
107
+ await runCli(['search', 'where', 'are', 'we'])
108
+
109
+ expect(index.search).toHaveBeenCalledWith('where are we', {
110
+ topK: 5,
111
+ sourceFile: undefined,
112
+ })
113
+ })
114
+
115
+ it('search: parses --topK and --file', async () => {
116
+ index.search.mockResolvedValue([])
117
+
118
+ await runCli([
119
+ 'search',
120
+ 'hello',
121
+ 'world',
122
+ '--topK',
123
+ '2',
124
+ '--file',
125
+ 'activeContext',
126
+ ])
127
+
128
+ expect(index.search).toHaveBeenCalledWith('hello world', {
129
+ topK: 2,
130
+ sourceFile: 'activeContext',
131
+ })
132
+ })
133
+
134
+ it('search: a non-numeric --topK falls back to 5', async () => {
135
+ index.search.mockResolvedValue([])
136
+
137
+ await runCli(['search', 'q', '--topK', 'not-a-number'])
138
+
139
+ expect(index.search).toHaveBeenCalledWith('q', {
140
+ topK: 5,
141
+ sourceFile: undefined,
142
+ })
143
+ })
144
+
145
+ it('search: prints an empty result with a rebuild hint', async () => {
146
+ index.search.mockResolvedValue([])
147
+
148
+ await runCli(['search', 'nothing'])
149
+
150
+ expect(loggedJson()).toEqual({
151
+ results: [],
152
+ hint: 'No results. Try running "rebuild" first.',
153
+ })
154
+ })
155
+
156
+ it('search: missing query prints usage and exits 1', async () => {
157
+ index.search.mockResolvedValue([])
158
+
159
+ await runCli(['search'])
160
+
161
+ expect(errorSpy).toHaveBeenCalledWith(
162
+ 'Usage: node scripts/memory-cli.js search "<query>" [--topK 5] [--file name]',
163
+ )
164
+ expect(exitSpy).toHaveBeenCalledWith(1)
165
+ })
166
+
167
+ it('search: flags at the end of argv become positional query words', async () => {
168
+ index.search.mockResolvedValue([])
169
+
170
+ await runCli(['search', 'q', '--topK'])
171
+
172
+ expect(index.search).toHaveBeenCalledWith('q --topK', {
173
+ topK: 5,
174
+ sourceFile: undefined,
175
+ })
176
+ })
177
+
178
+ it('search: a trailing --file without a value becomes positional', async () => {
179
+ index.search.mockResolvedValue([])
180
+
181
+ await runCli(['search', 'q', '--topK', '2', '--file'])
182
+
183
+ expect(index.search).toHaveBeenCalledWith('q --file', {
184
+ topK: 2,
185
+ sourceFile: undefined,
186
+ })
187
+ })
188
+
189
+ // ── rebuild / update ─────────────────────────────────────────────────────
190
+
191
+ it('rebuild: prints status ok followed by the engine result', async () => {
192
+ index.rebuildIndex.mockResolvedValue({ indexed: 12, skipped: 3 })
193
+
194
+ await runCli(['rebuild'])
195
+
196
+ expect(index.rebuildIndex).toHaveBeenCalledTimes(1)
197
+ expect(loggedJson()).toEqual({
198
+ status: 'ok',
199
+ indexed: 12,
200
+ skipped: 3,
201
+ })
202
+ })
203
+
204
+ it('update: prints status ok followed by the engine result', async () => {
205
+ index.incrementalUpdate.mockResolvedValue({ indexed: 4, skipped: 0 })
206
+
207
+ await runCli(['update'])
208
+
209
+ expect(index.incrementalUpdate).toHaveBeenCalledTimes(1)
210
+ expect(loggedJson()).toEqual({ status: 'ok', indexed: 4, skipped: 0 })
211
+ })
212
+
213
+ // ── get ──────────────────────────────────────────────────────────────────
214
+
215
+ it('get: prints the raw file content', async () => {
216
+ index.getFile.mockReturnValue('# Active Context\n\nWorking.')
217
+
218
+ await runCli(['get', 'activeContext'])
219
+
220
+ expect(index.getFile).toHaveBeenCalledWith('activeContext')
221
+ expect(logSpy).toHaveBeenCalledWith('# Active Context\n\nWorking.')
222
+ })
223
+
224
+ it('get: missing fileId prints usage and exits 1', async () => {
225
+ index.getFile.mockReturnValue(undefined)
226
+
227
+ await runCli(['get'])
228
+
229
+ expect(errorSpy).toHaveBeenCalledWith(
230
+ 'Usage: node scripts/memory-cli.js get <fileId>',
231
+ )
232
+ expect(exitSpy).toHaveBeenCalledWith(1)
233
+ })
234
+
235
+ it('get: unknown file prints an error and exits 1', async () => {
236
+ index.getFile.mockReturnValue(undefined)
237
+
238
+ await runCli(['get', 'missing'])
239
+
240
+ expect(index.getFile).toHaveBeenCalledWith('missing')
241
+ expect(errorSpy).toHaveBeenCalledWith('File not found: missing')
242
+ expect(exitSpy).toHaveBeenCalledWith(1)
243
+ })
244
+
245
+ // ── stats ────────────────────────────────────────────────────────────────
246
+
247
+ it('stats: prints the index statistics as JSON', async () => {
248
+ const stats = { totalVectors: 42, totalFiles: 7, dbSize: '256 kB' }
249
+ index.getStats.mockReturnValue(stats)
250
+
251
+ await runCli(['stats'])
252
+
253
+ expect(index.getStats).toHaveBeenCalledTimes(1)
254
+ expect(loggedJson()).toEqual(stats)
255
+ })
256
+
257
+ // ── unknown / missing command ────────────────────────────────────────────
258
+
259
+ it('unknown command: prints usage and exits 1', async () => {
260
+ await runCli(['bogus'])
261
+
262
+ expect(errorSpy).toHaveBeenCalledWith(
263
+ 'Usage: node scripts/memory-cli.js <search|rebuild|update|get|stats> [args...]',
264
+ )
265
+ expect(errorSpy).toHaveBeenCalledWith('Commands:')
266
+ expect(exitSpy).toHaveBeenCalledWith(1)
267
+ })
268
+
269
+ it('no command: prints usage and exits 1', async () => {
270
+ await runCli([])
271
+
272
+ expect(errorSpy).toHaveBeenCalledWith(
273
+ 'Usage: node scripts/memory-cli.js <search|rebuild|update|get|stats> [args...]',
274
+ )
275
+ expect(exitSpy).toHaveBeenCalledWith(1)
276
+ })
277
+
278
+ // ── Error handling ───────────────────────────────────────────────────────
279
+
280
+ it('engine failure: prints `Error: <message>` and exits 1', async () => {
281
+ index.search.mockRejectedValue(new Error('database is locked'))
282
+
283
+ await runCli(['search', 'anything'])
284
+
285
+ expect(errorSpy).toHaveBeenCalledWith('Error: database is locked')
286
+ expect(exitSpy).toHaveBeenCalledWith(1)
287
+ expect(index.closeDB).toHaveBeenCalledTimes(1)
288
+ })
289
+
290
+ it('always closes the DB, including on success', async () => {
291
+ index.search.mockResolvedValue([])
292
+
293
+ await runCli(['search', 'q'])
294
+
295
+ expect(index.closeDB).toHaveBeenCalledTimes(1)
296
+ })
297
+ })