@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/README.md +9 -7
- package/package.json +1 -1
- package/src/api-types.ts +155 -0
- package/src/cli.ts +4 -0
- package/src/client.ts +157 -0
- package/src/commands/brain.ts +99 -113
- package/src/commands/compose.ts +17 -116
- package/src/commands/init.ts +65 -40
- package/src/commands/migrate.ts +61 -0
- package/src/commands/pack.ts +1 -5
- package/src/commands/persona.ts +97 -145
- package/src/commands/rules.ts +71 -174
- package/src/commands/server.ts +212 -0
- package/src/commands/shell.ts +53 -46
- package/src/commands/soul.ts +75 -110
- package/src/commands/status.ts +36 -41
- package/src/commands/sync.ts +0 -2
- package/src/config.ts +125 -0
- package/src/daemon.ts +404 -0
- package/src/errors.ts +172 -0
- package/src/migrate.ts +247 -0
- package/src/pack.ts +149 -428
- package/src/paths.ts +1 -6
- package/src/seeds.ts +62 -103
- package/src/state.ts +12 -368
- package/src/sync.ts +60 -85
- package/src/version-check.ts +137 -0
- package/src/brain.ts +0 -69
- package/src/hooks.test.ts +0 -132
- package/src/pack.test.ts +0 -472
- package/src/seeds/templates/persona.md +0 -19
- package/src/seeds/templates/rule.md +0 -11
- package/src/seeds/templates/soul.md +0 -20
- /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
- /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
- /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
package/src/pack.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { readFile, readdir, writeFile, access, mkdir,
|
|
2
|
-
import { join, dirname
|
|
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 {
|
|
6
|
-
import { normalizeSlug,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
198
|
-
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
318
|
-
async function
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
262
|
+
const soulPath = join(packDir, 'souls', `${manifest.contents.soul}.md`)
|
|
332
263
|
try {
|
|
333
|
-
|
|
264
|
+
bundle.souls![manifest.contents.soul] = {
|
|
265
|
+
content: await readFile(soulPath, 'utf-8'),
|
|
266
|
+
}
|
|
334
267
|
} catch {
|
|
335
|
-
throw
|
|
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
|
|
274
|
+
const personaPath = join(packDir, 'personas', `${manifest.contents.persona}.md`)
|
|
343
275
|
try {
|
|
344
|
-
await
|
|
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
|
|
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
|
|
354
|
-
const dirPath = join(packDir, 'rules',
|
|
355
|
-
const filePath = join(packDir, 'rules', `${
|
|
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())
|
|
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
|
|
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
|
|
372
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
344
|
+
return bundle
|
|
465
345
|
}
|
|
466
346
|
|
|
467
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
612
|
-
|
|
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
|
|
650
|
-
await
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
}
|