@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.
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/package.json +55 -0
- package/src/cli.ts +30 -0
- package/src/commands/brain.ts +256 -0
- package/src/commands/compose.ts +156 -0
- package/src/commands/identity.ts +276 -0
- package/src/commands/init.ts +78 -0
- package/src/commands/persona.ts +259 -0
- package/src/commands/reset.ts +46 -0
- package/src/commands/rules.ts +269 -0
- package/src/commands/shell.ts +119 -0
- package/src/commands/soul.ts +207 -0
- package/src/commands/status.ts +131 -0
- package/src/engines/bitwarden.ts +105 -0
- package/src/engines/index.ts +12 -0
- package/src/engines/types.ts +12 -0
- package/src/paths.ts +48 -0
- package/src/seeds/personas/engineer.md +26 -0
- package/src/seeds/personas/planner.md +24 -0
- package/src/seeds/personas/reviewer.md +27 -0
- package/src/seeds/rules/default/boundaries.md +25 -0
- package/src/seeds/rules/default/context-recovery.md +17 -0
- package/src/seeds/rules/default/task-completion.md +31 -0
- package/src/seeds/rules/git-discipline.md +22 -0
- package/src/seeds/rules/security.md +26 -0
- package/src/seeds/souls/craftsman.md +24 -0
- package/src/seeds/templates/persona.md +19 -0
- package/src/seeds/templates/rule.md +11 -0
- package/src/seeds/templates/soul.md +20 -0
- package/src/seeds.ts +116 -0
- package/src/state.ts +414 -0
- package/src/sync.ts +190 -0
|
@@ -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
|
+
})
|