feat: add one-click Claude Code install wizard

Add an auto-install wizard accessible from the ConnectionStatus dialog
when Claude Code CLI is not detected (Electron only).

- Electron main: install orchestrator with IPC handlers for check/start/cancel
- Preload: expose install API via contextBridge
- InstallWizard: multi-step dialog with real-time log streaming
- ConnectionStatus: add "Install Automatically" button when disconnected

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-13 13:29:39 +08:00
1 parent 23d05cabe1
commit e4b917172c
4 files changed
+694 -25

No files matched your search

+283 -24
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess } from 'electron';
import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess, ipcMain } from 'electron';
import path from 'path';
import { execFileSync } from 'child_process';
import { execFileSync, spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import net from 'net';
import os from 'os';
@@ -14,6 +14,30 @@ let serverExitCode: number | null = null;
let userShellEnv: Record<string, string> = {};
let isQuitting = false;
// --- Install orchestrator ---
interface InstallStep {
id: string;
label: string;
status: 'pending' | 'running' | 'success' | 'failed' | 'skipped';
error?: string;
}
interface InstallState {
status: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
currentStep: string | null;
steps: InstallStep[];
logs: string[];
}
let installState: InstallState = {
status: 'idle',
currentStep: null,
steps: [],
logs: [],
};
let installProcess: ChildProcess | null = null;
const isDev = !app.isPackaged;
/**
@@ -141,6 +165,35 @@ function loadUserShellEnv(): Record<string, string> {
}
}
/**
* Build an expanded PATH that includes common locations for node, npm globals,
* claude, nvm, homebrew, etc. Shared by the server launcher and install orchestrator.
*/
function getExpandedShellPath(): string {
const home = os.homedir();
const shellPath = userShellEnv.PATH || process.env.PATH || '';
const sep = path.delimiter;
if (process.platform === 'win32') {
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
const winExtra = [
path.join(appData, 'npm'),
path.join(localAppData, 'npm'),
path.join(home, '.npm-global', 'bin'),
path.join(home, '.local', 'bin'),
path.join(home, '.claude', 'bin'),
];
const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean);
return [...new Set(allParts)].join(sep);
} else {
const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`;
const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`;
const allParts = raw.split(':').filter(Boolean);
return [...new Set(allParts)].join(':');
}
}
function getPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -202,28 +255,7 @@ function startServer(port: number): Electron.UtilityProcess {
serverExitCode = null;
const home = os.homedir();
const shellPath = userShellEnv.PATH || process.env.PATH || '';
const sep = path.delimiter; // ';' on Windows, ':' on Unix
let constructedPath: string;
if (process.platform === 'win32') {
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
const winExtra = [
path.join(appData, 'npm'),
path.join(localAppData, 'npm'),
path.join(home, '.npm-global', 'bin'),
path.join(home, '.local', 'bin'),
path.join(home, '.claude', 'bin'),
];
const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean);
constructedPath = [...new Set(allParts)].join(sep);
} else {
const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`;
const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`;
const allParts = raw.split(':').filter(Boolean);
constructedPath = [...new Set(allParts)].join(':');
}
const constructedPath = getExpandedShellPath();
const env: Record<string, string> = {
...userShellEnv,
@@ -353,6 +385,227 @@ app.whenReady().then(async () => {
app.dock.setIcon(nativeImage.createFromPath(iconPath));
}
// --- Install wizard IPC handlers ---
ipcMain.handle('install:check-prerequisites', async () => {
const expandedPath = getExpandedShellPath();
const execEnv = { ...process.env, ...userShellEnv, PATH: expandedPath };
const execOpts = { timeout: 5000, encoding: 'utf-8' as const, env: execEnv };
let hasNode = false;
let nodeVersion: string | undefined;
try {
const result = execFileSync('node', ['--version'], execOpts);
nodeVersion = result.trim();
hasNode = true;
} catch {
// node not found
}
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
}
return { hasNode, nodeVersion, hasClaude, claudeVersion };
});
ipcMain.handle('install:start', () => {
if (installState.status === 'running') {
return { error: 'Installation is already running' };
}
// Reset state
installState = {
status: 'running',
currentStep: null,
steps: [
{ id: 'check-node', label: 'Checking Node.js', status: 'pending' },
{ id: 'install-claude', label: 'Installing Claude Code', status: 'pending' },
{ id: 'verify', label: 'Verifying installation', status: 'pending' },
],
logs: [],
};
const expandedPath = getExpandedShellPath();
const execEnv: Record<string, string> = {
...userShellEnv,
...(process.env as Record<string, string>),
...userShellEnv,
PATH: expandedPath,
};
function sendProgress() {
mainWindow?.webContents.send('install:progress', installState);
}
function setStep(id: string, status: InstallStep['status'], error?: string) {
const step = installState.steps.find(s => s.id === id);
if (step) {
step.status = status;
step.error = error;
}
installState.currentStep = id;
sendProgress();
}
function addLog(line: string) {
installState.logs.push(line);
sendProgress();
}
// Run the installation sequence asynchronously
(async () => {
try {
// 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
setStep('verify', 'running');
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 claudeResult = execFileSync('claude', ['--version'], verifyOpts);
addLog(`Claude Code installed: ${claudeResult.trim()}`);
setStep('verify', 'success');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addLog(`Verification failed: ${msg}`);
setStep('verify', 'failed', 'Claude Code was installed but could not be verified.');
installState.status = 'failed';
sendProgress();
return;
}
installState.status = 'success';
installState.currentStep = null;
sendProgress();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addLog(`Unexpected error: ${msg}`);
installState.status = 'failed';
sendProgress();
}
})();
return { ok: true };
});
ipcMain.handle('install:cancel', () => {
if (installState.status !== 'running') {
return { error: 'No installation is running' };
}
installState.status = 'cancelled';
if (installProcess) {
try {
installProcess.kill();
} catch {
// already dead
}
installProcess = null;
}
mainWindow?.webContents.send('install:progress', installState);
return { ok: true };
});
ipcMain.handle('install:get-logs', () => {
return installState.logs;
});
// --- End install wizard IPC handlers ---
try {
let port: number;
@@ -403,6 +656,12 @@ app.on('activate', async () => {
});
app.on('before-quit', async (e) => {
// Kill any running install process
if (installProcess) {
try { installProcess.kill(); } catch { /* already dead */ }
installProcess = null;
}
if (serverProcess && !isQuitting) {
isQuitting = true;
e.preventDefault();
+12 -1
View File
@@ -1,5 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { contextBridge } = require('electron');
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
versions: {
@@ -7,4 +7,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
node: process.versions.node,
chrome: process.versions.chrome,
},
install: {
checkPrerequisites: () => ipcRenderer.invoke('install:check-prerequisites'),
start: () => ipcRenderer.invoke('install:start'),
cancel: () => ipcRenderer.invoke('install:cancel'),
getLogs: () => ipcRenderer.invoke('install:get-logs'),
onProgress: (callback: (data: unknown) => void) => {
const listener = (_event: unknown, data: unknown) => callback(data);
ipcRenderer.on('install:progress', listener);
return () => { ipcRenderer.removeListener('install:progress', listener); };
},
},
});
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { InstallWizard } from "@/components/layout/InstallWizard";
interface ClaudeStatus {
connected: boolean;
@@ -24,6 +25,12 @@ const STABLE_THRESHOLD = 3;
export function ConnectionStatus() {
const [status, setStatus] = useState<ClaudeStatus | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const isElectron =
typeof window !== "undefined" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
!!(window as any).electronAPI?.install;
const stableCountRef = useRef(0);
const lastConnectedRef = useRef<boolean | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -162,6 +169,20 @@ export function ConnectionStatus() {
claude --version
</code>
</div>
{isElectron && (
<div className="pt-2 border-t">
<Button
onClick={() => {
setDialogOpen(false);
setWizardOpen(true);
}}
className="w-full"
>
Install Claude Code Automatically
</Button>
</div>
)}
</div>
)}
@@ -175,6 +196,12 @@ export function ConnectionStatus() {
</DialogFooter>
</DialogContent>
</Dialog>
<InstallWizard
open={wizardOpen}
onOpenChange={setWizardOpen}
onInstallComplete={handleManualRefresh}
/>
</>
);
}
+372
View File
@@ -0,0 +1,372 @@
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
CheckIcon,
XIcon,
MinusIcon,
LoaderIcon,
CircleIcon,
CopyIcon,
} from "lucide-react";
interface InstallProgress {
status: "idle" | "running" | "success" | "failed" | "cancelled";
currentStep: string | null;
steps: Array<{
id: string;
label: string;
status: "pending" | "running" | "success" | "failed" | "skipped";
error?: string;
}>;
logs: string[];
}
interface InstallWizardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onInstallComplete?: () => void;
}
type WizardPhase =
| "checking"
| "node-missing"
| "already-installed"
| "installing"
| "success"
| "failed";
function getInstallAPI() {
if (typeof window !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).electronAPI?.install as
| {
checkPrerequisites: () => Promise<{
hasNode: boolean;
nodeVersion?: string;
hasClaude: boolean;
claudeVersion?: string;
}>;
start: () => Promise<void>;
cancel: () => Promise<void>;
getLogs: () => Promise<string[]>;
onProgress: (
callback: (progress: InstallProgress) => void
) => () => void;
}
| undefined;
}
return undefined;
}
function StepIcon({ status }: { status: string }) {
switch (status) {
case "success":
return <CheckIcon className="size-4 text-emerald-500" />;
case "running":
return <LoaderIcon className="size-4 text-blue-500 animate-spin" />;
case "failed":
return <XIcon className="size-4 text-red-500" />;
case "skipped":
return <MinusIcon className="size-4 text-muted-foreground" />;
default:
return <CircleIcon className="size-3.5 text-muted-foreground/40" />;
}
}
export function InstallWizard({
open,
onOpenChange,
onInstallComplete,
}: InstallWizardProps) {
const [phase, setPhase] = useState<WizardPhase>("checking");
const [progress, setProgress] = useState<InstallProgress | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const scrollToBottom = useCallback(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [logs, scrollToBottom]);
const startInstall = useCallback(async () => {
const api = getInstallAPI();
if (!api) return;
setPhase("installing");
// Subscribe to progress updates
if (cleanupRef.current) cleanupRef.current();
cleanupRef.current = api.onProgress((p) => {
setProgress(p);
setLogs(p.logs);
if (p.status === "success") {
setPhase("success");
} else if (p.status === "failed" || p.status === "cancelled") {
setPhase("failed");
}
});
try {
await api.start();
} catch (err: unknown) {
setPhase("failed");
const msg = err instanceof Error ? err.message : String(err);
setLogs((prev) => [...prev, `Installation error: ${msg}`]);
}
}, []);
const checkPrereqs = useCallback(async () => {
const api = getInstallAPI();
if (!api) return;
setPhase("checking");
setLogs(["Checking environment..."]);
setProgress(null);
try {
const result = await api.checkPrerequisites();
if (!result.hasNode) {
setPhase("node-missing");
setLogs((prev) => [
...prev,
"Node.js not found.",
]);
return;
}
setLogs((prev) => [
...prev,
`Node.js ${result.nodeVersion} found.`,
]);
if (result.hasClaude) {
setPhase("already-installed");
setLogs((prev) => [
...prev,
`Claude Code ${result.claudeVersion} already installed.`,
]);
return;
}
setLogs((prev) => [...prev, "Claude Code not found. Starting installation..."]);
startInstall();
} catch (err: unknown) {
setPhase("failed");
const msg = err instanceof Error ? err.message : String(err);
setLogs((prev) => [...prev, `Error checking prerequisites: ${msg}`]);
}
}, [startInstall]);
const handleCancel = useCallback(async () => {
const api = getInstallAPI();
if (!api) return;
try {
await api.cancel();
} catch {
// ignore cancel errors
}
}, []);
const handleCopyLogs = useCallback(async () => {
try {
await navigator.clipboard.writeText(logs.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
}, [logs]);
const handleDone = useCallback(() => {
onOpenChange(false);
onInstallComplete?.();
}, [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([]); // eslint-disable-line react-hooks/set-state-in-effect
setProgress(null); // eslint-disable-line react-hooks/set-state-in-effect
setCopied(false); // eslint-disable-line react-hooks/set-state-in-effect
checkPrereqs();
}
return () => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}, [open, checkPrereqs]);
const steps = progress?.steps ?? [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Install Claude Code</DialogTitle>
<DialogDescription>
Automatically install Claude Code CLI
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Step list */}
{steps.length > 0 && (
<div className="space-y-2">
{steps.map((step) => (
<div
key={step.id}
className="flex items-center gap-2.5 text-sm"
>
<StepIcon status={step.status} />
<span
className={cn(
step.status === "pending" && "text-muted-foreground",
step.status === "running" && "text-foreground font-medium",
step.status === "success" && "text-emerald-700 dark:text-emerald-400",
step.status === "failed" && "text-red-700 dark:text-red-400",
step.status === "skipped" && "text-muted-foreground"
)}
>
{step.label}
</span>
{step.error && (
<span className="text-xs text-red-500 ml-auto truncate max-w-[200px]">
{step.error}
</span>
)}
</div>
))}
</div>
)}
{/* Phase-specific messages */}
{phase === "checking" && steps.length === 0 && (
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
<span>Checking environment...</span>
</div>
)}
{phase === "node-missing" && (
<div className="rounded-lg bg-red-500/10 px-4 py-3 text-sm space-y-2">
<p className="font-medium text-red-700 dark:text-red-400">
Node.js 18+ is required
</p>
<p className="text-muted-foreground">
Please install it from{" "}
<a
href="https://nodejs.org"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
https://nodejs.org
</a>{" "}
and try again.
</p>
</div>
)}
{phase === "already-installed" && (
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
<div className="text-sm">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
Already installed
</p>
<p className="text-muted-foreground text-xs">
Claude Code is already available.
</p>
</div>
</div>
)}
{phase === "success" && (
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
<div className="text-sm">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
Installation complete
</p>
<p className="text-muted-foreground text-xs">
Claude Code CLI has been installed successfully.
</p>
</div>
</div>
)}
{/* Log output */}
{logs.length > 0 && (
<div className="rounded-md bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 max-h-48 overflow-y-auto">
<div className="p-3 font-mono text-xs text-zinc-300 space-y-0.5">
{logs.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{line}
</div>
))}
<div ref={logEndRef} />
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleCopyLogs}
disabled={logs.length === 0}
>
<CopyIcon />
{copied ? "Copied" : "Copy Logs"}
</Button>
{phase === "node-missing" && (
<Button size="sm" onClick={checkPrereqs}>
Retry
</Button>
)}
{phase === "installing" && (
<Button variant="destructive" size="sm" onClick={handleCancel}>
Cancel
</Button>
)}
{phase === "failed" && (
<Button size="sm" onClick={checkPrereqs}>
Retry
</Button>
)}
{(phase === "success" || phase === "already-installed") && (
<Button size="sm" onClick={handleDone}>
Done
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}