@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.
- package/README.md +11 -10
- package/package.json +2 -2
- package/src/api-types.ts +155 -0
- package/src/cli.ts +5 -3
- package/src/client.ts +157 -0
- package/src/commands/brain.ts +99 -113
- package/src/commands/compose.ts +17 -116
- package/src/commands/init.ts +66 -42
- package/src/commands/migrate.ts +61 -0
- package/src/commands/pack.ts +1 -5
- package/src/commands/persona.ts +97 -145
- package/src/commands/rules.ts +71 -174
- package/src/commands/server.ts +212 -0
- package/src/commands/shell.ts +55 -51
- package/src/commands/soul.ts +75 -110
- package/src/commands/status.ts +37 -78
- package/src/commands/sync.ts +0 -2
- package/src/config.ts +125 -0
- package/src/daemon.ts +404 -0
- package/src/errors.ts +172 -0
- package/src/migrate.ts +247 -0
- package/src/pack.ts +149 -428
- package/src/paths.ts +1 -8
- package/src/seeds.ts +62 -105
- package/src/state.ts +12 -397
- package/src/sync.ts +61 -102
- package/src/version-check.ts +137 -0
- package/src/brain.ts +0 -69
- package/src/commands/identity.ts +0 -276
- package/src/engines/bitwarden.ts +0 -105
- package/src/engines/index.ts +0 -12
- package/src/engines/types.ts +0 -12
- package/src/hooks.test.ts +0 -132
- package/src/pack.test.ts +0 -472
- package/src/seeds/templates/persona.md +0 -19
- package/src/seeds/templates/rule.md +0 -11
- package/src/seeds/templates/soul.md +0 -20
- /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
- /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
- /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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
12
|
-
|
|
12
|
+
project?: boolean
|
|
13
|
+
api?: BrainjarClient
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
76
|
-
|
|
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
|
-
//
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/commands/identity.ts
DELETED
|
@@ -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
|
-
})
|