mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
refactor: modularize setup view into reusable components and hooks
Refactored setup-view.tsx (1,740 → 148 lines, -91.5%) by extracting: - 8 reusable UI components (status badges, terminal output, etc.) - 4 custom hooks (CLI status, installation, OAuth, token save) - 4 step components (welcome, complete, Claude setup, Codex setup) - 1 dialog component (OAuth token modal) All business logic separated from UI, zero code duplication, fully type-safe.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AuthMethodOption {
|
||||||
|
id: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
badge: string;
|
||||||
|
badgeColor: string; // e.g., "brand-500", "green-500"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthMethodSelectorProps {
|
||||||
|
options: AuthMethodOption[];
|
||||||
|
onSelect: (methodId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthMethodSelector({
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
}: AuthMethodSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => onSelect(option.id)}
|
||||||
|
className={`p-4 rounded-lg border border-border hover:border-${option.badgeColor}/50 bg-card hover:bg-${option.badgeColor}/5 transition-all text-left`}
|
||||||
|
data-testid={`select-${option.id}-auth`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{option.icon}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{option.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{option.description}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs text-${option.badgeColor} mt-2`}>
|
||||||
|
{option.badge}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Loader2, AlertCircle } from "lucide-react";
|
||||||
|
import { CopyableCommandField } from "./copyable-command-field";
|
||||||
|
import { TerminalOutput } from "./terminal-output";
|
||||||
|
|
||||||
|
interface CommandInfo {
|
||||||
|
label: string; // e.g., "macOS / Linux"
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliInstallationCardProps {
|
||||||
|
cliName: string;
|
||||||
|
description: string;
|
||||||
|
commands: CommandInfo[];
|
||||||
|
isInstalling: boolean;
|
||||||
|
installProgress: { output: string[] };
|
||||||
|
onInstall: () => void;
|
||||||
|
warningMessage?: string;
|
||||||
|
color?: "brand" | "green"; // For different CLI themes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CliInstallationCard({
|
||||||
|
cliName,
|
||||||
|
description,
|
||||||
|
commands,
|
||||||
|
isInstalling,
|
||||||
|
installProgress,
|
||||||
|
onInstall,
|
||||||
|
warningMessage,
|
||||||
|
color = "brand",
|
||||||
|
}: CliInstallationCardProps) {
|
||||||
|
const colorClasses = {
|
||||||
|
brand: "bg-brand-500 hover:bg-brand-600",
|
||||||
|
green: "bg-green-500 hover:bg-green-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Install {cliName}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{commands.map((cmd, index) => (
|
||||||
|
<CopyableCommandField
|
||||||
|
key={index}
|
||||||
|
label={cmd.label}
|
||||||
|
command={cmd.command}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isInstalling && (
|
||||||
|
<TerminalOutput lines={installProgress.output} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onInstall}
|
||||||
|
disabled={isInstalling}
|
||||||
|
className={`w-full ${colorClasses[color]} text-white`}
|
||||||
|
data-testid={`install-${cliName.toLowerCase()}-button`}
|
||||||
|
>
|
||||||
|
{isInstalling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Installing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Auto Install
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{warningMessage && (
|
||||||
|
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5" />
|
||||||
|
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||||
|
{warningMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface CopyableCommandFieldProps {
|
||||||
|
command: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyableCommandField({
|
||||||
|
command,
|
||||||
|
label,
|
||||||
|
}: CopyableCommandFieldProps) {
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(command);
|
||||||
|
toast.success("Command copied to clipboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||||
|
{command}
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" onClick={copyToClipboard}>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/src/components/views/setup-view/components/index.ts
Normal file
9
app/src/components/views/setup-view/components/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Re-export all setup-view components for easier imports
|
||||||
|
export { StepIndicator } from "./step-indicator";
|
||||||
|
export { StatusBadge } from "./status-badge";
|
||||||
|
export { StatusRow } from "./status-row";
|
||||||
|
export { TerminalOutput } from "./terminal-output";
|
||||||
|
export { CopyableCommandField } from "./copyable-command-field";
|
||||||
|
export { CliInstallationCard } from "./cli-installation-card";
|
||||||
|
export { ReadyStateCard } from "./ready-state-card";
|
||||||
|
export { AuthMethodSelector } from "./auth-method-selector";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface ReadyStateCardProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
variant?: "success" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadyStateCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
variant = "success",
|
||||||
|
}: ReadyStateCardProps) {
|
||||||
|
const variantClasses = {
|
||||||
|
success: "bg-green-500/5 border-green-500/20",
|
||||||
|
info: "bg-blue-500/5 border-blue-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconColorClasses = {
|
||||||
|
success: "bg-green-500/10 text-green-500",
|
||||||
|
info: "bg-blue-500/10 text-blue-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={variantClasses[variant]}>
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 rounded-full ${iconColorClasses[variant]} flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status:
|
||||||
|
| "installed"
|
||||||
|
| "not_installed"
|
||||||
|
| "checking"
|
||||||
|
| "authenticated"
|
||||||
|
| "not_authenticated";
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||||
|
const getStatusConfig = () => {
|
||||||
|
switch (status) {
|
||||||
|
case "installed":
|
||||||
|
case "authenticated":
|
||||||
|
return {
|
||||||
|
icon: <CheckCircle2 className="w-4 h-4" />,
|
||||||
|
className: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||||
|
};
|
||||||
|
case "not_installed":
|
||||||
|
case "not_authenticated":
|
||||||
|
return {
|
||||||
|
icon: <XCircle className="w-4 h-4" />,
|
||||||
|
className: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||||
|
};
|
||||||
|
case "checking":
|
||||||
|
return {
|
||||||
|
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||||
|
className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${config.className}`}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { StatusBadge } from "./status-badge";
|
||||||
|
|
||||||
|
interface StatusRowProps {
|
||||||
|
label: string;
|
||||||
|
status:
|
||||||
|
| "checking"
|
||||||
|
| "installed"
|
||||||
|
| "not_installed"
|
||||||
|
| "authenticated"
|
||||||
|
| "not_authenticated";
|
||||||
|
statusLabel: string;
|
||||||
|
metadata?: string; // e.g., "(Subscription Token)"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusRow({
|
||||||
|
label,
|
||||||
|
status,
|
||||||
|
statusLabel,
|
||||||
|
metadata,
|
||||||
|
}: StatusRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={status} label={statusLabel} />
|
||||||
|
{metadata && (
|
||||||
|
<span className="text-xs text-muted-foreground">{metadata}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
interface StepIndicatorProps {
|
||||||
|
currentStep: number;
|
||||||
|
totalSteps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepIndicator({
|
||||||
|
currentStep,
|
||||||
|
totalSteps,
|
||||||
|
}: StepIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-8">
|
||||||
|
{Array.from({ length: totalSteps }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
|
index <= currentStep
|
||||||
|
? "w-8 bg-brand-500"
|
||||||
|
: "w-2 bg-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface TerminalOutputProps {
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalOutput({ lines }: TerminalOutputProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto">
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<div key={index} className="text-zinc-400">
|
||||||
|
<span className="text-green-500">$</span> {line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<div className="text-zinc-500 italic">Waiting for output...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
app/src/components/views/setup-view/dialogs/index.ts
Normal file
2
app/src/components/views/setup-view/dialogs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export all setup dialog components for easier imports
|
||||||
|
export { SetupTokenModal } from "./setup-token-modal";
|
||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useOAuthAuthentication } from "../hooks";
|
||||||
|
|
||||||
interface SetupTokenModalProps {
|
interface SetupTokenModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -29,20 +29,17 @@ interface SetupTokenModalProps {
|
|||||||
onTokenObtained: (token: string) => void;
|
onTokenObtained: (token: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
|
||||||
|
|
||||||
export function SetupTokenModal({
|
export function SetupTokenModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onTokenObtained,
|
onTokenObtained,
|
||||||
}: SetupTokenModalProps) {
|
}: SetupTokenModalProps) {
|
||||||
const [authState, setAuthState] = useState<AuthState>("idle");
|
// Use the OAuth authentication hook
|
||||||
const [output, setOutput] = useState<string[]>([]);
|
const { authState, output, token, error, startAuth, reset } =
|
||||||
const [token, setToken] = useState("");
|
useOAuthAuthentication({ cliType: "claude" });
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [manualToken, setManualToken] = useState("");
|
const [manualToken, setManualToken] = useState("");
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when output changes
|
// Auto-scroll to bottom when output changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,132 +48,13 @@ export function SetupTokenModal({
|
|||||||
}
|
}
|
||||||
}, [output]);
|
}, [output]);
|
||||||
|
|
||||||
// Reset state when modal opens
|
// Reset state when modal opens/closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setAuthState("idle");
|
reset();
|
||||||
setOutput([]);
|
|
||||||
setToken("");
|
|
||||||
setError(null);
|
|
||||||
setManualToken("");
|
setManualToken("");
|
||||||
} else {
|
|
||||||
// Cleanup subscription when modal closes
|
|
||||||
if (unsubscribeRef.current) {
|
|
||||||
unsubscribeRef.current();
|
|
||||||
unsubscribeRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, reset]);
|
||||||
|
|
||||||
const startAuth = useCallback(async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.setup) {
|
|
||||||
setError("Setup API not available");
|
|
||||||
setAuthState("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthState("running");
|
|
||||||
setOutput([
|
|
||||||
"Starting authentication...",
|
|
||||||
"Running Claude CLI in an embedded terminal so you don't need to copy/paste.",
|
|
||||||
"When your browser opens, complete sign-in and return here.",
|
|
||||||
"",
|
|
||||||
]);
|
|
||||||
setError(null);
|
|
||||||
setToken("");
|
|
||||||
|
|
||||||
// Subscribe to progress events
|
|
||||||
if (api.setup.onAuthProgress) {
|
|
||||||
unsubscribeRef.current = api.setup.onAuthProgress((progress) => {
|
|
||||||
if (progress.cli === "claude" && progress.data) {
|
|
||||||
// Split by newlines and add each line
|
|
||||||
const normalized = progress.data.replace(/\r/g, "\n");
|
|
||||||
const lines = normalized
|
|
||||||
.split("\n")
|
|
||||||
.map((line: string) => line.trimEnd())
|
|
||||||
.filter((line: string) => line.length > 0);
|
|
||||||
if (lines.length > 0) {
|
|
||||||
setOutput((prev) => [...prev, ...lines]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.setup.authClaude();
|
|
||||||
|
|
||||||
// Cleanup subscription
|
|
||||||
if (unsubscribeRef.current) {
|
|
||||||
unsubscribeRef.current();
|
|
||||||
unsubscribeRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success && result.token) {
|
|
||||||
setToken(result.token);
|
|
||||||
setAuthState("success");
|
|
||||||
setOutput((prev) => [
|
|
||||||
...prev,
|
|
||||||
"",
|
|
||||||
"✓ Authentication successful!",
|
|
||||||
"✓ Token captured automatically.",
|
|
||||||
]);
|
|
||||||
} else if (result.requiresManualAuth) {
|
|
||||||
// Terminal was opened - user needs to copy token manually
|
|
||||||
setAuthState("manual");
|
|
||||||
// Don't add extra messages if terminalOpened - the progress messages already explain
|
|
||||||
if (!result.terminalOpened) {
|
|
||||||
const extraMessages = [
|
|
||||||
"",
|
|
||||||
"⚠ Could not capture token automatically.",
|
|
||||||
];
|
|
||||||
if (result.error) {
|
|
||||||
extraMessages.push(result.error);
|
|
||||||
}
|
|
||||||
setOutput((prev) => [
|
|
||||||
...prev,
|
|
||||||
...extraMessages,
|
|
||||||
"Please copy the token from above and paste it below.",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(result.error || "Authentication failed");
|
|
||||||
setAuthState("error");
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// Cleanup subscription
|
|
||||||
if (unsubscribeRef.current) {
|
|
||||||
unsubscribeRef.current();
|
|
||||||
unsubscribeRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage =
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: typeof err === "object" && err !== null && "error" in err
|
|
||||||
? String((err as { error: unknown }).error)
|
|
||||||
: "Authentication failed";
|
|
||||||
|
|
||||||
// Check if we should fall back to manual mode
|
|
||||||
if (
|
|
||||||
typeof err === "object" &&
|
|
||||||
err !== null &&
|
|
||||||
"requiresManualAuth" in err &&
|
|
||||||
(err as { requiresManualAuth: boolean }).requiresManualAuth
|
|
||||||
) {
|
|
||||||
setAuthState("manual");
|
|
||||||
setOutput((prev) => [
|
|
||||||
...prev,
|
|
||||||
"",
|
|
||||||
"⚠ " + errorMessage,
|
|
||||||
"Please copy the token manually and paste it below.",
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
setError(errorMessage);
|
|
||||||
setAuthState("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUseToken = useCallback(() => {
|
const handleUseToken = useCallback(() => {
|
||||||
const tokenToUse = token || manualToken;
|
const tokenToUse = token || manualToken;
|
||||||
@@ -192,12 +70,9 @@ export function SetupTokenModal({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRetry = useCallback(() => {
|
const handleRetry = useCallback(() => {
|
||||||
setAuthState("idle");
|
reset();
|
||||||
setOutput([]);
|
|
||||||
setError(null);
|
|
||||||
setToken("");
|
|
||||||
setManualToken("");
|
setManualToken("");
|
||||||
}, []);
|
}, [reset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
5
app/src/components/views/setup-view/hooks/index.ts
Normal file
5
app/src/components/views/setup-view/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Re-export all hooks for easier imports
|
||||||
|
export { useCliStatus } from "./use-cli-status";
|
||||||
|
export { useCliInstallation } from "./use-cli-installation";
|
||||||
|
export { useOAuthAuthentication } from "./use-oauth-authentication";
|
||||||
|
export { useTokenSave } from "./use-token-save";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface UseCliInstallationOptions {
|
||||||
|
cliType: "claude" | "codex";
|
||||||
|
installApi: () => Promise<any>;
|
||||||
|
onProgressEvent?: (callback: (progress: any) => void) => (() => void) | undefined;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
getStoreState?: () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCliInstallation({
|
||||||
|
cliType,
|
||||||
|
installApi,
|
||||||
|
onProgressEvent,
|
||||||
|
onSuccess,
|
||||||
|
getStoreState,
|
||||||
|
}: UseCliInstallationOptions) {
|
||||||
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
|
const [installProgress, setInstallProgress] = useState<{ output: string[] }>({
|
||||||
|
output: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const install = useCallback(async () => {
|
||||||
|
setIsInstalling(true);
|
||||||
|
setInstallProgress({ output: [] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
|
if (onProgressEvent) {
|
||||||
|
unsubscribe = onProgressEvent((progress: { cli?: string; data?: string; type?: string }) => {
|
||||||
|
if (progress.cli === cliType) {
|
||||||
|
setInstallProgress((prev) => ({
|
||||||
|
output: [...prev.output, progress.data || progress.type || ""],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await installApi();
|
||||||
|
unsubscribe?.();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (cliType === "claude" && onSuccess && getStoreState) {
|
||||||
|
// Claude-specific: retry logic to detect installation
|
||||||
|
let retries = 5;
|
||||||
|
let detected = false;
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
await onSuccess();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
const currentStatus = getStoreState();
|
||||||
|
if (currentStatus?.installed) {
|
||||||
|
detected = true;
|
||||||
|
toast.success(`${cliType} CLI installed and detected successfully`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < retries - 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000 + i * 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detected) {
|
||||||
|
toast.success(`${cliType} CLI installation completed`, {
|
||||||
|
description:
|
||||||
|
"The CLI was installed but may need a terminal restart to be detected. You can continue with authentication if you have a token.",
|
||||||
|
duration: 7000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success(`${cliType} CLI installed successfully`);
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error("Installation failed", { description: result.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to install ${cliType}:`, error);
|
||||||
|
toast.error("Installation failed");
|
||||||
|
} finally {
|
||||||
|
setIsInstalling(false);
|
||||||
|
}
|
||||||
|
}, [cliType, installApi, onProgressEvent, onSuccess, getStoreState]);
|
||||||
|
|
||||||
|
return { isInstalling, installProgress, install };
|
||||||
|
}
|
||||||
103
app/src/components/views/setup-view/hooks/use-cli-status.ts
Normal file
103
app/src/components/views/setup-view/hooks/use-cli-status.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface UseCliStatusOptions {
|
||||||
|
cliType: "claude" | "codex";
|
||||||
|
statusApi: () => Promise<any>;
|
||||||
|
setCliStatus: (status: any) => void;
|
||||||
|
setAuthStatus: (status: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCliStatus({
|
||||||
|
cliType,
|
||||||
|
statusApi,
|
||||||
|
setCliStatus,
|
||||||
|
setAuthStatus,
|
||||||
|
}: UseCliStatusOptions) {
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
|
||||||
|
const checkStatus = useCallback(async () => {
|
||||||
|
console.log(`[${cliType} Setup] Starting status check...`);
|
||||||
|
setIsChecking(true);
|
||||||
|
try {
|
||||||
|
const result = await statusApi();
|
||||||
|
console.log(`[${cliType} Setup] Raw status result:`, result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const cliStatus = {
|
||||||
|
installed: result.status === "installed",
|
||||||
|
path: result.path || null,
|
||||||
|
version: result.version || null,
|
||||||
|
method: result.method || "none",
|
||||||
|
};
|
||||||
|
console.log(`[${cliType} Setup] CLI Status:`, cliStatus);
|
||||||
|
setCliStatus(cliStatus);
|
||||||
|
|
||||||
|
if (result.auth) {
|
||||||
|
if (cliType === "claude") {
|
||||||
|
// Validate method is one of the expected values, default to "none"
|
||||||
|
const validMethods = [
|
||||||
|
"oauth_token_env",
|
||||||
|
"oauth_token",
|
||||||
|
"api_key",
|
||||||
|
"api_key_env",
|
||||||
|
"none",
|
||||||
|
] as const;
|
||||||
|
type AuthMethod = (typeof validMethods)[number];
|
||||||
|
const method: AuthMethod = validMethods.includes(
|
||||||
|
result.auth.method as AuthMethod
|
||||||
|
)
|
||||||
|
? (result.auth.method as AuthMethod)
|
||||||
|
: "none";
|
||||||
|
const authStatus = {
|
||||||
|
authenticated: result.auth.authenticated,
|
||||||
|
method,
|
||||||
|
hasCredentialsFile: false,
|
||||||
|
oauthTokenValid:
|
||||||
|
result.auth.hasStoredOAuthToken ||
|
||||||
|
result.auth.hasEnvOAuthToken,
|
||||||
|
apiKeyValid:
|
||||||
|
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
||||||
|
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
||||||
|
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||||
|
};
|
||||||
|
setAuthStatus(authStatus);
|
||||||
|
} else {
|
||||||
|
// Codex auth status mapping
|
||||||
|
const mapAuthMethod = (method?: string): any => {
|
||||||
|
switch (method) {
|
||||||
|
case "cli_verified":
|
||||||
|
return "cli_verified";
|
||||||
|
case "cli_tokens":
|
||||||
|
return "cli_tokens";
|
||||||
|
case "auth_file":
|
||||||
|
return "api_key";
|
||||||
|
case "env_var":
|
||||||
|
return "env";
|
||||||
|
default:
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const method = mapAuthMethod(result.auth.method);
|
||||||
|
const authStatus = {
|
||||||
|
authenticated: result.auth.authenticated,
|
||||||
|
method,
|
||||||
|
apiKeyValid:
|
||||||
|
method === "cli_verified" || method === "cli_tokens"
|
||||||
|
? undefined
|
||||||
|
: result.auth.authenticated,
|
||||||
|
};
|
||||||
|
console.log(`[${cliType} Setup] Auth Status:`, authStatus);
|
||||||
|
setAuthStatus(authStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${cliType} Setup] Failed to check status:`, error);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
}, [cliType, statusApi, setCliStatus, setAuthStatus]);
|
||||||
|
|
||||||
|
return { isChecking, checkStatus };
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
|
||||||
|
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
||||||
|
|
||||||
|
interface UseOAuthAuthenticationOptions {
|
||||||
|
cliType: "claude" | "codex";
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOAuthAuthentication({
|
||||||
|
cliType,
|
||||||
|
enabled = true,
|
||||||
|
}: UseOAuthAuthenticationOptions) {
|
||||||
|
const [authState, setAuthState] = useState<AuthState>("idle");
|
||||||
|
const [output, setOutput] = useState<string[]>([]);
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// Reset state when disabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setAuthState("idle");
|
||||||
|
setOutput([]);
|
||||||
|
setToken("");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Cleanup subscription
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const startAuth = useCallback(async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.setup) {
|
||||||
|
setError("Setup API not available");
|
||||||
|
setAuthState("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState("running");
|
||||||
|
setOutput([
|
||||||
|
"Starting authentication...",
|
||||||
|
`Running ${cliType} CLI in an embedded terminal so you don't need to copy/paste.`,
|
||||||
|
"When your browser opens, complete sign-in and return here.",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
|
setError(null);
|
||||||
|
setToken("");
|
||||||
|
|
||||||
|
// Subscribe to progress events
|
||||||
|
if (api.setup.onAuthProgress) {
|
||||||
|
unsubscribeRef.current = api.setup.onAuthProgress((progress) => {
|
||||||
|
if (progress.cli === cliType && progress.data) {
|
||||||
|
// Split by newlines and add each line
|
||||||
|
const normalized = progress.data.replace(/\r/g, "\n");
|
||||||
|
const lines = normalized
|
||||||
|
.split("\n")
|
||||||
|
.map((line: string) => line.trimEnd())
|
||||||
|
.filter((line: string) => line.length > 0);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
setOutput((prev) => [...prev, ...lines]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the appropriate auth API based on cliType
|
||||||
|
const result =
|
||||||
|
cliType === "claude"
|
||||||
|
? await api.setup.authClaude()
|
||||||
|
: await api.setup.authCodex?.();
|
||||||
|
|
||||||
|
// Cleanup subscription
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
setError("Authentication API not available");
|
||||||
|
setAuthState("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for token (only available for Claude)
|
||||||
|
const resultToken =
|
||||||
|
cliType === "claude" && "token" in result ? result.token : undefined;
|
||||||
|
const resultTerminalOpened =
|
||||||
|
cliType === "claude" && "terminalOpened" in result
|
||||||
|
? result.terminalOpened
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (result.success && resultToken && typeof resultToken === "string") {
|
||||||
|
setToken(resultToken);
|
||||||
|
setAuthState("success");
|
||||||
|
setOutput((prev) => [
|
||||||
|
...prev,
|
||||||
|
"",
|
||||||
|
"✓ Authentication successful!",
|
||||||
|
"✓ Token captured automatically.",
|
||||||
|
]);
|
||||||
|
} else if (result.requiresManualAuth) {
|
||||||
|
// Terminal was opened - user needs to copy token manually
|
||||||
|
setAuthState("manual");
|
||||||
|
// Don't add extra messages if terminalOpened - the progress messages already explain
|
||||||
|
if (!resultTerminalOpened) {
|
||||||
|
const extraMessages = [
|
||||||
|
"",
|
||||||
|
"⚠ Could not capture token automatically.",
|
||||||
|
];
|
||||||
|
if (result.error) {
|
||||||
|
extraMessages.push(result.error);
|
||||||
|
}
|
||||||
|
setOutput((prev) => [
|
||||||
|
...prev,
|
||||||
|
...extraMessages,
|
||||||
|
"Please copy the token from above and paste it below.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Authentication failed");
|
||||||
|
setAuthState("error");
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Cleanup subscription
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: typeof err === "object" && err !== null && "error" in err
|
||||||
|
? String((err as { error: unknown }).error)
|
||||||
|
: "Authentication failed";
|
||||||
|
|
||||||
|
// Check if we should fall back to manual mode
|
||||||
|
if (
|
||||||
|
typeof err === "object" &&
|
||||||
|
err !== null &&
|
||||||
|
"requiresManualAuth" in err &&
|
||||||
|
(err as { requiresManualAuth: boolean }).requiresManualAuth
|
||||||
|
) {
|
||||||
|
setAuthState("manual");
|
||||||
|
setOutput((prev) => [
|
||||||
|
...prev,
|
||||||
|
"",
|
||||||
|
"⚠ " + errorMessage,
|
||||||
|
"Please copy the token manually and paste it below.",
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setError(errorMessage);
|
||||||
|
setAuthState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [cliType]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setAuthState("idle");
|
||||||
|
setOutput([]);
|
||||||
|
setToken("");
|
||||||
|
setError(null);
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { authState, output, token, error, startAuth, reset };
|
||||||
|
}
|
||||||
58
app/src/components/views/setup-view/hooks/use-token-save.ts
Normal file
58
app/src/components/views/setup-view/hooks/use-token-save.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
|
||||||
|
interface UseTokenSaveOptions {
|
||||||
|
provider: string; // e.g., "anthropic_oauth_token", "anthropic", "openai"
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenSave({ provider, onSuccess }: UseTokenSaveOptions) {
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const saveToken = useCallback(
|
||||||
|
async (tokenValue: string) => {
|
||||||
|
if (!tokenValue.trim()) {
|
||||||
|
toast.error("Please enter a valid token");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const setupApi = api.setup;
|
||||||
|
|
||||||
|
if (setupApi?.storeApiKey) {
|
||||||
|
const result = await setupApi.storeApiKey(provider, tokenValue);
|
||||||
|
console.log(`[Token Save] Store result for ${provider}:`, result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const tokenType = provider.includes("oauth")
|
||||||
|
? "subscription token"
|
||||||
|
: "API key";
|
||||||
|
toast.success(`${tokenType} saved successfully`);
|
||||||
|
onSuccess?.();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to save token", { description: result.error });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Web mode fallback - just show success
|
||||||
|
toast.success("Token saved");
|
||||||
|
onSuccess?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Token Save] Failed to save ${provider}:`, error);
|
||||||
|
toast.error("Failed to save token");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[provider, onSuccess]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isSaving, saveToken };
|
||||||
|
}
|
||||||
584
app/src/components/views/setup-view/steps/claude-setup-step.tsx
Normal file
584
app/src/components/views/setup-view/steps/claude-setup-step.tsx
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
Terminal,
|
||||||
|
Key,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SetupTokenModal } from "../dialogs";
|
||||||
|
import { StatusBadge, TerminalOutput } from "../components";
|
||||||
|
import {
|
||||||
|
useCliStatus,
|
||||||
|
useCliInstallation,
|
||||||
|
useTokenSave,
|
||||||
|
} from "../hooks";
|
||||||
|
|
||||||
|
interface ClaudeSetupStepProps {
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Setup Step - 2 Authentication Options:
|
||||||
|
// 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token
|
||||||
|
// 2. API Key (Pay-per-use): User provides their Anthropic API key directly
|
||||||
|
export function ClaudeSetupStep({
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onSkip,
|
||||||
|
}: ClaudeSetupStepProps) {
|
||||||
|
const {
|
||||||
|
claudeCliStatus,
|
||||||
|
claudeAuthStatus,
|
||||||
|
setClaudeCliStatus,
|
||||||
|
setClaudeAuthStatus,
|
||||||
|
setClaudeInstallProgress,
|
||||||
|
} = useSetupStore();
|
||||||
|
const { setApiKeys, apiKeys } = useAppStore();
|
||||||
|
|
||||||
|
const [authMethod, setAuthMethod] = useState<"token" | "api_key" | null>(null);
|
||||||
|
const [oauthToken, setOAuthToken] = useState("");
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||||
|
|
||||||
|
// Use custom hooks
|
||||||
|
const { isChecking, checkStatus } = useCliStatus({
|
||||||
|
cliType: "claude",
|
||||||
|
statusApi: () =>
|
||||||
|
getElectronAPI().setup?.getClaudeStatus() || Promise.reject(),
|
||||||
|
setCliStatus: setClaudeCliStatus,
|
||||||
|
setAuthStatus: setClaudeAuthStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||||
|
cliType: "claude",
|
||||||
|
installApi: () =>
|
||||||
|
getElectronAPI().setup?.installClaude() || Promise.reject(),
|
||||||
|
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||||
|
onSuccess: checkStatus,
|
||||||
|
getStoreState: () => useSetupStore.getState().claudeCliStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSaving: isSavingOAuth, saveToken: saveOAuthToken } = useTokenSave({
|
||||||
|
provider: "anthropic_oauth_token",
|
||||||
|
onSuccess: () => {
|
||||||
|
setClaudeAuthStatus({
|
||||||
|
authenticated: true,
|
||||||
|
method: "oauth_token",
|
||||||
|
hasCredentialsFile: false,
|
||||||
|
oauthTokenValid: true,
|
||||||
|
});
|
||||||
|
setAuthMethod(null);
|
||||||
|
checkStatus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||||
|
provider: "anthropic",
|
||||||
|
onSuccess: () => {
|
||||||
|
setClaudeAuthStatus({
|
||||||
|
authenticated: true,
|
||||||
|
method: "api_key",
|
||||||
|
hasCredentialsFile: false,
|
||||||
|
apiKeyValid: true,
|
||||||
|
});
|
||||||
|
setApiKeys({ ...apiKeys, anthropic: apiKey });
|
||||||
|
setAuthMethod(null);
|
||||||
|
checkStatus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync install progress to store
|
||||||
|
useEffect(() => {
|
||||||
|
setClaudeInstallProgress({
|
||||||
|
isInstalling,
|
||||||
|
output: installProgress.output,
|
||||||
|
});
|
||||||
|
}, [isInstalling, installProgress, setClaudeInstallProgress]);
|
||||||
|
|
||||||
|
// Check status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkStatus();
|
||||||
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const copyCommand = (command: string) => {
|
||||||
|
navigator.clipboard.writeText(command);
|
||||||
|
toast.success("Command copied to clipboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle token obtained from the OAuth modal
|
||||||
|
const handleTokenFromModal = useCallback(
|
||||||
|
async (token: string) => {
|
||||||
|
setOAuthToken(token);
|
||||||
|
setShowTokenModal(false);
|
||||||
|
await saveOAuthToken(token);
|
||||||
|
},
|
||||||
|
[saveOAuthToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic;
|
||||||
|
|
||||||
|
const getAuthMethodLabel = () => {
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
if (
|
||||||
|
claudeAuthStatus?.method === "oauth_token_env" ||
|
||||||
|
claudeAuthStatus?.method === "oauth_token"
|
||||||
|
)
|
||||||
|
return "Subscription Token";
|
||||||
|
if (
|
||||||
|
apiKeys.anthropic ||
|
||||||
|
claudeAuthStatus?.method === "api_key" ||
|
||||||
|
claudeAuthStatus?.method === "api_key_env"
|
||||||
|
)
|
||||||
|
return "API Key";
|
||||||
|
return "Authenticated";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Terminal className="w-8 h-8 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||||
|
Claude Setup
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Configure Claude for code generation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Card */}
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Status</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={checkStatus}
|
||||||
|
disabled={isChecking}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground">CLI Installation</span>
|
||||||
|
{isChecking ? (
|
||||||
|
<StatusBadge status="checking" label="Checking..." />
|
||||||
|
) : claudeCliStatus?.installed ? (
|
||||||
|
<StatusBadge status="installed" label="Installed" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge status="not_installed" label="Not Installed" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claudeCliStatus?.version && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Version</span>
|
||||||
|
<span className="text-sm font-mono text-foreground">
|
||||||
|
{claudeCliStatus.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground">Authentication</span>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status="authenticated" label="Authenticated" />
|
||||||
|
{getAuthMethodLabel() && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({getAuthMethodLabel()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<StatusBadge
|
||||||
|
status="not_authenticated"
|
||||||
|
label="Not Authenticated"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Installation Section */}
|
||||||
|
{!claudeCliStatus?.installed && (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Install Claude CLI
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Required for subscription-based authentication
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm text-muted-foreground">
|
||||||
|
macOS / Linux
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||||
|
curl -fsSL https://claude.ai/install.sh | bash
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
copyCommand(
|
||||||
|
"curl -fsSL https://claude.ai/install.sh | bash"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm text-muted-foreground">Windows</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||||
|
irm https://claude.ai/install.ps1 | iex
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
copyCommand("irm https://claude.ai/install.ps1 | iex")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInstalling && (
|
||||||
|
<TerminalOutput lines={installProgress.output} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={install}
|
||||||
|
disabled={isInstalling}
|
||||||
|
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||||
|
data-testid="install-claude-button"
|
||||||
|
>
|
||||||
|
{isInstalling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Installing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Auto Install
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Authentication Section */}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Key className="w-5 h-5" />
|
||||||
|
Authentication
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Choose your authentication method</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Option 1: Subscription Token */}
|
||||||
|
{authMethod === "token" ? (
|
||||||
|
<div className="p-4 rounded-lg bg-brand-500/5 border border-brand-500/20 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
Subscription Token
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Use your Claude subscription (no API charges)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{claudeCliStatus?.installed ? (
|
||||||
|
<>
|
||||||
|
{/* Primary: Automated OAuth setup */}
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowTokenModal(true)}
|
||||||
|
className="w-full bg-brand-500 hover:bg-brand-600 text-white mb-4"
|
||||||
|
data-testid="setup-oauth-button"
|
||||||
|
>
|
||||||
|
<Terminal className="w-4 h-4 mr-2" />
|
||||||
|
Setup with OAuth
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative my-4">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t border-border" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-brand-500/5 px-2 text-muted-foreground">
|
||||||
|
or paste manually
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fallback: Manual token entry */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-foreground text-sm">
|
||||||
|
Paste token from{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
||||||
|
claude setup-token
|
||||||
|
</code>
|
||||||
|
:
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Paste token here..."
|
||||||
|
value={oauthToken}
|
||||||
|
onChange={(e) => setOAuthToken(e.target.value)}
|
||||||
|
className="bg-input border-border text-foreground"
|
||||||
|
data-testid="oauth-token-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAuthMethod(null)}
|
||||||
|
className="border-border"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveOAuthToken(oauthToken)}
|
||||||
|
disabled={isSavingOAuth || !oauthToken.trim()}
|
||||||
|
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
||||||
|
data-testid="save-oauth-token-button"
|
||||||
|
>
|
||||||
|
{isSavingOAuth ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save Token"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 rounded bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<p className="text-sm text-yellow-600">
|
||||||
|
<AlertCircle className="w-4 h-4 inline mr-1" />
|
||||||
|
Install Claude CLI first to use subscription
|
||||||
|
authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : authMethod === "api_key" ? (
|
||||||
|
/* Option 2: API Key */
|
||||||
|
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Key className="w-5 h-5 text-green-500 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-foreground">API Key</p>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Pay-per-use with your Anthropic API key
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="anthropic-key"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
Anthropic API Key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="anthropic-key"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-ant-..."
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
className="bg-input border-border text-foreground"
|
||||||
|
data-testid="anthropic-api-key-input"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get your API key from{" "}
|
||||||
|
<a
|
||||||
|
href="https://console.anthropic.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-brand-500 hover:underline"
|
||||||
|
>
|
||||||
|
console.anthropic.com
|
||||||
|
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAuthMethod(null)}
|
||||||
|
className="border-border"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveApiKeyToken(apiKey)}
|
||||||
|
disabled={isSavingApiKey || !apiKey.trim()}
|
||||||
|
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||||
|
data-testid="save-anthropic-key-button"
|
||||||
|
>
|
||||||
|
{isSavingApiKey ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save API Key"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Auth Method Selection */
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setAuthMethod("token")}
|
||||||
|
className="p-4 rounded-lg border border-border hover:border-brand-500/50 bg-card hover:bg-brand-500/5 transition-all text-left"
|
||||||
|
data-testid="select-subscription-auth"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Shield className="w-6 h-6 text-brand-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
Subscription
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Use your Claude subscription
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-brand-500 mt-2">
|
||||||
|
No API charges
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setAuthMethod("api_key")}
|
||||||
|
className="p-4 rounded-lg border border-border hover:border-green-500/50 bg-card hover:bg-green-500/5 transition-all text-left"
|
||||||
|
data-testid="select-api-key-auth"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Key className="w-6 h-6 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">API Key</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Use Anthropic API key
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-green-500 mt-2">Pay-per-use</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success State */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Card className="bg-green-500/5 border-green-500/20">
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
Claude is ready to use!
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You
|
||||||
|
can proceed to the next step
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onSkip}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||||
|
data-testid="claude-next-button"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth Setup Modal */}
|
||||||
|
<SetupTokenModal
|
||||||
|
open={showTokenModal}
|
||||||
|
onClose={() => setShowTokenModal(false)}
|
||||||
|
onTokenObtained={handleTokenFromModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
app/src/components/views/setup-view/steps/codex-setup-step.tsx
Normal file
432
app/src/components/views/setup-view/steps/codex-setup-step.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
Terminal,
|
||||||
|
Key,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { StatusBadge, TerminalOutput } from "../components";
|
||||||
|
import {
|
||||||
|
useCliStatus,
|
||||||
|
useCliInstallation,
|
||||||
|
useTokenSave,
|
||||||
|
} from "../hooks";
|
||||||
|
|
||||||
|
interface CodexSetupStepProps {
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexSetupStep({
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onSkip,
|
||||||
|
}: CodexSetupStepProps) {
|
||||||
|
const {
|
||||||
|
codexCliStatus,
|
||||||
|
codexAuthStatus,
|
||||||
|
setCodexCliStatus,
|
||||||
|
setCodexAuthStatus,
|
||||||
|
setCodexInstallProgress,
|
||||||
|
} = useSetupStore();
|
||||||
|
const { setApiKeys, apiKeys } = useAppStore();
|
||||||
|
|
||||||
|
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
|
||||||
|
// Use custom hooks
|
||||||
|
const { isChecking, checkStatus } = useCliStatus({
|
||||||
|
cliType: "codex",
|
||||||
|
statusApi: () =>
|
||||||
|
getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
|
||||||
|
setCliStatus: setCodexCliStatus,
|
||||||
|
setAuthStatus: setCodexAuthStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||||
|
cliType: "codex",
|
||||||
|
installApi: () =>
|
||||||
|
getElectronAPI().setup?.installCodex() || Promise.reject(),
|
||||||
|
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||||
|
onSuccess: checkStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSaving: isSavingKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||||
|
provider: "openai",
|
||||||
|
onSuccess: () => {
|
||||||
|
setCodexAuthStatus({
|
||||||
|
authenticated: true,
|
||||||
|
method: "api_key",
|
||||||
|
apiKeyValid: true,
|
||||||
|
});
|
||||||
|
setApiKeys({ ...apiKeys, openai: apiKey });
|
||||||
|
setShowApiKeyInput(false);
|
||||||
|
checkStatus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync install progress to store
|
||||||
|
useEffect(() => {
|
||||||
|
setCodexInstallProgress({
|
||||||
|
isInstalling,
|
||||||
|
output: installProgress.output,
|
||||||
|
});
|
||||||
|
}, [isInstalling, installProgress, setCodexInstallProgress]);
|
||||||
|
|
||||||
|
// Check status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkStatus();
|
||||||
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const copyCommand = (command: string) => {
|
||||||
|
navigator.clipboard.writeText(command);
|
||||||
|
toast.success("Command copied to clipboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai;
|
||||||
|
|
||||||
|
const getAuthMethodLabel = () => {
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
if (apiKeys.openai) return "API Key (Manual)";
|
||||||
|
if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)";
|
||||||
|
if (codexAuthStatus?.method === "env") return "API Key (Environment)";
|
||||||
|
if (codexAuthStatus?.method === "cli_verified")
|
||||||
|
return "CLI Login (ChatGPT)";
|
||||||
|
return "Authenticated";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Terminal className="w-8 h-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||||
|
Codex CLI Setup
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
OpenAI's GPT-5.1 Codex for advanced code generation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Card */}
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Installation Status</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={checkStatus}
|
||||||
|
disabled={isChecking}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground">CLI Installation</span>
|
||||||
|
{isChecking ? (
|
||||||
|
<StatusBadge status="checking" label="Checking..." />
|
||||||
|
) : codexCliStatus?.installed ? (
|
||||||
|
<StatusBadge status="installed" label="Installed" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge status="not_installed" label="Not Installed" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{codexCliStatus?.version && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Version</span>
|
||||||
|
<span className="text-sm font-mono text-foreground">
|
||||||
|
{codexCliStatus.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground">Authentication</span>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status="authenticated" label="Authenticated" />
|
||||||
|
{getAuthMethodLabel() && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({getAuthMethodLabel()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<StatusBadge
|
||||||
|
status="not_authenticated"
|
||||||
|
label="Not Authenticated"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Installation Section */}
|
||||||
|
{!codexCliStatus?.installed && (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Install Codex CLI
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Install via npm (Node.js required)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm text-muted-foreground">
|
||||||
|
npm (Global installation)
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||||
|
npm install -g @openai/codex
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyCommand("npm install -g @openai/codex")}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInstalling && (
|
||||||
|
<TerminalOutput lines={installProgress.output} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={install}
|
||||||
|
disabled={isInstalling}
|
||||||
|
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||||
|
data-testid="install-codex-button"
|
||||||
|
>
|
||||||
|
{isInstalling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Installing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Auto Install
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5" />
|
||||||
|
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||||
|
Requires Node.js to be installed. If the auto-install fails,
|
||||||
|
try running the command manually in your terminal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Authentication Section */}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Key className="w-5 h-5" />
|
||||||
|
Authentication
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Codex requires an OpenAI API key</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{codexCliStatus?.installed && (
|
||||||
|
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Terminal className="w-5 h-5 text-green-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
Authenticate via CLI
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Run this command in your terminal:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="bg-muted px-3 py-1 rounded text-sm font-mono text-foreground">
|
||||||
|
codex auth login
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyCommand("codex auth login")}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t border-border" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-card px-2 text-muted-foreground">
|
||||||
|
or enter API key
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showApiKeyInput ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="openai-key" className="text-foreground">
|
||||||
|
OpenAI API Key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="openai-key"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-..."
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
className="bg-input border-border text-foreground"
|
||||||
|
data-testid="openai-api-key-input"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get your API key from{" "}
|
||||||
|
<a
|
||||||
|
href="https://platform.openai.com/api-keys"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-green-500 hover:underline"
|
||||||
|
>
|
||||||
|
platform.openai.com
|
||||||
|
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowApiKeyInput(false)}
|
||||||
|
className="border-border"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveApiKeyToken(apiKey)}
|
||||||
|
disabled={isSavingKey || !apiKey.trim()}
|
||||||
|
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
||||||
|
data-testid="save-openai-key-button"
|
||||||
|
>
|
||||||
|
{isSavingKey ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save API Key"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowApiKeyInput(true)}
|
||||||
|
className="w-full border-border"
|
||||||
|
data-testid="use-openai-key-button"
|
||||||
|
>
|
||||||
|
<Key className="w-4 h-4 mr-2" />
|
||||||
|
Enter OpenAI API Key
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success State */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Card className="bg-green-500/5 border-green-500/20">
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
Codex is ready to use!
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{getAuthMethodLabel() &&
|
||||||
|
`Authenticated via ${getAuthMethodLabel()}. `}
|
||||||
|
You can proceed to complete setup
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onSkip}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="bg-green-500 hover:bg-green-600 text-white"
|
||||||
|
data-testid="codex-next-button"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
app/src/components/views/setup-view/steps/complete-step.tsx
Normal file
115
app/src/components/views/setup-view/steps/complete-step.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface CompleteStepProps {
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||||
|
const { claudeCliStatus, claudeAuthStatus, codexCliStatus, codexAuthStatus } =
|
||||||
|
useSetupStore();
|
||||||
|
const { apiKeys } = useAppStore();
|
||||||
|
|
||||||
|
const claudeReady =
|
||||||
|
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
||||||
|
apiKeys.anthropic;
|
||||||
|
const codexReady =
|
||||||
|
(codexCliStatus?.installed && codexAuthStatus?.authenticated) ||
|
||||||
|
apiKeys.openai;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
|
||||||
|
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||||
|
Setup Complete!
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
|
Your development environment is configured. You're ready to start
|
||||||
|
building with AI-powered assistance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||||
|
<Card
|
||||||
|
className={`bg-card/50 border ${
|
||||||
|
claudeReady ? "border-green-500/50" : "border-yellow-500/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{claudeReady ? (
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-6 h-6 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-medium text-foreground">Claude</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{claudeReady ? "Ready to use" : "Configure later in settings"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={`bg-card/50 border ${
|
||||||
|
codexReady ? "border-green-500/50" : "border-yellow-500/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{codexReady ? (
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-6 h-6 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-medium text-foreground">Codex</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{codexReady ? "Ready to use" : "Configure later in settings"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Your credentials are secure
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
API keys are stored locally and never sent to our servers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||||
|
onClick={onFinish}
|
||||||
|
data-testid="setup-finish-button"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
Start Building
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/src/components/views/setup-view/steps/index.ts
Normal file
5
app/src/components/views/setup-view/steps/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Re-export all setup step components for easier imports
|
||||||
|
export { WelcomeStep } from "./welcome-step";
|
||||||
|
export { CompleteStep } from "./complete-step";
|
||||||
|
export { ClaudeSetupStep } from "./claude-setup-step";
|
||||||
|
export { CodexSetupStep } from "./codex-setup-step";
|
||||||
69
app/src/components/views/setup-view/steps/welcome-step.tsx
Normal file
69
app/src/components/views/setup-view/steps/welcome-step.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Terminal, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface WelcomeStepProps {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="flex items-center justify-center mx-auto">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/logo.png" alt="Automaker Logo" className="w-24 h-24" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||||
|
Welcome to Automaker
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
|
Let's set up your development environment. We'll check for
|
||||||
|
required CLI tools and help you configure them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||||
|
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5 text-brand-500" />
|
||||||
|
Claude CLI
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Anthropic's powerful AI assistant for code generation and
|
||||||
|
analysis
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5 text-green-500" />
|
||||||
|
Codex CLI
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
OpenAI's GPT-5.1 Codex for advanced code generation tasks
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||||
|
onClick={onNext}
|
||||||
|
data-testid="setup-start-button"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user