@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 { 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,23 @@ 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
+ pack: z.boolean().default(false).describe('Create as a rule pack (multiple entries)'),
21
22
  }),
22
23
  async run(c) {
23
- await requireBrainjarDir()
24
24
  const name = normalizeSlug(c.args.name, 'rule name')
25
+ const api = await getApi()
25
26
 
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`)
27
+ // Check if it already exists
67
28
  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
- })
29
+ await api.get<ApiRule>(`/api/v1/rules/${name}`)
30
+ throw createError(ErrorCode.RULE_EXISTS, { params: [name] })
74
31
  } catch (e) {
75
- if (e instanceof IncurError) throw e
32
+ if (e instanceof IncurError && e.code === ErrorCode.RULE_EXISTS) throw e
33
+ if (e instanceof IncurError && e.code !== ErrorCode.NOT_FOUND) throw e
76
34
  }
77
35
 
78
36
  const scaffold = [
@@ -85,44 +43,48 @@ export const rules = Cli.create('rules', {
85
43
  '',
86
44
  ].join('\n')
87
45
 
88
- await writeFile(dest, scaffold)
46
+ await api.put<ApiRule>(`/api/v1/rules/${name}`, {
47
+ entries: [{ name: `${name}.md`, content: scaffold }],
48
+ })
89
49
 
90
50
  if (c.agent || c.formatExplicit) {
91
- return { created: dest, name, template: scaffold }
51
+ return { created: name, name, pack: c.options.pack, template: scaffold }
92
52
  }
93
53
 
94
54
  return {
95
- created: dest,
55
+ created: name,
96
56
  name,
57
+ pack: c.options.pack,
97
58
  template: `\n${scaffold}`,
98
- next: `Edit ${dest} to define your rule, then run \`brainjar rules add ${name}\` to activate.`,
59
+ next: `Run \`brainjar rules show ${name}\` to view, then \`brainjar rules add ${name}\` to activate.`,
99
60
  }
100
61
  },
101
62
  })
102
63
  .command('list', {
103
64
  description: 'List available and active rules',
104
65
  options: z.object({
105
- local: z.boolean().default(false).describe('Show local rules delta only'),
66
+ project: z.boolean().default(false).describe('Show project rules delta only'),
106
67
  }),
107
68
  async run(c) {
108
- await requireBrainjarDir()
69
+ const api = await getApi()
70
+ const available = await api.get<ApiRuleList>('/api/v1/rules')
71
+ const availableSlugs = available.rules.map(r => r.slug)
109
72
 
110
- if (c.options.local) {
111
- const local = await readLocalState()
112
- const available = await listAvailableRules()
73
+ if (c.options.project) {
74
+ const override = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
75
+ project: basename(process.cwd()),
76
+ })
113
77
  return {
114
- add: local.rules?.add ?? [],
115
- remove: local.rules?.remove ?? [],
116
- available,
117
- scope: 'local',
78
+ add: override.rules_to_add ?? [],
79
+ remove: override.rules_to_remove ?? [],
80
+ available: availableSlugs,
81
+ scope: 'project',
118
82
  }
119
83
  }
120
84
 
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 }
85
+ const state = await getEffectiveState(api)
86
+ const active = state.rules
87
+ return { active, available: availableSlugs, rules: state.rules }
126
88
  },
127
89
  })
128
90
  .command('show', {
@@ -131,98 +93,52 @@ export const rules = Cli.create('rules', {
131
93
  name: z.string().describe('Rule name to show'),
132
94
  }),
133
95
  async run(c) {
134
- await requireBrainjarDir()
135
96
  const name = normalizeSlug(c.args.name, 'rule name')
136
- const dirPath = join(paths.rules, name)
137
- const filePath = join(paths.rules, `${name}.md`)
97
+ const api = await getApi()
138
98
 
139
- // Try directory of .md files first
140
99
  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') }
100
+ const rule = await api.get<ApiRule>(`/api/v1/rules/${name}`)
101
+ const content = rule.entries.map(e => e.content.trim()).join('\n\n')
102
+ return { name, content }
103
+ } catch (e) {
104
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
105
+ throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
151
106
  }
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
- })
107
+ throw e
108
+ }
165
109
  },
166
110
  })
167
111
  .command('add', {
168
112
  description: 'Activate a rule or rule pack',
169
113
  args: z.object({
170
- name: z.string().describe('Rule name or directory name in ~/.brainjar/rules/'),
114
+ name: z.string().describe('Rule name to activate'),
171
115
  }),
172
116
  options: z.object({
173
- local: z.boolean().default(false).describe('Add rule as a local override (delta, not snapshot)'),
117
+ project: z.boolean().default(false).describe('Add rule at project scope'),
174
118
  }),
175
119
  async run(c) {
176
- await requireBrainjarDir()
177
120
  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
121
+ const api = await getApi()
182
122
 
123
+ // Validate it exists on server
183
124
  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 {}
125
+ await api.get<ApiRule>(`/api/v1/rules/${name}`)
126
+ } catch (e) {
127
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
128
+ throw createError(ErrorCode.RULE_NOT_FOUND, { params: [name] })
129
+ }
130
+ throw e
193
131
  }
194
132
 
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
- }
133
+ const mutationOpts = c.options.project
134
+ ? { project: basename(process.cwd()) }
135
+ : undefined
136
+ await putState(api, { rules_to_add: [name] }, mutationOpts)
202
137
 
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
- }
138
+ await sync({ api })
139
+ if (c.options.project) await sync({ api, project: true })
224
140
 
225
- return { activated: name, local: c.options.local }
141
+ return { activated: name, project: c.options.project }
226
142
  },
227
143
  })
228
144
  .command('remove', {
@@ -231,39 +147,20 @@ export const rules = Cli.create('rules', {
231
147
  name: z.string().describe('Rule name to remove'),
232
148
  }),
233
149
  options: z.object({
234
- local: z.boolean().default(false).describe('Remove rule as a local override (delta, not snapshot)'),
150
+ project: z.boolean().default(false).describe('Remove rule at project scope'),
235
151
  }),
236
152
  async run(c) {
237
- await requireBrainjarDir()
238
153
  const name = normalizeSlug(c.args.name, 'rule name')
154
+ const api = await getApi()
239
155
 
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
- }
156
+ const mutationOpts = c.options.project
157
+ ? { project: basename(process.cwd()) }
158
+ : undefined
159
+ await putState(api, { rules_to_remove: [name] }, mutationOpts)
160
+
161
+ await sync({ api })
162
+ if (c.options.project) await sync({ api, project: true })
266
163
 
267
- return { removed: name, local: c.options.local }
164
+ return { removed: name, project: c.options.project }
268
165
  },
269
166
  })
@@ -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)