diff --git a/electron/main.ts b/electron/main.ts index c4f2568..9d5fd32 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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(); + + 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 = { ...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((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((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((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((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'); diff --git a/electron/preload.ts b/electron/preload.ts index 2da9136..61f9f9a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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) => { diff --git a/src/app/api/claude-status/invalidate/route.ts b/src/app/api/claude-status/invalidate/route.ts new file mode 100644 index 0000000..3cd6ab8 --- /dev/null +++ b/src/app/api/claude-status/invalidate/route.ts @@ -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 }); +} diff --git a/src/app/api/claude-status/route.ts b/src/app/api/claude-status/route.ts index 16ee488..390f36e 100644 --- a/src/app/api/claude-status/route.ts +++ b/src/app/api/claude-status/route.ts @@ -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 }); } } diff --git a/src/components/layout/ConnectionStatus.tsx b/src/components/layout/ConnectionStatus.tsx index c946ace..42baef2 100644 --- a/src/components/layout/ConnectionStatus.tsx +++ b/src/components/layout/ConnectionStatus.tsx @@ -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 = { + 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(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')} - {connected ? t('connection.installed') : t('connection.notInstalled')} + {connected + ? t('connection.installed') + : missingGit + ? t('connection.missingGitTitle') + : t('connection.notInstalled')} {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."} @@ -169,9 +222,62 @@ export function ConnectionStatus() {

Active

-

{t('connection.version', { version: status?.version ?? '' })}

+

+ {t('connection.version', { version: status?.version ?? '' })} + {status?.installType && ` (${INSTALL_TYPE_LABELS[status.installType] || status.installType})`} +

+ {status?.binaryPath && ( +

{status.binaryPath}

+ )}
+ + {/* Conflict warning */} + {hasConflicts && ( +
+
+ +

+ {t('connection.conflictWarning')} +

+
+
+ {status?.otherInstalls?.map((inst, i) => { + const advice = getUninstallAdvice(inst.type); + return ( +
+

+ {inst.path} + {" "}({INSTALL_TYPE_LABELS[inst.type]} {inst.version}) +

+ {advice && ( +

{t('connection.conflictRemove')}: {advice}

+ )} +
+ ); + })} +
+
+ )} + + ) : missingGit ? ( +
+
+ +
+

{t('connection.missingGitTitle')}

+ {status?.version && ( +

Claude Code v{status.version} is installed but cannot run without Git.

+ )} +
+
+
+
    +
  1. {t('install.gitStep1')}
  2. +
  3. {t('install.gitStep2')}
  4. +
  5. {t('install.gitStep3')}
  6. +
+
) : (
@@ -182,9 +288,15 @@ export function ConnectionStatus() {

1. {t('connection.installClaude')}

- - npm install -g @anthropic-ai/claude-code - + {navigator.platform?.startsWith('Win') ? ( + + irm https://claude.ai/install.ps1 | iex + + ) : ( + + curl -fsSL https://claude.ai/install.sh | bash + + )}
@@ -231,7 +343,7 @@ export function ConnectionStatus() { ); diff --git a/src/components/layout/InstallWizard.tsx b/src/components/layout/InstallWizard.tsx index e75cab0..cbe75a3 100644 --- a/src/components/layout/InstallWizard.tsx +++ b/src/components/layout/InstallWizard.tsx @@ -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 = { + 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(null); const [logs, setLogs] = useState([]); const [copied, setCopied] = useState(false); - const [copiedBrew, setCopiedBrew] = useState(false); const [prereqs, setPrereqs] = useState(null); const logEndRef = useRef(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 ( @@ -246,8 +276,8 @@ export function InstallWizard({ {t('install.title')} {phase === "confirm" - ? "Claude Code CLI was not detected. Install it now?" - : "Automatically install Claude Code CLI"} + ? t('install.nativeDescription') + : t('install.autoDescription')} @@ -290,84 +320,87 @@ export function InstallWizard({
)} - {/* 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" && (
-

- {t('install.homebrewRequired')} -

+
+ +

+ {t('install.gitRequired')} +

+

- {t('install.homebrewDescription')} + {t('install.gitDescription')}

-
- - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - - -
-

{t('install.homebrewSteps')}

+

{t('install.gitSteps')}

    -
  1. {t('install.homebrewStep1')}
  2. -
  3. {t('install.homebrewStep2')}
  4. -
  5. {t('install.homebrewStep3')}
  6. -
  7. {t('install.homebrewStep4')}
  8. +
  9. {t('install.gitStep1')}
  10. +
  11. {t('install.gitStep2')}
  12. +
  13. {t('install.gitStep3')}
)} - {phase === "confirm" && !(prereqs && !prereqs.hasNode && !prereqs.hasHomebrew && prereqs.platform === "darwin") && ( + + {/* Phase: confirm — ask user before installing */} + {phase === "confirm" && (
- {prereqs && !prereqs.hasNode && ( -

- Node.js — not found (will be installed via {prereqs.platform === "win32" ? "winget" : "Homebrew"}) -

- )} - {prereqs?.hasNode && ( -

- Node.js {prereqs.nodeVersion} — found -

- )}

- Claude Code CLI — not found + Claude Code CLI — {t('install.notDetected')}

- Click Install to automatically set up{" "} - {prereqs && !prereqs.hasNode ? "Node.js and " : ""}Claude Code CLI. + {t('install.nativeExplain')}

)} {/* Phase: already-installed */} {phase === "already-installed" && ( -
- -
-

- Already installed -

-

- {t('install.alreadyInstalled')} -

+
+
+ +
+

+ {t('install.alreadyInstalled')} +

+

+ {prereqs?.claudeVersion} ({INSTALL_TYPE_LABELS[prereqs?.claudeInstallType || "unknown"]}) +

+
+ + {/* Conflict warning: multiple installations detected */} + {hasConflicts && ( +
+
+ +

+ {t('install.conflictTitle')} +

+
+
+

{t('install.conflictUsing')}: {prereqs?.claudePath}

+ {prereqs?.otherInstalls?.map((inst, i) => { + const advice = getUninstallAdvice(inst.type); + return ( +
+

+ {t('install.conflictAlso')}: {inst.path} ({INSTALL_TYPE_LABELS[inst.type]} {inst.version}) +

+ {advice && ( +

{t('install.conflictRemove')}: {advice}

+ )} +
+ ); + })} +
+
+ )}
)} @@ -380,7 +413,7 @@ export function InstallWizard({ {t('install.complete')}

- Claude Code CLI has been installed successfully. + {t('install.nativeCompleteDesc')}

@@ -413,14 +446,16 @@ export function InstallWizard({ )} - {/* 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" && ( )} - {phase === "confirm" && !(prereqs && !prereqs.hasNode && !prereqs.hasHomebrew && prereqs.platform === "darwin") && ( - diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 0be9860..15636b5 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -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(); + + 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. diff --git a/src/lib/stream-session-manager.ts b/src/lib/stream-session-manager.ts index 3db6097..8371f40 100644 --- a/src/lib/stream-session-manager.ts +++ b/src/lib/stream-session-manager.ts @@ -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, diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 6e95f7f..208d99a 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -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; + start: () => Promise; cancel: () => Promise; getLogs: () => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any