@brainjar/cli 0.2.3 → 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.
Files changed (40) hide show
  1. package/README.md +11 -10
  2. package/package.json +2 -2
  3. package/src/api-types.ts +155 -0
  4. package/src/cli.ts +5 -3
  5. package/src/client.ts +157 -0
  6. package/src/commands/brain.ts +99 -113
  7. package/src/commands/compose.ts +17 -116
  8. package/src/commands/init.ts +66 -42
  9. package/src/commands/migrate.ts +61 -0
  10. package/src/commands/pack.ts +1 -5
  11. package/src/commands/persona.ts +97 -145
  12. package/src/commands/rules.ts +71 -174
  13. package/src/commands/server.ts +212 -0
  14. package/src/commands/shell.ts +55 -51
  15. package/src/commands/soul.ts +75 -110
  16. package/src/commands/status.ts +37 -78
  17. package/src/commands/sync.ts +0 -2
  18. package/src/config.ts +125 -0
  19. package/src/daemon.ts +404 -0
  20. package/src/errors.ts +172 -0
  21. package/src/migrate.ts +247 -0
  22. package/src/pack.ts +149 -428
  23. package/src/paths.ts +1 -8
  24. package/src/seeds.ts +62 -105
  25. package/src/state.ts +12 -397
  26. package/src/sync.ts +61 -102
  27. package/src/version-check.ts +137 -0
  28. package/src/brain.ts +0 -69
  29. package/src/commands/identity.ts +0 -276
  30. package/src/engines/bitwarden.ts +0 -105
  31. package/src/engines/index.ts +0 -12
  32. package/src/engines/types.ts +0 -12
  33. package/src/hooks.test.ts +0 -132
  34. package/src/pack.test.ts +0 -472
  35. package/src/seeds/templates/persona.md +0 -19
  36. package/src/seeds/templates/rule.md +0 -11
  37. package/src/seeds/templates/soul.md +0 -20
  38. /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
  39. /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
  40. /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
package/src/sync.ts CHANGED
@@ -1,57 +1,19 @@
1
- import { readFile, writeFile, copyFile, mkdir, access } from 'node:fs/promises'
2
- import { join } from 'node:path'
3
- import { type Backend, getBackendConfig, paths } from './paths.js'
4
- import { type State, readState, readLocalState, readEnvState, mergeState, requireBrainjarDir, stripFrontmatter, resolveRuleContent } from './state.js'
1
+ import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises'
2
+ import { type Backend, getBackendConfig } from './paths.js'
3
+ import { getEffectiveState } from './state.js'
4
+ import { getApi, type BrainjarClient } from './client.js'
5
+ import type { ApiEffectiveState, ApiSoul, ApiPersona, ApiRule } from './api-types.js'
5
6
 
6
7
  export const MARKER_START = '<!-- brainjar:start -->'
7
8
  export const MARKER_END = '<!-- brainjar:end -->'
8
9
 
9
10
  export interface SyncOptions {
10
11
  backend?: Backend
11
- local?: boolean
12
- envOverrides?: Record<string, string>
12
+ project?: boolean
13
+ api?: BrainjarClient
13
14
  }
14
15
 
15
- async function inlineSoul(name: string, sections: string[]) {
16
- const raw = await readFile(join(paths.souls, `${name}.md`), 'utf-8')
17
- const content = stripFrontmatter(raw)
18
- sections.push('')
19
- sections.push('## Soul')
20
- sections.push('')
21
- sections.push(content)
22
- }
23
-
24
- async function inlinePersona(name: string, sections: string[]) {
25
- const raw = await readFile(join(paths.personas, `${name}.md`), 'utf-8')
26
- const content = stripFrontmatter(raw)
27
- sections.push('')
28
- sections.push('## Persona')
29
- sections.push('')
30
- sections.push(content)
31
- }
32
-
33
- async function inlineRules(rules: string[], sections: string[], warnings: string[]) {
34
- for (const rule of rules) {
35
- const contents = await resolveRuleContent(rule, warnings)
36
- for (const content of contents) {
37
- sections.push('')
38
- sections.push(content)
39
- }
40
- }
41
- }
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
- /** Extract content before, inside, and after brainjar markers. */
16
+ /** Extract content before and after brainjar markers. */
55
17
  function parseMarkers(content: string): { before: string; after: string } | null {
56
18
  const startIdx = content.indexOf(MARKER_START)
57
19
  const endIdx = content.indexOf(MARKER_END)
@@ -62,18 +24,59 @@ function parseMarkers(content: string): { before: string; after: string } | null
62
24
  return { before, after }
63
25
  }
64
26
 
65
- export async function sync(options?: Backend | SyncOptions) {
66
- await requireBrainjarDir()
27
+ /** Fetch content from server and assemble the brainjar markdown sections. */
28
+ async function assembleFromServer(api: BrainjarClient, state: ApiEffectiveState): Promise<{ sections: string[]; warnings: string[] }> {
29
+ const sections: string[] = []
30
+ const warnings: string[] = []
67
31
 
68
- // Normalize legacy call signature: sync('claude') → sync({ backend: 'claude' })
69
- const opts: SyncOptions = typeof options === 'string' ? { backend: options } : options ?? {}
32
+ if (state.soul) {
33
+ try {
34
+ const soul = await api.get<ApiSoul>(`/api/v1/souls/${state.soul}`)
35
+ sections.push('')
36
+ sections.push('## Soul')
37
+ sections.push('')
38
+ sections.push(soul.content.trim())
39
+ } catch {
40
+ warnings.push(`Could not fetch soul "${state.soul}"`)
41
+ }
42
+ }
70
43
 
71
- const globalState = await readState()
72
- const backend: Backend = opts.backend ?? (globalState.backend as Backend) ?? 'claude'
73
- const config = getBackendConfig(backend, { local: opts.local })
44
+ if (state.persona) {
45
+ try {
46
+ const persona = await api.get<ApiPersona>(`/api/v1/personas/${state.persona}`)
47
+ sections.push('')
48
+ sections.push('## Persona')
49
+ sections.push('')
50
+ sections.push(persona.content.trim())
51
+ } catch {
52
+ warnings.push(`Could not fetch persona "${state.persona}"`)
53
+ }
54
+ }
74
55
 
75
- const envState = readEnvState(opts.envOverrides)
76
- const warnings: string[] = []
56
+ for (const ruleSlug of state.rules) {
57
+ try {
58
+ const ruleData = await api.get<ApiRule>(`/api/v1/rules/${ruleSlug}`)
59
+ for (const entry of ruleData.entries) {
60
+ sections.push('')
61
+ sections.push(entry.content.trim())
62
+ }
63
+ } catch {
64
+ warnings.push(`Could not fetch rule "${ruleSlug}"`)
65
+ }
66
+ }
67
+
68
+ return { sections, warnings }
69
+ }
70
+
71
+ export async function sync(options?: SyncOptions) {
72
+ const opts = options ?? {}
73
+ const api = opts.api ?? await getApi()
74
+
75
+ const state = await getEffectiveState(api)
76
+ const backend: Backend = opts.backend ?? 'claude'
77
+ const config = getBackendConfig(backend, { local: opts.project })
78
+
79
+ const { sections, warnings } = await assembleFromServer(api, state)
77
80
 
78
81
  // Read existing config file
79
82
  let existingContent: string | null = null
@@ -92,46 +95,8 @@ export async function sync(options?: Backend | SyncOptions) {
92
95
  }
93
96
  }
94
97
 
95
- // Build the brainjar section content
96
- const sections: string[] = []
97
-
98
- if (opts.local) {
99
- // Local mode: read local state + env, only write overridden layers.
100
- // Everything else falls back to the global config (Claude Code merges both files).
101
- const localState = await readLocalState()
102
- const effective = mergeState(globalState, localState, envState)
103
-
104
- if ('soul' in localState && effective.soul.value) {
105
- await inlineSoul(effective.soul.value, sections)
106
- }
107
- if ('persona' in localState && effective.persona.value) {
108
- await inlinePersona(effective.persona.value, sections)
109
- }
110
- if (localState.rules) {
111
- // Inline the effective rules that are active (not removed)
112
- const activeRules = effective.rules
113
- .filter(r => !r.scope.startsWith('-'))
114
- .map(r => r.value)
115
- // But only write rules section if local state has rules overrides
116
- await inlineRules(activeRules, sections, warnings)
117
- }
118
- if ('identity' in localState && effective.identity.value) {
119
- await inlineIdentity(effective.identity.value, sections)
120
- }
121
- } else {
122
- // Global mode: apply env overrides on top of global state, write all layers
123
- const effective = mergeState(globalState, {}, envState)
124
- const effectiveSoul = effective.soul.value
125
- const effectivePersona = effective.persona.value
126
- const effectiveRules = effective.rules.filter(r => !r.scope.startsWith('-')).map(r => r.value)
127
- const effectiveIdentity = effective.identity.value
128
-
129
- if (effectiveSoul) await inlineSoul(effectiveSoul, sections)
130
- if (effectivePersona) await inlinePersona(effectivePersona, sections)
131
- await inlineRules(effectiveRules, sections, warnings)
132
- if (effectiveIdentity) await inlineIdentity(effectiveIdentity, sections)
133
-
134
- // Local Overrides note (only for global config)
98
+ // Add project-level overrides note for global config
99
+ if (!opts.project) {
135
100
  sections.push('')
136
101
  sections.push('## Project-Level Overrides')
137
102
  sections.push('')
@@ -152,8 +117,6 @@ export async function sync(options?: Backend | SyncOptions) {
152
117
  const parsed = existingContent ? parseMarkers(existingContent) : null
153
118
 
154
119
  if (parsed) {
155
- // Existing file with markers — replace the brainjar section, preserve the rest
156
- // Discard legacy brainjar content that ended up outside markers during migration
157
120
  const before = parsed.before
158
121
  const after = parsed.after?.includes('# Managed by brainjar') ? '' : parsed.after
159
122
  const parts: string[] = []
@@ -162,16 +125,12 @@ export async function sync(options?: Backend | SyncOptions) {
162
125
  if (after) parts.push('', after)
163
126
  output = parts.join('\n')
164
127
  } else if (existingContent && !existingContent.includes(MARKER_START)) {
165
- // Existing file without markers (first sync)
166
128
  if (existingContent.includes('# Managed by brainjar')) {
167
- // Legacy brainjar-managed file — replace entirely
168
129
  output = brainjarBlock + '\n'
169
130
  } else {
170
- // User-owned file — prepend brainjar section, preserve user content
171
131
  output = brainjarBlock + '\n\n' + existingContent
172
132
  }
173
133
  } else {
174
- // No existing file
175
134
  output = brainjarBlock + '\n'
176
135
  }
177
136
 
@@ -181,7 +140,7 @@ export async function sync(options?: Backend | SyncOptions) {
181
140
  return {
182
141
  backend,
183
142
  written: config.configFile,
184
- local: opts.local ?? false,
143
+ project: opts.project ?? false,
185
144
  ...(warnings.length ? { warnings } : {}),
186
145
  }
187
146
  }
@@ -0,0 +1,137 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { getBrainjarDir } from './paths.js'
4
+ import { fetchLatestVersion, DIST_BASE } from './daemon.js'
5
+
6
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000 // 1 hour
7
+ const NPM_REGISTRY = 'https://registry.npmjs.org'
8
+
9
+ interface VersionCache {
10
+ checkedAt: number
11
+ cli?: string
12
+ server?: string
13
+ }
14
+
15
+ function cachePath(): string {
16
+ return join(getBrainjarDir(), 'version-cache.json')
17
+ }
18
+
19
+ export function installedVersionPath(): string {
20
+ return join(getBrainjarDir(), 'server-version')
21
+ }
22
+
23
+ /** Read the installed server version, or null if not tracked. */
24
+ export async function getInstalledServerVersion(): Promise<string | null> {
25
+ try {
26
+ return (await readFile(installedVersionPath(), 'utf-8')).trim()
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ /** Write the installed server version after a successful download. */
33
+ export async function setInstalledServerVersion(version: string): Promise<void> {
34
+ await mkdir(getBrainjarDir(), { recursive: true })
35
+ await writeFile(installedVersionPath(), version)
36
+ }
37
+
38
+ /** Read the cached version check result. */
39
+ async function readCache(): Promise<VersionCache | null> {
40
+ try {
41
+ const raw = await readFile(cachePath(), 'utf-8')
42
+ return JSON.parse(raw) as VersionCache
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /** Write the version check cache. */
49
+ async function writeCache(cache: VersionCache): Promise<void> {
50
+ await mkdir(getBrainjarDir(), { recursive: true })
51
+ await writeFile(cachePath(), JSON.stringify(cache))
52
+ }
53
+
54
+ /** Fetch the latest CLI version from npm registry. */
55
+ async function fetchLatestCliVersion(): Promise<string | null> {
56
+ try {
57
+ const response = await fetch(`${NPM_REGISTRY}/-/package/@brainjar/cli/dist-tags`, {
58
+ signal: AbortSignal.timeout(3000),
59
+ })
60
+ if (!response.ok) return null
61
+ const tags = (await response.json()) as Record<string, string>
62
+ return tags.latest ?? null
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ export interface UpdateInfo {
69
+ cli?: { current: string; latest: string }
70
+ server?: { current: string; latest: string }
71
+ }
72
+
73
+ /**
74
+ * Check for available updates. Results are cached for 1 hour.
75
+ * Never throws — returns null on any failure.
76
+ */
77
+ export async function checkForUpdates(currentCliVersion: string): Promise<UpdateInfo | null> {
78
+ try {
79
+ const cache = await readCache()
80
+ const now = Date.now()
81
+
82
+ let latestCli: string | undefined
83
+ let latestServer: string | undefined
84
+
85
+ if (cache && (now - cache.checkedAt) < CHECK_INTERVAL_MS) {
86
+ latestCli = cache.cli
87
+ latestServer = cache.server
88
+ } else {
89
+ const [cli, server] = await Promise.all([
90
+ fetchLatestCliVersion(),
91
+ fetchLatestVersion(DIST_BASE).catch(() => null),
92
+ ])
93
+
94
+ latestCli = cli ?? undefined
95
+ latestServer = server ?? undefined
96
+
97
+ await writeCache({ checkedAt: now, cli: latestCli, server: latestServer }).catch(() => {})
98
+ }
99
+
100
+ const info: UpdateInfo = {}
101
+
102
+ if (latestCli && latestCli !== currentCliVersion) {
103
+ info.cli = { current: currentCliVersion, latest: latestCli }
104
+ }
105
+
106
+ const installedServer = await getInstalledServerVersion()
107
+ if (latestServer && installedServer && latestServer !== installedServer) {
108
+ info.server = { current: installedServer, latest: latestServer }
109
+ }
110
+
111
+ if (info.cli || info.server) return info
112
+ return null
113
+ } catch {
114
+ return null
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Render update banner text. Returns undefined if no updates available.
120
+ * Ready to wire into incur's `banner` option.
121
+ */
122
+ export async function renderUpdateBanner(currentCliVersion: string): Promise<string | undefined> {
123
+ const updates = await checkForUpdates(currentCliVersion)
124
+ if (!updates) return undefined
125
+
126
+ const lines: string[] = []
127
+
128
+ if (updates.cli) {
129
+ lines.push(` ⬆ brainjar ${updates.cli.latest} available (current: ${updates.cli.current}) — npm update -g @brainjar/cli`)
130
+ }
131
+
132
+ if (updates.server) {
133
+ lines.push(` ⬆ server ${updates.server.latest} available (current: ${updates.server.current}) — brainjar server upgrade`)
134
+ }
135
+
136
+ return lines.length > 0 ? lines.join('\n') : undefined
137
+ }
package/src/brain.ts DELETED
@@ -1,69 +0,0 @@
1
- import { Errors } from 'incur'
2
- import { readFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
4
- import { parse as parseYaml } from 'yaml'
5
- import { paths } from './paths.js'
6
- import { normalizeSlug } from './state.js'
7
-
8
- const { IncurError } = Errors
9
-
10
- /** Brain YAML schema: soul + persona + rules */
11
- export interface BrainConfig {
12
- soul: string
13
- persona: string
14
- rules: string[]
15
- }
16
-
17
- /** Read and validate a brain YAML file. */
18
- export async function readBrain(name: string): Promise<BrainConfig> {
19
- const slug = normalizeSlug(name, 'brain name')
20
- const file = join(paths.brains, `${slug}.yaml`)
21
-
22
- let raw: string
23
- try {
24
- raw = await readFile(file, 'utf-8')
25
- } catch {
26
- throw new IncurError({
27
- code: 'BRAIN_NOT_FOUND',
28
- message: `Brain "${slug}" not found.`,
29
- hint: 'Run `brainjar brain list` to see available brains.',
30
- })
31
- }
32
-
33
- let parsed: unknown
34
- try {
35
- parsed = parseYaml(raw)
36
- } catch (e) {
37
- throw new IncurError({
38
- code: 'BRAIN_CORRUPT',
39
- message: `Brain "${slug}" has invalid YAML: ${(e as Error).message}`,
40
- })
41
- }
42
-
43
- if (!parsed || typeof parsed !== 'object') {
44
- throw new IncurError({
45
- code: 'BRAIN_CORRUPT',
46
- message: `Brain "${slug}" is empty or invalid.`,
47
- })
48
- }
49
-
50
- const p = parsed as Record<string, unknown>
51
-
52
- if (typeof p.soul !== 'string' || !p.soul) {
53
- throw new IncurError({
54
- code: 'BRAIN_INVALID',
55
- message: `Brain "${slug}" is missing required field "soul".`,
56
- })
57
- }
58
-
59
- if (typeof p.persona !== 'string' || !p.persona) {
60
- throw new IncurError({
61
- code: 'BRAIN_INVALID',
62
- message: `Brain "${slug}" is missing required field "persona".`,
63
- })
64
- }
65
-
66
- const rules = Array.isArray(p.rules) ? p.rules.map(String) : []
67
-
68
- return { soul: p.soul, persona: p.persona, rules }
69
- }
@@ -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
- })