@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,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
|
+
})
|