@brainjar/cli 0.5.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainjar/cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.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",
@@ -160,7 +160,26 @@ export const brain = Cli.create('brain', {
160
160
  },
161
161
  })
162
162
  .command('drop', {
163
- description: 'Delete a brain',
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 { dropped: name }
199
+ return { deleted: name }
181
200
  },
182
201
  })
@@ -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
- const lines: string[] = []
54
- lines.push(`# ${name}`)
55
- lines.push('')
56
- if (c.options.description) {
57
- lines.push(c.options.description)
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
- const chunks: Uint8Array[] = []
113
- for await (const chunk of Bun.stdin.stream()) {
114
- chunks.push(chunk)
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
@@ -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: null }, mutationOpts)
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 })
@@ -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
- const scaffold = [
36
- `# ${name}`,
37
- '',
38
- c.options.description ?? 'Describe what this rule enforces and why.',
39
- '',
40
- '## Constraints',
41
- '- ',
42
- '',
43
- ].join('\n')
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
- const chunks: Uint8Array[] = []
81
- for await (const chunk of Bun.stdin.stream()) {
82
- chunks.push(chunk)
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, {
@@ -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('remove', {
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 remove'),
225
+ name: z.string().describe('Rule name to deactivate'),
189
226
  }),
190
227
  options: z.object({
191
- project: z.boolean().default(false).describe('Remove rule at project scope'),
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')
@@ -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
- const lines: string[] = []
36
- lines.push(`# ${name}`)
37
- lines.push('')
38
- if (c.options.description) {
39
- lines.push(c.options.description)
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
- lines.push('## Voice')
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
- const chunks: Uint8Array[] = []
87
- for await (const chunk of Bun.stdin.stream()) {
88
- chunks.push(chunk)
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, {
@@ -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: null }, mutationOpts)
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/daemon.ts CHANGED
@@ -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.
@@ -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
- return (await response.text()).trim()
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
  /**
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
- await writeFile(join(ruleDir, entry.name), entry.content)
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/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 {