@brainjar/cli 0.1.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/src/sync.ts ADDED
@@ -0,0 +1,190 @@
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'
5
+
6
+ export const MARKER_START = '<!-- brainjar:start -->'
7
+ export const MARKER_END = '<!-- brainjar:end -->'
8
+
9
+ export interface SyncOptions {
10
+ backend?: Backend
11
+ local?: boolean
12
+ envOverrides?: Record<string, string>
13
+ }
14
+
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. */
55
+ function parseMarkers(content: string): { before: string; after: string } | null {
56
+ const startIdx = content.indexOf(MARKER_START)
57
+ const endIdx = content.indexOf(MARKER_END)
58
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return null
59
+
60
+ const before = content.slice(0, startIdx).trimEnd()
61
+ const after = content.slice(endIdx + MARKER_END.length).trimStart()
62
+ return { before, after }
63
+ }
64
+
65
+ export async function sync(options?: Backend | SyncOptions) {
66
+ await requireBrainjarDir()
67
+
68
+ // Normalize legacy call signature: sync('claude') → sync({ backend: 'claude' })
69
+ const opts: SyncOptions = typeof options === 'string' ? { backend: options } : options ?? {}
70
+
71
+ const globalState = await readState()
72
+ const backend: Backend = opts.backend ?? (globalState.backend as Backend) ?? 'claude'
73
+ const config = getBackendConfig(backend, { local: opts.local })
74
+
75
+ const envState = readEnvState(opts.envOverrides)
76
+ const warnings: string[] = []
77
+
78
+ // Read existing config file
79
+ let existingContent: string | null = null
80
+ try {
81
+ existingContent = await readFile(config.configFile, 'utf-8')
82
+ } catch (e) {
83
+ const code = (e as NodeJS.ErrnoException).code
84
+ if (code !== 'ENOENT') {
85
+ warnings.push(`Could not read existing config: ${(e as Error).message}`)
86
+ }
87
+ }
88
+
89
+ // Backup existing config if it has no brainjar markers (first-time takeover)
90
+ if (existingContent !== null && !existingContent.includes(MARKER_START)) {
91
+ try {
92
+ await copyFile(config.configFile, config.backupFile)
93
+ } catch (e) {
94
+ warnings.push(`Could not back up existing config: ${(e as Error).message}`)
95
+ }
96
+ }
97
+
98
+ // Build the brainjar section content
99
+ const sections: string[] = []
100
+
101
+ if (opts.local) {
102
+ // Local mode: read local state + env, only write overridden layers.
103
+ // Everything else falls back to the global config (Claude Code merges both files).
104
+ const localState = await readLocalState()
105
+ const effective = mergeState(globalState, localState, envState)
106
+
107
+ if ('soul' in localState && effective.soul.value) {
108
+ await inlineSoul(effective.soul.value, sections)
109
+ }
110
+ if ('persona' in localState && effective.persona.value) {
111
+ await inlinePersona(effective.persona.value, sections)
112
+ }
113
+ if (localState.rules) {
114
+ // Inline the effective rules that are active (not removed)
115
+ const activeRules = effective.rules
116
+ .filter(r => !r.scope.startsWith('-'))
117
+ .map(r => r.value)
118
+ // But only write rules section if local state has rules overrides
119
+ await inlineRules(activeRules, sections, warnings)
120
+ }
121
+ if ('identity' in localState && effective.identity.value) {
122
+ await inlineIdentity(effective.identity.value, sections)
123
+ }
124
+ } else {
125
+ // Global mode: apply env overrides on top of global state, write all layers
126
+ const effective = mergeState(globalState, {}, envState)
127
+ const effectiveSoul = effective.soul.value
128
+ const effectivePersona = effective.persona.value
129
+ const effectiveRules = effective.rules.filter(r => !r.scope.startsWith('-')).map(r => r.value)
130
+ const effectiveIdentity = effective.identity.value
131
+
132
+ if (effectiveSoul) await inlineSoul(effectiveSoul, sections)
133
+ if (effectivePersona) await inlinePersona(effectivePersona, sections)
134
+ await inlineRules(effectiveRules, sections, warnings)
135
+ if (effectiveIdentity) await inlineIdentity(effectiveIdentity, sections)
136
+
137
+ // Local Overrides note (only for global config)
138
+ sections.push('')
139
+ sections.push('## Project-Level Overrides')
140
+ sections.push('')
141
+ sections.push('If a project has its own .claude/CLAUDE.md, those instructions take precedence for project-specific concerns. These global rules still apply for general behavior.')
142
+ }
143
+
144
+ // Wrap in markers
145
+ const brainjarBlock = [
146
+ MARKER_START,
147
+ `# ${config.configFileName} — Managed by brainjar`,
148
+ ...sections,
149
+ '',
150
+ MARKER_END,
151
+ ].join('\n')
152
+
153
+ // Splice into existing content or create fresh
154
+ let output: string
155
+ const parsed = existingContent ? parseMarkers(existingContent) : null
156
+
157
+ if (parsed) {
158
+ // Existing file with markers — replace the brainjar section, preserve the rest
159
+ // Discard legacy brainjar content that ended up outside markers during migration
160
+ const before = parsed.before
161
+ const after = parsed.after?.includes('# Managed by brainjar') ? '' : parsed.after
162
+ const parts: string[] = []
163
+ if (before) parts.push(before, '')
164
+ parts.push(brainjarBlock)
165
+ if (after) parts.push('', after)
166
+ output = parts.join('\n')
167
+ } else if (existingContent && !existingContent.includes(MARKER_START)) {
168
+ // Existing file without markers (first sync)
169
+ if (existingContent.includes('# Managed by brainjar')) {
170
+ // Legacy brainjar-managed file — replace entirely
171
+ output = brainjarBlock + '\n'
172
+ } else {
173
+ // User-owned file — prepend brainjar section, preserve user content
174
+ output = brainjarBlock + '\n\n' + existingContent
175
+ }
176
+ } else {
177
+ // No existing file
178
+ output = brainjarBlock + '\n'
179
+ }
180
+
181
+ await mkdir(config.dir, { recursive: true })
182
+ await writeFile(config.configFile, output)
183
+
184
+ return {
185
+ backend,
186
+ written: config.configFile,
187
+ local: opts.local ?? false,
188
+ ...(warnings.length ? { warnings } : {}),
189
+ }
190
+ }