@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/LICENSE +21 -0
- package/README.md +309 -0
- package/package.json +55 -0
- package/src/cli.ts +30 -0
- package/src/commands/brain.ts +256 -0
- package/src/commands/compose.ts +156 -0
- package/src/commands/identity.ts +276 -0
- package/src/commands/init.ts +78 -0
- package/src/commands/persona.ts +259 -0
- package/src/commands/reset.ts +46 -0
- package/src/commands/rules.ts +269 -0
- package/src/commands/shell.ts +119 -0
- package/src/commands/soul.ts +207 -0
- package/src/commands/status.ts +131 -0
- package/src/engines/bitwarden.ts +105 -0
- package/src/engines/index.ts +12 -0
- package/src/engines/types.ts +12 -0
- package/src/paths.ts +48 -0
- package/src/seeds/personas/engineer.md +26 -0
- package/src/seeds/personas/planner.md +24 -0
- package/src/seeds/personas/reviewer.md +27 -0
- package/src/seeds/rules/default/boundaries.md +25 -0
- package/src/seeds/rules/default/context-recovery.md +17 -0
- package/src/seeds/rules/default/task-completion.md +31 -0
- package/src/seeds/rules/git-discipline.md +22 -0
- package/src/seeds/rules/security.md +26 -0
- package/src/seeds/souls/craftsman.md +24 -0
- package/src/seeds/templates/persona.md +19 -0
- package/src/seeds/templates/rule.md +11 -0
- package/src/seeds/templates/soul.md +20 -0
- package/src/seeds.ts +116 -0
- package/src/state.ts +414 -0
- package/src/sync.ts +190 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Git Discipline
|
|
2
|
+
|
|
3
|
+
This rule governs git behavior — commits, branches, and history.
|
|
4
|
+
|
|
5
|
+
## Commit Workflow
|
|
6
|
+
|
|
7
|
+
- Stage only the relevant files. No blind `git add -A`.
|
|
8
|
+
- Write meaningful commit messages. Say what changed and why.
|
|
9
|
+
- One logical change per commit. Don't mix refactors with features.
|
|
10
|
+
|
|
11
|
+
## Safety
|
|
12
|
+
|
|
13
|
+
- Don't commit secrets, credentials, or .env files. Ever.
|
|
14
|
+
- Don't amend published commits. Create a new commit instead.
|
|
15
|
+
- Never force push to main/master without explicit user approval.
|
|
16
|
+
- Don't skip hooks (--no-verify) unless the user explicitly asks.
|
|
17
|
+
- When in doubt about a destructive git operation, ask first.
|
|
18
|
+
|
|
19
|
+
## Branches
|
|
20
|
+
|
|
21
|
+
- Don't delete branches without confirming they're merged or abandoned.
|
|
22
|
+
- Don't switch branches with uncommitted changes — stash or commit first.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
This rule enforces secure coding practices.
|
|
4
|
+
|
|
5
|
+
## Secrets
|
|
6
|
+
|
|
7
|
+
- Never commit credentials, API keys, tokens, or .env files.
|
|
8
|
+
- If you encounter hardcoded secrets in the codebase, flag them immediately.
|
|
9
|
+
- Use environment variables or secret managers for sensitive values.
|
|
10
|
+
|
|
11
|
+
## Input Boundaries
|
|
12
|
+
|
|
13
|
+
- Validate and sanitize all external input — user input, API responses, file reads.
|
|
14
|
+
- Don't trust data from outside the system boundary.
|
|
15
|
+
- Use parameterized queries. Never interpolate user input into SQL or shell commands.
|
|
16
|
+
|
|
17
|
+
## Common Vulnerabilities
|
|
18
|
+
|
|
19
|
+
- Watch for injection: SQL, command, XSS, template injection.
|
|
20
|
+
- Don't disable security features (CORS, CSRF, auth checks) to "make it work."
|
|
21
|
+
- Prefer allowlists over denylists for validation.
|
|
22
|
+
|
|
23
|
+
## Dependencies
|
|
24
|
+
|
|
25
|
+
- Flag known-vulnerable dependencies if you notice them.
|
|
26
|
+
- Don't add dependencies without confirming with the user first.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Craftsman
|
|
2
|
+
|
|
3
|
+
Quality work, clearly communicated.
|
|
4
|
+
|
|
5
|
+
## Voice
|
|
6
|
+
|
|
7
|
+
- Clear and direct. Say what you mean without padding.
|
|
8
|
+
- Match the user's pace. Brief questions get brief answers. Complex problems get thorough treatment.
|
|
9
|
+
- State conclusions first, then reasoning if needed. Don't bury the answer.
|
|
10
|
+
- Be concrete. Examples over abstractions, specifics over generalities.
|
|
11
|
+
|
|
12
|
+
## Character
|
|
13
|
+
|
|
14
|
+
- Honest about tradeoffs. Every choice has a cost — surface it.
|
|
15
|
+
- Admit uncertainty. "I'm not sure" is better than a confident wrong answer.
|
|
16
|
+
- Respect the user's time. Don't repeat what they already know.
|
|
17
|
+
- Take ownership. If you broke something, say so and fix it.
|
|
18
|
+
|
|
19
|
+
## Standards
|
|
20
|
+
|
|
21
|
+
- Ship working code. Compiles, passes tests, handles errors.
|
|
22
|
+
- Finish what you start. No stubs, no TODOs, no "left as an exercise."
|
|
23
|
+
- Read before writing. Understand existing code before changing it.
|
|
24
|
+
- Leave things better than you found them — but only the things you're touching.
|
package/src/seeds.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdir, readdir, writeFile, copyFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { paths } from './paths.js'
|
|
4
|
+
|
|
5
|
+
const SEEDS_DIR = join(import.meta.dir, 'seeds')
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Obsidian vault configuration
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function obsidianAppearanceConfig() {
|
|
12
|
+
return JSON.stringify({
|
|
13
|
+
accentColor: '',
|
|
14
|
+
baseFontSize: 16,
|
|
15
|
+
}, null, 2)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Obsidian file-explorer exclusion via userIgnoreFilters.
|
|
20
|
+
* Excludes private/state files from the vault file explorer.
|
|
21
|
+
*/
|
|
22
|
+
function obsidianAppConfigWithExclusions() {
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
showLineNumber: true,
|
|
25
|
+
strictLineBreaks: true,
|
|
26
|
+
useMarkdownLinks: false,
|
|
27
|
+
alwaysUpdateLinks: true,
|
|
28
|
+
userIgnoreFilters: [
|
|
29
|
+
'identities/',
|
|
30
|
+
'state.yaml',
|
|
31
|
+
'.session',
|
|
32
|
+
'.gitignore',
|
|
33
|
+
],
|
|
34
|
+
}, null, 2)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function obsidianCorePlugins() {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
'file-explorer': true,
|
|
40
|
+
'global-search': true,
|
|
41
|
+
'graph': true,
|
|
42
|
+
'tag-pane': true,
|
|
43
|
+
'templates': true,
|
|
44
|
+
'outline': true,
|
|
45
|
+
'editor-status': true,
|
|
46
|
+
'starred': true,
|
|
47
|
+
'command-palette': true,
|
|
48
|
+
'markdown-importer': false,
|
|
49
|
+
'word-count': true,
|
|
50
|
+
}, null, 2)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function obsidianTemplatesConfig() {
|
|
54
|
+
return JSON.stringify({
|
|
55
|
+
folder: 'templates',
|
|
56
|
+
}, null, 2)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Seed functions
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** Copy a seed .md file from src/seeds/ to a target path. */
|
|
64
|
+
async function copySeed(seedRelPath: string, destPath: string) {
|
|
65
|
+
await copyFile(join(SEEDS_DIR, seedRelPath), destPath)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Seed the default rule pack — the baseline every persona references */
|
|
69
|
+
export async function seedDefaultRule(rulesDir: string) {
|
|
70
|
+
const defaultDir = join(rulesDir, 'default')
|
|
71
|
+
await mkdir(defaultDir, { recursive: true })
|
|
72
|
+
|
|
73
|
+
const seedDir = join(SEEDS_DIR, 'rules', 'default')
|
|
74
|
+
const files = await readdir(seedDir)
|
|
75
|
+
await Promise.all(
|
|
76
|
+
files.filter(f => f.endsWith('.md')).map(f =>
|
|
77
|
+
copySeed(join('rules', 'default', f), join(defaultDir, f))
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Seed starter content: soul, personas, and rules */
|
|
83
|
+
export async function seedDefaults() {
|
|
84
|
+
await Promise.all([
|
|
85
|
+
// Soul
|
|
86
|
+
copySeed('souls/craftsman.md', join(paths.souls, 'craftsman.md')),
|
|
87
|
+
|
|
88
|
+
// Personas
|
|
89
|
+
copySeed('personas/engineer.md', join(paths.personas, 'engineer.md')),
|
|
90
|
+
copySeed('personas/planner.md', join(paths.personas, 'planner.md')),
|
|
91
|
+
copySeed('personas/reviewer.md', join(paths.personas, 'reviewer.md')),
|
|
92
|
+
|
|
93
|
+
// Rules
|
|
94
|
+
copySeed('rules/git-discipline.md', join(paths.rules, 'git-discipline.md')),
|
|
95
|
+
copySeed('rules/security.md', join(paths.rules, 'security.md')),
|
|
96
|
+
])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Set up ~/.brainjar/ as an Obsidian vault */
|
|
100
|
+
export async function initObsidian(brainjarDir: string) {
|
|
101
|
+
const obsidianDir = join(brainjarDir, '.obsidian')
|
|
102
|
+
const templatesDir = join(brainjarDir, 'templates')
|
|
103
|
+
|
|
104
|
+
await mkdir(obsidianDir, { recursive: true })
|
|
105
|
+
await mkdir(templatesDir, { recursive: true })
|
|
106
|
+
|
|
107
|
+
await Promise.all([
|
|
108
|
+
writeFile(join(obsidianDir, 'app.json'), obsidianAppConfigWithExclusions()),
|
|
109
|
+
writeFile(join(obsidianDir, 'appearance.json'), obsidianAppearanceConfig()),
|
|
110
|
+
writeFile(join(obsidianDir, 'core-plugins.json'), obsidianCorePlugins()),
|
|
111
|
+
writeFile(join(obsidianDir, 'templates.json'), obsidianTemplatesConfig()),
|
|
112
|
+
copySeed('templates/soul.md', join(templatesDir, 'soul.md')),
|
|
113
|
+
copySeed('templates/persona.md', join(templatesDir, 'persona.md')),
|
|
114
|
+
copySeed('templates/rule.md', join(templatesDir, 'rule.md')),
|
|
115
|
+
])
|
|
116
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, access, rename, mkdir, rm, stat } from 'node:fs/promises'
|
|
2
|
+
import { join, basename } from 'node:path'
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
4
|
+
import { getBrainjarDir, getLocalDir, paths } from './paths.js'
|
|
5
|
+
|
|
6
|
+
const SLUG_RE = /^[a-zA-Z0-9_-]+$/
|
|
7
|
+
|
|
8
|
+
/** Normalize a layer name: strip .md extension if present, then validate. */
|
|
9
|
+
export function normalizeSlug(value: string, label: string): string {
|
|
10
|
+
const slug = value.endsWith('.md') ? value.slice(0, -3) : value
|
|
11
|
+
if (!SLUG_RE.test(slug)) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Invalid ${label}: "${value}". Names must contain only letters, numbers, hyphens, and underscores.`
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
return slug
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a single rule's content: validate slug, try directory (sorted .md files),
|
|
21
|
+
* fall back to single .md file. Pushes warnings for invalid names, empty dirs, missing rules.
|
|
22
|
+
* Returns an array of trimmed content strings.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveRuleContent(rule: string, warnings: string[]): Promise<string[]> {
|
|
25
|
+
let safe: string
|
|
26
|
+
try {
|
|
27
|
+
safe = normalizeSlug(rule, 'rule')
|
|
28
|
+
} catch {
|
|
29
|
+
warnings.push(`Rule "${rule}" has an invalid name — skipped`)
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rulePath = join(paths.rules, safe)
|
|
34
|
+
|
|
35
|
+
// Try directory first
|
|
36
|
+
try {
|
|
37
|
+
const files = await readdir(rulePath)
|
|
38
|
+
const mdFiles = files.filter(f => f.endsWith('.md')).sort()
|
|
39
|
+
if (mdFiles.length === 0) {
|
|
40
|
+
warnings.push(`Rule "${rule}" directory exists but contains no .md files`)
|
|
41
|
+
}
|
|
42
|
+
const contents: string[] = []
|
|
43
|
+
for (const file of mdFiles) {
|
|
44
|
+
const content = await readFile(join(rulePath, file), 'utf-8')
|
|
45
|
+
contents.push(content.trim())
|
|
46
|
+
}
|
|
47
|
+
return contents
|
|
48
|
+
} catch {
|
|
49
|
+
// Not a directory — fall back to single .md file
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const content = await readFile(`${rulePath}.md`, 'utf-8')
|
|
54
|
+
return [content.trim()]
|
|
55
|
+
} catch {}
|
|
56
|
+
|
|
57
|
+
warnings.push(`Rule "${rule}" not found in ${paths.rules}`)
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function listAvailableRules(): Promise<string[]> {
|
|
62
|
+
const entries = await readdir(paths.rules, { withFileTypes: true }).catch(() => [])
|
|
63
|
+
const available: string[] = []
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
available.push(entry.name)
|
|
67
|
+
} else if (entry.name.endsWith('.md')) {
|
|
68
|
+
available.push(basename(entry.name, '.md'))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return available.sort()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface State {
|
|
75
|
+
backend: string | null
|
|
76
|
+
identity: string | null
|
|
77
|
+
soul: string | null
|
|
78
|
+
persona: string | null
|
|
79
|
+
rules: string[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEFAULT_STATE: State = { backend: null, identity: null, soul: null, persona: null, rules: [] }
|
|
83
|
+
|
|
84
|
+
export async function requireBrainjarDir(): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
await access(getBrainjarDir())
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error(`~/.brainjar/ not found. Run \`brainjar init\` first.`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LayerFrontmatter {
|
|
93
|
+
rules: string[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseLayerFrontmatter(content: string): LayerFrontmatter {
|
|
97
|
+
const result: LayerFrontmatter = { rules: [] }
|
|
98
|
+
const normalized = content.replace(/\r\n/g, '\n')
|
|
99
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---/)
|
|
100
|
+
if (!match) return result
|
|
101
|
+
|
|
102
|
+
const parsed = parseYaml(match[1])
|
|
103
|
+
if (parsed && typeof parsed === 'object') {
|
|
104
|
+
if (Array.isArray(parsed.rules)) result.rules = parsed.rules.map(String)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function stripFrontmatter(content: string): string {
|
|
111
|
+
return content.replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---\n*/, '').trim()
|
|
112
|
+
}
|
|
113
|
+
|
|
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
|
+
/** Return a valid slug or null. Prevents path traversal from state.yaml. */
|
|
129
|
+
function safeName(value: unknown): string | null {
|
|
130
|
+
if (typeof value !== 'string' || !value) return null
|
|
131
|
+
return SLUG_RE.test(value) ? value : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function readState(): Promise<State> {
|
|
135
|
+
let raw: string
|
|
136
|
+
try {
|
|
137
|
+
raw = await readFile(paths.state, 'utf-8')
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { ...DEFAULT_STATE }
|
|
140
|
+
throw new Error(`Could not read state.yaml: ${(e as Error).message}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let parsed: unknown
|
|
144
|
+
try {
|
|
145
|
+
parsed = parseYaml(raw)
|
|
146
|
+
} catch (e) {
|
|
147
|
+
throw new Error(`state.yaml is corrupt: ${(e as Error).message}`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_STATE }
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
backend: ((parsed as any).backend === 'claude' || (parsed as any).backend === 'codex') ? (parsed as any).backend : null,
|
|
154
|
+
identity: safeName((parsed as any).identity),
|
|
155
|
+
soul: safeName((parsed as any).soul),
|
|
156
|
+
persona: safeName((parsed as any).persona),
|
|
157
|
+
rules: Array.isArray((parsed as any).rules)
|
|
158
|
+
? (parsed as any).rules.map(String).filter((r: string) => SLUG_RE.test(r))
|
|
159
|
+
: [],
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const LOCK_TIMEOUT_MS = 5000
|
|
164
|
+
const LOCK_STALE_MS = 10000
|
|
165
|
+
const LOCK_POLL_MS = 50
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Acquire an exclusive directory-based lock, run fn, then release.
|
|
169
|
+
* Uses mkdir (atomic on all filesystems) as the lock primitive.
|
|
170
|
+
* Stale locks older than 10s are automatically broken.
|
|
171
|
+
*/
|
|
172
|
+
async function withLock<T>(lockDir: string, label: string, fn: () => Promise<T>): Promise<T> {
|
|
173
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
174
|
+
|
|
175
|
+
while (true) {
|
|
176
|
+
try {
|
|
177
|
+
await mkdir(lockDir)
|
|
178
|
+
break
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
181
|
+
|
|
182
|
+
// Break stale locks
|
|
183
|
+
try {
|
|
184
|
+
const info = await stat(lockDir)
|
|
185
|
+
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
186
|
+
await rm(lockDir, { force: true, recursive: true })
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
} catch {}
|
|
190
|
+
|
|
191
|
+
if (Date.now() > deadline) {
|
|
192
|
+
throw new Error(`Could not acquire ${label} lock — another brainjar process may be running.`)
|
|
193
|
+
}
|
|
194
|
+
await new Promise(r => setTimeout(r, LOCK_POLL_MS))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
return await fn()
|
|
200
|
+
} finally {
|
|
201
|
+
await rm(lockDir, { force: true, recursive: true })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
206
|
+
return withLock(`${paths.state}.lock`, 'state', fn)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function writeState(state: State): Promise<void> {
|
|
210
|
+
const doc = {
|
|
211
|
+
backend: state.backend ?? null,
|
|
212
|
+
identity: state.identity ?? null,
|
|
213
|
+
soul: state.soul ?? null,
|
|
214
|
+
persona: state.persona ?? null,
|
|
215
|
+
rules: state.rules,
|
|
216
|
+
}
|
|
217
|
+
const tmp = `${paths.state}.tmp`
|
|
218
|
+
await writeFile(tmp, stringifyYaml(doc))
|
|
219
|
+
await rename(tmp, paths.state)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Local state ---
|
|
223
|
+
|
|
224
|
+
/** Local state only stores overrides. undefined = cascade, null = explicit unset. */
|
|
225
|
+
export interface LocalState {
|
|
226
|
+
identity?: string | null
|
|
227
|
+
soul?: string | null
|
|
228
|
+
persona?: string | null
|
|
229
|
+
rules?: {
|
|
230
|
+
add?: string[]
|
|
231
|
+
remove?: string[]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Override state from env vars. Same shape as LocalState, read-only. */
|
|
236
|
+
export type EnvState = LocalState
|
|
237
|
+
|
|
238
|
+
export type Scope = 'global' | 'local' | '+local' | '-local' | 'env' | '+env' | '-env'
|
|
239
|
+
|
|
240
|
+
/** Effective state after merging global + local + env, with scope annotations. */
|
|
241
|
+
export interface EffectiveState {
|
|
242
|
+
backend: string | null
|
|
243
|
+
identity: { value: string | null; scope: Scope }
|
|
244
|
+
soul: { value: string | null; scope: Scope }
|
|
245
|
+
persona: { value: string | null; scope: Scope }
|
|
246
|
+
rules: { value: string; scope: Scope }[]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function readLocalState(): Promise<LocalState> {
|
|
250
|
+
let raw: string
|
|
251
|
+
try {
|
|
252
|
+
raw = await readFile(paths.localState, 'utf-8')
|
|
253
|
+
} catch (e) {
|
|
254
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
|
255
|
+
throw new Error(`Could not read local state.yaml: ${(e as Error).message}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let parsed: unknown
|
|
259
|
+
try {
|
|
260
|
+
parsed = parseYaml(raw)
|
|
261
|
+
} catch (e) {
|
|
262
|
+
throw new Error(`Local state.yaml is corrupt: ${(e as Error).message}`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!parsed || typeof parsed !== 'object') return {}
|
|
266
|
+
|
|
267
|
+
const result: LocalState = {}
|
|
268
|
+
const p = parsed as Record<string, unknown>
|
|
269
|
+
|
|
270
|
+
// 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
|
+
if ('soul' in p) result.soul = p.soul === null ? null : safeName(p.soul)
|
|
273
|
+
if ('persona' in p) result.persona = p.persona === null ? null : safeName(p.persona)
|
|
274
|
+
|
|
275
|
+
if (p.rules && typeof p.rules === 'object') {
|
|
276
|
+
const r = p.rules as Record<string, unknown>
|
|
277
|
+
result.rules = {}
|
|
278
|
+
if (Array.isArray(r.add)) {
|
|
279
|
+
result.rules.add = r.add.map(String).filter((s: string) => SLUG_RE.test(s))
|
|
280
|
+
}
|
|
281
|
+
if (Array.isArray(r.remove)) {
|
|
282
|
+
result.rules.remove = r.remove.map(String).filter((s: string) => SLUG_RE.test(s))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return result
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function writeLocalState(local: LocalState): Promise<void> {
|
|
290
|
+
const localDir = getLocalDir()
|
|
291
|
+
await mkdir(localDir, { recursive: true })
|
|
292
|
+
|
|
293
|
+
// Build a clean doc — only include keys that are present in local
|
|
294
|
+
const doc: Record<string, unknown> = {}
|
|
295
|
+
if ('identity' in local) doc.identity = local.identity ?? null
|
|
296
|
+
if ('soul' in local) doc.soul = local.soul ?? null
|
|
297
|
+
if ('persona' in local) doc.persona = local.persona ?? null
|
|
298
|
+
if (local.rules) {
|
|
299
|
+
const rules: Record<string, string[]> = {}
|
|
300
|
+
if (local.rules.add?.length) rules.add = local.rules.add
|
|
301
|
+
if (local.rules.remove?.length) rules.remove = local.rules.remove
|
|
302
|
+
if (Object.keys(rules).length) doc.rules = rules
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const tmp = `${paths.localState}.tmp`
|
|
306
|
+
await writeFile(tmp, stringifyYaml(doc))
|
|
307
|
+
await rename(tmp, paths.localState)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Read override state from BRAINJAR_* env vars. Pure, no I/O.
|
|
311
|
+
* If extraEnv is provided, those values take precedence over process.env. */
|
|
312
|
+
export function readEnvState(extraEnv?: Record<string, string>): EnvState {
|
|
313
|
+
const env = extraEnv ? { ...process.env, ...extraEnv } : process.env
|
|
314
|
+
const result: EnvState = {}
|
|
315
|
+
|
|
316
|
+
if (env.BRAINJAR_IDENTITY !== undefined) {
|
|
317
|
+
result.identity = env.BRAINJAR_IDENTITY === '' ? null : safeName(env.BRAINJAR_IDENTITY)
|
|
318
|
+
}
|
|
319
|
+
if (env.BRAINJAR_SOUL !== undefined) {
|
|
320
|
+
result.soul = env.BRAINJAR_SOUL === '' ? null : safeName(env.BRAINJAR_SOUL)
|
|
321
|
+
}
|
|
322
|
+
if (env.BRAINJAR_PERSONA !== undefined) {
|
|
323
|
+
result.persona = env.BRAINJAR_PERSONA === '' ? null : safeName(env.BRAINJAR_PERSONA)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const addRaw = env.BRAINJAR_RULES_ADD
|
|
327
|
+
const removeRaw = env.BRAINJAR_RULES_REMOVE
|
|
328
|
+
if (addRaw !== undefined || removeRaw !== undefined) {
|
|
329
|
+
result.rules = {}
|
|
330
|
+
if (addRaw) {
|
|
331
|
+
result.rules.add = addRaw.split(',').map(s => s.trim()).filter(s => SLUG_RE.test(s))
|
|
332
|
+
}
|
|
333
|
+
if (removeRaw) {
|
|
334
|
+
result.rules.remove = removeRaw.split(',').map(s => s.trim()).filter(s => SLUG_RE.test(s))
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return result
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function withLocalStateLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
342
|
+
const localDir = getLocalDir()
|
|
343
|
+
await mkdir(localDir, { recursive: true })
|
|
344
|
+
return withLock(`${paths.localState}.lock`, 'local state', fn)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Apply overrides from a given scope onto an existing effective state. */
|
|
348
|
+
function applyOverrides(
|
|
349
|
+
base: EffectiveState,
|
|
350
|
+
overrides: LocalState | EnvState,
|
|
351
|
+
scope: 'local' | 'env',
|
|
352
|
+
): EffectiveState {
|
|
353
|
+
const plusScope = `+${scope}` as Scope
|
|
354
|
+
const minusScope = `-${scope}` as Scope
|
|
355
|
+
|
|
356
|
+
const identity = 'identity' in overrides
|
|
357
|
+
? { value: overrides.identity ?? null, scope: scope as Scope }
|
|
358
|
+
: base.identity
|
|
359
|
+
|
|
360
|
+
const soul = 'soul' in overrides
|
|
361
|
+
? { value: overrides.soul ?? null, scope: scope as Scope }
|
|
362
|
+
: base.soul
|
|
363
|
+
|
|
364
|
+
const persona = 'persona' in overrides
|
|
365
|
+
? { value: overrides.persona ?? null, scope: scope as Scope }
|
|
366
|
+
: base.persona
|
|
367
|
+
|
|
368
|
+
// Rules: take active rules from base, apply adds/removes
|
|
369
|
+
const adds = new Set(overrides.rules?.add ?? [])
|
|
370
|
+
const removes = new Set(overrides.rules?.remove ?? [])
|
|
371
|
+
|
|
372
|
+
const rules: EffectiveState['rules'] = []
|
|
373
|
+
const seen = new Set<string>()
|
|
374
|
+
|
|
375
|
+
// Process existing rules (keep active ones, mark newly removed)
|
|
376
|
+
for (const r of base.rules) {
|
|
377
|
+
if (r.scope.startsWith('-')) {
|
|
378
|
+
// Already removed by a lower scope — keep the removal marker
|
|
379
|
+
rules.push(r)
|
|
380
|
+
seen.add(r.value)
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
if (removes.has(r.value)) {
|
|
384
|
+
rules.push({ value: r.value, scope: minusScope })
|
|
385
|
+
} else {
|
|
386
|
+
rules.push(r)
|
|
387
|
+
}
|
|
388
|
+
seen.add(r.value)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Add new rules from this scope (that aren't already present)
|
|
392
|
+
for (const r of adds) {
|
|
393
|
+
if (!seen.has(r)) {
|
|
394
|
+
rules.push({ value: r, scope: plusScope })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { backend: base.backend, identity, soul, persona, rules }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Pure merge: global → local → env, each scope overrides the previous. */
|
|
402
|
+
export function mergeState(global: State, local: LocalState, env?: EnvState): EffectiveState {
|
|
403
|
+
// Start with global as the base effective state
|
|
404
|
+
const base: EffectiveState = {
|
|
405
|
+
backend: global.backend,
|
|
406
|
+
identity: { value: global.identity, scope: 'global' },
|
|
407
|
+
soul: { value: global.soul, scope: 'global' },
|
|
408
|
+
persona: { value: global.persona, scope: 'global' },
|
|
409
|
+
rules: global.rules.map(r => ({ value: r, scope: 'global' as Scope })),
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const withLocal = applyOverrides(base, local, 'local')
|
|
413
|
+
return env ? applyOverrides(withLocal, env, 'env') : withLocal
|
|
414
|
+
}
|