diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 526ff82d..d672cde0 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -11,8 +11,10 @@ # "gbrain_config_exists": true|false, # "gbrain_engine": "pglite"|"postgres" | null, # "gbrain_doctor_ok": true|false, +# "gbrain_mcp_mode": "local-stdio"|"remote-http"|"none", # "gstack_brain_sync_mode": "off"|"artifacts-only"|"full", -# "gstack_brain_git": true|false +# "gstack_brain_git": true|false, +# "gstack_artifacts_remote": "https://..." | "" # } # # The /setup-gbrain skill reads this once at startup to decide which path @@ -92,6 +94,76 @@ if [ -d "$STATE_DIR/.git" ]; then gstack_brain_git=true fi +# --- gbrain_mcp_mode: local-stdio | remote-http | none --- +# Defense-in-depth fallback chain (intentional ordering, do not reorder): +# 1. `claude mcp get gbrain --json` — public CLI surface, structured output +# 2. `claude mcp list` text-grep — older claude versions without --json +# 3. `~/.claude.json` jq read — last resort if `claude` isn't on PATH +# Fallback chain logged because if Anthropic moves the file or renames keys, +# the third tier breaks silently; the first two tiers should catch it. +gbrain_mcp_mode="none" +if command -v claude >/dev/null 2>&1; then + # Tier 1: claude mcp get --json + if mcp_get_json=$(claude mcp get gbrain --json 2>/dev/null); then + if echo "$mcp_get_json" | jq -e '.' >/dev/null 2>&1; then + mtype=$(echo "$mcp_get_json" | jq -r '.type // .transport // empty' 2>/dev/null) + mcommand=$(echo "$mcp_get_json" | jq -r '.command // empty' 2>/dev/null) + murl=$(echo "$mcp_get_json" | jq -r '.url // empty' 2>/dev/null) + case "$mtype" in + http|sse) gbrain_mcp_mode="remote-http" ;; + stdio) gbrain_mcp_mode="local-stdio" ;; + *) + # Newer claude versions may emit just url + command; infer. + if [ -n "$murl" ]; then gbrain_mcp_mode="remote-http" + elif [ -n "$mcommand" ]; then gbrain_mcp_mode="local-stdio" + fi + ;; + esac + fi + fi + # Tier 2: claude mcp list text-grep (only if Tier 1 didn't resolve) + if [ "$gbrain_mcp_mode" = "none" ]; then + if mcp_list=$(claude mcp list 2>/dev/null); then + gbrain_line=$(echo "$mcp_list" | grep -E '^gbrain:' || true) + if [ -n "$gbrain_line" ]; then + if echo "$gbrain_line" | grep -q 'http\|HTTP'; then + gbrain_mcp_mode="remote-http" + else + gbrain_mcp_mode="local-stdio" + fi + fi + fi + fi +fi +# Tier 3: ~/.claude.json jq read (only if claude binary or earlier tiers failed) +if [ "$gbrain_mcp_mode" = "none" ]; then + if [ -f "$HOME/.claude.json" ]; then + # Look for a gbrain MCP server entry. Type field disambiguates http vs stdio. + mtype=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null) + murl=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null) + mcommand=$(jq -r '.mcpServers.gbrain.command // empty' "$HOME/.claude.json" 2>/dev/null) + case "$mtype" in + url|http|sse) gbrain_mcp_mode="remote-http" ;; + stdio) gbrain_mcp_mode="local-stdio" ;; + *) + if [ -n "$murl" ]; then gbrain_mcp_mode="remote-http" + elif [ -n "$mcommand" ]; then gbrain_mcp_mode="local-stdio" + fi + ;; + esac + fi +fi + +# --- artifacts remote URL (post-rename) with brain-* fallback during the +# migration window (gstack-upgrade migration runs the rename). --- +gstack_artifacts_remote="" +if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then + gstack_artifacts_remote=$(head -1 "$HOME/.gstack-artifacts-remote.txt" 2>/dev/null | tr -d '[:space:]' || true) +elif [ -f "$HOME/.gstack-brain-remote.txt" ]; then + # Pre-migration fallback. Migration v1.27.0.0 will mv this to the new path. + gstack_artifacts_remote=$(head -1 "$HOME/.gstack-brain-remote.txt" 2>/dev/null | tr -d '[:space:]' || true) +fi + # Emit single-object JSON. jq -n \ --argjson on_path "$gbrain_on_path" \ @@ -99,14 +171,18 @@ jq -n \ --argjson config_exists "$gbrain_config_exists" \ --argjson engine "$gbrain_engine" \ --argjson doctor_ok "$gbrain_doctor_ok" \ + --arg mcp_mode "$gbrain_mcp_mode" \ --arg sync_mode "$gstack_brain_sync_mode" \ --argjson brain_git "$gstack_brain_git" \ + --arg artifacts_remote "$gstack_artifacts_remote" \ '{ gbrain_on_path: $on_path, gbrain_version: $version, gbrain_config_exists: $config_exists, gbrain_engine: $engine, gbrain_doctor_ok: $doctor_ok, + gbrain_mcp_mode: $mcp_mode, gstack_brain_sync_mode: $sync_mode, - gstack_brain_git: $brain_git + gstack_brain_git: $brain_git, + gstack_artifacts_remote: $artifacts_remote }' diff --git a/test/gstack-gbrain-detect-mcp-mode.test.ts b/test/gstack-gbrain-detect-mcp-mode.test.ts new file mode 100644 index 00000000..052583d3 --- /dev/null +++ b/test/gstack-gbrain-detect-mcp-mode.test.ts @@ -0,0 +1,275 @@ +/** + * gstack-gbrain-detect — gbrain_mcp_mode + gstack_artifacts_remote tests. + * + * The script has a 3-tier fallback chain for resolving gbrain_mcp_mode: + * 1. `claude mcp get gbrain --json` (preferred — public CLI surface) + * 2. `claude mcp list` text-grep (older claude versions without --json) + * 3. `~/.claude.json` jq read (fallback if claude binary is absent) + * + * Each layer is tested by mocking the layer it depends on. Per codex + * Finding #3 (defense-in-depth ordering): if Anthropic moves the + * ~/.claude.json file format, the first two tiers should still work. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const DETECT_BIN = path.join(ROOT, 'bin', 'gstack-gbrain-detect'); + +let tmpHome: string; +let fakeBinDir: string; + +function makeFakeClaude(opts: { + hasGetJson?: boolean; + getJsonOutput?: string; // raw JSON string + hasMcpList?: boolean; + mcpListOutput?: string; + exitOnAll?: number; // if set, claude always exits with this code +}) { + const { hasGetJson, getJsonOutput, hasMcpList, mcpListOutput, exitOnAll } = opts; + const script = `#!/bin/bash +${exitOnAll !== undefined ? `exit ${exitOnAll}` : ''} +case "$1 $2" in + "mcp get") + if [ "$3" = "gbrain" ] && [ "$4" = "--json" ]; then + ${hasGetJson ? `cat <<'JSON' +${getJsonOutput || '{}'} +JSON` : 'exit 1'} + exit 0 + fi + ;; + "mcp list") + ${hasMcpList ? `cat <<'EOM' +${mcpListOutput || ''} +EOM` : 'exit 1'} + exit 0 + ;; +esac +exit 1 +`; + fs.writeFileSync(path.join(fakeBinDir, 'claude'), script, { mode: 0o755 }); +} + +function runDetect(extraEnv: Record = {}): { code: number; json: any; stderr: string } { + const realPath = process.env.PATH ?? ''; + const r = spawnSync(DETECT_BIN, [], { + env: { + // Put fakeBinDir first so our claude shim wins; include the project bin + // for any sibling scripts and standard paths for jq/etc. + PATH: `${fakeBinDir}:${path.join(ROOT, 'bin')}:${realPath}`, + HOME: tmpHome, + GSTACK_HOME: path.join(tmpHome, '.gstack'), + ...extraEnv, + }, + encoding: 'utf-8', + }); + let json: any = null; + try { + json = JSON.parse(r.stdout || '{}'); + } catch { + json = null; + } + return { code: r.status ?? -1, json, stderr: r.stderr || '' }; +} + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'detect-mcp-mode-')); + fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'detect-fake-bin-')); +}); + +afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(fakeBinDir, { recursive: true, force: true }); +}); + +describe('gbrain_mcp_mode — Tier 1: claude mcp get --json', () => { + test('type=http → remote-http', () => { + makeFakeClaude({ + hasGetJson: true, + getJsonOutput: JSON.stringify({ type: 'http', url: 'https://example.com/mcp' }), + }); + const r = runDetect(); + expect(r.code).toBe(0); + expect(r.json.gbrain_mcp_mode).toBe('remote-http'); + }); + + test('type=stdio → local-stdio', () => { + makeFakeClaude({ + hasGetJson: true, + getJsonOutput: JSON.stringify({ type: 'stdio', command: '/usr/local/bin/gbrain' }), + }); + expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio'); + }); + + test('type=sse → remote-http', () => { + makeFakeClaude({ + hasGetJson: true, + getJsonOutput: JSON.stringify({ type: 'sse', url: 'https://example.com/sse' }), + }); + expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http'); + }); + + test('no type field but has url → remote-http (newer claude shape)', () => { + makeFakeClaude({ + hasGetJson: true, + getJsonOutput: JSON.stringify({ url: 'https://example.com/mcp' }), + }); + expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http'); + }); + + test('no type field but has command → local-stdio', () => { + makeFakeClaude({ + hasGetJson: true, + getJsonOutput: JSON.stringify({ command: '/path/to/gbrain' }), + }); + expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio'); + }); +}); + +describe('gbrain_mcp_mode — Tier 2: claude mcp list text-grep', () => { + test('falls back to mcp list when get --json fails', () => { + makeFakeClaude({ + hasGetJson: false, + hasMcpList: true, + mcpListOutput: 'gbrain: https://wintermute.tail554574.ts.net:3131/mcp (HTTP) - ✓ Connected', + }); + expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http'); + }); + + test('mcp list text-grep with stdio entry → local-stdio', () => { + makeFakeClaude({ + hasGetJson: false, + hasMcpList: true, + mcpListOutput: 'gbrain: /usr/local/bin/gbrain serve - ✓ Connected', + }); + expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio'); + }); + + test('mcp list with no gbrain entry → none', () => { + makeFakeClaude({ + hasGetJson: false, + hasMcpList: true, + mcpListOutput: 'posthog: https://mcp.posthog.com/mcp (HTTP)\nslack: https://slack.com/mcp (HTTP)', + }); + expect(runDetect().json.gbrain_mcp_mode).toBe('none'); + }); +}); + +describe('gbrain_mcp_mode — Tier 3: ~/.claude.json jq read', () => { + test('reads mcpServers.gbrain.type=url → remote-http', () => { + // No fake claude binary; force fallback to file read. + fs.writeFileSync( + path.join(tmpHome, '.claude.json'), + JSON.stringify({ + mcpServers: { gbrain: { type: 'url', url: 'https://example.com/mcp' } }, + }) + ); + expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http'); + }); + + test('reads mcpServers.gbrain.type=stdio → local-stdio', () => { + fs.writeFileSync( + path.join(tmpHome, '.claude.json'), + JSON.stringify({ + mcpServers: { gbrain: { type: 'stdio', command: '/path/gbrain' } }, + }) + ); + expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio'); + }); + + test('infers from url field if type is missing', () => { + fs.writeFileSync( + path.join(tmpHome, '.claude.json'), + JSON.stringify({ + mcpServers: { gbrain: { url: 'https://example.com/mcp' } }, + }) + ); + expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http'); + }); + + test('infers from command field if type is missing', () => { + fs.writeFileSync( + path.join(tmpHome, '.claude.json'), + JSON.stringify({ + mcpServers: { gbrain: { command: '/path/gbrain' } }, + }) + ); + expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio'); + }); + + test('no gbrain entry in ~/.claude.json → none', () => { + fs.writeFileSync( + path.join(tmpHome, '.claude.json'), + JSON.stringify({ mcpServers: { posthog: { type: 'url', url: 'https://x' } } }) + ); + expect(runDetect().json.gbrain_mcp_mode).toBe('none'); + }); +}); + +describe('gbrain_mcp_mode — no info anywhere', () => { + test('no claude binary AND no ~/.claude.json → none', () => { + // No fake claude, no file. + expect(runDetect().json.gbrain_mcp_mode).toBe('none'); + }); +}); + +describe('gstack_artifacts_remote', () => { + test('reads ~/.gstack-artifacts-remote.txt when present', () => { + fs.writeFileSync( + path.join(tmpHome, '.gstack-artifacts-remote.txt'), + 'https://github.com/garrytan/gstack-artifacts-garrytan\n' + ); + expect(runDetect().json.gstack_artifacts_remote).toBe( + 'https://github.com/garrytan/gstack-artifacts-garrytan' + ); + }); + + test('migration-window fallback: reads ~/.gstack-brain-remote.txt if artifacts file is missing', () => { + fs.writeFileSync( + path.join(tmpHome, '.gstack-brain-remote.txt'), + 'git@github.com:garrytan/gstack-brain-garrytan.git\n' + ); + expect(runDetect().json.gstack_artifacts_remote).toBe( + 'git@github.com:garrytan/gstack-brain-garrytan.git' + ); + }); + + test('artifacts file wins over brain file when both exist', () => { + fs.writeFileSync( + path.join(tmpHome, '.gstack-artifacts-remote.txt'), + 'https://github.com/x/new\n' + ); + fs.writeFileSync( + path.join(tmpHome, '.gstack-brain-remote.txt'), + 'https://github.com/x/old\n' + ); + expect(runDetect().json.gstack_artifacts_remote).toBe('https://github.com/x/new'); + }); + + test('empty when neither file exists', () => { + expect(runDetect().json.gstack_artifacts_remote).toBe(''); + }); +}); + +describe('schema regression', () => { + test('output JSON has all expected keys (sync-gbrain compat)', () => { + const r = runDetect(); + expect(r.code).toBe(0); + const keys = Object.keys(r.json).sort(); + expect(keys).toEqual([ + 'gbrain_config_exists', + 'gbrain_doctor_ok', + 'gbrain_engine', + 'gbrain_mcp_mode', + 'gbrain_on_path', + 'gbrain_version', + 'gstack_artifacts_remote', + 'gstack_brain_git', + 'gstack_brain_sync_mode', + ]); + }); +});