@brainjar/cli 0.3.0 → 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 +9 -7
- package/package.json +1 -1
- package/src/api-types.ts +155 -0
- package/src/cli.ts +4 -0
- 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 +65 -40
- 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 +53 -46
- package/src/commands/soul.ts +75 -110
- package/src/commands/status.ts +36 -41
- 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 -6
- package/src/seeds.ts +62 -103
- package/src/state.ts +12 -368
- package/src/sync.ts +60 -85
- package/src/version-check.ts +137 -0
- package/src/brain.ts +0 -69
- 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,46 +1,19 @@
|
|
|
1
1
|
import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
/** 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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
//
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
})
|