@brainjar/cli 0.2.3 → 0.3.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.
package/README.md CHANGED
@@ -5,9 +5,9 @@
5
5
  [![downloads](https://img.shields.io/npm/dm/@brainjar/cli)](https://www.npmjs.com/package/@brainjar/cli)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
7
 
8
- Shape how your AI thinks — identity, soul, persona, rules.
8
+ Shape how your AI thinks — soul, persona, rules.
9
9
 
10
- brainjar manages AI agent behavior through composable layers. Instead of one monolithic config file, you separate **what the agent sounds like** (soul), **how it works** (persona), and **what constraints it follows** (rules). Each layer is a markdown file. Mix and match them per project, per task, or per session.
10
+ brainjar manages AI agent behavior through composable layers. Instead of one monolithic config file, you separate **what the agent sounds like** (soul), **how it works** (persona), and **what constraints it follows** (rules). Each layer is a markdown file. Mix, match, and switch them per project, per task, or per session.
11
11
 
12
12
  **[Documentation](https://brainjar.sh)** · **[Getting Started](https://brainjar.sh/getting-started/)**
13
13
 
@@ -46,10 +46,9 @@ brainjar soul create|list|show|use|drop
46
46
  brainjar persona create|list|show|use|drop
47
47
  brainjar rules create|list|show|add|remove
48
48
 
49
- brainjar identity create|list|show|use|drop|unlock|get|status|lock
50
49
  brainjar pack export|import
51
50
  brainjar hooks install|remove|status [--local]
52
- brainjar shell [--brain|--soul|--persona|--identity|--rules-add|--rules-remove]
51
+ brainjar shell [--brain|--soul|--persona|--rules-add|--rules-remove]
53
52
  brainjar reset [--backend claude|codex]
54
53
  ```
55
54
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brainjar/cli",
3
- "version": "0.2.3",
4
- "description": "Shape how your AI thinks — composable identity, soul, persona, and rules for AI agents",
3
+ "version": "0.3.0",
4
+ "description": "Shape how your AI thinks — composable soul, persona, and rules for AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
package/src/cli.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  import { Cli } from 'incur'
3
3
  import pkg from '../package.json'
4
4
  import { init } from './commands/init.js'
5
- import { identity } from './commands/identity.js'
6
5
  import { soul } from './commands/soul.js'
7
6
  import { persona } from './commands/persona.js'
8
7
  import { rules } from './commands/rules.js'
@@ -16,7 +15,7 @@ import { hooks } from './commands/hooks.js'
16
15
  import { pack } from './commands/pack.js'
17
16
 
18
17
  Cli.create('brainjar', {
19
- description: 'Shape how your AI thinks — identity, soul, persona, rules',
18
+ description: 'Shape how your AI thinks — soul, persona, rules',
20
19
  version: pkg.version,
21
20
  sync: { depth: 0 },
22
21
  })
@@ -26,7 +25,6 @@ Cli.create('brainjar', {
26
25
  .command(persona)
27
26
  .command(rules)
28
27
  .command(brain)
29
- .command(identity)
30
28
  .command(reset)
31
29
  .command(shell)
32
30
  .command(compose)
@@ -21,14 +21,13 @@ export const init = Cli.create('init', {
21
21
  mkdir(paths.personas, { recursive: true }),
22
22
  mkdir(paths.rules, { recursive: true }),
23
23
  mkdir(paths.brains, { recursive: true }),
24
- mkdir(paths.identities, { recursive: true }),
25
24
  ])
26
25
 
27
26
  // Seed the default rule pack
28
27
  await seedDefaultRule(paths.rules)
29
28
 
30
29
  // Build .gitignore — always exclude private files, add .obsidian if vault enabled
31
- const gitignoreLines = ['identities/', '.session', 'state.yaml']
30
+ const gitignoreLines = ['state.yaml']
32
31
  if (c.options.obsidian) {
33
32
  gitignoreLines.push('.obsidian/', 'templates/')
34
33
  }
@@ -53,7 +52,7 @@ export const init = Cli.create('init', {
53
52
  const result: Record<string, unknown> = {
54
53
  created: brainjarDir,
55
54
  backend: c.options.backend,
56
- directories: ['souls/', 'personas/', 'rules/', 'brains/', 'identities/'],
55
+ directories: ['souls/', 'personas/', 'rules/', 'brains/'],
57
56
  }
58
57
 
59
58
  if (c.options.default) {
@@ -63,7 +62,7 @@ export const init = Cli.create('init', {
63
62
  result.personas = ['engineer', 'planner', 'reviewer']
64
63
  result.next = 'Ready to go. Run `brainjar status` to see your config.'
65
64
  } else {
66
- result.next = 'Run `brainjar identity create <slug> --name <name> --email <email>` to set up your first identity.'
65
+ result.next = 'Run `brainjar soul create <name>` to create your first soul.'
67
66
  }
68
67
 
69
68
  if (c.options.obsidian) {
@@ -14,20 +14,19 @@ export const shell = Cli.create('shell', {
14
14
  brain: z.string().optional().describe('Brain name — sets soul, persona, and rules from brain file'),
15
15
  soul: z.string().optional().describe('Soul override for this session'),
16
16
  persona: z.string().optional().describe('Persona override for this session'),
17
- identity: z.string().optional().describe('Identity override for this session'),
18
17
  'rules-add': z.string().optional().describe('Comma-separated rules to add'),
19
18
  'rules-remove': z.string().optional().describe('Comma-separated rules to remove'),
20
19
  }),
21
20
  async run(c) {
22
21
  await requireBrainjarDir()
23
22
 
24
- const individualFlags = c.options.soul || c.options.persona || c.options.identity
23
+ const individualFlags = c.options.soul || c.options.persona
25
24
  || c.options['rules-add'] || c.options['rules-remove']
26
25
 
27
26
  if (c.options.brain && individualFlags) {
28
27
  throw new IncurError({
29
28
  code: 'MUTUALLY_EXCLUSIVE',
30
- message: '--brain is mutually exclusive with --soul, --persona, --identity, --rules-add, --rules-remove.',
29
+ message: '--brain is mutually exclusive with --soul, --persona, --rules-add, --rules-remove.',
31
30
  hint: 'Use --brain alone or individual flags, not both.',
32
31
  })
33
32
  }
@@ -44,7 +43,6 @@ export const shell = Cli.create('shell', {
44
43
  } else {
45
44
  if (c.options.soul) envOverrides.BRAINJAR_SOUL = c.options.soul
46
45
  if (c.options.persona) envOverrides.BRAINJAR_PERSONA = c.options.persona
47
- if (c.options.identity) envOverrides.BRAINJAR_IDENTITY = c.options.identity
48
46
  if (c.options['rules-add']) envOverrides.BRAINJAR_RULES_ADD = c.options['rules-add']
49
47
  if (c.options['rules-remove']) envOverrides.BRAINJAR_RULES_REMOVE = c.options['rules-remove']
50
48
  }
@@ -53,7 +51,7 @@ export const shell = Cli.create('shell', {
53
51
  throw new IncurError({
54
52
  code: 'NO_OVERRIDES',
55
53
  message: 'No overrides specified.',
56
- hint: 'Use --brain, --soul, --persona, --identity, --rules-add, or --rules-remove.',
54
+ hint: 'Use --brain, --soul, --persona, --rules-add, or --rules-remove.',
57
55
  })
58
56
  }
59
57
 
@@ -66,7 +64,6 @@ export const shell = Cli.create('shell', {
66
64
  const labels: string[] = []
67
65
  if (envOverrides.BRAINJAR_SOUL) labels.push(`soul: ${envOverrides.BRAINJAR_SOUL}`)
68
66
  if (envOverrides.BRAINJAR_PERSONA) labels.push(`persona: ${envOverrides.BRAINJAR_PERSONA}`)
69
- if (envOverrides.BRAINJAR_IDENTITY) labels.push(`identity: ${envOverrides.BRAINJAR_IDENTITY}`)
70
67
  if (envOverrides.BRAINJAR_RULES_ADD) labels.push(`+rules: ${envOverrides.BRAINJAR_RULES_ADD}`)
71
68
  if (envOverrides.BRAINJAR_RULES_REMOVE) labels.push(`-rules: ${envOverrides.BRAINJAR_RULES_REMOVE}`)
72
69
 
@@ -1,5 +1,5 @@
1
1
  import { Cli, z } from 'incur'
2
- import { readState, readLocalState, readEnvState, mergeState, loadIdentity, requireBrainjarDir } from '../state.js'
2
+ import { readState, readLocalState, readEnvState, mergeState, requireBrainjarDir } from '../state.js'
3
3
  import { sync } from '../sync.js'
4
4
 
5
5
  export const status = Cli.create('status', {
@@ -8,7 +8,7 @@ export const status = Cli.create('status', {
8
8
  sync: z.boolean().default(false).describe('Regenerate config file from active layers'),
9
9
  global: z.boolean().default(false).describe('Show only global state'),
10
10
  local: z.boolean().default(false).describe('Show only local overrides'),
11
- short: z.boolean().default(false).describe('One-line output: soul | persona | identity'),
11
+ short: z.boolean().default(false).describe('One-line output: soul | persona'),
12
12
  }),
13
13
  async run(c) {
14
14
  await requireBrainjarDir()
@@ -19,13 +19,9 @@ export const status = Cli.create('status', {
19
19
  const local = await readLocalState()
20
20
  const env = readEnvState()
21
21
  const effective = mergeState(global, local, env)
22
- const slug = effective.identity.value
23
- ? (await loadIdentity(effective.identity.value).catch(() => null))?.slug ?? effective.identity.value
24
- : null
25
22
  const parts = [
26
23
  `soul: ${effective.soul.value ?? 'none'}`,
27
24
  `persona: ${effective.persona.value ?? 'none'}`,
28
- `identity: ${slug ?? 'none'}`,
29
25
  ]
30
26
  return parts.join(' | ')
31
27
  }
@@ -40,20 +36,10 @@ export const status = Cli.create('status', {
40
36
  // --global: show only global state (v0.1 behavior)
41
37
  if (c.options.global) {
42
38
  const state = await readState()
43
- let identityFull: Record<string, unknown> | null = null
44
- if (state.identity) {
45
- try {
46
- const { content: _, ...id } = await loadIdentity(state.identity)
47
- identityFull = id
48
- } catch {
49
- identityFull = { slug: state.identity, error: 'File not found' }
50
- }
51
- }
52
39
  const result: Record<string, unknown> = {
53
40
  soul: state.soul ?? null,
54
41
  persona: state.persona ?? null,
55
42
  rules: state.rules,
56
- identity: identityFull,
57
43
  }
58
44
  if (synced) result.synced = synced
59
45
  return result
@@ -66,7 +52,6 @@ export const status = Cli.create('status', {
66
52
  if ('soul' in local) result.soul = local.soul
67
53
  if ('persona' in local) result.persona = local.persona
68
54
  if (local.rules) result.rules = local.rules
69
- if ('identity' in local) result.identity = local.identity
70
55
  if (Object.keys(result).length === 0) result.note = 'No local overrides'
71
56
  if (synced) result.synced = synced
72
57
  return result
@@ -78,24 +63,12 @@ export const status = Cli.create('status', {
78
63
  const env = readEnvState()
79
64
  const effective = mergeState(global, local, env)
80
65
 
81
- // Resolve identity details
82
- let identityFull: Record<string, unknown> | null = null
83
- if (effective.identity.value) {
84
- try {
85
- const { content: _, ...id } = await loadIdentity(effective.identity.value)
86
- identityFull = { ...id, scope: effective.identity.scope }
87
- } catch {
88
- identityFull = { slug: effective.identity.value, scope: effective.identity.scope, error: 'File not found' }
89
- }
90
- }
91
-
92
66
  // Agents and explicit --format get full structured data
93
67
  if (c.agent || c.formatExplicit) {
94
68
  const result: Record<string, unknown> = {
95
69
  soul: effective.soul,
96
70
  persona: effective.persona,
97
71
  rules: effective.rules,
98
- identity: identityFull,
99
72
  }
100
73
  if (synced) result.synced = synced
101
74
  return result
@@ -104,14 +77,6 @@ export const status = Cli.create('status', {
104
77
  // Humans get a compact view with scope annotations
105
78
  const fmtScope = (scope: string) => `(${scope})`
106
79
 
107
- const identityLabel = identityFull
108
- ? identityFull.error
109
- ? `${effective.identity.value} (not found)`
110
- : identityFull.engine
111
- ? `${identityFull.slug} ${fmtScope(effective.identity.scope)} (${identityFull.engine})`
112
- : `${identityFull.slug} ${fmtScope(effective.identity.scope)}`
113
- : null
114
-
115
80
  const rulesLabel = effective.rules.length
116
81
  ? effective.rules
117
82
  .filter(r => !r.scope.startsWith('-'))
@@ -123,7 +88,6 @@ export const status = Cli.create('status', {
123
88
  soul: effective.soul.value ? `${effective.soul.value} ${fmtScope(effective.soul.scope)}` : null,
124
89
  persona: effective.persona.value ? `${effective.persona.value} ${fmtScope(effective.persona.scope)}` : null,
125
90
  rules: rulesLabel,
126
- identity: identityLabel,
127
91
  }
128
92
  if (synced) result.synced = synced
129
93
  return result
package/src/paths.ts CHANGED
@@ -41,8 +41,6 @@ export const paths = {
41
41
  get personas() { return join(getBrainjarDir(), 'personas') },
42
42
  get rules() { return join(getBrainjarDir(), 'rules') },
43
43
  get brains() { return join(getBrainjarDir(), 'brains') },
44
- get identities() { return join(getBrainjarDir(), 'identities') },
45
- get session() { return join(getBrainjarDir(), '.session') },
46
44
  get state() { return join(getBrainjarDir(), 'state.yaml') },
47
45
  get localState() { return join(getLocalDir(), 'state.yaml') },
48
46
  }
package/src/seeds.ts CHANGED
@@ -26,9 +26,7 @@ function obsidianAppConfigWithExclusions() {
26
26
  useMarkdownLinks: false,
27
27
  alwaysUpdateLinks: true,
28
28
  userIgnoreFilters: [
29
- 'identities/',
30
29
  'state.yaml',
31
- '.session',
32
30
  '.gitignore',
33
31
  ],
34
32
  }, null, 2)
package/src/state.ts CHANGED
@@ -73,13 +73,12 @@ export async function listAvailableRules(): Promise<string[]> {
73
73
 
74
74
  export interface State {
75
75
  backend: string | null
76
- identity: string | null
77
76
  soul: string | null
78
77
  persona: string | null
79
78
  rules: string[]
80
79
  }
81
80
 
82
- const DEFAULT_STATE: State = { backend: null, identity: null, soul: null, persona: null, rules: [] }
81
+ const DEFAULT_STATE: State = { backend: null, soul: null, persona: null, rules: [] }
83
82
 
84
83
  export async function requireBrainjarDir(): Promise<void> {
85
84
  try {
@@ -111,20 +110,6 @@ export function stripFrontmatter(content: string): string {
111
110
  return content.replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---\n*/, '').trim()
112
111
  }
113
112
 
114
- export function parseIdentity(content: string) {
115
- const parsed = parseYaml(content)
116
- return {
117
- name: parsed?.name as string | undefined,
118
- email: parsed?.email as string | undefined,
119
- engine: parsed?.engine as string | undefined,
120
- }
121
- }
122
-
123
- export async function loadIdentity(slug: string) {
124
- const content = await readFile(join(paths.identities, `${slug}.yaml`), 'utf-8')
125
- return { slug, content, ...parseIdentity(content) }
126
- }
127
-
128
113
  /** Return a valid slug or null. Prevents path traversal from state.yaml. */
129
114
  function safeName(value: unknown): string | null {
130
115
  if (typeof value !== 'string' || !value) return null
@@ -151,7 +136,6 @@ export async function readState(): Promise<State> {
151
136
 
152
137
  return {
153
138
  backend: ((parsed as any).backend === 'claude' || (parsed as any).backend === 'codex') ? (parsed as any).backend : null,
154
- identity: safeName((parsed as any).identity),
155
139
  soul: safeName((parsed as any).soul),
156
140
  persona: safeName((parsed as any).persona),
157
141
  rules: Array.isArray((parsed as any).rules)
@@ -209,7 +193,6 @@ export async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
209
193
  export async function writeState(state: State): Promise<void> {
210
194
  const doc = {
211
195
  backend: state.backend ?? null,
212
- identity: state.identity ?? null,
213
196
  soul: state.soul ?? null,
214
197
  persona: state.persona ?? null,
215
198
  rules: state.rules,
@@ -223,7 +206,6 @@ export async function writeState(state: State): Promise<void> {
223
206
 
224
207
  /** Local state only stores overrides. undefined = cascade, null = explicit unset. */
225
208
  export interface LocalState {
226
- identity?: string | null
227
209
  soul?: string | null
228
210
  persona?: string | null
229
211
  rules?: {
@@ -240,7 +222,6 @@ export type Scope = 'global' | 'local' | '+local' | '-local' | 'env' | '+env' |
240
222
  /** Effective state after merging global + local + env, with scope annotations. */
241
223
  export interface EffectiveState {
242
224
  backend: string | null
243
- identity: { value: string | null; scope: Scope }
244
225
  soul: { value: string | null; scope: Scope }
245
226
  persona: { value: string | null; scope: Scope }
246
227
  rules: { value: string; scope: Scope }[]
@@ -268,7 +249,6 @@ export async function readLocalState(): Promise<LocalState> {
268
249
  const p = parsed as Record<string, unknown>
269
250
 
270
251
  // For each layer: if key is present, include it (even if null)
271
- if ('identity' in p) result.identity = p.identity === null ? null : safeName(p.identity)
272
252
  if ('soul' in p) result.soul = p.soul === null ? null : safeName(p.soul)
273
253
  if ('persona' in p) result.persona = p.persona === null ? null : safeName(p.persona)
274
254
 
@@ -292,7 +272,6 @@ export async function writeLocalState(local: LocalState): Promise<void> {
292
272
 
293
273
  // Build a clean doc — only include keys that are present in local
294
274
  const doc: Record<string, unknown> = {}
295
- if ('identity' in local) doc.identity = local.identity ?? null
296
275
  if ('soul' in local) doc.soul = local.soul ?? null
297
276
  if ('persona' in local) doc.persona = local.persona ?? null
298
277
  if (local.rules) {
@@ -313,9 +292,6 @@ export function readEnvState(extraEnv?: Record<string, string>): EnvState {
313
292
  const env = extraEnv ? { ...process.env, ...extraEnv } : process.env
314
293
  const result: EnvState = {}
315
294
 
316
- if (env.BRAINJAR_IDENTITY !== undefined) {
317
- result.identity = env.BRAINJAR_IDENTITY === '' ? null : safeName(env.BRAINJAR_IDENTITY)
318
- }
319
295
  if (env.BRAINJAR_SOUL !== undefined) {
320
296
  result.soul = env.BRAINJAR_SOUL === '' ? null : safeName(env.BRAINJAR_SOUL)
321
297
  }
@@ -353,10 +329,6 @@ function applyOverrides(
353
329
  const plusScope = `+${scope}` as Scope
354
330
  const minusScope = `-${scope}` as Scope
355
331
 
356
- const identity = 'identity' in overrides
357
- ? { value: overrides.identity ?? null, scope: scope as Scope }
358
- : base.identity
359
-
360
332
  const soul = 'soul' in overrides
361
333
  ? { value: overrides.soul ?? null, scope: scope as Scope }
362
334
  : base.soul
@@ -395,7 +367,7 @@ function applyOverrides(
395
367
  }
396
368
  }
397
369
 
398
- return { backend: base.backend, identity, soul, persona, rules }
370
+ return { backend: base.backend, soul, persona, rules }
399
371
  }
400
372
 
401
373
  /** Pure merge: global → local → env, each scope overrides the previous. */
@@ -403,7 +375,6 @@ export function mergeState(global: State, local: LocalState, env?: EnvState): Ef
403
375
  // Start with global as the base effective state
404
376
  const base: EffectiveState = {
405
377
  backend: global.backend,
406
- identity: { value: global.identity, scope: 'global' },
407
378
  soul: { value: global.soul, scope: 'global' },
408
379
  persona: { value: global.persona, scope: 'global' },
409
380
  rules: global.rules.map(r => ({ value: r, scope: 'global' as Scope })),
package/src/sync.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, copyFile, mkdir, access } from 'node:fs/promises'
1
+ import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { type Backend, getBackendConfig, paths } from './paths.js'
4
4
  import { type State, readState, readLocalState, readEnvState, mergeState, requireBrainjarDir, stripFrontmatter, resolveRuleContent } from './state.js'
@@ -40,17 +40,6 @@ async function inlineRules(rules: string[], sections: string[], warnings: string
40
40
  }
41
41
  }
42
42
 
43
- async function inlineIdentity(name: string, sections: string[]) {
44
- try {
45
- await access(join(paths.identities, `${name}.yaml`))
46
- sections.push('')
47
- sections.push('## Identity')
48
- sections.push('')
49
- sections.push(`See ~/.brainjar/identities/${name}.yaml for active identity.`)
50
- sections.push('Manage with `brainjar identity [list|use|show]`.')
51
- } catch {}
52
- }
53
-
54
43
  /** Extract content before, inside, and after brainjar markers. */
55
44
  function parseMarkers(content: string): { before: string; after: string } | null {
56
45
  const startIdx = content.indexOf(MARKER_START)
@@ -115,21 +104,16 @@ export async function sync(options?: Backend | SyncOptions) {
115
104
  // But only write rules section if local state has rules overrides
116
105
  await inlineRules(activeRules, sections, warnings)
117
106
  }
118
- if ('identity' in localState && effective.identity.value) {
119
- await inlineIdentity(effective.identity.value, sections)
120
- }
121
107
  } else {
122
108
  // Global mode: apply env overrides on top of global state, write all layers
123
109
  const effective = mergeState(globalState, {}, envState)
124
110
  const effectiveSoul = effective.soul.value
125
111
  const effectivePersona = effective.persona.value
126
112
  const effectiveRules = effective.rules.filter(r => !r.scope.startsWith('-')).map(r => r.value)
127
- const effectiveIdentity = effective.identity.value
128
113
 
129
114
  if (effectiveSoul) await inlineSoul(effectiveSoul, sections)
130
115
  if (effectivePersona) await inlinePersona(effectivePersona, sections)
131
116
  await inlineRules(effectiveRules, sections, warnings)
132
- if (effectiveIdentity) await inlineIdentity(effectiveIdentity, sections)
133
117
 
134
118
  // Local Overrides note (only for global config)
135
119
  sections.push('')
@@ -1,276 +0,0 @@
1
- import { Cli, z, Errors } from 'incur'
2
- import { stringify as stringifyYaml } from 'yaml'
3
-
4
- const { IncurError } = Errors
5
- import { readdir, readFile, writeFile, mkdir, rm } from 'node:fs/promises'
6
- import { join, basename } from 'node:path'
7
- import { paths } from '../paths.js'
8
- import { readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, loadIdentity, parseIdentity, requireBrainjarDir, normalizeSlug } from '../state.js'
9
- import { getEngine } from '../engines/index.js'
10
- import { sync } from '../sync.js'
11
-
12
- function redactSession(status: Record<string, unknown>) {
13
- const { session: _, ...safe } = status as any
14
- return safe
15
- }
16
-
17
- async function requireActiveIdentity() {
18
- await requireBrainjarDir()
19
- const state = await readState()
20
- if (!state.identity) {
21
- throw new IncurError({
22
- code: 'NO_ACTIVE_IDENTITY',
23
- message: 'No active identity.',
24
- hint: 'Run `brainjar identity use <slug>` to activate one.',
25
- })
26
- }
27
- return loadIdentity(state.identity)
28
- }
29
-
30
- function requireEngine(engineName: string | undefined) {
31
- if (!engineName) {
32
- throw new IncurError({
33
- code: 'NO_ENGINE',
34
- message: 'Active identity has no engine configured.',
35
- })
36
- }
37
- const engine = getEngine(engineName)
38
- if (!engine) {
39
- throw new IncurError({
40
- code: 'UNKNOWN_ENGINE',
41
- message: `Unknown engine: ${engineName}`,
42
- hint: 'Supported engines: bitwarden',
43
- })
44
- }
45
- return engine
46
- }
47
-
48
- export const identity = Cli.create('identity', {
49
- description: 'Manage digital identity — one active at a time',
50
- })
51
- .command('create', {
52
- description: 'Create a new identity',
53
- args: z.object({
54
- slug: z.string().describe('Identity slug (e.g. personal, work)'),
55
- }),
56
- options: z.object({
57
- name: z.string().describe('Full display name'),
58
- email: z.string().describe('Email address'),
59
- engine: z.literal('bitwarden').default('bitwarden').describe('Credential engine'),
60
- }),
61
- async run(c) {
62
- await requireBrainjarDir()
63
- const slug = normalizeSlug(c.args.slug, 'identity slug')
64
- await mkdir(paths.identities, { recursive: true })
65
-
66
- const content = stringifyYaml({ name: c.options.name, email: c.options.email, engine: c.options.engine })
67
-
68
- const filePath = join(paths.identities, `${slug}.yaml`)
69
- await writeFile(filePath, content)
70
-
71
- return {
72
- created: filePath,
73
- identity: { slug, name: c.options.name, email: c.options.email, engine: c.options.engine },
74
- next: `Run \`brainjar identity use ${slug}\` to activate.`,
75
- }
76
- },
77
- })
78
- .command('list', {
79
- description: 'List available identities',
80
- async run() {
81
- const entries = await readdir(paths.identities).catch(() => [])
82
- const identities = []
83
-
84
- for (const file of entries.filter(f => f.endsWith('.yaml'))) {
85
- const slug = basename(file, '.yaml')
86
- const content = await readFile(join(paths.identities, file), 'utf-8')
87
- identities.push({ slug, ...parseIdentity(content) })
88
- }
89
-
90
- return { identities }
91
- },
92
- })
93
- .command('show', {
94
- description: 'Show the active identity',
95
- options: z.object({
96
- local: z.boolean().default(false).describe('Show local identity override (if any)'),
97
- short: z.boolean().default(false).describe('Print only the active identity slug'),
98
- }),
99
- async run(c) {
100
- if (c.options.short) {
101
- const global = await readState()
102
- const local = await readLocalState()
103
- const env = readEnvState()
104
- const effective = mergeState(global, local, env)
105
- return effective.identity.value ?? 'none'
106
- }
107
-
108
- if (c.options.local) {
109
- const local = await readLocalState()
110
- if (!('identity' in local)) return { active: false, scope: 'local', note: 'No local identity override (cascades from global)' }
111
- if (local.identity === null) return { active: false, scope: 'local', slug: null, note: 'Explicitly unset at local scope' }
112
- try {
113
- const content = await readFile(join(paths.identities, `${local.identity}.yaml`), 'utf-8')
114
- return { active: true, scope: 'local', slug: local.identity, ...parseIdentity(content) }
115
- } catch {
116
- return { active: false, scope: 'local', slug: local.identity, error: 'File not found' }
117
- }
118
- }
119
-
120
- const global = await readState()
121
- const local = await readLocalState()
122
- const env = readEnvState()
123
- const effective = mergeState(global, local, env)
124
- if (!effective.identity.value) return { active: false }
125
- try {
126
- const content = await readFile(join(paths.identities, `${effective.identity.value}.yaml`), 'utf-8')
127
- return { active: true, slug: effective.identity.value, scope: effective.identity.scope, ...parseIdentity(content) }
128
- } catch {
129
- return { active: false, slug: effective.identity.value, error: 'File not found' }
130
- }
131
- },
132
- })
133
- .command('use', {
134
- description: 'Activate an identity',
135
- args: z.object({
136
- slug: z.string().describe('Identity slug to activate'),
137
- }),
138
- options: z.object({
139
- local: z.boolean().default(false).describe('Write to local .claude/CLAUDE.md instead of global'),
140
- }),
141
- async run(c) {
142
- await requireBrainjarDir()
143
- const slug = normalizeSlug(c.args.slug, 'identity slug')
144
- const source = join(paths.identities, `${slug}.yaml`)
145
- try {
146
- await readFile(source, 'utf-8')
147
- } catch {
148
- throw new IncurError({
149
- code: 'IDENTITY_NOT_FOUND',
150
- message: `Identity "${slug}" not found.`,
151
- hint: 'Run `brainjar identity list` to see available identities.',
152
- })
153
- }
154
-
155
- if (c.options.local) {
156
- await withLocalStateLock(async () => {
157
- const local = await readLocalState()
158
- local.identity = slug
159
- await writeLocalState(local)
160
- await sync({ local: true })
161
- })
162
- } else {
163
- await withStateLock(async () => {
164
- const state = await readState()
165
- state.identity = slug
166
- await writeState(state)
167
- await sync()
168
- })
169
- }
170
-
171
- return { activated: slug, local: c.options.local }
172
- },
173
- })
174
- .command('drop', {
175
- description: 'Deactivate the current identity',
176
- options: z.object({
177
- local: z.boolean().default(false).describe('Remove local identity override or deactivate global identity'),
178
- }),
179
- async run(c) {
180
- await requireBrainjarDir()
181
- if (c.options.local) {
182
- await withLocalStateLock(async () => {
183
- const local = await readLocalState()
184
- delete local.identity
185
- await writeLocalState(local)
186
- await sync({ local: true })
187
- })
188
- } else {
189
- await withStateLock(async () => {
190
- const state = await readState()
191
- if (!state.identity) {
192
- throw new IncurError({
193
- code: 'NO_ACTIVE_IDENTITY',
194
- message: 'No active identity to deactivate.',
195
- })
196
- }
197
- state.identity = null
198
- await writeState(state)
199
- await sync()
200
- })
201
- }
202
- return { deactivated: true, local: c.options.local }
203
- },
204
- })
205
- .command('unlock', {
206
- description: 'Store the credential engine session token',
207
- args: z.object({
208
- session: z.string().optional().describe('Session token (reads from stdin if omitted)'),
209
- }),
210
- async run(c) {
211
- let session = c.args.session
212
- if (!session) {
213
- if (process.stdin.isTTY) {
214
- throw new IncurError({
215
- code: 'NO_SESSION',
216
- message: 'No session token provided.',
217
- hint: 'Pipe it in: bw unlock --raw | brainjar identity unlock',
218
- })
219
- }
220
- let data = ''
221
- for await (const chunk of process.stdin) {
222
- data += typeof chunk === 'string' ? chunk : chunk.toString('utf-8')
223
- }
224
- session = data.trim()
225
- }
226
- if (!session) {
227
- throw new IncurError({
228
- code: 'EMPTY_SESSION',
229
- message: 'Session token is empty.',
230
- })
231
- }
232
- await writeFile(paths.session, session, { mode: 0o600 })
233
- return { unlocked: true, stored: paths.session }
234
- },
235
- })
236
- .command('get', {
237
- description: 'Retrieve a credential from the active identity engine',
238
- args: z.object({
239
- item: z.string().describe('Item name or ID to retrieve from the vault'),
240
- }),
241
- async run(c) {
242
- const { engine: engineName } = await requireActiveIdentity()
243
- const engine = requireEngine(engineName)
244
-
245
- const status = await engine.status()
246
- if (status.state !== 'unlocked') {
247
- throw new IncurError({
248
- code: 'ENGINE_LOCKED',
249
- message: 'Credential engine is not unlocked.',
250
- hint: 'operator_action' in status ? status.operator_action : undefined,
251
- retryable: true,
252
- })
253
- }
254
-
255
- return engine.get(c.args.item, status.session)
256
- },
257
- })
258
- .command('status', {
259
- description: 'Check if the credential engine session is active',
260
- async run() {
261
- const { name, email, engine: engineName } = await requireActiveIdentity()
262
- const engine = requireEngine(engineName)
263
- const engineStatus = await engine.status()
264
- return { identity: { name, email, engine: engineName }, ...redactSession(engineStatus) }
265
- },
266
- })
267
- .command('lock', {
268
- description: 'Lock the credential engine session',
269
- async run() {
270
- const { engine: engineName } = await requireActiveIdentity()
271
- const engine = requireEngine(engineName)
272
- await engine.lock()
273
- await rm(paths.session, { force: true })
274
- return { locked: true }
275
- },
276
- })
@@ -1,105 +0,0 @@
1
- import { $ } from 'bun'
2
- import { readFile } from 'node:fs/promises'
3
- import { paths } from '../paths.js'
4
- import type { CredentialEngine, EngineStatus } from './types.js'
5
-
6
- export async function loadSession(): Promise<string | null> {
7
- // Env var takes precedence, then session file
8
- if (process.env.BW_SESSION) return process.env.BW_SESSION
9
- try {
10
- const session = (await readFile(paths.session, 'utf-8')).trim()
11
- return session || null
12
- } catch {
13
- return null
14
- }
15
- }
16
-
17
- /** Thin shell wrapper — extracted so tests can replace it via spyOn. */
18
- export const bw = {
19
- async whichBw(): Promise<void> {
20
- await $`which bw`.quiet()
21
- },
22
-
23
- async status(session: string | null): Promise<any> {
24
- return session
25
- ? $`bw status`.env({ ...process.env, BW_SESSION: session }).json()
26
- : $`bw status`.json()
27
- },
28
-
29
- async getItem(item: string, session: string): Promise<any> {
30
- return $`bw get item ${item}`.env({ ...process.env, BW_SESSION: session }).json()
31
- },
32
-
33
- async lock(): Promise<void> {
34
- await $`bw lock`.quiet()
35
- },
36
- }
37
-
38
- export const bitwarden: CredentialEngine = {
39
- name: 'bitwarden',
40
-
41
- async status(): Promise<EngineStatus> {
42
- try {
43
- await bw.whichBw()
44
- } catch {
45
- return { state: 'not_installed', install: 'npm install -g @bitwarden/cli' }
46
- }
47
-
48
- try {
49
- const session = await loadSession()
50
- const result = await bw.status(session)
51
-
52
- if (result.status === 'unauthenticated') {
53
- return {
54
- state: 'unauthenticated',
55
- operator_action: `bw login ${result.userEmail ?? '<email>'}`,
56
- }
57
- }
58
-
59
- if (result.status === 'unlocked' && session) {
60
- return { state: 'unlocked', session }
61
- }
62
-
63
- return {
64
- state: 'locked',
65
- operator_action: 'Run `bw unlock` and then `brainjar identity unlock <session>`',
66
- }
67
- } catch {
68
- return {
69
- state: 'locked',
70
- operator_action: 'Could not determine vault status. Run `bw unlock` and then `brainjar identity unlock <session>`',
71
- }
72
- }
73
- },
74
-
75
- async get(item: string, session: string) {
76
- if (!item || item.length > 256 || /[\x00-\x1f]/.test(item)) {
77
- return { error: `Invalid item name: "${item}"` }
78
- }
79
- try {
80
- const result = await bw.getItem(item, session)
81
-
82
- if (result.login?.password) {
83
- return { value: result.login.password }
84
- }
85
- if (result.notes) {
86
- return { value: result.notes }
87
- }
88
-
89
- return { error: `Item "${item}" found but has no password or notes.` }
90
- } catch (e) {
91
- const stderr = (e as any)?.stderr?.toString?.()?.trim?.()
92
- const message = stderr || (e as Error).message || 'unknown error'
93
- return { error: `Could not retrieve "${item}": ${message}` }
94
- }
95
- },
96
-
97
- async lock() {
98
- try {
99
- await bw.lock()
100
- } catch (e) {
101
- const stderr = (e as any)?.stderr?.toString?.()?.trim?.()
102
- throw new Error(`Failed to lock vault: ${stderr || (e as Error).message}`)
103
- }
104
- },
105
- }
@@ -1,12 +0,0 @@
1
- import type { CredentialEngine } from './types.js'
2
- import { bitwarden } from './bitwarden.js'
3
-
4
- const engines: Record<string, CredentialEngine> = {
5
- bitwarden,
6
- }
7
-
8
- export function getEngine(name: string): CredentialEngine | null {
9
- return engines[name] ?? null
10
- }
11
-
12
- export type { CredentialEngine }
@@ -1,12 +0,0 @@
1
- export type EngineStatus =
2
- | { state: 'not_installed'; install: string }
3
- | { state: 'unauthenticated'; operator_action: string }
4
- | { state: 'locked'; operator_action: string }
5
- | { state: 'unlocked'; session: string }
6
-
7
- export interface CredentialEngine {
8
- name: string
9
- status(): Promise<EngineStatus>
10
- get(item: string, session: string): Promise<{ value: string } | { error: string }>
11
- lock(): Promise<void>
12
- }