@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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # brainjar
2
2
 
3
- [![CI](https://github.com/brainjar-sh/brainjar-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/brainjar-sh/brainjar-cli/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/brainjar-sh/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/brainjar-sh/cli/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/@brainjar/cli)](https://www.npmjs.com/package/@brainjar/cli)
5
5
  [![downloads](https://img.shields.io/npm/dm/@brainjar/cli)](https://www.npmjs.com/package/@brainjar/cli)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
@@ -50,7 +50,8 @@ brainjar pack export|import
50
50
  brainjar hooks install|remove|status [--local]
51
51
  brainjar shell [--brain <name>] [--soul <name>] [--persona <name>]
52
52
  brainjar reset [--backend claude|codex]
53
- brainjar server start|stop|status|logs|local|remote|upgrade
53
+ brainjar server start|stop|status|logs|local|remote
54
+ brainjar upgrade [--cli-only] [--server-only]
54
55
  brainjar migrate [--dry-run] [--skip-backup]
55
56
  ```
56
57
 
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@brainjar/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shape how your AI thinks — composable soul, persona, and rules for AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/brainjar-sh/brainjar-cli.git"
9
+ "url": "https://github.com/brainjar-sh/cli.git"
10
10
  },
11
11
  "homepage": "https://brainjar.sh",
12
- "bugs": "https://github.com/brainjar-sh/brainjar-cli/issues",
12
+ "bugs": "https://github.com/brainjar-sh/cli/issues",
13
13
  "keywords": [
14
14
  "ai",
15
15
  "agent",
package/src/cli.ts CHANGED
@@ -15,6 +15,8 @@ import { hooks } from './commands/hooks.js'
15
15
  import { pack } from './commands/pack.js'
16
16
  import { server } from './commands/server.js'
17
17
  import { migrate } from './commands/migrate.js'
18
+ import { upgrade } from './commands/upgrade.js'
19
+ import { context } from './commands/context.js'
18
20
 
19
21
  Cli.create('brainjar', {
20
22
  description: 'Shape how your AI thinks — soul, persona, rules',
@@ -35,4 +37,6 @@ Cli.create('brainjar', {
35
37
  .command(pack)
36
38
  .command(server)
37
39
  .command(migrate)
40
+ .command(upgrade)
41
+ .command(context)
38
42
  .serve()
package/src/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Errors } from 'incur'
2
2
  import { basename } from 'node:path'
3
- import { readConfig } from './config.js'
3
+ import { readConfig, activeContext } from './config.js'
4
4
  import { getLocalDir } from './paths.js'
5
5
  import { access } from 'node:fs/promises'
6
6
  import { ensureRunning } from './daemon.js'
@@ -55,11 +55,12 @@ async function detectProject(explicit?: string): Promise<string | null> {
55
55
  */
56
56
  export async function createClient(options?: ClientOptions): Promise<BrainjarClient> {
57
57
  const config = await readConfig()
58
- const serverUrl = (options?.serverUrl ?? config.server.url).replace(/\/$/, '')
59
- const workspace = options?.workspace ?? config.workspace
58
+ const ctx = activeContext(config)
59
+ const serverUrl = (options?.serverUrl ?? ctx.url).replace(/\/$/, '')
60
+ const workspace = options?.workspace ?? ctx.workspace
60
61
  const session = options?.session ?? process.env.BRAINJAR_SESSION ?? null
61
62
  const defaultTimeout = options?.timeout ?? 10_000
62
- const mode = config.server.mode
63
+ const mode = ctx.mode
63
64
 
64
65
  async function request<T>(method: string, path: string, body?: unknown, reqOpts?: RequestOptions): Promise<T> {
65
66
  const url = `${serverUrl}${path}`
@@ -0,0 +1,220 @@
1
+ import { Cli, z } from 'incur'
2
+ import { readConfig, writeConfig, activeContext, isLocalContext, contextNameFromUrl, uniqueContextName } from '../config.js'
3
+ import type { RemoteContext } from '../config.js'
4
+ import { healthCheck, ensureRunning } from '../daemon.js'
5
+ import { getApi } from '../client.js'
6
+ import { sync } from '../sync.js'
7
+ import { ErrorCode, createError } from '../errors.js'
8
+
9
+ const SLUG_RE = /^[a-zA-Z0-9_-]+$/
10
+
11
+ const listCmd = Cli.create('list', {
12
+ description: 'List all contexts',
13
+ async run() {
14
+ const config = await readConfig()
15
+ const entries = Object.entries(config.contexts).map(([name, ctx]) => ({
16
+ name,
17
+ active: name === config.current_context,
18
+ mode: ctx.mode,
19
+ url: ctx.url,
20
+ workspace: ctx.workspace,
21
+ }))
22
+ return { contexts: entries }
23
+ },
24
+ })
25
+
26
+ const addCmd = Cli.create('add', {
27
+ description: 'Add a remote context',
28
+ args: z.object({
29
+ name: z.string().describe('Context name'),
30
+ url: z.string().describe('Server URL'),
31
+ }),
32
+ options: z.object({
33
+ workspace: z.string().default('default').describe('Workspace name'),
34
+ }),
35
+ async run(c) {
36
+ const name = c.args.name
37
+ const url = c.args.url.replace(/\/$/, '')
38
+
39
+ if (!SLUG_RE.test(name)) {
40
+ throw createError(ErrorCode.VALIDATION_ERROR, {
41
+ message: `Invalid context name "${name}". Use only letters, numbers, hyphens, and underscores.`,
42
+ })
43
+ }
44
+
45
+ if (name === 'local') {
46
+ throw createError(ErrorCode.CONTEXT_PROTECTED, { params: ['local'] })
47
+ }
48
+
49
+ const config = await readConfig()
50
+
51
+ if (name in config.contexts) {
52
+ throw createError(ErrorCode.CONTEXT_EXISTS, { params: [name] })
53
+ }
54
+
55
+ const health = await healthCheck({ url, timeout: 5000 })
56
+ if (!health.healthy) {
57
+ throw createError(ErrorCode.SERVER_UNREACHABLE, { params: [url] })
58
+ }
59
+
60
+ config.contexts[name] = {
61
+ url,
62
+ mode: 'remote',
63
+ workspace: c.options.workspace,
64
+ }
65
+ await writeConfig(config)
66
+
67
+ return { added: name, url, hint: `Switch with \`brainjar context use ${name}\`` }
68
+ },
69
+ })
70
+
71
+ const removeCmd = Cli.create('remove', {
72
+ description: 'Remove a context',
73
+ args: z.object({
74
+ name: z.string().describe('Context name'),
75
+ }),
76
+ async run(c) {
77
+ const name = c.args.name
78
+ const config = await readConfig()
79
+
80
+ if (name === 'local') {
81
+ throw createError(ErrorCode.CONTEXT_PROTECTED, { params: ['local'] })
82
+ }
83
+
84
+ if (!(name in config.contexts)) {
85
+ throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [name] })
86
+ }
87
+
88
+ if (name === config.current_context) {
89
+ throw createError(ErrorCode.CONTEXT_ACTIVE, { params: [name] })
90
+ }
91
+
92
+ delete config.contexts[name]
93
+ await writeConfig(config)
94
+
95
+ return { removed: name }
96
+ },
97
+ })
98
+
99
+ const useCmd = Cli.create('use', {
100
+ description: 'Switch active context',
101
+ args: z.object({
102
+ name: z.string().describe('Context name'),
103
+ }),
104
+ async run(c) {
105
+ const name = c.args.name
106
+ const config = await readConfig()
107
+
108
+ if (!(name in config.contexts)) {
109
+ throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [name] })
110
+ }
111
+
112
+ config.current_context = name
113
+ await writeConfig(config)
114
+
115
+ const ctx = activeContext(config)
116
+
117
+ // If switching to local, ensure running
118
+ if (isLocalContext(ctx)) {
119
+ await ensureRunning()
120
+ }
121
+
122
+ // Sync if server is reachable
123
+ const health = await healthCheck({ url: ctx.url, timeout: 3000 })
124
+ if (health.healthy) {
125
+ const api = await getApi()
126
+
127
+ // Ensure workspace exists
128
+ try {
129
+ await api.post('/api/v1/workspaces', { name: ctx.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
130
+ } catch (e: any) {
131
+ if (e.code !== 'CONFLICT') throw e
132
+ }
133
+
134
+ await sync({ api })
135
+ }
136
+
137
+ return { active: name, mode: ctx.mode, url: ctx.url, workspace: ctx.workspace }
138
+ },
139
+ })
140
+
141
+ const showCmd = Cli.create('show', {
142
+ description: 'Show context details',
143
+ args: z.object({
144
+ name: z.string().optional().describe('Context name (defaults to active)'),
145
+ }),
146
+ async run(c) {
147
+ const config = await readConfig()
148
+ const name = c.args.name ?? config.current_context
149
+
150
+ if (!(name in config.contexts)) {
151
+ throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [name] })
152
+ }
153
+
154
+ const ctx = config.contexts[name]
155
+ return {
156
+ name,
157
+ active: name === config.current_context,
158
+ ...ctx,
159
+ }
160
+ },
161
+ })
162
+
163
+ const renameCmd = Cli.create('rename', {
164
+ description: 'Rename a context',
165
+ args: z.object({
166
+ old: z.string().describe('Current name'),
167
+ new: z.string().describe('New name'),
168
+ }),
169
+ async run(c) {
170
+ const oldName = c.args.old
171
+ const newName = c.args.new
172
+
173
+ if (oldName === 'local') {
174
+ throw createError(ErrorCode.CONTEXT_PROTECTED, { params: ['local'] })
175
+ }
176
+
177
+ if (newName === 'local') {
178
+ throw createError(ErrorCode.CONTEXT_PROTECTED, {
179
+ message: 'Cannot rename to "local" — that name is reserved.',
180
+ })
181
+ }
182
+
183
+ if (!SLUG_RE.test(newName)) {
184
+ throw createError(ErrorCode.VALIDATION_ERROR, {
185
+ message: `Invalid context name "${newName}". Use only letters, numbers, hyphens, and underscores.`,
186
+ })
187
+ }
188
+
189
+ const config = await readConfig()
190
+
191
+ if (!(oldName in config.contexts)) {
192
+ throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [oldName] })
193
+ }
194
+
195
+ if (newName in config.contexts) {
196
+ throw createError(ErrorCode.CONTEXT_EXISTS, { params: [newName] })
197
+ }
198
+
199
+ config.contexts[newName] = config.contexts[oldName]
200
+ delete config.contexts[oldName]
201
+
202
+ if (config.current_context === oldName) {
203
+ config.current_context = newName
204
+ }
205
+
206
+ await writeConfig(config)
207
+
208
+ return { renamed: { from: oldName, to: newName } }
209
+ },
210
+ })
211
+
212
+ export const context = Cli.create('context', {
213
+ description: 'Manage server contexts — named server profiles',
214
+ })
215
+ .command(listCmd)
216
+ .command(addCmd)
217
+ .command(removeCmd)
218
+ .command(useCmd)
219
+ .command(showCmd)
220
+ .command(renameCmd)
@@ -5,7 +5,7 @@ import { buildSeedBundle } from '../seeds.js'
5
5
  import { putState } from '../state.js'
6
6
  import { sync } from '../sync.js'
7
7
  import { getApi } from '../client.js'
8
- import { readConfig, writeConfig, type Config } from '../config.js'
8
+ import { readConfig, writeConfig, activeContext, isLocalContext, type Config } from '../config.js'
9
9
  import { ensureBinary, upgradeServer } from '../daemon.js'
10
10
  import type { ApiImportResult } from '../api-types.js'
11
11
 
@@ -32,14 +32,18 @@ export const init = Cli.create('init', {
32
32
 
33
33
  if (!configExists) {
34
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`,
35
+ version: 2,
36
+ current_context: 'local',
37
+ contexts: {
38
+ local: {
39
+ url: 'http://localhost:7742',
40
+ mode: 'local',
41
+ bin: `${brainjarDir}/bin/brainjar-server`,
42
+ pid_file: `${brainjarDir}/server.pid`,
43
+ log_file: `${brainjarDir}/server.log`,
44
+ workspace: 'default',
45
+ },
41
46
  },
42
- workspace: 'default',
43
47
  backend: c.options.backend as Backend,
44
48
  }
45
49
  await writeConfig(config)
@@ -48,9 +52,10 @@ export const init = Cli.create('init', {
48
52
  // 3. Ensure server binary exists and is up to date
49
53
  await ensureBinary()
50
54
  const config = await readConfig()
51
- if (config.server.mode === 'local') {
55
+ const ctx = activeContext(config)
56
+ if (isLocalContext(ctx)) {
52
57
  // 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 })
58
+ const health = await (await import('../daemon.js')).healthCheck({ timeout: 1000, url: ctx.url })
54
59
  if (!health.healthy) {
55
60
  await upgradeServer()
56
61
  }
@@ -61,7 +66,7 @@ export const init = Cli.create('init', {
61
66
 
62
67
  // 5. Ensure workspace exists (ignore conflict if already created)
63
68
  try {
64
- await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
69
+ await api.post('/api/v1/workspaces', { name: ctx.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
65
70
  } catch (e: any) {
66
71
  if (e.code !== 'CONFLICT') throw e
67
72
  }
@@ -86,6 +86,61 @@ export const persona = Cli.create('persona', {
86
86
  }
87
87
  },
88
88
  })
89
+ .command('update', {
90
+ description: 'Update a persona\'s content (reads from stdin)',
91
+ args: z.object({
92
+ name: z.string().describe('Persona name'),
93
+ }),
94
+ options: z.object({
95
+ rules: z.array(z.string()).optional().describe('Update bundled rules'),
96
+ }),
97
+ async run(c) {
98
+ const name = normalizeSlug(c.args.name, 'persona name')
99
+ const api = await getApi()
100
+
101
+ // Validate it exists and get current data
102
+ let existing: ApiPersona
103
+ try {
104
+ existing = await api.get<ApiPersona>(`/api/v1/personas/${name}`)
105
+ } catch (e) {
106
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
107
+ throw createError(ErrorCode.PERSONA_NOT_FOUND, { params: [name] })
108
+ }
109
+ throw e
110
+ }
111
+
112
+ const chunks: Uint8Array[] = []
113
+ for await (const chunk of Bun.stdin.stream()) {
114
+ chunks.push(chunk)
115
+ }
116
+ const content = Buffer.concat(chunks).toString().trim()
117
+
118
+ // Validate rules if provided
119
+ const rulesList = c.options.rules
120
+ if (rulesList && rulesList.length > 0) {
121
+ const available = await api.get<ApiRuleList>('/api/v1/rules')
122
+ const availableSlugs = available.rules.map(r => r.slug)
123
+ const invalid = rulesList.filter(r => !availableSlugs.includes(r))
124
+ if (invalid.length > 0) {
125
+ throw createError(ErrorCode.RULES_NOT_FOUND, {
126
+ message: `Rules not found: ${invalid.join(', ')}`,
127
+ hint: `Available rules: ${availableSlugs.join(', ')}`,
128
+ })
129
+ }
130
+ }
131
+
132
+ await api.put<ApiPersona>(`/api/v1/personas/${name}`, {
133
+ content: content || existing.content,
134
+ bundled_rules: rulesList ?? existing.bundled_rules,
135
+ })
136
+
137
+ // Sync if this persona is active
138
+ const state = await getEffectiveState(api)
139
+ if (state.persona === name) await sync({ api })
140
+
141
+ return { updated: name, rules: rulesList ?? existing.bundled_rules }
142
+ },
143
+ })
89
144
  .command('list', {
90
145
  description: 'List available personas',
91
146
  async run() {
@@ -18,7 +18,6 @@ export const rules = Cli.create('rules', {
18
18
  }),
19
19
  options: z.object({
20
20
  description: z.string().optional().describe('One-line description of the rule'),
21
- pack: z.boolean().default(false).describe('Create as a rule pack (multiple entries)'),
22
21
  }),
23
22
  async run(c) {
24
23
  const name = normalizeSlug(c.args.name, 'rule name')
@@ -48,18 +47,60 @@ export const rules = Cli.create('rules', {
48
47
  })
49
48
 
50
49
  if (c.agent || c.formatExplicit) {
51
- return { created: name, name, pack: c.options.pack, template: scaffold }
50
+ return { created: name, name, template: scaffold }
52
51
  }
53
52
 
54
53
  return {
55
54
  created: name,
56
55
  name,
57
- pack: c.options.pack,
58
56
  template: `\n${scaffold}`,
59
57
  next: `Run \`brainjar rules show ${name}\` to view, then \`brainjar rules add ${name}\` to activate.`,
60
58
  }
61
59
  },
62
60
  })
61
+ .command('update', {
62
+ description: 'Update a rule\'s content (reads from stdin)',
63
+ args: z.object({
64
+ name: z.string().describe('Rule name'),
65
+ }),
66
+ async run(c) {
67
+ const name = normalizeSlug(c.args.name, 'rule name')
68
+ const api = await getApi()
69
+
70
+ // Validate it exists
71
+ try {
72
+ await api.get<ApiRule>(`/api/v1/rules/${name}`)
73
+ } catch (e) {
74
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
75
+ throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
76
+ }
77
+ throw e
78
+ }
79
+
80
+ const chunks: Uint8Array[] = []
81
+ for await (const chunk of Bun.stdin.stream()) {
82
+ chunks.push(chunk)
83
+ }
84
+ const content = Buffer.concat(chunks).toString().trim()
85
+
86
+ if (!content) {
87
+ throw createError(ErrorCode.MISSING_ARG, {
88
+ message: 'No content provided. Pipe content via stdin.',
89
+ hint: `echo "# ${name}\\n..." | brainjar rules update ${name}`,
90
+ })
91
+ }
92
+
93
+ await api.put<ApiRule>(`/api/v1/rules/${name}`, {
94
+ entries: [{ name: `${name}.md`, content }],
95
+ })
96
+
97
+ // Sync if this rule is active
98
+ const state = await getEffectiveState(api)
99
+ if (state.rules.includes(name)) await sync({ api })
100
+
101
+ return { updated: name }
102
+ },
103
+ })
63
104
  .command('list', {
64
105
  description: 'List available and active rules',
65
106
  options: z.object({
@@ -7,19 +7,19 @@ import {
7
7
  status as daemonStatus,
8
8
  ensureRunning,
9
9
  readLogFile,
10
- upgradeServer,
11
10
  } from '../daemon.js'
12
- import { readConfig, writeConfig } from '../config.js'
11
+ import { readConfig, writeConfig, activeContext, localContext, contextNameFromUrl, uniqueContextName } from '../config.js'
13
12
  import { getApi } from '../client.js'
14
13
  import { sync } from '../sync.js'
15
14
 
16
15
  const { IncurError } = Errors
17
16
  import { ErrorCode, createError } from '../errors.js'
18
17
 
19
- function assertLocalMode(config: { server: { mode: string } }, action: string) {
20
- if (config.server.mode === 'remote') {
18
+ function assertLocalContext(config: { current_context: string; contexts: Record<string, { mode: string }> }, action: string) {
19
+ const ctx = config.contexts[config.current_context]
20
+ if (ctx.mode === 'remote') {
21
21
  throw createError(ErrorCode.INVALID_MODE, {
22
- message: `Server is in remote mode. Cannot ${action}.`,
22
+ message: `Active context is remote. Cannot ${action}.`,
23
23
  })
24
24
  }
25
25
  }
@@ -35,6 +35,7 @@ const statusCmd = Cli.create('status', {
35
35
  healthy: s.healthy,
36
36
  running: s.running,
37
37
  pid: s.pid,
38
+ serverVersion: health.serverVersion ?? null,
38
39
  latencyMs: health.latencyMs ?? null,
39
40
  }
40
41
  },
@@ -44,12 +45,13 @@ const startCmd = Cli.create('start', {
44
45
  description: 'Start the local server daemon',
45
46
  async run() {
46
47
  const config = await readConfig()
47
- assertLocalMode(config, 'start')
48
+ assertLocalContext(config, 'start')
49
+ const ctx = activeContext(config)
48
50
 
49
51
  const health = await healthCheck({ timeout: 2000 })
50
52
  if (health.healthy) {
51
53
  const s = await daemonStatus()
52
- return { already_running: true, pid: s.pid, url: config.server.url }
54
+ return { already_running: true, pid: s.pid, url: ctx.url }
53
55
  }
54
56
 
55
57
  const { pid } = await start()
@@ -58,7 +60,7 @@ const startCmd = Cli.create('start', {
58
60
  while (Date.now() < deadline) {
59
61
  await new Promise(r => setTimeout(r, 200))
60
62
  const check = await healthCheck({ timeout: 2000 })
61
- if (check.healthy) return { started: true, pid, url: config.server.url }
63
+ if (check.healthy) return { started: true, pid, url: ctx.url }
62
64
  }
63
65
 
64
66
  throw createError(ErrorCode.SERVER_START_FAILED, {
@@ -71,7 +73,7 @@ const stopCmd = Cli.create('stop', {
71
73
  description: 'Stop the local server daemon',
72
74
  async run() {
73
75
  const config = await readConfig()
74
- assertLocalMode(config, 'stop')
76
+ assertLocalContext(config, 'stop')
75
77
 
76
78
  const result = await stop()
77
79
  if (!result.stopped) {
@@ -89,9 +91,10 @@ const logsCmd = Cli.create('logs', {
89
91
  }),
90
92
  async run(c) {
91
93
  const config = await readConfig()
94
+ const local = localContext(config)
92
95
 
93
96
  if (c.options.follow) {
94
- const child = spawn('tail', ['-f', '-n', String(c.options.lines), config.server.log_file], {
97
+ const child = spawn('tail', ['-f', '-n', String(c.options.lines), local.log_file], {
95
98
  stdio: 'inherit',
96
99
  })
97
100
  await new Promise<void>((resolve) => {
@@ -106,32 +109,31 @@ const logsCmd = Cli.create('logs', {
106
109
  })
107
110
 
108
111
  const localCmd = Cli.create('local', {
109
- description: 'Switch to managed local server',
112
+ description: 'Switch to local server (use `brainjar context use local` instead)',
110
113
  async run() {
111
114
  const config = await readConfig()
112
- config.server.url = 'http://localhost:7742'
113
- config.server.mode = 'local'
115
+ config.current_context = 'local'
114
116
  await writeConfig(config)
115
117
 
116
118
  await ensureRunning()
117
119
 
118
120
  const api = await getApi()
121
+ const ctx = activeContext(config)
119
122
 
120
- // Ensure workspace exists (ignore conflict if already created)
121
123
  try {
122
- await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
124
+ await api.post('/api/v1/workspaces', { name: ctx.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
123
125
  } catch (e: any) {
124
126
  if (e.code !== 'CONFLICT') throw e
125
127
  }
126
128
 
127
129
  await sync({ api })
128
130
 
129
- return { mode: 'local', url: config.server.url }
131
+ return { mode: 'local', url: ctx.url }
130
132
  },
131
133
  })
132
134
 
133
135
  const remoteCmd = Cli.create('remote', {
134
- description: 'Switch to a remote server',
136
+ description: 'Switch to a remote server (use `brainjar context add` instead)',
135
137
  args: z.object({
136
138
  url: z.string().describe('Remote server URL'),
137
139
  }),
@@ -144,15 +146,26 @@ const remoteCmd = Cli.create('remote', {
144
146
  }
145
147
 
146
148
  const config = await readConfig()
147
- config.server.url = url
148
- config.server.mode = 'remote'
149
+
150
+ // Find existing context with this URL, or create one
151
+ let ctxName = Object.entries(config.contexts).find(([, ctx]) => ctx.url === url)?.[0]
152
+ if (!ctxName) {
153
+ ctxName = uniqueContextName(contextNameFromUrl(url), config.contexts)
154
+ config.contexts[ctxName] = {
155
+ url,
156
+ mode: 'remote',
157
+ workspace: 'default',
158
+ }
159
+ }
160
+
161
+ config.current_context = ctxName
149
162
  await writeConfig(config)
150
163
 
151
164
  const api = await getApi()
165
+ const ctx = activeContext(config)
152
166
 
153
- // Ensure workspace exists (ignore conflict if already created)
154
167
  try {
155
- await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
168
+ await api.post('/api/v1/workspaces', { name: ctx.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
156
169
  } catch (e: any) {
157
170
  if (e.code !== 'CONFLICT') throw e
158
171
  }
@@ -163,43 +176,6 @@ const remoteCmd = Cli.create('remote', {
163
176
  },
164
177
  })
165
178
 
166
- const upgradeCmd = Cli.create('upgrade', {
167
- description: 'Upgrade the server binary to the latest version',
168
- async run() {
169
- const config = await readConfig()
170
- assertLocalMode(config, 'upgrade')
171
-
172
- // Stop server if running before replacing binary
173
- const s = await daemonStatus()
174
- const wasRunning = s.running
175
-
176
- if (wasRunning) {
177
- await stop()
178
- }
179
-
180
- const result = await upgradeServer()
181
-
182
- if (result.alreadyLatest) {
183
- if (wasRunning) await start()
184
- return { upgraded: false, version: result.version, message: 'Already on latest version' }
185
- }
186
-
187
- // Restart if it was running
188
- if (wasRunning) {
189
- const { pid } = await start()
190
- const deadline = Date.now() + 10_000
191
- while (Date.now() < deadline) {
192
- await new Promise(r => setTimeout(r, 200))
193
- const check = await healthCheck({ timeout: 2000 })
194
- if (check.healthy) return { upgraded: true, version: result.version, pid, restarted: true }
195
- }
196
- return { upgraded: true, version: result.version, restarted: false, warning: 'Server upgraded but failed health check after restart' }
197
- }
198
-
199
- return { upgraded: true, version: result.version }
200
- },
201
- })
202
-
203
179
  export const server = Cli.create('server', {
204
180
  description: 'Manage the brainjar server',
205
181
  })
@@ -209,4 +185,3 @@ export const server = Cli.create('server', {
209
185
  .command(logsCmd)
210
186
  .command(localCmd)
211
187
  .command(remoteCmd)
212
- .command(upgradeCmd)