@brainjar/cli 0.2.3 → 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.
Files changed (40) hide show
  1. package/README.md +11 -10
  2. package/package.json +2 -2
  3. package/src/api-types.ts +155 -0
  4. package/src/cli.ts +5 -3
  5. package/src/client.ts +157 -0
  6. package/src/commands/brain.ts +99 -113
  7. package/src/commands/compose.ts +17 -116
  8. package/src/commands/init.ts +66 -42
  9. package/src/commands/migrate.ts +61 -0
  10. package/src/commands/pack.ts +1 -5
  11. package/src/commands/persona.ts +97 -145
  12. package/src/commands/rules.ts +71 -174
  13. package/src/commands/server.ts +212 -0
  14. package/src/commands/shell.ts +55 -51
  15. package/src/commands/soul.ts +75 -110
  16. package/src/commands/status.ts +37 -78
  17. package/src/commands/sync.ts +0 -2
  18. package/src/config.ts +125 -0
  19. package/src/daemon.ts +404 -0
  20. package/src/errors.ts +172 -0
  21. package/src/migrate.ts +247 -0
  22. package/src/pack.ts +149 -428
  23. package/src/paths.ts +1 -8
  24. package/src/seeds.ts +62 -105
  25. package/src/state.ts +12 -397
  26. package/src/sync.ts +61 -102
  27. package/src/version-check.ts +137 -0
  28. package/src/brain.ts +0 -69
  29. package/src/commands/identity.ts +0 -276
  30. package/src/engines/bitwarden.ts +0 -105
  31. package/src/engines/index.ts +0 -12
  32. package/src/engines/types.ts +0 -12
  33. package/src/hooks.test.ts +0 -132
  34. package/src/pack.test.ts +0 -472
  35. package/src/seeds/templates/persona.md +0 -19
  36. package/src/seeds/templates/rule.md +0 -11
  37. package/src/seeds/templates/soul.md +0 -20
  38. /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
  39. /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
  40. /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
@@ -1,24 +1,12 @@
1
1
  import { Cli, z, Errors } from 'incur'
2
+ import { basename } from 'node:path'
2
3
 
3
4
  const { IncurError } = Errors
4
- import { readdir, readFile, writeFile, access, rm } from 'node:fs/promises'
5
- import { join, basename } from 'node:path'
6
- import { stringify as stringifyYaml } from 'yaml'
7
- import { paths } from '../paths.js'
8
- import {
9
- readState,
10
- writeState,
11
- withStateLock,
12
- readLocalState,
13
- writeLocalState,
14
- withLocalStateLock,
15
- readEnvState,
16
- mergeState,
17
- requireBrainjarDir,
18
- normalizeSlug,
19
- } from '../state.js'
20
- import { readBrain, type BrainConfig } from '../brain.js'
5
+ import { ErrorCode, createError } from '../errors.js'
6
+ import { normalizeSlug, getEffectiveState, putState } from '../state.js'
21
7
  import { sync } from '../sync.js'
8
+ import { getApi } from '../client.js'
9
+ import type { ApiBrain, ApiBrainList, ApiSoul, ApiPersona } from '../api-types.js'
22
10
 
23
11
  export const brain = Cli.create('brain', {
24
12
  description: 'Manage brains — full-stack configuration snapshots (soul + persona + rules)',
@@ -26,65 +14,50 @@ export const brain = Cli.create('brain', {
26
14
  .command('save', {
27
15
  description: 'Snapshot current effective state as a named brain',
28
16
  args: z.object({
29
- name: z.string().describe('Brain name (will be used as filename)'),
17
+ name: z.string().describe('Brain name'),
30
18
  }),
31
19
  options: z.object({
32
- overwrite: z.boolean().default(false).describe('Overwrite existing brain file'),
20
+ overwrite: z.boolean().default(false).describe('Overwrite existing brain'),
33
21
  }),
34
22
  async run(c) {
35
- await requireBrainjarDir()
36
23
  const name = normalizeSlug(c.args.name, 'brain name')
37
- const dest = join(paths.brains, `${name}.yaml`)
24
+ const api = await getApi()
38
25
 
39
26
  // Check for existing brain
40
27
  if (!c.options.overwrite) {
41
28
  try {
42
- await access(dest)
43
- throw new IncurError({
44
- code: 'BRAIN_EXISTS',
45
- message: `Brain "${name}" already exists.`,
46
- hint: 'Use --overwrite to replace it, or choose a different name.',
47
- })
29
+ await api.get<ApiBrain>(`/api/v1/brains/${name}`)
30
+ throw createError(ErrorCode.BRAIN_EXISTS, { params: [name] })
48
31
  } catch (e) {
49
- if (e instanceof IncurError) throw e
32
+ if (e instanceof IncurError && e.code === ErrorCode.BRAIN_EXISTS) throw e
33
+ if (e instanceof IncurError && e.code !== ErrorCode.NOT_FOUND) throw e
50
34
  }
51
35
  }
52
36
 
53
- // Read effective state
54
- const globalState = await readState()
55
- const localState = await readLocalState()
56
- const envState = readEnvState()
57
- const effective = mergeState(globalState, localState, envState)
58
-
59
- if (!effective.soul.value) {
60
- throw new IncurError({
61
- code: 'NO_ACTIVE_SOUL',
62
- message: 'Cannot save brain: no active soul.',
63
- hint: 'Activate a soul first with `brainjar soul use <name>`.',
64
- })
37
+ const effective = await getEffectiveState(api)
38
+
39
+ if (!effective.soul) {
40
+ throw createError(ErrorCode.NO_ACTIVE_SOUL)
65
41
  }
66
42
 
67
- if (!effective.persona.value) {
68
- throw new IncurError({
69
- code: 'NO_ACTIVE_PERSONA',
70
- message: 'Cannot save brain: no active persona.',
71
- hint: 'Activate a persona first with `brainjar persona use <name>`.',
72
- })
43
+ if (!effective.persona) {
44
+ throw createError(ErrorCode.NO_ACTIVE_PERSONA)
73
45
  }
74
46
 
75
47
  const activeRules = effective.rules
76
- .filter(r => !r.scope.startsWith('-'))
77
- .map(r => r.value)
78
48
 
79
- const config: BrainConfig = {
80
- soul: effective.soul.value,
81
- persona: effective.persona.value,
49
+ await api.put<ApiBrain>(`/api/v1/brains/${name}`, {
50
+ soul_slug: effective.soul,
51
+ persona_slug: effective.persona,
52
+ rule_slugs: activeRules,
53
+ })
54
+
55
+ return {
56
+ saved: name,
57
+ soul: effective.soul,
58
+ persona: effective.persona,
82
59
  rules: activeRules,
83
60
  }
84
-
85
- await writeFile(dest, stringifyYaml(config))
86
-
87
- return { saved: name, ...config }
88
61
  },
89
62
  })
90
63
  .command('use', {
@@ -93,68 +66,77 @@ export const brain = Cli.create('brain', {
93
66
  name: z.string().describe('Brain name to activate'),
94
67
  }),
95
68
  options: z.object({
96
- local: z.boolean().default(false).describe('Apply brain at project scope'),
69
+ project: z.boolean().default(false).describe('Apply brain at project scope'),
97
70
  }),
98
71
  async run(c) {
99
- await requireBrainjarDir()
100
72
  const name = normalizeSlug(c.args.name, 'brain name')
101
- const config = await readBrain(name)
73
+ const api = await getApi()
74
+
75
+ let config: ApiBrain
76
+ try {
77
+ config = await api.get<ApiBrain>(`/api/v1/brains/${name}`)
78
+ } catch (e) {
79
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
80
+ throw createError(ErrorCode.BRAIN_NOT_FOUND, { params: [name] })
81
+ }
82
+ throw e
83
+ }
102
84
 
103
85
  // Validate soul exists
104
86
  try {
105
- await readFile(join(paths.souls, `${config.soul}.md`), 'utf-8')
106
- } catch {
107
- throw new IncurError({
108
- code: 'SOUL_NOT_FOUND',
109
- message: `Brain "${name}" references soul "${config.soul}" which does not exist.`,
110
- hint: 'Create the soul first or update the brain file.',
111
- })
87
+ await api.get<ApiSoul>(`/api/v1/souls/${config.soul_slug}`)
88
+ } catch (e) {
89
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
90
+ throw createError(ErrorCode.SOUL_NOT_FOUND, {
91
+ params: [config.soul_slug],
92
+ message: `Brain "${name}" references soul "${config.soul_slug}" which does not exist.`,
93
+ hint: 'Create the soul first or update the brain.',
94
+ })
95
+ }
96
+ throw e
112
97
  }
113
98
 
114
99
  // Validate persona exists
115
100
  try {
116
- await readFile(join(paths.personas, `${config.persona}.md`), 'utf-8')
117
- } catch {
118
- throw new IncurError({
119
- code: 'PERSONA_NOT_FOUND',
120
- message: `Brain "${name}" references persona "${config.persona}" which does not exist.`,
121
- hint: 'Create the persona first or update the brain file.',
122
- })
101
+ await api.get<ApiPersona>(`/api/v1/personas/${config.persona_slug}`)
102
+ } catch (e) {
103
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
104
+ throw createError(ErrorCode.PERSONA_NOT_FOUND, {
105
+ params: [config.persona_slug],
106
+ message: `Brain "${name}" references persona "${config.persona_slug}" which does not exist.`,
107
+ hint: 'Create the persona first or update the brain.',
108
+ })
109
+ }
110
+ throw e
123
111
  }
124
112
 
125
- if (c.options.local) {
126
- await withLocalStateLock(async () => {
127
- const local = await readLocalState()
128
- local.soul = config.soul
129
- local.persona = config.persona
130
- // Replace rules entirely — brain is a complete snapshot
131
- local.rules = { add: config.rules, remove: [] }
132
- await writeLocalState(local)
133
- await sync({ local: true })
134
- })
135
- } else {
136
- await withStateLock(async () => {
137
- const state = await readState()
138
- state.soul = config.soul
139
- state.persona = config.persona
140
- state.rules = config.rules
141
- await writeState(state)
142
- await sync()
143
- })
113
+ const mutationOpts = c.options.project
114
+ ? { project: basename(process.cwd()) }
115
+ : undefined
116
+ await putState(api, {
117
+ soul_slug: config.soul_slug,
118
+ persona_slug: config.persona_slug,
119
+ rule_slugs: config.rule_slugs,
120
+ }, mutationOpts)
121
+
122
+ await sync({ api })
123
+ if (c.options.project) await sync({ api, project: true })
124
+
125
+ return {
126
+ activated: name,
127
+ project: c.options.project,
128
+ soul: config.soul_slug,
129
+ persona: config.persona_slug,
130
+ rules: config.rule_slugs,
144
131
  }
145
-
146
- return { activated: name, local: c.options.local, ...config }
147
132
  },
148
133
  })
149
134
  .command('list', {
150
135
  description: 'List available brains',
151
136
  async run() {
152
- await requireBrainjarDir()
153
- const entries = await readdir(paths.brains).catch(() => [])
154
- const brains = entries
155
- .filter(f => f.endsWith('.yaml'))
156
- .map(f => basename(f, '.yaml'))
157
- return { brains }
137
+ const api = await getApi()
138
+ const result = await api.get<ApiBrainList>('/api/v1/brains')
139
+ return { brains: result.brains.map(b => b.slug) }
158
140
  },
159
141
  })
160
142
  .command('show', {
@@ -163,10 +145,18 @@ export const brain = Cli.create('brain', {
163
145
  name: z.string().describe('Brain name to show'),
164
146
  }),
165
147
  async run(c) {
166
- await requireBrainjarDir()
167
148
  const name = normalizeSlug(c.args.name, 'brain name')
168
- const config = await readBrain(name)
169
- return { name, ...config }
149
+ const api = await getApi()
150
+
151
+ try {
152
+ const config = await api.get<ApiBrain>(`/api/v1/brains/${name}`)
153
+ return { name, soul: config.soul_slug, persona: config.persona_slug, rules: config.rule_slugs }
154
+ } catch (e) {
155
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
156
+ throw createError(ErrorCode.BRAIN_NOT_FOUND, { params: [name] })
157
+ }
158
+ throw e
159
+ }
170
160
  },
171
161
  })
172
162
  .command('drop', {
@@ -175,22 +165,18 @@ export const brain = Cli.create('brain', {
175
165
  name: z.string().describe('Brain name to delete'),
176
166
  }),
177
167
  async run(c) {
178
- await requireBrainjarDir()
179
168
  const name = normalizeSlug(c.args.name, 'brain name')
180
- const file = join(paths.brains, `${name}.yaml`)
169
+ const api = await getApi()
181
170
 
182
171
  try {
183
- await access(file)
184
- } catch {
185
- throw new IncurError({
186
- code: 'BRAIN_NOT_FOUND',
187
- message: `Brain "${name}" not found.`,
188
- hint: 'Run `brainjar brain list` to see available brains.',
189
- })
172
+ await api.delete(`/api/v1/brains/${name}`)
173
+ } catch (e) {
174
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
175
+ throw createError(ErrorCode.BRAIN_NOT_FOUND, { params: [name] })
176
+ }
177
+ throw e
190
178
  }
191
179
 
192
- await rm(file)
193
-
194
180
  return { dropped: name }
195
181
  },
196
182
  })
@@ -1,21 +1,9 @@
1
1
  import { Cli, z, Errors } from 'incur'
2
2
 
3
3
  const { IncurError } = Errors
4
- import { readFile } from 'node:fs/promises'
5
- import { join } from 'node:path'
6
- import { paths } from '../paths.js'
7
- import {
8
- readState,
9
- readLocalState,
10
- readEnvState,
11
- mergeState,
12
- requireBrainjarDir,
13
- normalizeSlug,
14
- parseLayerFrontmatter,
15
- stripFrontmatter,
16
- resolveRuleContent,
17
- } from '../state.js'
18
- import { readBrain } from '../brain.js'
4
+ import { ErrorCode, createError } from '../errors.js'
5
+ import { getApi } from '../client.js'
6
+ import type { ApiComposeResult } from '../api-types.js'
19
7
 
20
8
  export const compose = Cli.create('compose', {
21
9
  description: 'Assemble a full subagent prompt from a brain or ad-hoc persona',
@@ -27,129 +15,42 @@ export const compose = Cli.create('compose', {
27
15
  task: z.string().optional().describe('Task description to append to the prompt'),
28
16
  }),
29
17
  async run(c) {
30
- await requireBrainjarDir()
31
-
32
18
  const brainName = c.args.brain
33
19
  const personaFlag = c.options.persona
34
20
 
35
21
  // Mutual exclusivity
36
22
  if (brainName && personaFlag) {
37
- throw new IncurError({
38
- code: 'MUTUALLY_EXCLUSIVE',
23
+ throw createError(ErrorCode.MUTUALLY_EXCLUSIVE, {
39
24
  message: 'Cannot specify both a brain name and --persona.',
40
25
  hint: 'Use `brainjar compose <brain>` or `brainjar compose --persona <name>`, not both.',
41
26
  })
42
27
  }
43
28
 
44
29
  if (!brainName && !personaFlag) {
45
- throw new IncurError({
46
- code: 'MISSING_ARG',
30
+ throw createError(ErrorCode.MISSING_ARG, {
47
31
  message: 'Provide a brain name or --persona.',
48
32
  hint: 'Usage: `brainjar compose <brain>` or `brainjar compose --persona <name>`.',
49
33
  })
50
34
  }
51
35
 
52
- const sections: string[] = []
53
- const warnings: string[] = []
54
- let soulName: string | null = null
55
- let personaName: string
56
- let rulesList: string[]
57
-
58
- if (brainName) {
59
- // === Primary path: brain-driven ===
60
- const config = await readBrain(brainName)
61
- soulName = config.soul
62
- personaName = config.persona
63
- rulesList = config.rules
64
-
65
- // Soul — from brain
66
- try {
67
- const raw = await readFile(join(paths.souls, `${soulName}.md`), 'utf-8')
68
- sections.push(stripFrontmatter(raw))
69
- } catch {
70
- warnings.push(`Soul "${soulName}" not found — skipped`)
71
- soulName = null
72
- }
73
-
74
- // Persona — from brain
75
- let personaRaw: string
76
- try {
77
- personaRaw = await readFile(join(paths.personas, `${personaName}.md`), 'utf-8')
78
- } catch {
79
- throw new IncurError({
80
- code: 'PERSONA_NOT_FOUND',
81
- message: `Brain "${brainName}" references persona "${personaName}" which does not exist.`,
82
- hint: 'Create the persona first or update the brain file.',
83
- })
84
- }
85
- sections.push(stripFrontmatter(personaRaw))
86
-
87
- // Rules — from brain (overrides persona frontmatter)
88
- for (const rule of rulesList) {
89
- const resolved = await resolveRuleContent(rule, warnings)
90
- sections.push(...resolved)
91
- }
92
- } else {
93
- // === Ad-hoc path: --persona flag ===
94
- const personaSlug = normalizeSlug(personaFlag!, 'persona name')
95
- personaName = personaSlug
96
-
97
- let personaRaw: string
98
- try {
99
- personaRaw = await readFile(join(paths.personas, `${personaSlug}.md`), 'utf-8')
100
- } catch {
101
- throw new IncurError({
102
- code: 'PERSONA_NOT_FOUND',
103
- message: `Persona "${personaSlug}" not found.`,
104
- hint: 'Run `brainjar persona list` to see available personas.',
105
- })
106
- }
107
-
108
- const frontmatter = parseLayerFrontmatter(personaRaw)
109
- rulesList = frontmatter.rules
36
+ const api = await getApi()
110
37
 
111
- // Soul from active state cascade
112
- const globalState = await readState()
113
- const localState = await readLocalState()
114
- const envState = readEnvState()
115
- const effective = mergeState(globalState, localState, envState)
116
-
117
- if (effective.soul.value) {
118
- soulName = effective.soul.value
119
- try {
120
- const raw = await readFile(join(paths.souls, `${soulName}.md`), 'utf-8')
121
- sections.push(stripFrontmatter(raw))
122
- } catch {
123
- warnings.push(`Soul "${soulName}" not found — skipped`)
124
- soulName = null
125
- }
126
- }
127
-
128
- // Persona content
129
- sections.push(stripFrontmatter(personaRaw))
130
-
131
- // Rules — from persona frontmatter
132
- for (const rule of rulesList) {
133
- const resolved = await resolveRuleContent(rule, warnings)
134
- sections.push(...resolved)
135
- }
136
- }
137
-
138
- // Task section
139
- if (c.options.task) {
140
- sections.push(`# Task\n\n${c.options.task}`)
141
- }
38
+ const body: Record<string, unknown> = {}
39
+ if (brainName) body.brain = brainName
40
+ if (personaFlag) body.persona = personaFlag
41
+ if (c.options.task) body.task = c.options.task
142
42
 
143
- const prompt = sections.join('\n\n')
43
+ const composed = await api.post<ApiComposeResult>('/api/v1/compose', body)
144
44
 
145
45
  const result: Record<string, unknown> = {
146
- persona: personaName,
147
- rules: rulesList,
148
- prompt,
46
+ persona: composed.persona,
47
+ rules: composed.rules,
48
+ prompt: composed.prompt,
149
49
  }
150
50
  if (brainName) result.brain = brainName
151
- if (soulName) result.soul = soulName
152
- if (warnings.length) result.warnings = warnings
51
+ if (composed.soul) result.soul = composed.soul
52
+ if (composed.token_estimate) result.token_estimate = composed.token_estimate
53
+ if (composed.warnings?.length) result.warnings = composed.warnings
153
54
 
154
55
  return result
155
56
  },
@@ -1,76 +1,100 @@
1
1
  import { Cli, z } from 'incur'
2
- import { mkdir, writeFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
2
+ import { mkdir, access } from 'node:fs/promises'
4
3
  import { getBrainjarDir, paths, type Backend } from '../paths.js'
5
- import { seedDefaultRule, seedDefaults, initObsidian } from '../seeds.js'
6
- import { readState, writeState, withStateLock } from '../state.js'
4
+ import { buildSeedBundle } from '../seeds.js'
5
+ import { putState } from '../state.js'
7
6
  import { sync } from '../sync.js'
7
+ import { getApi } from '../client.js'
8
+ import { readConfig, writeConfig, type Config } from '../config.js'
9
+ import { ensureBinary, upgradeServer } from '../daemon.js'
10
+ import type { ApiImportResult } from '../api-types.js'
8
11
 
9
12
  export const init = Cli.create('init', {
10
- description: 'Bootstrap ~/.brainjar/ directory structure',
13
+ description: 'Initialize brainjar: config, server, and optional seed content',
11
14
  options: z.object({
12
15
  backend: z.enum(['claude', 'codex']).default('claude').describe('Agent backend to target'),
13
16
  default: z.boolean().default(false).describe('Seed starter soul, personas, and rules'),
14
- obsidian: z.boolean().default(false).describe('Set up ~/.brainjar/ as an Obsidian vault'),
15
17
  }),
16
18
  async run(c) {
17
19
  const brainjarDir = getBrainjarDir()
20
+ const binDir = `${brainjarDir}/bin`
18
21
 
19
- await Promise.all([
20
- mkdir(paths.souls, { recursive: true }),
21
- mkdir(paths.personas, { recursive: true }),
22
- mkdir(paths.rules, { recursive: true }),
23
- mkdir(paths.brains, { recursive: true }),
24
- mkdir(paths.identities, { recursive: true }),
25
- ])
22
+ // 1. Create directories
23
+ await mkdir(brainjarDir, { recursive: true })
24
+ await mkdir(binDir, { recursive: true })
26
25
 
27
- // Seed the default rule pack
28
- await seedDefaultRule(paths.rules)
26
+ // 2. Write config.yaml if missing
27
+ let configExists = false
28
+ try {
29
+ await access(paths.config)
30
+ configExists = true
31
+ } catch {}
29
32
 
30
- // Build .gitignore — always exclude private files, add .obsidian if vault enabled
31
- const gitignoreLines = ['identities/', '.session', 'state.yaml']
32
- if (c.options.obsidian) {
33
- gitignoreLines.push('.obsidian/', 'templates/')
33
+ if (!configExists) {
34
+ const config: Config = {
35
+ server: {
36
+ url: 'http://localhost:7742',
37
+ mode: 'local',
38
+ bin: `${brainjarDir}/bin/brainjar-server`,
39
+ pid_file: `${brainjarDir}/server.pid`,
40
+ log_file: `${brainjarDir}/server.log`,
41
+ },
42
+ workspace: 'default',
43
+ backend: c.options.backend as Backend,
44
+ }
45
+ await writeConfig(config)
46
+ }
47
+
48
+ // 3. Ensure server binary exists and is up to date
49
+ await ensureBinary()
50
+ const config = await readConfig()
51
+ if (config.server.mode === 'local') {
52
+ // Only upgrade if server isn't already running (can't overwrite a running binary)
53
+ const health = await (await import('../daemon.js')).healthCheck({ timeout: 1000, url: config.server.url })
54
+ if (!health.healthy) {
55
+ await upgradeServer()
56
+ }
34
57
  }
35
- await writeFile(join(brainjarDir, '.gitignore'), gitignoreLines.join('\n') + '\n')
36
58
 
59
+ // 4. Start server and get API client
60
+ const api = await getApi()
61
+
62
+ // 5. Ensure workspace exists (ignore conflict if already created)
63
+ try {
64
+ await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
65
+ } catch (e: any) {
66
+ if (e.code !== 'CONFLICT') throw e
67
+ }
68
+
69
+ // 6. Seed defaults if requested
37
70
  if (c.options.default) {
38
- await seedDefaults()
71
+ const bundle = await buildSeedBundle()
72
+ await api.post<ApiImportResult>('/api/v1/import', bundle)
73
+
74
+ await putState(api, {
75
+ soul_slug: 'craftsman',
76
+ persona_slug: 'engineer',
77
+ rule_slugs: ['boundaries', 'context-recovery', 'task-completion', 'git-discipline', 'security'],
78
+ })
39
79
  }
40
80
 
41
- await withStateLock(async () => {
42
- const state = await readState()
43
- state.backend = c.options.backend
44
- if (c.options.default) {
45
- state.soul = 'craftsman'
46
- state.persona = 'engineer'
47
- state.rules = ['default', 'git-discipline', 'security']
48
- }
49
- await writeState(state)
50
- await sync(c.options.backend as Backend)
51
- })
81
+ // 7. Sync to write CLAUDE.md / AGENTS.md
82
+ await sync({ api, backend: c.options.backend as Backend })
52
83
 
84
+ // 8. Build result
53
85
  const result: Record<string, unknown> = {
54
86
  created: brainjarDir,
55
87
  backend: c.options.backend,
56
- directories: ['souls/', 'personas/', 'rules/', 'brains/', 'identities/'],
57
88
  }
58
89
 
59
90
  if (c.options.default) {
60
91
  result.soul = 'craftsman'
61
92
  result.persona = 'engineer'
62
- result.rules = ['default', 'git-discipline', 'security']
93
+ result.rules = ['boundaries', 'context-recovery', 'task-completion', 'git-discipline', 'security']
63
94
  result.personas = ['engineer', 'planner', 'reviewer']
64
95
  result.next = 'Ready to go. Run `brainjar status` to see your config.'
65
96
  } else {
66
- result.next = 'Run `brainjar identity create <slug> --name <name> --email <email>` to set up your first identity.'
67
- }
68
-
69
- if (c.options.obsidian) {
70
- await initObsidian(brainjarDir)
71
- result.obsidian = true
72
- result.vault = brainjarDir
73
- result.hint = `Open "${brainjarDir}" as a vault in Obsidian.`
97
+ result.next = 'Run `brainjar soul create <name>` to create your first soul.'
74
98
  }
75
99
 
76
100
  return result
@@ -0,0 +1,61 @@
1
+ import { Cli, z } from 'incur'
2
+ import { getBrainjarDir } from '../paths.js'
3
+ import { buildMigrationBundle, backupContentDirs } from '../migrate.js'
4
+ import { getApi } from '../client.js'
5
+ import { putState } from '../state.js'
6
+ import { sync } from '../sync.js'
7
+ import type { ApiImportResult } from '../api-types.js'
8
+
9
+ export const migrate = Cli.create('migrate', {
10
+ description: 'Import file-based content into the server',
11
+ options: z.object({
12
+ dryRun: z.boolean().default(false).describe('Preview what would be imported without making changes'),
13
+ skipBackup: z.boolean().default(false).describe('Skip renaming source directories to .bak'),
14
+ }),
15
+ async run(c) {
16
+ const brainjarDir = getBrainjarDir()
17
+ const { bundle, state, counts, warnings: scanWarnings } = await buildMigrationBundle(brainjarDir)
18
+
19
+ const total = counts.souls + counts.personas + counts.rules + counts.brains
20
+ if (total === 0) {
21
+ return { migrated: false, reason: 'No file-based content found to migrate.' }
22
+ }
23
+
24
+ if (c.options.dryRun) {
25
+ return {
26
+ dry_run: true,
27
+ would_import: counts,
28
+ would_restore_state: state !== null,
29
+ warnings: scanWarnings,
30
+ }
31
+ }
32
+
33
+ const api = await getApi()
34
+ const result = await api.post<ApiImportResult>('/api/v1/import', bundle)
35
+
36
+ let stateRestored = false
37
+ if (state && (state.soul || state.persona || state.rules.length > 0)) {
38
+ await putState(api, {
39
+ soul_slug: state.soul || undefined,
40
+ persona_slug: state.persona || undefined,
41
+ rule_slugs: state.rules.length > 0 ? state.rules : undefined,
42
+ })
43
+ stateRestored = true
44
+ }
45
+
46
+ await sync({ api })
47
+
48
+ let backedUp: string[] = []
49
+ if (!c.options.skipBackup) {
50
+ backedUp = await backupContentDirs(brainjarDir)
51
+ }
52
+
53
+ return {
54
+ migrated: true,
55
+ imported: result.imported,
56
+ state_restored: stateRestored,
57
+ backed_up: backedUp,
58
+ warnings: [...scanWarnings, ...result.warnings],
59
+ }
60
+ },
61
+ })