mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-25 15:32:31 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/auq-auto-mode
# Conflicts: # CHANGELOG.md # VERSION # package.json
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* claude-bin.ts — Cross-platform `claude` binary resolution.
|
||||
*
|
||||
* Uses Bun.which() for the platform handling (PATH parsing, Windows PATHEXT,
|
||||
* X_OK, case-insensitive Path/PATH on Windows). Adds the gstack-specific
|
||||
* override + arg-prefix logic on top.
|
||||
*
|
||||
* Override precedence:
|
||||
* 1. GSTACK_CLAUDE_BIN (or CLAUDE_BIN as fallback) — absolute path or
|
||||
* PATH-resolvable command. `wsl` resolves through Bun.which('wsl') just
|
||||
* like a bare `claude` lookup would.
|
||||
* 2. Plain `Bun.which('claude')` if no override is set.
|
||||
*
|
||||
* Arg prefix:
|
||||
* GSTACK_CLAUDE_BIN_ARGS (or CLAUDE_BIN_ARGS) prepends arguments to every
|
||||
* spawn. Accepts a JSON array (e.g. '["claude", "--no-cache"]') or a single
|
||||
* scalar string treated as one argument. Only applied when an override is
|
||||
* active — bare `claude` resolution doesn't pick up an arg prefix.
|
||||
*
|
||||
* Returns null when nothing resolves; callers should degrade (e.g. transcript
|
||||
* classifier returns degraded:true) rather than throw.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
|
||||
export interface ClaudeCommand {
|
||||
command: string;
|
||||
argsPrefix: string[];
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
return value.replace(/^"(.*)"$/, '$1');
|
||||
}
|
||||
|
||||
function parseOverrideArgs(env: NodeJS.ProcessEnv): string[] {
|
||||
const raw = env.GSTACK_CLAUDE_BIN_ARGS ?? env.CLAUDE_BIN_ARGS;
|
||||
if (!raw?.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as a single scalar argument.
|
||||
}
|
||||
return [stripWrappingQuotes(raw.trim())];
|
||||
}
|
||||
|
||||
export function resolveClaudeCommand(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ClaudeCommand | null {
|
||||
const argsPrefix = parseOverrideArgs(env);
|
||||
const override = (env.GSTACK_CLAUDE_BIN ?? env.CLAUDE_BIN)?.trim();
|
||||
// Honor case-insensitive Path/PATH on Windows. Bun.which itself reads
|
||||
// process.env so we forward whichever the caller passed.
|
||||
const PATH = env.PATH ?? env.Path ?? '';
|
||||
|
||||
if (override) {
|
||||
const trimmed = stripWrappingQuotes(override);
|
||||
// Absolute path: use as-is. Otherwise PATH-resolve through Bun.which so
|
||||
// overrides like GSTACK_CLAUDE_BIN=wsl find the actual binary.
|
||||
const resolved = path.isAbsolute(trimmed) ? trimmed : Bun.which(trimmed, { PATH });
|
||||
return resolved ? { command: resolved, argsPrefix } : null;
|
||||
}
|
||||
|
||||
const command = Bun.which('claude', { PATH });
|
||||
return command ? { command, argsPrefix: [] } : null;
|
||||
}
|
||||
|
||||
/** Convenience wrapper for callers that only need the command path. */
|
||||
export function resolveClaudeBinary(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
return resolveClaudeCommand(env)?.command ?? null;
|
||||
}
|
||||
@@ -58,4 +58,12 @@ function main() {
|
||||
console.log(bin);
|
||||
}
|
||||
|
||||
main();
|
||||
// Only run main() when this module is the entry point. Without this guard,
|
||||
// any test that imports `locateBinary` from this file would have main() fire
|
||||
// at module-load time, calling process.exit(1) when no compiled binary
|
||||
// exists — killing the test process before any test runs. Surfaced on the
|
||||
// windows-free-tests CI lane where the runner has no compiled browse
|
||||
// binary (intentional — that lane only builds server-node.mjs).
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { THRESHOLDS, type LayerSignal } from './security';
|
||||
import { resolveClaudeCommand } from './claude-bin';
|
||||
|
||||
/**
|
||||
* Pinned Haiku model for the transcript classifier. Bumped deliberately when a
|
||||
@@ -392,8 +393,13 @@ let haikuAvailableCache: boolean | null = null;
|
||||
|
||||
function checkHaikuAvailable(): Promise<boolean> {
|
||||
if (haikuAvailableCache !== null) return Promise.resolve(haikuAvailableCache);
|
||||
const claude = resolveClaudeCommand();
|
||||
if (!claude) {
|
||||
haikuAvailableCache = false;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const p = spawn('claude', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const p = spawn(claude.command, [...claude.argsPrefix, '--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let done = false;
|
||||
const finish = (ok: boolean) => {
|
||||
if (done) return;
|
||||
@@ -493,7 +499,12 @@ export async function checkTranscript(params: {
|
||||
// timeout rate in the v1.5.2.0 ensemble bench because of this, plus
|
||||
// ~44k cache_creation tokens per call (massive cost inflation).
|
||||
// Using os.tmpdir() gives Haiku a clean context for pure classification.
|
||||
const p = spawn('claude', [
|
||||
const claude = resolveClaudeCommand();
|
||||
if (!claude) {
|
||||
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: 'claude_cli_not_found' } });
|
||||
}
|
||||
const p = spawn(claude.command, [
|
||||
...claude.argsPrefix,
|
||||
'-p', prompt,
|
||||
'--model', HAIKU_MODEL,
|
||||
'--output-format', 'json',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { resolveClaudeCommand, resolveClaudeBinary } from '../src/claude-bin';
|
||||
|
||||
// Empty env baseline — no PATH, no overrides — ensures no environmental claude binary leaks in.
|
||||
const EMPTY_ENV = { PATH: '', Path: '' } as NodeJS.ProcessEnv;
|
||||
|
||||
describe('claude-bin', () => {
|
||||
test('no override, no PATH match → returns null', () => {
|
||||
expect(resolveClaudeCommand(EMPTY_ENV)).toBeNull();
|
||||
expect(resolveClaudeBinary(EMPTY_ENV)).toBeNull();
|
||||
});
|
||||
|
||||
test('absolute-path override returned as-is', () => {
|
||||
const got = resolveClaudeCommand({
|
||||
...EMPTY_ENV,
|
||||
GSTACK_CLAUDE_BIN: '/opt/custom/claude',
|
||||
});
|
||||
expect(got).toEqual({ command: '/opt/custom/claude', argsPrefix: [] });
|
||||
});
|
||||
|
||||
test('CLAUDE_BIN works as fallback alias for GSTACK_CLAUDE_BIN', () => {
|
||||
const got = resolveClaudeCommand({
|
||||
...EMPTY_ENV,
|
||||
CLAUDE_BIN: '/opt/custom/claude',
|
||||
});
|
||||
expect(got?.command).toBe('/opt/custom/claude');
|
||||
});
|
||||
|
||||
test('GSTACK_CLAUDE_BIN takes precedence over CLAUDE_BIN', () => {
|
||||
const got = resolveClaudeCommand({
|
||||
...EMPTY_ENV,
|
||||
GSTACK_CLAUDE_BIN: '/explicit/path',
|
||||
CLAUDE_BIN: '/fallback/path',
|
||||
});
|
||||
expect(got?.command).toBe('/explicit/path');
|
||||
});
|
||||
|
||||
test('PATH-resolvable override goes through Bun.which (the bug the fork shipped)', () => {
|
||||
// Make a fake binary in a temp dir, point PATH at it, set override to bare command name.
|
||||
// Windows requires the file to have a PATHEXT-listed extension to be discoverable
|
||||
// via Bun.which — without the extension Bun.which returns undefined.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-bin-test-'));
|
||||
const isWindows = process.platform === 'win32';
|
||||
const fakeBinName = isWindows ? 'fake-claude-cli.cmd' : 'fake-claude-cli';
|
||||
const fakeBin = path.join(tmpDir, fakeBinName);
|
||||
fs.writeFileSync(fakeBin, isWindows ? '@echo fake\r\n' : '#!/bin/sh\necho fake\n');
|
||||
if (!isWindows) fs.chmodSync(fakeBin, 0o755);
|
||||
try {
|
||||
const got = resolveClaudeCommand({
|
||||
PATH: tmpDir,
|
||||
GSTACK_CLAUDE_BIN: 'fake-claude-cli',
|
||||
});
|
||||
expect(got?.command).toBe(fakeBin);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('override pointing at missing binary → null (no silent fallback to bare claude)', () => {
|
||||
const got = resolveClaudeCommand({
|
||||
...EMPTY_ENV,
|
||||
GSTACK_CLAUDE_BIN: 'definitely-not-a-real-binary-xyz',
|
||||
});
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
test('GSTACK_CLAUDE_BIN_ARGS as JSON array → parsed argsPrefix', () => {
|
||||
const got = resolveClaudeCommand({
|
||||
...EMPTY_ENV,
|
||||
GSTACK_CLAUDE_BIN: '/opt/custom/claude',
|
||||
GSTACK_CLAUDE_BIN_ARGS: '["--no-cache", "--verbose"]',
|
||||
});
|
||||
expect(got?.argsPrefix).toEqual(['--no-cache', '--verbose']);
|
||||
});
|
||||
|
||||
test('GSTACK_CLAUDE_BIN_ARGS as scalar string → treated as single argument', () => {
|
||||
const got = resolveClaudeCommand({
|
||||
...EMPTY_ENV,
|
||||
GSTACK_CLAUDE_BIN: '/opt/custom/claude',
|
||||
GSTACK_CLAUDE_BIN_ARGS: 'claude',
|
||||
});
|
||||
expect(got?.argsPrefix).toEqual(['claude']);
|
||||
});
|
||||
|
||||
test('argsPrefix empty when no override args set', () => {
|
||||
const got = resolveClaudeCommand({
|
||||
...EMPTY_ENV,
|
||||
GSTACK_CLAUDE_BIN: '/opt/custom/claude',
|
||||
});
|
||||
expect(got?.argsPrefix).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user