@brainjar/cli 0.6.1 → 0.6.3

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.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Shape how your AI thinks — composable soul, persona, and rules for AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api-types.ts CHANGED
@@ -107,6 +107,32 @@ export interface ApiComposeResult {
107
107
  warnings?: string[]
108
108
  }
109
109
 
110
+ // --- API key types ---
111
+
112
+ export interface ApiCreateKeyResult {
113
+ id: string
114
+ name: string
115
+ key: string
116
+ key_prefix: string
117
+ user_id: string
118
+ expires_at: string | null
119
+ created_at: string
120
+ }
121
+
122
+ export interface ApiKeySummary {
123
+ id: string
124
+ name: string
125
+ key_prefix: string
126
+ user_id: string
127
+ expires_at: string | null
128
+ revoked_at: string | null
129
+ created_at: string
130
+ }
131
+
132
+ export interface ApiKeyList {
133
+ api_keys: ApiKeySummary[]
134
+ }
135
+
110
136
  // --- Content version types ---
111
137
 
112
138
  export interface ApiVersionSummary {
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import { server } from './commands/server.js'
17
17
  import { migrate } from './commands/migrate.js'
18
18
  import { upgrade } from './commands/upgrade.js'
19
19
  import { context } from './commands/context.js'
20
+ import { apiKey } from './commands/api-key.js'
20
21
 
21
22
  Cli.create('brainjar', {
22
23
  description: 'Shape how your AI thinks — soul, persona, rules',
@@ -39,4 +40,5 @@ Cli.create('brainjar', {
39
40
  .command(migrate)
40
41
  .command(upgrade)
41
42
  .command(context)
43
+ .command(apiKey)
42
44
  .serve()
package/src/client.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { Errors } from 'incur'
2
- import { basename } from 'node:path'
3
- import { readConfig, activeContext } from './config.js'
4
- import { getLocalDir } from './paths.js'
5
- import { access } from 'node:fs/promises'
2
+ import { basename, join } from 'node:path'
3
+ import { readConfig, activeContext, isLocalContext } from './config.js'
4
+ import type { ServerContext } from './config.js'
5
+ import { getBrainjarDir, getLocalDir } from './paths.js'
6
+ import { access, readFile } from 'node:fs/promises'
6
7
  import { ensureRunning } from './daemon.js'
7
8
  import { ErrorCode, createError } from './errors.js'
8
9
 
@@ -41,6 +42,24 @@ const ERROR_MAP: Record<number, { code: ErrorCode; hint?: string }> = {
41
42
  503: { code: ErrorCode.SERVER_UNAVAILABLE, hint: 'Server is not ready. Try again in a moment.' },
42
43
  }
43
44
 
45
+ async function resolveToken(ctx: ServerContext): Promise<string | null> {
46
+ const envToken = process.env.BRAINJAR_TOKEN
47
+ if (envToken) return envToken
48
+
49
+ if (isLocalContext(ctx)) {
50
+ const tokenFile = ctx.auth_token_file ?? join(getBrainjarDir(), 'auth-token')
51
+ try {
52
+ return (await readFile(tokenFile, 'utf-8')).trim()
53
+ } catch {
54
+ return null // token file doesn't exist yet (server not started)
55
+ }
56
+ }
57
+
58
+ if (ctx.token) return ctx.token
59
+
60
+ return null
61
+ }
62
+
44
63
  async function detectProject(explicit?: string | null): Promise<string | null> {
45
64
  if (explicit === null) return null // explicitly suppress auto-detection
46
65
  if (explicit) return explicit
@@ -68,11 +87,14 @@ export async function createClient(options?: ClientOptions): Promise<BrainjarCli
68
87
  const url = `${serverUrl}${path}`
69
88
  const timeout = reqOpts?.timeout ?? defaultTimeout
70
89
 
90
+ const token = await resolveToken(ctx)
91
+
71
92
  const headers: Record<string, string> = {
72
93
  'Accept': 'application/json',
73
94
  'X-Brainjar-Workspace': workspace,
74
95
  ...(reqOpts?.headers ?? {}),
75
96
  }
97
+ if (token) headers['Authorization'] = `Bearer ${token}`
76
98
 
77
99
  const explicitProject = reqOpts && 'project' in reqOpts ? reqOpts.project : options?.project
78
100
  const project = await detectProject(explicitProject)
@@ -0,0 +1,50 @@
1
+ import { Cli, z } from 'incur'
2
+ import { getApi } from '../client.js'
3
+ import type { ApiCreateKeyResult, ApiKeyList } from '../api-types.js'
4
+
5
+ export const apiKey = Cli.create('api-key', {
6
+ description: 'Manage API keys for remote server authentication',
7
+ })
8
+ .command('create', {
9
+ description: 'Create a new API key',
10
+ options: z.object({
11
+ name: z.string().describe('Key name (e.g. ci-pipeline)'),
12
+ 'user-id': z.string().optional().describe('User ID label for the key'),
13
+ 'expires-in': z.string().optional().describe('Expiration duration (e.g. 30d, 90d, 365d)'),
14
+ }),
15
+ async run(c) {
16
+ const api = await getApi()
17
+ const result = await api.post<ApiCreateKeyResult>('/api/v1/api-keys', {
18
+ name: c.options.name,
19
+ user_id: c.options['user-id'] ?? '',
20
+ expires_in: c.options['expires-in'] ?? '',
21
+ })
22
+ return {
23
+ id: result.id,
24
+ name: result.name,
25
+ key: result.key,
26
+ key_prefix: result.key_prefix,
27
+ expires_at: result.expires_at,
28
+ warning: 'Save this key now — it will not be shown again.',
29
+ }
30
+ },
31
+ })
32
+ .command('list', {
33
+ description: 'List API keys',
34
+ async run() {
35
+ const api = await getApi()
36
+ const result = await api.get<ApiKeyList>('/api/v1/api-keys')
37
+ return result
38
+ },
39
+ })
40
+ .command('revoke', {
41
+ description: 'Revoke an API key',
42
+ args: z.object({
43
+ id: z.string().describe('API key ID'),
44
+ }),
45
+ async run(c) {
46
+ const api = await getApi()
47
+ await api.delete(`/api/v1/api-keys/${c.args.id}`)
48
+ return { revoked: c.args.id }
49
+ },
50
+ })
@@ -209,6 +209,33 @@ const renameCmd = Cli.create('rename', {
209
209
  },
210
210
  })
211
211
 
212
+ const setTokenCmd = Cli.create('set-token', {
213
+ description: 'Store an API key for a context',
214
+ args: z.object({
215
+ name: z.string().describe('Context name'),
216
+ key: z.string().describe('API key (bjk_...)'),
217
+ }),
218
+ async run(c) {
219
+ const config = await readConfig()
220
+
221
+ if (!(c.args.name in config.contexts)) {
222
+ throw createError(ErrorCode.CONTEXT_NOT_FOUND, { params: [c.args.name] })
223
+ }
224
+
225
+ const ctx = config.contexts[c.args.name]
226
+ if (isLocalContext(ctx)) {
227
+ throw createError(ErrorCode.VALIDATION_ERROR, {
228
+ message: 'Cannot set a token on a local context. Local contexts use auto-generated tokens.',
229
+ })
230
+ }
231
+
232
+ ctx.token = c.args.key
233
+ await writeConfig(config)
234
+
235
+ return { context: c.args.name, token_set: true }
236
+ },
237
+ })
238
+
212
239
  export const context = Cli.create('context', {
213
240
  description: 'Manage server contexts — named server profiles',
214
241
  })
@@ -218,3 +245,4 @@ export const context = Cli.create('context', {
218
245
  .command(useCmd)
219
246
  .command(showCmd)
220
247
  .command(renameCmd)
248
+ .command(setTokenCmd)
@@ -167,7 +167,7 @@ export const persona = Cli.create('persona', {
167
167
  options: z.object({
168
168
  project: z.boolean().default(false).describe('Show project persona override (if any)'),
169
169
  short: z.boolean().default(false).describe('Print only the active persona name'),
170
- version: z.number().optional().describe('Show a specific version from history'),
170
+ rev: z.number().optional().describe('Show a specific version from history'),
171
171
  }),
172
172
  async run(c) {
173
173
  const api = await getApi()
@@ -178,11 +178,11 @@ export const persona = Cli.create('persona', {
178
178
  return state.persona ?? 'none'
179
179
  }
180
180
 
181
- if (c.options.version) {
181
+ if (c.options.rev) {
182
182
  const name = c.args.name
183
- if (!name) throw createError(ErrorCode.MISSING_ARG, { message: 'Name is required when using --version' })
183
+ if (!name) throw createError(ErrorCode.MISSING_ARG, { message: 'Name is required when using --rev' })
184
184
  const slug = normalizeSlug(name, 'persona name')
185
- const v = await api.get<ApiContentVersion>(`/api/v1/personas/${slug}/versions/${c.options.version}`)
185
+ const v = await api.get<ApiContentVersion>(`/api/v1/personas/${slug}/versions/${c.options.rev}`)
186
186
  return { name: slug, version: v.version, content: v.content, metadata: v.metadata, created_at: v.created_at }
187
187
  }
188
188
 
@@ -146,14 +146,14 @@ export const rules = Cli.create('rules', {
146
146
  name: z.string().describe('Rule name to show'),
147
147
  }),
148
148
  options: z.object({
149
- version: z.number().optional().describe('Show a specific version from history'),
149
+ rev: z.number().optional().describe('Show a specific version from history'),
150
150
  }),
151
151
  async run(c) {
152
152
  const name = normalizeSlug(c.args.name, 'rule name')
153
153
  const api = await getApi()
154
154
 
155
- if (c.options.version) {
156
- const v = await api.get<ApiContentVersion>(`/api/v1/rules/${name}/versions/${c.options.version}`)
155
+ if (c.options.rev) {
156
+ const v = await api.get<ApiContentVersion>(`/api/v1/rules/${name}/versions/${c.options.rev}`)
157
157
  const entries = (v.metadata as { entries?: Array<{ sort_key: number; content: string }> })?.entries ?? []
158
158
  const content = entries.map(e => e.content.trim()).join('\n\n')
159
159
  return { name, version: v.version, content, created_at: v.created_at }
@@ -133,7 +133,7 @@ export const soul = Cli.create('soul', {
133
133
  options: z.object({
134
134
  project: z.boolean().default(false).describe('Show project soul override (if any)'),
135
135
  short: z.boolean().default(false).describe('Print only the active soul name'),
136
- version: z.number().optional().describe('Show a specific version from history'),
136
+ rev: z.number().optional().describe('Show a specific version from history'),
137
137
  }),
138
138
  async run(c) {
139
139
  const api = await getApi()
@@ -144,11 +144,11 @@ export const soul = Cli.create('soul', {
144
144
  return state.soul ?? 'none'
145
145
  }
146
146
 
147
- if (c.options.version) {
147
+ if (c.options.rev) {
148
148
  const name = c.args.name
149
- if (!name) throw createError(ErrorCode.MISSING_ARG, { message: 'Name is required when using --version' })
149
+ if (!name) throw createError(ErrorCode.MISSING_ARG, { message: 'Name is required when using --rev' })
150
150
  const slug = normalizeSlug(name, 'soul name')
151
- const v = await api.get<ApiContentVersion>(`/api/v1/souls/${slug}/versions/${c.options.version}`)
151
+ const v = await api.get<ApiContentVersion>(`/api/v1/souls/${slug}/versions/${c.options.rev}`)
152
152
  return { name: slug, version: v.version, content: v.content, created_at: v.created_at }
153
153
  }
154
154
 
package/src/config.ts CHANGED
@@ -13,12 +13,14 @@ export interface LocalContext {
13
13
  pid_file: string
14
14
  log_file: string
15
15
  workspace: string
16
+ auth_token_file?: string
16
17
  }
17
18
 
18
19
  export interface RemoteContext {
19
20
  url: string
20
21
  mode: 'remote'
21
22
  workspace: string
23
+ token?: string
22
24
  }
23
25
 
24
26
  export type ServerContext = LocalContext | RemoteContext
@@ -197,7 +199,7 @@ function parseV2(p: Record<string, unknown>): Config {
197
199
  if (!raw || typeof raw !== 'object') continue
198
200
  const ctx = raw as Record<string, unknown>
199
201
  if (ctx.mode === 'local') {
200
- config.contexts[name] = {
202
+ const local: LocalContext = {
201
203
  url: typeof ctx.url === 'string' ? ctx.url : 'http://localhost:7742',
202
204
  mode: 'local',
203
205
  bin: typeof ctx.bin === 'string' ? ctx.bin : defLocal.bin,
@@ -205,12 +207,16 @@ function parseV2(p: Record<string, unknown>): Config {
205
207
  log_file: typeof ctx.log_file === 'string' ? ctx.log_file : defLocal.log_file,
206
208
  workspace: typeof ctx.workspace === 'string' ? ctx.workspace : 'default',
207
209
  }
210
+ if (typeof ctx.auth_token_file === 'string') local.auth_token_file = ctx.auth_token_file
211
+ config.contexts[name] = local
208
212
  } else {
209
- config.contexts[name] = {
213
+ const remote: RemoteContext = {
210
214
  url: typeof ctx.url === 'string' ? ctx.url : '',
211
215
  mode: 'remote',
212
216
  workspace: typeof ctx.workspace === 'string' ? ctx.workspace : 'default',
213
217
  }
218
+ if (typeof ctx.token === 'string') remote.token = ctx.token
219
+ config.contexts[name] = remote
214
220
  }
215
221
  }
216
222
  }
@@ -287,7 +293,7 @@ export async function writeConfig(config: Config): Promise<void> {
287
293
  const contexts = doc.contexts as Record<string, unknown>
288
294
  for (const [name, ctx] of Object.entries(config.contexts)) {
289
295
  if (isLocalContext(ctx)) {
290
- contexts[name] = {
296
+ const local: Record<string, unknown> = {
291
297
  url: ctx.url,
292
298
  mode: ctx.mode,
293
299
  bin: ctx.bin,
@@ -295,12 +301,16 @@ export async function writeConfig(config: Config): Promise<void> {
295
301
  log_file: ctx.log_file,
296
302
  workspace: ctx.workspace,
297
303
  }
304
+ if (ctx.auth_token_file) local.auth_token_file = ctx.auth_token_file
305
+ contexts[name] = local
298
306
  } else {
299
- contexts[name] = {
307
+ const remote: Record<string, unknown> = {
300
308
  url: ctx.url,
301
309
  mode: ctx.mode,
302
310
  workspace: ctx.workspace,
303
311
  }
312
+ if (ctx.token) remote.token = ctx.token
313
+ contexts[name] = remote
304
314
  }
305
315
  }
306
316