@brainjar/cli 0.3.0 → 0.4.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.
@@ -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,30 @@ 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
  })
100
89
  .command('list', {
101
90
  description: 'List available personas',
102
91
  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 }
92
+ const api = await getApi()
93
+ const result = await api.get<ApiPersonaList>('/api/v1/personas')
94
+ return { personas: result.personas.map(p => p.slug) }
107
95
  },
108
96
  })
109
97
  .command('show', {
@@ -112,148 +100,112 @@ export const persona = Cli.create('persona', {
112
100
  name: z.string().optional().describe('Persona name to show (defaults to active persona)'),
113
101
  }),
114
102
  options: z.object({
115
- local: z.boolean().default(false).describe('Show local persona override (if any)'),
103
+ project: z.boolean().default(false).describe('Show project persona override (if any)'),
116
104
  short: z.boolean().default(false).describe('Print only the active persona name'),
117
105
  }),
118
106
  async run(c) {
119
- await requireBrainjarDir()
107
+ const api = await getApi()
120
108
 
121
109
  if (c.options.short) {
122
110
  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'
111
+ const state = await getEffectiveState(api)
112
+ return state.persona ?? 'none'
128
113
  }
129
114
 
130
- // If a specific name was given, show that persona directly
131
115
  if (c.args.name) {
132
116
  const name = normalizeSlug(c.args.name, 'persona name')
133
117
  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
- })
118
+ const p = await api.get<ApiPersona>(`/api/v1/personas/${name}`)
119
+ return { name, title: p.title, content: p.content, rules: p.bundled_rules }
120
+ } catch (e) {
121
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
122
+ throw createError(ErrorCode.PERSONA_NOT_FOUND, { params: [name] })
123
+ }
124
+ throw e
145
125
  }
146
126
  }
147
127
 
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' }
128
+ if (c.options.project) {
129
+ const state = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
130
+ project: basename(process.cwd()),
131
+ })
132
+ if (state.persona_slug === undefined) return { active: false, scope: 'project', note: 'No project persona override (cascades from workspace)' }
133
+ if (state.persona_slug === null) return { active: false, scope: 'project', name: null, note: 'Explicitly unset at project scope' }
152
134
  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 }
135
+ const p = await api.get<ApiPersona>(`/api/v1/personas/${state.persona_slug}`)
136
+ return { active: true, scope: 'project', name: state.persona_slug, title: p.title, content: p.content, rules: p.bundled_rules }
158
137
  } catch {
159
- return { active: false, scope: 'local', name: local.persona, error: 'File not found' }
138
+ return { active: false, scope: 'project', name: state.persona_slug, error: 'Not found on server' }
160
139
  }
161
140
  }
162
141
 
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 }
142
+ const state = await getEffectiveState(api)
143
+ if (!state.persona) return { active: false }
168
144
  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 }
145
+ const p = await api.get<ApiPersona>(`/api/v1/personas/${state.persona}`)
146
+ return { active: true, name: state.persona, title: p.title, content: p.content, rules: p.bundled_rules }
174
147
  } catch {
175
- return { active: false, name: effective.persona.value, error: 'File not found' }
148
+ return { active: false, name: state.persona, error: 'Not found on server' }
176
149
  }
177
150
  },
178
151
  })
179
152
  .command('use', {
180
153
  description: 'Activate a persona',
181
154
  args: z.object({
182
- name: z.string().describe('Persona name (filename without .md in ~/.brainjar/personas/)'),
155
+ name: z.string().describe('Persona name'),
183
156
  }),
184
157
  options: z.object({
185
- local: z.boolean().default(false).describe('Write to local .claude/CLAUDE.md instead of global'),
158
+ project: z.boolean().default(false).describe('Apply at project scope'),
186
159
  }),
187
160
  async run(c) {
188
- await requireBrainjarDir()
189
161
  const name = normalizeSlug(c.args.name, 'persona name')
190
- const source = join(paths.personas, `${name}.md`)
191
- let raw: string
162
+ const api = await getApi()
163
+
164
+ // Validate and get bundled rules
165
+ let personaData: ApiPersona
192
166
  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
- })
167
+ personaData = await api.get<ApiPersona>(`/api/v1/personas/${name}`)
168
+ } catch (e) {
169
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
170
+ throw createError(ErrorCode.PERSONA_NOT_FOUND, { params: [name] })
171
+ }
172
+ throw e
200
173
  }
201
174
 
202
- const frontmatter = parseLayerFrontmatter(raw)
175
+ const bundledRules = personaData.bundled_rules
203
176
 
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
- }
177
+ const mutationOpts = c.options.project
178
+ ? { project: basename(process.cwd()) }
179
+ : undefined
180
+ await putState(api, {
181
+ persona_slug: name,
182
+ rule_slugs: bundledRules.length > 0 ? bundledRules : undefined,
183
+ }, mutationOpts)
184
+
185
+ await sync({ api })
186
+ if (c.options.project) await sync({ api, project: true })
223
187
 
224
- const result: Record<string, unknown> = { activated: name, local: c.options.local }
225
- if (frontmatter.rules.length > 0) result.rules = frontmatter.rules
188
+ const result: Record<string, unknown> = { activated: name, project: c.options.project }
189
+ if (bundledRules.length > 0) result.rules = bundledRules
226
190
  return result
227
191
  },
228
192
  })
229
193
  .command('drop', {
230
194
  description: 'Deactivate the current persona',
231
195
  options: z.object({
232
- local: z.boolean().default(false).describe('Remove local persona override or deactivate global persona'),
196
+ project: z.boolean().default(false).describe('Remove project persona override or deactivate workspace persona'),
233
197
  }),
234
198
  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 }
199
+ const api = await getApi()
200
+
201
+ const mutationOpts = c.options.project
202
+ ? { project: basename(process.cwd()) }
203
+ : undefined
204
+ await putState(api, { persona_slug: null }, mutationOpts)
205
+
206
+ await sync({ api })
207
+ if (c.options.project) await sync({ api, project: true })
208
+
209
+ return { deactivated: true, project: c.options.project }
258
210
  },
259
211
  })