@brainjar/cli 0.4.0 → 0.5.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.
@@ -39,6 +39,15 @@ export const soul = Cli.create('soul', {
39
39
  lines.push(c.options.description)
40
40
  lines.push('')
41
41
  }
42
+ lines.push('## Voice')
43
+ lines.push('- ')
44
+ lines.push('')
45
+ lines.push('## Character')
46
+ lines.push('- ')
47
+ lines.push('')
48
+ lines.push('## Standards')
49
+ lines.push('- ')
50
+ lines.push('')
42
51
 
43
52
  const content = lines.join('\n')
44
53
  await api.put<ApiSoul>(`/api/v1/souls/${name}`, { content })
@@ -55,6 +64,47 @@ export const soul = Cli.create('soul', {
55
64
  }
56
65
  },
57
66
  })
67
+ .command('update', {
68
+ description: 'Update a soul\'s content (reads from stdin)',
69
+ args: z.object({
70
+ name: z.string().describe('Soul name'),
71
+ }),
72
+ async run(c) {
73
+ const name = normalizeSlug(c.args.name, 'soul name')
74
+ const api = await getApi()
75
+
76
+ // Validate it exists
77
+ try {
78
+ await api.get<ApiSoul>(`/api/v1/souls/${name}`)
79
+ } catch (e) {
80
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
81
+ throw createError(ErrorCode.SOUL_NOT_FOUND, { params: [name] })
82
+ }
83
+ throw e
84
+ }
85
+
86
+ const chunks: Uint8Array[] = []
87
+ for await (const chunk of Bun.stdin.stream()) {
88
+ chunks.push(chunk)
89
+ }
90
+ const content = Buffer.concat(chunks).toString().trim()
91
+
92
+ if (!content) {
93
+ throw createError(ErrorCode.MISSING_ARG, {
94
+ message: 'No content provided. Pipe content via stdin.',
95
+ hint: `echo "# ${name}\\n..." | brainjar soul update ${name}`,
96
+ })
97
+ }
98
+
99
+ await api.put<ApiSoul>(`/api/v1/souls/${name}`, { content })
100
+
101
+ // Sync if this soul is active
102
+ const state = await getEffectiveState(api)
103
+ if (state.soul === name) await sync({ api })
104
+
105
+ return { updated: name }
106
+ },
107
+ })
58
108
  .command('list', {
59
109
  description: 'List available souls',
60
110
  async run() {
@@ -0,0 +1,35 @@
1
+ import { Cli, z } from 'incur'
2
+ import { upgradeCli, upgradeServerBinary } from '../upgrade.js'
3
+ import type { UpgradeResult } from '../upgrade.js'
4
+ import { ErrorCode, createError } from '../errors.js'
5
+
6
+ export const upgrade = Cli.create('upgrade', {
7
+ description: 'Upgrade brainjar CLI and server to latest versions',
8
+ options: z.object({
9
+ 'cli-only': z.boolean().default(false).describe('Only upgrade the CLI'),
10
+ 'server-only': z.boolean().default(false).describe('Only upgrade the server'),
11
+ }),
12
+ async run(c) {
13
+ const cliOnly = c.options['cli-only']
14
+ const serverOnly = c.options['server-only']
15
+
16
+ if (cliOnly && serverOnly) {
17
+ throw createError(ErrorCode.MUTUALLY_EXCLUSIVE, {
18
+ message: '--cli-only and --server-only are mutually exclusive.',
19
+ })
20
+ }
21
+
22
+ const result: UpgradeResult = {}
23
+
24
+ if (!serverOnly) {
25
+ result.cli = await upgradeCli()
26
+ }
27
+
28
+ // Server upgrade always targets the local binary, regardless of active context
29
+ if (!cliOnly) {
30
+ result.server = await upgradeServerBinary()
31
+ }
32
+
33
+ return result
34
+ },
35
+ })
package/src/config.ts CHANGED
@@ -4,31 +4,67 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
4
  import { getBrainjarDir, paths } from './paths.js'
5
5
  import type { Backend } from './paths.js'
6
6
 
7
- export interface ServerConfig {
7
+ // ─── Context types ──────────────────────────────────────────────────────────
8
+
9
+ export interface LocalContext {
8
10
  url: string
9
- mode: 'local' | 'remote'
11
+ mode: 'local'
10
12
  bin: string
11
13
  pid_file: string
12
14
  log_file: string
15
+ workspace: string
13
16
  }
14
17
 
15
- export interface Config {
16
- server: ServerConfig
18
+ export interface RemoteContext {
19
+ url: string
20
+ mode: 'remote'
17
21
  workspace: string
22
+ }
23
+
24
+ export type ServerContext = LocalContext | RemoteContext
25
+
26
+ export function isLocalContext(ctx: ServerContext): ctx is LocalContext {
27
+ return ctx.mode === 'local'
28
+ }
29
+
30
+ // ─── Config types ───────────────────────────────────────────────────────────
31
+
32
+ export interface Config {
33
+ version: 2
34
+ current_context: string
35
+ contexts: Record<string, ServerContext>
18
36
  backend: Backend
19
37
  }
20
38
 
21
- function defaults(): Config {
39
+ // ─── Helpers ────────────────────────────────────────────────────────────────
40
+
41
+ /** Get the active context from config. */
42
+ export function activeContext(config: Config): ServerContext {
43
+ return config.contexts[config.current_context]
44
+ }
45
+
46
+ /** Get the local context from config. Always present. */
47
+ export function localContext(config: Config): LocalContext {
48
+ return config.contexts.local as LocalContext
49
+ }
50
+
51
+ function defaultLocalContext(): LocalContext {
22
52
  const dir = getBrainjarDir()
23
53
  return {
24
- server: {
25
- url: 'http://localhost:7742',
26
- mode: 'local',
27
- bin: `${dir}/bin/brainjar-server`,
28
- pid_file: `${dir}/server.pid`,
29
- log_file: `${dir}/server.log`,
30
- },
54
+ url: 'http://localhost:7742',
55
+ mode: 'local',
56
+ bin: `${dir}/bin/brainjar-server`,
57
+ pid_file: `${dir}/server.pid`,
58
+ log_file: `${dir}/server.log`,
31
59
  workspace: 'default',
60
+ }
61
+ }
62
+
63
+ function defaults(): Config {
64
+ return {
65
+ version: 2,
66
+ current_context: 'local',
67
+ contexts: { local: defaultLocalContext() },
32
68
  backend: 'claude',
33
69
  }
34
70
  }
@@ -41,15 +77,80 @@ function isValidBackend(v: unknown): v is Backend {
41
77
  return v === 'claude' || v === 'codex'
42
78
  }
43
79
 
80
+ /** Derive a context name from a URL hostname. */
81
+ export function contextNameFromUrl(url: string): string {
82
+ try {
83
+ const hostname = new URL(url).hostname
84
+ return hostname.replace(/\./g, '-')
85
+ } catch {
86
+ return 'remote'
87
+ }
88
+ }
89
+
90
+ /** Find a unique context name, appending -2, -3, etc. if needed. */
91
+ export function uniqueContextName(base: string, existing: Record<string, unknown>): string {
92
+ if (!(base in existing)) return base
93
+ let i = 2
94
+ while (`${base}-${i}` in existing) i++
95
+ return `${base}-${i}`
96
+ }
97
+
98
+ // ─── Migration: v1 → v2 ────────────────────────────────────────────────────
99
+
100
+ interface V1Config {
101
+ server: {
102
+ url: string
103
+ mode: 'local' | 'remote'
104
+ bin: string
105
+ pid_file: string
106
+ log_file: string
107
+ }
108
+ workspace: string
109
+ backend: Backend
110
+ }
111
+
112
+ function migrateV1(v1: V1Config): Config {
113
+ const dir = getBrainjarDir()
114
+ const localCtx: LocalContext = {
115
+ url: 'http://localhost:7742',
116
+ mode: 'local',
117
+ bin: v1.server.bin || `${dir}/bin/brainjar-server`,
118
+ pid_file: v1.server.pid_file || `${dir}/server.pid`,
119
+ log_file: v1.server.log_file || `${dir}/server.log`,
120
+ workspace: v1.workspace || 'default',
121
+ }
122
+
123
+ const config: Config = {
124
+ version: 2,
125
+ current_context: 'local',
126
+ contexts: { local: localCtx },
127
+ backend: v1.backend || 'claude',
128
+ }
129
+
130
+ if (v1.server.mode === 'remote') {
131
+ const name = contextNameFromUrl(v1.server.url)
132
+ const uniqueName = uniqueContextName(name, config.contexts)
133
+ config.contexts[uniqueName] = {
134
+ url: v1.server.url,
135
+ mode: 'remote',
136
+ workspace: v1.workspace || 'default',
137
+ }
138
+ config.current_context = uniqueName
139
+ }
140
+
141
+ return config
142
+ }
143
+
144
+ // ─── Read / Write ───────────────────────────────────────────────────────────
145
+
44
146
  /**
45
147
  * Read config from ~/.brainjar/config.yaml.
46
148
  * Returns defaults if file doesn't exist.
149
+ * Migrates v1 configs to v2 on read.
47
150
  * Applies env var overrides on top.
48
- * Throws if file exists but is corrupt YAML.
49
151
  */
50
152
  export async function readConfig(): Promise<Config> {
51
- const def = defaults()
52
- let config = { ...def, server: { ...def.server } }
153
+ let config: Config
53
154
 
54
155
  try {
55
156
  const raw = await readFile(paths.config, 'utf-8')
@@ -63,32 +164,107 @@ export async function readConfig(): Promise<Config> {
63
164
  if (parsed && typeof parsed === 'object') {
64
165
  const p = parsed as Record<string, unknown>
65
166
 
66
- if (typeof p.workspace === 'string') config.workspace = p.workspace
67
- if (isValidBackend(p.backend)) config.backend = p.backend
68
-
69
- if (p.server && typeof p.server === 'object') {
70
- const s = p.server as Record<string, unknown>
71
- if (typeof s.url === 'string') config.server.url = s.url
72
- if (isValidMode(s.mode)) config.server.mode = s.mode
73
- if (typeof s.bin === 'string') config.server.bin = s.bin
74
- if (typeof s.pid_file === 'string') config.server.pid_file = s.pid_file
75
- if (typeof s.log_file === 'string') config.server.log_file = s.log_file
167
+ if (p.version === 2) {
168
+ // v2 config
169
+ config = parseV2(p)
170
+ } else {
171
+ // v1 config (no version field)
172
+ config = parseV1(p)
76
173
  }
174
+ } else {
175
+ config = defaults()
77
176
  }
78
177
  } catch (e) {
79
- if ((e as NodeJS.ErrnoException).code === 'ENOENT') return applyEnvOverrides(config)
178
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return applyEnvOverrides(defaults())
80
179
  throw e
81
180
  }
82
181
 
83
182
  return applyEnvOverrides(config)
84
183
  }
85
184
 
185
+ function parseV2(p: Record<string, unknown>): Config {
186
+ const def = defaults()
187
+ const defLocal = localContext(def)
188
+ const config: Config = {
189
+ version: 2,
190
+ current_context: typeof p.current_context === 'string' ? p.current_context : 'local',
191
+ contexts: {},
192
+ backend: isValidBackend(p.backend) ? p.backend : def.backend,
193
+ }
194
+
195
+ if (p.contexts && typeof p.contexts === 'object') {
196
+ for (const [name, raw] of Object.entries(p.contexts as Record<string, unknown>)) {
197
+ if (!raw || typeof raw !== 'object') continue
198
+ const ctx = raw as Record<string, unknown>
199
+ if (ctx.mode === 'local') {
200
+ config.contexts[name] = {
201
+ url: typeof ctx.url === 'string' ? ctx.url : 'http://localhost:7742',
202
+ mode: 'local',
203
+ bin: typeof ctx.bin === 'string' ? ctx.bin : defLocal.bin,
204
+ pid_file: typeof ctx.pid_file === 'string' ? ctx.pid_file : defLocal.pid_file,
205
+ log_file: typeof ctx.log_file === 'string' ? ctx.log_file : defLocal.log_file,
206
+ workspace: typeof ctx.workspace === 'string' ? ctx.workspace : 'default',
207
+ }
208
+ } else {
209
+ config.contexts[name] = {
210
+ url: typeof ctx.url === 'string' ? ctx.url : '',
211
+ mode: 'remote',
212
+ workspace: typeof ctx.workspace === 'string' ? ctx.workspace : 'default',
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // Ensure local context always exists
219
+ if (!config.contexts.local) {
220
+ config.contexts.local = defaultLocalContext()
221
+ }
222
+
223
+ // Ensure current_context points to an existing context
224
+ if (!(config.current_context in config.contexts)) {
225
+ config.current_context = 'local'
226
+ }
227
+
228
+ return config
229
+ }
230
+
231
+ function parseV1(p: Record<string, unknown>): Config {
232
+ const dir = getBrainjarDir()
233
+ const v1: V1Config = {
234
+ server: {
235
+ url: 'http://localhost:7742',
236
+ mode: 'local',
237
+ bin: `${dir}/bin/brainjar-server`,
238
+ pid_file: `${dir}/server.pid`,
239
+ log_file: `${dir}/server.log`,
240
+ },
241
+ workspace: 'default',
242
+ backend: 'claude',
243
+ }
244
+
245
+ if (typeof p.workspace === 'string') v1.workspace = p.workspace
246
+ if (isValidBackend(p.backend)) v1.backend = p.backend
247
+
248
+ if (p.server && typeof p.server === 'object') {
249
+ const s = p.server as Record<string, unknown>
250
+ if (typeof s.url === 'string') v1.server.url = s.url
251
+ if (isValidMode(s.mode)) v1.server.mode = s.mode
252
+ if (typeof s.bin === 'string') v1.server.bin = s.bin
253
+ if (typeof s.pid_file === 'string') v1.server.pid_file = s.pid_file
254
+ if (typeof s.log_file === 'string') v1.server.log_file = s.log_file
255
+ }
256
+
257
+ return migrateV1(v1)
258
+ }
259
+
86
260
  function applyEnvOverrides(config: Config): Config {
261
+ const ctx = activeContext(config)
262
+
87
263
  const url = process.env.BRAINJAR_SERVER_URL
88
- if (typeof url === 'string' && url) config.server.url = url
264
+ if (typeof url === 'string' && url) ctx.url = url
89
265
 
90
266
  const workspace = process.env.BRAINJAR_WORKSPACE
91
- if (typeof workspace === 'string' && workspace) config.workspace = workspace
267
+ if (typeof workspace === 'string' && workspace) ctx.workspace = workspace
92
268
 
93
269
  const backend = process.env.BRAINJAR_BACKEND
94
270
  if (isValidBackend(backend)) config.backend = backend
@@ -98,21 +274,36 @@ function applyEnvOverrides(config: Config): Config {
98
274
 
99
275
  /**
100
276
  * Write config to ~/.brainjar/config.yaml.
101
- * Atomic write (tmp + rename).
277
+ * Always writes v2 format. Atomic write (tmp + rename).
102
278
  */
103
279
  export async function writeConfig(config: Config): Promise<void> {
104
- const doc = {
105
- server: {
106
- url: config.server.url,
107
- mode: config.server.mode,
108
- bin: config.server.bin,
109
- pid_file: config.server.pid_file,
110
- log_file: config.server.log_file,
111
- },
112
- workspace: config.workspace,
280
+ const doc: Record<string, unknown> = {
281
+ version: 2,
282
+ current_context: config.current_context,
283
+ contexts: {} as Record<string, unknown>,
113
284
  backend: config.backend,
114
285
  }
115
286
 
287
+ const contexts = doc.contexts as Record<string, unknown>
288
+ for (const [name, ctx] of Object.entries(config.contexts)) {
289
+ if (isLocalContext(ctx)) {
290
+ contexts[name] = {
291
+ url: ctx.url,
292
+ mode: ctx.mode,
293
+ bin: ctx.bin,
294
+ pid_file: ctx.pid_file,
295
+ log_file: ctx.log_file,
296
+ workspace: ctx.workspace,
297
+ }
298
+ } else {
299
+ contexts[name] = {
300
+ url: ctx.url,
301
+ mode: ctx.mode,
302
+ workspace: ctx.workspace,
303
+ }
304
+ }
305
+ }
306
+
116
307
  await mkdir(dirname(paths.config), { recursive: true })
117
308
  const tmp = `${paths.config}.tmp`
118
309
  await writeFile(tmp, stringifyYaml(doc))
package/src/daemon.ts CHANGED
@@ -4,17 +4,39 @@ import { readFile, writeFile, rm, access, open, chmod, mkdir } from 'node:fs/pro
4
4
  import { dirname, join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { Errors } from 'incur'
7
- import { readConfig } from './config.js'
7
+ import { readConfig, activeContext, localContext } from './config.js'
8
8
  import { ErrorCode, createError } from './errors.js'
9
9
 
10
10
  export const DIST_BASE = 'https://get.brainjar.sh/brainjar-server'
11
11
 
12
+ /**
13
+ * Compare two semver strings. Returns -1, 0, or 1.
14
+ * Strips leading 'v' prefix. Only compares major.minor.patch.
15
+ */
16
+ export function compareSemver(a: string, b: string): number {
17
+ const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
18
+ const pa = parse(a)
19
+ const pb = parse(b)
20
+ for (let i = 0; i < 3; i++) {
21
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0)
22
+ if (diff !== 0) return diff > 0 ? 1 : -1
23
+ }
24
+ return 0
25
+ }
26
+
12
27
  const { IncurError } = Errors
13
28
 
29
+ /**
30
+ * Minimum server version this CLI is compatible with.
31
+ * Bump when the CLI depends on server features/API changes.
32
+ */
33
+ export const MIN_SERVER_VERSION = '0.2.1'
34
+
14
35
  export interface HealthStatus {
15
36
  healthy: boolean
16
37
  url: string
17
38
  latencyMs?: number
39
+ serverVersion?: string
18
40
  error?: string
19
41
  }
20
42
 
@@ -26,13 +48,27 @@ export interface DaemonStatus {
26
48
  healthy: boolean
27
49
  }
28
50
 
51
+ /**
52
+ * Assert the server version is compatible with this CLI.
53
+ * No-op if the server doesn't report a version (old servers).
54
+ */
55
+ function assertCompatible(serverVersion: string | undefined): void {
56
+ if (!serverVersion) return
57
+ if (compareSemver(serverVersion, MIN_SERVER_VERSION) < 0) {
58
+ throw createError(ErrorCode.SERVER_INCOMPATIBLE, {
59
+ message: `Server ${serverVersion} is incompatible with this CLI (requires >= ${MIN_SERVER_VERSION}).`,
60
+ })
61
+ }
62
+ }
63
+
29
64
  /**
30
65
  * Check if the server is healthy.
31
66
  * Returns health status without throwing.
32
67
  */
33
68
  export async function healthCheck(options?: { timeout?: number; url?: string }): Promise<HealthStatus> {
34
69
  const config = await readConfig()
35
- const url = options?.url ?? config.server.url
70
+ const ctx = activeContext(config)
71
+ const url = options?.url ?? ctx.url
36
72
  const timeout = options?.timeout ?? 2000
37
73
  const start = Date.now()
38
74
 
@@ -44,9 +80,9 @@ export async function healthCheck(options?: { timeout?: number; url?: string }):
44
80
 
45
81
  if (response.status === 200) {
46
82
  try {
47
- const body = await response.json() as { status?: string }
83
+ const body = await response.json() as { status?: string; version?: string }
48
84
  if (body.status === 'ok') {
49
- return { healthy: true, url, latencyMs }
85
+ return { healthy: true, url, latencyMs, serverVersion: body.version }
50
86
  }
51
87
  } catch {}
52
88
  return { healthy: true, url, latencyMs }
@@ -200,7 +236,8 @@ export async function downloadAndVerify(binPath: string, versionBase: string): P
200
236
  */
201
237
  export async function ensureBinary(): Promise<void> {
202
238
  const config = await readConfig()
203
- const binPath = config.server.bin
239
+ const local = localContext(config)
240
+ const binPath = local.bin
204
241
 
205
242
  try {
206
243
  await access(binPath)
@@ -221,7 +258,8 @@ export async function ensureBinary(): Promise<void> {
221
258
  export async function upgradeServer(): Promise<{ version: string; alreadyLatest: boolean }> {
222
259
  const { getInstalledServerVersion, setInstalledServerVersion } = await import('./version-check.js')
223
260
  const config = await readConfig()
224
- const binPath = config.server.bin
261
+ const local = localContext(config)
262
+ const binPath = local.bin
225
263
 
226
264
  const version = await fetchLatestVersion()
227
265
  const installed = await getInstalledServerVersion()
@@ -242,7 +280,8 @@ export async function upgradeServer(): Promise<{ version: string; alreadyLatest:
242
280
  */
243
281
  export async function start(): Promise<{ pid: number }> {
244
282
  const config = await readConfig()
245
- const { bin, pid_file, log_file, url } = config.server
283
+ const local = localContext(config)
284
+ const { bin, pid_file, log_file, url } = local
246
285
 
247
286
  try {
248
287
  await access(bin)
@@ -290,7 +329,7 @@ export async function start(): Promise<{ pid: number }> {
290
329
  */
291
330
  export async function stop(): Promise<{ stopped: boolean }> {
292
331
  const config = await readConfig()
293
- const { pid_file } = config.server
332
+ const { pid_file } = localContext(config)
294
333
 
295
334
  const pid = await readPid(pid_file)
296
335
  if (pid === null) return { stopped: false }
@@ -324,15 +363,16 @@ export async function stop(): Promise<{ stopped: boolean }> {
324
363
  */
325
364
  export async function status(): Promise<DaemonStatus> {
326
365
  const config = await readConfig()
327
- const { mode, url, pid_file } = config.server
366
+ const ctx = activeContext(config)
367
+ const local = localContext(config)
328
368
 
329
- const pid = await readPid(pid_file)
369
+ const pid = await readPid(local.pid_file)
330
370
  const running = pid !== null && isAlive(pid)
331
- const health = await healthCheck({ timeout: 2000, url })
371
+ const health = await healthCheck({ timeout: 2000, url: ctx.url })
332
372
 
333
373
  return {
334
- mode,
335
- url,
374
+ mode: ctx.mode,
375
+ url: ctx.url,
336
376
  running,
337
377
  pid: running ? pid : null,
338
378
  healthy: health.healthy,
@@ -346,7 +386,7 @@ export async function readLogFile(options?: { lines?: number }): Promise<string>
346
386
  const config = await readConfig()
347
387
  const lines = options?.lines ?? 50
348
388
  try {
349
- const content = await readFile(config.server.log_file, 'utf-8')
389
+ const content = await readFile(localContext(config).log_file, 'utf-8')
350
390
  const allLines = content.trimEnd().split('\n')
351
391
  return allLines.slice(-lines).join('\n')
352
392
  } catch (e) {
@@ -363,21 +403,25 @@ export async function readLogFile(options?: { lines?: number }): Promise<string>
363
403
  */
364
404
  export async function ensureRunning(): Promise<void> {
365
405
  const config = await readConfig()
366
- const { mode, url } = config.server
406
+ const ctx = activeContext(config)
407
+ const local = localContext(config)
367
408
 
368
409
  // Check health first — fast path
369
- const health = await healthCheck({ timeout: 2000, url })
370
- if (health.healthy) return
410
+ const health = await healthCheck({ timeout: 2000, url: ctx.url })
411
+ if (health.healthy) {
412
+ assertCompatible(health.serverVersion)
413
+ return
414
+ }
371
415
 
372
- if (mode === 'remote') {
416
+ if (ctx.mode === 'remote') {
373
417
  throw createError(ErrorCode.SERVER_UNREACHABLE, {
374
- params: [url],
375
- hint: `Check the URL or run 'brainjar server remote <url>'.`,
418
+ params: [ctx.url],
419
+ hint: `Check the URL or run 'brainjar context add <name> <url>'.`,
376
420
  })
377
421
  }
378
422
 
379
423
  // Local mode: auto-start
380
- await cleanStalePid(config.server.pid_file)
424
+ await cleanStalePid(local.pid_file)
381
425
 
382
426
  try {
383
427
  await start()
@@ -385,7 +429,7 @@ export async function ensureRunning(): Promise<void> {
385
429
  if (e instanceof IncurError) throw e
386
430
  throw createError(ErrorCode.SERVER_START_FAILED, {
387
431
  message: 'Failed to start brainjar server.',
388
- hint: `Check ${config.server.log_file}`,
432
+ hint: `Check ${local.log_file}`,
389
433
  })
390
434
  }
391
435
 
@@ -393,12 +437,15 @@ export async function ensureRunning(): Promise<void> {
393
437
  const deadline = Date.now() + 10_000
394
438
  while (Date.now() < deadline) {
395
439
  await new Promise(r => setTimeout(r, 200))
396
- const check = await healthCheck({ timeout: 2000, url })
397
- if (check.healthy) return
440
+ const check = await healthCheck({ timeout: 2000, url: ctx.url })
441
+ if (check.healthy) {
442
+ assertCompatible(check.serverVersion)
443
+ return
444
+ }
398
445
  }
399
446
 
400
447
  throw createError(ErrorCode.SERVER_START_FAILED, {
401
448
  message: 'Server started but failed health check after 10s.',
402
- hint: `Check ${config.server.log_file}`,
449
+ hint: `Check ${local.log_file}`,
403
450
  })
404
451
  }
package/src/errors.ts CHANGED
@@ -53,12 +53,19 @@ export const ErrorCode = {
53
53
  SERVER_UNREACHABLE: 'SERVER_UNREACHABLE',
54
54
  BINARY_NOT_FOUND: 'BINARY_NOT_FOUND',
55
55
  SERVER_START_FAILED: 'SERVER_START_FAILED',
56
+ SERVER_INCOMPATIBLE: 'SERVER_INCOMPATIBLE',
56
57
 
57
58
  // Validation
58
59
  MUTUALLY_EXCLUSIVE: 'MUTUALLY_EXCLUSIVE',
59
60
  MISSING_ARG: 'MISSING_ARG',
60
61
  NO_OVERRIDES: 'NO_OVERRIDES',
61
62
 
63
+ // Contexts
64
+ CONTEXT_NOT_FOUND: 'CONTEXT_NOT_FOUND',
65
+ CONTEXT_PROTECTED: 'CONTEXT_PROTECTED',
66
+ CONTEXT_EXISTS: 'CONTEXT_EXISTS',
67
+ CONTEXT_ACTIVE: 'CONTEXT_ACTIVE',
68
+
62
69
  // Other
63
70
  INVALID_MODE: 'INVALID_MODE',
64
71
  SHELL_ERROR: 'SHELL_ERROR',
@@ -99,6 +106,12 @@ export const Messages: Partial<Record<ErrorCode, string | ((...args: string[]) =
99
106
  PACK_NOT_DIR: (path: string) => `Pack path "${path}" is a file, not a directory. Packs are directories.`,
100
107
  PACK_NOT_FOUND: (path: string) => `Pack path "${path}" does not exist.`,
101
108
 
109
+ // Contexts
110
+ CONTEXT_NOT_FOUND: (name: string) => `Context "${name}" not found.`,
111
+ CONTEXT_EXISTS: (name: string) => `Context "${name}" already exists.`,
112
+ CONTEXT_PROTECTED: (name: string) => `Context "${name}" is protected and cannot be removed or renamed.`,
113
+ CONTEXT_ACTIVE: (name: string) => `Context "${name}" is the active context. Switch first.`,
114
+
102
115
  // Infra
103
116
  SERVER_UNREACHABLE: (url: string) => `Cannot reach server at ${url}`,
104
117
  }
@@ -132,10 +145,17 @@ export const Hints: Partial<Record<ErrorCode, string | ((...args: string[]) => s
132
145
  PACK_DIR_EXISTS: 'Remove the directory first, or use --out to write elsewhere.',
133
146
  PACK_NO_MANIFEST: 'A valid pack needs a pack.yaml at its root.',
134
147
 
148
+ // Contexts
149
+ CONTEXT_NOT_FOUND: 'List available contexts: `brainjar context list`',
150
+ CONTEXT_EXISTS: 'Pick a different name.',
151
+ CONTEXT_PROTECTED: 'The local context is always present and cannot be modified.',
152
+ CONTEXT_ACTIVE: 'Switch to a different context first: `brainjar context use <name>`',
153
+
135
154
  // Infra
136
155
  BINARY_NOT_FOUND: 'Install the server: `brainjar init`',
137
156
  SERVER_UNREACHABLE: 'Start the server: `brainjar server start`, or set a remote: `brainjar server remote <url>`',
138
157
  SERVER_START_FAILED: 'Check server logs: `brainjar server logs`',
158
+ SERVER_INCOMPATIBLE: 'Run `brainjar upgrade` to update both CLI and server.',
139
159
  SERVER_UNAVAILABLE: 'Server is starting up. Retry in a moment, or check: `brainjar server status`',
140
160
  UNAUTHORIZED: 'Verify server config: `brainjar server status`',
141
161
  SERVER_ERROR: 'Check server logs: `brainjar server logs`',