@brainjar/cli 0.5.1 → 0.6.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/package.json +1 -1
- package/src/api-types.ts +7 -1
- package/src/client.ts +6 -3
- package/src/commands/brain.ts +21 -2
- package/src/commands/persona.ts +59 -24
- package/src/commands/rules.ts +56 -19
- package/src/commands/soul.ts +61 -24
- package/src/commands/status.ts +4 -5
- package/src/daemon.ts +53 -13
- package/src/pack.ts +8 -2
- package/src/state.ts +10 -4
- package/src/sync.ts +2 -1
- package/src/upgrade.ts +11 -6
package/package.json
CHANGED
package/src/api-types.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface ApiEffectiveState {
|
|
|
63
63
|
rules: string[]
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/** State override at a single scope
|
|
66
|
+
/** State override at a single scope. */
|
|
67
67
|
export interface ApiStateOverride {
|
|
68
68
|
soul_slug?: string | null
|
|
69
69
|
persona_slug?: string | null
|
|
@@ -72,6 +72,12 @@ export interface ApiStateOverride {
|
|
|
72
72
|
rules_to_remove?: string[]
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Envelope returned by GET /api/v1/state/override. */
|
|
76
|
+
export interface ApiStateOverrideResponse {
|
|
77
|
+
override: ApiStateOverride | null
|
|
78
|
+
scope: { id: string; scope_type: string; reference_id: string; parent_scope_id?: string } | null
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
/** Body for PUT /api/v1/state — partial update. */
|
|
76
82
|
export interface ApiStateMutation {
|
|
77
83
|
soul_slug?: string | null
|
package/src/client.ts
CHANGED
|
@@ -19,7 +19,8 @@ export interface ClientOptions {
|
|
|
19
19
|
export interface RequestOptions {
|
|
20
20
|
timeout?: number
|
|
21
21
|
headers?: Record<string, string>
|
|
22
|
-
project
|
|
22
|
+
/** Pass a project name to scope to that project, null to suppress auto-detection, or undefined for auto-detect. */
|
|
23
|
+
project?: string | null
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export interface BrainjarClient {
|
|
@@ -40,7 +41,8 @@ const ERROR_MAP: Record<number, { code: ErrorCode; hint?: string }> = {
|
|
|
40
41
|
503: { code: ErrorCode.SERVER_UNAVAILABLE, hint: 'Server is not ready. Try again in a moment.' },
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
async function detectProject(explicit?: string): Promise<string | null> {
|
|
44
|
+
async function detectProject(explicit?: string | null): Promise<string | null> {
|
|
45
|
+
if (explicit === null) return null // explicitly suppress auto-detection
|
|
44
46
|
if (explicit) return explicit
|
|
45
47
|
try {
|
|
46
48
|
await access(getLocalDir())
|
|
@@ -72,7 +74,8 @@ export async function createClient(options?: ClientOptions): Promise<BrainjarCli
|
|
|
72
74
|
...(reqOpts?.headers ?? {}),
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
const
|
|
77
|
+
const explicitProject = reqOpts && 'project' in reqOpts ? reqOpts.project : options?.project
|
|
78
|
+
const project = await detectProject(explicitProject)
|
|
76
79
|
if (project) headers['X-Brainjar-Project'] = project
|
|
77
80
|
if (session) headers['X-Brainjar-Session'] = session
|
|
78
81
|
|
package/src/commands/brain.ts
CHANGED
|
@@ -160,7 +160,26 @@ export const brain = Cli.create('brain', {
|
|
|
160
160
|
},
|
|
161
161
|
})
|
|
162
162
|
.command('drop', {
|
|
163
|
-
description: '
|
|
163
|
+
description: 'Deactivate the current brain — clears soul, persona, and rules',
|
|
164
|
+
options: z.object({
|
|
165
|
+
project: z.boolean().default(false).describe('Remove project brain override or deactivate workspace brain'),
|
|
166
|
+
}),
|
|
167
|
+
async run(c) {
|
|
168
|
+
const api = await getApi()
|
|
169
|
+
|
|
170
|
+
const mutationOpts = c.options.project
|
|
171
|
+
? { project: basename(process.cwd()) }
|
|
172
|
+
: undefined
|
|
173
|
+
await putState(api, { soul_slug: '', persona_slug: '', rule_slugs: [] }, mutationOpts)
|
|
174
|
+
|
|
175
|
+
await sync({ api })
|
|
176
|
+
if (c.options.project) await sync({ api, project: true })
|
|
177
|
+
|
|
178
|
+
return { deactivated: true, project: c.options.project }
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
.command('delete', {
|
|
182
|
+
description: 'Delete a brain permanently',
|
|
164
183
|
args: z.object({
|
|
165
184
|
name: z.string().describe('Brain name to delete'),
|
|
166
185
|
}),
|
|
@@ -177,6 +196,6 @@ export const brain = Cli.create('brain', {
|
|
|
177
196
|
throw e
|
|
178
197
|
}
|
|
179
198
|
|
|
180
|
-
return {
|
|
199
|
+
return { deleted: name }
|
|
181
200
|
},
|
|
182
201
|
})
|
package/src/commands/persona.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
const { IncurError } = Errors
|
|
5
5
|
import { ErrorCode, createError } from '../errors.js'
|
|
6
|
-
import { normalizeSlug, getEffectiveState, putState } from '../state.js'
|
|
6
|
+
import { normalizeSlug, getEffectiveState, getStateOverride, putState } from '../state.js'
|
|
7
7
|
import { sync } from '../sync.js'
|
|
8
8
|
import { getApi } from '../client.js'
|
|
9
9
|
import type { ApiPersona, ApiPersonaList, ApiRuleList } from '../api-types.js'
|
|
@@ -17,6 +17,7 @@ export const persona = Cli.create('persona', {
|
|
|
17
17
|
name: z.string().describe('Persona name'),
|
|
18
18
|
}),
|
|
19
19
|
options: z.object({
|
|
20
|
+
content: z.string().optional().describe('Persona content (if omitted, creates with a starter template you can edit)'),
|
|
20
21
|
description: z.string().optional().describe('One-line description of the persona'),
|
|
21
22
|
rules: z.array(z.string()).optional().describe('Rules to bundle with this persona'),
|
|
22
23
|
}),
|
|
@@ -50,24 +51,29 @@ export const persona = Cli.create('persona', {
|
|
|
50
51
|
|
|
51
52
|
const effectiveRules = rulesList
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
lines
|
|
54
|
+
let content: string
|
|
55
|
+
if (c.options.content) {
|
|
56
|
+
content = c.options.content.trim()
|
|
57
|
+
} else {
|
|
58
|
+
const lines: string[] = []
|
|
59
|
+
lines.push(`# ${name}`)
|
|
60
|
+
lines.push('')
|
|
61
|
+
if (c.options.description) {
|
|
62
|
+
lines.push(c.options.description)
|
|
63
|
+
}
|
|
64
|
+
lines.push('')
|
|
65
|
+
lines.push('## Direct mode')
|
|
66
|
+
lines.push('- ')
|
|
67
|
+
lines.push('')
|
|
68
|
+
lines.push('## Subagent mode')
|
|
69
|
+
lines.push('- ')
|
|
70
|
+
lines.push('')
|
|
71
|
+
lines.push('## Always')
|
|
72
|
+
lines.push('- ')
|
|
73
|
+
lines.push('')
|
|
74
|
+
content = lines.join('\n')
|
|
58
75
|
}
|
|
59
|
-
lines.push('')
|
|
60
|
-
lines.push('## Direct mode')
|
|
61
|
-
lines.push('- ')
|
|
62
|
-
lines.push('')
|
|
63
|
-
lines.push('## Subagent mode')
|
|
64
|
-
lines.push('- ')
|
|
65
|
-
lines.push('')
|
|
66
|
-
lines.push('## Always')
|
|
67
|
-
lines.push('- ')
|
|
68
|
-
lines.push('')
|
|
69
76
|
|
|
70
|
-
const content = lines.join('\n')
|
|
71
77
|
await api.put<ApiPersona>(`/api/v1/personas/${name}`, {
|
|
72
78
|
content,
|
|
73
79
|
bundled_rules: effectiveRules,
|
|
@@ -87,11 +93,12 @@ export const persona = Cli.create('persona', {
|
|
|
87
93
|
},
|
|
88
94
|
})
|
|
89
95
|
.command('update', {
|
|
90
|
-
description: 'Update a persona\'s content (reads from stdin)',
|
|
96
|
+
description: 'Update a persona\'s content (reads from stdin or --content)',
|
|
91
97
|
args: z.object({
|
|
92
98
|
name: z.string().describe('Persona name'),
|
|
93
99
|
}),
|
|
94
100
|
options: z.object({
|
|
101
|
+
content: z.string().optional().describe('Persona content (reads from stdin if omitted)'),
|
|
95
102
|
rules: z.array(z.string()).optional().describe('Update bundled rules'),
|
|
96
103
|
}),
|
|
97
104
|
async run(c) {
|
|
@@ -109,11 +116,14 @@ export const persona = Cli.create('persona', {
|
|
|
109
116
|
throw e
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
chunks
|
|
119
|
+
let content = c.options.content?.trim()
|
|
120
|
+
if (!content) {
|
|
121
|
+
const chunks: Uint8Array[] = []
|
|
122
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
123
|
+
chunks.push(chunk)
|
|
124
|
+
}
|
|
125
|
+
content = Buffer.concat(chunks).toString().trim()
|
|
115
126
|
}
|
|
116
|
-
const content = Buffer.concat(chunks).toString().trim()
|
|
117
127
|
|
|
118
128
|
// Validate rules if provided
|
|
119
129
|
const rulesList = c.options.rules
|
|
@@ -181,7 +191,7 @@ export const persona = Cli.create('persona', {
|
|
|
181
191
|
}
|
|
182
192
|
|
|
183
193
|
if (c.options.project) {
|
|
184
|
-
const state = await
|
|
194
|
+
const state = await getStateOverride(api, {
|
|
185
195
|
project: basename(process.cwd()),
|
|
186
196
|
})
|
|
187
197
|
if (state.persona_slug === undefined) return { active: false, scope: 'project', note: 'No project persona override (cascades from workspace)' }
|
|
@@ -245,6 +255,31 @@ export const persona = Cli.create('persona', {
|
|
|
245
255
|
return result
|
|
246
256
|
},
|
|
247
257
|
})
|
|
258
|
+
.command('delete', {
|
|
259
|
+
description: 'Delete a persona permanently',
|
|
260
|
+
args: z.object({
|
|
261
|
+
name: z.string().describe('Persona name to delete'),
|
|
262
|
+
}),
|
|
263
|
+
async run(c) {
|
|
264
|
+
const name = normalizeSlug(c.args.name, 'persona name')
|
|
265
|
+
const api = await getApi()
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
await api.delete(`/api/v1/personas/${name}`)
|
|
269
|
+
} catch (e) {
|
|
270
|
+
if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
|
|
271
|
+
throw createError(ErrorCode.PERSONA_NOT_FOUND, { params: [name] })
|
|
272
|
+
}
|
|
273
|
+
throw e
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// If this persona was active, sync to reflect removal
|
|
277
|
+
const state = await getEffectiveState(api)
|
|
278
|
+
if (state.persona === name) await sync({ api })
|
|
279
|
+
|
|
280
|
+
return { deleted: name }
|
|
281
|
+
},
|
|
282
|
+
})
|
|
248
283
|
.command('drop', {
|
|
249
284
|
description: 'Deactivate the current persona',
|
|
250
285
|
options: z.object({
|
|
@@ -256,7 +291,7 @@ export const persona = Cli.create('persona', {
|
|
|
256
291
|
const mutationOpts = c.options.project
|
|
257
292
|
? { project: basename(process.cwd()) }
|
|
258
293
|
: undefined
|
|
259
|
-
await putState(api, { persona_slug:
|
|
294
|
+
await putState(api, { persona_slug: '' }, mutationOpts)
|
|
260
295
|
|
|
261
296
|
await sync({ api })
|
|
262
297
|
if (c.options.project) await sync({ api, project: true })
|
package/src/commands/rules.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
const { IncurError } = Errors
|
|
5
5
|
import { ErrorCode, createError } from '../errors.js'
|
|
6
|
-
import { normalizeSlug, getEffectiveState, putState } from '../state.js'
|
|
6
|
+
import { normalizeSlug, getEffectiveState, getStateOverride, putState } from '../state.js'
|
|
7
7
|
import { sync } from '../sync.js'
|
|
8
8
|
import { getApi } from '../client.js'
|
|
9
9
|
import type { ApiRule, ApiRuleList } from '../api-types.js'
|
|
@@ -17,6 +17,7 @@ export const rules = Cli.create('rules', {
|
|
|
17
17
|
name: z.string().describe('Rule name'),
|
|
18
18
|
}),
|
|
19
19
|
options: z.object({
|
|
20
|
+
content: z.string().optional().describe('Rule content (if omitted, creates with a starter template you can edit)'),
|
|
20
21
|
description: z.string().optional().describe('One-line description of the rule'),
|
|
21
22
|
}),
|
|
22
23
|
async run(c) {
|
|
@@ -32,15 +33,20 @@ export const rules = Cli.create('rules', {
|
|
|
32
33
|
if (e instanceof IncurError && e.code !== ErrorCode.NOT_FOUND) throw e
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
let scaffold: string
|
|
37
|
+
if (c.options.content) {
|
|
38
|
+
scaffold = c.options.content.trim()
|
|
39
|
+
} else {
|
|
40
|
+
scaffold = [
|
|
41
|
+
`# ${name}`,
|
|
42
|
+
'',
|
|
43
|
+
c.options.description ?? 'Describe what this rule enforces and why.',
|
|
44
|
+
'',
|
|
45
|
+
'## Constraints',
|
|
46
|
+
'- ',
|
|
47
|
+
'',
|
|
48
|
+
].join('\n')
|
|
49
|
+
}
|
|
44
50
|
|
|
45
51
|
await api.put<ApiRule>(`/api/v1/rules/${name}`, {
|
|
46
52
|
entries: [{ name: `${name}.md`, content: scaffold }],
|
|
@@ -59,10 +65,13 @@ export const rules = Cli.create('rules', {
|
|
|
59
65
|
},
|
|
60
66
|
})
|
|
61
67
|
.command('update', {
|
|
62
|
-
description: 'Update a rule\'s content (reads from stdin)',
|
|
68
|
+
description: 'Update a rule\'s content (reads from stdin or --content)',
|
|
63
69
|
args: z.object({
|
|
64
70
|
name: z.string().describe('Rule name'),
|
|
65
71
|
}),
|
|
72
|
+
options: z.object({
|
|
73
|
+
content: z.string().optional().describe('Rule content (reads from stdin if omitted)'),
|
|
74
|
+
}),
|
|
66
75
|
async run(c) {
|
|
67
76
|
const name = normalizeSlug(c.args.name, 'rule name')
|
|
68
77
|
const api = await getApi()
|
|
@@ -77,11 +86,14 @@ export const rules = Cli.create('rules', {
|
|
|
77
86
|
throw e
|
|
78
87
|
}
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
chunks
|
|
89
|
+
let content = c.options.content?.trim()
|
|
90
|
+
if (!content) {
|
|
91
|
+
const chunks: Uint8Array[] = []
|
|
92
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
93
|
+
chunks.push(chunk)
|
|
94
|
+
}
|
|
95
|
+
content = Buffer.concat(chunks).toString().trim()
|
|
83
96
|
}
|
|
84
|
-
const content = Buffer.concat(chunks).toString().trim()
|
|
85
97
|
|
|
86
98
|
if (!content) {
|
|
87
99
|
throw createError(ErrorCode.MISSING_ARG, {
|
|
@@ -112,7 +124,7 @@ export const rules = Cli.create('rules', {
|
|
|
112
124
|
const availableSlugs = available.rules.map(r => r.slug)
|
|
113
125
|
|
|
114
126
|
if (c.options.project) {
|
|
115
|
-
const override = await
|
|
127
|
+
const override = await getStateOverride(api, {
|
|
116
128
|
project: basename(process.cwd()),
|
|
117
129
|
})
|
|
118
130
|
return {
|
|
@@ -182,13 +194,38 @@ export const rules = Cli.create('rules', {
|
|
|
182
194
|
return { activated: name, project: c.options.project }
|
|
183
195
|
},
|
|
184
196
|
})
|
|
185
|
-
.command('
|
|
197
|
+
.command('delete', {
|
|
198
|
+
description: 'Delete a rule permanently',
|
|
199
|
+
args: z.object({
|
|
200
|
+
name: z.string().describe('Rule name to delete'),
|
|
201
|
+
}),
|
|
202
|
+
async run(c) {
|
|
203
|
+
const name = normalizeSlug(c.args.name, 'rule name')
|
|
204
|
+
const api = await getApi()
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await api.delete(`/api/v1/rules/${name}`)
|
|
208
|
+
} catch (e) {
|
|
209
|
+
if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
|
|
210
|
+
throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
|
|
211
|
+
}
|
|
212
|
+
throw e
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// If this rule was active, sync to reflect removal
|
|
216
|
+
const state = await getEffectiveState(api)
|
|
217
|
+
if (state.rules.includes(name)) await sync({ api })
|
|
218
|
+
|
|
219
|
+
return { deleted: name }
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
.command('drop', {
|
|
186
223
|
description: 'Deactivate a rule',
|
|
187
224
|
args: z.object({
|
|
188
|
-
name: z.string().describe('Rule name to
|
|
225
|
+
name: z.string().describe('Rule name to deactivate'),
|
|
189
226
|
}),
|
|
190
227
|
options: z.object({
|
|
191
|
-
project: z.boolean().default(false).describe('
|
|
228
|
+
project: z.boolean().default(false).describe('Deactivate rule at project scope'),
|
|
192
229
|
}),
|
|
193
230
|
async run(c) {
|
|
194
231
|
const name = normalizeSlug(c.args.name, 'rule name')
|
package/src/commands/soul.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
const { IncurError } = Errors
|
|
5
5
|
import { ErrorCode, createError } from '../errors.js'
|
|
6
|
-
import { normalizeSlug, getEffectiveState, putState } from '../state.js'
|
|
6
|
+
import { normalizeSlug, getEffectiveState, getStateOverride, putState } from '../state.js'
|
|
7
7
|
import { sync } from '../sync.js'
|
|
8
8
|
import { getApi } from '../client.js'
|
|
9
9
|
import type { ApiSoul, ApiSoulList } from '../api-types.js'
|
|
@@ -17,6 +17,7 @@ export const soul = Cli.create('soul', {
|
|
|
17
17
|
name: z.string().describe('Soul name'),
|
|
18
18
|
}),
|
|
19
19
|
options: z.object({
|
|
20
|
+
content: z.string().optional().describe('Soul content (if omitted, creates with a starter template you can edit)'),
|
|
20
21
|
description: z.string().optional().describe('One-line description of the soul'),
|
|
21
22
|
}),
|
|
22
23
|
async run(c) {
|
|
@@ -32,24 +33,29 @@ export const soul = Cli.create('soul', {
|
|
|
32
33
|
if (e instanceof IncurError && e.code !== ErrorCode.NOT_FOUND) throw e
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
lines
|
|
36
|
+
let content: string
|
|
37
|
+
if (c.options.content) {
|
|
38
|
+
content = c.options.content.trim()
|
|
39
|
+
} else {
|
|
40
|
+
const lines: string[] = []
|
|
41
|
+
lines.push(`# ${name}`)
|
|
40
42
|
lines.push('')
|
|
43
|
+
if (c.options.description) {
|
|
44
|
+
lines.push(c.options.description)
|
|
45
|
+
lines.push('')
|
|
46
|
+
}
|
|
47
|
+
lines.push('## Voice')
|
|
48
|
+
lines.push('- ')
|
|
49
|
+
lines.push('')
|
|
50
|
+
lines.push('## Character')
|
|
51
|
+
lines.push('- ')
|
|
52
|
+
lines.push('')
|
|
53
|
+
lines.push('## Standards')
|
|
54
|
+
lines.push('- ')
|
|
55
|
+
lines.push('')
|
|
56
|
+
content = lines.join('\n')
|
|
41
57
|
}
|
|
42
|
-
|
|
43
|
-
lines.push('- ')
|
|
44
|
-
lines.push('')
|
|
45
|
-
lines.push('## Character')
|
|
46
|
-
lines.push('- ')
|
|
47
|
-
lines.push('')
|
|
48
|
-
lines.push('## Standards')
|
|
49
|
-
lines.push('- ')
|
|
50
|
-
lines.push('')
|
|
51
|
-
|
|
52
|
-
const content = lines.join('\n')
|
|
58
|
+
|
|
53
59
|
await api.put<ApiSoul>(`/api/v1/souls/${name}`, { content })
|
|
54
60
|
|
|
55
61
|
if (c.agent || c.formatExplicit) {
|
|
@@ -65,10 +71,13 @@ export const soul = Cli.create('soul', {
|
|
|
65
71
|
},
|
|
66
72
|
})
|
|
67
73
|
.command('update', {
|
|
68
|
-
description: 'Update a soul\'s content (reads from stdin)',
|
|
74
|
+
description: 'Update a soul\'s content (reads from stdin or --content)',
|
|
69
75
|
args: z.object({
|
|
70
76
|
name: z.string().describe('Soul name'),
|
|
71
77
|
}),
|
|
78
|
+
options: z.object({
|
|
79
|
+
content: z.string().optional().describe('Soul content (reads from stdin if omitted)'),
|
|
80
|
+
}),
|
|
72
81
|
async run(c) {
|
|
73
82
|
const name = normalizeSlug(c.args.name, 'soul name')
|
|
74
83
|
const api = await getApi()
|
|
@@ -83,11 +92,14 @@ export const soul = Cli.create('soul', {
|
|
|
83
92
|
throw e
|
|
84
93
|
}
|
|
85
94
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
chunks
|
|
95
|
+
let content = c.options.content?.trim()
|
|
96
|
+
if (!content) {
|
|
97
|
+
const chunks: Uint8Array[] = []
|
|
98
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
99
|
+
chunks.push(chunk)
|
|
100
|
+
}
|
|
101
|
+
content = Buffer.concat(chunks).toString().trim()
|
|
89
102
|
}
|
|
90
|
-
const content = Buffer.concat(chunks).toString().trim()
|
|
91
103
|
|
|
92
104
|
if (!content) {
|
|
93
105
|
throw createError(ErrorCode.MISSING_ARG, {
|
|
@@ -145,7 +157,7 @@ export const soul = Cli.create('soul', {
|
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
if (c.options.project) {
|
|
148
|
-
const state = await
|
|
160
|
+
const state = await getStateOverride(api, {
|
|
149
161
|
project: basename(process.cwd()),
|
|
150
162
|
})
|
|
151
163
|
if (state.soul_slug === undefined) return { active: false, scope: 'project', note: 'No project soul override (cascades from workspace)' }
|
|
@@ -201,6 +213,31 @@ export const soul = Cli.create('soul', {
|
|
|
201
213
|
return { activated: name, project: c.options.project }
|
|
202
214
|
},
|
|
203
215
|
})
|
|
216
|
+
.command('delete', {
|
|
217
|
+
description: 'Delete a soul permanently',
|
|
218
|
+
args: z.object({
|
|
219
|
+
name: z.string().describe('Soul name to delete'),
|
|
220
|
+
}),
|
|
221
|
+
async run(c) {
|
|
222
|
+
const name = normalizeSlug(c.args.name, 'soul name')
|
|
223
|
+
const api = await getApi()
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await api.delete(`/api/v1/souls/${name}`)
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
|
|
229
|
+
throw createError(ErrorCode.SOUL_NOT_FOUND, { params: [name] })
|
|
230
|
+
}
|
|
231
|
+
throw e
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// If this soul was active, sync to reflect removal
|
|
235
|
+
const state = await getEffectiveState(api)
|
|
236
|
+
if (state.soul === name) await sync({ api })
|
|
237
|
+
|
|
238
|
+
return { deleted: name }
|
|
239
|
+
},
|
|
240
|
+
})
|
|
204
241
|
.command('drop', {
|
|
205
242
|
description: 'Deactivate the current soul',
|
|
206
243
|
options: z.object({
|
|
@@ -212,7 +249,7 @@ export const soul = Cli.create('soul', {
|
|
|
212
249
|
const mutationOpts = c.options.project
|
|
213
250
|
? { project: basename(process.cwd()) }
|
|
214
251
|
: undefined
|
|
215
|
-
await putState(api, { soul_slug:
|
|
252
|
+
await putState(api, { soul_slug: '' }, mutationOpts)
|
|
216
253
|
|
|
217
254
|
await sync({ api })
|
|
218
255
|
if (c.options.project) await sync({ api, project: true })
|
package/src/commands/status.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { Cli, z } from 'incur'
|
|
2
2
|
import { basename } from 'node:path'
|
|
3
|
-
import { getEffectiveState } from '../state.js'
|
|
3
|
+
import { getEffectiveState, getStateOverride } from '../state.js'
|
|
4
4
|
import { sync } from '../sync.js'
|
|
5
5
|
import { getApi } from '../client.js'
|
|
6
|
-
import type { ApiStateOverride } from '../api-types.js'
|
|
7
6
|
|
|
8
7
|
export const status = Cli.create('status', {
|
|
9
8
|
description: 'Show active brain configuration',
|
|
@@ -35,11 +34,11 @@ export const status = Cli.create('status', {
|
|
|
35
34
|
|
|
36
35
|
// --workspace: show only workspace-level override
|
|
37
36
|
if (c.options.workspace) {
|
|
38
|
-
const override = await
|
|
37
|
+
const override = await getStateOverride(api, { project: null })
|
|
39
38
|
const result: Record<string, unknown> = {
|
|
40
39
|
soul: override.soul_slug ?? null,
|
|
41
40
|
persona: override.persona_slug ?? null,
|
|
42
|
-
rules: override.
|
|
41
|
+
rules: override.rules_to_add ?? [],
|
|
43
42
|
}
|
|
44
43
|
if (synced) result.synced = synced
|
|
45
44
|
return result
|
|
@@ -47,7 +46,7 @@ export const status = Cli.create('status', {
|
|
|
47
46
|
|
|
48
47
|
// --project: show only project-level overrides
|
|
49
48
|
if (c.options.project) {
|
|
50
|
-
const override = await
|
|
49
|
+
const override = await getStateOverride(api, {
|
|
51
50
|
project: basename(process.cwd()),
|
|
52
51
|
})
|
|
53
52
|
const result: Record<string, unknown> = {}
|
package/src/daemon.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, execFileSync } from 'node:child_process'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import { readFile, writeFile, rm, access, open, chmod, mkdir } from 'node:fs/promises'
|
|
3
|
+
import { readFile, writeFile, rm, access, open, chmod, mkdir, constants } from 'node:fs/promises'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { Errors } from 'incur'
|
|
@@ -8,6 +8,7 @@ import { readConfig, activeContext, localContext } from './config.js'
|
|
|
8
8
|
import { ErrorCode, createError } from './errors.js'
|
|
9
9
|
|
|
10
10
|
export const DIST_BASE = 'https://get.brainjar.sh/brainjar-server'
|
|
11
|
+
export const SEMVER_RE = /^v?\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9.]+)?$/
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Compare two semver strings. Returns -1, 0, or 1.
|
|
@@ -30,7 +31,7 @@ const { IncurError } = Errors
|
|
|
30
31
|
* Minimum server version this CLI is compatible with.
|
|
31
32
|
* Bump when the CLI depends on server features/API changes.
|
|
32
33
|
*/
|
|
33
|
-
export const MIN_SERVER_VERSION = '0.2.
|
|
34
|
+
export const MIN_SERVER_VERSION = '0.2.4'
|
|
34
35
|
|
|
35
36
|
export interface HealthStatus {
|
|
36
37
|
healthy: boolean
|
|
@@ -156,7 +157,13 @@ export async function fetchLatestVersion(distBase: string = DIST_BASE): Promise<
|
|
|
156
157
|
hint: 'Check your network connection or try again later.',
|
|
157
158
|
})
|
|
158
159
|
}
|
|
159
|
-
|
|
160
|
+
const version = (await response.text()).trim()
|
|
161
|
+
if (!SEMVER_RE.test(version)) {
|
|
162
|
+
throw createError(ErrorCode.VALIDATION_ERROR, {
|
|
163
|
+
message: `Invalid server version string from distribution: "${version}"`,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
return version
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
/**
|
|
@@ -268,6 +275,12 @@ export async function upgradeServer(): Promise<{ version: string; alreadyLatest:
|
|
|
268
275
|
return { version, alreadyLatest: true }
|
|
269
276
|
}
|
|
270
277
|
|
|
278
|
+
// Stop server before replacing binary to avoid ETXTBSY on Linux
|
|
279
|
+
const pid = await readPid(localContext(config).pid_file)
|
|
280
|
+
if (pid !== null && isAlive(pid)) {
|
|
281
|
+
await stop()
|
|
282
|
+
}
|
|
283
|
+
|
|
271
284
|
const versionBase = `${DIST_BASE}/${version}`
|
|
272
285
|
await downloadAndVerify(binPath, versionBase)
|
|
273
286
|
await setInstalledServerVersion(version)
|
|
@@ -397,9 +410,25 @@ export async function readLogFile(options?: { lines?: number }): Promise<string>
|
|
|
397
410
|
}
|
|
398
411
|
}
|
|
399
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Try to acquire an exclusive lock file. Returns a release function on success, null if already locked.
|
|
415
|
+
*/
|
|
416
|
+
async function tryLock(lockFile: string): Promise<(() => Promise<void>) | null> {
|
|
417
|
+
try {
|
|
418
|
+
const fd = await open(lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY)
|
|
419
|
+
await fd.write(String(process.pid))
|
|
420
|
+
await fd.close()
|
|
421
|
+
return async () => { await rm(lockFile, { force: true }) }
|
|
422
|
+
} catch (e) {
|
|
423
|
+
if ((e as NodeJS.ErrnoException).code === 'EEXIST') return null
|
|
424
|
+
throw e
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
400
428
|
/**
|
|
401
429
|
* Ensure the server is running and healthy.
|
|
402
430
|
* Called by commands before making API calls.
|
|
431
|
+
* Uses a file lock to prevent parallel CLI invocations from spawning multiple server processes.
|
|
403
432
|
*/
|
|
404
433
|
export async function ensureRunning(): Promise<void> {
|
|
405
434
|
const config = await readConfig()
|
|
@@ -420,20 +449,31 @@ export async function ensureRunning(): Promise<void> {
|
|
|
420
449
|
})
|
|
421
450
|
}
|
|
422
451
|
|
|
423
|
-
// Local mode: auto-start
|
|
424
|
-
|
|
452
|
+
// Local mode: auto-start with file lock to prevent races
|
|
453
|
+
const lockFile = `${local.pid_file}.lock`
|
|
454
|
+
const release = await tryLock(lockFile)
|
|
425
455
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
456
|
+
if (release) {
|
|
457
|
+
// We hold the lock — we're responsible for starting
|
|
458
|
+
try {
|
|
459
|
+
await cleanStalePid(local.pid_file)
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
await start()
|
|
463
|
+
} catch (e) {
|
|
464
|
+
if (e instanceof IncurError) throw e
|
|
465
|
+
throw createError(ErrorCode.SERVER_START_FAILED, {
|
|
466
|
+
message: 'Failed to start brainjar server.',
|
|
467
|
+
hint: `Check ${local.log_file}`,
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
} finally {
|
|
471
|
+
await release()
|
|
472
|
+
}
|
|
434
473
|
}
|
|
435
474
|
|
|
436
475
|
// Poll until healthy (200ms intervals, 10s timeout)
|
|
476
|
+
// Both the lock holder and waiters converge here
|
|
437
477
|
const deadline = Date.now() + 10_000
|
|
438
478
|
while (Date.now() < deadline) {
|
|
439
479
|
await new Promise(r => setTimeout(r, 200))
|
package/src/pack.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, readdir, writeFile, access, mkdir, stat } from 'node:fs/promises'
|
|
2
|
-
import { join, dirname } from 'node:path'
|
|
2
|
+
import { join, dirname, basename } from 'node:path'
|
|
3
3
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
4
4
|
import { Errors } from 'incur'
|
|
5
5
|
import { ErrorCode, createError } from './errors.js'
|
|
@@ -127,7 +127,13 @@ export async function exportPack(brainName: string, options: ExportOptions = {})
|
|
|
127
127
|
const ruleDir = join(packDir, 'rules', ruleSlug)
|
|
128
128
|
await mkdir(ruleDir, { recursive: true })
|
|
129
129
|
for (const entry of rule.entries) {
|
|
130
|
-
|
|
130
|
+
const safeName = basename(entry.name)
|
|
131
|
+
if (!safeName || safeName !== entry.name) {
|
|
132
|
+
throw createError(ErrorCode.PACK_INVALID_MANIFEST, {
|
|
133
|
+
message: `Rule entry name contains invalid path: "${entry.name}"`,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
await writeFile(join(ruleDir, safeName), entry.content)
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
139
|
}
|
package/src/state.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { BrainjarClient } from './client.js'
|
|
2
|
-
import type { ApiEffectiveState, ApiStateMutation } from './api-types.js'
|
|
1
|
+
import type { BrainjarClient, RequestOptions } from './client.js'
|
|
2
|
+
import type { ApiEffectiveState, ApiStateMutation, ApiStateOverride, ApiStateOverrideResponse } from './api-types.js'
|
|
3
3
|
|
|
4
4
|
const SLUG_RE = /^[a-zA-Z0-9_-]+$/
|
|
5
5
|
|
|
@@ -15,8 +15,14 @@ export function normalizeSlug(value: string, label: string): string {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** Fetch the fully resolved effective state from the server. */
|
|
18
|
-
export async function getEffectiveState(api: BrainjarClient): Promise<ApiEffectiveState> {
|
|
19
|
-
return api.get<ApiEffectiveState>('/api/v1/state')
|
|
18
|
+
export async function getEffectiveState(api: BrainjarClient, options?: RequestOptions): Promise<ApiEffectiveState> {
|
|
19
|
+
return api.get<ApiEffectiveState>('/api/v1/state', options)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Fetch the raw override at a specific scope, unwrapping the server envelope. */
|
|
23
|
+
export async function getStateOverride(api: BrainjarClient, options?: RequestOptions): Promise<ApiStateOverride> {
|
|
24
|
+
const resp = await api.get<ApiStateOverrideResponse>('/api/v1/state/override', options)
|
|
25
|
+
return resp.override ?? {}
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** Mutate state on the server. Pass options.project to scope the mutation to a project. */
|
package/src/sync.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { basename } from 'node:path'
|
|
2
3
|
import { type Backend, getBackendConfig } from './paths.js'
|
|
3
4
|
import { getEffectiveState } from './state.js'
|
|
4
5
|
import { getApi, type BrainjarClient } from './client.js'
|
|
@@ -72,7 +73,7 @@ export async function sync(options?: SyncOptions) {
|
|
|
72
73
|
const opts = options ?? {}
|
|
73
74
|
const api = opts.api ?? await getApi()
|
|
74
75
|
|
|
75
|
-
const state = await getEffectiveState(api)
|
|
76
|
+
const state = await getEffectiveState(api, opts.project ? { project: basename(process.cwd()) } : undefined)
|
|
76
77
|
const backend: Backend = opts.backend ?? 'claude'
|
|
77
78
|
const config = getBackendConfig(backend, { local: opts.project })
|
|
78
79
|
|
package/src/upgrade.ts
CHANGED
|
@@ -2,14 +2,22 @@ import { execFile } from 'node:child_process'
|
|
|
2
2
|
import {
|
|
3
3
|
healthCheck,
|
|
4
4
|
start,
|
|
5
|
-
stop,
|
|
6
5
|
status as daemonStatus,
|
|
7
6
|
upgradeServer,
|
|
7
|
+
SEMVER_RE,
|
|
8
8
|
} from './daemon.js'
|
|
9
9
|
import { checkForUpdates } from './version-check.js'
|
|
10
10
|
import { ErrorCode, createError } from './errors.js'
|
|
11
11
|
import pkg from '../package.json'
|
|
12
12
|
|
|
13
|
+
function validateVersion(v: string, label: string): void {
|
|
14
|
+
if (!SEMVER_RE.test(v)) {
|
|
15
|
+
throw createError(ErrorCode.VALIDATION_ERROR, {
|
|
16
|
+
message: `Invalid ${label} version string: "${v}"`,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
export interface ComponentResult {
|
|
14
22
|
upgraded: boolean
|
|
15
23
|
from: string
|
|
@@ -59,6 +67,7 @@ export async function upgradeCli(): Promise<ComponentResult> {
|
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
const latestVersion = updates.cli.latest
|
|
70
|
+
validateVersion(latestVersion, 'CLI')
|
|
62
71
|
const pm = detectPackageManager()
|
|
63
72
|
|
|
64
73
|
try {
|
|
@@ -88,14 +97,10 @@ export async function upgradeServerBinary(): Promise<ServerResult> {
|
|
|
88
97
|
const { getInstalledServerVersion } = await import('./version-check.js')
|
|
89
98
|
const installedVersion = (await getInstalledServerVersion()) ?? 'unknown'
|
|
90
99
|
|
|
91
|
-
//
|
|
100
|
+
// upgradeServer() stops the server internally before replacing the binary
|
|
92
101
|
const s = await daemonStatus()
|
|
93
102
|
const wasRunning = s.running
|
|
94
103
|
|
|
95
|
-
if (wasRunning) {
|
|
96
|
-
await stop()
|
|
97
|
-
}
|
|
98
|
-
|
|
99
104
|
const result = await upgradeServer()
|
|
100
105
|
|
|
101
106
|
if (result.alreadyLatest) {
|