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

@@ -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>
);
}