@brainjar/cli 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # brainjar
2
2
 
3
3
  [![CI](https://github.com/brainjar-sh/brainjar-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/brainjar-sh/brainjar-cli/actions/workflows/ci.yml)
4
- [![npm](https://img.shields.io/npm/v/brainjar)](https://www.npmjs.com/package/brainjar)
4
+ [![npm](https://img.shields.io/npm/v/@brainjar/cli)](https://www.npmjs.com/package/@brainjar/cli)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
 
7
7
  Shape how your AI thinks — identity, soul, persona, rules.
@@ -12,7 +12,7 @@ brainjar manages AI agent behavior through composable layers. Instead of one mon
12
12
 
13
13
  ```bash
14
14
  # Install
15
- bun install -g brainjar
15
+ bun install -g @brainjar/cli
16
16
 
17
17
  # Initialize with starter content
18
18
  brainjar init --default
@@ -247,11 +247,53 @@ brainjar status
247
247
  # rules default (global), no-delete (+local)
248
248
  ```
249
249
 
250
+ ## Pack
251
+
252
+ Packs are self-contained, shareable bundles of a brain and all its layers — soul, persona, and rules. Export a brain as a pack directory, hand it to a teammate, and they import it in one command.
253
+
254
+ A pack mirrors the `~/.brainjar/` structure with a `pack.yaml` manifest at the root. No tarballs, no magic — just files you can inspect with `ls` and `cat`.
255
+
256
+ ```bash
257
+ # Export a brain as a pack
258
+ brainjar pack export review # creates ./review/
259
+ brainjar pack export review --out /tmp # creates /tmp/review/
260
+ brainjar pack export review --name my-review # override pack name
261
+ brainjar pack export review --version 1.0.0 # set version (default: 0.1.0)
262
+ brainjar pack export review --author frank # set author field
263
+
264
+ # Import a pack
265
+ brainjar pack import ./review # import into ~/.brainjar/
266
+ brainjar pack import ./review --force # overwrite conflicts
267
+ brainjar pack import ./review --merge # rename conflicts as <name>-from-<packname>
268
+ brainjar pack import ./review --activate # activate the brain after import
269
+ ```
270
+
271
+ On conflict (a file already exists with different content), import fails by default and lists the conflicts. Use `--force` to overwrite or `--merge` to keep both versions. Identical files are silently skipped.
272
+
273
+ ## Hooks
274
+
275
+ brainjar integrates with Claude Code's hook system for automatic context injection. When hooks are installed, brainjar syncs your config on every session start — no manual `brainjar sync` needed.
276
+
277
+ ```bash
278
+ # Install hooks (writes to ~/.claude/settings.json)
279
+ brainjar hooks install
280
+
281
+ # Install for this project only
282
+ brainjar hooks install --local
283
+
284
+ # Check hook status
285
+ brainjar hooks status
286
+
287
+ # Remove hooks
288
+ brainjar hooks remove
289
+ ```
290
+
250
291
  ## Commands
251
292
 
252
293
  ```
253
294
  brainjar init [--default] [--obsidian] [--backend claude|codex]
254
295
  brainjar status [--sync] [--global|--local] [--short]
296
+ brainjar sync [--quiet]
255
297
  brainjar compose <brain> [--task <text>]
256
298
  brainjar compose --persona <name> [--task <text>]
257
299
 
@@ -261,6 +303,8 @@ brainjar persona create|list|show|use|drop
261
303
  brainjar rules create|list|show|add|remove
262
304
 
263
305
  brainjar identity create|list|show|use|drop|unlock|get|status|lock
306
+ brainjar pack export|import
307
+ brainjar hooks install|remove|status [--local]
264
308
  brainjar shell [--brain|--soul|--persona|--identity|--rules-add|--rules-remove]
265
309
  brainjar reset [--backend claude|codex]
266
310
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainjar/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Shape how your AI thinks — composable identity, soul, persona, and rules for AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/brain.ts ADDED
@@ -0,0 +1,69 @@
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/cli.ts CHANGED
@@ -11,6 +11,9 @@ import { status } from './commands/status.js'
11
11
  import { reset } from './commands/reset.js'
12
12
  import { shell } from './commands/shell.js'
13
13
  import { compose } from './commands/compose.js'
14
+ import { sync } from './commands/sync.js'
15
+ import { hooks } from './commands/hooks.js'
16
+ import { pack } from './commands/pack.js'
14
17
 
15
18
  Cli.create('brainjar', {
16
19
  description: 'Shape how your AI thinks — identity, soul, persona, rules',
@@ -27,4 +30,7 @@ Cli.create('brainjar', {
27
30
  .command(reset)
28
31
  .command(shell)
29
32
  .command(compose)
33
+ .command(sync)
34
+ .command(hooks)
35
+ .command(pack)
30
36
  .serve()
@@ -3,7 +3,7 @@ import { Cli, z, Errors } from 'incur'
3
3
  const { IncurError } = Errors
4
4
  import { readdir, readFile, writeFile, access, rm } from 'node:fs/promises'
5
5
  import { join, basename } from 'node:path'
6
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
6
+ import { stringify as stringifyYaml } from 'yaml'
7
7
  import { paths } from '../paths.js'
8
8
  import {
9
9
  readState,
@@ -17,69 +17,9 @@ import {
17
17
  requireBrainjarDir,
18
18
  normalizeSlug,
19
19
  } from '../state.js'
20
+ import { readBrain, type BrainConfig } from '../brain.js'
20
21
  import { sync } from '../sync.js'
21
22
 
22
- /** Brain YAML schema: soul + persona + rules */
23
- export interface BrainConfig {
24
- soul: string
25
- persona: string
26
- rules: string[]
27
- }
28
-
29
- /** Read and validate a brain YAML file. */
30
- export async function readBrain(name: string): Promise<BrainConfig> {
31
- const slug = normalizeSlug(name, 'brain name')
32
- const file = join(paths.brains, `${slug}.yaml`)
33
-
34
- let raw: string
35
- try {
36
- raw = await readFile(file, 'utf-8')
37
- } catch {
38
- throw new IncurError({
39
- code: 'BRAIN_NOT_FOUND',
40
- message: `Brain "${slug}" not found.`,
41
- hint: 'Run `brainjar brain list` to see available brains.',
42
- })
43
- }
44
-
45
- let parsed: unknown
46
- try {
47
- parsed = parseYaml(raw)
48
- } catch (e) {
49
- throw new IncurError({
50
- code: 'BRAIN_CORRUPT',
51
- message: `Brain "${slug}" has invalid YAML: ${(e as Error).message}`,
52
- })
53
- }
54
-
55
- if (!parsed || typeof parsed !== 'object') {
56
- throw new IncurError({
57
- code: 'BRAIN_CORRUPT',
58
- message: `Brain "${slug}" is empty or invalid.`,
59
- })
60
- }
61
-
62
- const p = parsed as Record<string, unknown>
63
-
64
- if (typeof p.soul !== 'string' || !p.soul) {
65
- throw new IncurError({
66
- code: 'BRAIN_INVALID',
67
- message: `Brain "${slug}" is missing required field "soul".`,
68
- })
69
- }
70
-
71
- if (typeof p.persona !== 'string' || !p.persona) {
72
- throw new IncurError({
73
- code: 'BRAIN_INVALID',
74
- message: `Brain "${slug}" is missing required field "persona".`,
75
- })
76
- }
77
-
78
- const rules = Array.isArray(p.rules) ? p.rules.map(String) : []
79
-
80
- return { soul: p.soul, persona: p.persona, rules }
81
- }
82
-
83
23
  export const brain = Cli.create('brain', {
84
24
  description: 'Manage brains — full-stack configuration snapshots (soul + persona + rules)',
85
25
  })
@@ -15,7 +15,7 @@ import {
15
15
  stripFrontmatter,
16
16
  resolveRuleContent,
17
17
  } from '../state.js'
18
- import { readBrain } from './brain.js'
18
+ import { readBrain } from '../brain.js'
19
19
 
20
20
  export const compose = Cli.create('compose', {
21
21
  description: 'Assemble a full subagent prompt from a brain or ad-hoc persona',
@@ -0,0 +1,41 @@
1
+ import { Cli, z } from 'incur'
2
+ import { installHooks, removeHooks, getHooksStatus } from '../hooks.js'
3
+
4
+ const localOption = z.object({
5
+ local: z.boolean().default(false).describe('Target project-local .claude/settings.json'),
6
+ })
7
+
8
+ const install = Cli.create('install', {
9
+ description: 'Register brainjar hooks in Claude Code settings',
10
+ options: localOption,
11
+ async run(c) {
12
+ return installHooks({ local: c.options.local })
13
+ },
14
+ })
15
+
16
+ const remove = Cli.create('remove', {
17
+ description: 'Remove brainjar hooks from Claude Code settings',
18
+ options: localOption,
19
+ async run(c) {
20
+ return removeHooks({ local: c.options.local })
21
+ },
22
+ })
23
+
24
+ const status = Cli.create('status', {
25
+ description: 'Show brainjar hook installation status',
26
+ options: localOption,
27
+ async run(c) {
28
+ const result = await getHooksStatus({ local: c.options.local })
29
+ if (Object.keys(result.hooks).length === 0) {
30
+ return { ...result, installed: false }
31
+ }
32
+ return { ...result, installed: true }
33
+ },
34
+ })
35
+
36
+ export const hooks = Cli.create('hooks', {
37
+ description: 'Manage Claude Code hooks for brainjar',
38
+ })
39
+ .command(install)
40
+ .command(remove)
41
+ .command(status)
@@ -0,0 +1,49 @@
1
+ import { Cli, z } from 'incur'
2
+ import { resolve } from 'node:path'
3
+ import { exportPack, importPack } from '../pack.js'
4
+
5
+ const exportCmd = Cli.create('export', {
6
+ description: 'Export a brain as a shareable pack directory',
7
+ args: z.object({
8
+ brain: z.string().describe('Brain name to export'),
9
+ }),
10
+ options: z.object({
11
+ out: z.string().optional().describe('Parent directory for the exported pack (default: cwd)'),
12
+ name: z.string().optional().describe('Override pack name (and output directory name)'),
13
+ version: z.string().optional().describe('Semver version string (default: 0.1.0)'),
14
+ author: z.string().optional().describe('Author field in manifest'),
15
+ }),
16
+ async run(c) {
17
+ return exportPack(c.args.brain, {
18
+ out: c.options.out ? resolve(c.options.out) : undefined,
19
+ name: c.options.name,
20
+ version: c.options.version,
21
+ author: c.options.author,
22
+ })
23
+ },
24
+ })
25
+
26
+ const importCmd = Cli.create('import', {
27
+ description: 'Import a pack directory into ~/.brainjar/',
28
+ args: z.object({
29
+ path: z.string().describe('Path to pack directory'),
30
+ }),
31
+ options: z.object({
32
+ force: z.boolean().default(false).describe('Overwrite existing files on conflict'),
33
+ merge: z.boolean().default(false).describe('Rename incoming files on conflict as <name>-from-<packname>'),
34
+ activate: z.boolean().default(false).describe('Activate the brain after successful import'),
35
+ }),
36
+ async run(c) {
37
+ return importPack(resolve(c.args.path), {
38
+ force: c.options.force,
39
+ merge: c.options.merge,
40
+ activate: c.options.activate,
41
+ })
42
+ },
43
+ })
44
+
45
+ export const pack = Cli.create('pack', {
46
+ description: 'Export and import brainjar packs — self-contained shareable bundles',
47
+ })
48
+ .command(exportCmd)
49
+ .command(importCmd)
@@ -6,7 +6,7 @@ import { access } from 'node:fs/promises'
6
6
  import { requireBrainjarDir } from '../state.js'
7
7
  import { sync } from '../sync.js'
8
8
  import { getLocalDir } from '../paths.js'
9
- import { readBrain } from './brain.js'
9
+ import { readBrain } from '../brain.js'
10
10
 
11
11
  export const shell = Cli.create('shell', {
12
12
  description: 'Spawn a subshell with BRAINJAR_* env vars set',
@@ -0,0 +1,16 @@
1
+ import { Cli, z } from 'incur'
2
+ import { sync as runSync } from '../sync.js'
3
+ import { requireBrainjarDir } from '../state.js'
4
+
5
+ export const sync = Cli.create('sync', {
6
+ description: 'Regenerate config file from active layers',
7
+ options: z.object({
8
+ quiet: z.boolean().default(false).describe('Suppress output (for use in hooks)'),
9
+ }),
10
+ async run(c) {
11
+ await requireBrainjarDir()
12
+ const result = await runSync()
13
+ if (c.options.quiet) return
14
+ return result
15
+ },
16
+ })
@@ -0,0 +1,132 @@
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
+ })
package/src/hooks.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { join, dirname } from 'node:path'
3
+ import { getHome } from './paths.js'
4
+
5
+ export interface HookEntry {
6
+ type: 'command'
7
+ command: string
8
+ timeout?: number
9
+ _brainjar?: true
10
+ }
11
+
12
+ export interface HookMatcher {
13
+ matcher?: string
14
+ hooks: HookEntry[]
15
+ }
16
+
17
+ export interface HooksConfig {
18
+ [event: string]: HookMatcher[]
19
+ }
20
+
21
+ interface Settings {
22
+ hooks?: HooksConfig
23
+ [key: string]: unknown
24
+ }
25
+
26
+ const BRAINJAR_HOOKS: Record<string, HookMatcher> = {
27
+ SessionStart: {
28
+ matcher: 'startup',
29
+ hooks: [
30
+ {
31
+ type: 'command',
32
+ command: 'brainjar sync --quiet',
33
+ timeout: 5000,
34
+ _brainjar: true,
35
+ },
36
+ ],
37
+ },
38
+ }
39
+
40
+ function getSettingsPath(local: boolean): string {
41
+ if (local) return join(process.cwd(), '.claude', 'settings.json')
42
+ return join(getHome(), '.claude', 'settings.json')
43
+ }
44
+
45
+ async function readSettings(path: string): Promise<Settings> {
46
+ let raw: string
47
+ try {
48
+ raw = await readFile(path, 'utf-8')
49
+ } catch (e) {
50
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {}
51
+ throw e
52
+ }
53
+ try {
54
+ return JSON.parse(raw)
55
+ } catch {
56
+ throw new Error(`Invalid JSON in ${path} — fix the file or delete it and re-run.`)
57
+ }
58
+ }
59
+
60
+ async function writeSettings(path: string, settings: Settings): Promise<void> {
61
+ await mkdir(dirname(path), { recursive: true })
62
+ await writeFile(path, JSON.stringify(settings, null, 2) + '\n')
63
+ }
64
+
65
+ function isBrainjarHook(entry: HookEntry): boolean {
66
+ return entry._brainjar === true
67
+ }
68
+
69
+ function isBrainjarMatcher(matcher: HookMatcher): boolean {
70
+ return matcher.hooks.some(isBrainjarHook)
71
+ }
72
+
73
+ export async function installHooks(options: { local?: boolean } = {}): Promise<{ path: string; installed: string[] }> {
74
+ const path = getSettingsPath(options.local ?? false)
75
+ const settings = await readSettings(path)
76
+
77
+ if (!settings.hooks) settings.hooks = {}
78
+
79
+ const installed: string[] = []
80
+
81
+ for (const [event, matcher] of Object.entries(BRAINJAR_HOOKS)) {
82
+ if (!settings.hooks[event]) settings.hooks[event] = []
83
+
84
+ // Remove any existing brainjar entries for this event
85
+ settings.hooks[event] = settings.hooks[event].filter(m => !isBrainjarMatcher(m))
86
+
87
+ // Add the new one
88
+ settings.hooks[event].push(matcher)
89
+ installed.push(event)
90
+ }
91
+
92
+ await writeSettings(path, settings)
93
+ return { path, installed }
94
+ }
95
+
96
+ export async function removeHooks(options: { local?: boolean } = {}): Promise<{ path: string; removed: string[] }> {
97
+ const path = getSettingsPath(options.local ?? false)
98
+ const settings = await readSettings(path)
99
+
100
+ const removed: string[] = []
101
+
102
+ if (settings.hooks) {
103
+ for (const [event, matchers] of Object.entries(settings.hooks)) {
104
+ const filtered = matchers.filter(m => !isBrainjarMatcher(m))
105
+ if (filtered.length < matchers.length) {
106
+ removed.push(event)
107
+ if (filtered.length === 0) {
108
+ delete settings.hooks[event]
109
+ } else {
110
+ settings.hooks[event] = filtered
111
+ }
112
+ }
113
+ }
114
+ if (Object.keys(settings.hooks).length === 0) {
115
+ delete settings.hooks
116
+ }
117
+ }
118
+
119
+ await writeSettings(path, settings)
120
+ return { path, removed }
121
+ }
122
+
123
+ export async function getHooksStatus(options: { local?: boolean } = {}): Promise<{ path: string; hooks: Record<string, string> }> {
124
+ const path = getSettingsPath(options.local ?? false)
125
+ const settings = await readSettings(path)
126
+
127
+ const hooks: Record<string, string> = {}
128
+
129
+ if (settings.hooks) {
130
+ for (const [event, matchers] of Object.entries(settings.hooks)) {
131
+ for (const matcher of matchers) {
132
+ for (const hook of matcher.hooks) {
133
+ if (isBrainjarHook(hook)) {
134
+ hooks[event] = hook.command
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ return { path, hooks }
142
+ }