mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
feat: implement OAuth token setup for Claude CLI
- Added a new SetupTokenModal component for handling OAuth token authentication. - Integrated in-app terminal support using node-pty for seamless user experience. - Updated ClaudeCliDetector to extract tokens from command output and handle authentication flow. - Enhanced README with Windows-specific notes and authentication instructions. - Updated package.json and package-lock.json to include necessary dependencies for the new functionality.
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,6 +62,14 @@ 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.
|
||||||
|
|
||||||
|
### 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 IMPORANT: Run the Following after all is setup
|
**Step 6:** MOST IMPORANT: Run the Following after all is setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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,231 @@ 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" ||
|
||||||
|
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");
|
||||||
|
|
||||||
|
return await 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: Try common terminal emulators
|
||||||
|
const terminals = [
|
||||||
|
["gnome-terminal", ["--", claudePath, "setup-token"]],
|
||||||
|
["konsole", ["-e", claudePath, "setup-token"]],
|
||||||
|
["xterm", ["-e", claudePath, "setup-token"]],
|
||||||
|
["x-terminal-emulator", ["-e", `${claudePath} setup-token`]],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try to find an available terminal
|
||||||
|
for (const [term, termArgs] of terminals) {
|
||||||
|
try {
|
||||||
|
execSync(`which ${term}`, { stdio: "ignore" });
|
||||||
|
command = term;
|
||||||
|
args = termArgs;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// Terminal not found, try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Could not find a terminal emulator. Please run 'claude setup-token' manually in your terminal.",
|
||||||
|
requiresManualAuth: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
|||||||
387
app/src/components/views/setup-token-modal.tsx
Normal file
387
app/src/components/views/setup-token-modal.tsx
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"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 { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface SetupTokenModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onTokenObtained: (token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
||||||
|
|
||||||
|
export function SetupTokenModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onTokenObtained,
|
||||||
|
}: SetupTokenModalProps) {
|
||||||
|
const [authState, setAuthState] = useState<AuthState>("idle");
|
||||||
|
const [output, setOutput] = useState<string[]>([]);
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [manualToken, setManualToken] = useState("");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when output changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [output]);
|
||||||
|
|
||||||
|
// Reset state when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setAuthState("idle");
|
||||||
|
setOutput([]);
|
||||||
|
setToken("");
|
||||||
|
setError(null);
|
||||||
|
setManualToken("");
|
||||||
|
} else {
|
||||||
|
// Cleanup subscription when modal closes
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const startAuth = useCallback(async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.setup) {
|
||||||
|
setError("Setup API not available");
|
||||||
|
setAuthState("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState("running");
|
||||||
|
setOutput([
|
||||||
|
"Starting authentication...",
|
||||||
|
"Running Claude CLI in an embedded terminal so you don't need to copy/paste.",
|
||||||
|
"When your browser opens, complete sign-in and return here.",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
|
setError(null);
|
||||||
|
setToken("");
|
||||||
|
|
||||||
|
// Subscribe to progress events
|
||||||
|
if (api.setup.onAuthProgress) {
|
||||||
|
unsubscribeRef.current = api.setup.onAuthProgress((progress) => {
|
||||||
|
if (progress.cli === "claude" && progress.data) {
|
||||||
|
// Split by newlines and add each line
|
||||||
|
const normalized = progress.data.replace(/\r/g, "\n");
|
||||||
|
const lines = normalized
|
||||||
|
.split("\n")
|
||||||
|
.map((line: string) => line.trimEnd())
|
||||||
|
.filter((line: string) => line.length > 0);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
setOutput((prev) => [...prev, ...lines]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.setup.authClaude();
|
||||||
|
|
||||||
|
// Cleanup subscription
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success && result.token) {
|
||||||
|
setToken(result.token);
|
||||||
|
setAuthState("success");
|
||||||
|
setOutput((prev) => [
|
||||||
|
...prev,
|
||||||
|
"",
|
||||||
|
"✓ Authentication successful!",
|
||||||
|
"✓ Token captured automatically.",
|
||||||
|
]);
|
||||||
|
} else if (result.requiresManualAuth) {
|
||||||
|
// Terminal was opened - user needs to copy token manually
|
||||||
|
setAuthState("manual");
|
||||||
|
// Don't add extra messages if terminalOpened - the progress messages already explain
|
||||||
|
if (!result.terminalOpened) {
|
||||||
|
const extraMessages = [
|
||||||
|
"",
|
||||||
|
"⚠ Could not capture token automatically.",
|
||||||
|
];
|
||||||
|
if (result.error) {
|
||||||
|
extraMessages.push(result.error);
|
||||||
|
}
|
||||||
|
setOutput((prev) => [
|
||||||
|
...prev,
|
||||||
|
...extraMessages,
|
||||||
|
"Please copy the token from above and paste it below.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Authentication failed");
|
||||||
|
setAuthState("error");
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Cleanup subscription
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
unsubscribeRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: typeof err === "object" && err !== null && "error" in err
|
||||||
|
? String((err as { error: unknown }).error)
|
||||||
|
: "Authentication failed";
|
||||||
|
|
||||||
|
// Check if we should fall back to manual mode
|
||||||
|
if (
|
||||||
|
typeof err === "object" &&
|
||||||
|
err !== null &&
|
||||||
|
"requiresManualAuth" in err &&
|
||||||
|
(err as { requiresManualAuth: boolean }).requiresManualAuth
|
||||||
|
) {
|
||||||
|
setAuthState("manual");
|
||||||
|
setOutput((prev) => [
|
||||||
|
...prev,
|
||||||
|
"",
|
||||||
|
"⚠ " + errorMessage,
|
||||||
|
"Please copy the token manually and paste it below.",
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setError(errorMessage);
|
||||||
|
setAuthState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUseToken = useCallback(() => {
|
||||||
|
const 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(() => {
|
||||||
|
setAuthState("idle");
|
||||||
|
setOutput([]);
|
||||||
|
setError(null);
|
||||||
|
setToken("");
|
||||||
|
setManualToken("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { SetupTokenModal } from "./setup-token-modal";
|
||||||
|
|
||||||
// Step indicator component
|
// Step indicator component
|
||||||
function StepIndicator({
|
function StepIndicator({
|
||||||
@@ -212,6 +213,7 @@ function ClaudeSetupStep({
|
|||||||
const [oauthToken, setOAuthToken] = useState("");
|
const [oauthToken, setOAuthToken] = useState("");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||||
|
|
||||||
const checkStatus = useCallback(async () => {
|
const checkStatus = useCallback(async () => {
|
||||||
console.log("[Claude Setup] Starting status check...");
|
console.log("[Claude Setup] Starting status check...");
|
||||||
@@ -470,6 +472,49 @@ function ClaudeSetupStep({
|
|||||||
toast.success("Command copied to clipboard");
|
toast.success("Command copied to clipboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle token obtained from the OAuth modal
|
||||||
|
const handleTokenFromModal = useCallback(
|
||||||
|
async (token: string) => {
|
||||||
|
setOAuthToken(token);
|
||||||
|
setShowTokenModal(false);
|
||||||
|
|
||||||
|
// Auto-save the token
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const setupApi = api.setup;
|
||||||
|
|
||||||
|
if (setupApi?.storeApiKey) {
|
||||||
|
const result = await setupApi.storeApiKey(
|
||||||
|
"anthropic_oauth_token",
|
||||||
|
token
|
||||||
|
);
|
||||||
|
console.log("[Claude Setup] Store OAuth token result:", result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setClaudeAuthStatus({
|
||||||
|
authenticated: true,
|
||||||
|
method: "oauth_token",
|
||||||
|
hasCredentialsFile: false,
|
||||||
|
oauthTokenValid: true,
|
||||||
|
});
|
||||||
|
toast.success("Claude subscription token saved");
|
||||||
|
setAuthMethod(null);
|
||||||
|
await checkStatus();
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to save token", { description: result.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Claude Setup] Failed to save OAuth token:", error);
|
||||||
|
toast.error("Failed to save token");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[checkStatus, setClaudeAuthStatus]
|
||||||
|
);
|
||||||
|
|
||||||
const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic;
|
const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic;
|
||||||
|
|
||||||
const getAuthMethodLabel = () => {
|
const getAuthMethodLabel = () => {
|
||||||
@@ -666,31 +711,40 @@ function ClaudeSetupStep({
|
|||||||
|
|
||||||
{claudeCliStatus?.installed ? (
|
{claudeCliStatus?.installed ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3">
|
{/* Primary: Automated OAuth setup */}
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<Button
|
||||||
1. Run this command in your terminal:
|
onClick={() => setShowTokenModal(true)}
|
||||||
</p>
|
className="w-full bg-brand-500 hover:bg-brand-600 text-white mb-4"
|
||||||
<div className="flex items-center gap-2">
|
data-testid="setup-oauth-button"
|
||||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
>
|
||||||
claude setup-token
|
<Terminal className="w-4 h-4 mr-2" />
|
||||||
</code>
|
Setup with OAuth
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
{/* Divider */}
|
||||||
onClick={() => copyCommand("claude setup-token")}
|
<div className="relative my-4">
|
||||||
>
|
<div className="absolute inset-0 flex items-center">
|
||||||
<Copy className="w-4 h-4" />
|
<span className="w-full border-t border-border" />
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fallback: Manual token entry */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-foreground">
|
<Label className="text-foreground text-sm">
|
||||||
2. Paste the token here:
|
Paste token from{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
||||||
|
claude setup-token
|
||||||
|
</code>
|
||||||
|
:
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Paste token from claude setup-token..."
|
placeholder="Paste token here..."
|
||||||
value={oauthToken}
|
value={oauthToken}
|
||||||
onChange={(e) => setOAuthToken(e.target.value)}
|
onChange={(e) => setOAuthToken(e.target.value)}
|
||||||
className="bg-input border-border text-foreground"
|
className="bg-input border-border text-foreground"
|
||||||
@@ -893,6 +947,13 @@ function ClaudeSetupStep({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth Setup Modal */}
|
||||||
|
<SetupTokenModal
|
||||||
|
open={showTokenModal}
|
||||||
|
onClose={() => setShowTokenModal(false)}
|
||||||
|
onTokenObtained={handleTokenFromModal}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,9 +369,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;
|
||||||
@@ -781,9 +785,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