@brainjar/cli 0.1.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.
@@ -0,0 +1,259 @@
1
+ import { Cli, z, Errors } from 'incur'
2
+
3
+ const { IncurError } = Errors
4
+ import { readdir, readFile, writeFile, access } from 'node:fs/promises'
5
+ import { join, basename } from 'node:path'
6
+ import { paths } from '../paths.js'
7
+ import { type State, readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, requireBrainjarDir, parseLayerFrontmatter, stripFrontmatter, normalizeSlug, listAvailableRules } from '../state.js'
8
+ import { sync } from '../sync.js'
9
+
10
+ export const persona = Cli.create('persona', {
11
+ description: 'Manage personas — role behavior and workflow for the agent',
12
+ })
13
+ .command('create', {
14
+ description: 'Create a new persona',
15
+ args: z.object({
16
+ name: z.string().describe('Persona name (will be used as filename)'),
17
+ }),
18
+ options: z.object({
19
+ description: z.string().optional().describe('One-line description of the persona'),
20
+ rules: z.array(z.string()).optional().describe('Rules to bundle with this persona'),
21
+ }),
22
+ async run(c) {
23
+ await requireBrainjarDir()
24
+ const name = normalizeSlug(c.args.name, 'persona name')
25
+ const dest = join(paths.personas, `${name}.md`)
26
+
27
+ try {
28
+ await access(dest)
29
+ throw new IncurError({
30
+ code: 'PERSONA_EXISTS',
31
+ message: `Persona "${name}" already exists.`,
32
+ hint: 'Choose a different name or edit the existing file.',
33
+ })
34
+ } catch (e) {
35
+ if (e instanceof IncurError) throw e
36
+ }
37
+
38
+ const rulesList = c.options.rules ?? []
39
+
40
+ // Validate rules exist
41
+ const availableRules = await listAvailableRules()
42
+ const invalid = rulesList.filter(r => !availableRules.includes(r))
43
+ if (invalid.length > 0) {
44
+ throw new IncurError({
45
+ code: 'RULES_NOT_FOUND',
46
+ message: `Rules not found: ${invalid.join(', ')}`,
47
+ hint: `Available rules: ${availableRules.join(', ')}`,
48
+ })
49
+ }
50
+
51
+ const lines: string[] = []
52
+
53
+ // Frontmatter — always write it (rules default to [default])
54
+ const effectiveRules = rulesList.length > 0 ? rulesList : ['default']
55
+ lines.push('---')
56
+ lines.push('rules:')
57
+ for (const rule of effectiveRules) {
58
+ lines.push(` - ${rule}`)
59
+ }
60
+ lines.push('---')
61
+ lines.push('')
62
+
63
+ lines.push(`# ${name}`)
64
+ lines.push('')
65
+ if (c.options.description) {
66
+ lines.push(c.options.description)
67
+ }
68
+ lines.push('')
69
+ lines.push('## Direct mode')
70
+ lines.push('- ')
71
+ lines.push('')
72
+ lines.push('## Subagent mode')
73
+ lines.push('- ')
74
+ lines.push('')
75
+ lines.push('## Always')
76
+ lines.push('- ')
77
+ lines.push('')
78
+
79
+ const content = lines.join('\n')
80
+ await writeFile(dest, content)
81
+
82
+ if (c.agent || c.formatExplicit) {
83
+ return {
84
+ created: dest,
85
+ name,
86
+ rules: effectiveRules,
87
+ template: content,
88
+ }
89
+ }
90
+
91
+ return {
92
+ created: dest,
93
+ name,
94
+ rules: effectiveRules,
95
+ template: `\n${content}`,
96
+ next: `Edit ${dest} to fill in your persona, then run \`brainjar persona use ${name}\` to activate.`,
97
+ }
98
+ },
99
+ })
100
+ .command('list', {
101
+ description: 'List available personas',
102
+ async run() {
103
+ await requireBrainjarDir()
104
+ const entries = await readdir(paths.personas).catch(() => [])
105
+ const personas = entries.filter(f => f.endsWith('.md')).map(f => basename(f, '.md'))
106
+ return { personas }
107
+ },
108
+ })
109
+ .command('show', {
110
+ description: 'Show a persona by name, or the active persona if no name given',
111
+ args: z.object({
112
+ name: z.string().optional().describe('Persona name to show (defaults to active persona)'),
113
+ }),
114
+ options: z.object({
115
+ local: z.boolean().default(false).describe('Show local persona override (if any)'),
116
+ short: z.boolean().default(false).describe('Print only the active persona name'),
117
+ }),
118
+ async run(c) {
119
+ await requireBrainjarDir()
120
+
121
+ if (c.options.short) {
122
+ if (c.args.name) return c.args.name
123
+ const global = await readState()
124
+ const local = await readLocalState()
125
+ const env = readEnvState()
126
+ const effective = mergeState(global, local, env)
127
+ return effective.persona.value ?? 'none'
128
+ }
129
+
130
+ // If a specific name was given, show that persona directly
131
+ if (c.args.name) {
132
+ const name = normalizeSlug(c.args.name, 'persona name')
133
+ try {
134
+ const raw = await readFile(join(paths.personas, `${name}.md`), 'utf-8')
135
+ const frontmatter = parseLayerFrontmatter(raw)
136
+ const content = stripFrontmatter(raw)
137
+ const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
138
+ return { name, title, content, ...frontmatter }
139
+ } catch {
140
+ throw new IncurError({
141
+ code: 'PERSONA_NOT_FOUND',
142
+ message: `Persona "${name}" not found.`,
143
+ hint: 'Run `brainjar persona list` to see available personas.',
144
+ })
145
+ }
146
+ }
147
+
148
+ if (c.options.local) {
149
+ const local = await readLocalState()
150
+ if (!('persona' in local)) return { active: false, scope: 'local', note: 'No local persona override (cascades from global)' }
151
+ if (local.persona === null) return { active: false, scope: 'local', name: null, note: 'Explicitly unset at local scope' }
152
+ try {
153
+ const raw = await readFile(join(paths.personas, `${local.persona}.md`), 'utf-8')
154
+ const frontmatter = parseLayerFrontmatter(raw)
155
+ const content = stripFrontmatter(raw)
156
+ const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
157
+ return { active: true, scope: 'local', name: local.persona, title, content, ...frontmatter }
158
+ } catch {
159
+ return { active: false, scope: 'local', name: local.persona, error: 'File not found' }
160
+ }
161
+ }
162
+
163
+ const global = await readState()
164
+ const local = await readLocalState()
165
+ const env = readEnvState()
166
+ const effective = mergeState(global, local, env)
167
+ if (!effective.persona.value) return { active: false }
168
+ try {
169
+ const raw = await readFile(join(paths.personas, `${effective.persona.value}.md`), 'utf-8')
170
+ const frontmatter = parseLayerFrontmatter(raw)
171
+ const content = stripFrontmatter(raw)
172
+ const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
173
+ return { active: true, name: effective.persona.value, scope: effective.persona.scope, title, content, ...frontmatter }
174
+ } catch {
175
+ return { active: false, name: effective.persona.value, error: 'File not found' }
176
+ }
177
+ },
178
+ })
179
+ .command('use', {
180
+ description: 'Activate a persona',
181
+ args: z.object({
182
+ name: z.string().describe('Persona name (filename without .md in ~/.brainjar/personas/)'),
183
+ }),
184
+ options: z.object({
185
+ local: z.boolean().default(false).describe('Write to local .claude/CLAUDE.md instead of global'),
186
+ }),
187
+ async run(c) {
188
+ await requireBrainjarDir()
189
+ const name = normalizeSlug(c.args.name, 'persona name')
190
+ const source = join(paths.personas, `${name}.md`)
191
+ let raw: string
192
+ try {
193
+ raw = await readFile(source, 'utf-8')
194
+ } catch {
195
+ throw new IncurError({
196
+ code: 'PERSONA_NOT_FOUND',
197
+ message: `Persona "${name}" not found.`,
198
+ hint: 'Run `brainjar persona list` to see available personas.',
199
+ })
200
+ }
201
+
202
+ const frontmatter = parseLayerFrontmatter(raw)
203
+
204
+ if (c.options.local) {
205
+ await withLocalStateLock(async () => {
206
+ const local = await readLocalState()
207
+ local.persona = name
208
+ if (frontmatter.rules.length > 0) {
209
+ local.rules = { ...local.rules, add: frontmatter.rules }
210
+ }
211
+ await writeLocalState(local)
212
+ await sync({ local: true })
213
+ })
214
+ } else {
215
+ await withStateLock(async () => {
216
+ const state = await readState()
217
+ state.persona = name
218
+ if (frontmatter.rules.length > 0) state.rules = frontmatter.rules
219
+ await writeState(state)
220
+ await sync()
221
+ })
222
+ }
223
+
224
+ const result: Record<string, unknown> = { activated: name, local: c.options.local }
225
+ if (frontmatter.rules.length > 0) result.rules = frontmatter.rules
226
+ return result
227
+ },
228
+ })
229
+ .command('drop', {
230
+ description: 'Deactivate the current persona',
231
+ options: z.object({
232
+ local: z.boolean().default(false).describe('Remove local persona override or deactivate global persona'),
233
+ }),
234
+ async run(c) {
235
+ await requireBrainjarDir()
236
+ if (c.options.local) {
237
+ await withLocalStateLock(async () => {
238
+ const local = await readLocalState()
239
+ delete local.persona
240
+ await writeLocalState(local)
241
+ await sync({ local: true })
242
+ })
243
+ } else {
244
+ await withStateLock(async () => {
245
+ const state = await readState()
246
+ if (!state.persona) {
247
+ throw new IncurError({
248
+ code: 'NO_ACTIVE_PERSONA',
249
+ message: 'No active persona to deactivate.',
250
+ })
251
+ }
252
+ state.persona = null
253
+ await writeState(state)
254
+ await sync()
255
+ })
256
+ }
257
+ return { deactivated: true, local: c.options.local }
258
+ },
259
+ })
@@ -0,0 +1,46 @@
1
+ import { Cli, z } from 'incur'
2
+ import { readFile, rm, copyFile, writeFile } from 'node:fs/promises'
3
+ import { getBackendConfig, type Backend } from '../paths.js'
4
+ import { MARKER_START, MARKER_END } from '../sync.js'
5
+
6
+ export const reset = Cli.create('reset', {
7
+ description: 'Remove brainjar-managed config from agent backend and restore backup',
8
+ options: z.object({
9
+ backend: z.enum(['claude', 'codex']).default('claude').describe('Agent backend to reset'),
10
+ }),
11
+ async run(c) {
12
+ const backend = c.options.backend as Backend
13
+ const config = getBackendConfig(backend)
14
+ let removed = false
15
+ let restored = false
16
+
17
+ try {
18
+ const content = await readFile(config.configFile, 'utf-8')
19
+ const startIdx = content.indexOf(MARKER_START)
20
+ const endIdx = content.indexOf(MARKER_END)
21
+
22
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
23
+ // Remove the brainjar section, preserve user content
24
+ const before = content.slice(0, startIdx).trimEnd()
25
+ const after = content.slice(endIdx + MARKER_END.length).trimStart()
26
+ const remaining = [before, after].filter(Boolean).join('\n\n')
27
+
28
+ if (remaining.trim()) {
29
+ await writeFile(config.configFile, remaining + '\n')
30
+ } else {
31
+ // Nothing left — try to restore backup or remove file
32
+ try {
33
+ await copyFile(config.backupFile, config.configFile)
34
+ await rm(config.backupFile, { force: true })
35
+ restored = true
36
+ } catch {
37
+ await rm(config.configFile, { force: true })
38
+ }
39
+ }
40
+ removed = true
41
+ }
42
+ } catch {}
43
+
44
+ return { backend, removed, restored }
45
+ },
46
+ })
@@ -0,0 +1,269 @@
1
+ import { Cli, z, Errors } from 'incur'
2
+
3
+ const { IncurError } = Errors
4
+ import { access, readFile, readdir, stat, writeFile, mkdir } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { paths } from '../paths.js'
7
+ import { readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, requireBrainjarDir, normalizeSlug, listAvailableRules } from '../state.js'
8
+ import { sync } from '../sync.js'
9
+
10
+ export const rules = Cli.create('rules', {
11
+ description: 'Manage rules — behavioral constraints for the agent',
12
+ })
13
+ .command('create', {
14
+ description: 'Create a new rule',
15
+ args: z.object({
16
+ name: z.string().describe('Rule name (will be used as filename)'),
17
+ }),
18
+ options: z.object({
19
+ description: z.string().optional().describe('One-line description of the rule'),
20
+ pack: z.boolean().default(false).describe('Create as a rule pack (directory of .md files)'),
21
+ }),
22
+ async run(c) {
23
+ await requireBrainjarDir()
24
+ const name = normalizeSlug(c.args.name, 'rule name')
25
+
26
+ if (c.options.pack) {
27
+ const dirPath = join(paths.rules, name)
28
+ try {
29
+ await access(dirPath)
30
+ throw new IncurError({
31
+ code: 'RULE_EXISTS',
32
+ message: `Rule "${name}" already exists.`,
33
+ hint: 'Choose a different name or edit the existing files.',
34
+ })
35
+ } catch (e) {
36
+ if (e instanceof IncurError) throw e
37
+ }
38
+
39
+ await mkdir(dirPath, { recursive: true })
40
+
41
+ const scaffold = [
42
+ `# ${name}`,
43
+ '',
44
+ c.options.description ?? 'Describe what this rule enforces and why.',
45
+ '',
46
+ '## Constraints',
47
+ '- ',
48
+ '',
49
+ ].join('\n')
50
+
51
+ await writeFile(join(dirPath, `${name}.md`), scaffold)
52
+
53
+ if (c.agent || c.formatExplicit) {
54
+ return { created: dirPath, name, pack: true, template: scaffold }
55
+ }
56
+
57
+ return {
58
+ created: dirPath,
59
+ name,
60
+ pack: true,
61
+ template: `\n${scaffold}`,
62
+ next: `Edit ${join(dirPath, `${name}.md`)} to define your rule, then run \`brainjar rules add ${name}\` to activate.`,
63
+ }
64
+ }
65
+
66
+ const dest = join(paths.rules, `${name}.md`)
67
+ try {
68
+ await access(dest)
69
+ throw new IncurError({
70
+ code: 'RULE_EXISTS',
71
+ message: `Rule "${name}" already exists.`,
72
+ hint: 'Choose a different name or edit the existing file.',
73
+ })
74
+ } catch (e) {
75
+ if (e instanceof IncurError) throw e
76
+ }
77
+
78
+ const scaffold = [
79
+ `# ${name}`,
80
+ '',
81
+ c.options.description ?? 'Describe what this rule enforces and why.',
82
+ '',
83
+ '## Constraints',
84
+ '- ',
85
+ '',
86
+ ].join('\n')
87
+
88
+ await writeFile(dest, scaffold)
89
+
90
+ if (c.agent || c.formatExplicit) {
91
+ return { created: dest, name, template: scaffold }
92
+ }
93
+
94
+ return {
95
+ created: dest,
96
+ name,
97
+ template: `\n${scaffold}`,
98
+ next: `Edit ${dest} to define your rule, then run \`brainjar rules add ${name}\` to activate.`,
99
+ }
100
+ },
101
+ })
102
+ .command('list', {
103
+ description: 'List available and active rules',
104
+ options: z.object({
105
+ local: z.boolean().default(false).describe('Show local rules delta only'),
106
+ }),
107
+ async run(c) {
108
+ await requireBrainjarDir()
109
+
110
+ if (c.options.local) {
111
+ const local = await readLocalState()
112
+ const available = await listAvailableRules()
113
+ return {
114
+ add: local.rules?.add ?? [],
115
+ remove: local.rules?.remove ?? [],
116
+ available,
117
+ scope: 'local',
118
+ }
119
+ }
120
+
121
+ const [global, local, available] = await Promise.all([readState(), readLocalState(), listAvailableRules()])
122
+ const env = readEnvState()
123
+ const effective = mergeState(global, local, env)
124
+ const active = effective.rules.filter(r => !r.scope.startsWith('-')).map(r => r.value)
125
+ return { active, available, rules: effective.rules }
126
+ },
127
+ })
128
+ .command('show', {
129
+ description: 'Show the content of a rule by name',
130
+ args: z.object({
131
+ name: z.string().describe('Rule name to show'),
132
+ }),
133
+ async run(c) {
134
+ await requireBrainjarDir()
135
+ const name = normalizeSlug(c.args.name, 'rule name')
136
+ const dirPath = join(paths.rules, name)
137
+ const filePath = join(paths.rules, `${name}.md`)
138
+
139
+ // Try directory of .md files first
140
+ try {
141
+ const s = await stat(dirPath)
142
+ if (s.isDirectory()) {
143
+ const files = await readdir(dirPath)
144
+ const mdFiles = files.filter(f => f.endsWith('.md')).sort()
145
+ const sections: string[] = []
146
+ for (const file of mdFiles) {
147
+ const content = await readFile(join(dirPath, file), 'utf-8')
148
+ sections.push(content.trim())
149
+ }
150
+ return { name, content: sections.join('\n\n') }
151
+ }
152
+ } catch {}
153
+
154
+ // Try single .md file
155
+ try {
156
+ const content = await readFile(filePath, 'utf-8')
157
+ return { name, content: content.trim() }
158
+ } catch {}
159
+
160
+ throw new IncurError({
161
+ code: 'RULE_NOT_FOUND',
162
+ message: `Rule "${name}" not found.`,
163
+ hint: 'Run `brainjar rules list` to see available rules.',
164
+ })
165
+ },
166
+ })
167
+ .command('add', {
168
+ description: 'Activate a rule or rule pack',
169
+ args: z.object({
170
+ name: z.string().describe('Rule name or directory name in ~/.brainjar/rules/'),
171
+ }),
172
+ options: z.object({
173
+ local: z.boolean().default(false).describe('Add rule as a local override (delta, not snapshot)'),
174
+ }),
175
+ async run(c) {
176
+ await requireBrainjarDir()
177
+ const name = normalizeSlug(c.args.name, 'rule name')
178
+ // Verify it exists as a directory or .md file
179
+ const dirPath = join(paths.rules, name)
180
+ const filePath = join(paths.rules, `${name}.md`)
181
+ let found = false
182
+
183
+ try {
184
+ const s = await stat(dirPath)
185
+ if (s.isDirectory()) found = true
186
+ } catch {}
187
+
188
+ if (!found) {
189
+ try {
190
+ await readFile(filePath, 'utf-8')
191
+ found = true
192
+ } catch {}
193
+ }
194
+
195
+ if (!found) {
196
+ throw new IncurError({
197
+ code: 'RULE_NOT_FOUND',
198
+ message: `Rule "${name}" not found in ${paths.rules}`,
199
+ hint: 'Place .md files or directories in ~/.brainjar/rules/',
200
+ })
201
+ }
202
+
203
+ if (c.options.local) {
204
+ await withLocalStateLock(async () => {
205
+ const local = await readLocalState()
206
+ const adds = local.rules?.add ?? []
207
+ if (!adds.includes(name)) adds.push(name)
208
+ // Also remove from local removes if present
209
+ const removes = (local.rules?.remove ?? []).filter(r => r !== name)
210
+ local.rules = { add: adds, ...(removes.length ? { remove: removes } : {}) }
211
+ await writeLocalState(local)
212
+ await sync({ local: true })
213
+ })
214
+ } else {
215
+ await withStateLock(async () => {
216
+ const state = await readState()
217
+ if (!state.rules.includes(name)) {
218
+ state.rules.push(name)
219
+ await writeState(state)
220
+ }
221
+ await sync()
222
+ })
223
+ }
224
+
225
+ return { activated: name, local: c.options.local }
226
+ },
227
+ })
228
+ .command('remove', {
229
+ description: 'Deactivate a rule',
230
+ args: z.object({
231
+ name: z.string().describe('Rule name to remove'),
232
+ }),
233
+ options: z.object({
234
+ local: z.boolean().default(false).describe('Remove rule as a local override (delta, not snapshot)'),
235
+ }),
236
+ async run(c) {
237
+ await requireBrainjarDir()
238
+ const name = normalizeSlug(c.args.name, 'rule name')
239
+
240
+ if (c.options.local) {
241
+ await withLocalStateLock(async () => {
242
+ const local = await readLocalState()
243
+ const removes = local.rules?.remove ?? []
244
+ if (!removes.includes(name)) removes.push(name)
245
+ // Also remove from local adds if present
246
+ const adds = (local.rules?.add ?? []).filter(r => r !== name)
247
+ local.rules = { ...(adds.length ? { add: adds } : {}), remove: removes }
248
+ await writeLocalState(local)
249
+ await sync({ local: true })
250
+ })
251
+ } else {
252
+ await withStateLock(async () => {
253
+ const state = await readState()
254
+ if (!state.rules.includes(name)) {
255
+ throw new IncurError({
256
+ code: 'RULE_NOT_ACTIVE',
257
+ message: `Rule "${name}" is not active.`,
258
+ hint: 'Run `brainjar rules list` to see active rules.',
259
+ })
260
+ }
261
+ state.rules = state.rules.filter(r => r !== name)
262
+ await writeState(state)
263
+ await sync()
264
+ })
265
+ }
266
+
267
+ return { removed: name, local: c.options.local }
268
+ },
269
+ })
@@ -0,0 +1,119 @@
1
+ import { Cli, z, Errors } from 'incur'
2
+
3
+ const { IncurError } = Errors
4
+ import { spawn } from 'node:child_process'
5
+ import { access } from 'node:fs/promises'
6
+ import { requireBrainjarDir } from '../state.js'
7
+ import { sync } from '../sync.js'
8
+ import { getLocalDir } from '../paths.js'
9
+ import { readBrain } from './brain.js'
10
+
11
+ export const shell = Cli.create('shell', {
12
+ description: 'Spawn a subshell with BRAINJAR_* env vars set',
13
+ options: z.object({
14
+ brain: z.string().optional().describe('Brain name — sets soul, persona, and rules from brain file'),
15
+ soul: z.string().optional().describe('Soul override for this session'),
16
+ persona: z.string().optional().describe('Persona override for this session'),
17
+ identity: z.string().optional().describe('Identity override for this session'),
18
+ 'rules-add': z.string().optional().describe('Comma-separated rules to add'),
19
+ 'rules-remove': z.string().optional().describe('Comma-separated rules to remove'),
20
+ }),
21
+ async run(c) {
22
+ await requireBrainjarDir()
23
+
24
+ const individualFlags = c.options.soul || c.options.persona || c.options.identity
25
+ || c.options['rules-add'] || c.options['rules-remove']
26
+
27
+ if (c.options.brain && individualFlags) {
28
+ throw new IncurError({
29
+ code: 'MUTUALLY_EXCLUSIVE',
30
+ message: '--brain is mutually exclusive with --soul, --persona, --identity, --rules-add, --rules-remove.',
31
+ hint: 'Use --brain alone or individual flags, not both.',
32
+ })
33
+ }
34
+
35
+ const envOverrides: Record<string, string> = {}
36
+
37
+ if (c.options.brain) {
38
+ const config = await readBrain(c.options.brain)
39
+ envOverrides.BRAINJAR_SOUL = config.soul
40
+ envOverrides.BRAINJAR_PERSONA = config.persona
41
+ if (config.rules.length > 0) {
42
+ envOverrides.BRAINJAR_RULES_ADD = config.rules.join(',')
43
+ }
44
+ } else {
45
+ if (c.options.soul) envOverrides.BRAINJAR_SOUL = c.options.soul
46
+ if (c.options.persona) envOverrides.BRAINJAR_PERSONA = c.options.persona
47
+ if (c.options.identity) envOverrides.BRAINJAR_IDENTITY = c.options.identity
48
+ if (c.options['rules-add']) envOverrides.BRAINJAR_RULES_ADD = c.options['rules-add']
49
+ if (c.options['rules-remove']) envOverrides.BRAINJAR_RULES_REMOVE = c.options['rules-remove']
50
+ }
51
+
52
+ if (Object.keys(envOverrides).length === 0) {
53
+ throw new IncurError({
54
+ code: 'NO_OVERRIDES',
55
+ message: 'No overrides specified.',
56
+ hint: 'Use --brain, --soul, --persona, --identity, --rules-add, or --rules-remove.',
57
+ })
58
+ }
59
+
60
+ // Sync with env overrides passed explicitly (no process.env mutation)
61
+ const hasLocal = await access(getLocalDir()).then(() => true, () => false)
62
+ await sync({ envOverrides })
63
+ if (hasLocal) await sync({ local: true, envOverrides })
64
+
65
+ // Print active config banner
66
+ const labels: string[] = []
67
+ if (envOverrides.BRAINJAR_SOUL) labels.push(`soul: ${envOverrides.BRAINJAR_SOUL}`)
68
+ if (envOverrides.BRAINJAR_PERSONA) labels.push(`persona: ${envOverrides.BRAINJAR_PERSONA}`)
69
+ if (envOverrides.BRAINJAR_IDENTITY) labels.push(`identity: ${envOverrides.BRAINJAR_IDENTITY}`)
70
+ if (envOverrides.BRAINJAR_RULES_ADD) labels.push(`+rules: ${envOverrides.BRAINJAR_RULES_ADD}`)
71
+ if (envOverrides.BRAINJAR_RULES_REMOVE) labels.push(`-rules: ${envOverrides.BRAINJAR_RULES_REMOVE}`)
72
+
73
+ if (!c.agent) {
74
+ const banner = `[brainjar] ${labels.join(' | ')}`
75
+ process.stderr.write(`${banner}\n`)
76
+ process.stderr.write(`${'─'.repeat(banner.length)}\n`)
77
+ }
78
+
79
+ // Spawn subshell with overrides
80
+ const userShell = process.env.SHELL || '/bin/sh'
81
+ const child = spawn(userShell, [], {
82
+ stdio: 'inherit',
83
+ env: { ...process.env, ...envOverrides },
84
+ })
85
+
86
+ return new Promise((resolve, reject) => {
87
+ child.on('exit', async (code) => {
88
+ // Re-sync without env overrides to restore config
89
+ let syncWarning: string | undefined
90
+ try {
91
+ await sync()
92
+ if (hasLocal) await sync({ local: true })
93
+ } catch (err) {
94
+ syncWarning = `Re-sync on exit failed: ${(err as Error).message}`
95
+ }
96
+
97
+ const result = {
98
+ shell: userShell,
99
+ env: envOverrides,
100
+ exitCode: code ?? 0,
101
+ ...(syncWarning ? { warning: syncWarning } : {}),
102
+ }
103
+
104
+ if (c.agent || c.formatExplicit) {
105
+ resolve(result)
106
+ } else {
107
+ if (syncWarning) process.stderr.write(`[brainjar] ${syncWarning}\n`)
108
+ resolve(undefined)
109
+ }
110
+ })
111
+ child.on('error', (err) => {
112
+ reject(new IncurError({
113
+ code: 'SHELL_ERROR',
114
+ message: `Failed to spawn shell: ${err.message}`,
115
+ }))
116
+ })
117
+ })
118
+ },
119
+ })