From 531a433ca276a0717be630b4f9c386b510068ef6 Mon Sep 17 00:00:00 2001 From: yashraj Date: Wed, 13 May 2026 17:25:43 +0530 Subject: [PATCH] feat(install): add --with-gbrain flag to install gbrain alongside fleet Co-Authored-By: Claude Sonnet 4.6 --- src/cli/install.ts | 81 +++++++++++++++++++++++++-- tests/install.test.ts | 124 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 5 deletions(-) diff --git a/src/cli/install.ts b/src/cli/install.ts index c3c7a938..1b2e3350 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { execSync, execFileSync } from 'node:child_process'; import { serverVersion } from '../version.js'; @@ -276,6 +277,57 @@ export function killApraFleet(): void { } } +export function installGbrain(): void { + const homeDir = os.homedir(); + const gbrainDir = path.join(homeDir, 'gbrain'); + + // Step 1: Check bun is available + try { + execFileSync('bun', ['--version'], { stdio: 'pipe', shell: true }); + } catch { + console.warn(' ⚠ gbrain install skipped — bun not found. Install bun first: https://bun.sh'); + return; + } + + // Step 2: Check if already installed + if (fs.existsSync(gbrainDir)) { + // Already cloned — just verify it works + try { + execFileSync('gbrain', ['--version'], { stdio: 'pipe', shell: true }); + console.log(' ✓ gbrain already installed'); + return; + } catch { + // Exists but not in PATH — re-link + console.log(' gbrain dir exists, re-linking...'); + } + } else { + // Clone + console.log(' Cloning gbrain...'); + execFileSync('git', ['clone', 'https://github.com/garrytan/gbrain.git', gbrainDir], { stdio: 'inherit', shell: true }); + } + + // Step 3: bun install + bun link + console.log(' Running bun install...'); + try { + execFileSync('bun', ['install'], { cwd: gbrainDir, stdio: 'inherit', shell: true }); + } catch { + // postinstall script fails on Windows — benign, packages are still installed + } + console.log(' Linking gbrain CLI...'); + execFileSync('bun', ['link'], { cwd: gbrainDir, stdio: 'inherit', shell: true }); + + // Step 4: verify + let gbrainVersion = 'installed'; + try { + const v = execFileSync('gbrain', ['--version'], { stdio: 'pipe', encoding: 'utf-8', shell: true }); + gbrainVersion = (v as string).trim() || 'installed'; + } catch { + gbrainVersion = 'linked (restart shell to use gbrain in PATH)'; + } + console.log(` ✓ gbrain ${gbrainVersion}`); + console.log(' Next: run `gbrain init` to create your brain database.'); +} + export async function runInstall(args: string[]): Promise { // --help / -h guard — must come first, before any side effects (#142) if (args.includes('--help') || args.includes('-h')) { @@ -292,6 +344,7 @@ Usage: apra-fleet install --no-skill Same as --skill none apra-fleet install --force Stop a running server before installing apra-fleet install --llm Target LLM provider: claude (default), gemini, codex, copilot + apra-fleet install --with-gbrain Install gbrain alongside fleet (git clone + bun link) apra-fleet install --help Show this help Options: @@ -359,9 +412,12 @@ Options: // Parse --force flag const force = args.includes('--force'); + // Parse --with-gbrain flag + const withGbrain = args.includes('--with-gbrain'); + // Reject unknown flags to catch typos early const knownFlagPrefixes = ['--llm=', '--skill=']; - const knownFlagExact = new Set(['--llm', '--skill', '--no-skill', '--force', '--help', '-h']); + const knownFlagExact = new Set(['--llm', '--skill', '--no-skill', '--force', '--with-gbrain', '--help', '-h']); for (const a of args) { if (knownFlagExact.has(a)) continue; if (knownFlagPrefixes.some(p => a.startsWith(p))) continue; @@ -372,7 +428,8 @@ Options: const installFleet = skillMode === 'fleet' || skillMode === 'pm' || skillMode === 'all'; const installPm = skillMode === 'pm' || skillMode === 'all'; - const totalSteps = (installFleet && installPm) ? 8 : installFleet ? 7 : installPm ? 8 : 6; + const baseSteps = (installFleet && installPm) ? 8 : installFleet ? 7 : installPm ? 8 : 6; + const totalSteps = withGbrain ? baseSteps + 1 : baseSteps; if (llm === 'gemini' && (installFleet || installPm)) { console.warn(`\n⚠ Note: Gemini does not support background agents. If you plan to use Gemini as the\n PM/orchestrator, fleet operations will run sequentially (no parallel dispatch).\n For best orchestration performance, consider using Claude. See docs for details.\n`); @@ -523,7 +580,7 @@ ${killHint} // --- Step 8: Install Beads task tracker --- // shell:true required on Windows — npm global packages install as .cmd wrappers // that cannot be directly spawned by Node without a shell - console.log(` [${totalSteps}/${totalSteps}] Installing Beads task tracker...`); + console.log(` [${baseSteps}/${totalSteps}] Installing Beads task tracker...`); try { // Check if already installed try { @@ -538,6 +595,12 @@ ${killHint} console.warn(' ⚠ Beads install skipped — npm not available or install failed'); } + // --- Step 9: Install gbrain (optional) --- + if (withGbrain) { + console.log(` [${totalSteps}/${totalSteps}] Installing gbrain...`); + installGbrain(); + } + // Finalize permissions mergePermissions(paths); @@ -553,6 +616,16 @@ ${killHint} beadsVersion = 'not available'; } + let gbrainStatus = ''; + if (withGbrain) { + try { + const gv = execFileSync('gbrain', ['--version'], { stdio: 'pipe', encoding: 'utf-8', shell: true }); + gbrainStatus = (gv as string).trim() || 'installed'; + } catch { + gbrainStatus = 'linked (restart shell to use gbrain in PATH)'; + } + } + const instructions = llm === 'claude' ? 'Run /mcp in Claude Code to load the server.' : `Restart ${paths.name} to load the server.`; const forceNote = force ? '\nRestart Claude Code to reload the MCP server.' : ''; console.log(` @@ -561,7 +634,7 @@ Apra Fleet ${serverVersion} installed successfully for ${paths.name}. Hooks: ${HOOKS_DIR} Scripts: ${SCRIPTS_DIR} Settings: ${paths.settingsFile}${installFleet ? `\n Fleet Skill: ${paths.fleetSkillsDir}` : ''}${installPm ? `\n PM Skill: ${paths.skillsDir}` : ''} - Beads: ${beadsVersion} + Beads: ${beadsVersion}${withGbrain ? `\n gbrain: ${gbrainStatus}` : ''} ${instructions}${forceNote} `); diff --git a/tests/install.test.ts b/tests/install.test.ts index c63c6874..4f972e92 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { runInstall, _setSeaOverride, _setManifestOverride } from '../src/cli/install.js'; +import { runInstall, installGbrain, _setSeaOverride, _setManifestOverride } from '../src/cli/install.js'; vi.mock('node:os', () => ({ default: { @@ -178,3 +178,125 @@ describe('install step 8 — Beads task tracker', () => { warnSpy.mockRestore(); }); }); + +describe('installGbrain()', () => { + const mockHome = '/mock/home'; + const gbrainDir = path.join(mockHome, 'gbrain'); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(os.homedir).mockReturnValue(mockHome); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('skips with warning when bun not found', () => { + vi.mocked(execFileSync).mockImplementation((cmd: any) => { + if (cmd === 'bun') throw new Error('bun: command not found'); + return undefined as any; + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + installGbrain(); + + const warns = warnSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(warns).toContain('bun not found'); + + // git clone should not be called + const cloneCall = vi.mocked(execFileSync).mock.calls.find( + c => c[0] === 'git' && Array.isArray(c[1]) && c[1].includes('clone') + ); + expect(cloneCall).toBeUndefined(); + }); + + it('skips with "already installed" when gbrain --version succeeds', () => { + // bun --version succeeds; gbrainDir exists; gbrain --version succeeds + vi.mocked(fs.existsSync).mockImplementation((p: any) => p.toString() === gbrainDir); + vi.mocked(execFileSync).mockReturnValue('1.0.0\n' as any); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + installGbrain(); + + const logs = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(logs).toContain('already installed'); + + // git clone should not be called + const cloneCall = vi.mocked(execFileSync).mock.calls.find( + c => c[0] === 'git' && Array.isArray(c[1]) && c[1].includes('clone') + ); + expect(cloneCall).toBeUndefined(); + }); + + it('calls git clone when gbrainDir does not exist', () => { + // bun --version succeeds; gbrainDir does NOT exist + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(execFileSync).mockReturnValue(undefined as any); + + installGbrain(); + + const cloneCall = vi.mocked(execFileSync).mock.calls.find( + c => c[0] === 'git' && Array.isArray(c[1]) && c[1].includes('clone') + ); + expect(cloneCall).toBeDefined(); + expect(cloneCall![1]).toContain(gbrainDir); + }); + + it('calls bun install and bun link after cloning', () => { + // bun --version succeeds; gbrainDir does NOT exist + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(execFileSync).mockReturnValue(undefined as any); + + installGbrain(); + + const bunInstallCall = vi.mocked(execFileSync).mock.calls.find( + c => c[0] === 'bun' && Array.isArray(c[1]) && c[1][0] === 'install' + ); + expect(bunInstallCall).toBeDefined(); + + const bunLinkCall = vi.mocked(execFileSync).mock.calls.find( + c => c[0] === 'bun' && Array.isArray(c[1]) && c[1][0] === 'link' + ); + expect(bunLinkCall).toBeDefined(); + }); +}); + +describe('--with-gbrain flag parsing', () => { + it('--with-gbrain is in knownFlagExact (no unknown flag error)', async () => { + // Minimal setup to get past flag validation — we just want to confirm no process.exit(1) for unknown flag + vi.mocked(os.homedir).mockReturnValue('/mock/home'); + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (ps.includes('version.json')) return true; + if (ps.includes('hooks-config.json')) return true; + return false; + }); + vi.mocked(fs.readFileSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (ps.includes('version.json')) return JSON.stringify({ version: '0.1.0' }); + if (ps.includes('hooks-config.json')) return JSON.stringify({ hooks: { PostToolUse: [] } }); + return ''; + }); + vi.mocked(fs.readdirSync).mockReturnValue([] as any); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined as any); + vi.mocked(fs.chmodSync).mockImplementation(() => {}); + vi.mocked(fs.copyFileSync).mockImplementation(() => {}); + vi.mocked(fs.writeFileSync).mockImplementation(() => {}); + _setSeaOverride(false); + _setManifestOverride({ version: '0.1.0', hooks: {}, scripts: {}, skills: {}, fleetSkills: {} }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(execFileSync).mockReturnValue(undefined as any); + + // Should not throw or call process.exit with error + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + await runInstall(['--with-gbrain']); + // process.exit(1) should NOT have been called (unknown flag path) + const errorExits = exitSpy.mock.calls.filter(c => c[0] === 1); + expect(errorExits).toHaveLength(0); + + exitSpy.mockRestore(); + _setSeaOverride(null); + _setManifestOverride(null); + }); +});