@brainjar/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pack.ts CHANGED
@@ -1,11 +1,15 @@
1
- import { readFile, readdir, writeFile, access, mkdir, cp, stat } from 'node:fs/promises'
2
- import { join, dirname, basename } from 'node:path'
1
+ import { readFile, readdir, writeFile, access, mkdir, stat } from 'node:fs/promises'
2
+ import { join, dirname } from 'node:path'
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
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 './brain.js'
5
+ import { ErrorCode, createError } from './errors.js'
6
+ import { normalizeSlug, putState } from './state.js'
8
7
  import { sync } from './sync.js'
8
+ import { getApi } from './client.js'
9
+ import type {
10
+ ApiBrain, ApiSoul, ApiPersona, ApiRule,
11
+ ContentBundle, BundleRule, ApiImportResult,
12
+ } from './api-types.js'
9
13
 
10
14
  const { IncurError } = Errors
11
15
 
@@ -24,15 +28,6 @@ export interface PackManifest {
24
28
  }
25
29
  }
26
30
 
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
31
  export interface ExportOptions {
37
32
  out?: string
38
33
  name?: string
@@ -49,157 +44,108 @@ export interface ExportResult {
49
44
  }
50
45
 
51
46
  export interface ImportOptions {
52
- force?: boolean
53
- merge?: boolean
54
47
  activate?: boolean
55
48
  }
56
49
 
57
- interface Conflict {
58
- rel: string
59
- target: string
60
- }
61
-
62
50
  export interface ImportResult {
63
51
  imported: string
64
52
  from: string
65
53
  brain: string
66
- written: string[]
67
- skipped: string[]
68
- overwritten: string[]
54
+ counts: { souls: number; personas: number; rules: number; brains: number }
69
55
  activated: boolean
70
56
  warnings: string[]
71
57
  }
72
58
 
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. */
59
+ /** Export a brain as a pack directory. Content fetched from server. */
141
60
  export async function exportPack(brainName: string, options: ExportOptions = {}): Promise<ExportResult> {
142
- await requireBrainjarDir()
143
-
144
61
  const slug = normalizeSlug(brainName, 'brain name')
145
- const config = await readBrain(slug)
62
+ const api = await getApi()
63
+ const brain = await api.get<ApiBrain>(`/api/v1/brains/${slug}`)
146
64
  const packName = options.name ? normalizeSlug(options.name, 'pack name') : slug
147
65
  const version = options.version ?? '0.1.0'
148
66
 
149
67
  if (!SEMVER_RE.test(version)) {
150
- throw new IncurError({
151
- code: 'PACK_INVALID_VERSION',
68
+ throw createError(ErrorCode.PACK_INVALID_VERSION, {
152
69
  message: `Invalid version "${version}". Expected semver (e.g., 0.1.0).`,
153
70
  })
154
71
  }
155
72
 
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
73
  const parentDir = options.out ?? process.cwd()
164
74
  const packDir = join(parentDir, packName)
165
75
 
166
76
  try {
167
77
  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
- })
78
+ throw createError(ErrorCode.PACK_DIR_EXISTS, { params: [packDir] })
173
79
  } catch (e) {
174
80
  if (e instanceof IncurError) throw e
175
81
  }
176
82
 
177
- // Create pack directory structure
83
+ const warnings: string[] = []
84
+
85
+ // Fetch soul
86
+ const soul = await api.get<ApiSoul>(`/api/v1/souls/${brain.soul_slug}`)
87
+
88
+ // Fetch persona
89
+ const persona = await api.get<ApiPersona>(`/api/v1/personas/${brain.persona_slug}`)
90
+
91
+ // Fetch rules (soft failure)
92
+ const fetchedRules: Array<{ slug: string; rule: ApiRule }> = []
93
+ for (const ruleSlug of brain.rule_slugs) {
94
+ try {
95
+ const rule = await api.get<ApiRule>(`/api/v1/rules/${ruleSlug}`)
96
+ fetchedRules.push({ slug: ruleSlug, rule })
97
+ } catch {
98
+ warnings.push(`Rule "${ruleSlug}" not found — skipped.`)
99
+ }
100
+ }
101
+
102
+ // Write pack directory
178
103
  await mkdir(packDir, { recursive: true })
179
104
 
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 })
105
+ // Brain YAML
106
+ await mkdir(join(packDir, 'brains'), { recursive: true })
107
+ await writeFile(join(packDir, 'brains', `${slug}.yaml`), stringifyYaml({
108
+ soul: brain.soul_slug,
109
+ persona: brain.persona_slug,
110
+ rules: brain.rule_slugs,
111
+ }))
112
+
113
+ // Soul
114
+ await mkdir(join(packDir, 'souls'), { recursive: true })
115
+ await writeFile(join(packDir, 'souls', `${brain.soul_slug}.md`), soul.content)
116
+
117
+ // Persona
118
+ await mkdir(join(packDir, 'personas'), { recursive: true })
119
+ await writeFile(join(packDir, 'personas', `${brain.persona_slug}.md`), persona.content)
120
+
121
+ // Rules
122
+ for (const { slug: ruleSlug, rule } of fetchedRules) {
123
+ if (rule.entries.length === 1) {
124
+ await mkdir(join(packDir, 'rules'), { recursive: true })
125
+ await writeFile(join(packDir, 'rules', `${ruleSlug}.md`), rule.entries[0].content)
185
126
  } else {
186
- await cp(file.src, dest)
127
+ const ruleDir = join(packDir, 'rules', ruleSlug)
128
+ await mkdir(ruleDir, { recursive: true })
129
+ for (const entry of rule.entries) {
130
+ await writeFile(join(ruleDir, entry.name), entry.content)
131
+ }
187
132
  }
188
133
  }
189
134
 
190
- // Write manifest
135
+ const exportedRules = fetchedRules.map(r => r.slug)
136
+
137
+ // Manifest
191
138
  const manifest: PackManifest = {
192
139
  name: packName,
193
140
  version,
194
141
  ...(options.author ? { author: options.author } : {}),
195
142
  brain: slug,
196
143
  contents: {
197
- soul: config.soul,
198
- persona: config.persona,
144
+ soul: brain.soul_slug,
145
+ persona: brain.persona_slug,
199
146
  rules: exportedRules,
200
147
  },
201
148
  }
202
-
203
149
  await writeFile(join(packDir, 'pack.yaml'), stringifyYaml(manifest))
204
150
 
205
151
  return {
@@ -219,25 +165,20 @@ export async function readManifest(packDir: string): Promise<PackManifest> {
219
165
  try {
220
166
  raw = await readFile(manifestPath, 'utf-8')
221
167
  } catch {
222
- throw new IncurError({
223
- code: 'PACK_NO_MANIFEST',
224
- message: `No pack.yaml found in "${packDir}". Is this a brainjar pack?`,
225
- })
168
+ throw createError(ErrorCode.PACK_NO_MANIFEST, { params: [packDir] })
226
169
  }
227
170
 
228
171
  let parsed: unknown
229
172
  try {
230
173
  parsed = parseYaml(raw)
231
174
  } catch (e) {
232
- throw new IncurError({
233
- code: 'PACK_CORRUPT_MANIFEST',
175
+ throw createError(ErrorCode.PACK_CORRUPT_MANIFEST, {
234
176
  message: `pack.yaml is corrupt: ${(e as Error).message}`,
235
177
  })
236
178
  }
237
179
 
238
180
  if (!parsed || typeof parsed !== 'object') {
239
- throw new IncurError({
240
- code: 'PACK_CORRUPT_MANIFEST',
181
+ throw createError(ErrorCode.PACK_CORRUPT_MANIFEST, {
241
182
  message: 'pack.yaml is empty or invalid.',
242
183
  })
243
184
  }
@@ -246,45 +187,39 @@ export async function readManifest(packDir: string): Promise<PackManifest> {
246
187
 
247
188
  for (const field of ['name', 'version', 'brain'] as const) {
248
189
  if (typeof p[field] !== 'string' || !p[field]) {
249
- throw new IncurError({
250
- code: 'PACK_INVALID_MANIFEST',
190
+ throw createError(ErrorCode.PACK_INVALID_MANIFEST, {
251
191
  message: `pack.yaml is missing required field "${field}".`,
252
192
  })
253
193
  }
254
194
  }
255
195
 
256
196
  if (!SEMVER_RE.test(p.version as string)) {
257
- throw new IncurError({
258
- code: 'PACK_INVALID_VERSION',
197
+ throw createError(ErrorCode.PACK_INVALID_VERSION, {
259
198
  message: `Invalid version "${p.version}" in pack.yaml. Expected semver (e.g., 0.1.0).`,
260
199
  })
261
200
  }
262
201
 
263
202
  const contents = p.contents as Record<string, unknown> | undefined
264
203
  if (!contents || typeof contents !== 'object') {
265
- throw new IncurError({
266
- code: 'PACK_INVALID_MANIFEST',
204
+ throw createError(ErrorCode.PACK_INVALID_MANIFEST, {
267
205
  message: 'pack.yaml is missing required field "contents".',
268
206
  })
269
207
  }
270
208
 
271
209
  if (typeof contents.soul !== 'string' || !contents.soul) {
272
- throw new IncurError({
273
- code: 'PACK_INVALID_MANIFEST',
210
+ throw createError(ErrorCode.PACK_INVALID_MANIFEST, {
274
211
  message: 'pack.yaml is missing required field "contents.soul".',
275
212
  })
276
213
  }
277
214
 
278
215
  if (typeof contents.persona !== 'string' || !contents.persona) {
279
- throw new IncurError({
280
- code: 'PACK_INVALID_MANIFEST',
216
+ throw createError(ErrorCode.PACK_INVALID_MANIFEST, {
281
217
  message: 'pack.yaml is missing required field "contents.persona".',
282
218
  })
283
219
  }
284
220
 
285
221
  if (!Array.isArray(contents.rules)) {
286
- throw new IncurError({
287
- code: 'PACK_INVALID_MANIFEST',
222
+ throw createError(ErrorCode.PACK_INVALID_MANIFEST, {
288
223
  message: 'pack.yaml is missing required field "contents.rules".',
289
224
  })
290
225
  }
@@ -314,347 +249,130 @@ export async function readManifest(packDir: string): Promise<PackManifest> {
314
249
  }
315
250
  }
316
251
 
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
- })
252
+ /** Read pack directory and build a ContentBundle for server import. */
253
+ async function buildBundle(packDir: string, manifest: PackManifest): Promise<ContentBundle> {
254
+ const bundle: ContentBundle = {
255
+ souls: {},
256
+ personas: {},
257
+ rules: {},
258
+ brains: {},
328
259
  }
329
260
 
330
261
  // Soul
331
- const soulFile = join(packDir, 'souls', `${manifest.contents.soul}.md`)
262
+ const soulPath = join(packDir, 'souls', `${manifest.contents.soul}.md`)
332
263
  try {
333
- await access(soulFile)
264
+ bundle.souls![manifest.contents.soul] = {
265
+ content: await readFile(soulPath, 'utf-8'),
266
+ }
334
267
  } catch {
335
- throw new IncurError({
336
- code: 'PACK_MISSING_FILE',
268
+ throw createError(ErrorCode.PACK_MISSING_FILE, {
337
269
  message: `Pack declares soul "${manifest.contents.soul}" but souls/${manifest.contents.soul}.md is missing.`,
338
270
  })
339
271
  }
340
272
 
341
273
  // Persona
342
- const personaFile = join(packDir, 'personas', `${manifest.contents.persona}.md`)
274
+ const personaPath = join(packDir, 'personas', `${manifest.contents.persona}.md`)
343
275
  try {
344
- await access(personaFile)
276
+ const content = await readFile(personaPath, 'utf-8')
277
+ bundle.personas![manifest.contents.persona] = {
278
+ content,
279
+ bundled_rules: manifest.contents.rules,
280
+ }
345
281
  } catch {
346
- throw new IncurError({
347
- code: 'PACK_MISSING_FILE',
282
+ throw createError(ErrorCode.PACK_MISSING_FILE, {
348
283
  message: `Pack declares persona "${manifest.contents.persona}" but personas/${manifest.contents.persona}.md is missing.`,
349
284
  })
350
285
  }
351
286
 
352
287
  // Rules
353
- for (const rule of manifest.contents.rules) {
354
- const dirPath = join(packDir, 'rules', rule)
355
- const filePath = join(packDir, 'rules', `${rule}.md`)
288
+ for (const ruleSlug of manifest.contents.rules) {
289
+ const dirPath = join(packDir, 'rules', ruleSlug)
290
+ const filePath = join(packDir, 'rules', `${ruleSlug}.md`)
356
291
 
357
292
  let found = false
293
+
294
+ // Try directory (multi-entry rule)
358
295
  try {
359
296
  const s = await stat(dirPath)
360
- if (s.isDirectory()) found = true
297
+ if (s.isDirectory()) {
298
+ const entries = await readdir(dirPath)
299
+ const mdFiles = entries.filter(f => f.endsWith('.md')).sort()
300
+ const ruleEntries = await Promise.all(
301
+ mdFiles.map(async (file, i) => ({
302
+ sort_key: i,
303
+ content: await readFile(join(dirPath, file), 'utf-8'),
304
+ }))
305
+ )
306
+ bundle.rules![ruleSlug] = { entries: ruleEntries }
307
+ found = true
308
+ }
361
309
  } catch {}
362
310
 
311
+ // Try single file
363
312
  if (!found) {
364
313
  try {
365
- await access(filePath)
314
+ const content = await readFile(filePath, 'utf-8')
315
+ bundle.rules![ruleSlug] = { entries: [{ sort_key: 0, content }] }
366
316
  found = true
367
317
  } catch {}
368
318
  }
369
319
 
370
320
  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.`,
321
+ throw createError(ErrorCode.PACK_MISSING_FILE, {
322
+ message: `Pack declares rule "${ruleSlug}" but neither rules/${ruleSlug}/ nor rules/${ruleSlug}.md exists.`,
374
323
  })
375
324
  }
376
325
  }
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
326
 
383
327
  // 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
- }
328
+ const brainPath = join(packDir, 'brains', `${manifest.brain}.yaml`)
329
+ try {
330
+ const raw = await readFile(brainPath, 'utf-8')
331
+ const parsed = parseYaml(raw) as Record<string, unknown>
332
+ bundle.brains![manifest.brain] = {
333
+ soul_slug: String(parsed.soul ?? ''),
334
+ persona_slug: String(parsed.persona ?? ''),
335
+ rule_slugs: Array.isArray(parsed.rules) ? parsed.rules.map(String) : [],
461
336
  }
337
+ } catch (e) {
338
+ if (e instanceof IncurError) throw e
339
+ throw createError(ErrorCode.PACK_MISSING_FILE, {
340
+ message: `Pack declares brain "${manifest.brain}" but brains/${manifest.brain}.yaml is missing.`,
341
+ })
462
342
  }
463
343
 
464
- return { conflicts, skippedRels, skippedLabels }
344
+ return bundle
465
345
  }
466
346
 
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/. */
347
+ /** Import a pack directory into the server via POST /api/v1/import. */
485
348
  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
349
  // Validate path
496
350
  try {
497
351
  const s = await stat(packDir)
498
352
  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
- })
353
+ throw createError(ErrorCode.PACK_NOT_DIR, { params: [packDir] })
503
354
  }
504
355
  } catch (e) {
505
356
  if (e instanceof IncurError) throw e
506
- throw new IncurError({
507
- code: 'PACK_NOT_FOUND',
508
- message: `Pack path "${packDir}" does not exist.`,
509
- })
357
+ throw createError(ErrorCode.PACK_NOT_FOUND, { params: [packDir] })
510
358
  }
511
359
 
512
360
  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
- }
361
+ const bundle = await buildBundle(packDir, manifest)
610
362
 
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
- }
363
+ const api = await getApi()
364
+ const result = await api.post<ApiImportResult>('/api/v1/import', bundle)
645
365
 
646
366
  // Activate brain if requested
647
367
  let activated = false
648
368
  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()
369
+ const brain = await api.get<ApiBrain>(`/api/v1/brains/${manifest.brain}`)
370
+ await putState(api, {
371
+ soul_slug: brain.soul_slug,
372
+ persona_slug: brain.persona_slug,
373
+ rule_slugs: brain.rule_slugs,
657
374
  })
375
+ await sync({ api })
658
376
  activated = true
659
377
  }
660
378
 
@@ -662,10 +380,13 @@ export async function importPack(packDir: string, options: ImportOptions = {}):
662
380
  imported: manifest.name,
663
381
  from: packDir,
664
382
  brain: manifest.brain,
665
- written,
666
- skipped: skippedLabels,
667
- overwritten,
383
+ counts: {
384
+ souls: result.imported.souls,
385
+ personas: result.imported.personas,
386
+ rules: result.imported.rules,
387
+ brains: result.imported.brains,
388
+ },
668
389
  activated,
669
- warnings,
390
+ warnings: result.warnings,
670
391
  }
671
392
  }