@brainjar/cli 0.3.0 → 0.4.1
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 +9 -7
- package/package.json +1 -1
- package/src/api-types.ts +155 -0
- package/src/cli.ts +4 -0
- package/src/client.ts +157 -0
- package/src/commands/brain.ts +99 -113
- package/src/commands/compose.ts +17 -116
- package/src/commands/init.ts +65 -40
- package/src/commands/migrate.ts +61 -0
- package/src/commands/pack.ts +1 -5
- package/src/commands/persona.ts +152 -145
- package/src/commands/rules.ts +112 -174
- package/src/commands/server.ts +212 -0
- package/src/commands/shell.ts +53 -46
- package/src/commands/soul.ts +125 -110
- package/src/commands/status.ts +36 -41
- package/src/commands/sync.ts +0 -2
- package/src/config.ts +125 -0
- package/src/daemon.ts +404 -0
- package/src/errors.ts +172 -0
- package/src/migrate.ts +247 -0
- package/src/pack.ts +149 -428
- package/src/paths.ts +1 -6
- package/src/seeds.ts +62 -103
- package/src/state.ts +12 -368
- package/src/sync.ts +60 -85
- package/src/version-check.ts +137 -0
- package/src/brain.ts +0 -69
- package/src/hooks.test.ts +0 -132
- package/src/pack.test.ts +0 -472
- package/src/seeds/templates/persona.md +0 -19
- package/src/seeds/templates/rule.md +0 -11
- package/src/seeds/templates/soul.md +0 -20
- /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
- /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
- /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
package/src/commands/rules.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Cli, z, Errors } from 'incur'
|
|
2
|
+
import { basename } from 'node:path'
|
|
2
3
|
|
|
3
4
|
const { IncurError } = Errors
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { paths } from '../paths.js'
|
|
7
|
-
import { readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, requireBrainjarDir, normalizeSlug, listAvailableRules } from '../state.js'
|
|
5
|
+
import { ErrorCode, createError } from '../errors.js'
|
|
6
|
+
import { normalizeSlug, getEffectiveState, putState } from '../state.js'
|
|
8
7
|
import { sync } from '../sync.js'
|
|
8
|
+
import { getApi } from '../client.js'
|
|
9
|
+
import type { ApiRule, ApiRuleList } from '../api-types.js'
|
|
9
10
|
|
|
10
11
|
export const rules = Cli.create('rules', {
|
|
11
12
|
description: 'Manage rules — behavioral constraints for the agent',
|
|
@@ -13,66 +14,22 @@ export const rules = Cli.create('rules', {
|
|
|
13
14
|
.command('create', {
|
|
14
15
|
description: 'Create a new rule',
|
|
15
16
|
args: z.object({
|
|
16
|
-
name: z.string().describe('Rule name
|
|
17
|
+
name: z.string().describe('Rule name'),
|
|
17
18
|
}),
|
|
18
19
|
options: z.object({
|
|
19
20
|
description: z.string().optional().describe('One-line description of the rule'),
|
|
20
|
-
pack: z.boolean().default(false).describe('Create as a rule pack (directory of .md files)'),
|
|
21
21
|
}),
|
|
22
22
|
async run(c) {
|
|
23
|
-
await requireBrainjarDir()
|
|
24
23
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
24
|
+
const api = await getApi()
|
|
25
25
|
|
|
26
|
-
if
|
|
27
|
-
const dirPath = join(paths.rules, name)
|
|
28
|
-
try {
|
|
29
|
-
await access(dirPath)
|
|
30
|
-
throw new IncurError({
|
|
31
|
-
code: 'RULE_EXISTS',
|
|
32
|
-
message: `Rule "${name}" already exists.`,
|
|
33
|
-
hint: 'Choose a different name or edit the existing files.',
|
|
34
|
-
})
|
|
35
|
-
} catch (e) {
|
|
36
|
-
if (e instanceof IncurError) throw e
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
await mkdir(dirPath, { recursive: true })
|
|
40
|
-
|
|
41
|
-
const scaffold = [
|
|
42
|
-
`# ${name}`,
|
|
43
|
-
'',
|
|
44
|
-
c.options.description ?? 'Describe what this rule enforces and why.',
|
|
45
|
-
'',
|
|
46
|
-
'## Constraints',
|
|
47
|
-
'- ',
|
|
48
|
-
'',
|
|
49
|
-
].join('\n')
|
|
50
|
-
|
|
51
|
-
await writeFile(join(dirPath, `${name}.md`), scaffold)
|
|
52
|
-
|
|
53
|
-
if (c.agent || c.formatExplicit) {
|
|
54
|
-
return { created: dirPath, name, pack: true, template: scaffold }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
created: dirPath,
|
|
59
|
-
name,
|
|
60
|
-
pack: true,
|
|
61
|
-
template: `\n${scaffold}`,
|
|
62
|
-
next: `Edit ${join(dirPath, `${name}.md`)} to define your rule, then run \`brainjar rules add ${name}\` to activate.`,
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const dest = join(paths.rules, `${name}.md`)
|
|
26
|
+
// Check if it already exists
|
|
67
27
|
try {
|
|
68
|
-
await
|
|
69
|
-
throw
|
|
70
|
-
code: 'RULE_EXISTS',
|
|
71
|
-
message: `Rule "${name}" already exists.`,
|
|
72
|
-
hint: 'Choose a different name or edit the existing file.',
|
|
73
|
-
})
|
|
28
|
+
await api.get<ApiRule>(`/api/v1/rules/${name}`)
|
|
29
|
+
throw createError(ErrorCode.RULE_EXISTS, { params: [name] })
|
|
74
30
|
} catch (e) {
|
|
75
|
-
if (e instanceof IncurError) throw e
|
|
31
|
+
if (e instanceof IncurError && e.code === ErrorCode.RULE_EXISTS) throw e
|
|
32
|
+
if (e instanceof IncurError && e.code !== ErrorCode.NOT_FOUND) throw e
|
|
76
33
|
}
|
|
77
34
|
|
|
78
35
|
const scaffold = [
|
|
@@ -85,44 +42,90 @@ export const rules = Cli.create('rules', {
|
|
|
85
42
|
'',
|
|
86
43
|
].join('\n')
|
|
87
44
|
|
|
88
|
-
await
|
|
45
|
+
await api.put<ApiRule>(`/api/v1/rules/${name}`, {
|
|
46
|
+
entries: [{ name: `${name}.md`, content: scaffold }],
|
|
47
|
+
})
|
|
89
48
|
|
|
90
49
|
if (c.agent || c.formatExplicit) {
|
|
91
|
-
return { created:
|
|
50
|
+
return { created: name, name, template: scaffold }
|
|
92
51
|
}
|
|
93
52
|
|
|
94
53
|
return {
|
|
95
|
-
created:
|
|
54
|
+
created: name,
|
|
96
55
|
name,
|
|
97
56
|
template: `\n${scaffold}`,
|
|
98
|
-
next: `
|
|
57
|
+
next: `Run \`brainjar rules show ${name}\` to view, then \`brainjar rules add ${name}\` to activate.`,
|
|
58
|
+
}
|
|
59
|
+
},
|
|
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)
|
|
99
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 }
|
|
100
102
|
},
|
|
101
103
|
})
|
|
102
104
|
.command('list', {
|
|
103
105
|
description: 'List available and active rules',
|
|
104
106
|
options: z.object({
|
|
105
|
-
|
|
107
|
+
project: z.boolean().default(false).describe('Show project rules delta only'),
|
|
106
108
|
}),
|
|
107
109
|
async run(c) {
|
|
108
|
-
await
|
|
110
|
+
const api = await getApi()
|
|
111
|
+
const available = await api.get<ApiRuleList>('/api/v1/rules')
|
|
112
|
+
const availableSlugs = available.rules.map(r => r.slug)
|
|
109
113
|
|
|
110
|
-
if (c.options.
|
|
111
|
-
const
|
|
112
|
-
|
|
114
|
+
if (c.options.project) {
|
|
115
|
+
const override = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
|
|
116
|
+
project: basename(process.cwd()),
|
|
117
|
+
})
|
|
113
118
|
return {
|
|
114
|
-
add:
|
|
115
|
-
remove:
|
|
116
|
-
available,
|
|
117
|
-
scope: '
|
|
119
|
+
add: override.rules_to_add ?? [],
|
|
120
|
+
remove: override.rules_to_remove ?? [],
|
|
121
|
+
available: availableSlugs,
|
|
122
|
+
scope: 'project',
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
const active = effective.rules.filter(r => !r.scope.startsWith('-')).map(r => r.value)
|
|
125
|
-
return { active, available, rules: effective.rules }
|
|
126
|
+
const state = await getEffectiveState(api)
|
|
127
|
+
const active = state.rules
|
|
128
|
+
return { active, available: availableSlugs, rules: state.rules }
|
|
126
129
|
},
|
|
127
130
|
})
|
|
128
131
|
.command('show', {
|
|
@@ -131,98 +134,52 @@ export const rules = Cli.create('rules', {
|
|
|
131
134
|
name: z.string().describe('Rule name to show'),
|
|
132
135
|
}),
|
|
133
136
|
async run(c) {
|
|
134
|
-
await requireBrainjarDir()
|
|
135
137
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
136
|
-
const
|
|
137
|
-
const filePath = join(paths.rules, `${name}.md`)
|
|
138
|
+
const api = await getApi()
|
|
138
139
|
|
|
139
|
-
// Try directory of .md files first
|
|
140
140
|
try {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const content = await readFile(join(dirPath, file), 'utf-8')
|
|
148
|
-
sections.push(content.trim())
|
|
149
|
-
}
|
|
150
|
-
return { name, content: sections.join('\n\n') }
|
|
141
|
+
const rule = await api.get<ApiRule>(`/api/v1/rules/${name}`)
|
|
142
|
+
const content = rule.entries.map(e => e.content.trim()).join('\n\n')
|
|
143
|
+
return { name, content }
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
|
|
146
|
+
throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
|
|
151
147
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// Try single .md file
|
|
155
|
-
try {
|
|
156
|
-
const content = await readFile(filePath, 'utf-8')
|
|
157
|
-
return { name, content: content.trim() }
|
|
158
|
-
} catch {}
|
|
159
|
-
|
|
160
|
-
throw new IncurError({
|
|
161
|
-
code: 'RULE_NOT_FOUND',
|
|
162
|
-
message: `Rule "${name}" not found.`,
|
|
163
|
-
hint: 'Run `brainjar rules list` to see available rules.',
|
|
164
|
-
})
|
|
148
|
+
throw e
|
|
149
|
+
}
|
|
165
150
|
},
|
|
166
151
|
})
|
|
167
152
|
.command('add', {
|
|
168
153
|
description: 'Activate a rule or rule pack',
|
|
169
154
|
args: z.object({
|
|
170
|
-
name: z.string().describe('Rule name
|
|
155
|
+
name: z.string().describe('Rule name to activate'),
|
|
171
156
|
}),
|
|
172
157
|
options: z.object({
|
|
173
|
-
|
|
158
|
+
project: z.boolean().default(false).describe('Add rule at project scope'),
|
|
174
159
|
}),
|
|
175
160
|
async run(c) {
|
|
176
|
-
await requireBrainjarDir()
|
|
177
161
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
178
|
-
|
|
179
|
-
const dirPath = join(paths.rules, name)
|
|
180
|
-
const filePath = join(paths.rules, `${name}.md`)
|
|
181
|
-
let found = false
|
|
162
|
+
const api = await getApi()
|
|
182
163
|
|
|
164
|
+
// Validate it exists on server
|
|
183
165
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
await readFile(filePath, 'utf-8')
|
|
191
|
-
found = true
|
|
192
|
-
} catch {}
|
|
166
|
+
await api.get<ApiRule>(`/api/v1/rules/${name}`)
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
|
|
169
|
+
throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
|
|
170
|
+
}
|
|
171
|
+
throw e
|
|
193
172
|
}
|
|
194
173
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
hint: 'Place .md files or directories in ~/.brainjar/rules/',
|
|
200
|
-
})
|
|
201
|
-
}
|
|
174
|
+
const mutationOpts = c.options.project
|
|
175
|
+
? { project: basename(process.cwd()) }
|
|
176
|
+
: undefined
|
|
177
|
+
await putState(api, { rules_to_add: [name] }, mutationOpts)
|
|
202
178
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const local = await readLocalState()
|
|
206
|
-
const adds = local.rules?.add ?? []
|
|
207
|
-
if (!adds.includes(name)) adds.push(name)
|
|
208
|
-
// Also remove from local removes if present
|
|
209
|
-
const removes = (local.rules?.remove ?? []).filter(r => r !== name)
|
|
210
|
-
local.rules = { add: adds, ...(removes.length ? { remove: removes } : {}) }
|
|
211
|
-
await writeLocalState(local)
|
|
212
|
-
await sync({ local: true })
|
|
213
|
-
})
|
|
214
|
-
} else {
|
|
215
|
-
await withStateLock(async () => {
|
|
216
|
-
const state = await readState()
|
|
217
|
-
if (!state.rules.includes(name)) {
|
|
218
|
-
state.rules.push(name)
|
|
219
|
-
await writeState(state)
|
|
220
|
-
}
|
|
221
|
-
await sync()
|
|
222
|
-
})
|
|
223
|
-
}
|
|
179
|
+
await sync({ api })
|
|
180
|
+
if (c.options.project) await sync({ api, project: true })
|
|
224
181
|
|
|
225
|
-
return { activated: name,
|
|
182
|
+
return { activated: name, project: c.options.project }
|
|
226
183
|
},
|
|
227
184
|
})
|
|
228
185
|
.command('remove', {
|
|
@@ -231,39 +188,20 @@ export const rules = Cli.create('rules', {
|
|
|
231
188
|
name: z.string().describe('Rule name to remove'),
|
|
232
189
|
}),
|
|
233
190
|
options: z.object({
|
|
234
|
-
|
|
191
|
+
project: z.boolean().default(false).describe('Remove rule at project scope'),
|
|
235
192
|
}),
|
|
236
193
|
async run(c) {
|
|
237
|
-
await requireBrainjarDir()
|
|
238
194
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
195
|
+
const api = await getApi()
|
|
239
196
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
local.rules = { ...(adds.length ? { add: adds } : {}), remove: removes }
|
|
248
|
-
await writeLocalState(local)
|
|
249
|
-
await sync({ local: true })
|
|
250
|
-
})
|
|
251
|
-
} else {
|
|
252
|
-
await withStateLock(async () => {
|
|
253
|
-
const state = await readState()
|
|
254
|
-
if (!state.rules.includes(name)) {
|
|
255
|
-
throw new IncurError({
|
|
256
|
-
code: 'RULE_NOT_ACTIVE',
|
|
257
|
-
message: `Rule "${name}" is not active.`,
|
|
258
|
-
hint: 'Run `brainjar rules list` to see active rules.',
|
|
259
|
-
})
|
|
260
|
-
}
|
|
261
|
-
state.rules = state.rules.filter(r => r !== name)
|
|
262
|
-
await writeState(state)
|
|
263
|
-
await sync()
|
|
264
|
-
})
|
|
265
|
-
}
|
|
197
|
+
const mutationOpts = c.options.project
|
|
198
|
+
? { project: basename(process.cwd()) }
|
|
199
|
+
: undefined
|
|
200
|
+
await putState(api, { rules_to_remove: [name] }, mutationOpts)
|
|
201
|
+
|
|
202
|
+
await sync({ api })
|
|
203
|
+
if (c.options.project) await sync({ api, project: true })
|
|
266
204
|
|
|
267
|
-
return { removed: name,
|
|
205
|
+
return { removed: name, project: c.options.project }
|
|
268
206
|
},
|
|
269
207
|
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Cli, z, Errors } from 'incur'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
healthCheck,
|
|
5
|
+
start,
|
|
6
|
+
stop,
|
|
7
|
+
status as daemonStatus,
|
|
8
|
+
ensureRunning,
|
|
9
|
+
readLogFile,
|
|
10
|
+
upgradeServer,
|
|
11
|
+
} from '../daemon.js'
|
|
12
|
+
import { readConfig, writeConfig } from '../config.js'
|
|
13
|
+
import { getApi } from '../client.js'
|
|
14
|
+
import { sync } from '../sync.js'
|
|
15
|
+
|
|
16
|
+
const { IncurError } = Errors
|
|
17
|
+
import { ErrorCode, createError } from '../errors.js'
|
|
18
|
+
|
|
19
|
+
function assertLocalMode(config: { server: { mode: string } }, action: string) {
|
|
20
|
+
if (config.server.mode === 'remote') {
|
|
21
|
+
throw createError(ErrorCode.INVALID_MODE, {
|
|
22
|
+
message: `Server is in remote mode. Cannot ${action}.`,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const statusCmd = Cli.create('status', {
|
|
28
|
+
description: 'Show server status',
|
|
29
|
+
async run() {
|
|
30
|
+
const s = await daemonStatus()
|
|
31
|
+
const health = await healthCheck({ timeout: 2000 })
|
|
32
|
+
return {
|
|
33
|
+
mode: s.mode,
|
|
34
|
+
url: s.url,
|
|
35
|
+
healthy: s.healthy,
|
|
36
|
+
running: s.running,
|
|
37
|
+
pid: s.pid,
|
|
38
|
+
latencyMs: health.latencyMs ?? null,
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const startCmd = Cli.create('start', {
|
|
44
|
+
description: 'Start the local server daemon',
|
|
45
|
+
async run() {
|
|
46
|
+
const config = await readConfig()
|
|
47
|
+
assertLocalMode(config, 'start')
|
|
48
|
+
|
|
49
|
+
const health = await healthCheck({ timeout: 2000 })
|
|
50
|
+
if (health.healthy) {
|
|
51
|
+
const s = await daemonStatus()
|
|
52
|
+
return { already_running: true, pid: s.pid, url: config.server.url }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { pid } = await start()
|
|
56
|
+
|
|
57
|
+
const deadline = Date.now() + 10_000
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
await new Promise(r => setTimeout(r, 200))
|
|
60
|
+
const check = await healthCheck({ timeout: 2000 })
|
|
61
|
+
if (check.healthy) return { started: true, pid, url: config.server.url }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
65
|
+
message: 'Server started but failed health check after 10s.',
|
|
66
|
+
})
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const stopCmd = Cli.create('stop', {
|
|
71
|
+
description: 'Stop the local server daemon',
|
|
72
|
+
async run() {
|
|
73
|
+
const config = await readConfig()
|
|
74
|
+
assertLocalMode(config, 'stop')
|
|
75
|
+
|
|
76
|
+
const result = await stop()
|
|
77
|
+
if (!result.stopped) {
|
|
78
|
+
return { stopped: false, reason: 'not running' }
|
|
79
|
+
}
|
|
80
|
+
return { stopped: true }
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const logsCmd = Cli.create('logs', {
|
|
85
|
+
description: 'Show server logs',
|
|
86
|
+
options: z.object({
|
|
87
|
+
lines: z.number().default(50).describe('Number of lines to show'),
|
|
88
|
+
follow: z.boolean().default(false).describe('Follow log output'),
|
|
89
|
+
}),
|
|
90
|
+
async run(c) {
|
|
91
|
+
const config = await readConfig()
|
|
92
|
+
|
|
93
|
+
if (c.options.follow) {
|
|
94
|
+
const child = spawn('tail', ['-f', '-n', String(c.options.lines), config.server.log_file], {
|
|
95
|
+
stdio: 'inherit',
|
|
96
|
+
})
|
|
97
|
+
await new Promise<void>((resolve) => {
|
|
98
|
+
child.on('close', () => resolve())
|
|
99
|
+
})
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const content = await readLogFile({ lines: c.options.lines })
|
|
104
|
+
return content || 'No logs found.'
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const localCmd = Cli.create('local', {
|
|
109
|
+
description: 'Switch to managed local server',
|
|
110
|
+
async run() {
|
|
111
|
+
const config = await readConfig()
|
|
112
|
+
config.server.url = 'http://localhost:7742'
|
|
113
|
+
config.server.mode = 'local'
|
|
114
|
+
await writeConfig(config)
|
|
115
|
+
|
|
116
|
+
await ensureRunning()
|
|
117
|
+
|
|
118
|
+
const api = await getApi()
|
|
119
|
+
|
|
120
|
+
// Ensure workspace exists (ignore conflict if already created)
|
|
121
|
+
try {
|
|
122
|
+
await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
123
|
+
} catch (e: any) {
|
|
124
|
+
if (e.code !== 'CONFLICT') throw e
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await sync({ api })
|
|
128
|
+
|
|
129
|
+
return { mode: 'local', url: config.server.url }
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const remoteCmd = Cli.create('remote', {
|
|
134
|
+
description: 'Switch to a remote server',
|
|
135
|
+
args: z.object({
|
|
136
|
+
url: z.string().describe('Remote server URL'),
|
|
137
|
+
}),
|
|
138
|
+
async run(c) {
|
|
139
|
+
const url = c.args.url.replace(/\/$/, '')
|
|
140
|
+
|
|
141
|
+
const health = await healthCheck({ url, timeout: 5000 })
|
|
142
|
+
if (!health.healthy) {
|
|
143
|
+
throw createError(ErrorCode.SERVER_UNREACHABLE, { params: [url] })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const config = await readConfig()
|
|
147
|
+
config.server.url = url
|
|
148
|
+
config.server.mode = 'remote'
|
|
149
|
+
await writeConfig(config)
|
|
150
|
+
|
|
151
|
+
const api = await getApi()
|
|
152
|
+
|
|
153
|
+
// Ensure workspace exists (ignore conflict if already created)
|
|
154
|
+
try {
|
|
155
|
+
await api.post('/api/v1/workspaces', { name: config.workspace }, { headers: { 'X-Brainjar-Workspace': '' } })
|
|
156
|
+
} catch (e: any) {
|
|
157
|
+
if (e.code !== 'CONFLICT') throw e
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await sync({ api })
|
|
161
|
+
|
|
162
|
+
return { mode: 'remote', url }
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
|
|
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
|
+
export const server = Cli.create('server', {
|
|
204
|
+
description: 'Manage the brainjar server',
|
|
205
|
+
})
|
|
206
|
+
.command(statusCmd)
|
|
207
|
+
.command(startCmd)
|
|
208
|
+
.command(stopCmd)
|
|
209
|
+
.command(logsCmd)
|
|
210
|
+
.command(localCmd)
|
|
211
|
+
.command(remoteCmd)
|
|
212
|
+
.command(upgradeCmd)
|