@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 +3 -2
- package/package.json +3 -3
- package/src/cli.ts +4 -0
- package/src/client.ts +5 -4
- package/src/commands/context.ts +220 -0
- package/src/commands/init.ts +16 -11
- package/src/commands/persona.ts +55 -0
- package/src/commands/rules.ts +44 -3
- package/src/commands/server.ts +34 -59
- package/src/commands/soul.ts +50 -0
- package/src/commands/upgrade.ts +35 -0
- package/src/config.ts +229 -38
- package/src/daemon.ts +72 -25
- package/src/errors.ts +20 -0
- package/src/upgrade.ts +121 -0
- package/src/version-check.ts +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# brainjar
|
|
2
2
|
|
|
3
|
-
[](https://github.com/brainjar-sh/cli/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@brainjar/cli)
|
|
5
5
|
[](https://www.npmjs.com/package/@brainjar/cli)
|
|
6
6
|
[](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
|
|
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.
|
|
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/
|
|
9
|
+
"url": "https://github.com/brainjar-sh/cli.git"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://brainjar.sh",
|
|
12
|
-
"bugs": "https://github.com/brainjar-sh/
|
|
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
|
|
59
|
-
const
|
|
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 =
|
|
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)
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|
package/src/commands/persona.ts
CHANGED
|
@@ -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() {
|
package/src/commands/rules.ts
CHANGED
|
@@ -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,
|
|
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({
|
package/src/commands/server.ts
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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: `
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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),
|
|
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
|
|
112
|
+
description: 'Switch to local server (use `brainjar context use local` instead)',
|
|
110
113
|
async run() {
|
|
111
114
|
const config = await readConfig()
|
|
112
|
-
config.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
148
|
-
|
|
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:
|
|
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)
|