mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +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
|
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:
|
**Step 3:** Run the Claude Code setup token command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -55,7 +62,15 @@ npm run dev:electron
|
|||||||
|
|
||||||
This will start both the Next.js development server and the Electron application.
|
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
|
```bash
|
||||||
echo "W"
|
echo "W"
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const os = require("os");
|
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
|
* 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.",
|
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;
|
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/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@electron/rebuild": "^4.0.2",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@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<{
|
authClaude: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
token?: string;
|
||||||
requiresManualAuth?: boolean;
|
requiresManualAuth?: boolean;
|
||||||
|
terminalOpened?: boolean;
|
||||||
command?: string;
|
command?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
output?: string;
|
||||||
}>;
|
}>;
|
||||||
authCodex: (apiKey?: string) => Promise<{
|
authCodex: (apiKey?: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -786,9 +790,13 @@ interface SetupAPI {
|
|||||||
}>;
|
}>;
|
||||||
authClaude: () => Promise<{
|
authClaude: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
token?: string;
|
||||||
requiresManualAuth?: boolean;
|
requiresManualAuth?: boolean;
|
||||||
|
terminalOpened?: boolean;
|
||||||
command?: string;
|
command?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
output?: string;
|
||||||
}>;
|
}>;
|
||||||
authCodex: (apiKey?: string) => Promise<{
|
authCodex: (apiKey?: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user