@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,156 @@
1
+ import { Cli, z, Errors } from 'incur'
2
+
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'
19
+
20
+ export const compose = Cli.create('compose', {
21
+ description: 'Assemble a full subagent prompt from a brain or ad-hoc persona',
22
+ args: z.object({
23
+ brain: z.string().optional().describe('Brain name (primary path — resolves soul + persona + rules from brain file)'),
24
+ }),
25
+ options: z.object({
26
+ persona: z.string().optional().describe('Ad-hoc persona name (fallback when no brain is saved)'),
27
+ task: z.string().optional().describe('Task description to append to the prompt'),
28
+ }),
29
+ async run(c) {
30
+ await requireBrainjarDir()
31
+
32
+ const brainName = c.args.brain
33
+ const personaFlag = c.options.persona
34
+
35
+ // Mutual exclusivity
36
+ if (brainName && personaFlag) {
37
+ throw new IncurError({
38
+ code: 'MUTUALLY_EXCLUSIVE',
39
+ message: 'Cannot specify both a brain name and --persona.',
40
+ hint: 'Use `brainjar compose <brain>` or `brainjar compose --persona <name>`, not both.',
41
+ })
42
+ }
43
+
44
+ if (!brainName && !personaFlag) {
45
+ throw new IncurError({
46
+ code: 'MISSING_ARG',
47
+ message: 'Provide a brain name or --persona.',
48
+ hint: 'Usage: `brainjar compose <brain>` or `brainjar compose --persona <name>`.',
49
+ })
50
+ }
51
+
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
110
+
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
+ }
142
+
143
+ const prompt = sections.join('\n\n')
144
+
145
+ const result: Record<string, unknown> = {
146
+ persona: personaName,
147
+ rules: rulesList,
148
+ prompt,
149
+ }
150
+ if (brainName) result.brain = brainName
151
+ if (soulName) result.soul = soulName
152
+ if (warnings.length) result.warnings = warnings
153
+
154
+ return result
155
+ },
156
+ })
@@ -0,0 +1,276 @@
1
+ import { Cli, z, Errors } from 'incur'
2
+ import { stringify as stringifyYaml } from 'yaml'
3
+
4
+ const { IncurError } = Errors
5
+ import { readdir, readFile, writeFile, mkdir, rm } from 'node:fs/promises'
6
+ import { join, basename } from 'node:path'
7
+ import { paths } from '../paths.js'
8
+ import { readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, loadIdentity, parseIdentity, requireBrainjarDir, normalizeSlug } from '../state.js'
9
+ import { getEngine } from '../engines/index.js'
10
+ import { sync } from '../sync.js'
11
+
12
+ function redactSession(status: Record<string, unknown>) {
13
+ const { session: _, ...safe } = status as any
14
+ return safe
15
+ }
16
+
17
+ async function requireActiveIdentity() {
18
+ await requireBrainjarDir()
19
+ const state = await readState()
20
+ if (!state.identity) {
21
+ throw new IncurError({
22
+ code: 'NO_ACTIVE_IDENTITY',
23
+ message: 'No active identity.',
24
+ hint: 'Run `brainjar identity use <slug>` to activate one.',
25
+ })
26
+ }
27
+ return loadIdentity(state.identity)
28
+ }
29
+
30
+ function requireEngine(engineName: string | undefined) {
31
+ if (!engineName) {
32
+ throw new IncurError({
33
+ code: 'NO_ENGINE',
34
+ message: 'Active identity has no engine configured.',
35
+ })
36
+ }
37
+ const engine = getEngine(engineName)
38
+ if (!engine) {
39
+ throw new IncurError({
40
+ code: 'UNKNOWN_ENGINE',
41
+ message: `Unknown engine: ${engineName}`,
42
+ hint: 'Supported engines: bitwarden',
43
+ })
44
+ }
45
+ return engine
46
+ }
47
+
48
+ export const identity = Cli.create('identity', {
49
+ description: 'Manage digital identity — one active at a time',
50
+ })
51
+ .command('create', {
52
+ description: 'Create a new identity',
53
+ args: z.object({
54
+ slug: z.string().describe('Identity slug (e.g. personal, work)'),
55
+ }),
56
+ options: z.object({
57
+ name: z.string().describe('Full display name'),
58
+ email: z.string().describe('Email address'),
59
+ engine: z.literal('bitwarden').default('bitwarden').describe('Credential engine'),
60
+ }),
61
+ async run(c) {
62
+ await requireBrainjarDir()
63
+ const slug = normalizeSlug(c.args.slug, 'identity slug')
64
+ await mkdir(paths.identities, { recursive: true })
65
+
66
+ const content = stringifyYaml({ name: c.options.name, email: c.options.email, engine: c.options.engine })
67
+
68
+ const filePath = join(paths.identities, `${slug}.yaml`)
69
+ await writeFile(filePath, content)
70
+
71
+ return {
72
+ created: filePath,
73
+ identity: { slug, name: c.options.name, email: c.options.email, engine: c.options.engine },
74
+ next: `Run \`brainjar identity use ${slug}\` to activate.`,
75
+ }
76
+ },
77
+ })
78
+ .command('list', {
79
+ description: 'List available identities',
80
+ async run() {
81
+ const entries = await readdir(paths.identities).catch(() => [])
82
+ const identities = []
83
+
84
+ for (const file of entries.filter(f => f.endsWith('.yaml'))) {
85
+ const slug = basename(file, '.yaml')
86
+ const content = await readFile(join(paths.identities, file), 'utf-8')
87
+ identities.push({ slug, ...parseIdentity(content) })
88
+ }
89
+
90
+ return { identities }
91
+ },
92
+ })
93
+ .command('show', {
94
+ description: 'Show the active identity',
95
+ options: z.object({
96
+ local: z.boolean().default(false).describe('Show local identity override (if any)'),
97
+ short: z.boolean().default(false).describe('Print only the active identity slug'),
98
+ }),
99
+ async run(c) {
100
+ if (c.options.short) {
101
+ const global = await readState()
102
+ const local = await readLocalState()
103
+ const env = readEnvState()
104
+ const effective = mergeState(global, local, env)
105
+ return effective.identity.value ?? 'none'
106
+ }
107
+
108
+ if (c.options.local) {
109
+ const local = await readLocalState()
110
+ if (!('identity' in local)) return { active: false, scope: 'local', note: 'No local identity override (cascades from global)' }
111
+ if (local.identity === null) return { active: false, scope: 'local', slug: null, note: 'Explicitly unset at local scope' }
112
+ try {
113
+ const content = await readFile(join(paths.identities, `${local.identity}.yaml`), 'utf-8')
114
+ return { active: true, scope: 'local', slug: local.identity, ...parseIdentity(content) }
115
+ } catch {
116
+ return { active: false, scope: 'local', slug: local.identity, error: 'File not found' }
117
+ }
118
+ }
119
+
120
+ const global = await readState()
121
+ const local = await readLocalState()
122
+ const env = readEnvState()
123
+ const effective = mergeState(global, local, env)
124
+ if (!effective.identity.value) return { active: false }
125
+ try {
126
+ const content = await readFile(join(paths.identities, `${effective.identity.value}.yaml`), 'utf-8')
127
+ return { active: true, slug: effective.identity.value, scope: effective.identity.scope, ...parseIdentity(content) }
128
+ } catch {
129
+ return { active: false, slug: effective.identity.value, error: 'File not found' }
130
+ }
131
+ },
132
+ })
133
+ .command('use', {
134
+ description: 'Activate an identity',
135
+ args: z.object({
136
+ slug: z.string().describe('Identity slug to activate'),
137
+ }),
138
+ options: z.object({
139
+ local: z.boolean().default(false).describe('Write to local .claude/CLAUDE.md instead of global'),
140
+ }),
141
+ async run(c) {
142
+ await requireBrainjarDir()
143
+ const slug = normalizeSlug(c.args.slug, 'identity slug')
144
+ const source = join(paths.identities, `${slug}.yaml`)
145
+ try {
146
+ await readFile(source, 'utf-8')
147
+ } catch {
148
+ throw new IncurError({
149
+ code: 'IDENTITY_NOT_FOUND',
150
+ message: `Identity "${slug}" not found.`,
151
+ hint: 'Run `brainjar identity list` to see available identities.',
152
+ })
153
+ }
154
+
155
+ if (c.options.local) {
156
+ await withLocalStateLock(async () => {
157
+ const local = await readLocalState()
158
+ local.identity = slug
159
+ await writeLocalState(local)
160
+ await sync({ local: true })
161
+ })
162
+ } else {
163
+ await withStateLock(async () => {
164
+ const state = await readState()
165
+ state.identity = slug
166
+ await writeState(state)
167
+ await sync()
168
+ })
169
+ }
170
+
171
+ return { activated: slug, local: c.options.local }
172
+ },
173
+ })
174
+ .command('drop', {
175
+ description: 'Deactivate the current identity',
176
+ options: z.object({
177
+ local: z.boolean().default(false).describe('Remove local identity override or deactivate global identity'),
178
+ }),
179
+ async run(c) {
180
+ await requireBrainjarDir()
181
+ if (c.options.local) {
182
+ await withLocalStateLock(async () => {
183
+ const local = await readLocalState()
184
+ delete local.identity
185
+ await writeLocalState(local)
186
+ await sync({ local: true })
187
+ })
188
+ } else {
189
+ await withStateLock(async () => {
190
+ const state = await readState()
191
+ if (!state.identity) {
192
+ throw new IncurError({
193
+ code: 'NO_ACTIVE_IDENTITY',
194
+ message: 'No active identity to deactivate.',
195
+ })
196
+ }
197
+ state.identity = null
198
+ await writeState(state)
199
+ await sync()
200
+ })
201
+ }
202
+ return { deactivated: true, local: c.options.local }
203
+ },
204
+ })
205
+ .command('unlock', {
206
+ description: 'Store the credential engine session token',
207
+ args: z.object({
208
+ session: z.string().optional().describe('Session token (reads from stdin if omitted)'),
209
+ }),
210
+ async run(c) {
211
+ let session = c.args.session
212
+ if (!session) {
213
+ if (process.stdin.isTTY) {
214
+ throw new IncurError({
215
+ code: 'NO_SESSION',
216
+ message: 'No session token provided.',
217
+ hint: 'Pipe it in: bw unlock --raw | brainjar identity unlock',
218
+ })
219
+ }
220
+ let data = ''
221
+ for await (const chunk of process.stdin) {
222
+ data += typeof chunk === 'string' ? chunk : chunk.toString('utf-8')
223
+ }
224
+ session = data.trim()
225
+ }
226
+ if (!session) {
227
+ throw new IncurError({
228
+ code: 'EMPTY_SESSION',
229
+ message: 'Session token is empty.',
230
+ })
231
+ }
232
+ await writeFile(paths.session, session, { mode: 0o600 })
233
+ return { unlocked: true, stored: paths.session }
234
+ },
235
+ })
236
+ .command('get', {
237
+ description: 'Retrieve a credential from the active identity engine',
238
+ args: z.object({
239
+ item: z.string().describe('Item name or ID to retrieve from the vault'),
240
+ }),
241
+ async run(c) {
242
+ const { engine: engineName } = await requireActiveIdentity()
243
+ const engine = requireEngine(engineName)
244
+
245
+ const status = await engine.status()
246
+ if (status.state !== 'unlocked') {
247
+ throw new IncurError({
248
+ code: 'ENGINE_LOCKED',
249
+ message: 'Credential engine is not unlocked.',
250
+ hint: 'operator_action' in status ? status.operator_action : undefined,
251
+ retryable: true,
252
+ })
253
+ }
254
+
255
+ return engine.get(c.args.item, status.session)
256
+ },
257
+ })
258
+ .command('status', {
259
+ description: 'Check if the credential engine session is active',
260
+ async run() {
261
+ const { name, email, engine: engineName } = await requireActiveIdentity()
262
+ const engine = requireEngine(engineName)
263
+ const engineStatus = await engine.status()
264
+ return { identity: { name, email, engine: engineName }, ...redactSession(engineStatus) }
265
+ },
266
+ })
267
+ .command('lock', {
268
+ description: 'Lock the credential engine session',
269
+ async run() {
270
+ const { engine: engineName } = await requireActiveIdentity()
271
+ const engine = requireEngine(engineName)
272
+ await engine.lock()
273
+ await rm(paths.session, { force: true })
274
+ return { locked: true }
275
+ },
276
+ })
@@ -0,0 +1,78 @@
1
+ import { Cli, z } from 'incur'
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { getBrainjarDir, paths, type Backend } from '../paths.js'
5
+ import { seedDefaultRule, seedDefaults, initObsidian } from '../seeds.js'
6
+ import { readState, writeState, withStateLock } from '../state.js'
7
+ import { sync } from '../sync.js'
8
+
9
+ export const init = Cli.create('init', {
10
+ description: 'Bootstrap ~/.brainjar/ directory structure',
11
+ options: z.object({
12
+ backend: z.enum(['claude', 'codex']).default('claude').describe('Agent backend to target'),
13
+ 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
+ }),
16
+ async run(c) {
17
+ const brainjarDir = getBrainjarDir()
18
+
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
+ ])
26
+
27
+ // Seed the default rule pack
28
+ await seedDefaultRule(paths.rules)
29
+
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/')
34
+ }
35
+ await writeFile(join(brainjarDir, '.gitignore'), gitignoreLines.join('\n') + '\n')
36
+
37
+ if (c.options.default) {
38
+ await seedDefaults()
39
+ }
40
+
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
+ })
52
+
53
+ const result: Record<string, unknown> = {
54
+ created: brainjarDir,
55
+ backend: c.options.backend,
56
+ directories: ['souls/', 'personas/', 'rules/', 'brains/', 'identities/'],
57
+ }
58
+
59
+ if (c.options.default) {
60
+ result.soul = 'craftsman'
61
+ result.persona = 'engineer'
62
+ result.rules = ['default', 'git-discipline', 'security']
63
+ result.personas = ['engineer', 'planner', 'reviewer']
64
+ result.next = 'Ready to go. Run `brainjar status` to see your config.'
65
+ } 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.`
74
+ }
75
+
76
+ return result
77
+ },
78
+ })