@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/sync.ts CHANGED
@@ -1,46 +1,19 @@
1
1
  import { readFile, writeFile, copyFile, mkdir } 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'
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
- /** Extract content before, inside, and after brainjar markers. */
16
+ /** Extract content before and after brainjar markers. */
44
17
  function parseMarkers(content: string): { before: string; after: string } | null {
45
18
  const startIdx = content.indexOf(MARKER_START)
46
19
  const endIdx = content.indexOf(MARKER_END)
@@ -51,18 +24,59 @@ function parseMarkers(content: string): { before: string; after: string } | null
51
24
  return { before, after }
52
25
  }
53
26
 
54
- export async function sync(options?: Backend | SyncOptions) {
55
- 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[] = []
56
31
 
57
- // Normalize legacy call signature: sync('claude') → sync({ backend: 'claude' })
58
- 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
+ }
59
43
 
60
- const globalState = await readState()
61
- const backend: Backend = opts.backend ?? (globalState.backend as Backend) ?? 'claude'
62
- 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
+ }
63
55
 
64
- const envState = readEnvState(opts.envOverrides)
65
- 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)
66
80
 
67
81
  // Read existing config file
68
82
  let existingContent: string | null = null
@@ -81,41 +95,8 @@ export async function sync(options?: Backend | SyncOptions) {
81
95
  }
82
96
  }
83
97
 
84
- // Build the brainjar section content
85
- const sections: string[] = []
86
-
87
- if (opts.local) {
88
- // Local mode: read local state + env, only write overridden layers.
89
- // Everything else falls back to the global config (Claude Code merges both files).
90
- const localState = await readLocalState()
91
- const effective = mergeState(globalState, localState, envState)
92
-
93
- if ('soul' in localState && effective.soul.value) {
94
- await inlineSoul(effective.soul.value, sections)
95
- }
96
- if ('persona' in localState && effective.persona.value) {
97
- await inlinePersona(effective.persona.value, sections)
98
- }
99
- if (localState.rules) {
100
- // Inline the effective rules that are active (not removed)
101
- const activeRules = effective.rules
102
- .filter(r => !r.scope.startsWith('-'))
103
- .map(r => r.value)
104
- // But only write rules section if local state has rules overrides
105
- await inlineRules(activeRules, sections, warnings)
106
- }
107
- } else {
108
- // Global mode: apply env overrides on top of global state, write all layers
109
- const effective = mergeState(globalState, {}, envState)
110
- const effectiveSoul = effective.soul.value
111
- const effectivePersona = effective.persona.value
112
- const effectiveRules = effective.rules.filter(r => !r.scope.startsWith('-')).map(r => r.value)
113
-
114
- if (effectiveSoul) await inlineSoul(effectiveSoul, sections)
115
- if (effectivePersona) await inlinePersona(effectivePersona, sections)
116
- await inlineRules(effectiveRules, sections, warnings)
117
-
118
- // Local Overrides note (only for global config)
98
+ // Add project-level overrides note for global config
99
+ if (!opts.project) {
119
100
  sections.push('')
120
101
  sections.push('## Project-Level Overrides')
121
102
  sections.push('')
@@ -136,8 +117,6 @@ export async function sync(options?: Backend | SyncOptions) {
136
117
  const parsed = existingContent ? parseMarkers(existingContent) : null
137
118
 
138
119
  if (parsed) {
139
- // Existing file with markers — replace the brainjar section, preserve the rest
140
- // Discard legacy brainjar content that ended up outside markers during migration
141
120
  const before = parsed.before
142
121
  const after = parsed.after?.includes('# Managed by brainjar') ? '' : parsed.after
143
122
  const parts: string[] = []
@@ -146,16 +125,12 @@ export async function sync(options?: Backend | SyncOptions) {
146
125
  if (after) parts.push('', after)
147
126
  output = parts.join('\n')
148
127
  } else if (existingContent && !existingContent.includes(MARKER_START)) {
149
- // Existing file without markers (first sync)
150
128
  if (existingContent.includes('# Managed by brainjar')) {
151
- // Legacy brainjar-managed file — replace entirely
152
129
  output = brainjarBlock + '\n'
153
130
  } else {
154
- // User-owned file — prepend brainjar section, preserve user content
155
131
  output = brainjarBlock + '\n\n' + existingContent
156
132
  }
157
133
  } else {
158
- // No existing file
159
134
  output = brainjarBlock + '\n'
160
135
  }
161
136
 
@@ -165,7 +140,7 @@ export async function sync(options?: Backend | SyncOptions) {
165
140
  return {
166
141
  backend,
167
142
  written: config.configFile,
168
- local: opts.local ?? false,
143
+ project: opts.project ?? false,
169
144
  ...(warnings.length ? { warnings } : {}),
170
145
  }
171
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/hooks.test.ts DELETED
@@ -1,132 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
2
- import { readFile, rm, mkdir, writeFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
4
- import { installHooks, removeHooks, getHooksStatus } from './hooks.js'
5
-
6
- const TEST_HOME = join(import.meta.dir, '..', '.test-home-hooks')
7
- const SETTINGS_PATH = join(TEST_HOME, '.claude', 'settings.json')
8
-
9
- beforeEach(async () => {
10
- process.env.BRAINJAR_TEST_HOME = TEST_HOME
11
- await mkdir(join(TEST_HOME, '.claude'), { recursive: true })
12
- })
13
-
14
- afterEach(async () => {
15
- delete process.env.BRAINJAR_TEST_HOME
16
- await rm(TEST_HOME, { recursive: true, force: true })
17
- })
18
-
19
- describe('hooks install', () => {
20
- test('creates hooks in empty settings', async () => {
21
- const result = await installHooks()
22
- expect(result.installed).toContain('SessionStart')
23
-
24
- const settings = JSON.parse(await readFile(SETTINGS_PATH, 'utf-8'))
25
- expect(settings.hooks.SessionStart).toHaveLength(1)
26
- expect(settings.hooks.SessionStart[0].matcher).toBe('startup')
27
- expect(settings.hooks.SessionStart[0].hooks[0].command).toBe('brainjar sync --quiet')
28
- expect(settings.hooks.SessionStart[0].hooks[0]._brainjar).toBe(true)
29
- })
30
-
31
- test('preserves existing settings', async () => {
32
- await writeFile(SETTINGS_PATH, JSON.stringify({
33
- statusLine: { type: 'command', command: 'echo hi' },
34
- enabledPlugins: { foo: true },
35
- }))
36
-
37
- await installHooks()
38
-
39
- const settings = JSON.parse(await readFile(SETTINGS_PATH, 'utf-8'))
40
- expect(settings.statusLine.command).toBe('echo hi')
41
- expect(settings.enabledPlugins.foo).toBe(true)
42
- expect(settings.hooks.SessionStart).toHaveLength(1)
43
- })
44
-
45
- test('preserves existing non-brainjar hooks', async () => {
46
- await writeFile(SETTINGS_PATH, JSON.stringify({
47
- hooks: {
48
- SessionStart: [
49
- { matcher: 'startup', hooks: [{ type: 'command', command: 'echo hello' }] },
50
- ],
51
- PreToolUse: [
52
- { matcher: 'Edit', hooks: [{ type: 'command', command: 'lint.sh' }] },
53
- ],
54
- },
55
- }))
56
-
57
- await installHooks()
58
-
59
- const settings = JSON.parse(await readFile(SETTINGS_PATH, 'utf-8'))
60
- // Existing non-brainjar SessionStart hook preserved
61
- expect(settings.hooks.SessionStart).toHaveLength(2)
62
- // PreToolUse untouched
63
- expect(settings.hooks.PreToolUse).toHaveLength(1)
64
- expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe('lint.sh')
65
- })
66
-
67
- test('is idempotent — no duplicates on re-install', async () => {
68
- await installHooks()
69
- await installHooks()
70
-
71
- const settings = JSON.parse(await readFile(SETTINGS_PATH, 'utf-8'))
72
- const brainjarEntries = settings.hooks.SessionStart.filter(
73
- (m: any) => m.hooks.some((h: any) => h._brainjar)
74
- )
75
- expect(brainjarEntries).toHaveLength(1)
76
- })
77
- })
78
-
79
- describe('hooks remove', () => {
80
- test('removes brainjar hooks', async () => {
81
- await installHooks()
82
- const result = await removeHooks()
83
-
84
- expect(result.removed).toContain('SessionStart')
85
-
86
- const settings = JSON.parse(await readFile(SETTINGS_PATH, 'utf-8'))
87
- expect(settings.hooks).toBeUndefined()
88
- })
89
-
90
- test('preserves non-brainjar hooks', async () => {
91
- await writeFile(SETTINGS_PATH, JSON.stringify({
92
- hooks: {
93
- SessionStart: [
94
- { matcher: 'startup', hooks: [{ type: 'command', command: 'echo hello' }] },
95
- ],
96
- },
97
- }))
98
-
99
- await installHooks()
100
- await removeHooks()
101
-
102
- const settings = JSON.parse(await readFile(SETTINGS_PATH, 'utf-8'))
103
- expect(settings.hooks.SessionStart).toHaveLength(1)
104
- expect(settings.hooks.SessionStart[0].hooks[0].command).toBe('echo hello')
105
- })
106
-
107
- test('no-op when no brainjar hooks exist', async () => {
108
- await writeFile(SETTINGS_PATH, JSON.stringify({ statusLine: { command: 'echo' } }))
109
-
110
- const result = await removeHooks()
111
- expect(result.removed).toHaveLength(0)
112
- })
113
- })
114
-
115
- describe('hooks status', () => {
116
- test('reports not installed when no hooks', async () => {
117
- await writeFile(SETTINGS_PATH, JSON.stringify({}))
118
- const result = await getHooksStatus()
119
- expect(Object.keys(result.hooks)).toHaveLength(0)
120
- })
121
-
122
- test('reports installed hooks', async () => {
123
- await installHooks()
124
- const result = await getHooksStatus()
125
- expect(result.hooks.SessionStart).toBe('brainjar sync --quiet')
126
- })
127
-
128
- test('handles missing settings file', async () => {
129
- const result = await getHooksStatus()
130
- expect(Object.keys(result.hooks)).toHaveLength(0)
131
- })
132
- })