feat(claude-bin): Bun.which wrapper for cross-platform claude resolution

Replaces 75 LOC of fork-side reimplementation (PATH parsing, Windows PATHEXT,
case-insensitive Path/PATH, X_OK) with a thin wrapper around Bun.which() — the
runtime built-in that already does all of it. New file is ~70 LOC including
the override + arg-prefix logic the runtime doesn't cover.

Override branch fixed: GSTACK_CLAUDE_BIN=wsl now resolves through Bun.which()
just like a bare claude lookup would. The McGluut fork's claude-bin.ts only
handled absolute-path overrides; bare commands silently returned null. Passing
the override value through Bun.which fixes the documented use case for free.

Five hardcoded claude spawn sites rewired through resolveClaudeCommand:
  - browse/src/security-classifier.ts:396 — version probe
  - browse/src/security-classifier.ts:496 — Haiku transcript classifier
  - scripts/preflight-agent-sdk.ts — preflight binary pinning
  - test/helpers/providers/claude.ts — LLM judge availability + run
  - test/helpers/agent-sdk-runner.ts — SDK harness binary resolver
All retain their existing degrade-on-missing semantics.

Tests: browse/test/claude-bin.test.ts has 9 unit tests including the
override-PATH-resolution case the fork's version got wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-27 23:01:31 -07:00
parent d9f17c2394
commit df9f7b69c9
6 changed files with 197 additions and 21 deletions
+2 -6
View File
@@ -35,7 +35,7 @@ import {
} from '@anthropic-ai/claude-agent-sdk';
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import { resolveClaudeBinary as resolveClaudeBinaryShared } from '../../browse/src/claude-bin';
import type { SkillTestResult } from './session-runner';
// ---------------------------------------------------------------------------
@@ -278,11 +278,7 @@ function resolveSdkVersion(): string {
}
export function resolveClaudeBinary(): string | null {
try {
return execSync('which claude', { encoding: 'utf-8' }).trim() || null;
} catch {
return null;
}
return resolveClaudeBinaryShared();
}
// ---------------------------------------------------------------------------
+13 -7
View File
@@ -1,9 +1,10 @@
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
import { estimateCostUsd } from '../pricing';
import { execFileSync, spawnSync } from 'child_process';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { resolveClaudeCommand } from '../../../browse/src/claude-bin';
/**
* Claude adapter — wraps the `claude` CLI via claude -p.
@@ -18,10 +19,11 @@ export class ClaudeAdapter implements ProviderAdapter {
readonly family = 'claude' as const;
async available(): Promise<AvailabilityCheck> {
// Binary on PATH?
const res = spawnSync('sh', ['-c', 'command -v claude'], { timeout: 2000 });
if (res.status !== 0) {
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code' };
// Binary on PATH (or GSTACK_CLAUDE_BIN override). Routes through the shared
// resolver so Windows + override paths behave the same as production sites.
const resolved = resolveClaudeCommand();
if (!resolved) {
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code (or set GSTACK_CLAUDE_BIN)' };
}
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
@@ -35,12 +37,16 @@ export class ClaudeAdapter implements ProviderAdapter {
async run(opts: RunOpts): Promise<RunResult> {
const start = Date.now();
const args = ['-p', '--output-format', 'json'];
const resolved = resolveClaudeCommand();
if (!resolved) {
throw new Error('claude CLI not resolvable (set GSTACK_CLAUDE_BIN or install)');
}
const args = [...resolved.argsPrefix, '-p', '--output-format', 'json'];
if (opts.model) args.push('--model', opts.model);
if (opts.extraArgs) args.push(...opts.extraArgs);
try {
const out = execFileSync('claude', args, {
const out = execFileSync(resolved.command, args, {
input: opts.prompt,
cwd: opts.workdir,
timeout: opts.timeoutMs,