@brainjar/cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/package.json +1 -1
- package/src/api-types.ts +155 -0
- package/src/cli.ts +4 -0
- package/src/client.ts +157 -0
- package/src/commands/brain.ts +99 -113
- package/src/commands/compose.ts +17 -116
- package/src/commands/init.ts +65 -40
- package/src/commands/migrate.ts +61 -0
- package/src/commands/pack.ts +1 -5
- package/src/commands/persona.ts +97 -145
- package/src/commands/rules.ts +71 -174
- package/src/commands/server.ts +212 -0
- package/src/commands/shell.ts +53 -46
- package/src/commands/soul.ts +75 -110
- package/src/commands/status.ts +36 -41
- package/src/commands/sync.ts +0 -2
- package/src/config.ts +125 -0
- package/src/daemon.ts +404 -0
- package/src/errors.ts +172 -0
- package/src/migrate.ts +247 -0
- package/src/pack.ts +149 -428
- package/src/paths.ts +1 -6
- package/src/seeds.ts +62 -103
- package/src/state.ts +12 -368
- package/src/sync.ts +60 -85
- package/src/version-check.ts +137 -0
- package/src/brain.ts +0 -69
- package/src/hooks.test.ts +0 -132
- package/src/pack.test.ts +0 -472
- package/src/seeds/templates/persona.md +0 -19
- package/src/seeds/templates/rule.md +0 -11
- package/src/seeds/templates/soul.md +0 -20
- /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
- /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
- /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
package/src/commands/compose.ts
CHANGED
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
import { Cli, z, Errors } from 'incur'
|
|
2
2
|
|
|
3
3
|
const { IncurError } = Errors
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
readState,
|
|
9
|
-
readLocalState,
|
|
10
|
-
readEnvState,
|
|
11
|
-
mergeState,
|
|
12
|
-
requireBrainjarDir,
|
|
13
|
-
normalizeSlug,
|
|
14
|
-
parseLayerFrontmatter,
|
|
15
|
-
stripFrontmatter,
|
|
16
|
-
resolveRuleContent,
|
|
17
|
-
} from '../state.js'
|
|
18
|
-
import { readBrain } from '../brain.js'
|
|
4
|
+
import { ErrorCode, createError } from '../errors.js'
|
|
5
|
+
import { getApi } from '../client.js'
|
|
6
|
+
import type { ApiComposeResult } from '../api-types.js'
|
|
19
7
|
|
|
20
8
|
export const compose = Cli.create('compose', {
|
|
21
9
|
description: 'Assemble a full subagent prompt from a brain or ad-hoc persona',
|
|
@@ -27,129 +15,42 @@ export const compose = Cli.create('compose', {
|
|
|
27
15
|
task: z.string().optional().describe('Task description to append to the prompt'),
|
|
28
16
|
}),
|
|
29
17
|
async run(c) {
|
|
30
|
-
await requireBrainjarDir()
|
|
31
|
-
|
|
32
18
|
const brainName = c.args.brain
|
|
33
19
|
const personaFlag = c.options.persona
|
|
34
20
|
|
|
35
21
|
// Mutual exclusivity
|
|
36
22
|
if (brainName && personaFlag) {
|
|
37
|
-
throw
|
|
38
|
-
code: 'MUTUALLY_EXCLUSIVE',
|
|
23
|
+
throw createError(ErrorCode.MUTUALLY_EXCLUSIVE, {
|
|
39
24
|
message: 'Cannot specify both a brain name and --persona.',
|
|
40
25
|
hint: 'Use `brainjar compose <brain>` or `brainjar compose --persona <name>`, not both.',
|
|
41
26
|
})
|
|
42
27
|
}
|
|
43
28
|
|
|
44
29
|
if (!brainName && !personaFlag) {
|
|
45
|
-
throw
|
|
46
|
-
code: 'MISSING_ARG',
|
|
30
|
+
throw createError(ErrorCode.MISSING_ARG, {
|
|
47
31
|
message: 'Provide a brain name or --persona.',
|
|
48
32
|
hint: 'Usage: `brainjar compose <brain>` or `brainjar compose --persona <name>`.',
|
|
49
33
|
})
|
|
50
34
|
}
|
|
51
35
|
|
|
52
|
-
const
|
|
53
|
-
const warnings: string[] = []
|
|
54
|
-
let soulName: string | null = null
|
|
55
|
-
let personaName: string
|
|
56
|
-
let rulesList: string[]
|
|
57
|
-
|
|
58
|
-
if (brainName) {
|
|
59
|
-
// === Primary path: brain-driven ===
|
|
60
|
-
const config = await readBrain(brainName)
|
|
61
|
-
soulName = config.soul
|
|
62
|
-
personaName = config.persona
|
|
63
|
-
rulesList = config.rules
|
|
64
|
-
|
|
65
|
-
// Soul — from brain
|
|
66
|
-
try {
|
|
67
|
-
const raw = await readFile(join(paths.souls, `${soulName}.md`), 'utf-8')
|
|
68
|
-
sections.push(stripFrontmatter(raw))
|
|
69
|
-
} catch {
|
|
70
|
-
warnings.push(`Soul "${soulName}" not found — skipped`)
|
|
71
|
-
soulName = null
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Persona — from brain
|
|
75
|
-
let personaRaw: string
|
|
76
|
-
try {
|
|
77
|
-
personaRaw = await readFile(join(paths.personas, `${personaName}.md`), 'utf-8')
|
|
78
|
-
} catch {
|
|
79
|
-
throw new IncurError({
|
|
80
|
-
code: 'PERSONA_NOT_FOUND',
|
|
81
|
-
message: `Brain "${brainName}" references persona "${personaName}" which does not exist.`,
|
|
82
|
-
hint: 'Create the persona first or update the brain file.',
|
|
83
|
-
})
|
|
84
|
-
}
|
|
85
|
-
sections.push(stripFrontmatter(personaRaw))
|
|
86
|
-
|
|
87
|
-
// Rules — from brain (overrides persona frontmatter)
|
|
88
|
-
for (const rule of rulesList) {
|
|
89
|
-
const resolved = await resolveRuleContent(rule, warnings)
|
|
90
|
-
sections.push(...resolved)
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
// === Ad-hoc path: --persona flag ===
|
|
94
|
-
const personaSlug = normalizeSlug(personaFlag!, 'persona name')
|
|
95
|
-
personaName = personaSlug
|
|
96
|
-
|
|
97
|
-
let personaRaw: string
|
|
98
|
-
try {
|
|
99
|
-
personaRaw = await readFile(join(paths.personas, `${personaSlug}.md`), 'utf-8')
|
|
100
|
-
} catch {
|
|
101
|
-
throw new IncurError({
|
|
102
|
-
code: 'PERSONA_NOT_FOUND',
|
|
103
|
-
message: `Persona "${personaSlug}" not found.`,
|
|
104
|
-
hint: 'Run `brainjar persona list` to see available personas.',
|
|
105
|
-
})
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const frontmatter = parseLayerFrontmatter(personaRaw)
|
|
109
|
-
rulesList = frontmatter.rules
|
|
36
|
+
const api = await getApi()
|
|
110
37
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const effective = mergeState(globalState, localState, envState)
|
|
116
|
-
|
|
117
|
-
if (effective.soul.value) {
|
|
118
|
-
soulName = effective.soul.value
|
|
119
|
-
try {
|
|
120
|
-
const raw = await readFile(join(paths.souls, `${soulName}.md`), 'utf-8')
|
|
121
|
-
sections.push(stripFrontmatter(raw))
|
|
122
|
-
} catch {
|
|
123
|
-
warnings.push(`Soul "${soulName}" not found — skipped`)
|
|
124
|
-
soulName = null
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Persona content
|
|
129
|
-
sections.push(stripFrontmatter(personaRaw))
|
|
130
|
-
|
|
131
|
-
// Rules — from persona frontmatter
|
|
132
|
-
for (const rule of rulesList) {
|
|
133
|
-
const resolved = await resolveRuleContent(rule, warnings)
|
|
134
|
-
sections.push(...resolved)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Task section
|
|
139
|
-
if (c.options.task) {
|
|
140
|
-
sections.push(`# Task\n\n${c.options.task}`)
|
|
141
|
-
}
|
|
38
|
+
const body: Record<string, unknown> = {}
|
|
39
|
+
if (brainName) body.brain = brainName
|
|
40
|
+
if (personaFlag) body.persona = personaFlag
|
|
41
|
+
if (c.options.task) body.task = c.options.task
|
|
142
42
|
|
|
143
|
-
const
|
|
43
|
+
const composed = await api.post<ApiComposeResult>('/api/v1/compose', body)
|
|
144
44
|
|
|
145
45
|
const result: Record<string, unknown> = {
|
|
146
|
-
persona:
|
|
147
|
-
rules:
|
|
148
|
-
prompt,
|
|
46
|
+
persona: composed.persona,
|
|
47
|
+
rules: composed.rules,
|
|
48
|
+
prompt: composed.prompt,
|
|
149
49
|
}
|
|
150
50
|
if (brainName) result.brain = brainName
|
|
151
|
-
if (
|
|
152
|
-
if (
|
|
51
|
+
if (composed.soul) result.soul = composed.soul
|
|
52
|
+
if (composed.token_estimate) result.token_estimate = composed.token_estimate
|
|
53
|
+
if (composed.warnings?.length) result.warnings = composed.warnings
|
|
153
54
|
|
|
154
55
|
return result
|
|
155
56
|
},
|
package/src/commands/init.ts
CHANGED
|
@@ -1,77 +1,102 @@
|
|
|
1
1
|
import { Cli, z } from 'incur'
|
|
2
|
-
import { mkdir,
|
|
3
|
-
import { join } from 'node:path'
|
|
2
|
+
import { mkdir, access } from 'node:fs/promises'
|
|
4
3
|
import { getBrainjarDir, paths, type Backend } from '../paths.js'
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { buildSeedBundle } from '../seeds.js'
|
|
5
|
+
import { putState } from '../state.js'
|
|
7
6
|
import { sync } from '../sync.js'
|
|
7
|
+
import { getApi } from '../client.js'
|
|
8
|
+
import { readConfig, writeConfig, type Config } from '../config.js'
|
|
9
|
+
import { ensureBinary, upgradeServer } from '../daemon.js'
|
|
10
|
+
import type { ApiImportResult } from '../api-types.js'
|
|
8
11
|
|
|
9
12
|
export const init = Cli.create('init', {
|
|
10
|
-
description: '
|
|
13
|
+
description: 'Initialize brainjar: config, server, and optional seed content',
|
|
11
14
|
options: z.object({
|
|
12
15
|
backend: z.enum(['claude', 'codex']).default('claude').describe('Agent backend to target'),
|
|
13
16
|
default: z.boolean().default(false).describe('Seed starter soul, personas, and rules'),
|
|
14
|
-
obsidian: z.boolean().default(false).describe('Set up ~/.brainjar/ as an Obsidian vault'),
|
|
15
17
|
}),
|
|
16
18
|
async run(c) {
|
|
17
19
|
const brainjarDir = getBrainjarDir()
|
|
20
|
+
const binDir = `${brainjarDir}/bin`
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
mkdir(paths.rules, { recursive: true }),
|
|
23
|
-
mkdir(paths.brains, { recursive: true }),
|
|
24
|
-
])
|
|
22
|
+
// 1. Create directories
|
|
23
|
+
await mkdir(brainjarDir, { recursive: true })
|
|
24
|
+
await mkdir(binDir, { recursive: true })
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
|
|
26
|
+
// 2. Write config.yaml if missing
|
|
27
|
+
let configExists = false
|
|
28
|
+
try {
|
|
29
|
+
await access(paths.config)
|
|
30
|
+
configExists = true
|
|
31
|
+
} catch {}
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
if (!configExists) {
|
|
34
|
+
const config: Config = {
|
|
35
|
+
server: {
|
|
36
|
+
url: 'http://localhost:7742',
|
|
37
|
+
mode: 'local',
|
|
38
|
+
bin: `${brainjarDir}/bin/brainjar-server`,
|
|
39
|
+
pid_file: `${brainjarDir}/server.pid`,
|
|
40
|
+
log_file: `${brainjarDir}/server.log`,
|
|
41
|
+
},
|
|
42
|
+
workspace: 'default',
|
|
43
|
+
backend: c.options.backend as Backend,
|
|
44
|
+
}
|
|
45
|
+
await writeConfig(config)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Ensure server binary exists and is up to date
|
|
49
|
+
await ensureBinary()
|
|
50
|
+
const config = await readConfig()
|
|
51
|
+
if (config.server.mode === 'local') {
|
|
52
|
+
// Only upgrade if server isn't already running (can't overwrite a running binary)
|
|
53
|
+
const health = await (await import('../daemon.js')).healthCheck({ timeout: 1000, url: config.server.url })
|
|
54
|
+
if (!health.healthy) {
|
|
55
|
+
await upgradeServer()
|
|
56
|
+
}
|
|
33
57
|
}
|
|
34
|
-
await writeFile(join(brainjarDir, '.gitignore'), gitignoreLines.join('\n') + '\n')
|
|
35
58
|
|
|
59
|
+
// 4. Start server and get API client
|
|
60
|
+
const api = await getApi()
|
|
61
|
+
|
|
62
|
+
// 5. Ensure workspace exists (ignore conflict if already created)
|
|
63
|
+
try {
|
|
64
|
+
await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
65
|
+
} catch (e: any) {
|
|
66
|
+
if (e.code !== 'CONFLICT') throw e
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 6. Seed defaults if requested
|
|
36
70
|
if (c.options.default) {
|
|
37
|
-
await
|
|
71
|
+
const bundle = await buildSeedBundle()
|
|
72
|
+
await api.post<ApiImportResult>('/api/v1/import', bundle)
|
|
73
|
+
|
|
74
|
+
await putState(api, {
|
|
75
|
+
soul_slug: 'craftsman',
|
|
76
|
+
persona_slug: 'engineer',
|
|
77
|
+
rule_slugs: ['boundaries', 'context-recovery', 'task-completion', 'git-discipline', 'security'],
|
|
78
|
+
})
|
|
38
79
|
}
|
|
39
80
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
state.backend = c.options.backend
|
|
43
|
-
if (c.options.default) {
|
|
44
|
-
state.soul = 'craftsman'
|
|
45
|
-
state.persona = 'engineer'
|
|
46
|
-
state.rules = ['default', 'git-discipline', 'security']
|
|
47
|
-
}
|
|
48
|
-
await writeState(state)
|
|
49
|
-
await sync(c.options.backend as Backend)
|
|
50
|
-
})
|
|
81
|
+
// 7. Sync to write CLAUDE.md / AGENTS.md
|
|
82
|
+
await sync({ api, backend: c.options.backend as Backend })
|
|
51
83
|
|
|
84
|
+
// 8. Build result
|
|
52
85
|
const result: Record<string, unknown> = {
|
|
53
86
|
created: brainjarDir,
|
|
54
87
|
backend: c.options.backend,
|
|
55
|
-
directories: ['souls/', 'personas/', 'rules/', 'brains/'],
|
|
56
88
|
}
|
|
57
89
|
|
|
58
90
|
if (c.options.default) {
|
|
59
91
|
result.soul = 'craftsman'
|
|
60
92
|
result.persona = 'engineer'
|
|
61
|
-
result.rules = ['
|
|
93
|
+
result.rules = ['boundaries', 'context-recovery', 'task-completion', 'git-discipline', 'security']
|
|
62
94
|
result.personas = ['engineer', 'planner', 'reviewer']
|
|
63
95
|
result.next = 'Ready to go. Run `brainjar status` to see your config.'
|
|
64
96
|
} else {
|
|
65
97
|
result.next = 'Run `brainjar soul create <name>` to create your first soul.'
|
|
66
98
|
}
|
|
67
99
|
|
|
68
|
-
if (c.options.obsidian) {
|
|
69
|
-
await initObsidian(brainjarDir)
|
|
70
|
-
result.obsidian = true
|
|
71
|
-
result.vault = brainjarDir
|
|
72
|
-
result.hint = `Open "${brainjarDir}" as a vault in Obsidian.`
|
|
73
|
-
}
|
|
74
|
-
|
|
75
100
|
return result
|
|
76
101
|
},
|
|
77
102
|
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Cli, z } from 'incur'
|
|
2
|
+
import { getBrainjarDir } from '../paths.js'
|
|
3
|
+
import { buildMigrationBundle, backupContentDirs } from '../migrate.js'
|
|
4
|
+
import { getApi } from '../client.js'
|
|
5
|
+
import { putState } from '../state.js'
|
|
6
|
+
import { sync } from '../sync.js'
|
|
7
|
+
import type { ApiImportResult } from '../api-types.js'
|
|
8
|
+
|
|
9
|
+
export const migrate = Cli.create('migrate', {
|
|
10
|
+
description: 'Import file-based content into the server',
|
|
11
|
+
options: z.object({
|
|
12
|
+
dryRun: z.boolean().default(false).describe('Preview what would be imported without making changes'),
|
|
13
|
+
skipBackup: z.boolean().default(false).describe('Skip renaming source directories to .bak'),
|
|
14
|
+
}),
|
|
15
|
+
async run(c) {
|
|
16
|
+
const brainjarDir = getBrainjarDir()
|
|
17
|
+
const { bundle, state, counts, warnings: scanWarnings } = await buildMigrationBundle(brainjarDir)
|
|
18
|
+
|
|
19
|
+
const total = counts.souls + counts.personas + counts.rules + counts.brains
|
|
20
|
+
if (total === 0) {
|
|
21
|
+
return { migrated: false, reason: 'No file-based content found to migrate.' }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (c.options.dryRun) {
|
|
25
|
+
return {
|
|
26
|
+
dry_run: true,
|
|
27
|
+
would_import: counts,
|
|
28
|
+
would_restore_state: state !== null,
|
|
29
|
+
warnings: scanWarnings,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const api = await getApi()
|
|
34
|
+
const result = await api.post<ApiImportResult>('/api/v1/import', bundle)
|
|
35
|
+
|
|
36
|
+
let stateRestored = false
|
|
37
|
+
if (state && (state.soul || state.persona || state.rules.length > 0)) {
|
|
38
|
+
await putState(api, {
|
|
39
|
+
soul_slug: state.soul || undefined,
|
|
40
|
+
persona_slug: state.persona || undefined,
|
|
41
|
+
rule_slugs: state.rules.length > 0 ? state.rules : undefined,
|
|
42
|
+
})
|
|
43
|
+
stateRestored = true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await sync({ api })
|
|
47
|
+
|
|
48
|
+
let backedUp: string[] = []
|
|
49
|
+
if (!c.options.skipBackup) {
|
|
50
|
+
backedUp = await backupContentDirs(brainjarDir)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
migrated: true,
|
|
55
|
+
imported: result.imported,
|
|
56
|
+
state_restored: stateRestored,
|
|
57
|
+
backed_up: backedUp,
|
|
58
|
+
warnings: [...scanWarnings, ...result.warnings],
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
})
|
package/src/commands/pack.ts
CHANGED
|
@@ -24,19 +24,15 @@ const exportCmd = Cli.create('export', {
|
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
const importCmd = Cli.create('import', {
|
|
27
|
-
description: 'Import a pack directory into
|
|
27
|
+
description: 'Import a pack directory into the server',
|
|
28
28
|
args: z.object({
|
|
29
29
|
path: z.string().describe('Path to pack directory'),
|
|
30
30
|
}),
|
|
31
31
|
options: z.object({
|
|
32
|
-
force: z.boolean().default(false).describe('Overwrite existing files on conflict'),
|
|
33
|
-
merge: z.boolean().default(false).describe('Rename incoming files on conflict as <name>-from-<packname>'),
|
|
34
32
|
activate: z.boolean().default(false).describe('Activate the brain after successful import'),
|
|
35
33
|
}),
|
|
36
34
|
async run(c) {
|
|
37
35
|
return importPack(resolve(c.args.path), {
|
|
38
|
-
force: c.options.force,
|
|
39
|
-
merge: c.options.merge,
|
|
40
36
|
activate: c.options.activate,
|
|
41
37
|
})
|
|
42
38
|
},
|