@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.ts
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import { readFile, readdir, writeFile, access, mkdir, cp, stat } from 'node:fs/promises'
|
|
2
|
+
import { join, dirname, basename } from 'node:path'
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
4
|
+
import { Errors } from 'incur'
|
|
5
|
+
import { paths } from './paths.js'
|
|
6
|
+
import { normalizeSlug, requireBrainjarDir, readState, writeState, withStateLock } from './state.js'
|
|
7
|
+
import { readBrain, type BrainConfig } from './commands/brain.js'
|
|
8
|
+
import { sync } from './sync.js'
|
|
9
|
+
|
|
10
|
+
const { IncurError } = Errors
|
|
11
|
+
|
|
12
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/
|
|
13
|
+
|
|
14
|
+
export interface PackManifest {
|
|
15
|
+
name: string
|
|
16
|
+
version: string
|
|
17
|
+
description?: string
|
|
18
|
+
author?: string
|
|
19
|
+
brain: string
|
|
20
|
+
contents: {
|
|
21
|
+
soul: string
|
|
22
|
+
persona: string
|
|
23
|
+
rules: string[]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PackFile {
|
|
28
|
+
/** Relative path within the pack directory (e.g. "souls/craftsman.md") */
|
|
29
|
+
rel: string
|
|
30
|
+
/** Absolute source path in ~/.brainjar/ */
|
|
31
|
+
src: string
|
|
32
|
+
/** Whether this is a directory (rule pack) */
|
|
33
|
+
isDir: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExportOptions {
|
|
37
|
+
out?: string
|
|
38
|
+
name?: string
|
|
39
|
+
version?: string
|
|
40
|
+
author?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExportResult {
|
|
44
|
+
exported: string
|
|
45
|
+
path: string
|
|
46
|
+
brain: string
|
|
47
|
+
contents: { soul: string; persona: string; rules: string[] }
|
|
48
|
+
warnings: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ImportOptions {
|
|
52
|
+
force?: boolean
|
|
53
|
+
merge?: boolean
|
|
54
|
+
activate?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Conflict {
|
|
58
|
+
rel: string
|
|
59
|
+
target: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ImportResult {
|
|
63
|
+
imported: string
|
|
64
|
+
from: string
|
|
65
|
+
brain: string
|
|
66
|
+
written: string[]
|
|
67
|
+
skipped: string[]
|
|
68
|
+
overwritten: string[]
|
|
69
|
+
activated: boolean
|
|
70
|
+
warnings: string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Collect all files referenced by a brain config. */
|
|
74
|
+
async function collectFiles(brainName: string, config: BrainConfig): Promise<{ files: PackFile[]; warnings: string[] }> {
|
|
75
|
+
const files: PackFile[] = []
|
|
76
|
+
const warnings: string[] = []
|
|
77
|
+
|
|
78
|
+
// Brain YAML
|
|
79
|
+
files.push({
|
|
80
|
+
rel: `brains/${brainName}.yaml`,
|
|
81
|
+
src: join(paths.brains, `${brainName}.yaml`),
|
|
82
|
+
isDir: false,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Soul
|
|
86
|
+
const soulPath = join(paths.souls, `${config.soul}.md`)
|
|
87
|
+
try {
|
|
88
|
+
await access(soulPath)
|
|
89
|
+
files.push({ rel: `souls/${config.soul}.md`, src: soulPath, isDir: false })
|
|
90
|
+
} catch {
|
|
91
|
+
throw new IncurError({
|
|
92
|
+
code: 'PACK_MISSING_SOUL',
|
|
93
|
+
message: `Brain "${brainName}" references soul "${config.soul}" which does not exist.`,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Persona
|
|
98
|
+
const personaPath = join(paths.personas, `${config.persona}.md`)
|
|
99
|
+
try {
|
|
100
|
+
await access(personaPath)
|
|
101
|
+
files.push({ rel: `personas/${config.persona}.md`, src: personaPath, isDir: false })
|
|
102
|
+
} catch {
|
|
103
|
+
throw new IncurError({
|
|
104
|
+
code: 'PACK_MISSING_PERSONA',
|
|
105
|
+
message: `Brain "${brainName}" references persona "${config.persona}" which does not exist.`,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Rules — soft failure
|
|
110
|
+
for (const rule of config.rules) {
|
|
111
|
+
const dirPath = join(paths.rules, rule)
|
|
112
|
+
const filePath = join(paths.rules, `${rule}.md`)
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const s = await stat(dirPath)
|
|
116
|
+
if (s.isDirectory()) {
|
|
117
|
+
const entries = await readdir(dirPath)
|
|
118
|
+
const mdFiles = entries.filter(f => f.endsWith('.md'))
|
|
119
|
+
if (mdFiles.length === 0) {
|
|
120
|
+
warnings.push(`Rule "${rule}" directory exists but contains no .md files — skipped.`)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
files.push({ rel: `rules/${rule}`, src: dirPath, isDir: true })
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await access(filePath)
|
|
130
|
+
files.push({ rel: `rules/${rule}.md`, src: filePath, isDir: false })
|
|
131
|
+
continue
|
|
132
|
+
} catch {}
|
|
133
|
+
|
|
134
|
+
warnings.push(`Rule "${rule}" not found — skipped.`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { files, warnings }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Export a brain as a pack directory. */
|
|
141
|
+
export async function exportPack(brainName: string, options: ExportOptions = {}): Promise<ExportResult> {
|
|
142
|
+
await requireBrainjarDir()
|
|
143
|
+
|
|
144
|
+
const slug = normalizeSlug(brainName, 'brain name')
|
|
145
|
+
const config = await readBrain(slug)
|
|
146
|
+
const packName = options.name ? normalizeSlug(options.name, 'pack name') : slug
|
|
147
|
+
const version = options.version ?? '0.1.0'
|
|
148
|
+
|
|
149
|
+
if (!SEMVER_RE.test(version)) {
|
|
150
|
+
throw new IncurError({
|
|
151
|
+
code: 'PACK_INVALID_VERSION',
|
|
152
|
+
message: `Invalid version "${version}". Expected semver (e.g., 0.1.0).`,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { files, warnings } = await collectFiles(slug, config)
|
|
157
|
+
|
|
158
|
+
// Determine which rules actually made it into the pack
|
|
159
|
+
const exportedRules = files
|
|
160
|
+
.filter(f => f.rel.startsWith('rules/'))
|
|
161
|
+
.map(f => f.isDir ? basename(f.rel) : basename(f.rel, '.md'))
|
|
162
|
+
|
|
163
|
+
const parentDir = options.out ?? process.cwd()
|
|
164
|
+
const packDir = join(parentDir, packName)
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await access(packDir)
|
|
168
|
+
throw new IncurError({
|
|
169
|
+
code: 'PACK_DIR_EXISTS',
|
|
170
|
+
message: `Pack directory "${packDir}" already exists.`,
|
|
171
|
+
hint: 'Remove it first or use a different --out path.',
|
|
172
|
+
})
|
|
173
|
+
} catch (e) {
|
|
174
|
+
if (e instanceof IncurError) throw e
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Create pack directory structure
|
|
178
|
+
await mkdir(packDir, { recursive: true })
|
|
179
|
+
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
const dest = join(packDir, file.rel)
|
|
182
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
183
|
+
if (file.isDir) {
|
|
184
|
+
await cp(file.src, dest, { recursive: true })
|
|
185
|
+
} else {
|
|
186
|
+
await cp(file.src, dest)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Write manifest
|
|
191
|
+
const manifest: PackManifest = {
|
|
192
|
+
name: packName,
|
|
193
|
+
version,
|
|
194
|
+
...(options.author ? { author: options.author } : {}),
|
|
195
|
+
brain: slug,
|
|
196
|
+
contents: {
|
|
197
|
+
soul: config.soul,
|
|
198
|
+
persona: config.persona,
|
|
199
|
+
rules: exportedRules,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await writeFile(join(packDir, 'pack.yaml'), stringifyYaml(manifest))
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
exported: packName,
|
|
207
|
+
path: packDir,
|
|
208
|
+
brain: slug,
|
|
209
|
+
contents: manifest.contents,
|
|
210
|
+
warnings,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Read and validate a pack.yaml manifest. */
|
|
215
|
+
export async function readManifest(packDir: string): Promise<PackManifest> {
|
|
216
|
+
const manifestPath = join(packDir, 'pack.yaml')
|
|
217
|
+
|
|
218
|
+
let raw: string
|
|
219
|
+
try {
|
|
220
|
+
raw = await readFile(manifestPath, 'utf-8')
|
|
221
|
+
} catch {
|
|
222
|
+
throw new IncurError({
|
|
223
|
+
code: 'PACK_NO_MANIFEST',
|
|
224
|
+
message: `No pack.yaml found in "${packDir}". Is this a brainjar pack?`,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let parsed: unknown
|
|
229
|
+
try {
|
|
230
|
+
parsed = parseYaml(raw)
|
|
231
|
+
} catch (e) {
|
|
232
|
+
throw new IncurError({
|
|
233
|
+
code: 'PACK_CORRUPT_MANIFEST',
|
|
234
|
+
message: `pack.yaml is corrupt: ${(e as Error).message}`,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
239
|
+
throw new IncurError({
|
|
240
|
+
code: 'PACK_CORRUPT_MANIFEST',
|
|
241
|
+
message: 'pack.yaml is empty or invalid.',
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const p = parsed as Record<string, unknown>
|
|
246
|
+
|
|
247
|
+
for (const field of ['name', 'version', 'brain'] as const) {
|
|
248
|
+
if (typeof p[field] !== 'string' || !p[field]) {
|
|
249
|
+
throw new IncurError({
|
|
250
|
+
code: 'PACK_INVALID_MANIFEST',
|
|
251
|
+
message: `pack.yaml is missing required field "${field}".`,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!SEMVER_RE.test(p.version as string)) {
|
|
257
|
+
throw new IncurError({
|
|
258
|
+
code: 'PACK_INVALID_VERSION',
|
|
259
|
+
message: `Invalid version "${p.version}" in pack.yaml. Expected semver (e.g., 0.1.0).`,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const contents = p.contents as Record<string, unknown> | undefined
|
|
264
|
+
if (!contents || typeof contents !== 'object') {
|
|
265
|
+
throw new IncurError({
|
|
266
|
+
code: 'PACK_INVALID_MANIFEST',
|
|
267
|
+
message: 'pack.yaml is missing required field "contents".',
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof contents.soul !== 'string' || !contents.soul) {
|
|
272
|
+
throw new IncurError({
|
|
273
|
+
code: 'PACK_INVALID_MANIFEST',
|
|
274
|
+
message: 'pack.yaml is missing required field "contents.soul".',
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (typeof contents.persona !== 'string' || !contents.persona) {
|
|
279
|
+
throw new IncurError({
|
|
280
|
+
code: 'PACK_INVALID_MANIFEST',
|
|
281
|
+
message: 'pack.yaml is missing required field "contents.persona".',
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!Array.isArray(contents.rules)) {
|
|
286
|
+
throw new IncurError({
|
|
287
|
+
code: 'PACK_INVALID_MANIFEST',
|
|
288
|
+
message: 'pack.yaml is missing required field "contents.rules".',
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const rules = contents.rules.map(String)
|
|
293
|
+
|
|
294
|
+
// Validate all slugs to prevent path traversal from untrusted pack.yaml
|
|
295
|
+
normalizeSlug(p.name as string, 'pack name')
|
|
296
|
+
normalizeSlug(p.brain as string, 'brain name')
|
|
297
|
+
normalizeSlug(contents.soul as string, 'soul name')
|
|
298
|
+
normalizeSlug(contents.persona as string, 'persona name')
|
|
299
|
+
for (const rule of rules) {
|
|
300
|
+
normalizeSlug(rule, 'rule name')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
name: p.name as string,
|
|
305
|
+
version: p.version as string,
|
|
306
|
+
...(p.description ? { description: p.description as string } : {}),
|
|
307
|
+
...(p.author ? { author: p.author as string } : {}),
|
|
308
|
+
brain: p.brain as string,
|
|
309
|
+
contents: {
|
|
310
|
+
soul: contents.soul as string,
|
|
311
|
+
persona: contents.persona as string,
|
|
312
|
+
rules,
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Validate that all files declared in the manifest exist in the pack directory. */
|
|
318
|
+
async function validatePackFiles(packDir: string, manifest: PackManifest): Promise<void> {
|
|
319
|
+
// Brain YAML
|
|
320
|
+
const brainFile = join(packDir, 'brains', `${manifest.brain}.yaml`)
|
|
321
|
+
try {
|
|
322
|
+
await access(brainFile)
|
|
323
|
+
} catch {
|
|
324
|
+
throw new IncurError({
|
|
325
|
+
code: 'PACK_MISSING_FILE',
|
|
326
|
+
message: `Pack declares brain "${manifest.brain}" but brains/${manifest.brain}.yaml is missing.`,
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Soul
|
|
331
|
+
const soulFile = join(packDir, 'souls', `${manifest.contents.soul}.md`)
|
|
332
|
+
try {
|
|
333
|
+
await access(soulFile)
|
|
334
|
+
} catch {
|
|
335
|
+
throw new IncurError({
|
|
336
|
+
code: 'PACK_MISSING_FILE',
|
|
337
|
+
message: `Pack declares soul "${manifest.contents.soul}" but souls/${manifest.contents.soul}.md is missing.`,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Persona
|
|
342
|
+
const personaFile = join(packDir, 'personas', `${manifest.contents.persona}.md`)
|
|
343
|
+
try {
|
|
344
|
+
await access(personaFile)
|
|
345
|
+
} catch {
|
|
346
|
+
throw new IncurError({
|
|
347
|
+
code: 'PACK_MISSING_FILE',
|
|
348
|
+
message: `Pack declares persona "${manifest.contents.persona}" but personas/${manifest.contents.persona}.md is missing.`,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Rules
|
|
353
|
+
for (const rule of manifest.contents.rules) {
|
|
354
|
+
const dirPath = join(packDir, 'rules', rule)
|
|
355
|
+
const filePath = join(packDir, 'rules', `${rule}.md`)
|
|
356
|
+
|
|
357
|
+
let found = false
|
|
358
|
+
try {
|
|
359
|
+
const s = await stat(dirPath)
|
|
360
|
+
if (s.isDirectory()) found = true
|
|
361
|
+
} catch {}
|
|
362
|
+
|
|
363
|
+
if (!found) {
|
|
364
|
+
try {
|
|
365
|
+
await access(filePath)
|
|
366
|
+
found = true
|
|
367
|
+
} catch {}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!found) {
|
|
371
|
+
throw new IncurError({
|
|
372
|
+
code: 'PACK_MISSING_FILE',
|
|
373
|
+
message: `Pack declares rule "${rule}" but neither rules/${rule}/ nor rules/${rule}.md exists.`,
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Collect all importable files from a validated pack. Returns relative paths. */
|
|
380
|
+
async function collectImportFiles(packDir: string, manifest: PackManifest): Promise<PackFile[]> {
|
|
381
|
+
const files: PackFile[] = []
|
|
382
|
+
|
|
383
|
+
// Brain
|
|
384
|
+
files.push({
|
|
385
|
+
rel: `brains/${manifest.brain}.yaml`,
|
|
386
|
+
src: join(packDir, 'brains', `${manifest.brain}.yaml`),
|
|
387
|
+
isDir: false,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// Soul
|
|
391
|
+
files.push({
|
|
392
|
+
rel: `souls/${manifest.contents.soul}.md`,
|
|
393
|
+
src: join(packDir, 'souls', `${manifest.contents.soul}.md`),
|
|
394
|
+
isDir: false,
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
// Persona
|
|
398
|
+
files.push({
|
|
399
|
+
rel: `personas/${manifest.contents.persona}.md`,
|
|
400
|
+
src: join(packDir, 'personas', `${manifest.contents.persona}.md`),
|
|
401
|
+
isDir: false,
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
// Rules
|
|
405
|
+
for (const rule of manifest.contents.rules) {
|
|
406
|
+
const dirPath = join(packDir, 'rules', rule)
|
|
407
|
+
try {
|
|
408
|
+
const s = await stat(dirPath)
|
|
409
|
+
if (s.isDirectory()) {
|
|
410
|
+
files.push({ rel: `rules/${rule}`, src: dirPath, isDir: true })
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
} catch {}
|
|
414
|
+
|
|
415
|
+
files.push({ rel: `rules/${rule}.md`, src: join(packDir, 'rules', `${rule}.md`), isDir: false })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return files
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Compare file content to detect conflicts. For directories, compares each .md file. */
|
|
422
|
+
async function detectConflicts(files: PackFile[]): Promise<{ conflicts: Conflict[]; skippedRels: Set<string>; skippedLabels: string[] }> {
|
|
423
|
+
const conflicts: Conflict[] = []
|
|
424
|
+
const skippedRels = new Set<string>()
|
|
425
|
+
const skippedLabels: string[] = []
|
|
426
|
+
|
|
427
|
+
for (const file of files) {
|
|
428
|
+
const target = join(paths.root, file.rel)
|
|
429
|
+
|
|
430
|
+
if (file.isDir) {
|
|
431
|
+
const srcEntries = await readdir(file.src)
|
|
432
|
+
for (const entry of srcEntries.filter(f => f.endsWith('.md'))) {
|
|
433
|
+
const rel = `${file.rel}/${entry}`
|
|
434
|
+
const srcContent = await readFile(join(file.src, entry), 'utf-8')
|
|
435
|
+
const targetFile = join(target, entry)
|
|
436
|
+
try {
|
|
437
|
+
const targetContent = await readFile(targetFile, 'utf-8')
|
|
438
|
+
if (srcContent === targetContent) {
|
|
439
|
+
skippedRels.add(rel)
|
|
440
|
+
skippedLabels.push(`${rel} (identical)`)
|
|
441
|
+
} else {
|
|
442
|
+
conflicts.push({ rel, target: targetFile })
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// Doesn't exist — will be copied
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
try {
|
|
450
|
+
const srcContent = await readFile(file.src, 'utf-8')
|
|
451
|
+
const targetContent = await readFile(target, 'utf-8')
|
|
452
|
+
if (srcContent === targetContent) {
|
|
453
|
+
skippedRels.add(file.rel)
|
|
454
|
+
skippedLabels.push(`${file.rel} (identical)`)
|
|
455
|
+
} else {
|
|
456
|
+
conflicts.push({ rel: file.rel, target })
|
|
457
|
+
}
|
|
458
|
+
} catch {
|
|
459
|
+
// Doesn't exist — will be copied
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { conflicts, skippedRels, skippedLabels }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Generate a non-conflicting merge name. */
|
|
468
|
+
async function findMergeName(basePath: string, slug: string, packName: string, ext: string): Promise<string> {
|
|
469
|
+
let candidate = `${slug}-from-${packName}`
|
|
470
|
+
let suffix = 2
|
|
471
|
+
|
|
472
|
+
while (true) {
|
|
473
|
+
const candidatePath = join(basePath, `${candidate}${ext}`)
|
|
474
|
+
try {
|
|
475
|
+
await access(candidatePath)
|
|
476
|
+
candidate = `${slug}-from-${packName}-${suffix}`
|
|
477
|
+
suffix++
|
|
478
|
+
} catch {
|
|
479
|
+
return candidate
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Import a pack directory into ~/.brainjar/. */
|
|
485
|
+
export async function importPack(packDir: string, options: ImportOptions = {}): Promise<ImportResult> {
|
|
486
|
+
await requireBrainjarDir()
|
|
487
|
+
|
|
488
|
+
if (options.force && options.merge) {
|
|
489
|
+
throw new IncurError({
|
|
490
|
+
code: 'PACK_INVALID_OPTIONS',
|
|
491
|
+
message: '--force and --merge are mutually exclusive.',
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Validate path
|
|
496
|
+
try {
|
|
497
|
+
const s = await stat(packDir)
|
|
498
|
+
if (!s.isDirectory()) {
|
|
499
|
+
throw new IncurError({
|
|
500
|
+
code: 'PACK_NOT_DIR',
|
|
501
|
+
message: `Pack path "${packDir}" is a file, not a directory. Packs are directories.`,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
} catch (e) {
|
|
505
|
+
if (e instanceof IncurError) throw e
|
|
506
|
+
throw new IncurError({
|
|
507
|
+
code: 'PACK_NOT_FOUND',
|
|
508
|
+
message: `Pack path "${packDir}" does not exist.`,
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const manifest = await readManifest(packDir)
|
|
513
|
+
await validatePackFiles(packDir, manifest)
|
|
514
|
+
|
|
515
|
+
const files = await collectImportFiles(packDir, manifest)
|
|
516
|
+
const { conflicts, skippedRels, skippedLabels } = await detectConflicts(files)
|
|
517
|
+
|
|
518
|
+
const written: string[] = []
|
|
519
|
+
const overwritten: string[] = []
|
|
520
|
+
const warnings: string[] = []
|
|
521
|
+
|
|
522
|
+
if (conflicts.length > 0 && !options.force && !options.merge) {
|
|
523
|
+
const list = conflicts.map(c => ` ${c.rel}`).join('\n')
|
|
524
|
+
throw new IncurError({
|
|
525
|
+
code: 'PACK_CONFLICTS',
|
|
526
|
+
message: `Import blocked — ${conflicts.length} file(s) conflict with existing content:\n${list}`,
|
|
527
|
+
hint: 'Use --force to overwrite or --merge to keep both.',
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Build rename map for merge mode
|
|
532
|
+
const renameMap = new Map<string, string>()
|
|
533
|
+
const conflictRels = new Set(conflicts.map(c => c.rel))
|
|
534
|
+
|
|
535
|
+
if (options.merge && conflicts.length > 0) {
|
|
536
|
+
for (const conflict of conflicts) {
|
|
537
|
+
const parts = conflict.rel.split('/')
|
|
538
|
+
const fileName = parts[parts.length - 1]
|
|
539
|
+
const parentRel = parts.slice(0, -1).join('/')
|
|
540
|
+
const parentAbs = join(paths.root, parentRel)
|
|
541
|
+
const ext = fileName.endsWith('.yaml') ? '.yaml' : '.md'
|
|
542
|
+
const slug = basename(fileName, ext)
|
|
543
|
+
|
|
544
|
+
const newSlug = await findMergeName(parentAbs, slug, manifest.name, ext)
|
|
545
|
+
renameMap.set(conflict.rel, newSlug)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Track which files are handled by brain patching so we don't write them twice
|
|
550
|
+
const brainRel = `brains/${manifest.brain}.yaml`
|
|
551
|
+
const needsBrainPatch = options.merge && renameMap.size > 0
|
|
552
|
+
|
|
553
|
+
// Copy files
|
|
554
|
+
for (const file of files) {
|
|
555
|
+
const target = join(paths.root, file.rel)
|
|
556
|
+
await mkdir(dirname(target), { recursive: true })
|
|
557
|
+
|
|
558
|
+
if (file.isDir) {
|
|
559
|
+
const srcEntries = await readdir(file.src)
|
|
560
|
+
for (const entry of srcEntries.filter(f => f.endsWith('.md'))) {
|
|
561
|
+
const srcFile = join(file.src, entry)
|
|
562
|
+
const rel = `${file.rel}/${entry}`
|
|
563
|
+
const targetFile = join(target, entry)
|
|
564
|
+
|
|
565
|
+
if (skippedRels.has(rel)) continue
|
|
566
|
+
|
|
567
|
+
if (conflictRels.has(rel)) {
|
|
568
|
+
if (options.force) {
|
|
569
|
+
await cp(srcFile, targetFile)
|
|
570
|
+
overwritten.push(targetFile)
|
|
571
|
+
} else if (options.merge) {
|
|
572
|
+
const newSlug = renameMap.get(rel)!
|
|
573
|
+
const newTarget = join(target, `${newSlug}.md`)
|
|
574
|
+
await cp(srcFile, newTarget)
|
|
575
|
+
written.push(newTarget)
|
|
576
|
+
}
|
|
577
|
+
} else {
|
|
578
|
+
await mkdir(target, { recursive: true })
|
|
579
|
+
await cp(srcFile, targetFile)
|
|
580
|
+
written.push(targetFile)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
} else {
|
|
584
|
+
if (skippedRels.has(file.rel)) continue
|
|
585
|
+
|
|
586
|
+
// Skip brain file copy if we'll write a patched version later
|
|
587
|
+
if (needsBrainPatch && file.rel === brainRel && !conflictRels.has(brainRel)) continue
|
|
588
|
+
|
|
589
|
+
if (conflictRels.has(file.rel)) {
|
|
590
|
+
if (options.force) {
|
|
591
|
+
await cp(file.src, target)
|
|
592
|
+
overwritten.push(target)
|
|
593
|
+
} else if (options.merge) {
|
|
594
|
+
// Brain will be handled by patching below
|
|
595
|
+
if (file.rel === brainRel) continue
|
|
596
|
+
|
|
597
|
+
const newSlug = renameMap.get(file.rel)!
|
|
598
|
+
const ext = file.rel.endsWith('.yaml') ? '.yaml' : '.md'
|
|
599
|
+
const parentRel = dirname(file.rel)
|
|
600
|
+
const newTarget = join(paths.root, parentRel, `${newSlug}${ext}`)
|
|
601
|
+
await cp(file.src, newTarget)
|
|
602
|
+
written.push(newTarget)
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
await cp(file.src, target)
|
|
606
|
+
written.push(target)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// In merge mode, write a patched brain YAML with renamed references
|
|
612
|
+
if (needsBrainPatch) {
|
|
613
|
+
const brainRenamed = renameMap.get(brainRel)
|
|
614
|
+
|
|
615
|
+
const packBrainContent = await readFile(join(packDir, 'brains', `${manifest.brain}.yaml`), 'utf-8')
|
|
616
|
+
const brainConfig = parseYaml(packBrainContent) as Record<string, unknown>
|
|
617
|
+
|
|
618
|
+
// Patch soul reference
|
|
619
|
+
const soulRel = `souls/${manifest.contents.soul}.md`
|
|
620
|
+
if (renameMap.has(soulRel)) {
|
|
621
|
+
brainConfig.soul = renameMap.get(soulRel)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Patch persona reference
|
|
625
|
+
const personaRel = `personas/${manifest.contents.persona}.md`
|
|
626
|
+
if (renameMap.has(personaRel)) {
|
|
627
|
+
brainConfig.persona = renameMap.get(personaRel)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Patch rules (single-file rules only — directory rules keep their name)
|
|
631
|
+
if (Array.isArray(brainConfig.rules)) {
|
|
632
|
+
brainConfig.rules = (brainConfig.rules as string[]).map(rule => {
|
|
633
|
+
const fileRel = `rules/${rule}.md`
|
|
634
|
+
if (renameMap.has(fileRel)) return renameMap.get(fileRel)
|
|
635
|
+
return rule
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Write the patched brain (possibly renamed)
|
|
640
|
+
const brainSlug = brainRenamed ?? manifest.brain
|
|
641
|
+
const brainTarget = join(paths.brains, `${brainSlug}.yaml`)
|
|
642
|
+
await writeFile(brainTarget, stringifyYaml(brainConfig))
|
|
643
|
+
written.push(brainTarget)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Activate brain if requested
|
|
647
|
+
let activated = false
|
|
648
|
+
if (options.activate) {
|
|
649
|
+
const config = await readBrain(manifest.brain)
|
|
650
|
+
await withStateLock(async () => {
|
|
651
|
+
const state = await readState()
|
|
652
|
+
state.soul = config.soul
|
|
653
|
+
state.persona = config.persona
|
|
654
|
+
state.rules = config.rules
|
|
655
|
+
await writeState(state)
|
|
656
|
+
await sync()
|
|
657
|
+
})
|
|
658
|
+
activated = true
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
imported: manifest.name,
|
|
663
|
+
from: packDir,
|
|
664
|
+
brain: manifest.brain,
|
|
665
|
+
written,
|
|
666
|
+
skipped: skippedLabels,
|
|
667
|
+
overwritten,
|
|
668
|
+
activated,
|
|
669
|
+
warnings,
|
|
670
|
+
}
|
|
671
|
+
}
|