Merge pull request #108 from AutoMaker-Org/api-key-redesign

redesign our approach for api keys to not use claude setup-token
This commit is contained in:
Web Dev Cody
2025-12-15 20:13:54 -05:00
committed by GitHub
41 changed files with 1638 additions and 1145 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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`.

View File

@@ -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;

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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<AccordionContextValue | null>(
null
);
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
type?: "single" | "multiple";
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
collapsible?: boolean;
}
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
(
{
type = "single",
value,
defaultValue,
onValueChange,
collapsible = false,
className,
children,
...props
},
ref
) => {
const [internalValue, setInternalValue] = React.useState<string | string[]>(
() => {
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 (
<AccordionContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion"
className={cn("w-full", className)}
{...props}
>
{children}
</div>
</AccordionContext.Provider>
);
}
);
Accordion.displayName = "Accordion";
interface AccordionItemContextValue {
value: string;
isOpen: boolean;
}
const AccordionItemContext =
React.createContext<AccordionItemContextValue | null>(null);
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
}
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
({ 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 (
<AccordionItemContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion-item"
data-state={isOpen ? "open" : "closed"}
className={cn("border-b border-border", className)}
{...props}
>
{children}
</div>
</AccordionItemContext.Provider>
);
}
);
AccordionItem.displayName = "AccordionItem";
interface AccordionTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
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 (
<div data-slot="accordion-header" className="flex">
<button
ref={ref}
type="button"
data-slot="accordion-trigger"
data-state={isOpen ? "open" : "closed"}
aria-expanded={isOpen}
onClick={() => onValueChange(value)}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</button>
</div>
);
});
AccordionTrigger.displayName = "AccordionTrigger";
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
({ className, children, ...props }, ref) => {
const itemContext = React.useContext(AccordionItemContext);
const contentRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number | undefined>(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 (
<div
data-slot="accordion-content"
data-state={isOpen ? "open" : "closed"}
className="overflow-hidden text-sm transition-all duration-200 ease-out"
style={{
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
opacity: isOpen ? 1 : 0,
}}
{...props}
>
<div ref={contentRef}>
<div ref={ref} className={cn("pb-4 pt-0", className)}>
{children}
</div>
</div>
</div>
);
}
);
AccordionContent.displayName = "AccordionContent";
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -6,25 +6,37 @@ import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
checked?: boolean | "indeterminate";
defaultChecked?: boolean | "indeterminate";
onCheckedChange?: (checked: boolean) => void;
required?: boolean;
}
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
className
)}
onCheckedChange={(checked) => {
// Handle indeterminate state by treating it as false for consumers expecting boolean
if (onCheckedChange) {
onCheckedChange(checked === true);
}
}}
{...props}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -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<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
forceMount?: true;
}
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
SheetOverlayProps & { "data-slot": string }
>;
return (
<SheetPrimitive.Overlay
<Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
@@ -41,21 +45,35 @@ function SheetOverlay({
)}
{...props}
/>
)
);
};
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
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<typeof SheetPrimitive.Content> & {
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 (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
<Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -72,14 +90,14 @@ function SheetContent({
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</Close>
</Content>
</SheetPortal>
)
}
);
};
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<typeof SheetPrimitive.Title>) {
interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
const Title = SheetPrimitive.Title as React.ComponentType<
SheetTitleProps & { "data-slot": string }
>;
return (
<SheetPrimitive.Title
<Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
);
};
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
interface SheetDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {}
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
const Description = SheetPrimitive.Description as React.ComponentType<
SheetDescriptionProps & { "data-slot": string }
>;
return (
<SheetPrimitive.Description
<Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
);
};
export {
Sheet,
@@ -136,4 +159,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};

View File

@@ -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<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderPrimitive.Root>
));
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "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<HTMLSpanElement, SliderProps>(
({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderPrimitive.Root>
)
);
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -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 (
<div
id="api-keys"
@@ -55,8 +93,8 @@ export function ApiKeysSection() {
{/* Security Notice */}
<SecurityNotice />
{/* Save Button */}
<div className="flex items-center gap-4 pt-2">
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button
onClick={handleSave}
data-testid="save-settings"
@@ -79,6 +117,33 @@ export function ApiKeysSection() {
"Save API Keys"
)}
</Button>
<Button
onClick={openSetupWizard}
variant="outline"
className="h-10 border-border"
data-testid="run-setup-wizard"
>
<Settings className="w-4 h-4 mr-2" />
Run Setup Wizard
</Button>
{apiKeys.anthropic && (
<Button
onClick={deleteAnthropicKey}
disabled={isDeletingAnthropicKey}
variant="outline"
className="h-10 border-red-500/30 text-red-500 hover:bg-red-500/10 hover:border-red-500/50"
data-testid="delete-anthropic-key"
>
{isDeletingAnthropicKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
Delete Anthropic Key
</Button>
)}
</div>
</div>
</div>

View File

@@ -48,14 +48,14 @@ export function AuthenticationStatusDisplay({
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
<span className="text-green-400 font-medium">
Authenticated
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{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`}
</span>
</div>
</>
@@ -87,46 +89,6 @@ export function AuthenticationStatusDisplay({
)}
</div>
</div>
{/* Google/Gemini Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Sparkles className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-foreground">
Gemini (Google)
</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{apiKeyStatus?.hasGoogleKey ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>Using GOOGLE_API_KEY</span>
</div>
</>
) : apiKeys.google ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>Using stored API key</span>
</div>
</>
) : (
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
<span className="text-xs">Not configured</span>
</div>
)}
</div>
</div>
</div>
</div>
);

View File

@@ -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: <XCircle className="w-4 h-4" />,
className: "bg-red-500/10 text-red-500 border-red-500/20",
};
case "error":
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",
};
case "unverified":
return {
icon: <AlertCircle className="w-4 h-4" />,
className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
};
}
};

View File

@@ -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)

View File

@@ -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<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 &quot;Use Token&quot; 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>
);
}

View File

@@ -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";

View File

@@ -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<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 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 };
}

View File

@@ -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) {
</p>
</div>
<div className="max-w-md 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>
</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"

View File

@@ -19,29 +19,11 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
Welcome to Automaker
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Let&apos;s set up your development environment. We&apos;ll check for
required CLI tools and help you configure them.
To get started, we&apos;ll need to verify either claude code cli is
installed or you have Anthropic api keys
</p>
</div>
<div className="grid grid-cols-1 gap-4 max-w-md mx-auto place-items-center">
<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&apos;s powerful AI assistant for code generation and
analysis
</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"

View File

@@ -60,7 +60,7 @@ export const buildProviderConfigs = ({
}: ProviderConfigParams): ProviderConfig[] => [
{
key: "anthropic",
label: "Anthropic API Key (Claude)",
label: "Anthropic API Key",
inputId: "anthropic-key",
placeholder: "sk-ant-...",
value: anthropic.value,
@@ -82,33 +82,32 @@ export const buildProviderConfigs = ({
descriptionPrefix: "Used for Claude AI features. Get your key at",
descriptionLinkHref: "https://console.anthropic.com/account/keys",
descriptionLinkText: "console.anthropic.com",
descriptionSuffix:
". Alternatively, the CLAUDE_CODE_OAUTH_TOKEN environment variable can be used.",
},
{
key: "google",
label: "Google API Key (Gemini)",
inputId: "google-key",
placeholder: "AIza...",
value: google.value,
setValue: google.setValue,
showValue: google.show,
setShowValue: google.setShow,
hasStoredKey: apiKeys.google,
inputTestId: "google-api-key-input",
toggleTestId: "toggle-google-visibility",
testButton: {
onClick: google.onTest,
disabled: !google.value || google.testing,
loading: google.testing,
testId: "test-gemini-connection",
},
result: google.result,
resultTestId: "gemini-test-connection-result",
resultMessageTestId: "gemini-test-connection-message",
descriptionPrefix:
"Used for Gemini AI features (including image/design prompts). Get your key at",
descriptionLinkHref: "https://makersuite.google.com/app/apikey",
descriptionLinkText: "makersuite.google.com",
descriptionSuffix: ".",
},
// {
// key: "google",
// label: "Google API Key (Gemini)",
// inputId: "google-key",
// placeholder: "AIza...",
// value: google.value,
// setValue: google.setValue,
// showValue: google.show,
// setShowValue: google.setShow,
// hasStoredKey: apiKeys.google,
// inputTestId: "google-api-key-input",
// toggleTestId: "toggle-google-visibility",
// testButton: {
// onClick: google.onTest,
// disabled: !google.value || google.testing,
// loading: google.testing,
// testId: "test-gemini-connection",
// },
// result: google.result,
// resultTestId: "gemini-test-connection-result",
// resultMessageTestId: "gemini-test-connection-message",
// descriptionPrefix:
// "Used for Gemini AI features (including image/design prompts). Get your key at",
// descriptionLinkHref: "https://makersuite.google.com/app/apikey",
// descriptionLinkText: "makersuite.google.com",
// },
];

View File

@@ -91,3 +91,4 @@ export const CHAT_TOOLS = [
* Default max turns for chat
*/
export const CHAT_MAX_TURNS = 1000;

View File

@@ -355,6 +355,9 @@ export interface ElectronAPI {
provider: string,
apiKey: string
) => Promise<{ success: boolean; error?: string }>;
deleteApiKey: (
provider: string
) => Promise<{ success: boolean; error?: string; message?: string }>;
getApiKeys: () => Promise<{
success: boolean;
hasAnthropicKey: boolean;
@@ -369,6 +372,11 @@ export interface ElectronAPI {
isMac: boolean;
isLinux: boolean;
}>;
verifyClaudeAuth: (authMethod?: "cli" | "api_key") => Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
};
@@ -874,6 +882,9 @@ interface SetupAPI {
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
}>;
deleteApiKey: (
provider: string
) => Promise<{ success: boolean; error?: string; message?: string }>;
getPlatform: () => Promise<{
success: boolean;
platform: string;
@@ -883,6 +894,11 @@ interface SetupAPI {
isMac: boolean;
isLinux: boolean;
}>;
verifyClaudeAuth: (authMethod?: "cli" | "api_key") => Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
}
@@ -942,6 +958,11 @@ function createMockSetupAPI(): SetupAPI {
};
},
deleteApiKey: async (provider: string) => {
console.log("[Mock] Deleting API key for:", provider);
return { success: true, message: `API key for ${provider} deleted` };
},
getPlatform: async () => {
return {
success: true,
@@ -954,6 +975,16 @@ function createMockSetupAPI(): SetupAPI {
};
},
verifyClaudeAuth: async (authMethod?: "cli" | "api_key") => {
console.log("[Mock] Verifying Claude auth with method:", authMethod);
// Mock always returns not authenticated
return {
success: true,
authenticated: false,
error: "Mock environment - authentication not available",
};
},
onInstallProgress: (callback) => {
// Mock progress events
return () => {};

View File

@@ -438,6 +438,14 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
deleteApiKey: (
provider: string
): Promise<{
success: boolean;
error?: string;
message?: string;
}> => this.post("/api/setup/delete-api-key", { provider }),
getApiKeys: (): Promise<{
success: boolean;
hasAnthropicKey: boolean;
@@ -454,6 +462,12 @@ export class HttpApiClient implements ElectronAPI {
isLinux: boolean;
}> => this.get("/api/setup/platform"),
verifyClaudeAuth: (authMethod?: "cli" | "api_key"): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
},

View File

@@ -12,7 +12,7 @@ export interface CliStatus {
// Claude Auth Method - all possible authentication sources
export type ClaudeAuthMethod =
| "oauth_token_env" // CLAUDE_CODE_OAUTH_TOKEN environment variable
| "oauth_token_env"
| "oauth_token" // Stored OAuth token from claude login
| "api_key_env" // ANTHROPIC_API_KEY environment variable
| "api_key" // Manually stored API key
@@ -65,6 +65,7 @@ export interface SetupState {
export interface SetupActions {
// Setup flow
setCurrentStep: (step: SetupStep) => void;
setSetupComplete: (complete: boolean) => void;
completeSetup: () => void;
resetSetup: () => void;
setIsFirstRun: (isFirstRun: boolean) => void;
@@ -109,6 +110,12 @@ export const useSetupStore = create<SetupState & SetupActions>()(
// Setup flow
setCurrentStep: (step) => set({ currentStep: step }),
setSetupComplete: (complete) =>
set({
setupComplete: complete,
currentStep: complete ? "complete" : "welcome",
}),
completeSetup: () =>
set({ setupComplete: true, currentStep: "complete" }),

View File

@@ -50,30 +50,21 @@ const DATA_DIR = process.env.DATA_DIR || "./data";
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== "false"; // Default to true
// Check for required environment variables
// Claude Agent SDK supports EITHER OAuth token (subscription) OR API key (pay-per-use)
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (!hasAnthropicKey && !hasOAuthToken) {
if (!hasAnthropicKey) {
console.warn(`
╔═══════════════════════════════════════════════════════════════════════╗
║ ⚠️ WARNING: No Claude authentication configured ║
║ ║
║ The Claude Agent SDK requires authentication to function. ║
║ ║
Option 1 - Subscription (OAuth Token):
║ export CLAUDE_CODE_OAUTH_TOKEN="your-oauth-token" ║
║ ║
║ Option 2 - Pay-per-use (API Key): ║
Set your Anthropic API key:
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
║ ║
║ Or use the setup wizard in Settings to configure authentication. ║
╚═══════════════════════════════════════════════════════════════════════╝
`);
} else if (hasOAuthToken) {
console.log(
"[Server] ✓ CLAUDE_CODE_OAUTH_TOKEN detected (subscription auth)"
);
} else {
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)");
}

View File

@@ -72,3 +72,4 @@ export function getLogLevel(): LogLevel {
export function setLogLevel(level: LogLevel): void {
currentLogLevel = level;
}

View File

@@ -112,9 +112,7 @@ export class ClaudeProvider extends BaseProvider {
*/
async detectInstallation(): Promise<InstallationStatus> {
// Claude SDK is always available since it's a dependency
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
const hasApiKey = hasAnthropicKey || hasOAuthToken;
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
const status: InstallationStatus = {
installed: true,

View File

@@ -35,19 +35,9 @@ export function setRunningState(
* Helper to log authentication status
*/
export function logAuthStatus(context: string): void {
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
logger.info(`${context} - Auth Status:`);
logger.info(
` CLAUDE_CODE_OAUTH_TOKEN: ${
hasOAuthToken
? "SET (" +
process.env.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) +
"...)"
: "NOT SET"
}`
);
logger.info(
` ANTHROPIC_API_KEY: ${
hasApiKey
@@ -56,7 +46,7 @@ export function logAuthStatus(context: string): void {
}`
);
if (!hasOAuthToken && !hasApiKey) {
if (!hasApiKey) {
logger.warn("⚠️ WARNING: No authentication configured! SDK will fail.");
}
}

View File

@@ -21,3 +21,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
return router;
}

View File

@@ -15,9 +15,7 @@ export function createProvidersHandler() {
const providers: Record<string, any> = {
anthropic: {
available: statuses.claude?.installed || false,
hasApiKey:
!!process.env.ANTHROPIC_API_KEY ||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN,
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
},
google: {
available: !!process.env.GOOGLE_API_KEY,

View File

@@ -74,7 +74,6 @@ export async function getClaudeStatus() {
hasStoredOAuthToken: !!getApiKey("anthropic_oauth_token"),
hasStoredApiKey: !!getApiKey("anthropic"),
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY,
hasEnvOAuthToken: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
// Additional fields for detailed status
oauthTokenValid: false,
apiKeyValid: false,
@@ -148,11 +147,7 @@ export async function getClaudeStatus() {
}
// Environment variables override stored credentials (higher priority)
if (auth.hasEnvOAuthToken) {
auth.authenticated = true;
auth.oauthTokenValid = true;
auth.method = "oauth_token_env"; // OAuth token from CLAUDE_CODE_OAUTH_TOKEN env var
} else if (auth.hasEnvApiKey) {
if (auth.hasEnvApiKey) {
auth.authenticated = true;
auth.apiKeyValid = true;
auth.method = "api_key_env"; // API key from ANTHROPIC_API_KEY env var

View File

@@ -7,8 +7,10 @@ import { createClaudeStatusHandler } from "./routes/claude-status.js";
import { createInstallClaudeHandler } from "./routes/install-claude.js";
import { createAuthClaudeHandler } from "./routes/auth-claude.js";
import { createStoreApiKeyHandler } from "./routes/store-api-key.js";
import { createDeleteApiKeyHandler } from "./routes/delete-api-key.js";
import { createApiKeysHandler } from "./routes/api-keys.js";
import { createPlatformHandler } from "./routes/platform.js";
import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js";
export function createSetupRoutes(): Router {
const router = Router();
@@ -17,8 +19,10 @@ export function createSetupRoutes(): Router {
router.post("/install-claude", createInstallClaudeHandler());
router.post("/auth-claude", createAuthClaudeHandler());
router.post("/store-api-key", createStoreApiKeyHandler());
router.post("/delete-api-key", createDeleteApiKeyHandler());
router.get("/api-keys", createApiKeysHandler());
router.get("/platform", createPlatformHandler());
router.post("/verify-claude-auth", createVerifyClaudeAuthHandler());
return router;
}

View File

@@ -0,0 +1,104 @@
/**
* POST /delete-api-key endpoint - Delete a stored API key
*/
import type { Request, Response } from "express";
import { createLogger } from "../../../lib/logger.js";
import path from "path";
import fs from "fs/promises";
const logger = createLogger("Setup");
// In-memory storage reference (imported from common.ts pattern)
// We need to modify common.ts to export a deleteApiKey function
import { setApiKey } from "../common.js";
/**
* Remove an API key from the .env file
*/
async function removeApiKeyFromEnv(key: string): Promise<void> {
const envPath = path.join(process.cwd(), ".env");
try {
let envContent = "";
try {
envContent = await fs.readFile(envPath, "utf-8");
} catch {
// .env file doesn't exist, nothing to delete
return;
}
// Parse existing env content and remove the key
const lines = envContent.split("\n");
const keyRegex = new RegExp(`^${key}=`);
const newLines = lines.filter((line) => !keyRegex.test(line));
// Remove empty lines at the end
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === "") {
newLines.pop();
}
await fs.writeFile(envPath, newLines.join("\n") + (newLines.length > 0 ? "\n" : ""));
logger.info(`[Setup] Removed ${key} from .env file`);
} catch (error) {
logger.error(`[Setup] Failed to remove ${key} from .env:`, error);
throw error;
}
}
export function createDeleteApiKeyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { provider } = req.body as { provider: string };
if (!provider) {
res.status(400).json({
success: false,
error: "Provider is required",
});
return;
}
logger.info(`[Setup] Deleting API key for provider: ${provider}`);
// Map provider to env key name
const envKeyMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
google: "GOOGLE_GENERATIVE_AI_API_KEY",
openai: "OPENAI_API_KEY",
};
const envKey = envKeyMap[provider];
if (!envKey) {
res.status(400).json({
success: false,
error: `Unknown provider: ${provider}`,
});
return;
}
// Clear from in-memory storage
setApiKey(provider, "");
// Remove from environment
delete process.env[envKey];
// Remove from .env file
await removeApiKeyFromEnv(envKey);
logger.info(`[Setup] Successfully deleted API key for ${provider}`);
res.json({
success: true,
message: `API key for ${provider} has been deleted`,
});
} catch (error) {
logger.error("[Setup] Delete API key error:", error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : "Failed to delete API key",
});
}
};
}

View File

@@ -31,16 +31,8 @@ export function createStoreApiKeyHandler() {
setApiKey(provider, apiKey);
// Also set as environment variable and persist to .env
// IMPORTANT: OAuth tokens and API keys must be stored separately
// - OAuth tokens (subscription auth) -> CLAUDE_CODE_OAUTH_TOKEN
// - API keys (pay-per-use) -> ANTHROPIC_API_KEY
if (provider === "anthropic_oauth_token") {
// OAuth token from claude setup-token (subscription-based auth)
process.env.CLAUDE_CODE_OAUTH_TOKEN = apiKey;
await persistApiKeyToEnv("CLAUDE_CODE_OAUTH_TOKEN", apiKey);
logger.info("[Setup] Stored OAuth token as CLAUDE_CODE_OAUTH_TOKEN");
} else if (provider === "anthropic") {
// Direct API key (pay-per-use)
if (provider === "anthropic" || provider === "anthropic_oauth_token") {
// Both API key and OAuth token use ANTHROPIC_API_KEY
process.env.ANTHROPIC_API_KEY = apiKey;
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
logger.info("[Setup] Stored API key as ANTHROPIC_API_KEY");

View File

@@ -0,0 +1,330 @@
/**
* POST /verify-claude-auth endpoint - Verify Claude authentication by running a test query
* Supports verifying either CLI auth or API key auth independently
*/
import type { Request, Response } from "express";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { createLogger } from "../../../lib/logger.js";
import { getApiKey } from "../common.js";
const logger = createLogger("Setup");
// Known error patterns that indicate auth failure
const AUTH_ERROR_PATTERNS = [
"OAuth token revoked",
"Please run /login",
"please run /login",
"token revoked",
"invalid_api_key",
"authentication_error",
"unauthorized",
"not authenticated",
"authentication failed",
"invalid api key",
"api key is invalid",
];
// Patterns that indicate billing/credit issues - should FAIL verification
const BILLING_ERROR_PATTERNS = [
"credit balance is too low",
"credit balance too low",
"insufficient credits",
"insufficient balance",
"no credits",
"out of credits",
"billing",
"payment required",
"add credits",
];
// Patterns that indicate rate/usage limits - should FAIL verification
// Users need to wait or upgrade their plan
const RATE_LIMIT_PATTERNS = [
"limit reached",
"rate limit",
"rate_limit",
"resets", // Only valid if it's a temporary reset, not a billing issue
"/upgrade",
"extra-usage",
];
function isRateLimitError(text: string): boolean {
const lowerText = text.toLowerCase();
// First check if it's a billing error - billing errors are NOT rate limits
if (isBillingError(text)) {
return false;
}
return RATE_LIMIT_PATTERNS.some((pattern) =>
lowerText.includes(pattern.toLowerCase())
);
}
function isBillingError(text: string): boolean {
const lowerText = text.toLowerCase();
return BILLING_ERROR_PATTERNS.some((pattern) =>
lowerText.includes(pattern.toLowerCase())
);
}
function containsAuthError(text: string): boolean {
const lowerText = text.toLowerCase();
return AUTH_ERROR_PATTERNS.some((pattern) =>
lowerText.includes(pattern.toLowerCase())
);
}
export function createVerifyClaudeAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
// Get the auth method from the request body
const { authMethod } = req.body as { authMethod?: "cli" | "api_key" };
logger.info(
`[Setup] Verifying Claude authentication using method: ${
authMethod || "auto"
}`
);
// Create an AbortController with a 30-second timeout
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 30000);
let authenticated = false;
let errorMessage = "";
let receivedAnyContent = false;
// Save original env values
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
try {
// Configure environment based on auth method
if (authMethod === "cli") {
// For CLI verification, remove any API key so it uses CLI credentials only
delete process.env.ANTHROPIC_API_KEY;
logger.info(
"[Setup] Cleared API key environment for CLI verification"
);
} else if (authMethod === "api_key") {
// For API key verification, ensure we're using the stored API key
const storedApiKey = getApiKey("anthropic");
if (storedApiKey) {
process.env.ANTHROPIC_API_KEY = storedApiKey;
logger.info("[Setup] Using stored API key for verification");
} else {
// Check env var
if (!process.env.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: "No API key configured. Please enter an API key first.",
});
return;
}
}
}
// Run a minimal query to verify authentication
const stream = query({
prompt: "Reply with only the word 'ok'",
options: {
model: "claude-sonnet-4-20250514",
maxTurns: 1,
allowedTools: [],
abortController,
},
});
// Collect all messages and check for errors
const allMessages: string[] = [];
for await (const msg of stream) {
const msgStr = JSON.stringify(msg);
allMessages.push(msgStr);
logger.info("[Setup] Stream message:", msgStr.substring(0, 500));
// Check for billing errors FIRST - these should fail verification
if (isBillingError(msgStr)) {
logger.error("[Setup] Found billing error in message");
errorMessage =
"Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com";
authenticated = false;
break;
}
// Check if any part of the message contains auth errors
if (containsAuthError(msgStr)) {
logger.error("[Setup] Found auth error in message");
if (authMethod === "cli") {
errorMessage =
"CLI authentication failed. Please run 'claude login' in your terminal to authenticate.";
} else {
errorMessage = "API key is invalid or has been revoked.";
}
break;
}
// Check specifically for assistant messages with text content
if (msg.type === "assistant" && (msg as any).message?.content) {
const content = (msg as any).message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
const text = block.text;
logger.info("[Setup] Assistant text:", text);
if (containsAuthError(text)) {
if (authMethod === "cli") {
errorMessage =
"CLI authentication failed. Please run 'claude login' in your terminal to authenticate.";
} else {
errorMessage = "API key is invalid or has been revoked.";
}
break;
}
// Valid text response that's not an error
if (text.toLowerCase().includes("ok") || text.length > 0) {
receivedAnyContent = true;
}
}
}
}
}
// Check for result messages
if (msg.type === "result") {
const resultStr = JSON.stringify(msg);
// First check for billing errors - these should FAIL verification
if (isBillingError(resultStr)) {
logger.error(
"[Setup] Billing error detected - insufficient credits"
);
errorMessage =
"Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com";
authenticated = false;
break;
}
// Check if it's a rate limit error - should FAIL verification
else if (isRateLimitError(resultStr)) {
logger.warn(
"[Setup] Rate limit detected - treating as unverified"
);
errorMessage =
"Rate limit reached. Please wait a while before trying again or upgrade your plan.";
authenticated = false;
break;
} else if (containsAuthError(resultStr)) {
if (authMethod === "cli") {
errorMessage =
"CLI authentication failed. Please run 'claude login' in your terminal to authenticate.";
} else {
errorMessage = "API key is invalid or has been revoked.";
}
} else {
// Got a result without errors
receivedAnyContent = true;
}
}
}
// Determine authentication status
if (errorMessage) {
authenticated = false;
} else if (receivedAnyContent) {
authenticated = true;
} else {
// No content received - might be an issue
logger.warn("[Setup] No content received from stream");
logger.warn("[Setup] All messages:", allMessages.join("\n"));
errorMessage =
"No response received from Claude. Please check your authentication.";
}
} catch (error: unknown) {
const errMessage =
error instanceof Error ? error.message : String(error);
logger.error("[Setup] Claude auth verification exception:", errMessage);
// Check for billing errors FIRST - these always fail
if (isBillingError(errMessage)) {
authenticated = false;
errorMessage =
"Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com";
}
// Check for rate limit in exception - should FAIL verification
else if (isRateLimitError(errMessage)) {
authenticated = false;
errorMessage =
"Rate limit reached. Please wait a while before trying again or upgrade your plan.";
logger.warn(
"[Setup] Rate limit in exception - treating as unverified"
);
}
// If we already determined auth was successful, keep it
else if (authenticated) {
logger.info("[Setup] Auth already confirmed, ignoring exception");
}
// Check for auth-related errors in exception
else if (containsAuthError(errMessage)) {
if (authMethod === "cli") {
errorMessage =
"CLI authentication failed. Please run 'claude login' in your terminal to authenticate.";
} else {
errorMessage = "API key is invalid or has been revoked.";
}
} else if (
errMessage.includes("abort") ||
errMessage.includes("timeout")
) {
errorMessage = "Verification timed out. Please try again.";
} else if (
errMessage.includes("exit") &&
errMessage.includes("code 1")
) {
// Process exited with code 1 but we might have gotten rate limit info in the stream
// Check if we received any content that indicated auth worked
if (receivedAnyContent && !errorMessage) {
authenticated = true;
logger.info(
"[Setup] Process exit 1 but content received - auth valid"
);
} else if (!errorMessage) {
errorMessage = errMessage;
}
} else if (!errorMessage) {
errorMessage = errMessage;
}
} finally {
clearTimeout(timeoutId);
// Restore original environment
if (originalAnthropicKey !== undefined) {
process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
} else if (authMethod === "cli") {
// If we cleared it and there was no original, keep it cleared
delete process.env.ANTHROPIC_API_KEY;
}
}
logger.info("[Setup] Verification result:", {
authenticated,
errorMessage,
authMethod,
});
res.json({
success: true,
authenticated,
error: errorMessage || undefined,
});
} catch (error) {
logger.error("[Setup] Verify Claude auth endpoint error:", error);
res.status(500).json({
success: false,
authenticated: false,
error: error instanceof Error ? error.message : "Verification failed",
});
}
};
}

View File

@@ -12,7 +12,6 @@ describe("claude-provider.ts", () => {
vi.clearAllMocks();
provider = new ClaudeProvider();
delete process.env.ANTHROPIC_API_KEY;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
});
describe("getName", () => {
@@ -254,15 +253,6 @@ describe("claude-provider.ts", () => {
expect(result.authenticated).toBe(true);
});
it("should detect CLAUDE_CODE_OAUTH_TOKEN", async () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-token";
const result = await provider.detectInstallation();
expect(result.hasApiKey).toBe(true);
expect(result.authenticated).toBe(true);
});
it("should return hasApiKey false when no keys present", async () => {
const result = await provider.detectInstallation();

View File

@@ -179,9 +179,8 @@ Routes models that:
#### Authentication
Requires one of:
Requires:
- `ANTHROPIC_API_KEY` environment variable
- `CLAUDE_CODE_OAUTH_TOKEN` environment variable
#### Example Usage
@@ -704,9 +703,8 @@ describe("Provider Integration", () => {
### Claude Provider
```bash
# Required (one of):
# Required:
ANTHROPIC_API_KEY=sk-ant-...
CLAUDE_CODE_OAUTH_TOKEN=...
```
### Codex Provider

View File

@@ -580,3 +580,4 @@ The route organization pattern provides:
5. **Testability** - Functions can be tested independently
Apply this pattern to all route modules for consistency and improved code quality.