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:
7418andClaude Opus 4.6 committed 2026-03-12 17:27:35 +08:00
1 parent 2a0451329b
commit 857e9fc413
9 files changed
+683 -300

No files matched your search

@@ -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 });
}
+29 -4
View File
@@ -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 });
}
}
+123 -11
View File
@@ -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}
/>
</>
);
+134 -99
View File
@@ -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 &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
</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
View File
@@ -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.
+3
View File
@@ -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,
+12 -3
View File
@@ -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