Skip to content

Commit d27cbf7

Browse files
committed
feat: merge bun support and CLI path detection improvements
Merges feature/bun-support-claude-cli-detection branch with: - Bun runtime detection and CLI discovery - HAPPY_CLAUDE_PATH env var override - PATH fallback from tiann's PR slopus#83 (stdio suppression, existence check) - Source detection (npm, Bun, Homebrew, native installer) Detection priority: HAPPY_CLAUDE_PATH > PATH > npm > Bun > Homebrew > native Also fixes test issues: - apiSession.test.ts: vi.hoisted pattern for mock hoisting - claudeLocal.test.ts: check spawn args instead of array mutation - runtime.test.ts: ESM imports instead of require() Credit: @tiann (slopus#83)
2 parents 9b8f034 + 4252145 commit d27cbf7

File tree

5 files changed

+71
-22
lines changed

5 files changed

+71
-22
lines changed

scripts/claude_version_utils.cjs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* - macOS/Linux: curl -fsSL https://claude.ai/install.sh | bash
1010
* - PowerShell: irm https://claude.ai/install.ps1 | iex
1111
* - Windows CMD: curl -fsSL https://claude.ai/install.cmd | cmd
12+
* 4. PATH fallback: bun, pnpm, or any other package manager
1213
*/
1314

1415
const { execSync } = require('child_process');
@@ -57,13 +58,22 @@ function findClaudeInPath() {
5758
try {
5859
// Cross-platform: 'where' on Windows, 'which' on Unix
5960
const command = process.platform === 'win32' ? 'where claude' : 'which claude';
60-
const claudePath = execSync(command, { encoding: 'utf8' })
61-
.trim()
62-
.split('\n')[0]; // Take first match
61+
// stdio suppression for cleaner execution (from tiann/PR#83)
62+
const result = execSync(command, {
63+
encoding: 'utf8',
64+
stdio: ['pipe', 'pipe', 'pipe']
65+
}).trim();
6366

64-
const resolvedPath = resolvePathSafe(claudePath);
67+
const claudePath = result.split('\n')[0].trim(); // Take first match
68+
if (!claudePath) return null;
6569

66-
if (resolvedPath && fs.existsSync(resolvedPath)) {
70+
// Check existence BEFORE resolving (from tiann/PR#83)
71+
if (!fs.existsSync(claudePath)) return null;
72+
73+
// Resolve with fallback to original path (from tiann/PR#83)
74+
const resolvedPath = resolvePathSafe(claudePath) || claudePath;
75+
76+
if (resolvedPath) {
6777
// Detect source from BOTH original PATH entry and resolved path
6878
// Original path tells us HOW user accessed it (context)
6979
// Resolved path tells us WHERE it actually lives (content)
@@ -370,15 +380,22 @@ function findLatestVersionBinary(versionsDir, binaryName = null) {
370380

371381
/**
372382
* Find path to globally installed Claude Code CLI
373-
* Priority: PATH (user preference) > npm > Bun > Homebrew > Native
383+
* Priority: HAPPY_CLAUDE_PATH env var > PATH > npm > Bun > Homebrew > Native
374384
* @returns {{path: string, source: string}|null} Path and source, or null if not found
375385
*/
376386
function findGlobalClaudeCliPath() {
377-
// 1. Check PATH first (respects user's choice)
387+
// 1. Environment variable (explicit override)
388+
const envPath = process.env.HAPPY_CLAUDE_PATH;
389+
if (envPath && fs.existsSync(envPath)) {
390+
const resolved = resolvePathSafe(envPath) || envPath;
391+
return { path: resolved, source: 'HAPPY_CLAUDE_PATH' };
392+
}
393+
394+
// 2. Check PATH (respects user's shell config)
378395
const pathResult = findClaudeInPath();
379396
if (pathResult) return pathResult;
380397

381-
// 2. Fall back to package manager detection
398+
// 3. Fall back to package manager detection
382399
const npmPath = findNpmGlobalCliPath();
383400
if (npmPath) return { path: npmPath, source: 'npm' };
384401

scripts/claude_version_utils.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import * as fs from 'node:fs';
23
import {
34
findGlobalClaudeCliPath,
45
findClaudeInPath,
@@ -335,4 +336,37 @@ describe('Claude Version Utils - Cross-Platform Detection', () => {
335336
});
336337
});
337338
});
339+
});
340+
341+
describe('HAPPY_CLAUDE_PATH env var', () => {
342+
const testClaudePath = '/tmp/test-claude-path';
343+
344+
beforeEach(() => {
345+
// Create mock executable
346+
fs.writeFileSync(testClaudePath, '#!/bin/bash\necho "mock"');
347+
fs.chmodSync(testClaudePath, 0o755);
348+
});
349+
350+
afterEach(() => {
351+
if (fs.existsSync(testClaudePath)) fs.unlinkSync(testClaudePath);
352+
delete process.env.HAPPY_CLAUDE_PATH;
353+
});
354+
355+
it('should use HAPPY_CLAUDE_PATH when set', () => {
356+
process.env.HAPPY_CLAUDE_PATH = testClaudePath;
357+
const result = findGlobalClaudeCliPath();
358+
expect(result?.source).toBe('HAPPY_CLAUDE_PATH');
359+
expect(result?.path).toBe(testClaudePath);
360+
});
361+
362+
it('should fall back to auto-discovery when env var not set', () => {
363+
const result = findGlobalClaudeCliPath();
364+
expect(result?.source).not.toBe('HAPPY_CLAUDE_PATH');
365+
});
366+
367+
it('should ignore env var if path does not exist', () => {
368+
process.env.HAPPY_CLAUDE_PATH = '/nonexistent/path/claude';
369+
const result = findGlobalClaudeCliPath();
370+
expect(result?.source).not.toBe('HAPPY_CLAUDE_PATH');
371+
});
338372
});

src/api/apiSession.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { ApiSessionClient } from './apiSession';
33

4-
// Mock socket.io client before all tests
5-
const mockIo = vi.fn();
4+
// Mock socket.io client - use vi.hoisted to ensure mock is available at hoist time
5+
const { mockIo } = vi.hoisted(() => ({
6+
mockIo: vi.fn()
7+
}));
8+
69
vi.mock('socket.io-client', () => ({
710
io: mockIo
811
}));

src/claude/claudeLocal.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,9 @@ describe('claudeLocal --continue handling', () => {
169169
claudeArgs
170170
});
171171

172-
// claudeArgs should be modified to remove --continue
173-
expect(claudeArgs).not.toContain('--continue');
174-
expect(claudeArgs).toContain('--other-flag');
172+
// Verify spawn was called without --continue (it gets converted to --resume)
173+
const spawnArgs = mockSpawn.mock.calls[0][1];
174+
expect(spawnArgs).not.toContain('--continue');
175+
expect(spawnArgs).toContain('--other-flag');
175176
});
176177
});

src/utils/__tests__/runtime.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { getRuntime, isNode, isBun, isDeno } from '../runtime';
23

34
describe('Runtime Detection', () => {
45
beforeEach(() => {
@@ -8,7 +9,6 @@ describe('Runtime Detection', () => {
89
it('detects Node.js runtime correctly', () => {
910
// Test actual runtime detection
1011
if (process.versions.node && !process.versions.bun && !process.versions.deno) {
11-
const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js');
1212
expect(getRuntime()).toBe('node');
1313
expect(isNode()).toBe(true);
1414
expect(isBun()).toBe(false);
@@ -18,7 +18,6 @@ describe('Runtime Detection', () => {
1818

1919
it('detects Bun runtime correctly', () => {
2020
if (process.versions.bun) {
21-
const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js');
2221
expect(getRuntime()).toBe('bun');
2322
expect(isNode()).toBe(false);
2423
expect(isBun()).toBe(true);
@@ -28,7 +27,6 @@ describe('Runtime Detection', () => {
2827

2928
it('detects Deno runtime correctly', () => {
3029
if (process.versions.deno) {
31-
const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js');
3230
expect(getRuntime()).toBe('deno');
3331
expect(isNode()).toBe(false);
3432
expect(isBun()).toBe(false);
@@ -37,13 +35,11 @@ describe('Runtime Detection', () => {
3735
});
3836

3937
it('returns valid runtime type', () => {
40-
const { getRuntime } = require('../runtime.js');
4138
const runtime = getRuntime();
4239
expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime);
4340
});
4441

4542
it('provides consistent predicate functions', () => {
46-
const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js');
4743
const runtime = getRuntime();
4844

4945
// Only one should be true
@@ -57,13 +53,11 @@ describe('Runtime Detection', () => {
5753
});
5854

5955
it('handles edge cases gracefully', () => {
60-
const { getRuntime } = require('../runtime.js');
61-
6256
// Should not throw
6357
expect(() => getRuntime()).not.toThrow();
6458

6559
// Should return string
6660
const runtime = getRuntime();
6761
expect(typeof runtime).toBe('string');
6862
});
69-
});
63+
});

0 commit comments

Comments
 (0)