diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 644576bd..24065347 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -21,10 +21,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: package-lock.json + - name: Configure Git for HTTPS + # Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp) + # This is needed because SSH authentication isn't available in CI + run: git config --global url."https://github.com/".insteadOf "git@github.com:" + - name: Install dependencies # Use npm install instead of npm ci to correctly resolve platform-specific # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 8fb0a5f6..604c9d8d 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -20,10 +20,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: package-lock.json + - name: Configure Git for HTTPS + # Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp) + # This is needed because SSH authentication isn't available in CI + run: git config --global url."https://github.com/".insteadOf "git@github.com:" + - name: Install dependencies # Use npm install instead of npm ci to correctly resolve platform-specific # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d39673da..aa6ec548 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,10 +39,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: package-lock.json + - name: Configure Git for HTTPS + # Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp) + # This is needed because SSH authentication isn't available in CI + run: git config --global url."https://github.com/".insteadOf "git@github.com:" + - name: Install dependencies # Use npm install instead of npm ci to correctly resolve platform-specific # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4cdc9c6e..cadeb2f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,10 +20,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: package-lock.json + - name: Configure Git for HTTPS + # Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp) + # This is needed because SSH authentication isn't available in CI + run: git config --global url."https://github.com/".insteadOf "git@github.com:" + - name: Install dependencies # Use npm install instead of npm ci to correctly resolve platform-specific # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) diff --git a/README.md b/README.md index 82ac4956..8c863c53 100644 --- a/README.md +++ b/README.md @@ -195,21 +195,17 @@ npm run lint Automaker supports multiple authentication methods (in order of priority): -| Method | Environment Variable | Description | -| -------------------- | ------------------------- | --------------------------------------------------------- | -| OAuth Token (env) | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` - uses your Claude subscription | -| OAuth Token (stored) | — | Stored in app credentials file | -| API Key (stored) | — | Anthropic API key stored in app | -| API Key (env) | `ANTHROPIC_API_KEY` | Pay-per-use API key | - -**Recommended:** Use `CLAUDE_CODE_OAUTH_TOKEN` if you have a Claude subscription. +| Method | Environment Variable | Description | +| ---------------- | -------------------- | ------------------------------- | +| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key | +| API Key (stored) | — | Anthropic API key stored in app | ### Persistent Setup (Optional) Add to your `~/.bashrc` or `~/.zshrc`: ```bash -export CLAUDE_CODE_OAUTH_TOKEN="YOUR_TOKEN_HERE" +export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE" ``` Then restart your terminal or run `source ~/.bashrc`. diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index 7f34a8f1..65c102b9 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -2,9 +2,6 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "export", - env: { - CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "", - }, }; export default nextConfig; diff --git a/apps/app/src/app/api/claude/test/route.ts b/apps/app/src/app/api/claude/test/route.ts index 36a46e3a..95dab4ba 100644 --- a/apps/app/src/app/api/claude/test/route.ts +++ b/apps/app/src/app/api/claude/test/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest) { const { apiKey } = await request.json(); // Use provided API key or fall back to environment variable - const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN; + const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY; if (!effectiveApiKey) { return NextResponse.json( diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 0f2e2d70..1f791058 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -2521,3 +2521,34 @@ .xml-editor .xml-highlight { z-index: 0; } + +/* Accordion animations - CSS-only approach */ +@keyframes accordion-down { + from { + height: 0; + opacity: 0; + } + to { + height: var(--accordion-content-height, auto); + opacity: 1; + } +} + +@keyframes accordion-up { + from { + height: var(--accordion-content-height, auto); + opacity: 1; + } + to { + height: 0; + opacity: 0; + } +} + +.animate-accordion-down { + animation: accordion-down 0.2s ease-out forwards; +} + +.animate-accordion-up { + animation: accordion-up 0.2s ease-out forwards; +} diff --git a/apps/app/src/components/ui/accordion.tsx b/apps/app/src/components/ui/accordion.tsx new file mode 100644 index 00000000..781d31f7 --- /dev/null +++ b/apps/app/src/components/ui/accordion.tsx @@ -0,0 +1,243 @@ +"use client"; + +import * as React from "react"; +import { ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type AccordionType = "single" | "multiple"; + +interface AccordionContextValue { + type: AccordionType; + value: string | string[]; + onValueChange: (value: string) => void; + collapsible?: boolean; +} + +const AccordionContext = React.createContext( + null +); + +interface AccordionProps extends React.HTMLAttributes { + type?: "single" | "multiple"; + value?: string | string[]; + defaultValue?: string | string[]; + onValueChange?: (value: string | string[]) => void; + collapsible?: boolean; +} + +const Accordion = React.forwardRef( + ( + { + type = "single", + value, + defaultValue, + onValueChange, + collapsible = false, + className, + children, + ...props + }, + ref + ) => { + const [internalValue, setInternalValue] = React.useState( + () => { + if (value !== undefined) return value; + if (defaultValue !== undefined) return defaultValue; + return type === "single" ? "" : []; + } + ); + + const currentValue = value !== undefined ? value : internalValue; + + const handleValueChange = React.useCallback( + (itemValue: string) => { + let newValue: string | string[]; + + if (type === "single") { + if (currentValue === itemValue && collapsible) { + newValue = ""; + } else if (currentValue === itemValue && !collapsible) { + return; + } else { + newValue = itemValue; + } + } else { + const currentArray = Array.isArray(currentValue) + ? currentValue + : [currentValue].filter(Boolean); + if (currentArray.includes(itemValue)) { + newValue = currentArray.filter((v) => v !== itemValue); + } else { + newValue = [...currentArray, itemValue]; + } + } + + if (value === undefined) { + setInternalValue(newValue); + } + onValueChange?.(newValue); + }, + [type, currentValue, collapsible, value, onValueChange] + ); + + const contextValue = React.useMemo( + () => ({ + type, + value: currentValue, + onValueChange: handleValueChange, + collapsible, + }), + [type, currentValue, handleValueChange, collapsible] + ); + + return ( + +
+ {children} +
+
+ ); + } +); +Accordion.displayName = "Accordion"; + +interface AccordionItemContextValue { + value: string; + isOpen: boolean; +} + +const AccordionItemContext = + React.createContext(null); + +interface AccordionItemProps extends React.HTMLAttributes { + value: string; +} + +const AccordionItem = React.forwardRef( + ({ className, value, children, ...props }, ref) => { + const accordionContext = React.useContext(AccordionContext); + + if (!accordionContext) { + throw new Error("AccordionItem must be used within an Accordion"); + } + + const isOpen = Array.isArray(accordionContext.value) + ? accordionContext.value.includes(value) + : accordionContext.value === value; + + const contextValue = React.useMemo( + () => ({ value, isOpen }), + [value, isOpen] + ); + + return ( + +
+ {children} +
+
+ ); + } +); +AccordionItem.displayName = "AccordionItem"; + +interface AccordionTriggerProps + extends React.ButtonHTMLAttributes {} + +const AccordionTrigger = React.forwardRef< + HTMLButtonElement, + AccordionTriggerProps +>(({ className, children, ...props }, ref) => { + const accordionContext = React.useContext(AccordionContext); + const itemContext = React.useContext(AccordionItemContext); + + if (!accordionContext || !itemContext) { + throw new Error("AccordionTrigger must be used within an AccordionItem"); + } + + const { onValueChange } = accordionContext; + const { value, isOpen } = itemContext; + + return ( +
+ +
+ ); +}); +AccordionTrigger.displayName = "AccordionTrigger"; + +interface AccordionContentProps extends React.HTMLAttributes {} + +const AccordionContent = React.forwardRef( + ({ className, children, ...props }, ref) => { + const itemContext = React.useContext(AccordionItemContext); + const contentRef = React.useRef(null); + const [height, setHeight] = React.useState(undefined); + + if (!itemContext) { + throw new Error("AccordionContent must be used within an AccordionItem"); + } + + const { isOpen } = itemContext; + + React.useEffect(() => { + if (contentRef.current) { + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setHeight(entry.contentRect.height); + } + }); + resizeObserver.observe(contentRef.current); + return () => resizeObserver.disconnect(); + } + }, []); + + return ( +
+
+
+ {children} +
+
+
+ ); + } +); +AccordionContent.displayName = "AccordionContent"; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/app/src/components/ui/checkbox.tsx b/apps/app/src/components/ui/checkbox.tsx index 69f4bf56..5b00e0cc 100644 --- a/apps/app/src/components/ui/checkbox.tsx +++ b/apps/app/src/components/ui/checkbox.tsx @@ -6,25 +6,37 @@ import { Check } from "lucide-react"; import { cn } from "@/lib/utils"; -const Checkbox = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - , "checked" | "defaultChecked"> { + checked?: boolean | "indeterminate"; + defaultChecked?: boolean | "indeterminate"; + onCheckedChange?: (checked: boolean) => void; + required?: boolean; +} + +const Checkbox = React.forwardRef( + ({ className, onCheckedChange, ...props }, ref) => ( + { + // Handle indeterminate state by treating it as false for consumers expecting boolean + if (onCheckedChange) { + onCheckedChange(checked === true); + } + }} + {...props} > - - - -)); + + + + + ) +); Checkbox.displayName = CheckboxPrimitive.Root.displayName; export { Checkbox }; diff --git a/apps/app/src/components/ui/sheet.tsx b/apps/app/src/components/ui/sheet.tsx index 84649ad0..0175dac2 100644 --- a/apps/app/src/components/ui/sheet.tsx +++ b/apps/app/src/components/ui/sheet.tsx @@ -1,39 +1,43 @@ -"use client" +"use client"; -import * as React from "react" -import * as SheetPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Sheet({ ...props }: React.ComponentProps) { - return + return ; } function SheetTrigger({ ...props }: React.ComponentProps) { - return + return ; } function SheetClose({ ...props }: React.ComponentProps) { - return + return ; } function SheetPortal({ ...props }: React.ComponentProps) { - return + return ; } -function SheetOverlay({ - className, - ...props -}: React.ComponentProps) { +interface SheetOverlayProps extends React.HTMLAttributes { + forceMount?: true; +} + +const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => { + const Overlay = SheetPrimitive.Overlay as React.ComponentType< + SheetOverlayProps & { "data-slot": string } + >; return ( - - ) + ); +}; + +interface SheetContentProps extends React.HTMLAttributes { + side?: "top" | "right" | "bottom" | "left"; + forceMount?: true; + onEscapeKeyDown?: (event: KeyboardEvent) => void; + onPointerDownOutside?: (event: PointerEvent) => void; + onInteractOutside?: (event: Event) => void; } -function SheetContent({ +const SheetContent = ({ className, children, side = "right", ...props -}: React.ComponentProps & { - side?: "top" | "right" | "bottom" | "left" -}) { +}: SheetContentProps) => { + const Content = SheetPrimitive.Content as React.ComponentType< + SheetContentProps & { "data-slot": string } + >; + const Close = SheetPrimitive.Close as React.ComponentType<{ + className: string; + children: React.ReactNode; + }>; + return ( - {children} - + Close - - + + - ) -} + ); +}; function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -88,7 +106,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex flex-col gap-1.5 p-4", className)} {...props} /> - ) + ); } function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -98,34 +116,39 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} /> - ) + ); } -function SheetTitle({ - className, - ...props -}: React.ComponentProps) { +interface SheetTitleProps extends React.HTMLAttributes {} + +const SheetTitle = ({ className, ...props }: SheetTitleProps) => { + const Title = SheetPrimitive.Title as React.ComponentType< + SheetTitleProps & { "data-slot": string } + >; return ( - - ) -} + ); +}; -function SheetDescription({ - className, - ...props -}: React.ComponentProps) { +interface SheetDescriptionProps + extends React.HTMLAttributes {} + +const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => { + const Description = SheetPrimitive.Description as React.ComponentType< + SheetDescriptionProps & { "data-slot": string } + >; return ( - - ) -} + ); +}; export { Sheet, @@ -136,4 +159,4 @@ export { SheetFooter, SheetTitle, SheetDescription, -} +}; diff --git a/apps/app/src/components/ui/slider.tsx b/apps/app/src/components/ui/slider.tsx index b76f2404..09253417 100644 --- a/apps/app/src/components/ui/slider.tsx +++ b/apps/app/src/components/ui/slider.tsx @@ -4,24 +4,38 @@ import * as React from "react"; import * as SliderPrimitive from "@radix-ui/react-slider"; import { cn } from "@/lib/utils"; -const Slider = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - - - -)); +interface SliderProps extends Omit, "defaultValue" | "dir"> { + value?: number[]; + defaultValue?: number[]; + onValueChange?: (value: number[]) => void; + onValueCommit?: (value: number[]) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + orientation?: "horizontal" | "vertical"; + dir?: "ltr" | "rtl"; + inverted?: boolean; + minStepsBetweenThumbs?: number; +} + +const Slider = React.forwardRef( + ({ className, ...props }, ref) => ( + + + + + + + ) +); Slider.displayName = SliderPrimitive.Root.displayName; export { Slider }; diff --git a/apps/app/src/components/views/settings-view/api-keys/api-keys-section.tsx b/apps/app/src/components/views/settings-view/api-keys/api-keys-section.tsx index be774940..527cbf22 100644 --- a/apps/app/src/components/views/settings-view/api-keys/api-keys-section.tsx +++ b/apps/app/src/components/views/settings-view/api-keys/api-keys-section.tsx @@ -1,23 +1,61 @@ import { useAppStore } from "@/store/app-store"; import { useSetupStore } from "@/store/setup-store"; import { Button } from "@/components/ui/button"; -import { Key, CheckCircle2 } from "lucide-react"; +import { Key, CheckCircle2, Settings, Trash2, Loader2 } from "lucide-react"; import { ApiKeyField } from "./api-key-field"; import { buildProviderConfigs } from "@/config/api-providers"; import { AuthenticationStatusDisplay } from "./authentication-status-display"; import { SecurityNotice } from "./security-notice"; import { useApiKeyManagement } from "./hooks/use-api-key-management"; import { cn } from "@/lib/utils"; +import { useState, useCallback } from "react"; +import { getElectronAPI } from "@/lib/electron"; +import { toast } from "sonner"; export function ApiKeysSection() { - const { apiKeys } = useAppStore(); - const { claudeAuthStatus } = useSetupStore(); + const { apiKeys, setApiKeys } = useAppStore(); + const { claudeAuthStatus, setClaudeAuthStatus, setSetupComplete } = useSetupStore(); + const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false); const { providerConfigParams, apiKeyStatus, handleSave, saved } = useApiKeyManagement(); const providerConfigs = buildProviderConfigs(providerConfigParams); + // Delete Anthropic API key + const deleteAnthropicKey = useCallback(async () => { + setIsDeletingAnthropicKey(true); + try { + const api = getElectronAPI(); + if (!api.setup?.deleteApiKey) { + toast.error("Delete API not available"); + return; + } + + const result = await api.setup.deleteApiKey("anthropic"); + if (result.success) { + setApiKeys({ ...apiKeys, anthropic: "" }); + setClaudeAuthStatus({ + authenticated: false, + method: "none", + hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, + }); + toast.success("Anthropic API key deleted"); + } else { + toast.error(result.error || "Failed to delete API key"); + } + } catch (error) { + toast.error("Failed to delete API key"); + } finally { + setIsDeletingAnthropicKey(false); + } + }, [apiKeys, setApiKeys, claudeAuthStatus, setClaudeAuthStatus]); + + // Open setup wizard + const openSetupWizard = useCallback(() => { + setSetupComplete(false); + }, [setSetupComplete]); + return (
- {/* Save Button */} -
+ {/* Action Buttons */} +
+ + {apiKeys.anthropic && ( + + )}
diff --git a/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx b/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx index 988e62cb..ad24d82a 100644 --- a/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx +++ b/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx @@ -48,14 +48,14 @@ export function AuthenticationStatusDisplay({ <>
- Authenticated + + Authenticated +
- {claudeAuthStatus.method === "oauth_token_env" - ? "Using CLAUDE_CODE_OAUTH_TOKEN" - : claudeAuthStatus.method === "oauth_token" + {claudeAuthStatus.method === "oauth_token" ? "Using stored OAuth token (subscription)" : claudeAuthStatus.method === "api_key_env" ? "Using ANTHROPIC_API_KEY" @@ -65,7 +65,9 @@ export function AuthenticationStatusDisplay({ ? "Using credentials file" : claudeAuthStatus.method === "cli_authenticated" ? "Using Claude CLI authentication" - : `Using ${claudeAuthStatus.method || "detected"} authentication`} + : `Using ${ + claudeAuthStatus.method || "detected" + } authentication`}
@@ -87,46 +89,6 @@ export function AuthenticationStatusDisplay({ )} - - {/* Google/Gemini Authentication Status */} -
-
- - - Gemini (Google) - -
-
- {apiKeyStatus?.hasGoogleKey ? ( - <> -
- - Authenticated -
-
- - Using GOOGLE_API_KEY -
- - ) : apiKeys.google ? ( - <> -
- - Authenticated -
-
- - Using stored API key -
- - ) : ( -
- - Not configured -
- )} -
-
); diff --git a/apps/app/src/components/views/setup-view/components/status-badge.tsx b/apps/app/src/components/views/setup-view/components/status-badge.tsx index cf9cfc24..44ba8794 100644 --- a/apps/app/src/components/views/setup-view/components/status-badge.tsx +++ b/apps/app/src/components/views/setup-view/components/status-badge.tsx @@ -1,4 +1,4 @@ -import { CheckCircle2, XCircle, Loader2 } from "lucide-react"; +import { CheckCircle2, XCircle, Loader2, AlertCircle } from "lucide-react"; interface StatusBadgeProps { status: @@ -6,7 +6,9 @@ interface StatusBadgeProps { | "not_installed" | "checking" | "authenticated" - | "not_authenticated"; + | "not_authenticated" + | "error" + | "unverified"; label: string; } @@ -25,11 +27,21 @@ export function StatusBadge({ status, label }: StatusBadgeProps) { icon: , className: "bg-red-500/10 text-red-500 border-red-500/20", }; + case "error": + return { + icon: , + className: "bg-red-500/10 text-red-500 border-red-500/20", + }; case "checking": return { icon: , className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", }; + case "unverified": + return { + icon: , + className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", + }; } }; diff --git a/apps/app/src/components/views/setup-view/dialogs/index.ts b/apps/app/src/components/views/setup-view/dialogs/index.ts index 0466469f..c762ecfe 100644 --- a/apps/app/src/components/views/setup-view/dialogs/index.ts +++ b/apps/app/src/components/views/setup-view/dialogs/index.ts @@ -1,2 +1,2 @@ // Re-export all setup dialog components for easier imports -export { SetupTokenModal } from "./setup-token-modal"; +// (SetupTokenModal was removed - setup flow now uses inline API key entry) diff --git a/apps/app/src/components/views/setup-view/dialogs/setup-token-modal.tsx b/apps/app/src/components/views/setup-view/dialogs/setup-token-modal.tsx deleted file mode 100644 index bf8a5256..00000000 --- a/apps/app/src/components/views/setup-view/dialogs/setup-token-modal.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"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(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 ( - - - - - - Claude Subscription Authentication - - - {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."} - - - - {/* Terminal Output */} -
- {output.map((line, index) => ( -
- {line.startsWith("Error") || line.startsWith("⚠") ? ( - {line} - ) : line.startsWith("✓") ? ( - {line} - ) : ( - line - )} -
- ))} - {output.length === 0 && ( -
Waiting to start...
- )} - {authState === "running" && ( -
- - Waiting for authentication... -
- )} -
- - {/* Manual Token Input (for fallback) */} - {(authState === "manual" || authState === "error") && ( -
-
- Run this command in your terminal: - - claude setup-token - - -
-
- - setManualToken(e.target.value)} - className="bg-input border-border text-foreground" - data-testid="manual-token-input" - /> -
-
- )} - - {/* Success State */} - {authState === "success" && ( -
- -
-

- Token captured successfully! -

-

- Click "Use Token" to save and continue. -

-
-
- )} - - {/* Error State */} - {error && authState === "error" && ( -
- -
-

Error

-

{error}

-
-
- )} - - - - - {authState === "idle" && ( - - )} - - {authState === "running" && ( - - )} - - {authState === "success" && ( - - )} - - {authState === "manual" && ( - - )} - - {authState === "error" && ( - <> - {manualToken.trim() && ( - - )} - - - )} - -
-
- ); -} diff --git a/apps/app/src/components/views/setup-view/hooks/index.ts b/apps/app/src/components/views/setup-view/hooks/index.ts index 88db7c8c..f4414692 100644 --- a/apps/app/src/components/views/setup-view/hooks/index.ts +++ b/apps/app/src/components/views/setup-view/hooks/index.ts @@ -1,5 +1,4 @@ // 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"; diff --git a/apps/app/src/components/views/setup-view/hooks/use-oauth-authentication.ts b/apps/app/src/components/views/setup-view/hooks/use-oauth-authentication.ts deleted file mode 100644 index f1b8957f..00000000 --- a/apps/app/src/components/views/setup-view/hooks/use-oauth-authentication.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from "react"; -import { getElectronAPI } from "@/lib/electron"; - -type AuthState = "idle" | "running" | "success" | "error" | "manual"; - -interface UseOAuthAuthenticationOptions { - cliType: "claude"; - enabled?: boolean; -} - -export function useOAuthAuthentication({ - cliType, - enabled = true, -}: UseOAuthAuthenticationOptions) { - const [authState, setAuthState] = useState("idle"); - const [output, setOutput] = useState([]); - const [token, setToken] = useState(""); - const [error, setError] = useState(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 auth API - const result = await api.setup.authClaude(); - - // 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 }; -} diff --git a/apps/app/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/app/src/components/views/setup-view/steps/claude-setup-step.tsx index 20637530..ece7886b 100644 --- a/apps/app/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/app/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -11,6 +11,12 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; import { useSetupStore } from "@/store/setup-store"; import { useAppStore } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; @@ -23,19 +29,17 @@ import { ArrowLeft, ExternalLink, Copy, - AlertCircle, RefreshCw, Download, - Shield, + Info, + AlertTriangle, + ShieldCheck, + XCircle, + Trash2, } from "lucide-react"; import { toast } from "sonner"; -import { SetupTokenModal } from "../dialogs"; import { StatusBadge, TerminalOutput } from "../components"; -import { - useCliStatus, - useCliInstallation, - useTokenSave, -} from "../hooks"; +import { useCliStatus, useCliInstallation, useTokenSave } from "../hooks"; interface ClaudeSetupStepProps { onNext: () => void; @@ -43,9 +47,12 @@ interface ClaudeSetupStepProps { 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 +type VerificationStatus = "idle" | "verifying" | "verified" | "error"; + +// Claude Setup Step +// Users can either: +// 1. Have Claude CLI installed and authenticated (verified by running a test query) +// 2. Provide an Anthropic API key manually export function ClaudeSetupStep({ onNext, onBack, @@ -60,10 +67,24 @@ export function ClaudeSetupStep({ } = 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); + + // CLI Verification state + const [cliVerificationStatus, setCliVerificationStatus] = + useState("idle"); + const [cliVerificationError, setCliVerificationError] = useState< + string | null + >(null); + + // API Key Verification state + const [apiKeyVerificationStatus, setApiKeyVerificationStatus] = + useState("idle"); + const [apiKeyVerificationError, setApiKeyVerificationError] = useState< + string | null + >(null); + + // Delete API Key state + const [isDeletingApiKey, setIsDeletingApiKey] = useState(false); // Memoize API functions to prevent infinite loops const statusApi = useCallback( @@ -101,34 +122,151 @@ export function ClaudeSetupStep({ 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 }); + toast.success("API key saved successfully!"); + }, + } + ); - 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(); - }, - }); + // Verify CLI authentication by running a test query (uses CLI credentials only, not API key) + const verifyCliAuth = useCallback(async () => { + setCliVerificationStatus("verifying"); + setCliVerificationError(null); + + try { + const api = getElectronAPI(); + if (!api.setup?.verifyClaudeAuth) { + setCliVerificationStatus("error"); + setCliVerificationError("Verification API not available"); + return; + } + + // Pass "cli" to verify CLI authentication only (ignores any API key) + const result = await api.setup.verifyClaudeAuth("cli"); + + // Check for "Limit reached" error - treat as unverified + const hasLimitReachedError = + result.error?.toLowerCase().includes("limit reached") || + result.error?.toLowerCase().includes("rate limit"); + + if (result.authenticated && !hasLimitReachedError) { + setCliVerificationStatus("verified"); + setClaudeAuthStatus({ + authenticated: true, + method: "cli_authenticated", + hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, + }); + toast.success("Claude CLI authentication verified!"); + } else { + setCliVerificationStatus("error"); + setCliVerificationError( + hasLimitReachedError + ? "Rate limit reached. Please try again later." + : result.error || "Authentication failed" + ); + setClaudeAuthStatus({ + authenticated: false, + method: "none", + hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, + }); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Verification failed"; + // Also check for limit reached in caught errors + const isLimitError = + errorMessage.toLowerCase().includes("limit reached") || + errorMessage.toLowerCase().includes("rate limit"); + setCliVerificationStatus("error"); + setCliVerificationError( + isLimitError + ? "Rate limit reached. Please try again later." + : errorMessage + ); + } + }, [claudeAuthStatus, setClaudeAuthStatus]); + + // Verify API Key authentication (uses API key only) + const verifyApiKeyAuth = useCallback(async () => { + setApiKeyVerificationStatus("verifying"); + setApiKeyVerificationError(null); + + try { + const api = getElectronAPI(); + if (!api.setup?.verifyClaudeAuth) { + setApiKeyVerificationStatus("error"); + setApiKeyVerificationError("Verification API not available"); + return; + } + + // Pass "api_key" to verify API key authentication only + const result = await api.setup.verifyClaudeAuth("api_key"); + + if (result.authenticated) { + setApiKeyVerificationStatus("verified"); + setClaudeAuthStatus({ + authenticated: true, + method: "api_key", + hasCredentialsFile: false, + apiKeyValid: true, + }); + toast.success("API key authentication verified!"); + } else { + setApiKeyVerificationStatus("error"); + setApiKeyVerificationError(result.error || "Authentication failed"); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Verification failed"; + setApiKeyVerificationStatus("error"); + setApiKeyVerificationError(errorMessage); + } + }, [setClaudeAuthStatus]); + + // Delete API Key + const deleteApiKey = useCallback(async () => { + setIsDeletingApiKey(true); + try { + const api = getElectronAPI(); + if (!api.setup?.deleteApiKey) { + toast.error("Delete API not available"); + return; + } + + const result = await api.setup.deleteApiKey("anthropic"); + if (result.success) { + // Clear local state + setApiKey(""); + setApiKeys({ ...apiKeys, anthropic: "" }); + setApiKeyVerificationStatus("idle"); + setApiKeyVerificationError(null); + setClaudeAuthStatus({ + authenticated: false, + method: "none", + hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, + }); + toast.success("API key deleted successfully"); + } else { + toast.error(result.error || "Failed to delete API key"); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to delete API key"; + toast.error(errorMessage); + } finally { + setIsDeletingApiKey(false); + } + }, [apiKeys, setApiKeys, claudeAuthStatus, setClaudeAuthStatus]); // Sync install progress to store useEffect(() => { @@ -148,32 +286,52 @@ export function ClaudeSetupStep({ 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; + // User is ready if either method is verified + const hasApiKey = + !!apiKeys.anthropic || + claudeAuthStatus?.method === "api_key" || + claudeAuthStatus?.method === "api_key_env"; + const isCliVerified = cliVerificationStatus === "verified"; + const isApiKeyVerified = apiKeyVerificationStatus === "verified"; + const isReady = isCliVerified || isApiKeyVerified; 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"; + if (isApiKeyVerified) return "API Key"; + if (isCliVerified) return "Claude CLI"; + return null; + }; + + // Helper to get status badge for CLI + const getCliStatusBadge = () => { + if (cliVerificationStatus === "verified") { + return ; + } + if (cliVerificationStatus === "error") { + return ; + } + if (isChecking) { + return ; + } + if (claudeCliStatus?.installed) { + // Installed but not yet verified - show yellow unverified badge + return ; + } + return ; + }; + + // Helper to get status badge for API Key + const getApiKeyStatusBadge = () => { + if (apiKeyVerificationStatus === "verified") { + return ; + } + if (apiKeyVerificationStatus === "error") { + return ; + } + if (hasApiKey) { + // API key configured but not yet verified - show yellow unverified badge + return ; + } + return ; }; return ( @@ -183,18 +341,19 @@ export function ClaudeSetupStep({

- Claude Setup + API Key Setup

-

- Configure Claude for code generation -

+

Configure for code generation

- {/* Status Card */} + {/* Requirements Info */}
- Status + + + Authentication Methods +
+ + Choose one of the following methods to authenticate with Claude: +
- -
- CLI Installation - {isChecking ? ( - - ) : claudeCliStatus?.installed ? ( - - ) : ( - - )} -
+ + + {/* Option 1: Claude CLI */} + + +
+
+ +
+

Claude CLI

+

+ Use Claude Code subscription +

+
+
+ {getCliStatusBadge()} +
+
+ + {/* CLI Install Section */} + {!claudeCliStatus?.installed && ( +
+
+ +

+ Install Claude CLI +

+
- {claudeCliStatus?.version && ( -
- Version - - {claudeCliStatus.version} - -
- )} - -
- Authentication - {isAuthenticated ? ( -
- - {getAuthMethodLabel() && ( - - ({getAuthMethodLabel()}) - - )} -
- ) : ( - - )} -
- - - - {/* Installation Section */} - {!claudeCliStatus?.installed && ( - - - - - Install Claude CLI - - - Required for subscription-based authentication - - - -
- -
- - curl -fsSL https://claude.ai/install.sh | bash - - -
-
- -
- -
- - irm https://claude.ai/install.ps1 | iex - - -
-
- - {isInstalling && ( - - )} - - -
-
- )} - - {/* Authentication Section */} - {!isAuthenticated && ( - - - - - Authentication - - Choose your authentication method - - - {/* Option 1: Subscription Token */} - {authMethod === "token" ? ( -
-
- -
-

- Subscription Token -

-

- Use your Claude subscription (no API charges) -

- - {claudeCliStatus?.installed ? ( - <> - {/* Primary: Automated OAuth setup */} +
+ +
+ + curl -fsSL https://claude.ai/install.sh | bash + +
+
- {/* Divider */} -
-
- -
-
- - or paste manually - -
-
+
+ +
+ + irm https://claude.ai/install.ps1 | iex + + +
+
- {/* Fallback: Manual token entry */} -
-
+ )} + + {/* CLI Version Info */} + {claudeCliStatus?.installed && claudeCliStatus?.version && ( +

+ Version: {claudeCliStatus.version} +

+ )} + + {/* CLI Verification Status */} + {cliVerificationStatus === "verifying" && ( +
+ +
+

+ Verifying CLI authentication... +

+

+ Running a test query +

+
+
+ )} + + {cliVerificationStatus === "verified" && ( +
+ +
+

+ CLI Authentication verified! +

+

+ Your Claude CLI is working correctly. +

+
+
+ )} + + {cliVerificationStatus === "error" && cliVerificationError && ( +
+ +
+

+ Verification failed +

+

+ {cliVerificationError} +

+ {cliVerificationError.includes("login") && ( +
+

+ Run this command in your terminal: +

+
+ + claude login - : - - setOAuthToken(e.target.value)} - className="bg-input border-border text-foreground" - data-testid="oauth-token-input" - /> + +
+ )} +
+
+ )} -
- - -
+ {/* CLI Verify Button - Hide if CLI is verified */} + {cliVerificationStatus !== "verified" && ( + + )} + + + + {/* Option 2: API Key */} + + +
+
+ +
+

+ Anthropic API Key +

+

+ Pay-per-use with your own API key +

+
+
+ {getApiKeyStatusBadge()} +
+
+ + {/* API Key Input */} +
+
+ + setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + data-testid="anthropic-api-key-input" + /> +

+ Don't have an API key?{" "} + + Get one from Anthropic Console + + +

+
+ +
+ + {hasApiKey && ( + )}
-
- ) : authMethod === "api_key" ? ( - /* Option 2: API Key */ -
-
- -
-

API Key

-

- Pay-per-use with your Anthropic API key -

-
- - setApiKey(e.target.value)} - className="bg-input border-border text-foreground" - data-testid="anthropic-api-key-input" - /> -

- Get your API key from{" "} - - console.anthropic.com - - -

-
- -
- - -
-
-
-
- ) : ( - /* Auth Method Selection */ -
- + )} - -
- )} - - - )} + )} - {/* Success State */} - {isAuthenticated && ( - - -
-
- -
-
-

- Claude is ready to use! -

-

- {getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You - can proceed to the next step -

-
-
-
-
- )} + {apiKeyVerificationStatus === "error" && + apiKeyVerificationError && ( +
+ +
+

+ Verification failed +

+

+ {apiKeyVerificationError} +

+
+
+ )} + + {/* API Key Verify Button - Hide if API key is verified */} + {apiKeyVerificationStatus !== "verified" && ( + + )} + + + + + {/* Navigation */}
@@ -582,7 +760,8 @@ export function ClaudeSetupStep({
- - {/* OAuth Setup Modal */} - setShowTokenModal(false)} - onTokenObtained={handleTokenFromModal} - />
); } diff --git a/apps/app/src/components/views/setup-view/steps/complete-step.tsx b/apps/app/src/components/views/setup-view/steps/complete-step.tsx index bcffebc1..a4391e3d 100644 --- a/apps/app/src/components/views/setup-view/steps/complete-step.tsx +++ b/apps/app/src/components/views/setup-view/steps/complete-step.tsx @@ -1,11 +1,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { - CheckCircle2, - AlertCircle, - Shield, - Sparkles, -} from "lucide-react"; +import { CheckCircle2, AlertCircle, Shield, Sparkles } from "lucide-react"; import { useSetupStore } from "@/store/setup-store"; import { useAppStore } from "@/store/app-store"; @@ -14,8 +9,7 @@ interface CompleteStepProps { } export function CompleteStep({ onFinish }: CompleteStepProps) { - const { claudeCliStatus, claudeAuthStatus } = - useSetupStore(); + const { claudeCliStatus, claudeAuthStatus } = useSetupStore(); const { apiKeys } = useAppStore(); const claudeReady = @@ -38,44 +32,6 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {

-
- - -
- {claudeReady ? ( - - ) : ( - - )} -
-

Claude

-

- {claudeReady ? "Ready to use" : "Configure later in settings"} -

-
-
-
-
-
- -
-
- -
-

- Your credentials are secure -

-

- API keys are stored locally and never sent to our servers -

-
-
-
-