refactor: Electron install wizard, connection status, and platform detection
- Rewrite install-check-prerequisites with multi-path Claude CLI detection (native, bun, homebrew, npm) and structured result reporting - Improve InstallWizard with step-by-step progress and better error handling - Enhance ConnectionStatus with detailed status display and invalidation - Add /api/claude-status/invalidate route for cache-busting - Expand platform.ts with richer detection utilities - Add onInitMeta handler in stream-session-manager - Update electron.d.ts types for new IPC channels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a0451329b
commit
857e9fc413
9 files changed
+683
-300
No files matched your search
+244
-171
@@ -582,58 +582,166 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('install:check-prerequisites', async () => {
|
||||
const expandedPath = getExpandedShellPath();
|
||||
const execEnv = { ...sanitizedProcessEnv(), ...userShellEnv, PATH: expandedPath };
|
||||
const execOpts = { timeout: 5000, encoding: 'utf-8' as const, env: execEnv };
|
||||
|
||||
let hasNode = false;
|
||||
let nodeVersion: string | undefined;
|
||||
// Candidate paths — native first, then bun, then homebrew, then npm
|
||||
const home = os.homedir();
|
||||
const candidatePaths = process.platform === 'win32'
|
||||
? [
|
||||
path.join(home, '.local', 'bin', 'claude.exe'),
|
||||
path.join(home, '.local', 'bin', 'claude.cmd'),
|
||||
path.join(home, '.claude', 'bin', 'claude.exe'),
|
||||
path.join(home, '.claude', 'bin', 'claude.cmd'),
|
||||
path.join(home, '.bun', 'bin', 'claude.exe'),
|
||||
path.join(home, '.bun', 'bin', 'claude.cmd'),
|
||||
path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
|
||||
path.join(process.env.LOCALAPPDATA || '', 'npm', 'claude.cmd'),
|
||||
].filter(p => p && !p.startsWith(path.sep))
|
||||
: [
|
||||
path.join(home, '.local', 'bin', 'claude'),
|
||||
path.join(home, '.claude', 'bin', 'claude'),
|
||||
path.join(home, '.bun', 'bin', 'claude'),
|
||||
'/opt/homebrew/bin/claude',
|
||||
'/usr/local/bin/claude',
|
||||
path.join(home, '.npm-global', 'bin', 'claude'),
|
||||
];
|
||||
|
||||
function classifyPath(p: string): 'native' | 'homebrew' | 'npm' | 'bun' | 'unknown' {
|
||||
const n = p.replace(/\\/g, '/');
|
||||
if (n.includes('/.local/bin/') || n.includes('/.claude/bin/')) return 'native';
|
||||
if (n.includes('/.bun/bin/') || n.includes('/.bun/install/')) return 'bun';
|
||||
if (n.includes('/homebrew/') || n.includes('/Cellar/')) return 'homebrew';
|
||||
if (n.includes('/npm')) return 'npm';
|
||||
if (n === '/usr/local/bin/claude') {
|
||||
try {
|
||||
const real = fs.realpathSync(p);
|
||||
if (real.includes('node_modules')) return 'npm';
|
||||
if (real.includes('homebrew') || real.includes('Cellar')) return 'homebrew';
|
||||
if (real.includes('.bun')) return 'bun';
|
||||
} catch { /* ignore */ }
|
||||
return 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
interface Detection { path: string; version: string | null; type: string }
|
||||
const allInstalls: Detection[] = [];
|
||||
const seenReal = new Set<string>();
|
||||
|
||||
for (const p of candidatePaths) {
|
||||
try {
|
||||
let realPath: string;
|
||||
try { realPath = fs.realpathSync(p); } catch { realPath = p; }
|
||||
if (seenReal.has(realPath)) continue;
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
const shell = isWin && /\.(cmd|bat)$/i.test(p);
|
||||
const result = execFileSync(p, ['--version'], {
|
||||
timeout: 5000, encoding: 'utf-8', env: execEnv, shell, stdio: 'pipe',
|
||||
});
|
||||
seenReal.add(realPath);
|
||||
allInstalls.push({ path: p, version: result.trim() || null, type: classifyPath(p) });
|
||||
} catch {
|
||||
// not at this path
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan PATH via which/where to catch bun, custom, or other non-standard installs
|
||||
try {
|
||||
const result = execFileSync('node', ['--version'], execOpts);
|
||||
nodeVersion = result.trim();
|
||||
hasNode = true;
|
||||
const isWinPlatform = process.platform === 'win32';
|
||||
const cmd = isWinPlatform ? 'where' : '/usr/bin/which';
|
||||
const args = isWinPlatform ? ['claude'] : ['-a', 'claude']; // -a = show ALL matches
|
||||
const whichResult = execFileSync(cmd, args, {
|
||||
timeout: 3000, encoding: 'utf-8', env: execEnv,
|
||||
shell: isWinPlatform, stdio: 'pipe',
|
||||
});
|
||||
for (const line of whichResult.trim().split(/\r?\n/)) {
|
||||
const candidate = line.trim();
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
let realPath: string;
|
||||
try { realPath = fs.realpathSync(candidate); } catch { realPath = candidate; }
|
||||
if (seenReal.has(realPath)) continue;
|
||||
|
||||
const shell = isWinPlatform && /\.(cmd|bat)$/i.test(candidate);
|
||||
const result = execFileSync(candidate, ['--version'], {
|
||||
timeout: 5000, encoding: 'utf-8', env: execEnv, shell, stdio: 'pipe',
|
||||
});
|
||||
seenReal.add(realPath);
|
||||
allInstalls.push({ path: candidate, version: result.trim() || null, type: classifyPath(candidate) });
|
||||
} catch {
|
||||
// invalid binary at this path
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// node not found
|
||||
// which/where failed
|
||||
}
|
||||
|
||||
let hasClaude = false;
|
||||
let claudeVersion: string | undefined;
|
||||
try {
|
||||
const claudeOpts = process.platform === 'win32'
|
||||
? { ...execOpts, shell: true }
|
||||
: execOpts;
|
||||
const result = execFileSync('claude', ['--version'], claudeOpts);
|
||||
claudeVersion = result.trim();
|
||||
hasClaude = true;
|
||||
} catch {
|
||||
// claude not found
|
||||
const primary = allInstalls[0];
|
||||
const hasClaude = !!primary;
|
||||
|
||||
// On Windows, check for Git Bash (bash.exe) — this is what the SDK actually uses at runtime.
|
||||
// Must match the detection strategy in platform.ts:findGitBash() to avoid false negatives.
|
||||
let hasGit = true; // default true for non-Windows
|
||||
if (process.platform === 'win32') {
|
||||
hasGit = false;
|
||||
// 1. User-specified env var
|
||||
const envBash = process.env.CLAUDE_CODE_GIT_BASH_PATH || userShellEnv.CLAUDE_CODE_GIT_BASH_PATH;
|
||||
if (envBash && fs.existsSync(envBash)) {
|
||||
hasGit = true;
|
||||
}
|
||||
// 2. Common installation paths
|
||||
if (!hasGit) {
|
||||
const commonPaths = [
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
||||
];
|
||||
if (commonPaths.some(p => fs.existsSync(p))) {
|
||||
hasGit = true;
|
||||
}
|
||||
}
|
||||
// 3. Derive from `where git`
|
||||
if (!hasGit) {
|
||||
try {
|
||||
const whereResult = execFileSync('where', ['git'], {
|
||||
timeout: 3000, encoding: 'utf-8', shell: true, stdio: 'pipe',
|
||||
});
|
||||
for (const line of whereResult.trim().split(/\r?\n/)) {
|
||||
const gitExe = line.trim();
|
||||
if (!gitExe) continue;
|
||||
const gitDir = path.dirname(path.dirname(gitExe));
|
||||
const bashPath = path.join(gitDir, 'bin', 'bash.exe');
|
||||
if (fs.existsSync(bashPath)) {
|
||||
hasGit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// where git failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check Homebrew on macOS
|
||||
let hasHomebrew = false;
|
||||
if (process.platform === 'darwin') {
|
||||
const brewPaths = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
|
||||
hasHomebrew = brewPaths.some(p => fs.existsSync(p));
|
||||
}
|
||||
|
||||
return { hasNode, nodeVersion, hasClaude, claudeVersion, hasHomebrew, platform: process.platform };
|
||||
return {
|
||||
hasClaude,
|
||||
claudeVersion: primary?.version,
|
||||
claudePath: primary?.path,
|
||||
claudeInstallType: primary?.type,
|
||||
otherInstalls: allInstalls.slice(1),
|
||||
hasGit,
|
||||
platform: process.platform,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('install:start', (_event: Electron.IpcMainInvokeEvent, options?: { includeNode?: boolean }) => {
|
||||
ipcMain.handle('install:start', () => {
|
||||
if (installState.status === 'running') {
|
||||
throw new Error('Installation is already running');
|
||||
}
|
||||
|
||||
const needsNode = options?.includeNode === true;
|
||||
|
||||
// Reset state
|
||||
const steps: InstallStep[] = [];
|
||||
if (needsNode) {
|
||||
steps.push({ id: 'install-node', label: 'Installing Node.js', status: 'pending' });
|
||||
}
|
||||
steps.push(
|
||||
{ id: 'check-node', label: 'Checking Node.js', status: 'pending' },
|
||||
{ id: 'install-claude', label: 'Installing Claude Code', status: 'pending' },
|
||||
// Reset state — native installer needs no Node.js prerequisite
|
||||
const steps: InstallStep[] = [
|
||||
{ id: 'install-claude', label: 'Installing Claude Code (native)', status: 'pending' },
|
||||
{ id: 'verify', label: 'Verifying installation', status: 'pending' },
|
||||
);
|
||||
];
|
||||
|
||||
installState = {
|
||||
status: 'running',
|
||||
@@ -643,6 +751,7 @@ app.whenReady().then(async () => {
|
||||
};
|
||||
|
||||
const expandedPath = getExpandedShellPath();
|
||||
const home = os.homedir();
|
||||
const execEnv: Record<string, string> = {
|
||||
...userShellEnv,
|
||||
...sanitizedProcessEnv(),
|
||||
@@ -672,173 +781,137 @@ app.whenReady().then(async () => {
|
||||
// Run the installation sequence asynchronously
|
||||
(async () => {
|
||||
try {
|
||||
// Step 0 (optional): Install Node.js via package manager
|
||||
if (needsNode) {
|
||||
setStep('install-node', 'running');
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
const nodeInstalled = await new Promise<boolean>((resolve) => {
|
||||
const isWin = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
let cmd: string;
|
||||
let args: string[];
|
||||
// Step 1: Install Claude Code via native installer
|
||||
setStep('install-claude', 'running');
|
||||
|
||||
if (isMac) {
|
||||
// macOS: Homebrew only — UI should guide the user to install Homebrew first
|
||||
const brewPaths = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
|
||||
const brewPath = brewPaths.find(p => fs.existsSync(p));
|
||||
if (brewPath) {
|
||||
cmd = brewPath;
|
||||
args = ['install', 'node'];
|
||||
addLog(`Running: ${brewPath} install node`);
|
||||
} else {
|
||||
addLog('Homebrew is required. Please install Homebrew first and retry.');
|
||||
if (isWin) {
|
||||
// Windows: download and run install.cmd
|
||||
addLog('Downloading native installer for Windows...');
|
||||
|
||||
const installSuccess = await new Promise<boolean>((resolve) => {
|
||||
// Download install.cmd to temp, then execute it
|
||||
const tmpDir = os.tmpdir();
|
||||
const installCmd = path.join(tmpDir, 'claude-install.cmd');
|
||||
|
||||
const downloadChild = spawn('curl', ['-fsSL', 'https://claude.ai/install.cmd', '-o', installCmd], {
|
||||
env: execEnv,
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
installProcess = downloadChild;
|
||||
|
||||
downloadChild.stderr?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) addLog(line);
|
||||
});
|
||||
|
||||
downloadChild.on('close', (dlCode) => {
|
||||
if (dlCode !== 0) {
|
||||
addLog('Failed to download installer.');
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
} else if (isWin) {
|
||||
cmd = 'winget';
|
||||
args = ['install', '-e', '--id', 'OpenJS.NodeJS.LTS', '--accept-source-agreements', '--accept-package-agreements'];
|
||||
addLog('Running: winget install -e --id OpenJS.NodeJS.LTS');
|
||||
} else {
|
||||
// Linux — no universal package manager
|
||||
addLog('Auto-install of Node.js is not supported on this platform.');
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
addLog('Running installer...');
|
||||
const child = spawn(installCmd, [], {
|
||||
env: execEnv,
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
installProcess = child;
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) addLog(line);
|
||||
});
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) addLog(line);
|
||||
});
|
||||
child.on('error', (err) => { addLog(`Error: ${err.message}`); resolve(false); });
|
||||
child.on('close', (code) => {
|
||||
installProcess = null;
|
||||
// Clean up temp file
|
||||
try { fs.unlinkSync(installCmd); } catch { /* ignore */ }
|
||||
resolve(code === 0);
|
||||
});
|
||||
});
|
||||
|
||||
downloadChild.on('error', (err) => {
|
||||
addLog(`Download error: ${err.message}`);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (installState.status === 'cancelled') {
|
||||
setStep('install-claude', 'failed', 'Cancelled');
|
||||
return;
|
||||
}
|
||||
if (!installSuccess) {
|
||||
setStep('install-claude', 'failed', 'Native installer failed. Check logs for details.');
|
||||
installState.status = 'failed';
|
||||
sendProgress();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// macOS / Linux: curl | bash
|
||||
addLog('Running: curl -fsSL https://claude.ai/install.sh | bash');
|
||||
|
||||
const installSuccess = await new Promise<boolean>((resolve) => {
|
||||
const userShell = process.env.SHELL || '/bin/bash';
|
||||
const child = spawn(userShell, ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'], {
|
||||
env: execEnv,
|
||||
shell: isWin,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
installProcess = child;
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) {
|
||||
addLog(line);
|
||||
}
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) addLog(line);
|
||||
});
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) {
|
||||
addLog(line);
|
||||
}
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
addLog(`Error: ${err.message}`);
|
||||
resolve(false);
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) addLog(line);
|
||||
});
|
||||
child.on('error', (err) => { addLog(`Error: ${err.message}`); resolve(false); });
|
||||
child.on('close', (code) => {
|
||||
installProcess = null;
|
||||
resolve(code === 0);
|
||||
if (code === 0) {
|
||||
addLog('Native installer completed successfully.');
|
||||
resolve(true);
|
||||
} else if (installState.status === 'cancelled') {
|
||||
addLog('Installation was cancelled.');
|
||||
resolve(false);
|
||||
} else {
|
||||
addLog(`Installer exited with code ${code}`);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (installState.status === 'cancelled') {
|
||||
setStep('install-node', 'failed', 'Cancelled');
|
||||
setStep('install-claude', 'failed', 'Cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nodeInstalled) {
|
||||
setStep('install-node', 'failed', 'Could not auto-install Node.js.');
|
||||
if (!installSuccess) {
|
||||
setStep('install-claude', 'failed', 'Native installer failed. Check logs for details.');
|
||||
installState.status = 'failed';
|
||||
sendProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('install-node', 'success');
|
||||
addLog('Node.js installation completed.');
|
||||
}
|
||||
|
||||
// Step 1: Check node
|
||||
setStep('check-node', 'running');
|
||||
try {
|
||||
const nodeResult = execFileSync('node', ['--version'], {
|
||||
timeout: 5000,
|
||||
encoding: 'utf-8',
|
||||
env: execEnv,
|
||||
});
|
||||
addLog(`Node.js found: ${nodeResult.trim()}`);
|
||||
setStep('check-node', 'success');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
addLog(`Node.js not found: ${msg}`);
|
||||
setStep('check-node', 'failed', 'Node.js is not installed. Please install Node.js first.');
|
||||
installState.status = 'failed';
|
||||
sendProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Install Claude Code via npm
|
||||
setStep('install-claude', 'running');
|
||||
addLog('Running: npm install -g @anthropic-ai/claude-code');
|
||||
|
||||
const npmInstallSuccess = await new Promise<boolean>((resolve) => {
|
||||
const isWin = process.platform === 'win32';
|
||||
const npmCmd = isWin ? 'npm.cmd' : 'npm';
|
||||
|
||||
const child = spawn(npmCmd, ['install', '-g', '@anthropic-ai/claude-code'], {
|
||||
env: execEnv,
|
||||
shell: isWin,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
installProcess = child;
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
addLog(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
addLog(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
addLog(`npm error: ${err.message}`);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
installProcess = null;
|
||||
if (code === 0) {
|
||||
addLog('npm install completed successfully');
|
||||
resolve(true);
|
||||
} else if (installState.status === 'cancelled') {
|
||||
addLog('Installation was cancelled');
|
||||
resolve(false);
|
||||
} else {
|
||||
addLog(`npm install exited with code ${code}`);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (installState.status === 'cancelled') {
|
||||
setStep('install-claude', 'failed', 'Cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!npmInstallSuccess) {
|
||||
setStep('install-claude', 'failed', 'npm install failed. Check logs for details.');
|
||||
installState.status = 'failed';
|
||||
sendProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('install-claude', 'success');
|
||||
|
||||
// Step 3: Verify claude is available
|
||||
// Step 2: Verify claude is available
|
||||
setStep('verify', 'running');
|
||||
|
||||
// Native installer puts binary in ~/.local/bin/claude — add to PATH for verification
|
||||
const verifyPath = `${path.join(home, '.local', 'bin')}${path.delimiter}${expandedPath}`;
|
||||
const verifyEnv = { ...execEnv, PATH: verifyPath };
|
||||
|
||||
try {
|
||||
const verifyOpts = process.platform === 'win32'
|
||||
? { timeout: 5000, encoding: 'utf-8' as const, env: execEnv, shell: true }
|
||||
: { timeout: 5000, encoding: 'utf-8' as const, env: execEnv };
|
||||
const verifyOpts = isWin
|
||||
? { timeout: 5000, encoding: 'utf-8' as const, env: verifyEnv, shell: true, stdio: 'pipe' as const }
|
||||
: { timeout: 5000, encoding: 'utf-8' as const, env: verifyEnv, stdio: 'pipe' as const };
|
||||
const claudeResult = execFileSync('claude', ['--version'], verifyOpts);
|
||||
addLog(`Claude Code installed: ${claudeResult.trim()}`);
|
||||
setStep('verify', 'success');
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
},
|
||||
install: {
|
||||
checkPrerequisites: () => ipcRenderer.invoke('install:check-prerequisites'),
|
||||
start: (options?: { includeNode?: boolean }) => ipcRenderer.invoke('install:start', options),
|
||||
start: () => ipcRenderer.invoke('install:start'),
|
||||
cancel: () => ipcRenderer.invoke('install:cancel'),
|
||||
getLogs: () => ipcRenderer.invoke('install:get-logs'),
|
||||
onProgress: (callback: (data: unknown) => void) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { invalidateClaudeClientCache } from '@/lib/claude-client';
|
||||
|
||||
/**
|
||||
* POST /api/claude-status/invalidate
|
||||
* Clears all cached Claude binary paths so the next status check or SDK call
|
||||
* picks up a freshly-installed binary. Called by the install wizard on success.
|
||||
*/
|
||||
export async function POST() {
|
||||
invalidateClaudeClientCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,15 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { findClaudeBinary, getClaudeVersion } from '@/lib/platform';
|
||||
import { findClaudeBinary, getClaudeVersion, findAllClaudeBinaries, classifyClaudePath, isWindows, findGitBash } from '@/lib/platform';
|
||||
import type { ClaudeInstallInfo } from '@/lib/platform';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const claudePath = findClaudeBinary();
|
||||
|
||||
// On Windows, check for Git Bash (bash.exe) using the same detection as the SDK runtime.
|
||||
// This avoids false negatives when Git is installed but git.exe isn't on PATH.
|
||||
const missingGit = isWindows && findGitBash() === null;
|
||||
|
||||
if (!claudePath) {
|
||||
return NextResponse.json({ connected: false, version: null });
|
||||
return NextResponse.json({ connected: false, version: null, binaryPath: null, installType: null, otherInstalls: [], missingGit });
|
||||
}
|
||||
const version = await getClaudeVersion(claudePath);
|
||||
return NextResponse.json({ connected: !!version, version });
|
||||
const installType = classifyClaudePath(claudePath);
|
||||
|
||||
// Detect other installations for conflict warning
|
||||
let otherInstalls: ClaudeInstallInfo[] = [];
|
||||
try {
|
||||
const all = findAllClaudeBinaries();
|
||||
otherInstalls = all.filter(i => i.path !== claudePath);
|
||||
} catch {
|
||||
// non-critical — don't fail the status check
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
// If Git Bash is missing on Windows, Claude is installed but not usable
|
||||
connected: !!version && !missingGit,
|
||||
version,
|
||||
binaryPath: claudePath,
|
||||
installType,
|
||||
otherInstalls,
|
||||
missingGit,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ connected: false, version: null });
|
||||
return NextResponse.json({ connected: false, version: null, binaryPath: null, installType: null, otherInstalls: [], missingGit: false });
|
||||
}
|
||||
}
|
||||
@@ -11,19 +11,47 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Warning } from "@/components/ui/icon";
|
||||
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { InstallWizard } from "@/components/layout/InstallWizard";
|
||||
|
||||
interface ClaudeInstallInfo {
|
||||
path: string;
|
||||
version: string | null;
|
||||
type: "native" | "homebrew" | "npm" | "bun" | "unknown";
|
||||
}
|
||||
|
||||
interface ClaudeStatus {
|
||||
connected: boolean;
|
||||
version: string | null;
|
||||
binaryPath?: string | null;
|
||||
installType?: string | null;
|
||||
otherInstalls?: ClaudeInstallInfo[];
|
||||
missingGit?: boolean;
|
||||
}
|
||||
|
||||
const BASE_INTERVAL = 30_000; // 30s
|
||||
const BACKED_OFF_INTERVAL = 60_000; // 60s after 3 consecutive stable results
|
||||
const STABLE_THRESHOLD = 3;
|
||||
|
||||
const INSTALL_TYPE_LABELS: Record<string, string> = {
|
||||
native: "Native",
|
||||
homebrew: "Homebrew",
|
||||
npm: "npm (deprecated)",
|
||||
bun: "bun",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
function getUninstallAdvice(type: string): string | null {
|
||||
switch (type) {
|
||||
case 'npm': return 'npm uninstall -g @anthropic-ai/claude-code';
|
||||
case 'bun': return 'bun remove -g @anthropic-ai/claude-code';
|
||||
case 'homebrew': return 'brew uninstall --cask claude-code';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ConnectionStatus() {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<ClaudeStatus | null>(null);
|
||||
@@ -90,6 +118,15 @@ export function ConnectionStatus() {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
// Invalidate server-side caches then refresh — called after install success
|
||||
const handleInstallComplete = useCallback(async () => {
|
||||
try {
|
||||
await fetch('/api/claude-status/invalidate', { method: 'POST' });
|
||||
} catch { /* best-effort */ }
|
||||
stableCountRef.current = 0;
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
// Auto-prompt install wizard on first disconnect detection (Electron only)
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -117,6 +154,8 @@ export function ConnectionStatus() {
|
||||
}, []);
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
const hasConflicts = (status?.otherInstalls?.length ?? 0) > 0;
|
||||
const missingGit = status?.missingGit ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,7 +168,9 @@ export function ConnectionStatus() {
|
||||
status === null
|
||||
? "bg-muted text-muted-foreground"
|
||||
: connected
|
||||
? "bg-status-success-muted text-status-success-foreground"
|
||||
? hasConflicts
|
||||
? "bg-status-warning-muted text-status-warning-foreground"
|
||||
: "bg-status-success-muted text-status-success-foreground"
|
||||
: "bg-status-error-muted text-status-error-foreground"
|
||||
)}
|
||||
>
|
||||
@@ -139,27 +180,39 @@ export function ConnectionStatus() {
|
||||
status === null
|
||||
? "bg-muted-foreground/40"
|
||||
: connected
|
||||
? "bg-status-success"
|
||||
? hasConflicts
|
||||
? "bg-status-warning"
|
||||
: "bg-status-success"
|
||||
: "bg-status-error"
|
||||
)}
|
||||
/>
|
||||
{status === null
|
||||
? t('connection.checking')
|
||||
: connected
|
||||
? t('connection.connected')
|
||||
: t('connection.disconnected')}
|
||||
? hasConflicts
|
||||
? t('connection.conflict')
|
||||
: t('connection.connected')
|
||||
: missingGit
|
||||
? t('connection.missingGit')
|
||||
: t('connection.disconnected')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{connected ? t('connection.installed') : t('connection.notInstalled')}
|
||||
{connected
|
||||
? t('connection.installed')
|
||||
: missingGit
|
||||
? t('connection.missingGitTitle')
|
||||
: t('connection.notInstalled')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{connected
|
||||
? `Claude Code CLI v${status?.version} is running and ready.`
|
||||
: "Claude Code CLI is required to use this application."}
|
||||
: missingGit
|
||||
? t('connection.missingGitDesc')
|
||||
: "Claude Code CLI is required to use this application."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -169,9 +222,62 @@ export function ConnectionStatus() {
|
||||
<span className="block h-2.5 w-2.5 shrink-0 rounded-full bg-status-success" />
|
||||
<div>
|
||||
<p className="font-medium text-status-success-foreground">Active</p>
|
||||
<p className="text-xs text-muted-foreground">{t('connection.version', { version: status?.version ?? '' })}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('connection.version', { version: status?.version ?? '' })}
|
||||
{status?.installType && ` (${INSTALL_TYPE_LABELS[status.installType] || status.installType})`}
|
||||
</p>
|
||||
{status?.binaryPath && (
|
||||
<p className="text-xs text-muted-foreground font-mono">{status.binaryPath}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{hasConflicts && (
|
||||
<div className="rounded-lg bg-status-warning-muted px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Warning size={16} className="text-status-warning-foreground shrink-0" />
|
||||
<p className="font-medium text-status-warning-foreground text-xs">
|
||||
{t('connection.conflictWarning')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{status?.otherInstalls?.map((inst, i) => {
|
||||
const advice = getUninstallAdvice(inst.type);
|
||||
return (
|
||||
<div key={i} className="space-y-0.5">
|
||||
<p>
|
||||
<code className="bg-muted px-1 rounded">{inst.path}</code>
|
||||
{" "}({INSTALL_TYPE_LABELS[inst.type]} {inst.version})
|
||||
</p>
|
||||
{advice && (
|
||||
<p>{t('connection.conflictRemove')}: <code className="bg-muted px-1 rounded">{advice}</code></p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : missingGit ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-status-warning-muted px-4 py-3">
|
||||
<Warning size={16} className="text-status-warning-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-status-warning-foreground">{t('connection.missingGitTitle')}</p>
|
||||
{status?.version && (
|
||||
<p className="text-xs text-muted-foreground">Claude Code v{status.version} is installed but cannot run without Git.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<ol className="list-decimal list-inside space-y-0.5">
|
||||
<li>{t('install.gitStep1')}</li>
|
||||
<li>{t('install.gitStep2')}</li>
|
||||
<li>{t('install.gitStep3')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
@@ -182,9 +288,15 @@ export function ConnectionStatus() {
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-1.5">1. {t('connection.installClaude')}</h4>
|
||||
<code className="block rounded-md bg-muted px-3 py-2 text-xs">
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
</code>
|
||||
{navigator.platform?.startsWith('Win') ? (
|
||||
<code className="block rounded-md bg-muted px-3 py-2 text-xs">
|
||||
irm https://claude.ai/install.ps1 | iex
|
||||
</code>
|
||||
) : (
|
||||
<code className="block rounded-md bg-muted px-3 py-2 text-xs">
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -231,7 +343,7 @@ export function ConnectionStatus() {
|
||||
<InstallWizard
|
||||
open={wizardOpen}
|
||||
onOpenChange={handleWizardOpenChange}
|
||||
onInstallComplete={handleManualRefresh}
|
||||
onInstallComplete={handleInstallComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Circle,
|
||||
Copy,
|
||||
DownloadSimple,
|
||||
Warning,
|
||||
} from "@/components/ui/icon";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
|
||||
@@ -34,6 +35,12 @@ interface InstallProgress {
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
interface ClaudeInstallDetection {
|
||||
path: string;
|
||||
version: string | null;
|
||||
type: "native" | "homebrew" | "npm" | "bun" | "unknown";
|
||||
}
|
||||
|
||||
interface InstallWizardProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -43,17 +50,19 @@ interface InstallWizardProps {
|
||||
type WizardPhase =
|
||||
| "checking"
|
||||
| "confirm"
|
||||
| "needs-git"
|
||||
| "already-installed"
|
||||
| "installing"
|
||||
| "success"
|
||||
| "failed";
|
||||
|
||||
interface PrereqResult {
|
||||
hasNode: boolean;
|
||||
nodeVersion?: string;
|
||||
hasClaude: boolean;
|
||||
claudeVersion?: string;
|
||||
hasHomebrew?: boolean;
|
||||
claudePath?: string;
|
||||
claudeInstallType?: string;
|
||||
otherInstalls?: ClaudeInstallDetection[];
|
||||
hasGit?: boolean;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
@@ -79,6 +88,23 @@ function StepIcon({ status }: { status: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const INSTALL_TYPE_LABELS: Record<string, string> = {
|
||||
native: "Native",
|
||||
homebrew: "Homebrew",
|
||||
npm: "npm (deprecated)",
|
||||
bun: "bun",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
function getUninstallAdvice(type: string): string | null {
|
||||
switch (type) {
|
||||
case 'npm': return 'npm uninstall -g @anthropic-ai/claude-code';
|
||||
case 'bun': return 'bun remove -g @anthropic-ai/claude-code';
|
||||
case 'homebrew': return 'brew uninstall --cask claude-code';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function InstallWizard({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -89,7 +115,6 @@ export function InstallWizard({
|
||||
const [progress, setProgress] = useState<InstallProgress | null>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedBrew, setCopiedBrew] = useState(false);
|
||||
const [prereqs, setPrereqs] = useState<PrereqResult | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
@@ -113,7 +138,7 @@ export function InstallWizard({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startInstall = useCallback(async (options?: { includeNode?: boolean }) => {
|
||||
const startInstall = useCallback(async () => {
|
||||
const api = getInstallAPI();
|
||||
if (!api) return;
|
||||
|
||||
@@ -133,7 +158,7 @@ export function InstallWizard({
|
||||
});
|
||||
|
||||
try {
|
||||
await api.start(options);
|
||||
await api.start();
|
||||
} catch (err: unknown) {
|
||||
setPhase("failed");
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -154,30 +179,35 @@ export function InstallWizard({
|
||||
const result = await api.checkPrerequisites();
|
||||
setPrereqs(result);
|
||||
|
||||
if (result.hasClaude) {
|
||||
// Windows requires Git for Windows — check FIRST, even if Claude is already installed,
|
||||
// because Claude Code won't actually work without Git Bash on Windows.
|
||||
if (result.platform === "win32" && result.hasGit === false) {
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`Node.js ${result.nodeVersion} found.`,
|
||||
`Claude Code ${result.claudeVersion} already installed.`,
|
||||
...(result.hasClaude
|
||||
? [`Claude Code ${result.claudeVersion} found, but Git for Windows is missing.`]
|
||||
: ["Claude Code CLI not detected."]),
|
||||
"Git for Windows is required for Claude Code to work on Windows.",
|
||||
]);
|
||||
setPhase("needs-git");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.hasClaude) {
|
||||
const typeLabel = INSTALL_TYPE_LABELS[result.claudeInstallType || "unknown"];
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`Claude Code ${result.claudeVersion} found (${typeLabel}).`,
|
||||
`Path: ${result.claudePath}`,
|
||||
]);
|
||||
setPhase("already-installed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't auto-install — show confirmation first
|
||||
if (result.hasNode) {
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`Node.js ${result.nodeVersion} found.`,
|
||||
"Claude Code CLI not detected.",
|
||||
]);
|
||||
} else {
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"Node.js not found.",
|
||||
"Claude Code CLI not detected.",
|
||||
]);
|
||||
}
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"Claude Code CLI not detected.",
|
||||
]);
|
||||
setPhase("confirm");
|
||||
} catch (err: unknown) {
|
||||
setPhase("failed");
|
||||
@@ -186,12 +216,6 @@ export function InstallWizard({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// User explicitly clicks "Install" — only then start the actual install
|
||||
const handleConfirmInstall = useCallback(() => {
|
||||
const needsNode = prereqs ? !prereqs.hasNode : false;
|
||||
startInstall({ includeNode: needsNode });
|
||||
}, [prereqs, startInstall]);
|
||||
|
||||
const handleCopyLogs = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(logs.join("\n"));
|
||||
@@ -207,26 +231,31 @@ export function InstallWizard({
|
||||
onInstallComplete?.();
|
||||
}, [onOpenChange, onInstallComplete]);
|
||||
|
||||
// [P1] Close dialog = cancel running install
|
||||
// Close dialog: cancel if installing, invalidate caches if install succeeded
|
||||
const handleOpenChange = useCallback(
|
||||
async (nextOpen: boolean) => {
|
||||
if (!nextOpen && phase === "installing") {
|
||||
await cancelInstall();
|
||||
if (!nextOpen) {
|
||||
if (phase === "installing") {
|
||||
await cancelInstall();
|
||||
}
|
||||
// If install succeeded, always invalidate caches regardless of how the dialog was closed
|
||||
if (phase === "success") {
|
||||
onInstallComplete?.();
|
||||
}
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
[phase, cancelInstall, onOpenChange]
|
||||
[phase, cancelInstall, onOpenChange, onInstallComplete]
|
||||
);
|
||||
|
||||
// Auto-check when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhase("checking"); // eslint-disable-line react-hooks/set-state-in-effect -- reset state before async check
|
||||
setLogs([]);
|
||||
setProgress(null);
|
||||
setCopied(false);
|
||||
setCopiedBrew(false);
|
||||
setPrereqs(null);
|
||||
setLogs([]);
|
||||
setProgress(null);
|
||||
setCopied(false);
|
||||
setPrereqs(null);
|
||||
checkPrereqs();
|
||||
}
|
||||
return () => {
|
||||
@@ -238,6 +267,7 @@ export function InstallWizard({
|
||||
}, [open, checkPrereqs]);
|
||||
|
||||
const steps = progress?.steps ?? [];
|
||||
const hasConflicts = (prereqs?.otherInstalls?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -246,8 +276,8 @@ export function InstallWizard({
|
||||
<DialogTitle>{t('install.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{phase === "confirm"
|
||||
? "Claude Code CLI was not detected. Install it now?"
|
||||
: "Automatically install Claude Code CLI"}
|
||||
? t('install.nativeDescription')
|
||||
: t('install.autoDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -290,84 +320,87 @@ export function InstallWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase: confirm — ask user before installing */}
|
||||
{phase === "confirm" && prereqs && !prereqs.hasNode && !prereqs.hasHomebrew && prereqs.platform === "darwin" && (
|
||||
{/* Phase: needs-git — Windows requires Git for Windows */}
|
||||
{phase === "needs-git" && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-status-warning-muted px-4 py-3 text-sm space-y-1.5">
|
||||
<p className="text-status-warning-foreground font-medium">
|
||||
{t('install.homebrewRequired')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Warning size={16} className="text-status-warning-foreground shrink-0" />
|
||||
<p className="text-status-warning-foreground font-medium">
|
||||
{t('install.gitRequired')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('install.homebrewDescription')}
|
||||
{t('install.gitDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-3 py-2.5 flex items-center gap-2">
|
||||
<code className="flex-1 text-xs text-zinc-300 break-all select-all">
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 h-7 px-2"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"');
|
||||
setCopiedBrew(true);
|
||||
setTimeout(() => setCopiedBrew(false), 2000);
|
||||
} catch { /* clipboard not available */ }
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<span className="text-xs">{copiedBrew ? t('install.copied') : t('install.copy')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>{t('install.homebrewSteps')}</p>
|
||||
<p>{t('install.gitSteps')}</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5 text-xs">
|
||||
<li>{t('install.homebrewStep1')}</li>
|
||||
<li>{t('install.homebrewStep2')}</li>
|
||||
<li>{t('install.homebrewStep3')}</li>
|
||||
<li>{t('install.homebrewStep4')}</li>
|
||||
<li>{t('install.gitStep1')}</li>
|
||||
<li>{t('install.gitStep2')}</li>
|
||||
<li>{t('install.gitStep3')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{phase === "confirm" && !(prereqs && !prereqs.hasNode && !prereqs.hasHomebrew && prereqs.platform === "darwin") && (
|
||||
|
||||
{/* Phase: confirm — ask user before installing */}
|
||||
{phase === "confirm" && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-status-warning-muted px-4 py-3 text-sm space-y-1.5">
|
||||
{prereqs && !prereqs.hasNode && (
|
||||
<p className="text-status-warning-foreground">
|
||||
Node.js — not found (will be installed via {prereqs.platform === "win32" ? "winget" : "Homebrew"})
|
||||
</p>
|
||||
)}
|
||||
{prereqs?.hasNode && (
|
||||
<p className="text-status-success-foreground">
|
||||
Node.js {prereqs.nodeVersion} — found
|
||||
</p>
|
||||
)}
|
||||
<p className="text-status-warning-foreground">
|
||||
Claude Code CLI — not found
|
||||
Claude Code CLI — {t('install.notDetected')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click <strong>Install</strong> to automatically set up{" "}
|
||||
{prereqs && !prereqs.hasNode ? "Node.js and " : ""}Claude Code CLI.
|
||||
{t('install.nativeExplain')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase: already-installed */}
|
||||
{phase === "already-installed" && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-status-success-muted px-4 py-3">
|
||||
<Check size={20} className="text-status-success-foreground shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-status-success-foreground">
|
||||
Already installed
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('install.alreadyInstalled')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-status-success-muted px-4 py-3">
|
||||
<Check size={20} className="text-status-success-foreground shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-status-success-foreground">
|
||||
{t('install.alreadyInstalled')}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{prereqs?.claudeVersion} ({INSTALL_TYPE_LABELS[prereqs?.claudeInstallType || "unknown"]})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict warning: multiple installations detected */}
|
||||
{hasConflicts && (
|
||||
<div className="rounded-lg bg-status-warning-muted px-4 py-3 text-sm space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Warning size={16} className="text-status-warning-foreground shrink-0" />
|
||||
<p className="font-medium text-status-warning-foreground">
|
||||
{t('install.conflictTitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>{t('install.conflictUsing')}: <code className="bg-muted px-1 rounded">{prereqs?.claudePath}</code></p>
|
||||
{prereqs?.otherInstalls?.map((inst, i) => {
|
||||
const advice = getUninstallAdvice(inst.type);
|
||||
return (
|
||||
<div key={i} className="space-y-0.5">
|
||||
<p>
|
||||
{t('install.conflictAlso')}: <code className="bg-muted px-1 rounded">{inst.path}</code> ({INSTALL_TYPE_LABELS[inst.type]} {inst.version})
|
||||
</p>
|
||||
{advice && (
|
||||
<p>{t('install.conflictRemove')}: <code className="bg-muted px-1 rounded">{advice}</code></p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -380,7 +413,7 @@ export function InstallWizard({
|
||||
{t('install.complete')}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Claude Code CLI has been installed successfully.
|
||||
{t('install.nativeCompleteDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,14 +446,16 @@ export function InstallWizard({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Confirm phase: "Recheck" when Homebrew missing on macOS, otherwise "Install" */}
|
||||
{phase === "confirm" && prereqs && !prereqs.hasNode && !prereqs.hasHomebrew && prereqs.platform === "darwin" && (
|
||||
{/* Needs Git: "Recheck" button */}
|
||||
{phase === "needs-git" && (
|
||||
<Button size="sm" onClick={checkPrereqs}>
|
||||
{t('install.recheck')}
|
||||
</Button>
|
||||
)}
|
||||
{phase === "confirm" && !(prereqs && !prereqs.hasNode && !prereqs.hasHomebrew && prereqs.platform === "darwin") && (
|
||||
<Button size="sm" onClick={handleConfirmInstall}>
|
||||
|
||||
{/* Confirm phase: "Install" button */}
|
||||
{phase === "confirm" && (
|
||||
<Button size="sm" onClick={startInstall}>
|
||||
<DownloadSimple size={16} />
|
||||
{t('install.install')}
|
||||
</Button>
|
||||
|
||||
+125
-11
@@ -26,28 +26,68 @@ export function getExtraPathDirs(): string[] {
|
||||
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
return [
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.claude', 'bin'),
|
||||
path.join(home, '.bun', 'bin'),
|
||||
path.join(appData, 'npm'),
|
||||
path.join(localAppData, 'npm'),
|
||||
path.join(home, '.npm-global', 'bin'),
|
||||
path.join(home, '.claude', 'bin'),
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.nvm', 'current', 'bin'),
|
||||
];
|
||||
}
|
||||
return [
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.claude', 'bin'),
|
||||
path.join(home, '.bun', 'bin'),
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
path.join(home, '.npm-global', 'bin'),
|
||||
path.join(home, '.nvm', 'current', 'bin'),
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.claude', 'bin'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Claude CLI binary path by installation method.
|
||||
*/
|
||||
export type ClaudeInstallType = 'native' | 'homebrew' | 'npm' | 'bun' | 'unknown';
|
||||
|
||||
export function classifyClaudePath(binPath: string): ClaudeInstallType {
|
||||
const home = os.homedir();
|
||||
const normalized = binPath.replace(/\\/g, '/');
|
||||
// Native installer: ~/.local/bin/claude or ~/.claude/bin/claude
|
||||
if (normalized.includes('/.local/bin/')) return 'native';
|
||||
if (normalized.includes('/.claude/bin/')) return 'native';
|
||||
// Bun: ~/.bun/bin/claude
|
||||
if (normalized.includes('/.bun/bin/') || normalized.includes('/.bun/install/')) return 'bun';
|
||||
// Homebrew: /opt/homebrew/bin or /usr/local/Cellar or homebrew in path
|
||||
if (normalized.includes('/homebrew/') || normalized.includes('/Cellar/')) return 'homebrew';
|
||||
// npm: npm-global, .npm, AppData/npm
|
||||
if (normalized.includes('/npm') || normalized.includes('npm-global')) return 'npm';
|
||||
if (normalized === '/usr/local/bin/claude') {
|
||||
// /usr/local/bin could be npm or homebrew — check symlink target
|
||||
try {
|
||||
const real = fs.realpathSync(binPath);
|
||||
if (real.includes('node_modules')) return 'npm';
|
||||
if (real.includes('homebrew') || real.includes('Cellar')) return 'homebrew';
|
||||
if (real.includes('.bun')) return 'bun';
|
||||
} catch { /* ignore */ }
|
||||
return 'unknown';
|
||||
}
|
||||
if (isWindows) {
|
||||
const appData = (process.env.APPDATA || '').replace(/\\/g, '/');
|
||||
const localAppData = (process.env.LOCALAPPDATA || '').replace(/\\/g, '/');
|
||||
if (appData && normalized.startsWith(appData + '/npm')) return 'npm';
|
||||
if (localAppData && normalized.startsWith(localAppData + '/npm')) return 'npm';
|
||||
if (normalized.includes(home.replace(/\\/g, '/') + '/.local/bin')) return 'native';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude CLI candidate installation paths.
|
||||
* Priority: native install > homebrew > npm (deprecated).
|
||||
*/
|
||||
export function getClaudeCandidatePaths(): string[] {
|
||||
const home = os.homedir();
|
||||
@@ -55,12 +95,14 @@ export function getClaudeCandidatePaths(): string[] {
|
||||
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
const exts = ['.cmd', '.exe', '.bat', ''];
|
||||
// Native first, then bun, then npm paths
|
||||
const baseDirs = [
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.claude', 'bin'),
|
||||
path.join(home, '.bun', 'bin'),
|
||||
path.join(appData, 'npm'),
|
||||
path.join(localAppData, 'npm'),
|
||||
path.join(home, '.npm-global', 'bin'),
|
||||
path.join(home, '.claude', 'bin'),
|
||||
path.join(home, '.local', 'bin'),
|
||||
];
|
||||
const candidates: string[] = [];
|
||||
for (const dir of baseDirs) {
|
||||
@@ -70,15 +112,77 @@ export function getClaudeCandidatePaths(): string[] {
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
// macOS/Linux: native first, then bun, then homebrew, then npm paths
|
||||
return [
|
||||
'/usr/local/bin/claude',
|
||||
'/opt/homebrew/bin/claude',
|
||||
path.join(home, '.npm-global', 'bin', 'claude'),
|
||||
path.join(home, '.local', 'bin', 'claude'),
|
||||
path.join(home, '.claude', 'bin', 'claude'),
|
||||
path.join(home, '.local', 'bin', 'claude'), // native installer
|
||||
path.join(home, '.claude', 'bin', 'claude'), // native alt
|
||||
path.join(home, '.bun', 'bin', 'claude'), // bun global
|
||||
'/opt/homebrew/bin/claude', // homebrew (Apple Silicon)
|
||||
'/usr/local/bin/claude', // homebrew (Intel) or npm global
|
||||
path.join(home, '.npm-global', 'bin', 'claude'), // npm custom prefix
|
||||
];
|
||||
}
|
||||
|
||||
export interface ClaudeInstallInfo {
|
||||
path: string;
|
||||
version: string | null;
|
||||
type: ClaudeInstallType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect ALL Claude CLI installations on the system.
|
||||
* Used to warn about conflicts when multiple versions coexist.
|
||||
*/
|
||||
export function findAllClaudeBinaries(): ClaudeInstallInfo[] {
|
||||
const results: ClaudeInstallInfo[] = [];
|
||||
const seenReal = new Set<string>();
|
||||
|
||||
function tryAdd(p: string) {
|
||||
try {
|
||||
let realPath: string;
|
||||
try { realPath = fs.realpathSync(p); } catch { realPath = p; }
|
||||
if (seenReal.has(realPath)) return;
|
||||
|
||||
const out = execFileSync(p, ['--version'], {
|
||||
timeout: 3000,
|
||||
stdio: 'pipe',
|
||||
shell: needsShell(p),
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
seenReal.add(realPath);
|
||||
results.push({ path: p, version: out.trim() || null, type: classifyClaudePath(p) });
|
||||
} catch {
|
||||
// not found at this path
|
||||
}
|
||||
}
|
||||
|
||||
// Check all known candidate paths
|
||||
for (const p of getClaudeCandidatePaths()) {
|
||||
tryAdd(p);
|
||||
}
|
||||
|
||||
// Also scan PATH via which/where to catch bun, custom, or other non-standard installs
|
||||
try {
|
||||
const cmd = isWindows ? 'where' : '/usr/bin/which';
|
||||
const args = isWindows ? ['claude'] : ['-a', 'claude']; // -a = show ALL matches
|
||||
const result = execFileSync(cmd, args, {
|
||||
timeout: 3000,
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, PATH: getExpandedPath() },
|
||||
shell: isWindows,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
for (const line of result.trim().split(/\r?\n/)) {
|
||||
const candidate = line.trim();
|
||||
if (candidate) tryAdd(candidate);
|
||||
}
|
||||
} catch {
|
||||
// which/where failed
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an expanded PATH string with extra directories, deduped and filtered.
|
||||
*/
|
||||
@@ -102,6 +206,16 @@ let _cachedBinaryPath: string | undefined | null = null; // null = not cached
|
||||
let _cachedBinaryTimestamp = 0;
|
||||
const BINARY_CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
/**
|
||||
* Invalidate all cached binary paths.
|
||||
* Must be called after a new installation so that subsequent SDK calls
|
||||
* pick up the freshly-installed binary instead of a stale npm/bun path.
|
||||
*/
|
||||
export function invalidateClaudePathCache(): void {
|
||||
_cachedBinaryPath = null;
|
||||
_cachedBinaryTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and validate the Claude CLI binary.
|
||||
* Positive results are cached for 60s; negative results are never cached.
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface StartStreamParams {
|
||||
effort?: string;
|
||||
/** SDK thinking config */
|
||||
thinking?: { type: string; budgetTokens?: number };
|
||||
/** Enable 1M context window (beta) */
|
||||
context1m?: boolean;
|
||||
/** Called when init status event provides metadata (tools, slash_commands, skills) */
|
||||
onInitMeta?: (meta: { tools?: unknown; slash_commands?: unknown; skills?: unknown }) => void;
|
||||
/** Display-only content for user message (e.g. /skillName instead of expanded prompt) */
|
||||
@@ -240,6 +242,7 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi
|
||||
...(params.autoTrigger ? { autoTrigger: true } : {}),
|
||||
...(params.effort ? { effort: params.effort } : {}),
|
||||
...(params.thinking ? { thinking: params.thinking } : {}),
|
||||
...(params.context1m ? { context_1m: true } : {}),
|
||||
...(params.displayOverride ? { displayOverride: params.displayOverride } : {}),
|
||||
}),
|
||||
signal: stream.abortController.signal,
|
||||
|
||||
Vendored
+12
-3
@@ -3,14 +3,23 @@
|
||||
* Exposed via contextBridge.exposeInMainWorld('electronAPI', ...) in electron/preload.ts.
|
||||
*/
|
||||
|
||||
interface ClaudeInstallDetection {
|
||||
path: string;
|
||||
version: string | null;
|
||||
type: 'native' | 'homebrew' | 'npm' | 'bun' | 'unknown';
|
||||
}
|
||||
|
||||
interface ElectronInstallAPI {
|
||||
checkPrerequisites: () => Promise<{
|
||||
hasNode: boolean;
|
||||
nodeVersion?: string;
|
||||
hasClaude: boolean;
|
||||
claudeVersion?: string;
|
||||
claudePath?: string;
|
||||
claudeInstallType?: 'native' | 'homebrew' | 'npm' | 'bun' | 'unknown';
|
||||
otherInstalls?: ClaudeInstallDetection[];
|
||||
hasGit?: boolean;
|
||||
platform?: string;
|
||||
}>;
|
||||
start: (options?: { includeNode?: boolean }) => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
cancel: () => Promise<void>;
|
||||
getLogs: () => Promise<string[]>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
Reference in new issue
Block a user