Compare commits

..

21 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
3b8b14b714 Initial plan 2025-12-16 00:28:05 +00:00
Cody Seibert
049f9a9e37 chore: add Git configuration for HTTPS in workflow files to support CI environment 2025-12-15 19:19:23 -05:00
Cody Seibert
19f1c32805 chore: update Node.js version in workflow files from 20 to 22 2025-12-15 19:08:00 -05:00
Cody Seibert
ece8ff8cbc Merge branch 'main' into api-key-redesign 2025-12-15 19:00:14 -05:00
Cody Seibert
a3a648aef1 feat: add Accordion component with customizable behavior and animations, update Checkbox and Slider components for improved functionality, and enhance package dependencies 2025-12-15 18:57:32 -05:00
Web Dev Cody
3bc2b74d30 Merge pull request #105 from AutoMaker-Org/fix/bug-button-position
Fix/bug button position
2025-12-15 17:57:08 -05:00
trueheads
123b471b68 How many Devs does it take to center a navbar icon? 3, as it turns out. 2025-12-15 15:13:43 -06:00
Cody Seibert
b66d228460 feat: enhance CLI and API key verification buttons to hide when already verified 2025-12-15 15:12:49 -05:00
Kacper
770d67d8c4 feat: refactor bug report button into a reusable component for improved sidebar functionality 2025-12-15 20:49:22 +01:00
Cody Seibert
d42857ec26 refactor: remove CLAUDE_CODE_OAUTH_TOKEN references and update authentication to use ANTHROPIC_API_KEY exclusively 2025-12-15 14:33:58 -05:00
Cody Seibert
54b977ee1b redesign our approach for api keys to not use claude setup-token 2025-12-15 14:24:18 -05:00
Kacper
e8999ba908 chore: update README to include a detailed Table of Contents and Community & Support section 2025-12-15 20:14:44 +01:00
Kacper
96c4383b29 feat: Whe sidebar is closed the bug button is overlapping the l... 2025-12-15 20:07:27 +01:00
Web Dev Cody
93d1d2c41a Merge pull request #104 from AutoMaker-Org/chore/update-readme
chore: update clone url from ssh to https
2025-12-15 13:52:58 -05:00
Shirone
b075af5bc9 chore: update clone url from ssh to https 2025-12-15 19:51:03 +01:00
Web Dev Cody
07ca7fccb8 Merge pull request #102 from AutoMaker-Org/feat/disable-worktree-in-ui
feat: In our Feature Defaults section in setting view we have a...
2025-12-15 12:47:31 -05:00
Web Dev Cody
797643ffdc Merge pull request #101 from AutoMaker-Org/readme-update
updating readme to reflect logo and featureset
2025-12-15 12:47:11 -05:00
trueheads
7d4052be95 adjustments 2025-12-15 11:24:01 -06:00
Shirone
1036719f2a Update apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-15 18:19:46 +01:00
Kacper
1ab520eda3 feat: In our Feature Defaults section in setting view we have a...
Implemented by Automaker auto-mode
2025-12-15 18:17:19 +01:00
trueheads
658f7d816e updating readme to reflect logo and featureset 2025-12-15 11:01:36 -06:00
47 changed files with 13649 additions and 12020 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

@@ -1,3 +1,7 @@
<p align="center">
<img src="apps/app/public/readme_logo.png" alt="Automaker Logo" height="80" />
</p>
> **[!TIP]**
>
> **Learn more about Agentic Coding!**
@@ -10,8 +14,39 @@
**Stop typing code. Start directing AI agents.**
<details open>
<summary><h2>Table of Contents</h2></summary>
- [What Makes Automaker Different?](#what-makes-automaker-different)
- [The Workflow](#the-workflow)
- [Powered by Claude Code](#powered-by-claude-code)
- [Why This Matters](#why-this-matters)
- [Security Disclaimer](#security-disclaimer)
- [Community & Support](#community--support)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [How to Run](#how-to-run)
- [Development Mode](#development-mode)
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
- [Web Browser Mode](#web-browser-mode)
- [Building for Production](#building-for-production)
- [Running Production Build](#running-production-build)
- [Testing](#testing)
- [Linting](#linting)
- [Authentication Options](#authentication-options)
- [Persistent Setup (Optional)](#persistent-setup-optional)
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Learn More](#learn-more)
- [License](#license)
</details>
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
![Automaker UI](https://i.imgur.com/jdwKydM.png)
## What Makes Automaker Different?
Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
@@ -48,6 +83,22 @@ The future of software development is **agentic coding**—where developers beco
---
## Community & Support
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
In the Discord, you can:
- 💬 Discuss agentic coding patterns and best practices
- 🧠 Share ideas for AI-driven development workflows
- 🛠️ Get help setting up or extending Automaker
- 🚀 Show off projects built with AI agents
- 🤝 Collaborate with other developers and contributors
👉 **Join the Discord:**
https://discord.gg/jjem7aEDKU
---
## Getting Started
### Prerequisites
@@ -60,7 +111,7 @@ The future of software development is **agentic coding**—where developers beco
```bash
# 1. Clone the repo
git clone git@github.com:AutoMaker-Org/automaker.git
git clone https://github.com/AutoMaker-Org/automaker.git
cd automaker
# 2. Install dependencies
@@ -144,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

@@ -1,123 +0,0 @@
# Automaker
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
---
> **[!CAUTION]**
>
> ## Security Disclaimer
>
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
>
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
>
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
>
> **[Read the full disclaimer](../DISCLAIMER.md)**
---
## Getting Started
**Step 1:** Clone this repository:
```bash
git clone git@github.com:AutoMaker-Org/automaker.git
cd automaker
```
**Step 2:** Install dependencies:
```bash
npm install
```
### Windows notes (in-app Claude auth)
- Node.js 22.x
- Prebuilt PTY is bundled; Visual Studio build tools are not required for Claude auth.
- If you prefer the external terminal flow, set `CLAUDE_AUTH_DISABLE_PTY=1`.
- If you later add native modules beyond the prebuilt PTY, you may still need VS Build Tools + Python to rebuild those.
**Step 3:** Run the Claude Code setup token command:
```bash
claude setup-token
```
> **⚠️ Warning:** This command will print your token to your terminal. Be careful if you're streaming or sharing your screen, as the token will be visible to anyone watching.
**Step 4:** Export the Claude Code OAuth token in your shell:
```bash
export CLAUDE_CODE_OAUTH_TOKEN="your-token-here"
```
**Step 5:** Start the development server:
```bash
npm run dev:electron
```
This will start both the Next.js development server and the Electron application.
### Auth smoke test (Windows)
1. Ensure dependencies are installed (prebuilt pty is included).
2. Run `npm run dev:electron` and open the Setup modal.
3. Click Start on Claude auth; watch the embedded terminal stream logs.
4. Successful runs show “Token captured automatically.”; otherwise copy/paste the token from the log.
5. Optional: `node --test tests/claude-cli-detector.test.js` to verify token parsing.
**Step 6:** MOST IMPORTANT: Run the Following after all is setup
```bash
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
```
## Features
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
- 📁 **Context Management** - Add context files to help AI agents understand your project better
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
-**Concurrent Processing** - Configure concurrency to process multiple features simultaneously
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
## Tech Stack
- [Next.js](https://nextjs.org) - React framework
- [Electron](https://www.electronjs.org/) - Desktop application framework
- [Tailwind CSS](https://tailwindcss.com/) - Styling
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## License
See [LICENSE](../LICENSE) for details.

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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

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

@@ -195,6 +195,33 @@ const PROJECT_THEME_OPTIONS = [
})),
] as const;
// Reusable Bug Report Button Component
const BugReportButton = ({
sidebarExpanded,
onClick
}: {
sidebarExpanded: boolean;
onClick: () => void;
}) => {
return (
<button
onClick={onClick}
className={cn(
"titlebar-no-drag px-3 py-2.5 rounded-xl",
"text-muted-foreground hover:text-foreground hover:bg-accent/80",
"border border-transparent hover:border-border/40",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.97]",
sidebarExpanded && "absolute right-3"
)}
title="Report Bug / Feature Request"
data-testid={sidebarExpanded ? "bug-report-link" : "bug-report-link-collapsed"}
>
<Bug className="w-4 h-4" />
</button>
);
};
export function Sidebar() {
const {
projects,
@@ -836,6 +863,12 @@ export function Sidebar() {
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
// Handle bug report button click
const handleBugReportClick = useCallback(() => {
const api = getElectronAPI();
api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues");
}, []);
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
@@ -1394,30 +1427,20 @@ export function Sidebar() {
</div>
)}
</div>
{/* Bug Report Button */}
<button
onClick={() => {
const api = getElectronAPI();
api.openExternalLink(
"https://github.com/AutoMaker-Org/automaker/issues"
);
}}
className={cn(
"titlebar-no-drag p-1.5 rounded-lg absolute right-3",
"text-muted-foreground hover:text-foreground hover:bg-accent/80",
"transition-all duration-200 ease-out",
"hover:scale-105 active:scale-95"
)}
title="Report Bug / Feature Request"
data-testid="bug-report-link"
>
<Bug className="w-4 h-4" />
</button>
{/* Bug Report Button - Inside logo container when expanded */}
{sidebarOpen && <BugReportButton sidebarExpanded onClick={handleBugReportClick} />}
</div>
{/* Bug Report Button - Collapsed sidebar version */}
{!sidebarOpen && (
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} onClick={handleBugReportClick} />
</div>
)}
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
<div className="flex items-center gap-2.5 titlebar-no-drag px-3 mt-4">
<div className="flex items-center gap-2.5 titlebar-no-drag px-3 mt-5">
<button
onClick={() => setShowNewProjectModal(true)}
className={cn(
@@ -1789,7 +1812,7 @@ export function Sidebar() {
)}
{/* Nav Items - Scrollable */}
<nav className="flex-1 overflow-y-auto px-3 mt-5 pb-2">
<nav className={cn("flex-1 overflow-y-auto px-3 pb-2", sidebarOpen ? "mt-5" : "mt1")}>
{!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">
@@ -1802,7 +1825,7 @@ export function Sidebar() {
) : currentProject ? (
// Navigation sections when project is selected
navSections.map((section, sectionIdx) => (
<div key={sectionIdx} className={sectionIdx > 0 ? "mt-6" : ""}>
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? "mt-6" : ""}>
{/* Section Label */}
{section.label && sidebarOpen && (
<div className="hidden lg:block px-3 mb-2">
@@ -1812,7 +1835,7 @@ export function Sidebar() {
</div>
)}
{section.label && !sidebarOpen && (
<div className="h-px bg-border/30 mx-2 mb-3"></div>
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
)}
{/* Nav Items */}

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

@@ -104,20 +104,21 @@ export function FeatureDefaultsSection({
<div className="border-t border-border/30" />
{/* Worktree Isolation Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="group flex items-start space-x-3 p-3 rounded-xl transition-colors duration-200 -mx-3 opacity-60">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) =>
onUseWorktreesChange(checked === true)
}
disabled={true}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
className="text-foreground font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
@@ -129,6 +130,9 @@ export function FeatureDefaultsSection({
Creates isolated git branches for each feature. When disabled,
agents work directly in the main project directory.
</p>
<p className="text-xs text-orange-500/80 leading-relaxed font-medium">
This feature is still under development and temporarily disabled.
</p>
</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.

22630
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,5 +29,8 @@
"test:headed": "npm run test:headed --workspace=apps/app",
"test:server": "npm run test --workspace=apps/server",
"test:server:coverage": "npm run test:cov --workspace=apps/server"
},
"dependencies": {
"cross-spawn": "^7.0.6"
}
}