mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge pull request #31 from AutoMaker-Org/feat/enchance-welcome-page-setup
feat: enchance welcome page setup
This commit is contained in:
@@ -33,6 +33,13 @@ cd automaker
|
||||
npm install
|
||||
```
|
||||
|
||||
### Windows notes (in-app Claude auth)
|
||||
|
||||
- Node.js 22.x
|
||||
- Prebuilt PTY is bundled; Visual Studio build tools are not required for Claude auth.
|
||||
- If you prefer the external terminal flow, set `CLAUDE_AUTH_DISABLE_PTY=1`.
|
||||
- If you later add native modules beyond the prebuilt PTY, you may still need VS Build Tools + Python to rebuild those.
|
||||
|
||||
**Step 3:** Run the Claude Code setup token command:
|
||||
|
||||
```bash
|
||||
@@ -55,7 +62,15 @@ npm run dev:electron
|
||||
|
||||
This will start both the Next.js development server and the Electron application.
|
||||
|
||||
**Step 6:** MOST IMPORANT: Run the Following after all is setup
|
||||
### Auth smoke test (Windows)
|
||||
|
||||
1. Ensure dependencies are installed (prebuilt pty is included).
|
||||
2. Run `npm run dev:electron` and open the Setup modal.
|
||||
3. Click Start on Claude auth; watch the embedded terminal stream logs.
|
||||
4. Successful runs show “Token captured automatically.”; otherwise copy/paste the token from the log.
|
||||
5. Optional: `node --test tests/claude-cli-detector.test.js` to verify token parsing.
|
||||
|
||||
**Step 6:** MOST IMPORTANT: Run the Following after all is setup
|
||||
|
||||
```bash
|
||||
echo "W"
|
||||
|
||||
@@ -3,6 +3,22 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
let runPtyCommand = null;
|
||||
try {
|
||||
({ runPtyCommand } = require("./pty-runner"));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[ClaudeCliDetector] node-pty unavailable, will fall back to external terminal:",
|
||||
error?.message || error
|
||||
);
|
||||
}
|
||||
|
||||
const ANSI_REGEX =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[@-_]|\u001b\][^\u0007]*\u0007/g;
|
||||
|
||||
const stripAnsi = (text = "") => text.replace(ANSI_REGEX, "");
|
||||
|
||||
/**
|
||||
* Claude CLI Detector
|
||||
*
|
||||
@@ -459,6 +475,247 @@ class ClaudeCliDetector {
|
||||
note: "This token is from your Claude subscription and allows you to use Claude without API charges.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract OAuth token from command output
|
||||
* Tries multiple patterns to find the token
|
||||
* @param {string} output The command output
|
||||
* @returns {string|null} Extracted token or null
|
||||
*/
|
||||
static extractTokenFromOutput(output) {
|
||||
// Pattern 1: CLAUDE_CODE_OAUTH_TOKEN=<token> or CLAUDE_CODE_OAUTH_TOKEN: <token>
|
||||
const envMatch = output.match(
|
||||
/CLAUDE_CODE_OAUTH_TOKEN[=:]\s*["']?([a-zA-Z0-9_\-\.]+)["']?/i
|
||||
);
|
||||
if (envMatch) return envMatch[1];
|
||||
|
||||
// Pattern 2: "Token: <token>" or "token: <token>"
|
||||
const tokenLabelMatch = output.match(
|
||||
/\btoken[:\s]+["']?([a-zA-Z0-9_\-\.]{40,})["']?/i
|
||||
);
|
||||
if (tokenLabelMatch) return tokenLabelMatch[1];
|
||||
|
||||
// Pattern 3: Look for token after success/authenticated message
|
||||
const successMatch = output.match(
|
||||
/(?:success|authenticated|generated|token is)[^\n]*\n\s*([a-zA-Z0-9_\-\.]{40,})/i
|
||||
);
|
||||
if (successMatch) return successMatch[1];
|
||||
|
||||
// Pattern 4: Standalone long alphanumeric string on its own line (last resort)
|
||||
// This catches tokens that are printed on their own line
|
||||
const lines = output.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Token should be 40+ chars, alphanumeric with possible hyphens/underscores/dots
|
||||
if (/^[a-zA-Z0-9_\-\.]{40,}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run claude setup-token command to generate OAuth token
|
||||
* Opens an external terminal window since Claude CLI requires TTY for its Ink-based UI
|
||||
* @param {Function} onProgress Callback for progress updates
|
||||
* @returns {Promise<Object>} Result indicating terminal was opened
|
||||
*/
|
||||
static async runSetupToken(onProgress) {
|
||||
const detection = this.detectClaudeInstallation();
|
||||
|
||||
if (!detection.installed) {
|
||||
throw {
|
||||
success: false,
|
||||
error: "Claude CLI is not installed. Please install it first.",
|
||||
requiresManualAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
const claudePath = detection.path;
|
||||
const platform = process.platform;
|
||||
const preferPty =
|
||||
(platform === "win32" ||
|
||||
platform === "darwin" ||
|
||||
process.env.CLAUDE_AUTH_FORCE_PTY === "1") &&
|
||||
process.env.CLAUDE_AUTH_DISABLE_PTY !== "1";
|
||||
|
||||
const send = (data) => {
|
||||
if (onProgress && data) {
|
||||
onProgress({ type: "stdout", data });
|
||||
}
|
||||
};
|
||||
|
||||
if (preferPty && runPtyCommand) {
|
||||
try {
|
||||
send("Starting in-app terminal session for Claude auth...\n");
|
||||
send("If your browser opens, complete sign-in and return here.\n\n");
|
||||
|
||||
const ptyResult = await runPtyCommand(claudePath, ["setup-token"], {
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
onData: (chunk) => send(chunk),
|
||||
env: {
|
||||
FORCE_COLOR: "1",
|
||||
},
|
||||
});
|
||||
|
||||
const cleanedOutput = stripAnsi(ptyResult.output || "");
|
||||
const token = this.extractTokenFromOutput(cleanedOutput);
|
||||
|
||||
if (ptyResult.success && token) {
|
||||
send("\nCaptured token automatically.\n");
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
requiresManualAuth: false,
|
||||
terminalOpened: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (ptyResult.success && !token) {
|
||||
send(
|
||||
"\nCLI completed but token was not detected automatically. You can copy it above or retry.\n"
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
requiresManualAuth: true,
|
||||
terminalOpened: false,
|
||||
error: "Could not capture token automatically",
|
||||
output: cleanedOutput,
|
||||
};
|
||||
}
|
||||
|
||||
send(
|
||||
`\nClaude CLI exited with code ${ptyResult.exitCode}. Falling back to manual copy.\n`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: `Claude CLI exited with code ${ptyResult.exitCode}`,
|
||||
requiresManualAuth: true,
|
||||
output: cleanedOutput,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[ClaudeCliDetector] PTY auth failed, falling back:", error);
|
||||
send(
|
||||
`In-app terminal failed (${error?.message || "unknown error"}). Falling back to external terminal...\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: external terminal window
|
||||
if (preferPty && !runPtyCommand) {
|
||||
send("In-app terminal unavailable (node-pty not loaded).");
|
||||
} else if (!preferPty) {
|
||||
send("Using system terminal for authentication on this platform.");
|
||||
}
|
||||
send("Opening system terminal for authentication...\n");
|
||||
|
||||
// Helper function to check if a command exists asynchronously
|
||||
const commandExists = (cmd) => {
|
||||
return new Promise((resolve) => {
|
||||
require("child_process").exec(
|
||||
`which ${cmd}`,
|
||||
{ timeout: 1000 },
|
||||
(error) => {
|
||||
resolve(!error);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// For Linux, find available terminal first (async)
|
||||
let linuxTerminal = null;
|
||||
if (platform !== "win32" && platform !== "darwin") {
|
||||
const terminals = [
|
||||
["gnome-terminal", ["--", claudePath, "setup-token"]],
|
||||
["konsole", ["-e", claudePath, "setup-token"]],
|
||||
["xterm", ["-e", claudePath, "setup-token"]],
|
||||
["x-terminal-emulator", ["-e", `${claudePath} setup-token`]],
|
||||
];
|
||||
|
||||
for (const [term, termArgs] of terminals) {
|
||||
const exists = await commandExists(term);
|
||||
if (exists) {
|
||||
linuxTerminal = { command: term, args: termArgs };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Open command in external terminal since Claude CLI requires TTY
|
||||
let command, args;
|
||||
|
||||
if (platform === "win32") {
|
||||
// Windows: Open new cmd window that stays open
|
||||
command = "cmd";
|
||||
args = ["/c", "start", "cmd", "/k", `"${claudePath}" setup-token`];
|
||||
} else if (platform === "darwin") {
|
||||
// macOS: Open Terminal.app
|
||||
command = "osascript";
|
||||
args = [
|
||||
"-e",
|
||||
`tell application "Terminal" to do script "${claudePath} setup-token"`,
|
||||
"-e",
|
||||
'tell application "Terminal" to activate',
|
||||
];
|
||||
} else {
|
||||
// Linux: Use the terminal we found earlier
|
||||
if (!linuxTerminal) {
|
||||
reject({
|
||||
success: false,
|
||||
error:
|
||||
"Could not find a terminal emulator. Please run 'claude setup-token' manually in your terminal.",
|
||||
requiresManualAuth: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
command = linuxTerminal.command;
|
||||
args = linuxTerminal.args;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[ClaudeCliDetector] Spawning terminal:",
|
||||
command,
|
||||
args.join(" ")
|
||||
);
|
||||
|
||||
const proc = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
shell: platform === "win32",
|
||||
});
|
||||
|
||||
proc.unref();
|
||||
|
||||
proc.on("error", (error) => {
|
||||
console.error("[ClaudeCliDetector] Failed to open terminal:", error);
|
||||
reject({
|
||||
success: false,
|
||||
error: `Failed to open terminal: ${error.message}`,
|
||||
requiresManualAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Give the terminal a moment to open
|
||||
setTimeout(() => {
|
||||
send("Terminal window opened!\n\n");
|
||||
send("1. Complete the sign-in in your browser\n");
|
||||
send("2. Copy the token from the terminal\n");
|
||||
send("3. Paste it below\n");
|
||||
|
||||
// Resolve with manual auth required since we can't capture from external terminal
|
||||
resolve({
|
||||
success: true,
|
||||
requiresManualAuth: true,
|
||||
terminalOpened: true,
|
||||
message:
|
||||
"Terminal opened. Complete authentication and paste the token below.",
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeCliDetector;
|
||||
|
||||
84
app/electron/services/pty-runner.js
Normal file
84
app/electron/services/pty-runner.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const os = require("os");
|
||||
|
||||
// Prefer prebuilt to avoid native build issues.
|
||||
const pty = require("@homebridge/node-pty-prebuilt-multiarch");
|
||||
|
||||
/**
|
||||
* Minimal PTY helper to run CLI commands with a pseudo-terminal.
|
||||
* Useful for CLIs (like Claude) that need raw mode on Windows.
|
||||
*
|
||||
* @param {string} command Executable path
|
||||
* @param {string[]} args Arguments for the executable
|
||||
* @param {Object} options Additional spawn options
|
||||
* @param {(chunk: string) => void} [options.onData] Data callback
|
||||
* @param {string} [options.cwd] Working directory
|
||||
* @param {Object} [options.env] Extra env vars
|
||||
* @param {number} [options.cols] Terminal columns
|
||||
* @param {number} [options.rows] Terminal rows
|
||||
* @returns {Promise<{ success: boolean, exitCode: number, signal?: number, output: string, errorOutput: string }>}
|
||||
*/
|
||||
function runPtyCommand(command, args = [], options = {}) {
|
||||
const {
|
||||
onData,
|
||||
cwd = process.cwd(),
|
||||
env = {},
|
||||
cols = 120,
|
||||
rows = 30,
|
||||
} = options;
|
||||
|
||||
const mergedEnv = {
|
||||
...process.env,
|
||||
TERM: process.env.TERM || "xterm-256color",
|
||||
...env,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let ptyProcess;
|
||||
|
||||
try {
|
||||
ptyProcess = pty.spawn(command, args, {
|
||||
name: os.platform() === "win32" ? "Windows.Terminal" : "xterm-color",
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: mergedEnv,
|
||||
useConpty: true,
|
||||
});
|
||||
} catch (error) {
|
||||
return reject(error);
|
||||
}
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
output += data;
|
||||
if (typeof onData === "function") {
|
||||
onData(data);
|
||||
}
|
||||
});
|
||||
|
||||
// node-pty does not emit 'error' in practice, but guard anyway
|
||||
if (ptyProcess.on) {
|
||||
ptyProcess.on("error", (err) => {
|
||||
errorOutput += err?.message || "";
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
resolve({
|
||||
success: exitCode === 0,
|
||||
exitCode,
|
||||
signal,
|
||||
output,
|
||||
errorOutput,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runPtyCommand,
|
||||
};
|
||||
|
||||
1209
app/package-lock.json
generated
1209
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -56,6 +57,7 @@
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Map badge colors to complete Tailwind class names
|
||||
const getBadgeClasses = (badgeColor: string) => {
|
||||
const colorMap: Record<string, { border: string; bg: string; text: string }> = {
|
||||
"brand-500": {
|
||||
border: "hover:border-brand-500/50",
|
||||
bg: "hover:bg-brand-500/5",
|
||||
text: "text-brand-500",
|
||||
},
|
||||
"green-500": {
|
||||
border: "hover:border-green-500/50",
|
||||
bg: "hover:bg-green-500/5",
|
||||
text: "text-green-500",
|
||||
},
|
||||
"blue-500": {
|
||||
border: "hover:border-blue-500/50",
|
||||
bg: "hover:bg-blue-500/5",
|
||||
text: "text-blue-500",
|
||||
},
|
||||
"purple-500": {
|
||||
border: "hover:border-purple-500/50",
|
||||
bg: "hover:bg-purple-500/5",
|
||||
text: "text-purple-500",
|
||||
},
|
||||
};
|
||||
|
||||
return colorMap[badgeColor] || {
|
||||
border: "hover:border-brand-500/50",
|
||||
bg: "hover:bg-brand-500/5",
|
||||
text: "text-brand-500",
|
||||
};
|
||||
};
|
||||
|
||||
export function AuthMethodSelector({
|
||||
options,
|
||||
onSelect,
|
||||
}: AuthMethodSelectorProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{options.map((option) => {
|
||||
const badgeClasses = getBadgeClasses(option.badgeColor);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => onSelect(option.id)}
|
||||
className={`p-4 rounded-lg border border-border ${badgeClasses.border} bg-card ${badgeClasses.bg} 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 ${badgeClasses.text} 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";
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOAuthAuthentication } from "../hooks";
|
||||
|
||||
interface SetupTokenModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onTokenObtained: (token: string) => void;
|
||||
}
|
||||
|
||||
export function SetupTokenModal({
|
||||
open,
|
||||
onClose,
|
||||
onTokenObtained,
|
||||
}: SetupTokenModalProps) {
|
||||
// Use the OAuth authentication hook
|
||||
const { authState, output, token, error, startAuth, reset } =
|
||||
useOAuthAuthentication({ cliType: "claude" });
|
||||
|
||||
const [manualToken, setManualToken] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset();
|
||||
setManualToken("");
|
||||
}
|
||||
}, [open, reset]);
|
||||
|
||||
const handleUseToken = useCallback(() => {
|
||||
const tokenToUse = token || manualToken;
|
||||
if (tokenToUse.trim()) {
|
||||
onTokenObtained(tokenToUse.trim());
|
||||
onClose();
|
||||
}
|
||||
}, [token, manualToken, onTokenObtained, onClose]);
|
||||
|
||||
const copyCommand = useCallback(() => {
|
||||
navigator.clipboard.writeText("claude setup-token");
|
||||
toast.success("Command copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
reset();
|
||||
setManualToken("");
|
||||
}, [reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-2xl bg-card border-border"
|
||||
data-testid="setup-token-modal"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
Claude Subscription Authentication
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{authState === "idle" &&
|
||||
"Click Start to begin the authentication process."}
|
||||
{authState === "running" &&
|
||||
"Complete the sign-in in your browser..."}
|
||||
{authState === "success" &&
|
||||
"Authentication successful! Your token has been captured."}
|
||||
{authState === "error" &&
|
||||
"Authentication failed. Please try again or enter the token manually."}
|
||||
{authState === "manual" &&
|
||||
"Copy the token from your terminal and paste it below."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="bg-zinc-900 rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto border border-border mt-3"
|
||||
>
|
||||
{output.map((line, index) => (
|
||||
<div key={index} className="text-zinc-300 whitespace-pre-wrap">
|
||||
{line.startsWith("Error") || line.startsWith("⚠") ? (
|
||||
<span className="text-yellow-400">{line}</span>
|
||||
) : line.startsWith("✓") ? (
|
||||
<span className="text-green-400">{line}</span>
|
||||
) : (
|
||||
line
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{output.length === 0 && (
|
||||
<div className="text-zinc-500 italic">Waiting to start...</div>
|
||||
)}
|
||||
{authState === "running" && (
|
||||
<div className="flex items-center gap-2 text-brand-400 mt-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Waiting for authentication...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Token Input (for fallback) */}
|
||||
{(authState === "manual" || authState === "error") && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Run this command in your terminal:</span>
|
||||
<code className="bg-muted px-2 py-1 rounded font-mono text-foreground">
|
||||
claude setup-token
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyCommand}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-token" className="text-foreground">
|
||||
Paste your token:
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-token"
|
||||
type="password"
|
||||
placeholder="Paste token here..."
|
||||
value={manualToken}
|
||||
onChange={(e) => setManualToken(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid="manual-token-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{authState === "success" && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Token captured successfully!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click "Use Token" to save and continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && authState === "error" && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-6 h-6 text-red-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Error</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-5 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{authState === "idle" && (
|
||||
<Button
|
||||
onClick={startAuth}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="start-auth-button"
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Start Authentication
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "running" && (
|
||||
<Button disabled className="bg-brand-500">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Authenticating...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "success" && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="use-token-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "manual" && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
disabled={!manualToken.trim()}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50"
|
||||
data-testid="use-manual-token-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "error" && (
|
||||
<>
|
||||
{manualToken.trim() && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
>
|
||||
Use Manual Token
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
602
app/src/components/views/setup-view/steps/claude-setup-step.tsx
Normal file
602
app/src/components/views/setup-view/steps/claude-setup-step.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
"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);
|
||||
|
||||
// Memoize API functions to prevent infinite loops
|
||||
const statusApi = useCallback(
|
||||
() => getElectronAPI().setup?.getClaudeStatus() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const installApi = useCallback(
|
||||
() => getElectronAPI().setup?.installClaude() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const getStoreState = useCallback(
|
||||
() => useSetupStore.getState().claudeCliStatus,
|
||||
[]
|
||||
);
|
||||
|
||||
// Use custom hooks
|
||||
const { isChecking, checkStatus } = useCliStatus({
|
||||
cliType: "claude",
|
||||
statusApi,
|
||||
setCliStatus: setClaudeCliStatus,
|
||||
setAuthStatus: setClaudeAuthStatus,
|
||||
});
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||
cliType: "claude",
|
||||
installApi,
|
||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||
onSuccess: onInstallSuccess,
|
||||
getStoreState,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
445
app/src/components/views/setup-view/steps/codex-setup-step.tsx
Normal file
445
app/src/components/views/setup-view/steps/codex-setup-step.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
"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,
|
||||
} 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("");
|
||||
|
||||
// Memoize API functions to prevent infinite loops
|
||||
const statusApi = useCallback(
|
||||
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
const installApi = useCallback(
|
||||
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
|
||||
[]
|
||||
);
|
||||
|
||||
// Use custom hooks
|
||||
const { isChecking, checkStatus } = useCliStatus({
|
||||
cliType: "codex",
|
||||
statusApi,
|
||||
setCliStatus: setCodexCliStatus,
|
||||
setAuthStatus: setCodexAuthStatus,
|
||||
});
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||
cliType: "codex",
|
||||
installApi,
|
||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||
onSuccess: onInstallSuccess,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -374,9 +374,13 @@ export interface ElectronAPI {
|
||||
}>;
|
||||
authClaude: () => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
requiresManualAuth?: boolean;
|
||||
terminalOpened?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
output?: string;
|
||||
}>;
|
||||
authCodex: (apiKey?: string) => Promise<{
|
||||
success: boolean;
|
||||
@@ -786,9 +790,13 @@ interface SetupAPI {
|
||||
}>;
|
||||
authClaude: () => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
requiresManualAuth?: boolean;
|
||||
terminalOpened?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
output?: string;
|
||||
}>;
|
||||
authCodex: (apiKey?: string) => Promise<{
|
||||
success: boolean;
|
||||
|
||||
Reference in New Issue
Block a user