@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.
@@ -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 { readdir, readFile, writeFile, access } from 'node:fs/promises'
5
- import { join, basename } from 'node:path'
6
- import { paths } from '../paths.js'
7
- import { type State, readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, requireBrainjarDir, parseLayerFrontmatter, stripFrontmatter, 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 { ApiPersona, ApiPersonaList, ApiRuleList } from '../api-types.js'
9
10
 
10
11
  export const persona = Cli.create('persona', {
11
12
  description: 'Manage personas — role behavior and workflow for the agent',
@@ -13,53 +14,43 @@ export const persona = Cli.create('persona', {
13
14
  .command('create', {
14
15
  description: 'Create a new persona',
15
16
  args: z.object({
16
- name: z.string().describe('Persona name (will be used as filename)'),
17
+ name: z.string().describe('Persona name'),
17
18
  }),
18
19
  options: z.object({
19
20
  description: z.string().optional().describe('One-line description of the persona'),
20
21
  rules: z.array(z.string()).optional().describe('Rules to bundle with this persona'),
21
22
  }),
22
23
  async run(c) {
23
- await requireBrainjarDir()
24
24
  const name = normalizeSlug(c.args.name, 'persona name')
25
- const dest = join(paths.personas, `${name}.md`)
25
+ const api = await getApi()
26
26
 
27
+ // Check if it already exists
27
28
  try {
28
- await access(dest)
29
- throw new IncurError({
30
- code: 'PERSONA_EXISTS',
31
- message: `Persona "${name}" already exists.`,
32
- hint: 'Choose a different name or edit the existing file.',
33
- })
29
+ await api.get<ApiPersona>(`/api/v1/personas/${name}`)
30
+ throw createError(ErrorCode.PERSONA_EXISTS, { params: [name] })
34
31
  } catch (e) {
35
- if (e instanceof IncurError) throw e
32
+ if (e instanceof IncurError && e.code === ErrorCode.PERSONA_EXISTS) throw e
33
+ if (e instanceof IncurError && e.code !== ErrorCode.NOT_FOUND) throw e
36
34
  }
37
35
 
38
36
  const rulesList = c.options.rules ?? []
39
37
 
40
- // Validate rules exist
41
- const availableRules = await listAvailableRules()
42
- const invalid = rulesList.filter(r => !availableRules.includes(r))
43
- if (invalid.length > 0) {
44
- throw new IncurError({
45
- code: 'RULES_NOT_FOUND',
46
- message: `Rules not found: ${invalid.join(', ')}`,
47
- hint: `Available rules: ${availableRules.join(', ')}`,
48
- })
38
+ // Validate rules exist on server
39
+ if (rulesList.length > 0) {
40
+ const available = await api.get<ApiRuleList>('/api/v1/rules')
41
+ const availableSlugs = available.rules.map(r => r.slug)
42
+ const invalid = rulesList.filter(r => !availableSlugs.includes(r))
43
+ if (invalid.length > 0) {
44
+ throw createError(ErrorCode.RULES_NOT_FOUND, {
45
+ message: `Rules not found: ${invalid.join(', ')}`,
46
+ hint: `Available rules: ${availableSlugs.join(', ')}`,
47
+ })
48
+ }
49
49
  }
50
50
 
51
- const lines: string[] = []
52
-
53
- // Frontmatter — always write it (rules default to [default])
54
- const effectiveRules = rulesList.length > 0 ? rulesList : ['default']
55
- lines.push('---')
56
- lines.push('rules:')
57
- for (const rule of effectiveRules) {
58
- lines.push(` - ${rule}`)
59
- }
60
- lines.push('---')
61
- lines.push('')
51
+ const effectiveRules = rulesList
62
52
 
53
+ const lines: string[] = []
63
54
  lines.push(`# ${name}`)
64
55
  lines.push('')
65
56
  if (c.options.description) {
@@ -77,33 +68,85 @@ export const persona = Cli.create('persona', {
77
68
  lines.push('')
78
69
 
79
70
  const content = lines.join('\n')
80
- await writeFile(dest, content)
71
+ await api.put<ApiPersona>(`/api/v1/personas/${name}`, {
72
+ content,
73
+ bundled_rules: effectiveRules,
74
+ })
81
75
 
82
76
  if (c.agent || c.formatExplicit) {
83
- return {
84
- created: dest,
85
- name,
86
- rules: effectiveRules,
87
- template: content,
88
- }
77
+ return { created: name, name, rules: effectiveRules, template: content }
89
78
  }
90
79
 
91
80
  return {
92
- created: dest,
81
+ created: name,
93
82
  name,
94
83
  rules: effectiveRules,
95
84
  template: `\n${content}`,
96
- next: `Edit ${dest} to fill in your persona, then run \`brainjar persona use ${name}\` to activate.`,
85
+ next: `Run \`brainjar persona show ${name}\` to view, then \`brainjar persona use ${name}\` to activate.`,
97
86
  }
98
87
  },
99
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
+ })
100
144
  .command('list', {
101
145
  description: 'List available personas',
102
146
  async run() {
103
- await requireBrainjarDir()
104
- const entries = await readdir(paths.personas).catch(() => [])
105
- const personas = entries.filter(f => f.endsWith('.md')).map(f => basename(f, '.md'))
106
- return { personas }
147
+ const api = await getApi()
148
+ const result = await api.get<ApiPersonaList>('/api/v1/personas')
149
+ return { personas: result.personas.map(p => p.slug) }
107
150
  },
108
151
  })
109
152
  .command('show', {
@@ -112,148 +155,112 @@ export const persona = Cli.create('persona', {
112
155
  name: z.string().optional().describe('Persona name to show (defaults to active persona)'),
113
156
  }),
114
157
  options: z.object({
115
- local: z.boolean().default(false).describe('Show local persona override (if any)'),
158
+ project: z.boolean().default(false).describe('Show project persona override (if any)'),
116
159
  short: z.boolean().default(false).describe('Print only the active persona name'),
117
160
  }),
118
161
  async run(c) {
119
- await requireBrainjarDir()
162
+ const api = await getApi()
120
163
 
121
164
  if (c.options.short) {
122
165
  if (c.args.name) return c.args.name
123
- const global = await readState()
124
- const local = await readLocalState()
125
- const env = readEnvState()
126
- const effective = mergeState(global, local, env)
127
- return effective.persona.value ?? 'none'
166
+ const state = await getEffectiveState(api)
167
+ return state.persona ?? 'none'
128
168
  }
129
169
 
130
- // If a specific name was given, show that persona directly
131
170
  if (c.args.name) {
132
171
  const name = normalizeSlug(c.args.name, 'persona name')
133
172
  try {
134
- const raw = await readFile(join(paths.personas, `${name}.md`), 'utf-8')
135
- const frontmatter = parseLayerFrontmatter(raw)
136
- const content = stripFrontmatter(raw)
137
- const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
138
- return { name, title, content, ...frontmatter }
139
- } catch {
140
- throw new IncurError({
141
- code: 'PERSONA_NOT_FOUND',
142
- message: `Persona "${name}" not found.`,
143
- hint: 'Run `brainjar persona list` to see available personas.',
144
- })
173
+ const p = await api.get<ApiPersona>(`/api/v1/personas/${name}`)
174
+ return { name, title: p.title, content: p.content, rules: p.bundled_rules }
175
+ } catch (e) {
176
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
177
+ throw createError(ErrorCode.PERSONA_NOT_FOUND, { params: [name] })
178
+ }
179
+ throw e
145
180
  }
146
181
  }
147
182
 
148
- if (c.options.local) {
149
- const local = await readLocalState()
150
- if (!('persona' in local)) return { active: false, scope: 'local', note: 'No local persona override (cascades from global)' }
151
- if (local.persona === null) return { active: false, scope: 'local', name: null, note: 'Explicitly unset at local scope' }
183
+ if (c.options.project) {
184
+ const state = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
185
+ project: basename(process.cwd()),
186
+ })
187
+ if (state.persona_slug === undefined) return { active: false, scope: 'project', note: 'No project persona override (cascades from workspace)' }
188
+ if (state.persona_slug === null) return { active: false, scope: 'project', name: null, note: 'Explicitly unset at project scope' }
152
189
  try {
153
- const raw = await readFile(join(paths.personas, `${local.persona}.md`), 'utf-8')
154
- const frontmatter = parseLayerFrontmatter(raw)
155
- const content = stripFrontmatter(raw)
156
- const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
157
- return { active: true, scope: 'local', name: local.persona, title, content, ...frontmatter }
190
+ const p = await api.get<ApiPersona>(`/api/v1/personas/${state.persona_slug}`)
191
+ return { active: true, scope: 'project', name: state.persona_slug, title: p.title, content: p.content, rules: p.bundled_rules }
158
192
  } catch {
159
- return { active: false, scope: 'local', name: local.persona, error: 'File not found' }
193
+ return { active: false, scope: 'project', name: state.persona_slug, error: 'Not found on server' }
160
194
  }
161
195
  }
162
196
 
163
- const global = await readState()
164
- const local = await readLocalState()
165
- const env = readEnvState()
166
- const effective = mergeState(global, local, env)
167
- if (!effective.persona.value) return { active: false }
197
+ const state = await getEffectiveState(api)
198
+ if (!state.persona) return { active: false }
168
199
  try {
169
- const raw = await readFile(join(paths.personas, `${effective.persona.value}.md`), 'utf-8')
170
- const frontmatter = parseLayerFrontmatter(raw)
171
- const content = stripFrontmatter(raw)
172
- const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
173
- return { active: true, name: effective.persona.value, scope: effective.persona.scope, title, content, ...frontmatter }
200
+ const p = await api.get<ApiPersona>(`/api/v1/personas/${state.persona}`)
201
+ return { active: true, name: state.persona, title: p.title, content: p.content, rules: p.bundled_rules }
174
202
  } catch {
175
- return { active: false, name: effective.persona.value, error: 'File not found' }
203
+ return { active: false, name: state.persona, error: 'Not found on server' }
176
204
  }
177
205
  },
178
206
  })
179
207
  .command('use', {
180
208
  description: 'Activate a persona',
181
209
  args: z.object({
182
- name: z.string().describe('Persona name (filename without .md in ~/.brainjar/personas/)'),
210
+ name: z.string().describe('Persona name'),
183
211
  }),
184
212
  options: z.object({
185
- local: z.boolean().default(false).describe('Write to local .claude/CLAUDE.md instead of global'),
213
+ project: z.boolean().default(false).describe('Apply at project scope'),
186
214
  }),
187
215
  async run(c) {
188
- await requireBrainjarDir()
189
216
  const name = normalizeSlug(c.args.name, 'persona name')
190
- const source = join(paths.personas, `${name}.md`)
191
- let raw: string
217
+ const api = await getApi()
218
+
219
+ // Validate and get bundled rules
220
+ let personaData: ApiPersona
192
221
  try {
193
- raw = await readFile(source, 'utf-8')
194
- } catch {
195
- throw new IncurError({
196
- code: 'PERSONA_NOT_FOUND',
197
- message: `Persona "${name}" not found.`,
198
- hint: 'Run `brainjar persona list` to see available personas.',
199
- })
222
+ personaData = await api.get<ApiPersona>(`/api/v1/personas/${name}`)
223
+ } catch (e) {
224
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
225
+ throw createError(ErrorCode.PERSONA_NOT_FOUND, { params: [name] })
226
+ }
227
+ throw e
200
228
  }
201
229
 
202
- const frontmatter = parseLayerFrontmatter(raw)
230
+ const bundledRules = personaData.bundled_rules
203
231
 
204
- if (c.options.local) {
205
- await withLocalStateLock(async () => {
206
- const local = await readLocalState()
207
- local.persona = name
208
- if (frontmatter.rules.length > 0) {
209
- local.rules = { ...local.rules, add: frontmatter.rules }
210
- }
211
- await writeLocalState(local)
212
- await sync({ local: true })
213
- })
214
- } else {
215
- await withStateLock(async () => {
216
- const state = await readState()
217
- state.persona = name
218
- if (frontmatter.rules.length > 0) state.rules = frontmatter.rules
219
- await writeState(state)
220
- await sync()
221
- })
222
- }
232
+ const mutationOpts = c.options.project
233
+ ? { project: basename(process.cwd()) }
234
+ : undefined
235
+ await putState(api, {
236
+ persona_slug: name,
237
+ rule_slugs: bundledRules.length > 0 ? bundledRules : undefined,
238
+ }, mutationOpts)
239
+
240
+ await sync({ api })
241
+ if (c.options.project) await sync({ api, project: true })
223
242
 
224
- const result: Record<string, unknown> = { activated: name, local: c.options.local }
225
- if (frontmatter.rules.length > 0) result.rules = frontmatter.rules
243
+ const result: Record<string, unknown> = { activated: name, project: c.options.project }
244
+ if (bundledRules.length > 0) result.rules = bundledRules
226
245
  return result
227
246
  },
228
247
  })
229
248
  .command('drop', {
230
249
  description: 'Deactivate the current persona',
231
250
  options: z.object({
232
- local: z.boolean().default(false).describe('Remove local persona override or deactivate global persona'),
251
+ project: z.boolean().default(false).describe('Remove project persona override or deactivate workspace persona'),
233
252
  }),
234
253
  async run(c) {
235
- await requireBrainjarDir()
236
- if (c.options.local) {
237
- await withLocalStateLock(async () => {
238
- const local = await readLocalState()
239
- delete local.persona
240
- await writeLocalState(local)
241
- await sync({ local: true })
242
- })
243
- } else {
244
- await withStateLock(async () => {
245
- const state = await readState()
246
- if (!state.persona) {
247
- throw new IncurError({
248
- code: 'NO_ACTIVE_PERSONA',
249
- message: 'No active persona to deactivate.',
250
- })
251
- }
252
- state.persona = null
253
- await writeState(state)
254
- await sync()
255
- })
256
- }
257
- return { deactivated: true, local: c.options.local }
254
+ const api = await getApi()
255
+
256
+ const mutationOpts = c.options.project
257
+ ? { project: basename(process.cwd()) }
258
+ : undefined
259
+ await putState(api, { persona_slug: null }, mutationOpts)
260
+
261
+ await sync({ api })
262
+ if (c.options.project) await sync({ api, project: true })
263
+
264
+ return { deactivated: true, project: c.options.project }
258
265
  },
259
266
  })