@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/migrate.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { readFile, readdir, stat, rename, access } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+ import type {
5
+ ContentBundle, BundleRule, BundleSoul, BundlePersona, BundleBrain, BundleState,
6
+ } from './api-types.js'
7
+
8
+ export interface MigrateCounts {
9
+ souls: number
10
+ personas: number
11
+ rules: number
12
+ brains: number
13
+ }
14
+
15
+ export function parseFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } {
16
+ const trimmed = content.trimStart()
17
+ if (!trimmed.startsWith('---')) {
18
+ return { frontmatter: {}, body: content }
19
+ }
20
+
21
+ const endIdx = trimmed.indexOf('---', 3)
22
+ if (endIdx === -1) {
23
+ return { frontmatter: {}, body: content }
24
+ }
25
+
26
+ const yamlBlock = trimmed.slice(3, endIdx).trim()
27
+ const body = trimmed.slice(endIdx + 3).trimStart()
28
+
29
+ let frontmatter: Record<string, unknown> = {}
30
+ try {
31
+ const parsed = parseYaml(yamlBlock)
32
+ if (parsed && typeof parsed === 'object') {
33
+ frontmatter = parsed as Record<string, unknown>
34
+ }
35
+ } catch {
36
+ // Malformed frontmatter — treat as none
37
+ }
38
+
39
+ return { frontmatter, body }
40
+ }
41
+
42
+ export async function scanRules(rulesDir: string): Promise<{ rules: Record<string, BundleRule>; warnings: string[] }> {
43
+ const rules: Record<string, BundleRule> = {}
44
+ const warnings: string[] = []
45
+
46
+ let entries: string[]
47
+ try {
48
+ entries = await readdir(rulesDir)
49
+ } catch {
50
+ return { rules, warnings }
51
+ }
52
+
53
+ for (const entry of entries.sort()) {
54
+ const fullPath = join(rulesDir, entry)
55
+ const s = await stat(fullPath)
56
+
57
+ if (s.isDirectory()) {
58
+ try {
59
+ const files = await readdir(fullPath)
60
+ const mdFiles = files.filter(f => f.endsWith('.md')).sort()
61
+ const ruleEntries = await Promise.all(
62
+ mdFiles.map(async (file, i) => ({
63
+ sort_key: i,
64
+ content: await readFile(join(fullPath, file), 'utf-8'),
65
+ }))
66
+ )
67
+ if (ruleEntries.length > 0) {
68
+ rules[entry] = { entries: ruleEntries }
69
+ }
70
+ } catch (e) {
71
+ warnings.push(`Skipped rule directory "${entry}": ${(e as Error).message}`)
72
+ }
73
+ } else if (entry.endsWith('.md')) {
74
+ try {
75
+ const slug = entry.slice(0, -3)
76
+ const content = await readFile(fullPath, 'utf-8')
77
+ rules[slug] = { entries: [{ sort_key: 0, content }] }
78
+ } catch (e) {
79
+ warnings.push(`Skipped rule "${entry}": ${(e as Error).message}`)
80
+ }
81
+ }
82
+ }
83
+
84
+ return { rules, warnings }
85
+ }
86
+
87
+ export async function scanSouls(soulsDir: string): Promise<{ souls: Record<string, BundleSoul>; warnings: string[] }> {
88
+ const souls: Record<string, BundleSoul> = {}
89
+ const warnings: string[] = []
90
+
91
+ let entries: string[]
92
+ try {
93
+ entries = await readdir(soulsDir)
94
+ } catch {
95
+ return { souls, warnings }
96
+ }
97
+
98
+ for (const entry of entries.sort()) {
99
+ if (!entry.endsWith('.md')) continue
100
+ try {
101
+ const slug = entry.slice(0, -3)
102
+ const content = await readFile(join(soulsDir, entry), 'utf-8')
103
+ souls[slug] = { content }
104
+ } catch (e) {
105
+ warnings.push(`Skipped soul "${entry}": ${(e as Error).message}`)
106
+ }
107
+ }
108
+
109
+ return { souls, warnings }
110
+ }
111
+
112
+ export async function scanPersonas(personasDir: string): Promise<{ personas: Record<string, BundlePersona>; warnings: string[] }> {
113
+ const personas: Record<string, BundlePersona> = {}
114
+ const warnings: string[] = []
115
+
116
+ let entries: string[]
117
+ try {
118
+ entries = await readdir(personasDir)
119
+ } catch {
120
+ return { personas, warnings }
121
+ }
122
+
123
+ for (const entry of entries.sort()) {
124
+ if (!entry.endsWith('.md')) continue
125
+ try {
126
+ const slug = entry.slice(0, -3)
127
+ const raw = await readFile(join(personasDir, entry), 'utf-8')
128
+ const { frontmatter, body } = parseFrontmatter(raw)
129
+
130
+ let bundled_rules: string[] = []
131
+ if (Array.isArray(frontmatter.rules)) {
132
+ bundled_rules = frontmatter.rules.map(String)
133
+ }
134
+
135
+ personas[slug] = { content: body, bundled_rules }
136
+ } catch (e) {
137
+ warnings.push(`Skipped persona "${entry}": ${(e as Error).message}`)
138
+ }
139
+ }
140
+
141
+ return { personas, warnings }
142
+ }
143
+
144
+ export async function scanBrains(brainsDir: string): Promise<{ brains: Record<string, BundleBrain>; warnings: string[] }> {
145
+ const brains: Record<string, BundleBrain> = {}
146
+ const warnings: string[] = []
147
+
148
+ let entries: string[]
149
+ try {
150
+ entries = await readdir(brainsDir)
151
+ } catch {
152
+ return { brains, warnings }
153
+ }
154
+
155
+ for (const entry of entries.sort()) {
156
+ if (!entry.endsWith('.yaml')) continue
157
+ try {
158
+ const slug = entry.slice(0, -5)
159
+ const raw = await readFile(join(brainsDir, entry), 'utf-8')
160
+ const parsed = parseYaml(raw) as Record<string, unknown>
161
+
162
+ brains[slug] = {
163
+ soul_slug: String(parsed.soul ?? ''),
164
+ persona_slug: String(parsed.persona ?? ''),
165
+ rule_slugs: Array.isArray(parsed.rules) ? parsed.rules.map(String) : [],
166
+ }
167
+ } catch (e) {
168
+ warnings.push(`Skipped brain "${entry}": ${(e as Error).message}`)
169
+ }
170
+ }
171
+
172
+ return { brains, warnings }
173
+ }
174
+
175
+ export async function scanState(stateFile: string): Promise<BundleState | null> {
176
+ try {
177
+ const raw = await readFile(stateFile, 'utf-8')
178
+ const parsed = parseYaml(raw) as Record<string, unknown>
179
+ return {
180
+ soul: String(parsed.soul ?? ''),
181
+ persona: String(parsed.persona ?? ''),
182
+ rules: Array.isArray(parsed.rules) ? parsed.rules.map(String) : [],
183
+ }
184
+ } catch {
185
+ return null
186
+ }
187
+ }
188
+
189
+ export async function buildMigrationBundle(brainjarDir: string): Promise<{
190
+ bundle: ContentBundle
191
+ state: BundleState | null
192
+ counts: MigrateCounts
193
+ warnings: string[]
194
+ }> {
195
+ const [rulesResult, soulsResult, personasResult, brainsResult, state] = await Promise.all([
196
+ scanRules(join(brainjarDir, 'rules')),
197
+ scanSouls(join(brainjarDir, 'souls')),
198
+ scanPersonas(join(brainjarDir, 'personas')),
199
+ scanBrains(join(brainjarDir, 'brains')),
200
+ scanState(join(brainjarDir, 'state.yaml')),
201
+ ])
202
+
203
+ const warnings = [
204
+ ...rulesResult.warnings,
205
+ ...soulsResult.warnings,
206
+ ...personasResult.warnings,
207
+ ...brainsResult.warnings,
208
+ ]
209
+
210
+ const bundle: ContentBundle = {}
211
+ if (Object.keys(soulsResult.souls).length > 0) bundle.souls = soulsResult.souls
212
+ if (Object.keys(personasResult.personas).length > 0) bundle.personas = personasResult.personas
213
+ if (Object.keys(rulesResult.rules).length > 0) bundle.rules = rulesResult.rules
214
+ if (Object.keys(brainsResult.brains).length > 0) bundle.brains = brainsResult.brains
215
+ if (state) bundle.state = state
216
+
217
+ return {
218
+ bundle,
219
+ state,
220
+ counts: {
221
+ souls: Object.keys(soulsResult.souls).length,
222
+ personas: Object.keys(personasResult.personas).length,
223
+ rules: Object.keys(rulesResult.rules).length,
224
+ brains: Object.keys(brainsResult.brains).length,
225
+ },
226
+ warnings,
227
+ }
228
+ }
229
+
230
+ export async function backupContentDirs(brainjarDir: string): Promise<string[]> {
231
+ const dirs = ['souls', 'personas', 'rules', 'brains']
232
+ const backedUp: string[] = []
233
+
234
+ for (const dir of dirs) {
235
+ const src = join(brainjarDir, dir)
236
+ const dst = join(brainjarDir, `${dir}.bak`)
237
+ try {
238
+ await access(src)
239
+ await rename(src, dst)
240
+ backedUp.push(dir)
241
+ } catch {
242
+ // Directory doesn't exist — skip
243
+ }
244
+ }
245
+
246
+ return backedUp
247
+ }