@brainjar/cli 0.3.0 → 0.4.1

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/paths.ts CHANGED
@@ -37,10 +37,5 @@ export function getLocalDir() {
37
37
 
38
38
  export const paths = {
39
39
  get root() { return getBrainjarDir() },
40
- get souls() { return join(getBrainjarDir(), 'souls') },
41
- get personas() { return join(getBrainjarDir(), 'personas') },
42
- get rules() { return join(getBrainjarDir(), 'rules') },
43
- get brains() { return join(getBrainjarDir(), 'brains') },
44
- get state() { return join(getBrainjarDir(), 'state.yaml') },
45
- get localState() { return join(getLocalDir(), 'state.yaml') },
40
+ get config() { return join(getBrainjarDir(), 'config.yaml') },
46
41
  }
package/src/seeds.ts CHANGED
@@ -1,114 +1,73 @@
1
- import { mkdir, readdir, writeFile, copyFile } from 'node:fs/promises'
1
+ import { readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { paths } from './paths.js'
3
+ import type { ContentBundle } from './api-types.js'
4
+ import { parseFrontmatter } from './migrate.js'
4
5
 
5
6
  const SEEDS_DIR = join(import.meta.dir, 'seeds')
6
7
 
7
- // ---------------------------------------------------------------------------
8
- // Obsidian vault configuration
9
- // ---------------------------------------------------------------------------
10
-
11
- function obsidianAppearanceConfig() {
12
- return JSON.stringify({
13
- accentColor: '',
14
- baseFontSize: 16,
15
- }, null, 2)
8
+ async function readSeed(relPath: string): Promise<string> {
9
+ return readFile(join(SEEDS_DIR, relPath), 'utf-8')
16
10
  }
17
11
 
18
12
  /**
19
- * Obsidian file-explorer exclusion via userIgnoreFilters.
20
- * Excludes private/state files from the vault file explorer.
13
+ * Build a ContentBundle from the embedded seed files.
14
+ * This bundle can be POSTed to /api/v1/import.
21
15
  */
22
- function obsidianAppConfigWithExclusions() {
23
- return JSON.stringify({
24
- showLineNumber: true,
25
- strictLineBreaks: true,
26
- useMarkdownLinks: false,
27
- alwaysUpdateLinks: true,
28
- userIgnoreFilters: [
29
- 'state.yaml',
30
- '.gitignore',
31
- ],
32
- }, null, 2)
33
- }
34
-
35
- function obsidianCorePlugins() {
36
- return JSON.stringify({
37
- 'file-explorer': true,
38
- 'global-search': true,
39
- 'graph': true,
40
- 'tag-pane': true,
41
- 'templates': true,
42
- 'outline': true,
43
- 'editor-status': true,
44
- 'starred': true,
45
- 'command-palette': true,
46
- 'markdown-importer': false,
47
- 'word-count': true,
48
- }, null, 2)
49
- }
50
-
51
- function obsidianTemplatesConfig() {
52
- return JSON.stringify({
53
- folder: 'templates',
54
- }, null, 2)
55
- }
56
-
57
- // ---------------------------------------------------------------------------
58
- // Seed functions
59
- // ---------------------------------------------------------------------------
60
-
61
- /** Copy a seed .md file from src/seeds/ to a target path. */
62
- async function copySeed(seedRelPath: string, destPath: string) {
63
- await copyFile(join(SEEDS_DIR, seedRelPath), destPath)
64
- }
65
-
66
- /** Seed the default rule pack — the baseline every persona references */
67
- export async function seedDefaultRule(rulesDir: string) {
68
- const defaultDir = join(rulesDir, 'default')
69
- await mkdir(defaultDir, { recursive: true })
70
-
71
- const seedDir = join(SEEDS_DIR, 'rules', 'default')
72
- const files = await readdir(seedDir)
73
- await Promise.all(
74
- files.filter(f => f.endsWith('.md')).map(f =>
75
- copySeed(join('rules', 'default', f), join(defaultDir, f))
76
- )
77
- )
78
- }
79
-
80
- /** Seed starter content: soul, personas, and rules */
81
- export async function seedDefaults() {
82
- await Promise.all([
83
- // Soul
84
- copySeed('souls/craftsman.md', join(paths.souls, 'craftsman.md')),
85
-
86
- // Personas
87
- copySeed('personas/engineer.md', join(paths.personas, 'engineer.md')),
88
- copySeed('personas/planner.md', join(paths.personas, 'planner.md')),
89
- copySeed('personas/reviewer.md', join(paths.personas, 'reviewer.md')),
90
-
91
- // Rules
92
- copySeed('rules/git-discipline.md', join(paths.rules, 'git-discipline.md')),
93
- copySeed('rules/security.md', join(paths.rules, 'security.md')),
16
+ export async function buildSeedBundle(): Promise<ContentBundle> {
17
+ const [
18
+ craftsmanContent,
19
+ engineerContent,
20
+ plannerContent,
21
+ reviewerContent,
22
+ boundariesContent,
23
+ contextRecoveryContent,
24
+ taskCompletionContent,
25
+ gitDisciplineContent,
26
+ securityContent,
27
+ ] = await Promise.all([
28
+ readSeed('souls/craftsman.md'),
29
+ readSeed('personas/engineer.md'),
30
+ readSeed('personas/planner.md'),
31
+ readSeed('personas/reviewer.md'),
32
+ readSeed('rules/boundaries.md'),
33
+ readSeed('rules/context-recovery.md'),
34
+ readSeed('rules/task-completion.md'),
35
+ readSeed('rules/git-discipline.md'),
36
+ readSeed('rules/security.md'),
94
37
  ])
95
- }
96
-
97
- /** Set up ~/.brainjar/ as an Obsidian vault */
98
- export async function initObsidian(brainjarDir: string) {
99
- const obsidianDir = join(brainjarDir, '.obsidian')
100
- const templatesDir = join(brainjarDir, 'templates')
101
38
 
102
- await mkdir(obsidianDir, { recursive: true })
103
- await mkdir(templatesDir, { recursive: true })
104
-
105
- await Promise.all([
106
- writeFile(join(obsidianDir, 'app.json'), obsidianAppConfigWithExclusions()),
107
- writeFile(join(obsidianDir, 'appearance.json'), obsidianAppearanceConfig()),
108
- writeFile(join(obsidianDir, 'core-plugins.json'), obsidianCorePlugins()),
109
- writeFile(join(obsidianDir, 'templates.json'), obsidianTemplatesConfig()),
110
- copySeed('templates/soul.md', join(templatesDir, 'soul.md')),
111
- copySeed('templates/persona.md', join(templatesDir, 'persona.md')),
112
- copySeed('templates/rule.md', join(templatesDir, 'rule.md')),
113
- ])
39
+ const engineerParsed = parseFrontmatter(engineerContent)
40
+ const plannerParsed = parseFrontmatter(plannerContent)
41
+ const reviewerParsed = parseFrontmatter(reviewerContent)
42
+
43
+ function extractBundledRules(fm: Record<string, unknown>): string[] {
44
+ return Array.isArray(fm.rules) ? fm.rules.map(String) : []
45
+ }
46
+
47
+ return {
48
+ souls: {
49
+ craftsman: { content: craftsmanContent },
50
+ },
51
+ personas: {
52
+ engineer: {
53
+ content: engineerParsed.body,
54
+ bundled_rules: extractBundledRules(engineerParsed.frontmatter),
55
+ },
56
+ planner: {
57
+ content: plannerParsed.body,
58
+ bundled_rules: extractBundledRules(plannerParsed.frontmatter),
59
+ },
60
+ reviewer: {
61
+ content: reviewerParsed.body,
62
+ bundled_rules: extractBundledRules(reviewerParsed.frontmatter),
63
+ },
64
+ },
65
+ rules: {
66
+ boundaries: { entries: [{ sort_key: 0, content: boundariesContent }] },
67
+ 'context-recovery': { entries: [{ sort_key: 0, content: contextRecoveryContent }] },
68
+ 'task-completion': { entries: [{ sort_key: 0, content: taskCompletionContent }] },
69
+ 'git-discipline': { entries: [{ sort_key: 0, content: gitDisciplineContent }] },
70
+ security: { entries: [{ sort_key: 0, content: securityContent }] },
71
+ },
72
+ }
114
73
  }
package/src/state.ts CHANGED
@@ -1,7 +1,5 @@
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'
1
+ import type { BrainjarClient } from './client.js'
2
+ import type { ApiEffectiveState, ApiStateMutation } from './api-types.js'
5
3
 
6
4
  const SLUG_RE = /^[a-zA-Z0-9_-]+$/
7
5
 
@@ -16,370 +14,16 @@ export function normalizeSlug(value: string, label: string): string {
16
14
  return slug
17
15
  }
18
16
 
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
- soul: string | null
77
- persona: string | null
78
- rules: string[]
79
- }
80
-
81
- const DEFAULT_STATE: State = { backend: null, soul: null, persona: null, rules: [] }
82
-
83
- export async function requireBrainjarDir(): Promise<void> {
84
- try {
85
- await access(getBrainjarDir())
86
- } catch {
87
- throw new Error(`~/.brainjar/ not found. Run \`brainjar init\` first.`)
88
- }
89
- }
90
-
91
- export interface LayerFrontmatter {
92
- rules: string[]
93
- }
94
-
95
- export function parseLayerFrontmatter(content: string): LayerFrontmatter {
96
- const result: LayerFrontmatter = { rules: [] }
97
- const normalized = content.replace(/\r\n/g, '\n')
98
- const match = normalized.match(/^---\n([\s\S]*?)\n---/)
99
- if (!match) return result
100
-
101
- const parsed = parseYaml(match[1])
102
- if (parsed && typeof parsed === 'object') {
103
- if (Array.isArray(parsed.rules)) result.rules = parsed.rules.map(String)
104
- }
105
-
106
- return result
107
- }
108
-
109
- export function stripFrontmatter(content: string): string {
110
- return content.replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---\n*/, '').trim()
111
- }
112
-
113
- /** Return a valid slug or null. Prevents path traversal from state.yaml. */
114
- function safeName(value: unknown): string | null {
115
- if (typeof value !== 'string' || !value) return null
116
- return SLUG_RE.test(value) ? value : null
117
- }
118
-
119
- export async function readState(): Promise<State> {
120
- let raw: string
121
- try {
122
- raw = await readFile(paths.state, 'utf-8')
123
- } catch (e) {
124
- if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { ...DEFAULT_STATE }
125
- throw new Error(`Could not read state.yaml: ${(e as Error).message}`)
126
- }
127
-
128
- let parsed: unknown
129
- try {
130
- parsed = parseYaml(raw)
131
- } catch (e) {
132
- throw new Error(`state.yaml is corrupt: ${(e as Error).message}`)
133
- }
134
-
135
- if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_STATE }
136
-
137
- return {
138
- backend: ((parsed as any).backend === 'claude' || (parsed as any).backend === 'codex') ? (parsed as any).backend : null,
139
- soul: safeName((parsed as any).soul),
140
- persona: safeName((parsed as any).persona),
141
- rules: Array.isArray((parsed as any).rules)
142
- ? (parsed as any).rules.map(String).filter((r: string) => SLUG_RE.test(r))
143
- : [],
144
- }
145
- }
146
-
147
- const LOCK_TIMEOUT_MS = 5000
148
- const LOCK_STALE_MS = 10000
149
- const LOCK_POLL_MS = 50
150
-
151
- /**
152
- * Acquire an exclusive directory-based lock, run fn, then release.
153
- * Uses mkdir (atomic on all filesystems) as the lock primitive.
154
- * Stale locks older than 10s are automatically broken.
155
- */
156
- async function withLock<T>(lockDir: string, label: string, fn: () => Promise<T>): Promise<T> {
157
- const deadline = Date.now() + LOCK_TIMEOUT_MS
158
-
159
- while (true) {
160
- try {
161
- await mkdir(lockDir)
162
- break
163
- } catch (e) {
164
- if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
165
-
166
- // Break stale locks
167
- try {
168
- const info = await stat(lockDir)
169
- if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
170
- await rm(lockDir, { force: true, recursive: true })
171
- continue
172
- }
173
- } catch {}
174
-
175
- if (Date.now() > deadline) {
176
- throw new Error(`Could not acquire ${label} lock — another brainjar process may be running.`)
177
- }
178
- await new Promise(r => setTimeout(r, LOCK_POLL_MS))
179
- }
180
- }
181
-
182
- try {
183
- return await fn()
184
- } finally {
185
- await rm(lockDir, { force: true, recursive: true })
186
- }
187
- }
188
-
189
- export async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
190
- return withLock(`${paths.state}.lock`, 'state', fn)
191
- }
192
-
193
- export async function writeState(state: State): Promise<void> {
194
- const doc = {
195
- backend: state.backend ?? null,
196
- soul: state.soul ?? null,
197
- persona: state.persona ?? null,
198
- rules: state.rules,
199
- }
200
- const tmp = `${paths.state}.tmp`
201
- await writeFile(tmp, stringifyYaml(doc))
202
- await rename(tmp, paths.state)
17
+ /** Fetch the fully resolved effective state from the server. */
18
+ export async function getEffectiveState(api: BrainjarClient): Promise<ApiEffectiveState> {
19
+ return api.get<ApiEffectiveState>('/api/v1/state')
203
20
  }
204
21
 
205
- // --- Local state ---
206
-
207
- /** Local state only stores overrides. undefined = cascade, null = explicit unset. */
208
- export interface LocalState {
209
- soul?: string | null
210
- persona?: string | null
211
- rules?: {
212
- add?: string[]
213
- remove?: string[]
214
- }
215
- }
216
-
217
- /** Override state from env vars. Same shape as LocalState, read-only. */
218
- export type EnvState = LocalState
219
-
220
- export type Scope = 'global' | 'local' | '+local' | '-local' | 'env' | '+env' | '-env'
221
-
222
- /** Effective state after merging global + local + env, with scope annotations. */
223
- export interface EffectiveState {
224
- backend: string | null
225
- soul: { value: string | null; scope: Scope }
226
- persona: { value: string | null; scope: Scope }
227
- rules: { value: string; scope: Scope }[]
228
- }
229
-
230
- export async function readLocalState(): Promise<LocalState> {
231
- let raw: string
232
- try {
233
- raw = await readFile(paths.localState, 'utf-8')
234
- } catch (e) {
235
- if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {}
236
- throw new Error(`Could not read local state.yaml: ${(e as Error).message}`)
237
- }
238
-
239
- let parsed: unknown
240
- try {
241
- parsed = parseYaml(raw)
242
- } catch (e) {
243
- throw new Error(`Local state.yaml is corrupt: ${(e as Error).message}`)
244
- }
245
-
246
- if (!parsed || typeof parsed !== 'object') return {}
247
-
248
- const result: LocalState = {}
249
- const p = parsed as Record<string, unknown>
250
-
251
- // For each layer: if key is present, include it (even if null)
252
- if ('soul' in p) result.soul = p.soul === null ? null : safeName(p.soul)
253
- if ('persona' in p) result.persona = p.persona === null ? null : safeName(p.persona)
254
-
255
- if (p.rules && typeof p.rules === 'object') {
256
- const r = p.rules as Record<string, unknown>
257
- result.rules = {}
258
- if (Array.isArray(r.add)) {
259
- result.rules.add = r.add.map(String).filter((s: string) => SLUG_RE.test(s))
260
- }
261
- if (Array.isArray(r.remove)) {
262
- result.rules.remove = r.remove.map(String).filter((s: string) => SLUG_RE.test(s))
263
- }
264
- }
265
-
266
- return result
267
- }
268
-
269
- export async function writeLocalState(local: LocalState): Promise<void> {
270
- const localDir = getLocalDir()
271
- await mkdir(localDir, { recursive: true })
272
-
273
- // Build a clean doc — only include keys that are present in local
274
- const doc: Record<string, unknown> = {}
275
- if ('soul' in local) doc.soul = local.soul ?? null
276
- if ('persona' in local) doc.persona = local.persona ?? null
277
- if (local.rules) {
278
- const rules: Record<string, string[]> = {}
279
- if (local.rules.add?.length) rules.add = local.rules.add
280
- if (local.rules.remove?.length) rules.remove = local.rules.remove
281
- if (Object.keys(rules).length) doc.rules = rules
282
- }
283
-
284
- const tmp = `${paths.localState}.tmp`
285
- await writeFile(tmp, stringifyYaml(doc))
286
- await rename(tmp, paths.localState)
287
- }
288
-
289
- /** Read override state from BRAINJAR_* env vars. Pure, no I/O.
290
- * If extraEnv is provided, those values take precedence over process.env. */
291
- export function readEnvState(extraEnv?: Record<string, string>): EnvState {
292
- const env = extraEnv ? { ...process.env, ...extraEnv } : process.env
293
- const result: EnvState = {}
294
-
295
- if (env.BRAINJAR_SOUL !== undefined) {
296
- result.soul = env.BRAINJAR_SOUL === '' ? null : safeName(env.BRAINJAR_SOUL)
297
- }
298
- if (env.BRAINJAR_PERSONA !== undefined) {
299
- result.persona = env.BRAINJAR_PERSONA === '' ? null : safeName(env.BRAINJAR_PERSONA)
300
- }
301
-
302
- const addRaw = env.BRAINJAR_RULES_ADD
303
- const removeRaw = env.BRAINJAR_RULES_REMOVE
304
- if (addRaw !== undefined || removeRaw !== undefined) {
305
- result.rules = {}
306
- if (addRaw) {
307
- result.rules.add = addRaw.split(',').map(s => s.trim()).filter(s => SLUG_RE.test(s))
308
- }
309
- if (removeRaw) {
310
- result.rules.remove = removeRaw.split(',').map(s => s.trim()).filter(s => SLUG_RE.test(s))
311
- }
312
- }
313
-
314
- return result
315
- }
316
-
317
- export async function withLocalStateLock<T>(fn: () => Promise<T>): Promise<T> {
318
- const localDir = getLocalDir()
319
- await mkdir(localDir, { recursive: true })
320
- return withLock(`${paths.localState}.lock`, 'local state', fn)
321
- }
322
-
323
- /** Apply overrides from a given scope onto an existing effective state. */
324
- function applyOverrides(
325
- base: EffectiveState,
326
- overrides: LocalState | EnvState,
327
- scope: 'local' | 'env',
328
- ): EffectiveState {
329
- const plusScope = `+${scope}` as Scope
330
- const minusScope = `-${scope}` as Scope
331
-
332
- const soul = 'soul' in overrides
333
- ? { value: overrides.soul ?? null, scope: scope as Scope }
334
- : base.soul
335
-
336
- const persona = 'persona' in overrides
337
- ? { value: overrides.persona ?? null, scope: scope as Scope }
338
- : base.persona
339
-
340
- // Rules: take active rules from base, apply adds/removes
341
- const adds = new Set(overrides.rules?.add ?? [])
342
- const removes = new Set(overrides.rules?.remove ?? [])
343
-
344
- const rules: EffectiveState['rules'] = []
345
- const seen = new Set<string>()
346
-
347
- // Process existing rules (keep active ones, mark newly removed)
348
- for (const r of base.rules) {
349
- if (r.scope.startsWith('-')) {
350
- // Already removed by a lower scope — keep the removal marker
351
- rules.push(r)
352
- seen.add(r.value)
353
- continue
354
- }
355
- if (removes.has(r.value)) {
356
- rules.push({ value: r.value, scope: minusScope })
357
- } else {
358
- rules.push(r)
359
- }
360
- seen.add(r.value)
361
- }
362
-
363
- // Add new rules from this scope (that aren't already present)
364
- for (const r of adds) {
365
- if (!seen.has(r)) {
366
- rules.push({ value: r, scope: plusScope })
367
- }
368
- }
369
-
370
- return { backend: base.backend, soul, persona, rules }
371
- }
372
-
373
- /** Pure merge: global → local → env, each scope overrides the previous. */
374
- export function mergeState(global: State, local: LocalState, env?: EnvState): EffectiveState {
375
- // Start with global as the base effective state
376
- const base: EffectiveState = {
377
- backend: global.backend,
378
- soul: { value: global.soul, scope: 'global' },
379
- persona: { value: global.persona, scope: 'global' },
380
- rules: global.rules.map(r => ({ value: r, scope: 'global' as Scope })),
381
- }
382
-
383
- const withLocal = applyOverrides(base, local, 'local')
384
- return env ? applyOverrides(withLocal, env, 'env') : withLocal
22
+ /** Mutate state on the server. Pass options.project to scope the mutation to a project. */
23
+ export async function putState(
24
+ api: BrainjarClient,
25
+ body: ApiStateMutation,
26
+ options?: { project?: string },
27
+ ): Promise<void> {
28
+ await api.put<void>('/api/v1/state', body, options?.project ? { project: options.project } : undefined)
385
29
  }