@brainjar/cli 0.2.3 → 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.
Files changed (40) hide show
  1. package/README.md +11 -10
  2. package/package.json +2 -2
  3. package/src/api-types.ts +155 -0
  4. package/src/cli.ts +5 -3
  5. package/src/client.ts +157 -0
  6. package/src/commands/brain.ts +99 -113
  7. package/src/commands/compose.ts +17 -116
  8. package/src/commands/init.ts +66 -42
  9. package/src/commands/migrate.ts +61 -0
  10. package/src/commands/pack.ts +1 -5
  11. package/src/commands/persona.ts +97 -145
  12. package/src/commands/rules.ts +71 -174
  13. package/src/commands/server.ts +212 -0
  14. package/src/commands/shell.ts +55 -51
  15. package/src/commands/soul.ts +75 -110
  16. package/src/commands/status.ts +37 -78
  17. package/src/commands/sync.ts +0 -2
  18. package/src/config.ts +125 -0
  19. package/src/daemon.ts +404 -0
  20. package/src/errors.ts +172 -0
  21. package/src/migrate.ts +247 -0
  22. package/src/pack.ts +149 -428
  23. package/src/paths.ts +1 -8
  24. package/src/seeds.ts +62 -105
  25. package/src/state.ts +12 -397
  26. package/src/sync.ts +61 -102
  27. package/src/version-check.ts +137 -0
  28. package/src/brain.ts +0 -69
  29. package/src/commands/identity.ts +0 -276
  30. package/src/engines/bitwarden.ts +0 -105
  31. package/src/engines/index.ts +0 -12
  32. package/src/engines/types.ts +0 -12
  33. package/src/hooks.test.ts +0 -132
  34. package/src/pack.test.ts +0 -472
  35. package/src/seeds/templates/persona.md +0 -19
  36. package/src/seeds/templates/rule.md +0 -11
  37. package/src/seeds/templates/soul.md +0 -20
  38. /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
  39. /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
  40. /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
package/src/config.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { readFile, writeFile, rename, mkdir } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
+ import { getBrainjarDir, paths } from './paths.js'
5
+ import type { Backend } from './paths.js'
6
+
7
+ export interface ServerConfig {
8
+ url: string
9
+ mode: 'local' | 'remote'
10
+ bin: string
11
+ pid_file: string
12
+ log_file: string
13
+ }
14
+
15
+ export interface Config {
16
+ server: ServerConfig
17
+ workspace: string
18
+ backend: Backend
19
+ }
20
+
21
+ function defaults(): Config {
22
+ const dir = getBrainjarDir()
23
+ 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
+ },
31
+ workspace: 'default',
32
+ backend: 'claude',
33
+ }
34
+ }
35
+
36
+ function isValidMode(v: unknown): v is 'local' | 'remote' {
37
+ return v === 'local' || v === 'remote'
38
+ }
39
+
40
+ function isValidBackend(v: unknown): v is Backend {
41
+ return v === 'claude' || v === 'codex'
42
+ }
43
+
44
+ /**
45
+ * Read config from ~/.brainjar/config.yaml.
46
+ * Returns defaults if file doesn't exist.
47
+ * Applies env var overrides on top.
48
+ * Throws if file exists but is corrupt YAML.
49
+ */
50
+ export async function readConfig(): Promise<Config> {
51
+ const def = defaults()
52
+ let config = { ...def, server: { ...def.server } }
53
+
54
+ try {
55
+ const raw = await readFile(paths.config, 'utf-8')
56
+ let parsed: unknown
57
+ try {
58
+ parsed = parseYaml(raw)
59
+ } catch (e) {
60
+ throw new Error(`config.yaml is corrupt: ${(e as Error).message}`)
61
+ }
62
+
63
+ if (parsed && typeof parsed === 'object') {
64
+ const p = parsed as Record<string, unknown>
65
+
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
76
+ }
77
+ }
78
+ } catch (e) {
79
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return applyEnvOverrides(config)
80
+ throw e
81
+ }
82
+
83
+ return applyEnvOverrides(config)
84
+ }
85
+
86
+ function applyEnvOverrides(config: Config): Config {
87
+ const url = process.env.BRAINJAR_SERVER_URL
88
+ if (typeof url === 'string' && url) config.server.url = url
89
+
90
+ const workspace = process.env.BRAINJAR_WORKSPACE
91
+ if (typeof workspace === 'string' && workspace) config.workspace = workspace
92
+
93
+ const backend = process.env.BRAINJAR_BACKEND
94
+ if (isValidBackend(backend)) config.backend = backend
95
+
96
+ return config
97
+ }
98
+
99
+ /**
100
+ * Write config to ~/.brainjar/config.yaml.
101
+ * Atomic write (tmp + rename).
102
+ */
103
+ 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,
113
+ backend: config.backend,
114
+ }
115
+
116
+ await mkdir(dirname(paths.config), { recursive: true })
117
+ const tmp = `${paths.config}.tmp`
118
+ await writeFile(tmp, stringifyYaml(doc))
119
+ await rename(tmp, paths.config)
120
+ }
121
+
122
+ /** Get the config file path. */
123
+ export function getConfigPath(): string {
124
+ return paths.config
125
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,404 @@
1
+ import { spawn, execFileSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { readFile, writeFile, rm, access, open, chmod, mkdir } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { Errors } from 'incur'
7
+ import { readConfig } from './config.js'
8
+ import { ErrorCode, createError } from './errors.js'
9
+
10
+ export const DIST_BASE = 'https://get.brainjar.sh/brainjar-server'
11
+
12
+ const { IncurError } = Errors
13
+
14
+ export interface HealthStatus {
15
+ healthy: boolean
16
+ url: string
17
+ latencyMs?: number
18
+ error?: string
19
+ }
20
+
21
+ export interface DaemonStatus {
22
+ mode: 'local' | 'remote'
23
+ url: string
24
+ running: boolean
25
+ pid: number | null
26
+ healthy: boolean
27
+ }
28
+
29
+ /**
30
+ * Check if the server is healthy.
31
+ * Returns health status without throwing.
32
+ */
33
+ export async function healthCheck(options?: { timeout?: number; url?: string }): Promise<HealthStatus> {
34
+ const config = await readConfig()
35
+ const url = options?.url ?? config.server.url
36
+ const timeout = options?.timeout ?? 2000
37
+ const start = Date.now()
38
+
39
+ try {
40
+ const response = await fetch(`${url}/healthz`, {
41
+ signal: AbortSignal.timeout(timeout),
42
+ })
43
+ const latencyMs = Date.now() - start
44
+
45
+ if (response.status === 200) {
46
+ try {
47
+ const body = await response.json() as { status?: string }
48
+ if (body.status === 'ok') {
49
+ return { healthy: true, url, latencyMs }
50
+ }
51
+ } catch {}
52
+ return { healthy: true, url, latencyMs }
53
+ }
54
+
55
+ return { healthy: false, url, error: `Server returned ${response.status}` }
56
+ } catch (e) {
57
+ return { healthy: false, url, error: (e as Error).message }
58
+ }
59
+ }
60
+
61
+ /** Read PID from pid_file, return null if missing or unreadable. */
62
+ async function readPid(pidFile: string): Promise<number | null> {
63
+ try {
64
+ const raw = await readFile(pidFile, 'utf-8')
65
+ const pid = parseInt(raw.trim(), 10)
66
+ return Number.isFinite(pid) ? pid : null
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ /** Check if a process is alive. */
73
+ function isAlive(pid: number): boolean {
74
+ try {
75
+ process.kill(pid, 0)
76
+ return true
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ /** Remove stale PID file if process is dead. Returns true if PID file was stale. */
83
+ async function cleanStalePid(pidFile: string): Promise<boolean> {
84
+ const pid = await readPid(pidFile)
85
+ if (pid === null) return false
86
+ if (!isAlive(pid)) {
87
+ await rm(pidFile, { force: true })
88
+ return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ function detectPlatform(): { os: string; arch: string } {
94
+ const platform = process.platform
95
+ const arch = process.arch
96
+
97
+ const osMap: Record<string, string> = { darwin: 'darwin', linux: 'linux' }
98
+ const archMap: Record<string, string> = { arm64: 'arm64', x64: 'amd64' }
99
+
100
+ const os = osMap[platform]
101
+ const mapped = archMap[arch]
102
+
103
+ if (!os || !mapped) {
104
+ throw createError(ErrorCode.BINARY_NOT_FOUND, {
105
+ message: `Unsupported platform: ${platform}/${arch}. Supported: darwin/linux × amd64/arm64.`,
106
+ })
107
+ }
108
+
109
+ return { os, arch: mapped }
110
+ }
111
+
112
+ /**
113
+ * Fetch the latest server version from the distribution endpoint.
114
+ */
115
+ export async function fetchLatestVersion(distBase: string = DIST_BASE): Promise<string> {
116
+ const response = await fetch(`${distBase}/latest`)
117
+ if (!response.ok) {
118
+ throw createError(ErrorCode.BINARY_NOT_FOUND, {
119
+ message: `Failed to fetch latest server version: HTTP ${response.status}`,
120
+ hint: 'Check your network connection or try again later.',
121
+ })
122
+ }
123
+ return (await response.text()).trim()
124
+ }
125
+
126
+ /**
127
+ * Download a tarball, verify its SHA-256 checksum, and extract the binary.
128
+ * Exported for testing — ensureBinary() is the public entry point.
129
+ */
130
+ export async function downloadAndVerify(binPath: string, versionBase: string): Promise<void> {
131
+ const { os, arch } = detectPlatform()
132
+ const tarballName = `brainjar-server-${os}-${arch}.tar.gz`
133
+ const tarballUrl = `${versionBase}/${tarballName}`
134
+ const checksumsUrl = `${versionBase}/checksums.txt`
135
+
136
+ await mkdir(dirname(binPath), { recursive: true })
137
+
138
+ const [checksumsResponse, tarballResponse] = await Promise.all([
139
+ fetch(checksumsUrl),
140
+ fetch(tarballUrl),
141
+ ])
142
+
143
+ if (!tarballResponse.ok) {
144
+ throw createError(ErrorCode.BINARY_NOT_FOUND, {
145
+ message: `Failed to download server binary: HTTP ${tarballResponse.status} from ${tarballUrl}`,
146
+ hint: `Download manually from ${versionBase} and place at ${binPath}`,
147
+ })
148
+ }
149
+
150
+ const buffer = Buffer.from(await tarballResponse.arrayBuffer())
151
+
152
+ if (checksumsResponse.ok) {
153
+ const checksumsText = await checksumsResponse.text()
154
+ const expectedHash = checksumsText
155
+ .split('\n')
156
+ .find(line => line.includes(tarballName))
157
+ ?.split(/\s+/)[0]
158
+
159
+ if (expectedHash) {
160
+ const actualHash = createHash('sha256').update(buffer).digest('hex')
161
+ if (actualHash !== expectedHash) {
162
+ throw createError(ErrorCode.BINARY_NOT_FOUND, {
163
+ message: `Checksum mismatch for ${tarballName}: expected ${expectedHash}, got ${actualHash}`,
164
+ hint: 'The download may be corrupted. Retry, or download manually.',
165
+ })
166
+ }
167
+ }
168
+ }
169
+
170
+ // Extract tarball to a temp dir, then move the binary into place
171
+ const tmpDir = join(tmpdir(), `brainjar-dl-${Date.now()}`)
172
+ const tarPath = join(tmpDir, tarballName)
173
+ await mkdir(tmpDir, { recursive: true })
174
+ await writeFile(tarPath, buffer)
175
+
176
+ try {
177
+ execFileSync('tar', ['xzf', tarPath, '-C', tmpDir])
178
+ const extractedBin = join(tmpDir, 'brainjar-server')
179
+
180
+ // Verify the binary was extracted
181
+ try {
182
+ await access(extractedBin)
183
+ } catch {
184
+ throw createError(ErrorCode.BINARY_NOT_FOUND, {
185
+ message: `Tarball did not contain expected brainjar-server binary`,
186
+ })
187
+ }
188
+
189
+ const binContent = await readFile(extractedBin)
190
+ await writeFile(binPath, binContent)
191
+ await chmod(binPath, 0o755)
192
+ } finally {
193
+ await rm(tmpDir, { recursive: true, force: true })
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Ensure the server binary exists at the configured path.
199
+ * Fetches latest version from get.brainjar.sh, downloads tarball, verifies checksum.
200
+ */
201
+ export async function ensureBinary(): Promise<void> {
202
+ const config = await readConfig()
203
+ const binPath = config.server.bin
204
+
205
+ try {
206
+ await access(binPath)
207
+ return
208
+ } catch {}
209
+
210
+ const { setInstalledServerVersion } = await import('./version-check.js')
211
+ const version = await fetchLatestVersion()
212
+ const versionBase = `${DIST_BASE}/${version}`
213
+ await downloadAndVerify(binPath, versionBase)
214
+ await setInstalledServerVersion(version)
215
+ }
216
+
217
+ /**
218
+ * Download the latest server binary, replacing any existing one.
219
+ * Returns the version that was installed.
220
+ */
221
+ export async function upgradeServer(): Promise<{ version: string; alreadyLatest: boolean }> {
222
+ const { getInstalledServerVersion, setInstalledServerVersion } = await import('./version-check.js')
223
+ const config = await readConfig()
224
+ const binPath = config.server.bin
225
+
226
+ const version = await fetchLatestVersion()
227
+ const installed = await getInstalledServerVersion()
228
+
229
+ if (installed === version) {
230
+ return { version, alreadyLatest: true }
231
+ }
232
+
233
+ const versionBase = `${DIST_BASE}/${version}`
234
+ await downloadAndVerify(binPath, versionBase)
235
+ await setInstalledServerVersion(version)
236
+ return { version, alreadyLatest: false }
237
+ }
238
+
239
+ /**
240
+ * Start the server daemon.
241
+ * Spawns the binary in detached mode, writes PID file.
242
+ */
243
+ export async function start(): Promise<{ pid: number }> {
244
+ const config = await readConfig()
245
+ const { bin, pid_file, log_file, url } = config.server
246
+
247
+ try {
248
+ await access(bin)
249
+ } catch {
250
+ throw createError(ErrorCode.BINARY_NOT_FOUND, {
251
+ message: `Server binary not found at ${bin}`,
252
+ })
253
+ }
254
+
255
+ // Extract port from URL
256
+ let port: string
257
+ try {
258
+ port = new URL(url).port || '7742'
259
+ } catch {
260
+ port = '7742'
261
+ }
262
+
263
+ const logFd = await open(log_file, 'a')
264
+
265
+ const child = spawn(bin, [], {
266
+ detached: true,
267
+ stdio: ['ignore', logFd.fd, logFd.fd],
268
+ env: { ...process.env, PORT: port, BRAINJAR_POSTGRES_EMBEDDED: 'true' },
269
+ })
270
+
271
+ const pid = child.pid
272
+ if (!pid) {
273
+ await logFd.close()
274
+ throw createError(ErrorCode.SERVER_START_FAILED, {
275
+ message: 'Failed to start brainjar server — no PID returned.',
276
+ hint: `Check ${log_file}`,
277
+ })
278
+ }
279
+
280
+ child.unref()
281
+ await logFd.close()
282
+ await writeFile(pid_file, String(pid))
283
+
284
+ return { pid }
285
+ }
286
+
287
+ /**
288
+ * Stop the server daemon.
289
+ * Sends SIGTERM, waits up to 5s, falls back to SIGKILL.
290
+ */
291
+ export async function stop(): Promise<{ stopped: boolean }> {
292
+ const config = await readConfig()
293
+ const { pid_file } = config.server
294
+
295
+ const pid = await readPid(pid_file)
296
+ if (pid === null) return { stopped: false }
297
+ if (!isAlive(pid)) {
298
+ await rm(pid_file, { force: true })
299
+ return { stopped: false }
300
+ }
301
+
302
+ process.kill(pid, 'SIGTERM')
303
+
304
+ // Poll for exit, up to 5s
305
+ const deadline = Date.now() + 5000
306
+ while (Date.now() < deadline) {
307
+ await new Promise(r => setTimeout(r, 100))
308
+ if (!isAlive(pid)) {
309
+ await rm(pid_file, { force: true })
310
+ return { stopped: true }
311
+ }
312
+ }
313
+
314
+ // Force kill
315
+ try {
316
+ process.kill(pid, 'SIGKILL')
317
+ } catch {}
318
+ await rm(pid_file, { force: true })
319
+ return { stopped: true }
320
+ }
321
+
322
+ /**
323
+ * Get the current daemon status.
324
+ */
325
+ export async function status(): Promise<DaemonStatus> {
326
+ const config = await readConfig()
327
+ const { mode, url, pid_file } = config.server
328
+
329
+ const pid = await readPid(pid_file)
330
+ const running = pid !== null && isAlive(pid)
331
+ const health = await healthCheck({ timeout: 2000, url })
332
+
333
+ return {
334
+ mode,
335
+ url,
336
+ running,
337
+ pid: running ? pid : null,
338
+ healthy: health.healthy,
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Read the last N lines of the server log file.
344
+ */
345
+ export async function readLogFile(options?: { lines?: number }): Promise<string> {
346
+ const config = await readConfig()
347
+ const lines = options?.lines ?? 50
348
+ try {
349
+ const content = await readFile(config.server.log_file, 'utf-8')
350
+ const allLines = content.trimEnd().split('\n')
351
+ return allLines.slice(-lines).join('\n')
352
+ } catch (e) {
353
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
354
+ return ''
355
+ }
356
+ throw e
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Ensure the server is running and healthy.
362
+ * Called by commands before making API calls.
363
+ */
364
+ export async function ensureRunning(): Promise<void> {
365
+ const config = await readConfig()
366
+ const { mode, url } = config.server
367
+
368
+ // Check health first — fast path
369
+ const health = await healthCheck({ timeout: 2000, url })
370
+ if (health.healthy) return
371
+
372
+ if (mode === 'remote') {
373
+ throw createError(ErrorCode.SERVER_UNREACHABLE, {
374
+ params: [url],
375
+ hint: `Check the URL or run 'brainjar server remote <url>'.`,
376
+ })
377
+ }
378
+
379
+ // Local mode: auto-start
380
+ await cleanStalePid(config.server.pid_file)
381
+
382
+ try {
383
+ await start()
384
+ } catch (e) {
385
+ if (e instanceof IncurError) throw e
386
+ throw createError(ErrorCode.SERVER_START_FAILED, {
387
+ message: 'Failed to start brainjar server.',
388
+ hint: `Check ${config.server.log_file}`,
389
+ })
390
+ }
391
+
392
+ // Poll until healthy (200ms intervals, 10s timeout)
393
+ const deadline = Date.now() + 10_000
394
+ while (Date.now() < deadline) {
395
+ await new Promise(r => setTimeout(r, 200))
396
+ const check = await healthCheck({ timeout: 2000, url })
397
+ if (check.healthy) return
398
+ }
399
+
400
+ throw createError(ErrorCode.SERVER_START_FAILED, {
401
+ message: 'Server started but failed health check after 10s.',
402
+ hint: `Check ${config.server.log_file}`,
403
+ })
404
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { Errors } from 'incur'
2
+
3
+ const { IncurError } = Errors
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Error codes — single source of truth
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const ErrorCode = {
10
+ // HTTP-mapped
11
+ BAD_REQUEST: 'BAD_REQUEST',
12
+ UNAUTHORIZED: 'UNAUTHORIZED',
13
+ NOT_FOUND: 'NOT_FOUND',
14
+ CONFLICT: 'CONFLICT',
15
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
16
+ SERVER_ERROR: 'SERVER_ERROR',
17
+ SERVER_UNAVAILABLE: 'SERVER_UNAVAILABLE',
18
+ API_ERROR: 'API_ERROR',
19
+
20
+ // Domain: souls
21
+ SOUL_EXISTS: 'SOUL_EXISTS',
22
+ SOUL_NOT_FOUND: 'SOUL_NOT_FOUND',
23
+
24
+ // Domain: personas
25
+ PERSONA_EXISTS: 'PERSONA_EXISTS',
26
+ PERSONA_NOT_FOUND: 'PERSONA_NOT_FOUND',
27
+
28
+ // Domain: brains
29
+ BRAIN_EXISTS: 'BRAIN_EXISTS',
30
+ BRAIN_NOT_FOUND: 'BRAIN_NOT_FOUND',
31
+
32
+ // Domain: rules
33
+ RULE_EXISTS: 'RULE_EXISTS',
34
+ RULE_NOT_FOUND: 'RULE_NOT_FOUND',
35
+ RULES_NOT_FOUND: 'RULES_NOT_FOUND',
36
+
37
+ // Domain: state
38
+ NO_ACTIVE_SOUL: 'NO_ACTIVE_SOUL',
39
+ NO_ACTIVE_PERSONA: 'NO_ACTIVE_PERSONA',
40
+
41
+ // Packs
42
+ PACK_INVALID_VERSION: 'PACK_INVALID_VERSION',
43
+ PACK_DIR_EXISTS: 'PACK_DIR_EXISTS',
44
+ PACK_NO_MANIFEST: 'PACK_NO_MANIFEST',
45
+ PACK_CORRUPT_MANIFEST: 'PACK_CORRUPT_MANIFEST',
46
+ PACK_INVALID_MANIFEST: 'PACK_INVALID_MANIFEST',
47
+ PACK_MISSING_FILE: 'PACK_MISSING_FILE',
48
+ PACK_NOT_DIR: 'PACK_NOT_DIR',
49
+ PACK_NOT_FOUND: 'PACK_NOT_FOUND',
50
+
51
+ // Infra
52
+ TIMEOUT: 'TIMEOUT',
53
+ SERVER_UNREACHABLE: 'SERVER_UNREACHABLE',
54
+ BINARY_NOT_FOUND: 'BINARY_NOT_FOUND',
55
+ SERVER_START_FAILED: 'SERVER_START_FAILED',
56
+
57
+ // Validation
58
+ MUTUALLY_EXCLUSIVE: 'MUTUALLY_EXCLUSIVE',
59
+ MISSING_ARG: 'MISSING_ARG',
60
+ NO_OVERRIDES: 'NO_OVERRIDES',
61
+
62
+ // Other
63
+ INVALID_MODE: 'INVALID_MODE',
64
+ SHELL_ERROR: 'SHELL_ERROR',
65
+ } as const
66
+
67
+ export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Message templates — parameterized messages are functions, static are strings
71
+ // Not every code needs an entry. Codes with context-dependent messages
72
+ // (e.g. PACK_INVALID_MANIFEST) keep messages inline at the call site.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export const Messages: Partial<Record<ErrorCode, string | ((...args: string[]) => string)>> = {
76
+ // Souls
77
+ SOUL_EXISTS: (name: string) => `Soul "${name}" already exists.`,
78
+ SOUL_NOT_FOUND: (name: string) => `Soul "${name}" not found.`,
79
+
80
+ // Personas
81
+ PERSONA_EXISTS: (name: string) => `Persona "${name}" already exists.`,
82
+ PERSONA_NOT_FOUND: (name: string) => `Persona "${name}" not found.`,
83
+
84
+ // Brains
85
+ BRAIN_EXISTS: (name: string) => `Brain "${name}" already exists.`,
86
+ BRAIN_NOT_FOUND: (name: string) => `Brain "${name}" not found.`,
87
+
88
+ // Rules
89
+ RULE_EXISTS: (name: string) => `Rule "${name}" already exists.`,
90
+ RULE_NOT_FOUND: (name: string) => `Rule "${name}" not found.`,
91
+
92
+ // State
93
+ NO_ACTIVE_SOUL: 'Cannot save brain: no active soul.',
94
+ NO_ACTIVE_PERSONA: 'Cannot save brain: no active persona.',
95
+
96
+ // Packs
97
+ PACK_DIR_EXISTS: (dir: string) => `Pack directory "${dir}" already exists.`,
98
+ PACK_NO_MANIFEST: (dir: string) => `No pack.yaml found in "${dir}". Is this a brainjar pack?`,
99
+ PACK_NOT_DIR: (path: string) => `Pack path "${path}" is a file, not a directory. Packs are directories.`,
100
+ PACK_NOT_FOUND: (path: string) => `Pack path "${path}" does not exist.`,
101
+
102
+ // Infra
103
+ SERVER_UNREACHABLE: (url: string) => `Cannot reach server at ${url}`,
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Hints — not every code has one
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export const Hints: Partial<Record<ErrorCode, string | ((...args: string[]) => string)>> = {
111
+ // Domain: souls
112
+ SOUL_EXISTS: 'Pick a different name, or edit the existing one: `brainjar soul show <name>`',
113
+ SOUL_NOT_FOUND: 'List available souls: `brainjar soul list`',
114
+
115
+ // Domain: personas
116
+ PERSONA_EXISTS: 'Pick a different name, or edit the existing one: `brainjar persona show <name>`',
117
+ PERSONA_NOT_FOUND: 'List available personas: `brainjar persona list`',
118
+
119
+ // Domain: brains
120
+ BRAIN_EXISTS: 'Overwrite with --overwrite, or pick a different name.',
121
+ BRAIN_NOT_FOUND: 'List available brains: `brainjar brain list`',
122
+
123
+ // Domain: rules
124
+ RULE_EXISTS: 'Pick a different name, or edit the existing one: `brainjar rules show <name>`',
125
+ RULE_NOT_FOUND: 'List available rules: `brainjar rules list`',
126
+
127
+ // Domain: state
128
+ NO_ACTIVE_SOUL: 'Activate a soul first: `brainjar soul use <name>`',
129
+ NO_ACTIVE_PERSONA: 'Activate a persona first: `brainjar persona use <name>`',
130
+
131
+ // Packs
132
+ PACK_DIR_EXISTS: 'Remove the directory first, or use --out to write elsewhere.',
133
+ PACK_NO_MANIFEST: 'A valid pack needs a pack.yaml at its root.',
134
+
135
+ // Infra
136
+ BINARY_NOT_FOUND: 'Install the server: `brainjar init`',
137
+ SERVER_UNREACHABLE: 'Start the server: `brainjar server start`, or set a remote: `brainjar server remote <url>`',
138
+ SERVER_START_FAILED: 'Check server logs: `brainjar server logs`',
139
+ SERVER_UNAVAILABLE: 'Server is starting up. Retry in a moment, or check: `brainjar server status`',
140
+ UNAUTHORIZED: 'Verify server config: `brainjar server status`',
141
+ SERVER_ERROR: 'Check server logs: `brainjar server logs`',
142
+
143
+ // Validation
144
+ INVALID_MODE: 'Switch to local mode: `brainjar server local`',
145
+ NO_OVERRIDES: 'Pass --brain, --soul, --persona, --rules-add, or --rules-remove.',
146
+ MUTUALLY_EXCLUSIVE: 'Use one or the other, not both.',
147
+ MISSING_ARG: 'Run with --help to see usage.',
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Factory — convenience for common patterns. Not mandatory.
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export interface CreateErrorOptions {
155
+ message?: string
156
+ params?: string[]
157
+ hint?: string
158
+ retryable?: boolean
159
+ cause?: Error
160
+ }
161
+
162
+ export function createError(code: ErrorCode, options?: CreateErrorOptions): InstanceType<typeof IncurError> {
163
+ const template = Messages[code]
164
+ const message = options?.message
165
+ ?? (typeof template === 'function' ? template(...(options?.params ?? [])) : template)
166
+ ?? code
167
+ const hintTemplate = Hints[code]
168
+ const hint = options?.hint
169
+ ?? (typeof hintTemplate === 'function' ? (hintTemplate as (...args: string[]) => string)(...(options?.params ?? [])) : hintTemplate)
170
+
171
+ return new IncurError({ code, message, hint, retryable: options?.retryable, cause: options?.cause })
172
+ }