mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
7
.github/workflows/e2e-tests.yml
vendored
7
.github/workflows/e2e-tests.yml
vendored
@@ -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)
|
||||
|
||||
7
.github/workflows/pr-check.yml
vendored
7
.github/workflows/pr-check.yml
vendored
@@ -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)
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -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)
|
||||
|
||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -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)
|
||||
|
||||
14
README.md
14
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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
243
apps/app/src/components/ui/accordion.tsx
Normal file
243
apps/app/src/components/ui/accordion.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "Use Token" to save and continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && authState === "error" && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-6 h-6 text-red-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Error</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-5 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{authState === "idle" && (
|
||||
<Button
|
||||
onClick={startAuth}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="start-auth-button"
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Start Authentication
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "running" && (
|
||||
<Button disabled className="bg-brand-500">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Authenticating...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "success" && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
data-testid="use-token-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "manual" && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
disabled={!manualToken.trim()}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50"
|
||||
data-testid="use-manual-token-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use Token
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authState === "error" && (
|
||||
<>
|
||||
{manualToken.trim() && (
|
||||
<Button
|
||||
onClick={handleUseToken}
|
||||
className="bg-green-500 hover:bg-green-600 text-white"
|
||||
>
|
||||
Use Manual Token
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -19,29 +19,11 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
Welcome to Automaker
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Let's set up your development environment. We'll check for
|
||||
required CLI tools and help you configure them.
|
||||
To get started, we'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'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"
|
||||
|
||||
@@ -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",
|
||||
// },
|
||||
];
|
||||
|
||||
@@ -91,3 +91,4 @@ export const CHAT_TOOLS = [
|
||||
* Default max turns for chat
|
||||
*/
|
||||
export const CHAT_MAX_TURNS = 1000;
|
||||
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -72,3 +72,4 @@ export function getLogLevel(): LogLevel {
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLogLevel = level;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
104
apps/server/src/routes/setup/routes/delete-api-key.ts
Normal file
104
apps/server/src/routes/setup/routes/delete-api-key.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
330
apps/server/src/routes/setup/routes/verify-claude-auth.ts
Normal file
330
apps/server/src/routes/setup/routes/verify-claude-auth.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user