claude-brain 0.23.0 → 0.24.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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.22.4
1
+ 0.24.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Local development assistant bridging Obsidian vaults with Claude Code via MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -314,6 +314,80 @@ function installClaudeMd() {
314
314
  return true
315
315
  }
316
316
 
317
+ // ── Step 6: Install auto-start in shell profile ──────────
318
+
319
+ const AUTO_START_MARKER = '# >>> claude-brain auto-start >>>'
320
+ const AUTO_END_MARKER = '# <<< claude-brain auto-start <<<'
321
+
322
+ function getShellProfile() {
323
+ const home = homedir()
324
+ const os = process.platform
325
+
326
+ if (os === 'win32') {
327
+ const userProfile = process.env.USERPROFILE
328
+ if (userProfile) {
329
+ return join(userProfile, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1')
330
+ }
331
+ return null
332
+ }
333
+
334
+ // macOS defaults to zsh, Linux to bash
335
+ return os === 'darwin' ? join(home, '.zshrc') : join(home, '.bashrc')
336
+ }
337
+
338
+ function buildAutoStartSnippet(profilePath) {
339
+ if (profilePath && profilePath.endsWith('.ps1')) {
340
+ return [
341
+ AUTO_START_MARKER,
342
+ 'if (Get-Command claude-brain -ErrorAction SilentlyContinue) {',
343
+ ' $listening = Get-NetTCPConnection -LocalPort 3000 -ErrorAction SilentlyContinue',
344
+ ' if (-not $listening) {',
345
+ ' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve" -WindowStyle Hidden',
346
+ ' }',
347
+ '}',
348
+ AUTO_END_MARKER,
349
+ ].join('\n')
350
+ }
351
+ return [
352
+ AUTO_START_MARKER,
353
+ '# Auto-start claude-brain server if not already running',
354
+ '(command -v claude-brain >/dev/null 2>&1 && ! lsof -ti :3000 >/dev/null 2>&1) && {',
355
+ ' nohup claude-brain serve >/dev/null 2>&1 &',
356
+ ' disown 2>/dev/null',
357
+ '}',
358
+ AUTO_END_MARKER,
359
+ ].join('\n')
360
+ }
361
+
362
+ function installShellAutoStart() {
363
+ const profilePath = getShellProfile()
364
+ if (!profilePath) {
365
+ log('No supported shell profile found, skipping auto-start')
366
+ return false
367
+ }
368
+
369
+ // Check if already installed
370
+ if (existsSync(profilePath)) {
371
+ const content = readFileSync(profilePath, 'utf-8')
372
+ if (content.includes(AUTO_START_MARKER)) {
373
+ log('Auto-start already installed')
374
+ return true
375
+ }
376
+ }
377
+
378
+ // Ensure parent directory exists
379
+ const dir = join(profilePath, '..')
380
+ mkdirSync(dir, { recursive: true })
381
+
382
+ const existing = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : ''
383
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : '\n'
384
+ const snippet = buildAutoStartSnippet(profilePath)
385
+ writeFileSync(profilePath, existing + separator + snippet + '\n', 'utf-8')
386
+
387
+ log(`Auto-start installed in ${profilePath}`)
388
+ return true
389
+ }
390
+
317
391
  // ── Main ─────────────────────────────────────────────────
318
392
 
319
393
  async function main() {
@@ -332,6 +406,7 @@ async function main() {
332
406
  hooks: false,
333
407
  hookFiles: false,
334
408
  claudemd: false,
409
+ autostart: false,
335
410
  }
336
411
 
337
412
  // Step 1: Create data directory
@@ -349,15 +424,17 @@ async function main() {
349
424
  // Step 5: Install CLAUDE.md
350
425
  try { results.claudemd = installClaudeMd() } catch (e) { log(`CLAUDE.md install failed: ${e.message}`) }
351
426
 
427
+ // Step 6: Install auto-start in shell profile
428
+ try { results.autostart = installShellAutoStart() } catch (e) { log(`Auto-start install failed: ${e.message}`) }
429
+
352
430
  // Print success summary
353
431
  console.error('')
354
432
  console.error(`${PREFIX} ────────────────────────────────────────`)
355
433
  console.error(`${PREFIX}`)
356
434
  console.error(`${PREFIX} Claude Brain installed successfully!`)
357
435
  console.error(`${PREFIX}`)
358
- console.error(`${PREFIX} Next steps:`)
359
- console.error(`${PREFIX} 1. Start the server: claude-brain start`)
360
- console.error(`${PREFIX} 2. Restart Claude Code to activate hooks`)
436
+ console.error(`${PREFIX} Server will auto-start on next terminal session.`)
437
+ console.error(`${PREFIX} Auto-updates check every 24h in the background.`)
361
438
  console.error(`${PREFIX}`)
362
439
  console.error(`${PREFIX} Data: ${HOME}/data/`)
363
440
  console.error(`${PREFIX} Config: ${HOME}/config.yml`)
@@ -365,6 +442,7 @@ async function main() {
365
442
  console.error(`${PREFIX}`)
366
443
  console.error(`${PREFIX} No setup wizard needed — everything works out of the box.`)
367
444
  console.error(`${PREFIX} ChromaDB is optional (disabled by default, SQLite FTS5 is used).`)
445
+ console.error(`${PREFIX} To disable auto-start: claude-brain autostart uninstall`)
368
446
  console.error(`${PREFIX} ────────────────────────────────────────`)
369
447
  console.error('')
370
448
  }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Auto-start integration for claude-brain server.
3
+ * Installs/uninstalls a shell profile snippet so the server
4
+ * auto-starts on every new terminal session.
5
+ *
6
+ * Pure Node.js — no Bun-specific APIs.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
10
+ import { join, dirname } from 'node:path'
11
+ import { homedir, platform } from 'node:os'
12
+
13
+ const START_MARKER = '# >>> claude-brain auto-start >>>'
14
+ const END_MARKER = '# <<< claude-brain auto-start <<<'
15
+
16
+ const PREFIX = '[claude-brain]'
17
+
18
+ function log(msg: string) {
19
+ console.error(`${PREFIX} ${msg}`)
20
+ }
21
+
22
+ export interface AutoStartResult {
23
+ success: boolean
24
+ message: string
25
+ profilePath: string
26
+ }
27
+
28
+ /**
29
+ * Build the Unix/macOS/Linux shell snippet.
30
+ * Uses `lsof` to check if the port is already in use before starting.
31
+ */
32
+ function buildUnixSnippet(port: number): string {
33
+ return [
34
+ START_MARKER,
35
+ '# Auto-start claude-brain server if not already running',
36
+ `(command -v claude-brain >/dev/null 2>&1 && ! lsof -ti :${port} >/dev/null 2>&1) && {`,
37
+ ' nohup claude-brain serve >/dev/null 2>&1 &',
38
+ ' disown 2>/dev/null',
39
+ '}',
40
+ END_MARKER,
41
+ ].join('\n')
42
+ }
43
+
44
+ /**
45
+ * Build the Fish shell snippet.
46
+ * Fish uses different syntax than bash/zsh.
47
+ */
48
+ function buildFishSnippet(port: number): string {
49
+ return [
50
+ START_MARKER,
51
+ '# Auto-start claude-brain server if not already running',
52
+ `if command -v claude-brain >/dev/null 2>&1; and not lsof -ti :${port} >/dev/null 2>&1`,
53
+ ' nohup claude-brain serve >/dev/null 2>&1 &',
54
+ 'end',
55
+ END_MARKER,
56
+ ].join('\n')
57
+ }
58
+
59
+ /**
60
+ * Build the PowerShell snippet for Windows.
61
+ */
62
+ function buildPowerShellSnippet(port: number): string {
63
+ return [
64
+ START_MARKER,
65
+ 'if (Get-Command claude-brain -ErrorAction SilentlyContinue) {',
66
+ ` $port = ${port}`,
67
+ ' $listening = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue',
68
+ ' if (-not $listening) {',
69
+ ' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve" -WindowStyle Hidden',
70
+ ' }',
71
+ '}',
72
+ END_MARKER,
73
+ ].join('\n')
74
+ }
75
+
76
+ /**
77
+ * Detect available shell profile files based on platform.
78
+ * Returns paths that exist or are the primary target for the detected shell.
79
+ */
80
+ export function getShellProfiles(): string[] {
81
+ const home = homedir()
82
+ const os = platform()
83
+ const profiles: string[] = []
84
+
85
+ if (os === 'win32') {
86
+ // PowerShell profile
87
+ const psProfile = process.env.USERPROFILE
88
+ ? join(
89
+ process.env.USERPROFILE,
90
+ 'Documents',
91
+ 'PowerShell',
92
+ 'Microsoft.PowerShell_profile.ps1'
93
+ )
94
+ : null
95
+ // Also check WindowsPowerShell variant
96
+ const psProfileLegacy = process.env.USERPROFILE
97
+ ? join(
98
+ process.env.USERPROFILE,
99
+ 'Documents',
100
+ 'WindowsPowerShell',
101
+ 'Microsoft.PowerShell_profile.ps1'
102
+ )
103
+ : null
104
+
105
+ if (psProfile) profiles.push(psProfile)
106
+ if (psProfileLegacy && psProfileLegacy !== psProfile) profiles.push(psProfileLegacy)
107
+ return profiles
108
+ }
109
+
110
+ // Unix/macOS/Linux
111
+ if (os === 'darwin') {
112
+ // macOS defaults to zsh
113
+ profiles.push(join(home, '.zshrc'))
114
+ profiles.push(join(home, '.zprofile'))
115
+ } else {
116
+ // Linux defaults to bash
117
+ profiles.push(join(home, '.bashrc'))
118
+ profiles.push(join(home, '.bash_profile'))
119
+ }
120
+
121
+ // Fish (cross-platform Unix)
122
+ const fishConfig = join(home, '.config', 'fish', 'config.fish')
123
+ if (existsSync(fishConfig)) {
124
+ profiles.push(fishConfig)
125
+ }
126
+
127
+ return profiles
128
+ }
129
+
130
+ /**
131
+ * Check if the auto-start snippet is already in any profile file.
132
+ */
133
+ function profileContainsSnippet(profilePath: string): boolean {
134
+ if (!existsSync(profilePath)) return false
135
+ const content = readFileSync(profilePath, 'utf-8')
136
+ return content.includes(START_MARKER)
137
+ }
138
+
139
+ /**
140
+ * Get the appropriate snippet for a given profile path.
141
+ */
142
+ function getSnippetForProfile(profilePath: string, port: number): string {
143
+ if (profilePath.endsWith('.ps1')) {
144
+ return buildPowerShellSnippet(port)
145
+ }
146
+ if (profilePath.includes('fish')) {
147
+ return buildFishSnippet(port)
148
+ }
149
+ return buildUnixSnippet(port)
150
+ }
151
+
152
+ /**
153
+ * Install auto-start snippet into the primary shell profile.
154
+ * Idempotent — skips if already installed.
155
+ */
156
+ export function installAutoStart(port: number = 3000): AutoStartResult {
157
+ const profiles = getShellProfiles()
158
+
159
+ if (profiles.length === 0) {
160
+ return {
161
+ success: false,
162
+ message: 'No supported shell profile found',
163
+ profilePath: '',
164
+ }
165
+ }
166
+
167
+ // Use the first (primary) profile
168
+ const profilePath = profiles[0]
169
+
170
+ // Already installed?
171
+ if (profileContainsSnippet(profilePath)) {
172
+ log(`Auto-start already installed in ${profilePath}`)
173
+ return {
174
+ success: true,
175
+ message: 'Auto-start already installed',
176
+ profilePath,
177
+ }
178
+ }
179
+
180
+ // Ensure parent directory exists
181
+ const dir = dirname(profilePath)
182
+ if (!existsSync(dir)) {
183
+ mkdirSync(dir, { recursive: true })
184
+ }
185
+
186
+ // Read existing content or start fresh
187
+ const existing = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : ''
188
+
189
+ // Append snippet with a blank line separator
190
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : '\n'
191
+ const snippet = getSnippetForProfile(profilePath, port)
192
+ writeFileSync(profilePath, existing + separator + snippet + '\n', 'utf-8')
193
+
194
+ log(`Auto-start installed in ${profilePath}`)
195
+ return {
196
+ success: true,
197
+ message: `Auto-start installed in ${profilePath}`,
198
+ profilePath,
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Remove auto-start snippet from all known shell profiles.
204
+ * Removes everything between and including the marker comments.
205
+ */
206
+ export function uninstallAutoStart(): AutoStartResult {
207
+ const profiles = getShellProfiles()
208
+ let removedFrom = ''
209
+
210
+ for (const profilePath of profiles) {
211
+ if (!existsSync(profilePath)) continue
212
+
213
+ const content = readFileSync(profilePath, 'utf-8')
214
+ if (!content.includes(START_MARKER)) continue
215
+
216
+ // Remove the snippet block (markers + everything between)
217
+ const startIdx = content.indexOf(START_MARKER)
218
+ const endIdx = content.indexOf(END_MARKER)
219
+
220
+ if (startIdx === -1 || endIdx === -1) continue
221
+
222
+ const endOfMarker = endIdx + END_MARKER.length
223
+ // Also remove trailing newline after end marker
224
+ const endWithNewline =
225
+ endOfMarker < content.length && content[endOfMarker] === '\n'
226
+ ? endOfMarker + 1
227
+ : endOfMarker
228
+
229
+ // Remove leading blank line if the snippet was appended with separator
230
+ let start = startIdx
231
+ if (start > 0 && content[start - 1] === '\n') {
232
+ start--
233
+ if (start > 0 && content[start - 1] === '\n') {
234
+ start--
235
+ }
236
+ }
237
+
238
+ const cleaned = content.slice(0, start) + content.slice(endWithNewline)
239
+ writeFileSync(profilePath, cleaned, 'utf-8')
240
+ removedFrom = profilePath
241
+ log(`Auto-start removed from ${profilePath}`)
242
+ }
243
+
244
+ if (!removedFrom) {
245
+ log('Auto-start was not installed in any profile')
246
+ return {
247
+ success: true,
248
+ message: 'Auto-start was not installed in any profile',
249
+ profilePath: '',
250
+ }
251
+ }
252
+
253
+ return {
254
+ success: true,
255
+ message: `Auto-start removed from ${removedFrom}`,
256
+ profilePath: removedFrom,
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Check if auto-start is installed in any known shell profile.
262
+ */
263
+ export function isAutoStartInstalled(): boolean {
264
+ const profiles = getShellProfiles()
265
+ return profiles.some((p) => profileContainsSnippet(p))
266
+ }
package/src/cli/bin.ts CHANGED
@@ -34,6 +34,7 @@ function printHelp() {
34
34
  ['uninstall', 'Remove MCP server from Claude Code'],
35
35
  ['update', 'Update package and refresh CLAUDE.md'],
36
36
  ['refresh', 'Kill all, update to latest, and setup everything'],
37
+ ['autostart', 'Manage auto-start on terminal open (install/uninstall/status)'],
37
38
  ['chroma', 'Manage ChromaDB server (start/stop/status)'],
38
39
  ['hooks', 'Manage passive learning hooks (install/uninstall/status)'],
39
40
  ['git-hook', 'Manage git post-commit hook (install/uninstall/status)'],
@@ -142,6 +143,12 @@ async function main() {
142
143
  break
143
144
  }
144
145
 
146
+ case 'autostart': {
147
+ const { runAutoStart } = await import('./commands/autostart')
148
+ await runAutoStart()
149
+ break
150
+ }
151
+
145
152
  case 'chroma': {
146
153
  const { runChroma } = await import('./commands/chroma')
147
154
  await runChroma()
@@ -0,0 +1,90 @@
1
+ /**
2
+ * claude-brain autostart — Manage auto-start on terminal open.
3
+ *
4
+ * Usage:
5
+ * claude-brain autostart install Install auto-start snippet in shell profile
6
+ * claude-brain autostart uninstall Remove auto-start snippet from shell profiles
7
+ * claude-brain autostart status Check if auto-start is installed
8
+ */
9
+
10
+ import {
11
+ heading, successText, warningText, dimText, box,
12
+ } from '@/cli/ui/index.js'
13
+ import {
14
+ installAutoStart, uninstallAutoStart, isAutoStartInstalled, getShellProfiles,
15
+ } from '@/cli/auto-start'
16
+
17
+ export async function runAutoStart(): Promise<void> {
18
+ const subcommand = process.argv[3] || 'status'
19
+
20
+ switch (subcommand) {
21
+ case 'install': {
22
+ console.log()
23
+ console.log(heading('Auto-Start — Install'))
24
+ console.log()
25
+
26
+ const result = installAutoStart()
27
+ if (result.success) {
28
+ console.log(successText(` ${result.message}`))
29
+ if (result.profilePath) {
30
+ console.log(dimText(` Profile: ${result.profilePath}`))
31
+ console.log()
32
+ console.log(dimText(' Open a new terminal to activate.'))
33
+ }
34
+ } else {
35
+ console.log(warningText(` ${result.message}`))
36
+ }
37
+ console.log()
38
+ break
39
+ }
40
+
41
+ case 'uninstall': {
42
+ console.log()
43
+ console.log(heading('Auto-Start — Uninstall'))
44
+ console.log()
45
+
46
+ const result = uninstallAutoStart()
47
+ console.log(successText(` ${result.message}`))
48
+ console.log()
49
+ break
50
+ }
51
+
52
+ case 'status': {
53
+ console.log()
54
+ console.log(heading('Auto-Start — Status'))
55
+ console.log()
56
+
57
+ const installed = isAutoStartInstalled()
58
+ const profiles = getShellProfiles()
59
+
60
+ if (installed) {
61
+ console.log(successText(' Auto-start is installed'))
62
+ } else {
63
+ console.log(warningText(' Auto-start is not installed'))
64
+ }
65
+
66
+ console.log(dimText(` Shell profiles checked: ${profiles.join(', ') || 'none found'}`))
67
+ console.log()
68
+
69
+ if (!installed) {
70
+ console.log(box([
71
+ dimText('Install with:'),
72
+ ` claude-brain autostart install`,
73
+ ].join('\n'), 'Tip'))
74
+ console.log()
75
+ }
76
+ break
77
+ }
78
+
79
+ default: {
80
+ console.log()
81
+ console.log(warningText(`Unknown subcommand: ${subcommand}`))
82
+ console.log()
83
+ console.log(dimText('Usage:'))
84
+ console.log(dimText(' claude-brain autostart install Install auto-start'))
85
+ console.log(dimText(' claude-brain autostart uninstall Remove auto-start'))
86
+ console.log(dimText(' claude-brain autostart status Check status'))
87
+ console.log()
88
+ }
89
+ }
90
+ }
@@ -41,7 +41,7 @@ export async function runInstall() {
41
41
  } else {
42
42
  try {
43
43
  await withSpinner('Registering with Claude Code', async () => {
44
- execSync('claude mcp add claude-brain -- claude-brain serve', {
44
+ execSync('claude mcp add claude-brain -s user -- claude-brain serve', {
45
45
  encoding: 'utf-8',
46
46
  stdio: ['pipe', 'pipe', 'pipe']
47
47
  })
@@ -59,7 +59,7 @@ export async function runInstall() {
59
59
  errorText('Failed to register automatically.'),
60
60
  '',
61
61
  dimText('Run manually:'),
62
- ` ${theme.bold('claude mcp add claude-brain -- claude-brain serve')}`,
62
+ ` ${theme.bold('claude mcp add claude-brain -s user -- claude-brain serve')}`,
63
63
  ].join('\n'), 'Error'))
64
64
  }
65
65
  }
@@ -71,10 +71,10 @@ export async function runInstall() {
71
71
  console.log(box([
72
72
  `${theme.primary('Option 1:')} Install globally, then register`,
73
73
  ` ${theme.bold('bun install -g claude-brain')}`,
74
- ` ${theme.bold('claude mcp add claude-brain -- claude-brain serve')}`,
74
+ ` ${theme.bold('claude mcp add claude-brain -s user -- claude-brain serve')}`,
75
75
  '',
76
76
  `${theme.primary('Option 2:')} Use bunx ${dimText('(zero-install)')}`,
77
- ` ${theme.bold('claude mcp add claude-brain -- bunx claude-brain@latest')}`,
77
+ ` ${theme.bold('claude mcp add claude-brain -s user -- bunx claude-brain@latest')}`,
78
78
  '',
79
79
  `${theme.primary('Option 3:')} Add to Claude Code config manually`,
80
80
  dimText(' Add to your Claude Code MCP settings:'),
@@ -128,7 +128,7 @@ function isMcpConfigured(): boolean {
128
128
 
129
129
  function registerMcp(): boolean {
130
130
  try {
131
- execSync('claude mcp add claude-brain -- claude-brain serve', {
131
+ execSync('claude mcp add claude-brain -s user -- claude-brain serve', {
132
132
  encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10_000,
133
133
  })
134
134
  return true
@@ -41,6 +41,37 @@ export async function runServe() {
41
41
  console.error(`[claude-brain] Hook auto-install skipped: ${error instanceof Error ? error.message : 'unknown error'}`)
42
42
  }
43
43
 
44
+ // Auto-install shell auto-start if not already present (handles bun blocking postinstall)
45
+ try {
46
+ const { isAutoStartInstalled, installAutoStart } = await import('@/cli/auto-start')
47
+ if (!isAutoStartInstalled()) {
48
+ const result = installAutoStart()
49
+ if (result.success && result.profilePath) {
50
+ console.error(`[claude-brain] Auto-start installed in ${result.profilePath}`)
51
+ }
52
+ }
53
+ } catch (error) {
54
+ // Non-fatal — auto-start is a convenience feature
55
+ }
56
+
57
+ // Auto-install CLAUDE.md if not present (handles bun blocking postinstall)
58
+ try {
59
+ const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import('node:fs')
60
+ const { join, resolve, dirname } = await import('node:path')
61
+ const { homedir } = await import('node:os')
62
+ const { fileURLToPath } = await import('node:url')
63
+ const claudeMdDest = join(homedir(), '.claude', 'CLAUDE.md')
64
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
65
+ const claudeMdSrc = join(pkgRoot, 'assets', 'CLAUDE.md')
66
+ if (existsSync(claudeMdSrc) && !existsSync(claudeMdDest)) {
67
+ mkdirSync(join(homedir(), '.claude'), { recursive: true })
68
+ writeFileSync(claudeMdDest, readFileSync(claudeMdSrc, 'utf-8'), 'utf-8')
69
+ console.error('[claude-brain] CLAUDE.md installed to ~/.claude/CLAUDE.md')
70
+ }
71
+ } catch {
72
+ // Non-fatal
73
+ }
74
+
44
75
  const config = await loadConfig()
45
76
 
46
77
  if (config.logLevel === 'debug' || config.logLevel === 'info') {
@@ -208,6 +239,44 @@ export async function runServe() {
208
239
  }
209
240
  }
210
241
 
242
+ // Phase 31: Auto-update checker
243
+ let autoUpdater: InstanceType<typeof import('@/server/auto-updater').AutoUpdater> | null = null
244
+ if (config.autoUpdate?.enabled !== false) {
245
+ try {
246
+ const { AutoUpdater } = await import('@/server/auto-updater')
247
+ autoUpdater = new AutoUpdater(
248
+ {
249
+ enabled: config.autoUpdate?.enabled ?? true,
250
+ checkIntervalHours: config.autoUpdate?.checkIntervalHours ?? 24,
251
+ autoRestart: config.autoUpdate?.autoRestart ?? true,
252
+ },
253
+ mainLogger
254
+ )
255
+
256
+ // Check for updates on startup (non-blocking)
257
+ autoUpdater.check().then(result => {
258
+ if (result.updateAvailable && result.latestVersion) {
259
+ mainLogger.info(
260
+ { current: result.currentVersion, latest: result.latestVersion },
261
+ 'Update available! Run: claude-brain update'
262
+ )
263
+ }
264
+ }).catch(() => {})
265
+
266
+ // Schedule periodic checks
267
+ autoUpdater.schedulePeriodicCheck()
268
+
269
+ cleanup.register(async () => {
270
+ autoUpdater?.stopPeriodicCheck()
271
+ mainLogger.info('Auto-updater stopped')
272
+ })
273
+
274
+ mainLogger.info('Auto-updater initialized')
275
+ } catch (error) {
276
+ mainLogger.warn({ error }, 'Failed to initialize auto-updater')
277
+ }
278
+ }
279
+
211
280
  // Start HTTP server after MCP server is ready
212
281
  setTimeout(async () => {
213
282
  try {
@@ -14,12 +14,12 @@ export async function runUninstall() {
14
14
  try {
15
15
  await withSpinner('Removing Claude Brain from Claude Code', async () => {
16
16
  try {
17
- execSync('claude mcp remove claude-brain', {
17
+ execSync('claude mcp remove claude-brain -s user', {
18
18
  encoding: 'utf-8',
19
19
  stdio: ['pipe', 'pipe', 'pipe'],
20
20
  })
21
21
  } catch {
22
- // Falls back to scoped removal when registered in multiple scopes
22
+ // Falls back to local scope removal in case it was registered there
23
23
  execSync('claude mcp remove claude-brain -s local', {
24
24
  encoding: 'utf-8',
25
25
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -346,6 +346,16 @@ export const ConfigSchema = z.object({
346
346
  enabled: z.boolean().default(true),
347
347
  }).default({} as any),
348
348
 
349
+ /** Phase 31: Auto-update configuration */
350
+ autoUpdate: z.object({
351
+ /** Enable automatic update checks */
352
+ enabled: z.boolean().default(true),
353
+ /** Hours between update checks */
354
+ checkIntervalHours: z.number().int().min(1).max(168).default(24),
355
+ /** Automatically restart server after update */
356
+ autoRestart: z.boolean().default(true),
357
+ }).default({} as any),
358
+
349
359
  /** Phase 30: Optional LLM compression for observations */
350
360
  compression: z.object({
351
361
  /** Enable LLM-based compression of long observations */
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Auto-updater for claude-brain.
3
+ * Checks npm for new versions periodically, auto-updates with ghost process cleanup and fresh restart.
4
+ */
5
+
6
+ import { execSync, spawn } from 'node:child_process'
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
8
+ import { join, dirname, resolve } from 'node:path'
9
+ import { homedir, platform } from 'node:os'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ const __filename = fileURLToPath(import.meta.url)
13
+ const __dirname = dirname(__filename)
14
+ const PACKAGE_ROOT = resolve(__dirname, '..', '..')
15
+
16
+ interface UpdateCheckResult {
17
+ currentVersion: string
18
+ latestVersion: string | null
19
+ updateAvailable: boolean
20
+ lastChecked: string
21
+ }
22
+
23
+ interface AutoUpdateConfig {
24
+ enabled: boolean
25
+ checkIntervalHours: number
26
+ autoRestart: boolean
27
+ }
28
+
29
+ const DEFAULT_CONFIG: AutoUpdateConfig = {
30
+ enabled: true,
31
+ checkIntervalHours: 24,
32
+ autoRestart: true,
33
+ }
34
+
35
+ const isWindows = platform() === 'win32'
36
+
37
+ export class AutoUpdater {
38
+ private dataDir: string
39
+ private checkFile: string
40
+ private config: AutoUpdateConfig
41
+ private timer: ReturnType<typeof setInterval> | null = null
42
+ private logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void; error: (...args: any[]) => void }
43
+
44
+ constructor(config?: Partial<AutoUpdateConfig>, logger?: any) {
45
+ this.config = { ...DEFAULT_CONFIG, ...config }
46
+ this.dataDir = join(homedir(), '.claude-brain', 'data')
47
+ this.checkFile = join(this.dataDir, 'update-check.json')
48
+ this.logger = logger ?? {
49
+ info: (...args: any[]) => console.error('[auto-updater] INFO:', ...args),
50
+ warn: (...args: any[]) => console.error('[auto-updater] WARN:', ...args),
51
+ error: (...args: any[]) => console.error('[auto-updater] ERROR:', ...args),
52
+ }
53
+ }
54
+
55
+ /** Check npm registry for latest version */
56
+ async check(): Promise<UpdateCheckResult> {
57
+ const currentVersion = this.getCurrentVersion()
58
+
59
+ // Return cached result if checked recently
60
+ if (!this.shouldCheck()) {
61
+ try {
62
+ const cached = JSON.parse(readFileSync(this.checkFile, 'utf-8')) as UpdateCheckResult
63
+ return { ...cached, currentVersion }
64
+ } catch {
65
+ // Corrupted cache, proceed with fresh check
66
+ }
67
+ }
68
+
69
+ let latestVersion: string | null = null
70
+ try {
71
+ const result = execSync('npm view claude-brain version', {
72
+ encoding: 'utf-8',
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ timeout: 15_000,
75
+ })
76
+ latestVersion = result.trim()
77
+ } catch {
78
+ this.logger.warn('Failed to check npm registry for latest version')
79
+ }
80
+
81
+ const updateAvailable = latestVersion !== null && latestVersion !== currentVersion
82
+ const checkResult: UpdateCheckResult = {
83
+ currentVersion,
84
+ latestVersion,
85
+ updateAvailable,
86
+ lastChecked: new Date().toISOString(),
87
+ }
88
+
89
+ // Persist check result
90
+ try {
91
+ mkdirSync(this.dataDir, { recursive: true })
92
+ writeFileSync(this.checkFile, JSON.stringify(checkResult, null, 2), 'utf-8')
93
+ } catch (err) {
94
+ this.logger.warn('Failed to write update check file:', err)
95
+ }
96
+
97
+ if (updateAvailable) {
98
+ this.logger.info(`Update available: v${currentVersion} -> v${latestVersion}`)
99
+ }
100
+
101
+ return checkResult
102
+ }
103
+
104
+ /** Perform the update: kill ghosts -> update package -> restart */
105
+ async performUpdate(targetVersion: string): Promise<boolean> {
106
+ try {
107
+ this.logger.info(`Updating claude-brain to v${targetVersion}...`)
108
+
109
+ // Kill ghost processes
110
+ this.killGhostProcesses()
111
+
112
+ // Wait for ports to release
113
+ await this.sleep(2000)
114
+
115
+ // Try bun first, fallback to npm
116
+ try {
117
+ execSync('bun install -g claude-brain@latest', {
118
+ encoding: 'utf-8',
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ timeout: 120_000,
121
+ })
122
+ this.logger.info(`Updated to v${targetVersion} via bun`)
123
+ } catch {
124
+ try {
125
+ execSync('npm install -g claude-brain@latest', {
126
+ encoding: 'utf-8',
127
+ stdio: ['pipe', 'pipe', 'pipe'],
128
+ timeout: 120_000,
129
+ })
130
+ this.logger.info(`Updated to v${targetVersion} via npm`)
131
+ } catch (err) {
132
+ this.logger.error('Install failed with both bun and npm:', err)
133
+ return false
134
+ }
135
+ }
136
+
137
+ if (this.config.autoRestart) {
138
+ this.restartServer()
139
+ }
140
+
141
+ return true
142
+ } catch (err) {
143
+ this.logger.error('Update failed:', err)
144
+ return false
145
+ }
146
+ }
147
+
148
+ /** Schedule periodic background checks */
149
+ schedulePeriodicCheck(): void {
150
+ if (!this.config.enabled) return
151
+ if (this.timer) return
152
+
153
+ const intervalMs = this.config.checkIntervalHours * 3600 * 1000
154
+
155
+ this.timer = setInterval(async () => {
156
+ try {
157
+ const result = await this.check()
158
+ if (result.updateAvailable && result.latestVersion && this.config.enabled) {
159
+ await this.performUpdate(result.latestVersion)
160
+ }
161
+ } catch (err) {
162
+ this.logger.error('Periodic update check failed:', err)
163
+ }
164
+ }, intervalMs)
165
+
166
+ // Don't keep the process alive just for the update timer
167
+ if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
168
+ this.timer.unref()
169
+ }
170
+
171
+ this.logger.info(`Scheduled update checks every ${this.config.checkIntervalHours}h`)
172
+ }
173
+
174
+ /** Stop periodic checks */
175
+ stopPeriodicCheck(): void {
176
+ if (this.timer) {
177
+ clearInterval(this.timer)
178
+ this.timer = null
179
+ }
180
+ }
181
+
182
+ /** Check if enough time has passed since last check */
183
+ private shouldCheck(): boolean {
184
+ if (!existsSync(this.checkFile)) return true
185
+
186
+ try {
187
+ const data = JSON.parse(readFileSync(this.checkFile, 'utf-8')) as UpdateCheckResult
188
+ if (!data.lastChecked) return true
189
+
190
+ const lastChecked = new Date(data.lastChecked).getTime()
191
+ const intervalMs = this.config.checkIntervalHours * 3600 * 1000
192
+ return Date.now() - lastChecked >= intervalMs
193
+ } catch {
194
+ return true
195
+ }
196
+ }
197
+
198
+ /** Kill all ghost claude-brain processes */
199
+ private killGhostProcesses(): void {
200
+ const myPid = process.pid
201
+
202
+ // Kill by pattern
203
+ try {
204
+ if (isWindows) {
205
+ const result = execSync(
206
+ `wmic process where "commandline like '%claude-brain%' and processid != ${myPid}" get processid /format:list`,
207
+ { encoding: 'utf-8', stdio: 'pipe', timeout: 5000 }
208
+ )
209
+ const pids = result.match(/ProcessId=(\d+)/g)?.map(m => m.split('=')[1]) || []
210
+ for (const pid of pids) {
211
+ try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe', timeout: 5000 }) } catch {}
212
+ }
213
+ } else {
214
+ const result = execSync(`pgrep -f "claude-brain"`, {
215
+ encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
216
+ })
217
+ const pids = result.trim().split('\n').filter(p => p && Number(p) !== myPid)
218
+ for (const pid of pids) {
219
+ try { process.kill(Number(pid), 'SIGKILL') } catch {}
220
+ }
221
+ }
222
+ } catch {
223
+ // No matching processes — that's fine
224
+ }
225
+
226
+ // Kill by port 3000
227
+ try {
228
+ if (isWindows) {
229
+ const result = execSync(`netstat -ano | findstr :3000 | findstr LISTENING`, {
230
+ encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
231
+ })
232
+ const pids = new Set(
233
+ result.split('\n')
234
+ .map(line => line.trim().split(/\s+/).pop())
235
+ .filter(p => p && Number(p) !== myPid)
236
+ )
237
+ for (const pid of pids) {
238
+ try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe', timeout: 5000 }) } catch {}
239
+ }
240
+ } else {
241
+ const raw = execSync(`lsof -ti :3000`, {
242
+ encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
243
+ }).trim()
244
+ if (raw) {
245
+ const pids = raw.split('\n').filter(p => p && Number(p) !== myPid)
246
+ for (const pid of pids) {
247
+ try { process.kill(Number(pid), 'SIGKILL') } catch {}
248
+ }
249
+ }
250
+ }
251
+ } catch {
252
+ // No process on port — that's fine
253
+ }
254
+
255
+ // Clean up stale PID files
256
+ const pidPath = join(this.dataDir, 'server.pid')
257
+ if (existsSync(pidPath)) {
258
+ try {
259
+ const pid = parseInt(readFileSync(pidPath, 'utf-8').trim(), 10)
260
+ if (!isNaN(pid) && pid !== myPid) {
261
+ try { process.kill(pid, 0) } catch {
262
+ // Process not running, remove stale PID file
263
+ try { unlinkSync(pidPath) } catch {}
264
+ }
265
+ }
266
+ } catch {}
267
+ }
268
+ }
269
+
270
+ /** Spawn a fresh server instance and exit current */
271
+ private restartServer(): void {
272
+ this.logger.info('Restarting server with updated version...')
273
+
274
+ const child = spawn('claude-brain', ['serve'], {
275
+ detached: true,
276
+ stdio: 'ignore',
277
+ })
278
+
279
+ child.unref()
280
+
281
+ this.logger.info(`Spawned new server process (PID: ${child.pid}). Exiting current process.`)
282
+ process.exit(0)
283
+ }
284
+
285
+ /** Get current installed version from package.json */
286
+ private getCurrentVersion(): string {
287
+ try {
288
+ // Use the version from the package that's actually running
289
+ const pkgPath = join(PACKAGE_ROOT, 'package.json')
290
+ if (existsSync(pkgPath)) {
291
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
292
+ return pkg.version || 'unknown'
293
+ }
294
+ } catch {}
295
+ return 'unknown'
296
+ }
297
+
298
+ private sleep(ms: number): Promise<void> {
299
+ return new Promise(resolve => setTimeout(resolve, ms))
300
+ }
301
+ }