@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.
@@ -1,21 +1,9 @@
1
1
  import { Cli, z, Errors } from 'incur'
2
2
 
3
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'
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 new IncurError({
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 new IncurError({
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 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
36
+ const api = await getApi()
110
37
 
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
- }
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 prompt = sections.join('\n\n')
43
+ const composed = await api.post<ApiComposeResult>('/api/v1/compose', body)
144
44
 
145
45
  const result: Record<string, unknown> = {
146
- persona: personaName,
147
- rules: rulesList,
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 (soulName) result.soul = soulName
152
- if (warnings.length) result.warnings = warnings
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
  },
@@ -1,77 +1,102 @@
1
1
  import { Cli, z } from 'incur'
2
- import { mkdir, writeFile } from 'node:fs/promises'
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 { seedDefaultRule, seedDefaults, initObsidian } from '../seeds.js'
6
- import { readState, writeState, withStateLock } from '../state.js'
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: 'Bootstrap ~/.brainjar/ directory structure',
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
- 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
- ])
22
+ // 1. Create directories
23
+ await mkdir(brainjarDir, { recursive: true })
24
+ await mkdir(binDir, { recursive: true })
25
25
 
26
- // Seed the default rule pack
27
- await seedDefaultRule(paths.rules)
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
- // Build .gitignore — always exclude private files, add .obsidian if vault enabled
30
- const gitignoreLines = ['state.yaml']
31
- if (c.options.obsidian) {
32
- gitignoreLines.push('.obsidian/', 'templates/')
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 seedDefaults()
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
- await withStateLock(async () => {
41
- const state = await readState()
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 = ['default', 'git-discipline', 'security']
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
+ })
@@ -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 ~/.brainjar/',
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
  },