#!/usr/bin/env -S bun run /** * gstack-gbrain-detect — emit current gbrain/gstack-brain state as JSON. * * Rewritten from bash to TypeScript in v{X.Y.Z.0} to share the engine-status * classifier with bin/gstack-gbrain-sync.ts. Single source of truth via * lib/gbrain-local-status.ts. Filename and exec semantics unchanged: callers * just shell out to the file path; the bun shebang resolves at runtime. * * Output (always valid JSON, even when every check is false): * { * "gbrain_on_path": true|false, * "gbrain_version": "0.18.2" | null, * "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_artifacts_remote": "https://..." | "", * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db" * } * * Backward compatibility (per plan codex #5): the 9 pre-existing fields stay * identical in name + type + value semantics. One new field added: * gbrain_local_status. Key order may differ from the bash version's `jq -n` * output — downstream parsers must not depend on key order (none currently do). * * Env: * GSTACK_HOME — override ~/.gstack for state lookups (used by tests). * HOME — effective user home (drives ~/.gbrain/config.json path). * GSTACK_DETECT_NO_CACHE=1 — bypass the 60s local-status cache. */ import { execFileSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; import { localEngineStatus, resolveGbrainBin, readGbrainVersion, } from "../lib/gbrain-local-status"; const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack"); const SCRIPT_DIR = __dirname; const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config"); const GBRAIN_CONFIG = join(userHome(), ".gbrain", "config.json"); const CLAUDE_JSON = join(userHome(), ".claude.json"); function userHome(): string { return process.env.HOME || homedir(); } function tryExec(cmd: string, args: string[], timeoutMs = 5_000): string | null { try { return execFileSync(cmd, args, { encoding: "utf-8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return null; } } function tryReadJSON(path: string): unknown | null { if (!existsSync(path)) return null; try { return JSON.parse(readFileSync(path, "utf-8")); } catch { return null; } } // --- gbrain binary presence + version --- // Uses the shared memoized resolvers from lib/gbrain-local-status.ts so // detect and the classifier share probe results within one process. function detectGbrain(): { onPath: boolean; version: string | null } { const bin = resolveGbrainBin(); if (!bin) return { onPath: false, version: null }; const verRaw = readGbrainVersion(); if (!verRaw) return { onPath: true, version: null }; // Match bash behavior: head -1 | tr -d '[:space:]' const version = verRaw.split("\n")[0].replace(/\s+/g, "") || null; return { onPath: true, version }; } // --- gbrain config existence + engine kind --- function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null } { if (!existsSync(GBRAIN_CONFIG)) return { exists: false, engine: null }; const parsed = tryReadJSON(GBRAIN_CONFIG) as { engine?: string } | null; if (!parsed) return { exists: true, engine: null }; if (parsed.engine === "pglite" || parsed.engine === "postgres") { return { exists: true, engine: parsed.engine }; } return { exists: true, engine: null }; } // --- gbrain doctor health (any nonzero exit or non-"ok"/"warnings" status → false) --- // // Uses --fast to avoid hanging on a dead DB. Per the local-status classifier // (which probes DB directly via `gbrain sources list`), gbrain_doctor_ok is a // coarse health summary, not engine-reachability — that's gbrain_local_status. function detectDoctor(onPath: boolean): boolean { if (!onPath) return false; const out = tryExec("gbrain", ["doctor", "--json", "--fast"], 3_000); if (!out) return false; try { const parsed = JSON.parse(out) as { status?: string }; return parsed.status === "ok" || parsed.status === "warnings"; } catch { return false; } } // --- artifacts sync mode --- function detectSyncMode(): "off" | "artifacts-only" | "full" { if (!existsSync(CONFIG_BIN)) return "off"; const out = tryExec(CONFIG_BIN, ["get", "artifacts_sync_mode"], 2_000); if (out === "off" || out === "artifacts-only" || out === "full") return out; return "off"; } // --- gstack-brain git repo present? --- function detectBrainGit(): boolean { return existsSync(join(STATE_DIR, ".git")); } // --- MCP mode: local-stdio | remote-http | none --- // // Defense-in-depth fallback chain (same ordering as the bash version): // 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 function detectMcpMode(): "local-stdio" | "remote-http" | "none" { const claudeOnPath = tryExec("sh", ["-c", "command -v claude"], 1_000) !== null; if (claudeOnPath) { // Tier 1: `claude mcp get gbrain --json` const get = tryExec("claude", ["mcp", "get", "gbrain", "--json"], 3_000); if (get) { try { const parsed = JSON.parse(get) as { type?: string; transport?: string; command?: string; url?: string; }; const mtype = parsed.type || parsed.transport || ""; if (mtype === "http" || mtype === "sse") return "remote-http"; if (mtype === "stdio") return "local-stdio"; if (parsed.url) return "remote-http"; if (parsed.command) return "local-stdio"; } catch { // fall through } } // Tier 2: `claude mcp list` text-grep const list = tryExec("claude", ["mcp", "list"], 3_000); if (list) { const line = list.split("\n").find((l) => /^gbrain:/.test(l)); if (line) { if (/\b(http|HTTP)\b/.test(line)) return "remote-http"; return "local-stdio"; } } } // Tier 3: read ~/.claude.json directly const cj = tryReadJSON(CLAUDE_JSON) as | { mcpServers?: { gbrain?: { type?: string; transport?: string; command?: string; url?: string } } } | null; const entry = cj?.mcpServers?.gbrain; if (entry) { const mtype = entry.type || entry.transport || ""; if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote-http"; if (mtype === "stdio") return "local-stdio"; if (entry.url) return "remote-http"; if (entry.command) return "local-stdio"; } return "none"; } // --- artifacts remote URL with brain-* fallback during the rename migration window --- function detectArtifactsRemote(): string { const newPath = join(userHome(), ".gstack-artifacts-remote.txt"); const oldPath = join(userHome(), ".gstack-brain-remote.txt"); for (const p of [newPath, oldPath]) { if (existsSync(p)) { try { return readFileSync(p, "utf-8").split("\n")[0].trim(); } catch { // fall through } } } return ""; } function main(): void { const gbrain = detectGbrain(); const config = detectConfig(); const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1"; // Order MATCHES the bash version's jq output for callers that visually grep // (key order doesn't affect JSON parsers, but minimizes review noise). const out = { gbrain_on_path: gbrain.onPath, gbrain_version: gbrain.version, gbrain_config_exists: config.exists, gbrain_engine: config.engine, gbrain_doctor_ok: detectDoctor(gbrain.onPath), gbrain_mcp_mode: detectMcpMode(), gstack_brain_sync_mode: detectSyncMode(), gstack_brain_git: detectBrainGit(), gstack_artifacts_remote: detectArtifactsRemote(), gbrain_local_status: localEngineStatus({ noCache }), }; process.stdout.write(JSON.stringify(out, null, 2) + "\n"); } main();