@brainjar/cli 0.1.0 → 0.2.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 +46 -2
- package/package.json +4 -1
- package/src/cli.ts +6 -0
- package/src/commands/hooks.ts +41 -0
- package/src/commands/pack.ts +49 -0
- package/src/commands/sync.ts +16 -0
- package/src/hooks.test.ts +132 -0
- package/src/hooks.ts +137 -0
- package/src/pack.test.ts +472 -0
- package/src/pack.ts +671 -0
package/src/pack.test.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { readFile, rm, mkdir, writeFile, readdir } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { stringify as stringifyYaml, parse as parseYaml } from 'yaml'
|
|
5
|
+
import { exportPack, importPack, readManifest } from './pack.js'
|
|
6
|
+
|
|
7
|
+
const TEST_HOME = join(import.meta.dir, '..', '.test-home-pack')
|
|
8
|
+
const BRAINJAR_DIR = join(TEST_HOME, '.brainjar')
|
|
9
|
+
const EXPORT_DIR = join(import.meta.dir, '..', '.test-export-pack')
|
|
10
|
+
|
|
11
|
+
async function setupBrainjar() {
|
|
12
|
+
await mkdir(join(BRAINJAR_DIR, 'brains'), { recursive: true })
|
|
13
|
+
await mkdir(join(BRAINJAR_DIR, 'souls'), { recursive: true })
|
|
14
|
+
await mkdir(join(BRAINJAR_DIR, 'personas'), { recursive: true })
|
|
15
|
+
await mkdir(join(BRAINJAR_DIR, 'rules', 'default'), { recursive: true })
|
|
16
|
+
await mkdir(join(BRAINJAR_DIR, 'rules'), { recursive: true })
|
|
17
|
+
|
|
18
|
+
// Brain
|
|
19
|
+
await writeFile(join(BRAINJAR_DIR, 'brains', 'review.yaml'), stringifyYaml({
|
|
20
|
+
soul: 'craftsman',
|
|
21
|
+
persona: 'reviewer',
|
|
22
|
+
rules: ['default', 'security'],
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
// Soul
|
|
26
|
+
await writeFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), '# Craftsman\n\nDirect and rigorous.')
|
|
27
|
+
|
|
28
|
+
// Persona
|
|
29
|
+
await writeFile(join(BRAINJAR_DIR, 'personas', 'reviewer.md'), '# Reviewer\n\nCode review specialist.')
|
|
30
|
+
|
|
31
|
+
// Rules
|
|
32
|
+
await writeFile(join(BRAINJAR_DIR, 'rules', 'default', 'boundaries.md'), '# Boundaries\n\nStay in scope.')
|
|
33
|
+
await writeFile(join(BRAINJAR_DIR, 'rules', 'default', 'task-completion.md'), '# Task Completion\n\nFinish what you start.')
|
|
34
|
+
await writeFile(join(BRAINJAR_DIR, 'rules', 'security.md'), '# Security\n\nNo secrets in code.')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
process.env.BRAINJAR_TEST_HOME = TEST_HOME
|
|
39
|
+
process.env.BRAINJAR_HOME = BRAINJAR_DIR
|
|
40
|
+
await setupBrainjar()
|
|
41
|
+
await mkdir(EXPORT_DIR, { recursive: true })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
delete process.env.BRAINJAR_TEST_HOME
|
|
46
|
+
delete process.env.BRAINJAR_HOME
|
|
47
|
+
await rm(TEST_HOME, { recursive: true, force: true })
|
|
48
|
+
await rm(EXPORT_DIR, { recursive: true, force: true })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('pack export', () => {
|
|
52
|
+
test('exports a brain with all references', async () => {
|
|
53
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
54
|
+
|
|
55
|
+
expect(result.exported).toBe('review')
|
|
56
|
+
expect(result.brain).toBe('review')
|
|
57
|
+
expect(result.contents.soul).toBe('craftsman')
|
|
58
|
+
expect(result.contents.persona).toBe('reviewer')
|
|
59
|
+
expect(result.contents.rules).toEqual(['default', 'security'])
|
|
60
|
+
expect(result.warnings).toHaveLength(0)
|
|
61
|
+
|
|
62
|
+
// Verify files exist
|
|
63
|
+
const packDir = join(EXPORT_DIR, 'review')
|
|
64
|
+
const manifest = await readFile(join(packDir, 'pack.yaml'), 'utf-8')
|
|
65
|
+
expect(manifest).toContain('name: review')
|
|
66
|
+
expect(manifest).toContain('version: 0.1.0')
|
|
67
|
+
|
|
68
|
+
const brainYaml = await readFile(join(packDir, 'brains', 'review.yaml'), 'utf-8')
|
|
69
|
+
expect(brainYaml).toContain('soul: craftsman')
|
|
70
|
+
|
|
71
|
+
const soul = await readFile(join(packDir, 'souls', 'craftsman.md'), 'utf-8')
|
|
72
|
+
expect(soul).toContain('# Craftsman')
|
|
73
|
+
|
|
74
|
+
const persona = await readFile(join(packDir, 'personas', 'reviewer.md'), 'utf-8')
|
|
75
|
+
expect(persona).toContain('# Reviewer')
|
|
76
|
+
|
|
77
|
+
// Directory rule
|
|
78
|
+
const ruleFiles = await readdir(join(packDir, 'rules', 'default'))
|
|
79
|
+
expect(ruleFiles.sort()).toEqual(['boundaries.md', 'task-completion.md'])
|
|
80
|
+
|
|
81
|
+
// Single-file rule
|
|
82
|
+
const securityRule = await readFile(join(packDir, 'rules', 'security.md'), 'utf-8')
|
|
83
|
+
expect(securityRule).toContain('# Security')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('overrides pack name and version', async () => {
|
|
87
|
+
const result = await exportPack('review', {
|
|
88
|
+
out: EXPORT_DIR,
|
|
89
|
+
name: 'my-review',
|
|
90
|
+
version: '1.0.0',
|
|
91
|
+
author: 'frank',
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(result.exported).toBe('my-review')
|
|
95
|
+
expect(result.path).toBe(join(EXPORT_DIR, 'my-review'))
|
|
96
|
+
|
|
97
|
+
const manifest = await readFile(join(EXPORT_DIR, 'my-review', 'pack.yaml'), 'utf-8')
|
|
98
|
+
expect(manifest).toContain('name: my-review')
|
|
99
|
+
expect(manifest).toContain('version: 1.0.0')
|
|
100
|
+
expect(manifest).toContain('author: frank')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('errors on nonexistent brain', async () => {
|
|
104
|
+
await expect(exportPack('nonexistent', { out: EXPORT_DIR })).rejects.toThrow('not found')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('errors on missing soul', async () => {
|
|
108
|
+
await writeFile(join(BRAINJAR_DIR, 'brains', 'bad.yaml'), stringifyYaml({
|
|
109
|
+
soul: 'missing-soul',
|
|
110
|
+
persona: 'reviewer',
|
|
111
|
+
rules: [],
|
|
112
|
+
}))
|
|
113
|
+
|
|
114
|
+
await expect(exportPack('bad', { out: EXPORT_DIR })).rejects.toThrow('soul "missing-soul" which does not exist')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('errors on missing persona', async () => {
|
|
118
|
+
await writeFile(join(BRAINJAR_DIR, 'brains', 'bad.yaml'), stringifyYaml({
|
|
119
|
+
soul: 'craftsman',
|
|
120
|
+
persona: 'missing-persona',
|
|
121
|
+
rules: [],
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
await expect(exportPack('bad', { out: EXPORT_DIR })).rejects.toThrow('persona "missing-persona" which does not exist')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('warns on missing rule and continues', async () => {
|
|
128
|
+
await writeFile(join(BRAINJAR_DIR, 'brains', 'partial.yaml'), stringifyYaml({
|
|
129
|
+
soul: 'craftsman',
|
|
130
|
+
persona: 'reviewer',
|
|
131
|
+
rules: ['security', 'nonexistent'],
|
|
132
|
+
}))
|
|
133
|
+
|
|
134
|
+
const result = await exportPack('partial', { out: EXPORT_DIR })
|
|
135
|
+
|
|
136
|
+
expect(result.contents.rules).toEqual(['security'])
|
|
137
|
+
expect(result.warnings).toHaveLength(1)
|
|
138
|
+
expect(result.warnings[0]).toContain('nonexistent')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('warns on empty rule directory', async () => {
|
|
142
|
+
await mkdir(join(BRAINJAR_DIR, 'rules', 'empty-rule'), { recursive: true })
|
|
143
|
+
await writeFile(join(BRAINJAR_DIR, 'brains', 'empty-rules.yaml'), stringifyYaml({
|
|
144
|
+
soul: 'craftsman',
|
|
145
|
+
persona: 'reviewer',
|
|
146
|
+
rules: ['empty-rule'],
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
const result = await exportPack('empty-rules', { out: EXPORT_DIR })
|
|
150
|
+
|
|
151
|
+
expect(result.contents.rules).toEqual([])
|
|
152
|
+
expect(result.warnings).toHaveLength(1)
|
|
153
|
+
expect(result.warnings[0]).toContain('no .md files')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('errors when output directory already exists', async () => {
|
|
157
|
+
await mkdir(join(EXPORT_DIR, 'review'), { recursive: true })
|
|
158
|
+
|
|
159
|
+
await expect(exportPack('review', { out: EXPORT_DIR })).rejects.toThrow('already exists')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('errors on invalid version', async () => {
|
|
163
|
+
await expect(exportPack('review', { out: EXPORT_DIR, version: 'bad' })).rejects.toThrow('Invalid version')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('handles brain with no rules', async () => {
|
|
167
|
+
await writeFile(join(BRAINJAR_DIR, 'brains', 'minimal.yaml'), stringifyYaml({
|
|
168
|
+
soul: 'craftsman',
|
|
169
|
+
persona: 'reviewer',
|
|
170
|
+
rules: [],
|
|
171
|
+
}))
|
|
172
|
+
|
|
173
|
+
const result = await exportPack('minimal', { out: EXPORT_DIR })
|
|
174
|
+
|
|
175
|
+
expect(result.contents.rules).toEqual([])
|
|
176
|
+
|
|
177
|
+
// No rules directory should exist
|
|
178
|
+
const packDir = join(EXPORT_DIR, 'minimal')
|
|
179
|
+
const entries = await readdir(packDir)
|
|
180
|
+
expect(entries).not.toContain('rules')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('readManifest', () => {
|
|
185
|
+
test('reads valid manifest', async () => {
|
|
186
|
+
const packDir = join(EXPORT_DIR, 'test-pack')
|
|
187
|
+
await mkdir(packDir, { recursive: true })
|
|
188
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml({
|
|
189
|
+
name: 'test',
|
|
190
|
+
version: '1.0.0',
|
|
191
|
+
brain: 'review',
|
|
192
|
+
contents: { soul: 'craftsman', persona: 'reviewer', rules: ['default'] },
|
|
193
|
+
}))
|
|
194
|
+
|
|
195
|
+
const manifest = await readManifest(packDir)
|
|
196
|
+
expect(manifest.name).toBe('test')
|
|
197
|
+
expect(manifest.version).toBe('1.0.0')
|
|
198
|
+
expect(manifest.brain).toBe('review')
|
|
199
|
+
expect(manifest.contents.rules).toEqual(['default'])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('errors when pack.yaml missing', async () => {
|
|
203
|
+
await expect(readManifest(EXPORT_DIR)).rejects.toThrow('No pack.yaml')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('errors on missing required field', async () => {
|
|
207
|
+
const packDir = join(EXPORT_DIR, 'bad-pack')
|
|
208
|
+
await mkdir(packDir, { recursive: true })
|
|
209
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml({
|
|
210
|
+
name: 'test',
|
|
211
|
+
// missing version
|
|
212
|
+
brain: 'review',
|
|
213
|
+
contents: { soul: 'a', persona: 'b', rules: [] },
|
|
214
|
+
}))
|
|
215
|
+
|
|
216
|
+
await expect(readManifest(packDir)).rejects.toThrow('missing required field "version"')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('errors on invalid version format', async () => {
|
|
220
|
+
const packDir = join(EXPORT_DIR, 'bad-version')
|
|
221
|
+
await mkdir(packDir, { recursive: true })
|
|
222
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml({
|
|
223
|
+
name: 'test',
|
|
224
|
+
version: 'not-semver',
|
|
225
|
+
brain: 'review',
|
|
226
|
+
contents: { soul: 'a', persona: 'b', rules: [] },
|
|
227
|
+
}))
|
|
228
|
+
|
|
229
|
+
await expect(readManifest(packDir)).rejects.toThrow('Invalid version')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('errors when contents.rules is missing', async () => {
|
|
233
|
+
const packDir = join(EXPORT_DIR, 'no-rules-field')
|
|
234
|
+
await mkdir(packDir, { recursive: true })
|
|
235
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml({
|
|
236
|
+
name: 'test',
|
|
237
|
+
version: '1.0.0',
|
|
238
|
+
brain: 'review',
|
|
239
|
+
contents: { soul: 'craftsman', persona: 'reviewer' },
|
|
240
|
+
}))
|
|
241
|
+
|
|
242
|
+
await expect(readManifest(packDir)).rejects.toThrow('missing required field "contents.rules"')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('rejects path traversal in soul name', async () => {
|
|
246
|
+
const packDir = join(EXPORT_DIR, 'malicious')
|
|
247
|
+
await mkdir(packDir, { recursive: true })
|
|
248
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml({
|
|
249
|
+
name: 'legit',
|
|
250
|
+
version: '1.0.0',
|
|
251
|
+
brain: 'review',
|
|
252
|
+
contents: { soul: '../../.ssh/authorized_keys', persona: 'reviewer', rules: [] },
|
|
253
|
+
}))
|
|
254
|
+
|
|
255
|
+
await expect(readManifest(packDir)).rejects.toThrow('Invalid soul name')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('rejects path traversal in brain name', async () => {
|
|
259
|
+
const packDir = join(EXPORT_DIR, 'malicious')
|
|
260
|
+
await mkdir(packDir, { recursive: true })
|
|
261
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml({
|
|
262
|
+
name: 'legit',
|
|
263
|
+
version: '1.0.0',
|
|
264
|
+
brain: '../../../etc/passwd',
|
|
265
|
+
contents: { soul: 'craftsman', persona: 'reviewer', rules: [] },
|
|
266
|
+
}))
|
|
267
|
+
|
|
268
|
+
await expect(readManifest(packDir)).rejects.toThrow('Invalid brain name')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('rejects path traversal in rule name', async () => {
|
|
272
|
+
const packDir = join(EXPORT_DIR, 'malicious')
|
|
273
|
+
await mkdir(packDir, { recursive: true })
|
|
274
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml({
|
|
275
|
+
name: 'legit',
|
|
276
|
+
version: '1.0.0',
|
|
277
|
+
brain: 'review',
|
|
278
|
+
contents: { soul: 'craftsman', persona: 'reviewer', rules: ['../../etc/shadow'] },
|
|
279
|
+
}))
|
|
280
|
+
|
|
281
|
+
await expect(readManifest(packDir)).rejects.toThrow('Invalid rule name')
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe('pack import', () => {
|
|
286
|
+
async function exportAndClearTarget() {
|
|
287
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
288
|
+
// Clear target brainjar dir to simulate a fresh import
|
|
289
|
+
await rm(join(BRAINJAR_DIR, 'brains', 'review.yaml'))
|
|
290
|
+
await rm(join(BRAINJAR_DIR, 'souls', 'craftsman.md'))
|
|
291
|
+
await rm(join(BRAINJAR_DIR, 'personas', 'reviewer.md'))
|
|
292
|
+
await rm(join(BRAINJAR_DIR, 'rules', 'security.md'))
|
|
293
|
+
await rm(join(BRAINJAR_DIR, 'rules', 'default'), { recursive: true })
|
|
294
|
+
return result.path
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
test('imports a pack into ~/.brainjar/', async () => {
|
|
298
|
+
const packDir = await exportAndClearTarget()
|
|
299
|
+
|
|
300
|
+
const result = await importPack(packDir)
|
|
301
|
+
|
|
302
|
+
expect(result.imported).toBe('review')
|
|
303
|
+
expect(result.brain).toBe('review')
|
|
304
|
+
expect(result.written.length).toBeGreaterThan(0)
|
|
305
|
+
expect(result.skipped).toHaveLength(0)
|
|
306
|
+
expect(result.overwritten).toHaveLength(0)
|
|
307
|
+
|
|
308
|
+
// Verify files exist in brainjar dir
|
|
309
|
+
const soul = await readFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), 'utf-8')
|
|
310
|
+
expect(soul).toContain('# Craftsman')
|
|
311
|
+
|
|
312
|
+
const brainYaml = await readFile(join(BRAINJAR_DIR, 'brains', 'review.yaml'), 'utf-8')
|
|
313
|
+
expect(brainYaml).toContain('soul: craftsman')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('skips identical files', async () => {
|
|
317
|
+
// Export but don't clear — all files already exist with same content
|
|
318
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
319
|
+
|
|
320
|
+
const importResult = await importPack(result.path)
|
|
321
|
+
|
|
322
|
+
expect(importResult.written).toHaveLength(0)
|
|
323
|
+
expect(importResult.skipped.length).toBeGreaterThan(0)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('fails on conflict by default', async () => {
|
|
327
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
328
|
+
|
|
329
|
+
// Modify a file to create a conflict
|
|
330
|
+
await writeFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), '# Modified Craftsman')
|
|
331
|
+
|
|
332
|
+
await expect(importPack(result.path)).rejects.toThrow('conflict')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('--force overwrites conflicts', async () => {
|
|
336
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
337
|
+
|
|
338
|
+
await writeFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), '# Modified Craftsman')
|
|
339
|
+
|
|
340
|
+
const importResult = await importPack(result.path, { force: true })
|
|
341
|
+
|
|
342
|
+
expect(importResult.overwritten.length).toBeGreaterThan(0)
|
|
343
|
+
|
|
344
|
+
const soul = await readFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), 'utf-8')
|
|
345
|
+
expect(soul).toContain('Direct and rigorous')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('--merge renames conflicting files', async () => {
|
|
349
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
350
|
+
|
|
351
|
+
await writeFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), '# Modified Craftsman')
|
|
352
|
+
|
|
353
|
+
const importResult = await importPack(result.path, { merge: true })
|
|
354
|
+
|
|
355
|
+
expect(importResult.written.length).toBeGreaterThan(0)
|
|
356
|
+
|
|
357
|
+
// Original still has modified content
|
|
358
|
+
const original = await readFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), 'utf-8')
|
|
359
|
+
expect(original).toContain('Modified')
|
|
360
|
+
|
|
361
|
+
// Renamed file has pack content
|
|
362
|
+
const renamed = await readFile(join(BRAINJAR_DIR, 'souls', 'craftsman-from-review.md'), 'utf-8')
|
|
363
|
+
expect(renamed).toContain('Direct and rigorous')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('errors when --force and --merge both set', async () => {
|
|
367
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
368
|
+
|
|
369
|
+
await expect(importPack(result.path, { force: true, merge: true })).rejects.toThrow('mutually exclusive')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('errors on nonexistent path', async () => {
|
|
373
|
+
await expect(importPack('/nonexistent/path')).rejects.toThrow('does not exist')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('errors when path is a file', async () => {
|
|
377
|
+
const filePath = join(EXPORT_DIR, 'not-a-dir')
|
|
378
|
+
await writeFile(filePath, 'hello')
|
|
379
|
+
|
|
380
|
+
await expect(importPack(filePath)).rejects.toThrow('not a directory')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('errors when pack.yaml declares missing file', async () => {
|
|
384
|
+
const packDir = join(EXPORT_DIR, 'incomplete')
|
|
385
|
+
await mkdir(join(packDir, 'brains'), { recursive: true })
|
|
386
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml({
|
|
387
|
+
name: 'incomplete',
|
|
388
|
+
version: '0.1.0',
|
|
389
|
+
brain: 'test',
|
|
390
|
+
contents: { soul: 'missing', persona: 'also-missing', rules: [] },
|
|
391
|
+
}))
|
|
392
|
+
await writeFile(join(packDir, 'brains', 'test.yaml'), stringifyYaml({
|
|
393
|
+
soul: 'missing',
|
|
394
|
+
persona: 'also-missing',
|
|
395
|
+
rules: [],
|
|
396
|
+
}))
|
|
397
|
+
|
|
398
|
+
await expect(importPack(packDir)).rejects.toThrow('souls/missing.md is missing')
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('--merge patches brain YAML with renamed references', async () => {
|
|
402
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
403
|
+
|
|
404
|
+
// Create conflicts on soul and persona
|
|
405
|
+
await writeFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), '# Different Craftsman')
|
|
406
|
+
await writeFile(join(BRAINJAR_DIR, 'personas', 'reviewer.md'), '# Different Reviewer')
|
|
407
|
+
|
|
408
|
+
const importResult = await importPack(result.path, { merge: true })
|
|
409
|
+
|
|
410
|
+
// The brain YAML should reference the renamed files
|
|
411
|
+
const brainContent = await readFile(join(BRAINJAR_DIR, 'brains', 'review.yaml'), 'utf-8')
|
|
412
|
+
const brainConfig = parseYaml(brainContent) as Record<string, unknown>
|
|
413
|
+
expect(brainConfig.soul).toBe('craftsman-from-review')
|
|
414
|
+
expect(brainConfig.persona).toBe('reviewer-from-review')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
test('--merge escalates suffix on repeated conflicts', async () => {
|
|
418
|
+
const result = await exportPack('review', { out: EXPORT_DIR })
|
|
419
|
+
|
|
420
|
+
// Create the original conflict
|
|
421
|
+
await writeFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), '# Different')
|
|
422
|
+
// Pre-create the first merge name
|
|
423
|
+
await writeFile(join(BRAINJAR_DIR, 'souls', 'craftsman-from-review.md'), '# Also taken')
|
|
424
|
+
|
|
425
|
+
await importPack(result.path, { merge: true })
|
|
426
|
+
|
|
427
|
+
// Should escalate to -2
|
|
428
|
+
const renamed = await readFile(join(BRAINJAR_DIR, 'souls', 'craftsman-from-review-2.md'), 'utf-8')
|
|
429
|
+
expect(renamed).toContain('Direct and rigorous')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('--activate sets active state after import', async () => {
|
|
433
|
+
const packDir = await exportAndClearTarget()
|
|
434
|
+
|
|
435
|
+
// Write state file so readState works
|
|
436
|
+
await mkdir(join(BRAINJAR_DIR), { recursive: true })
|
|
437
|
+
await writeFile(join(BRAINJAR_DIR, 'state.yaml'), stringifyYaml({
|
|
438
|
+
soul: null,
|
|
439
|
+
persona: null,
|
|
440
|
+
rules: [],
|
|
441
|
+
}))
|
|
442
|
+
|
|
443
|
+
const importResult = await importPack(packDir, { activate: true })
|
|
444
|
+
|
|
445
|
+
expect(importResult.activated).toBe(true)
|
|
446
|
+
|
|
447
|
+
// Check state was updated
|
|
448
|
+
const stateContent = await readFile(join(BRAINJAR_DIR, 'state.yaml'), 'utf-8')
|
|
449
|
+
const state = parseYaml(stateContent) as Record<string, unknown>
|
|
450
|
+
expect(state.soul).toBe('craftsman')
|
|
451
|
+
expect(state.persona).toBe('reviewer')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('roundtrip: export then import to clean dir', async () => {
|
|
455
|
+
const packDir = await exportAndClearTarget()
|
|
456
|
+
|
|
457
|
+
await importPack(packDir)
|
|
458
|
+
|
|
459
|
+
// Verify all content matches originals by re-exporting and comparing
|
|
460
|
+
const soul = await readFile(join(BRAINJAR_DIR, 'souls', 'craftsman.md'), 'utf-8')
|
|
461
|
+
expect(soul).toBe('# Craftsman\n\nDirect and rigorous.')
|
|
462
|
+
|
|
463
|
+
const persona = await readFile(join(BRAINJAR_DIR, 'personas', 'reviewer.md'), 'utf-8')
|
|
464
|
+
expect(persona).toBe('# Reviewer\n\nCode review specialist.')
|
|
465
|
+
|
|
466
|
+
const security = await readFile(join(BRAINJAR_DIR, 'rules', 'security.md'), 'utf-8')
|
|
467
|
+
expect(security).toBe('# Security\n\nNo secrets in code.')
|
|
468
|
+
|
|
469
|
+
const boundaries = await readFile(join(BRAINJAR_DIR, 'rules', 'default', 'boundaries.md'), 'utf-8')
|
|
470
|
+
expect(boundaries).toBe('# Boundaries\n\nStay in scope.')
|
|
471
|
+
})
|
|
472
|
+
})
|