@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 { access, readFile, readdir, stat, writeFile, mkdir } from 'node:fs/promises'
5
- import { join } from 'node:path'
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 (will be used as filename)'),
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 (c.options.pack) {
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 access(dest)
69
- throw new IncurError({
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 writeFile(dest, scaffold)
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: dest, name, template: scaffold }
50
+ return { created: name, name, template: scaffold }
92
51
  }
93
52
 
94
53
  return {
95
- created: dest,
54
+ created: name,
96
55
  name,
97
56
  template: `\n${scaffold}`,
98
- next: `Edit ${dest} to define your rule, then run \`brainjar rules add ${name}\` to activate.`,
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
- local: z.boolean().default(false).describe('Show local rules delta only'),
107
+ project: z.boolean().default(false).describe('Show project rules delta only'),
106
108
  }),
107
109
  async run(c) {
108
- await requireBrainjarDir()
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.local) {
111
- const local = await readLocalState()
112
- const available = await listAvailableRules()
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: local.rules?.add ?? [],
115
- remove: local.rules?.remove ?? [],
116
- available,
117
- scope: 'local',
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 [global, local, available] = await Promise.all([readState(), readLocalState(), listAvailableRules()])
122
- const env = readEnvState()
123
- const effective = mergeState(global, local, env)
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 dirPath = join(paths.rules, name)
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 s = await stat(dirPath)
142
- if (s.isDirectory()) {
143
- const files = await readdir(dirPath)
144
- const mdFiles = files.filter(f => f.endsWith('.md')).sort()
145
- const sections: string[] = []
146
- for (const file of mdFiles) {
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
- } catch {}
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 or directory name in ~/.brainjar/rules/'),
155
+ name: z.string().describe('Rule name to activate'),
171
156
  }),
172
157
  options: z.object({
173
- local: z.boolean().default(false).describe('Add rule as a local override (delta, not snapshot)'),
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
- // Verify it exists as a directory or .md file
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
- const s = await stat(dirPath)
185
- if (s.isDirectory()) found = true
186
- } catch {}
187
-
188
- if (!found) {
189
- try {
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
- if (!found) {
196
- throw new IncurError({
197
- code: 'RULE_NOT_FOUND',
198
- message: `Rule "${name}" not found in ${paths.rules}`,
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
- if (c.options.local) {
204
- await withLocalStateLock(async () => {
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, local: c.options.local }
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
- local: z.boolean().default(false).describe('Remove rule as a local override (delta, not snapshot)'),
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
- if (c.options.local) {
241
- await withLocalStateLock(async () => {
242
- const local = await readLocalState()
243
- const removes = local.rules?.remove ?? []
244
- if (!removes.includes(name)) removes.push(name)
245
- // Also remove from local adds if present
246
- const adds = (local.rules?.add ?? []).filter(r => r !== name)
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, local: c.options.local }
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)