test: final coverage pass — CLI rendering, revert traps, keychain probe, gbrain doc ops

The user-directed third generation pass closes the audit's remaining
tail: the code-intelligence CLI's options/status/suggest surfaces get
behavioral coverage through the fake-shim chain; brain-context-load
gains an argv-logging trap that goes red if anyone reverts the memoized
PATH scan back to the spawn probe (receipt: simulated revert failed
exactly these tests); the darwin Keychain auth branch (#1890) gets its
first free-tier tests via a PATH-shimmed security binary; and the gbrain
add/delete/export ops are pinned (body piped byte-for-byte, receipt
sha256, stdin-EOF prompt guard, PROVIDER_UNAVAILABLE degradation) —
retiring their TODOS entry.
This commit is contained in:
Garry Tan 2026-08-15 07:36:58 -07:00
parent dd29d80be0
commit 19cb4062c2
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
4 changed files with 493 additions and 13 deletions

View File

@ -84,18 +84,6 @@ debug of what the child's preamble actually echoes.
**Why:** every red periodic run costs triage time; two of these have burned
three triage passes across two releases. **Effort:** M. **Priority:** P2.
### P3: gbrain-adapter add/delete/export behavioral coverage
**What:** Extend test/code-intelligence.test.ts's fake-gbrain shim to pin the
three untested capability-advertised ops: add() pipes the body to `put <slug>`
and writes a receipt with the body's sha256, delete() survives the stdin-EOF
prompt guard, export() returns stdout, and each degrades to
PROVIDER_UNAVAILABLE when the CLI is absent.
**Why:** Shipped public ops with zero exercise; the harness to test them
cheaply already exists. **Effort:** S. **Priority:** P3.
### P1: #1882 — portable skill-install prefix (non-`gstack` install dirs break silently)
**What:** Every generated SKILL.md hardcodes the literal `~/.claude/skills/gstack/...`

View File

@ -0,0 +1,151 @@
/**
* Free unit tests for the ClaudeAdapter auth sniff in
* test/helpers/providers/claude.ts specifically the macOS Keychain branch
* (#1890): the default subscription install stores OAuth under the
* generic-password service "Claude Code-credentials" and never writes
* ~/.claude/.credentials.json, so availability must consult
* `security find-generic-password -s "Claude Code-credentials"` when neither
* the creds file nor ANTHROPIC_API_KEY exists.
*
* The `security` spawn is darwin-gated in the adapter (the probe only runs
* when process.platform === 'darwin'), so the keychain-branch tests skip
* elsewhere; the creds-file / env-key / no-auth verdicts run on every
* platform.
*
* Harness note: available() spawns `security` without an explicit `env:`, and
* Bun resolves such spawns against the PROCESS STARTUP env mutating
* process.env.PATH in-test cannot shadow the real /usr/bin/security (verified;
* same Bun property lib/gbrain-exec.ts documents for DATABASE_URL). So each
* case runs available() in a child `bun -e` whose env (and PATH shim) the test
* fully controls; the fake `security` logs its argv, so the operator's real
* Keychain is never consulted and a logged-in machine can't false-pass the
* "no auth" cases. cwd is the fake home so Bun doesn't autoload the repo .env
* (which defines ANTHROPIC_API_KEY).
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { spawnSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
const ADAPTER = path.join(import.meta.dir, "helpers", "providers", "claude.ts");
describe("ClaudeAdapter.available() — auth sniff incl. macOS Keychain branch (#1890)", () => {
let fakeHome: string;
let shimDir: string;
let securityLog: string;
beforeEach(() => {
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-home-"));
shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-bin-"));
securityLog = path.join(shimDir, "security-argv.log");
});
afterEach(() => {
fs.rmSync(fakeHome, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
});
/** Fake `security` that logs its argv and exits with `exitCode`. */
function writeFakeSecurity(exitCode: number): void {
fs.writeFileSync(
path.join(shimDir, "security"),
`#!/bin/sh
printf '%s\\n' "$*" >> "${securityLog}"
exit ${exitCode}
`,
{ mode: 0o755 },
);
}
/** Run `new ClaudeAdapter().available()` in a child bun with a controlled env. */
function runAvailable(opts: { anthropicKey?: string; bareShimPath?: boolean } = {}): { ok: boolean; reason?: string } {
const env: Record<string, string | undefined> = {
...process.env,
// Shim dir first so a fake `security` shadows /usr/bin/security; the
// bare variant drops the inherited PATH entirely (no `claude` findable).
PATH: opts.bareShimPath ? shimDir : `${shimDir}${path.delimiter}${process.env.PATH ?? ""}`,
// os.homedir() honors $HOME (POSIX) / %USERPROFILE% (Windows), so the
// adapter's ~/.claude/.credentials.json check reads the fake home.
HOME: fakeHome,
USERPROFILE: fakeHome,
// Absolute-path override satisfies resolveClaudeCommand() without a real
// claude install — available() never spawns it, only resolves it. The
// bare-PATH case clears it to exercise the not-found branch.
GSTACK_CLAUDE_BIN: opts.bareShimPath ? undefined : path.join(shimDir, "claude"),
CLAUDE_BIN: undefined,
GSTACK_CLAUDE_BIN_ARGS: undefined,
CLAUDE_BIN_ARGS: undefined,
ANTHROPIC_API_KEY: opts.anthropicKey,
};
for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k];
const driver = `const { ClaudeAdapter } = await import(${JSON.stringify(ADAPTER)});
const check = await new ClaudeAdapter().available();
console.log(JSON.stringify(check));`;
// process.execPath (absolute bun binary): the bare-PATH case strips the
// inherited PATH, so a name-based "bun" lookup would ENOENT.
const res = spawnSync(process.execPath, ["-e", driver], {
encoding: "utf-8",
timeout: 30_000,
cwd: fakeHome, // no .env here — the repo root's would inject ANTHROPIC_API_KEY
env: env as Record<string, string>,
});
if (res.status !== 0) throw new Error(`driver failed (${res.status}): ${res.stderr}`);
return JSON.parse(res.stdout.trim()) as { ok: boolean; reason?: string };
}
test("creds file present → available, and the Keychain is never consulted", () => {
fs.mkdirSync(path.join(fakeHome, ".claude"), { recursive: true });
fs.writeFileSync(path.join(fakeHome, ".claude", ".credentials.json"), "{}");
// A fake security that would report NOT FOUND — if the probe ran anyway,
// ok would still be true, so the no-spawn pin is the argv log staying empty.
writeFakeSecurity(1);
const check = runAvailable();
expect(check.ok).toBe(true);
expect(fs.existsSync(securityLog)).toBe(false);
});
test("ANTHROPIC_API_KEY set → available without creds file or Keychain probe", () => {
writeFakeSecurity(1);
const check = runAvailable({ anthropicKey: "sk-ant-test-not-a-real-key" });
expect(check.ok).toBe(true);
expect(fs.existsSync(securityLog)).toBe(false);
});
test("darwin: Keychain hit (security exit 0) → available, probed with the exact service name", () => {
if (process.platform !== "darwin") return; // keychain branch is darwin-gated in the adapter
writeFakeSecurity(0);
const check = runAvailable();
expect(check.ok).toBe(true);
// Exactly one metadata-only probe, against the service subscription installs use.
const argv = fs.readFileSync(securityLog, "utf-8").trim().split("\n");
expect(argv).toEqual(["find-generic-password -s Claude Code-credentials"]);
expect(argv[0]).not.toContain("-w"); // never reads the secret itself
});
test("darwin: Keychain miss (security exit 1) → not available with the no-auth reason", () => {
if (process.platform !== "darwin") return;
writeFakeSecurity(1);
const check = runAvailable();
expect(check.ok).toBe(false);
expect(check.reason).toContain("No Claude auth found");
// The probe DID run (this is the miss path, not a skipped probe).
expect(fs.readFileSync(securityLog, "utf-8")).toContain("find-generic-password");
});
test("non-darwin: no creds file + no env key → not available (no Keychain to consult)", () => {
if (process.platform === "darwin") return; // darwin covered by the shimmed miss case above
const check = runAvailable();
expect(check.ok).toBe(false);
expect(check.reason).toContain("No Claude auth found");
});
test("claude CLI unresolvable → not-found reason, before any auth sniff", () => {
writeFakeSecurity(0); // even a keychain HIT can't rescue a missing binary
const check = runAvailable({ bareShimPath: true });
expect(check.ok).toBe(false);
expect(check.reason).toContain("claude CLI not found on PATH");
expect(fs.existsSync(securityLog)).toBe(false); // returned before the auth sniff
});
});

View File

@ -33,6 +33,7 @@ import {
RECOMMENDED_ORDER,
shouldOfferIndexing,
trackedFileCount,
LARGE_REPO_FILE_THRESHOLD,
} from "../lib/code-intelligence";
describe("capability matrix", () => {
@ -807,6 +808,265 @@ describe("consent CLI requires an explicit yes|no (never defaults to granted)",
});
});
// ── GBrain document ops (add/delete/export) — behavioral pins over the same
// fake-gbrain shim harness. add() must pipe the body to `put <slug>` on stdin
// and receipt the body's sha256 BEFORE the send; delete() must survive a
// confirmation prompt via the stdin-EOF guard; export() must return the CLI's
// stdout verbatim; and each must degrade to PROVIDER_UNAVAILABLE (not a hard
// error) when the CLI is absent.
describe("GBrain document ops (add/delete/export) via fake gbrain shim", () => {
let binDir: string;
let home: string;
let argvLog: string;
let stdinLog: string;
function env(): NodeJS.ProcessEnv {
return { PATH: `${binDir}:${process.env.PATH}`, HOME: home, GSTACK_HOME: home };
}
function ledgerLines(): Array<Record<string, unknown>> {
const p = path.join(home, "security", "egress.jsonl");
if (!fs.existsSync(p)) return [];
return fs
.readFileSync(p, "utf-8")
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l) as Record<string, unknown>);
}
beforeEach(() => {
binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-docops-bin-"));
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-docops-home-"));
argvLog = path.join(home, "gbrain-argv.log");
stdinLog = path.join(home, "gbrain-stdin.log");
// Shim logs every argv (so "refused BEFORE the subprocess" is provable),
// captures put's stdin byte-for-byte, and blocks delete on a confirmation
// read the way a real prompt would — the adapter's stdin-EOF guard is the
// only thing keeping that from hanging until the op timeout.
fs.writeFileSync(
path.join(binDir, "gbrain"),
`#!/usr/bin/env bash
printf '%s\\n' "$*" >> "${argvLog}"
case "$1" in
put) cat > "${stdinLog}"; echo "stored $2"; exit 0;;
delete) read -r _confirm; echo "deleted $2"; exit 0;;
export) printf 'line-one\\nline-two\\n'; exit 0;;
esac
exit 1
`,
{ mode: 0o755 },
);
});
afterEach(() => {
fs.rmSync(binDir, { recursive: true, force: true });
fs.rmSync(home, { recursive: true, force: true });
});
test("add() pipes the document body to `put <slug>` on stdin and receipts the body's sha256", async () => {
const body = "# curated memory\nthe exact body bytes\n";
const s = await new GbrainProvider().add({ slug: "my-doc", body }, { env: env(), consented: true });
expect(s).toEqual({ id: "my-doc", state: "ready" });
expect(fs.readFileSync(argvLog, "utf-8").trim()).toBe("put my-doc");
// The body reached gbrain via stdin, byte-for-byte (not argv, not a temp file).
expect(fs.readFileSync(stdinLog, "utf-8")).toBe(body);
const lines = ledgerLines();
expect(lines.length).toBe(1);
expect(lines[0].sink).toBe("gbrain");
expect(String(lines[0].payload_class)).toContain("document-body");
expect(String(lines[0].consent)).toContain("consented=true");
expect(lines[0].sha256).toBe(sha256Hex(body));
expect(lines[0].bytes).toBe(Buffer.byteLength(body));
});
test("add() asserts consent: unconsented add is refused BEFORE the subprocess runs, no receipt", async () => {
await expect(new GbrainProvider().add({ slug: "s", body: "secret body" }, { env: env() })).rejects.toMatchObject({
code: "PROVIDER_NOT_CONSENTED",
});
expect(fs.existsSync(argvLog)).toBe(false); // the body never left the machine
expect(ledgerLines()).toEqual([]);
});
test("delete() survives a confirmation prompt via the stdin-EOF guard (never hangs)", async () => {
// Shim blocks on `read` — with the adapter's input:"" the read gets EOF
// instantly. A regression to inherited/open stdin fails here at the 5s
// op timeout (PROVIDER_TIMEOUT) instead of returning state=absent.
const s = await new GbrainProvider().delete("stale-doc", { env: env(), timeout: 5_000 });
expect(s).toEqual({ id: "stale-doc", state: "absent" });
expect(fs.readFileSync(argvLog, "utf-8").trim()).toBe("delete stale-doc");
});
test("export() returns the CLI's stdout verbatim (multi-line, unparsed)", async () => {
const body = await new GbrainProvider().export({ id: "code" }, { env: env(), consented: true });
expect(body).toBe("line-one\nline-two\n");
expect(fs.readFileSync(argvLog, "utf-8").trim()).toBe("export");
});
test("add/delete/export each degrade to PROVIDER_UNAVAILABLE when the CLI is absent", async () => {
const missing = { PATH: os.tmpdir(), HOME: home, GSTACK_HOME: home };
const g = new GbrainProvider();
await expect(g.add({ slug: "s", body: "b" }, { env: missing, consented: true })).rejects.toMatchObject({
code: "PROVIDER_UNAVAILABLE",
});
await expect(g.delete("s", { env: missing })).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
await expect(g.export({ id: "code" }, { env: missing, consented: true })).rejects.toMatchObject({
code: "PROVIDER_UNAVAILABLE",
});
});
});
// ── CLI rendering surfaces: options / status / suggest --json ───────────────
// The human/agent-facing renderers had zero reach: pin that `options` lists
// the three providers with availability rows (GBrain first), `status` renders
// the persisted selection + per-provider availability, and `suggest --json`
// emits the machine-readable offer/reason contract.
describe("CLI rendering (options / status / suggest --json)", () => {
const CLI = path.join(import.meta.dir, "..", "bin", "gstack-code-intelligence");
let home: string;
let shimDir: string;
let env: NodeJS.ProcessEnv;
function runCli(...args: string[]) {
return spawnSync("bun", [CLI, ...args], {
encoding: "utf-8",
timeout: 30_000,
env: env as Record<string, string>,
});
}
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-render-"));
shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-render-bin-"));
// Fake gbrain satisfies the FULL localEngineStatus probe chain (--version
// resolution + `sources list --json` liveness) so the GBrain row is
// deterministically available; config.json lives in the temp GBRAIN_HOME
// so the operator's real ~/.gbrain is never read.
fs.writeFileSync(
path.join(shimDir, "gbrain"),
`#!/usr/bin/env bash
if [ "$1" = "--version" ]; then echo "gbrain 0.42.0"; exit 0; fi
if [ "$1" = "sources" ]; then echo '{"sources":[]}'; exit 0; fi
exit 1
`,
{ mode: 0o755 },
);
const gbrainHome = path.join(home, ".gbrain");
fs.mkdirSync(gbrainHome, { recursive: true });
fs.writeFileSync(path.join(gbrainHome, "config.json"), JSON.stringify({ engine: "pglite" }));
env = {
...process.env,
GSTACK_HOME: home,
HOME: home,
GBRAIN_HOME: gbrainHome,
PATH: `${shimDir}:${process.env.PATH}`,
// Dead loopback port → the Sourcebot probe fails fast + deterministically
// (connection refused) instead of poking whatever operator dev server
// happens to sit on localhost:3000.
SOURCEBOT_URL: "http://127.0.0.1:1",
};
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
});
function makeRepoWithFiles(count: number): string {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-suggest-"));
Bun.spawnSync(["git", "init", "-q", repo]);
for (let i = 0; i < count; i++) fs.writeFileSync(path.join(repo, `f${i}.ts`), "x\n");
Bun.spawnSync(["git", "-C", repo, "add", "-A"]);
return repo;
}
test("options lists the three providers with availability rows, GBrain first", () => {
const res = runCli("options");
expect(res.status).toBe(0);
expect(res.stdout).toContain("Code-intelligence providers");
// GBrain row: recommended marker + deterministically available via the shim.
expect(res.stdout).toMatch(/\* GBrain\s+\[available\]/);
expect(res.stdout).toContain("gbrain engine: ok");
// Sourcebot row: dead loopback port → not available, probe detail rendered.
expect(res.stdout).toMatch(/Sourcebot\s+\[not available\]/);
expect(res.stdout).toContain("unreachable at http://127.0.0.1:1");
// Graphify row renders an availability mark either way (the operator
// machine may or may not have graphify installed).
expect(res.stdout).toMatch(/Graphify\s+\[(available|not available)\]/);
// Rows come out in recommendation order (GBrain → Sourcebot → Graphify).
const at = ["GBrain", "Sourcebot", "Graphify"].map((s) => res.stdout.indexOf(s));
expect(at[0]).toBeGreaterThan(-1);
expect(at[0]).toBeLessThan(at[1]);
expect(at[1]).toBeLessThan(at[2]);
expect(res.stdout).toContain("select <provider>");
});
test("status renders the provider-OFF selection and per-provider availability", () => {
const res = runCli("status");
expect(res.status).toBe(0);
expect(res.stdout).toContain("selected: none (grep / file-only fallback)");
expect(res.stdout).toContain("GBrain: available (gbrain engine: ok)");
expect(res.stdout).toContain("Sourcebot: unavailable (unreachable at http://127.0.0.1:1)");
expect(res.stdout).toMatch(/Graphify: (available|unavailable)/);
});
test("status renders a persisted selection", () => {
setProvider("gbrain", env);
const res = runCli("status");
expect(res.status).toBe(0);
expect(res.stdout).toContain("selected: gbrain");
});
test("suggest --json: small repo → machine-readable offer:false / reason:small-repo", () => {
const repo = makeRepoWithFiles(3);
try {
const res = runCli("suggest", repo, "--json");
expect(res.status).toBe(0);
const parsed = JSON.parse(res.stdout) as Record<string, unknown>;
expect(parsed).toEqual({
offer: false,
reason: "small-repo",
fileCount: 3,
threshold: LARGE_REPO_FILE_THRESHOLD,
repoPath: repo,
});
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
test("suggest --json: large repo → offer:true with self-contained provider options", () => {
const repo = makeRepoWithFiles(LARGE_REPO_FILE_THRESHOLD);
try {
const res = runCli("suggest", repo, "--json");
expect(res.status).toBe(0);
const parsed = JSON.parse(res.stdout) as {
offer: boolean;
reason: string;
fileCount: number;
options: Array<{ id: string; label: string; reason: string; local: boolean; available: boolean; detail: string }>;
};
expect(parsed.offer).toBe(true);
expect(parsed.reason).toBe("large-repo");
expect(parsed.fileCount).toBe(LARGE_REPO_FILE_THRESHOLD);
// The offer is self-contained: every provider option carries the fields
// an agent needs to render the question without a second CLI call.
expect(parsed.options.map((o) => o.id)).toEqual(["gbrain", "sourcebot", "graphify"]);
for (const o of parsed.options) {
expect(typeof o.label).toBe("string");
expect(typeof o.reason).toBe("string");
expect(typeof o.local).toBe("boolean");
expect(typeof o.available).toBe("boolean");
expect(typeof o.detail).toBe("string");
}
const gbrain = parsed.options[0];
expect(gbrain.label).toBe("GBrain");
expect(gbrain.local).toBe(false);
expect(gbrain.available).toBe(true);
expect(gbrain.detail).toContain("gbrain engine:");
const sourcebot = parsed.options[1];
expect(sourcebot.local).toBe(true); // loopback SOURCEBOT_URL
expect(sourcebot.available).toBe(false);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
});
// ── picker probes: concurrent, short-timeout, never a 30s CLI stall ─────────
describe("detectAvailable probes fast even when Sourcebot is a dead non-loopback host", () => {
test("rows come back in recommendation order, well under the old 30s stall", async () => {

View File

@ -7,7 +7,7 @@
*/
import { describe, it, expect } from "bun:test";
import { chmodSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { chmodSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { tmpdir } from "os";
import { delimiter, join } from "path";
import { spawnSync } from "child_process";
@ -52,6 +52,38 @@ fi
chmodSync(fakeBin, 0o755);
}
/**
* Like writeFakeGbrain, but every invocation appends its argv to `logFile`.
* Still answers `--version` successfully ON PURPOSE: a revert from the
* memoized PATH stat scan back to the old `gbrain --version` spawn probe
* would pass every non-logging test only the argv log catches it.
*/
function writeLoggingGbrain(binDir: string, logFile: string): void {
if (process.platform === "win32") {
writeFileSync(
join(binDir, "gbrain.cmd"),
`@echo off\r\necho %* >> "${logFile}"\r\nif "%1"=="--version" (\r\n echo gbrain 0.test\r\n) else (\r\n echo fake gbrain %*\r\n)\r\n`,
"utf-8",
);
return;
}
const fakeBin = join(binDir, "gbrain");
writeFileSync(
fakeBin,
`#!/bin/sh
printf '%s\\n' "$*" >> "${logFile}"
if [ "$1" = "--version" ]; then
echo "gbrain 0.test"
else
echo "fake gbrain $*"
fi
`,
"utf-8",
);
chmodSync(fakeBin, 0o755);
}
function prependPath(binDir: string): Record<string, string> {
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH";
const currentPath = process.env[pathKey] || "";
@ -295,6 +327,55 @@ gbrain:
}
});
it("gbrain detection never spawns gbrain — stat-based PATH scan, not a `--version` probe (revert trap)", () => {
// The fix replaced a per-query `gbrain --version` spawn probe with a
// memoized PATH stat scan. The plain writeFakeGbrain shim still ANSWERS
// --version, so a revert to the spawn probe passes every other test in
// this file. This fake logs its argv: detection must invoke gbrain zero
// times, so the only invocations are the 3 default-manifest list_pages
// queries — a revert adds `--version` lines (and re-probing adds one per
// query) and fails exactly here.
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
const binDir = join(dir, "bin");
mkdirSync(binDir);
const logFile = join(dir, "gbrain-argv.log");
writeLoggingGbrain(binDir, logFile);
try {
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], prependPath(binDir));
expect(r.exitCode).toBe(0);
expect(r.stderr).toContain("queries=3");
const invocations = readFileSync(logFile, "utf-8").split("\n").filter(Boolean);
expect(invocations.some((argv) => argv.includes("--version"))).toBe(false);
// Exactly the 3 real queries — no extra availability spawns of any shape.
expect(invocations).toHaveLength(3);
for (const argv of invocations) expect(argv.startsWith("list_pages")).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("availability survives a 1ms query budget — detection is not subject to GSTACK_BRAIN_TIMEOUT_MS", () => {
// The spawn probe ran under the same MCP_TIMEOUT_MS budget as the queries,
// so a cold spawn slower than the budget misreported gbrain as MISSING.
// With the stat scan, a 1ms budget kills the queries themselves (SKIP)
// but detection still sees the CLI — "gbrain CLI missing" must not appear.
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
const binDir = join(dir, "bin");
mkdirSync(binDir);
writeFakeGbrain(binDir);
try {
const env = { ...prependPath(binDir), GSTACK_BRAIN_TIMEOUT_MS: "1" };
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], env);
expect(r.exitCode).toBe(0);
expect(r.stderr).toContain("SKIP");
expect(r.stderr).not.toContain("gbrain CLI missing");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("vector + list queries still complete (with SKIP) when gbrain CLI is missing", () => {
// We can't easily un-install gbrain; rely on the helper's own missing-binary
// detection. The default manifest uses kind: list which calls gbrain. If