mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Compare commits
21 Commits
v0.4.0
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b8b14b714 | ||
|
|
049f9a9e37 | ||
|
|
19f1c32805 | ||
|
|
ece8ff8cbc | ||
|
|
a3a648aef1 | ||
|
|
3bc2b74d30 | ||
|
|
123b471b68 | ||
|
|
b66d228460 | ||
|
|
770d67d8c4 | ||
|
|
d42857ec26 | ||
|
|
54b977ee1b | ||
|
|
e8999ba908 | ||
|
|
96c4383b29 | ||
|
|
93d1d2c41a | ||
|
|
b075af5bc9 | ||
|
|
07ca7fccb8 | ||
|
|
797643ffdc | ||
|
|
7d4052be95 | ||
|
|
1036719f2a | ||
|
|
1ab520eda3 | ||
|
|
658f7d816e |
7
.github/workflows/e2e-tests.yml
vendored
7
.github/workflows/e2e-tests.yml
vendored
@@ -21,10 +21,15 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
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
|
- name: Install dependencies
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# 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
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
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
|
- name: Install dependencies
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# 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
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
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
|
- name: Install dependencies
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# 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
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
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
|
- name: Install dependencies
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="apps/app/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||||
|
</p>
|
||||||
|
|
||||||
> **[!TIP]**
|
> **[!TIP]**
|
||||||
>
|
>
|
||||||
> **Learn more about Agentic Coding!**
|
> **Learn more about Agentic Coding!**
|
||||||
@@ -10,8 +14,39 @@
|
|||||||
|
|
||||||
**Stop typing code. Start directing AI agents.**
|
**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 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## What Makes Automaker Different?
|
## 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.
|
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
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -60,7 +111,7 @@ The future of software development is **agentic coding**—where developers beco
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repo
|
# 1. Clone the repo
|
||||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
git clone https://github.com/AutoMaker-Org/automaker.git
|
||||||
cd automaker
|
cd automaker
|
||||||
|
|
||||||
# 2. Install dependencies
|
# 2. Install dependencies
|
||||||
@@ -144,21 +195,17 @@ npm run lint
|
|||||||
|
|
||||||
Automaker supports multiple authentication methods (in order of priority):
|
Automaker supports multiple authentication methods (in order of priority):
|
||||||
|
|
||||||
| Method | Environment Variable | Description |
|
| Method | Environment Variable | Description |
|
||||||
| -------------------- | ------------------------- | --------------------------------------------------------- |
|
| ---------------- | -------------------- | ------------------------------- |
|
||||||
| OAuth Token (env) | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` - uses your Claude subscription |
|
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||||
| OAuth Token (stored) | — | Stored in app credentials file |
|
| API Key (stored) | — | Anthropic API key stored in app |
|
||||||
| 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.
|
|
||||||
|
|
||||||
### Persistent Setup (Optional)
|
### Persistent Setup (Optional)
|
||||||
|
|
||||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||||
|
|
||||||
```bash
|
```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`.
|
Then restart your terminal or run `source ~/.bashrc`.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -2,9 +2,6 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "export",
|
output: "export",
|
||||||
env: {
|
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
BIN
apps/app/public/readme_logo.png
Normal file
BIN
apps/app/public/readme_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -11,7 +11,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const { apiKey } = await request.json();
|
const { apiKey } = await request.json();
|
||||||
|
|
||||||
// Use provided API key or fall back to environment variable
|
// 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) {
|
if (!effectiveApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2521,3 +2521,34 @@
|
|||||||
.xml-editor .xml-highlight {
|
.xml-editor .xml-highlight {
|
||||||
z-index: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,6 +195,33 @@ const PROJECT_THEME_OPTIONS = [
|
|||||||
})),
|
})),
|
||||||
] as const;
|
] 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() {
|
export function Sidebar() {
|
||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
@@ -836,6 +863,12 @@ export function Sidebar() {
|
|||||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
[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.
|
* Opens the system folder selection dialog and initializes the selected project.
|
||||||
* Used by both the 'O' keyboard shortcut and the folder icon button.
|
* Used by both the 'O' keyboard shortcut and the folder icon button.
|
||||||
@@ -1394,30 +1427,20 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Bug Report Button */}
|
{/* Bug Report Button - Inside logo container when expanded */}
|
||||||
<button
|
{sidebarOpen && <BugReportButton sidebarExpanded onClick={handleBugReportClick} />}
|
||||||
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>
|
|
||||||
</div>
|
</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 */}
|
{/* Project Actions - Moved above project selector */}
|
||||||
{sidebarOpen && (
|
{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
|
<button
|
||||||
onClick={() => setShowNewProjectModal(true)}
|
onClick={() => setShowNewProjectModal(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1789,7 +1812,7 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nav Items - Scrollable */}
|
{/* 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 ? (
|
{!currentProject && sidebarOpen ? (
|
||||||
// Placeholder when no project is selected (only in expanded state)
|
// Placeholder when no project is selected (only in expanded state)
|
||||||
<div className="flex items-center justify-center h-full px-4">
|
<div className="flex items-center justify-center h-full px-4">
|
||||||
@@ -1802,7 +1825,7 @@ export function Sidebar() {
|
|||||||
) : currentProject ? (
|
) : currentProject ? (
|
||||||
// Navigation sections when project is selected
|
// Navigation sections when project is selected
|
||||||
navSections.map((section, sectionIdx) => (
|
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 */}
|
||||||
{section.label && sidebarOpen && (
|
{section.label && sidebarOpen && (
|
||||||
<div className="hidden lg:block px-3 mb-2">
|
<div className="hidden lg:block px-3 mb-2">
|
||||||
@@ -1812,7 +1835,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{section.label && !sidebarOpen && (
|
{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 */}
|
{/* Nav Items */}
|
||||||
|
|||||||
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
checked?: boolean | "indeterminate";
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
defaultChecked?: boolean | "indeterminate";
|
||||||
>(({ className, ...props }, ref) => (
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
<CheckboxPrimitive.Root
|
required?: boolean;
|
||||||
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",
|
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||||
className
|
({ className, onCheckedChange, ...props }, ref) => (
|
||||||
)}
|
<CheckboxPrimitive.Root
|
||||||
{...props}
|
ref={ref}
|
||||||
>
|
className={cn(
|
||||||
<CheckboxPrimitive.Indicator
|
"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={cn("flex items-center justify-center text-current")}
|
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.Indicator>
|
className={cn("flex items-center justify-center text-current")}
|
||||||
</CheckboxPrimitive.Root>
|
>
|
||||||
));
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
);
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Checkbox };
|
export { Checkbox };
|
||||||
|
|||||||
@@ -1,39 +1,43 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
function SheetTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetClose({
|
function SheetClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetOverlay({
|
interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
className,
|
forceMount?: true;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
|
||||||
|
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
|
||||||
|
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
|
||||||
|
SheetOverlayProps & { "data-slot": string }
|
||||||
|
>;
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Overlay
|
<Overlay
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
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",
|
"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}
|
{...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,
|
className,
|
||||||
children,
|
children,
|
||||||
side = "right",
|
side = "right",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: SheetContentProps) => {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
const Content = SheetPrimitive.Content as React.ComponentType<
|
||||||
}) {
|
SheetContentProps & { "data-slot": string }
|
||||||
|
>;
|
||||||
|
const Close = SheetPrimitive.Close as React.ComponentType<{
|
||||||
|
className: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<SheetPrimitive.Content
|
<Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</SheetPrimitive.Close>
|
</Close>
|
||||||
</SheetPrimitive.Content>
|
</Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -88,7 +106,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
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)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTitle({
|
interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||||
className,
|
|
||||||
...props
|
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
const Title = SheetPrimitive.Title as React.ComponentType<
|
||||||
|
SheetTitleProps & { "data-slot": string }
|
||||||
|
>;
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Title
|
<Title
|
||||||
data-slot="sheet-title"
|
data-slot="sheet-title"
|
||||||
className={cn("text-foreground font-semibold", className)}
|
className={cn("text-foreground font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function SheetDescription({
|
interface SheetDescriptionProps
|
||||||
className,
|
extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
|
||||||
|
const Description = SheetPrimitive.Description as React.ComponentType<
|
||||||
|
SheetDescriptionProps & { "data-slot": string }
|
||||||
|
>;
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Description
|
<Description
|
||||||
data-slot="sheet-description"
|
data-slot="sheet-description"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -136,4 +159,4 @@ export {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -4,24 +4,38 @@ import * as React from "react";
|
|||||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
|
||||||
React.ComponentRef<typeof SliderPrimitive.Root>,
|
value?: number[];
|
||||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
defaultValue?: number[];
|
||||||
>(({ className, ...props }, ref) => (
|
onValueChange?: (value: number[]) => void;
|
||||||
<SliderPrimitive.Root
|
onValueCommit?: (value: number[]) => void;
|
||||||
ref={ref}
|
min?: number;
|
||||||
className={cn(
|
max?: number;
|
||||||
"relative flex w-full touch-none select-none items-center",
|
step?: number;
|
||||||
className
|
disabled?: boolean;
|
||||||
)}
|
orientation?: "horizontal" | "vertical";
|
||||||
{...props}
|
dir?: "ltr" | "rtl";
|
||||||
>
|
inverted?: boolean;
|
||||||
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
minStepsBetweenThumbs?: number;
|
||||||
<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" />
|
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
||||||
</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>
|
||||||
|
)
|
||||||
|
);
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Slider };
|
export { Slider };
|
||||||
|
|||||||
@@ -1,23 +1,61 @@
|
|||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { ApiKeyField } from "./api-key-field";
|
||||||
import { buildProviderConfigs } from "@/config/api-providers";
|
import { buildProviderConfigs } from "@/config/api-providers";
|
||||||
import { AuthenticationStatusDisplay } from "./authentication-status-display";
|
import { AuthenticationStatusDisplay } from "./authentication-status-display";
|
||||||
import { SecurityNotice } from "./security-notice";
|
import { SecurityNotice } from "./security-notice";
|
||||||
import { useApiKeyManagement } from "./hooks/use-api-key-management";
|
import { useApiKeyManagement } from "./hooks/use-api-key-management";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function ApiKeysSection() {
|
export function ApiKeysSection() {
|
||||||
const { apiKeys } = useAppStore();
|
const { apiKeys, setApiKeys } = useAppStore();
|
||||||
const { claudeAuthStatus } = useSetupStore();
|
const { claudeAuthStatus, setClaudeAuthStatus, setSetupComplete } = useSetupStore();
|
||||||
|
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
|
||||||
|
|
||||||
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
|
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
|
||||||
useApiKeyManagement();
|
useApiKeyManagement();
|
||||||
|
|
||||||
const providerConfigs = buildProviderConfigs(providerConfigParams);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
id="api-keys"
|
id="api-keys"
|
||||||
@@ -55,8 +93,8 @@ export function ApiKeysSection() {
|
|||||||
{/* Security Notice */}
|
{/* Security Notice */}
|
||||||
<SecurityNotice />
|
<SecurityNotice />
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-4 pt-2">
|
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
data-testid="save-settings"
|
data-testid="save-settings"
|
||||||
@@ -79,6 +117,33 @@ export function ApiKeysSection() {
|
|||||||
"Save API Keys"
|
"Save API Keys"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ export function AuthenticationStatusDisplay({
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
<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>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{claudeAuthStatus.method === "oauth_token_env"
|
{claudeAuthStatus.method === "oauth_token"
|
||||||
? "Using CLAUDE_CODE_OAUTH_TOKEN"
|
|
||||||
: claudeAuthStatus.method === "oauth_token"
|
|
||||||
? "Using stored OAuth token (subscription)"
|
? "Using stored OAuth token (subscription)"
|
||||||
: claudeAuthStatus.method === "api_key_env"
|
: claudeAuthStatus.method === "api_key_env"
|
||||||
? "Using ANTHROPIC_API_KEY"
|
? "Using ANTHROPIC_API_KEY"
|
||||||
@@ -65,7 +65,9 @@ export function AuthenticationStatusDisplay({
|
|||||||
? "Using credentials file"
|
? "Using credentials file"
|
||||||
: claudeAuthStatus.method === "cli_authenticated"
|
: claudeAuthStatus.method === "cli_authenticated"
|
||||||
? "Using Claude CLI authentication"
|
? "Using Claude CLI authentication"
|
||||||
: `Using ${claudeAuthStatus.method || "detected"} authentication`}
|
: `Using ${
|
||||||
|
claudeAuthStatus.method || "detected"
|
||||||
|
} authentication`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -87,46 +89,6 @@ export function AuthenticationStatusDisplay({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,20 +104,21 @@ export function FeatureDefaultsSection({
|
|||||||
<div className="border-t border-border/30" />
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
{/* Worktree Isolation Setting */}
|
{/* 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
|
<Checkbox
|
||||||
id="use-worktrees"
|
id="use-worktrees"
|
||||||
checked={useWorktrees}
|
checked={useWorktrees}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onUseWorktreesChange(checked === true)
|
onUseWorktreesChange(checked === true)
|
||||||
}
|
}
|
||||||
|
disabled={true}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
data-testid="use-worktrees-checkbox"
|
data-testid="use-worktrees-checkbox"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="use-worktrees"
|
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" />
|
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||||
Enable Git Worktree Isolation
|
Enable Git Worktree Isolation
|
||||||
@@ -129,6 +130,9 @@ export function FeatureDefaultsSection({
|
|||||||
Creates isolated git branches for each feature. When disabled,
|
Creates isolated git branches for each feature. When disabled,
|
||||||
agents work directly in the main project directory.
|
agents work directly in the main project directory.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
import { CheckCircle2, XCircle, Loader2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
status:
|
status:
|
||||||
@@ -6,7 +6,9 @@ interface StatusBadgeProps {
|
|||||||
| "not_installed"
|
| "not_installed"
|
||||||
| "checking"
|
| "checking"
|
||||||
| "authenticated"
|
| "authenticated"
|
||||||
| "not_authenticated";
|
| "not_authenticated"
|
||||||
|
| "error"
|
||||||
|
| "unverified";
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +27,21 @@ export function StatusBadge({ status, label }: StatusBadgeProps) {
|
|||||||
icon: <XCircle className="w-4 h-4" />,
|
icon: <XCircle className="w-4 h-4" />,
|
||||||
className: "bg-red-500/10 text-red-500 border-red-500/20",
|
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":
|
case "checking":
|
||||||
return {
|
return {
|
||||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||||
className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
|
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
|
// 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
|
// Re-export all hooks for easier imports
|
||||||
export { useCliStatus } from "./use-cli-status";
|
export { useCliStatus } from "./use-cli-status";
|
||||||
export { useCliInstallation } from "./use-cli-installation";
|
export { useCliInstallation } from "./use-cli-installation";
|
||||||
export { useOAuthAuthentication } from "./use-oauth-authentication";
|
|
||||||
export { useTokenSave } from "./use-token-save";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import { CheckCircle2, AlertCircle, Shield, Sparkles } from "lucide-react";
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
Shield,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
@@ -14,8 +9,7 @@ interface CompleteStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||||
const { claudeCliStatus, claudeAuthStatus } =
|
const { claudeCliStatus, claudeAuthStatus } = useSetupStore();
|
||||||
useSetupStore();
|
|
||||||
const { apiKeys } = useAppStore();
|
const { apiKeys } = useAppStore();
|
||||||
|
|
||||||
const claudeReady =
|
const claudeReady =
|
||||||
@@ -38,44 +32,6 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
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
|
Welcome to Automaker
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
Let's set up your development environment. We'll check for
|
To get started, we'll need to verify either claude code cli is
|
||||||
required CLI tools and help you configure them.
|
installed or you have Anthropic api keys
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
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[] => [
|
}: ProviderConfigParams): ProviderConfig[] => [
|
||||||
{
|
{
|
||||||
key: "anthropic",
|
key: "anthropic",
|
||||||
label: "Anthropic API Key (Claude)",
|
label: "Anthropic API Key",
|
||||||
inputId: "anthropic-key",
|
inputId: "anthropic-key",
|
||||||
placeholder: "sk-ant-...",
|
placeholder: "sk-ant-...",
|
||||||
value: anthropic.value,
|
value: anthropic.value,
|
||||||
@@ -82,33 +82,32 @@ export const buildProviderConfigs = ({
|
|||||||
descriptionPrefix: "Used for Claude AI features. Get your key at",
|
descriptionPrefix: "Used for Claude AI features. Get your key at",
|
||||||
descriptionLinkHref: "https://console.anthropic.com/account/keys",
|
descriptionLinkHref: "https://console.anthropic.com/account/keys",
|
||||||
descriptionLinkText: "console.anthropic.com",
|
descriptionLinkText: "console.anthropic.com",
|
||||||
descriptionSuffix:
|
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",
|
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// 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
|
* Default max turns for chat
|
||||||
*/
|
*/
|
||||||
export const CHAT_MAX_TURNS = 1000;
|
export const CHAT_MAX_TURNS = 1000;
|
||||||
|
|
||||||
|
|||||||
@@ -355,6 +355,9 @@ export interface ElectronAPI {
|
|||||||
provider: string,
|
provider: string,
|
||||||
apiKey: string
|
apiKey: string
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
deleteApiKey: (
|
||||||
|
provider: string
|
||||||
|
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||||
getApiKeys: () => Promise<{
|
getApiKeys: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
@@ -369,6 +372,11 @@ export interface ElectronAPI {
|
|||||||
isMac: boolean;
|
isMac: boolean;
|
||||||
isLinux: boolean;
|
isLinux: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
verifyClaudeAuth: (authMethod?: "cli" | "api_key") => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
};
|
};
|
||||||
@@ -874,6 +882,9 @@ interface SetupAPI {
|
|||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
deleteApiKey: (
|
||||||
|
provider: string
|
||||||
|
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||||
getPlatform: () => Promise<{
|
getPlatform: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -883,6 +894,11 @@ interface SetupAPI {
|
|||||||
isMac: boolean;
|
isMac: boolean;
|
||||||
isLinux: boolean;
|
isLinux: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
verifyClaudeAuth: (authMethod?: "cli" | "api_key") => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
onAuthProgress?: (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 () => {
|
getPlatform: async () => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
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) => {
|
onInstallProgress: (callback) => {
|
||||||
// Mock progress events
|
// Mock progress events
|
||||||
return () => {};
|
return () => {};
|
||||||
|
|||||||
@@ -438,6 +438,14 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
|
}> => 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<{
|
getApiKeys: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
@@ -454,6 +462,12 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
isLinux: boolean;
|
isLinux: boolean;
|
||||||
}> => this.get("/api/setup/platform"),
|
}> => 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) => {
|
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||||
return this.subscribeToEvent("agent:stream", callback);
|
return this.subscribeToEvent("agent:stream", callback);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface CliStatus {
|
|||||||
|
|
||||||
// Claude Auth Method - all possible authentication sources
|
// Claude Auth Method - all possible authentication sources
|
||||||
export type ClaudeAuthMethod =
|
export type ClaudeAuthMethod =
|
||||||
| "oauth_token_env" // CLAUDE_CODE_OAUTH_TOKEN environment variable
|
| "oauth_token_env"
|
||||||
| "oauth_token" // Stored OAuth token from claude login
|
| "oauth_token" // Stored OAuth token from claude login
|
||||||
| "api_key_env" // ANTHROPIC_API_KEY environment variable
|
| "api_key_env" // ANTHROPIC_API_KEY environment variable
|
||||||
| "api_key" // Manually stored API key
|
| "api_key" // Manually stored API key
|
||||||
@@ -65,6 +65,7 @@ export interface SetupState {
|
|||||||
export interface SetupActions {
|
export interface SetupActions {
|
||||||
// Setup flow
|
// Setup flow
|
||||||
setCurrentStep: (step: SetupStep) => void;
|
setCurrentStep: (step: SetupStep) => void;
|
||||||
|
setSetupComplete: (complete: boolean) => void;
|
||||||
completeSetup: () => void;
|
completeSetup: () => void;
|
||||||
resetSetup: () => void;
|
resetSetup: () => void;
|
||||||
setIsFirstRun: (isFirstRun: boolean) => void;
|
setIsFirstRun: (isFirstRun: boolean) => void;
|
||||||
@@ -109,6 +110,12 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
|||||||
// Setup flow
|
// Setup flow
|
||||||
setCurrentStep: (step) => set({ currentStep: step }),
|
setCurrentStep: (step) => set({ currentStep: step }),
|
||||||
|
|
||||||
|
setSetupComplete: (complete) =>
|
||||||
|
set({
|
||||||
|
setupComplete: complete,
|
||||||
|
currentStep: complete ? "complete" : "welcome",
|
||||||
|
}),
|
||||||
|
|
||||||
completeSetup: () =>
|
completeSetup: () =>
|
||||||
set({ setupComplete: true, currentStep: "complete" }),
|
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
|
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== "false"; // Default to true
|
||||||
|
|
||||||
// Check for required environment variables
|
// 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 hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
||||||
|
|
||||||
if (!hasAnthropicKey && !hasOAuthToken) {
|
if (!hasAnthropicKey) {
|
||||||
console.warn(`
|
console.warn(`
|
||||||
╔═══════════════════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════════════════════╗
|
||||||
║ ⚠️ WARNING: No Claude authentication configured ║
|
║ ⚠️ WARNING: No Claude authentication configured ║
|
||||||
║ ║
|
║ ║
|
||||||
║ The Claude Agent SDK requires authentication to function. ║
|
║ The Claude Agent SDK requires authentication to function. ║
|
||||||
║ ║
|
║ ║
|
||||||
║ Option 1 - Subscription (OAuth Token): ║
|
║ Set your Anthropic API key: ║
|
||||||
║ export CLAUDE_CODE_OAUTH_TOKEN="your-oauth-token" ║
|
|
||||||
║ ║
|
|
||||||
║ Option 2 - Pay-per-use (API Key): ║
|
|
||||||
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
||||||
║ ║
|
║ ║
|
||||||
║ Or use the setup wizard in Settings to configure authentication. ║
|
║ Or use the setup wizard in Settings to configure authentication. ║
|
||||||
╚═══════════════════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
} else if (hasOAuthToken) {
|
|
||||||
console.log(
|
|
||||||
"[Server] ✓ CLAUDE_CODE_OAUTH_TOKEN detected (subscription auth)"
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)");
|
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,3 +72,4 @@ export function getLogLevel(): LogLevel {
|
|||||||
export function setLogLevel(level: LogLevel): void {
|
export function setLogLevel(level: LogLevel): void {
|
||||||
currentLogLevel = level;
|
currentLogLevel = level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,9 +112,7 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
*/
|
*/
|
||||||
async detectInstallation(): Promise<InstallationStatus> {
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
// Claude SDK is always available since it's a dependency
|
// Claude SDK is always available since it's a dependency
|
||||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
||||||
const hasApiKey = hasAnthropicKey || hasOAuthToken;
|
|
||||||
|
|
||||||
const status: InstallationStatus = {
|
const status: InstallationStatus = {
|
||||||
installed: true,
|
installed: true,
|
||||||
|
|||||||
@@ -35,19 +35,9 @@ export function setRunningState(
|
|||||||
* Helper to log authentication status
|
* Helper to log authentication status
|
||||||
*/
|
*/
|
||||||
export function logAuthStatus(context: string): void {
|
export function logAuthStatus(context: string): void {
|
||||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
||||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
logger.info(`${context} - Auth Status:`);
|
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(
|
logger.info(
|
||||||
` ANTHROPIC_API_KEY: ${
|
` ANTHROPIC_API_KEY: ${
|
||||||
hasApiKey
|
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.");
|
logger.warn("⚠️ WARNING: No authentication configured! SDK will fail.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
|||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ export function createProvidersHandler() {
|
|||||||
const providers: Record<string, any> = {
|
const providers: Record<string, any> = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
available: statuses.claude?.installed || false,
|
available: statuses.claude?.installed || false,
|
||||||
hasApiKey:
|
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||||
!!process.env.ANTHROPIC_API_KEY ||
|
|
||||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
available: !!process.env.GOOGLE_API_KEY,
|
available: !!process.env.GOOGLE_API_KEY,
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export async function getClaudeStatus() {
|
|||||||
hasStoredOAuthToken: !!getApiKey("anthropic_oauth_token"),
|
hasStoredOAuthToken: !!getApiKey("anthropic_oauth_token"),
|
||||||
hasStoredApiKey: !!getApiKey("anthropic"),
|
hasStoredApiKey: !!getApiKey("anthropic"),
|
||||||
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY,
|
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||||
hasEnvOAuthToken: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
||||||
// Additional fields for detailed status
|
// Additional fields for detailed status
|
||||||
oauthTokenValid: false,
|
oauthTokenValid: false,
|
||||||
apiKeyValid: false,
|
apiKeyValid: false,
|
||||||
@@ -148,11 +147,7 @@ export async function getClaudeStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Environment variables override stored credentials (higher priority)
|
// Environment variables override stored credentials (higher priority)
|
||||||
if (auth.hasEnvOAuthToken) {
|
if (auth.hasEnvApiKey) {
|
||||||
auth.authenticated = true;
|
|
||||||
auth.oauthTokenValid = true;
|
|
||||||
auth.method = "oauth_token_env"; // OAuth token from CLAUDE_CODE_OAUTH_TOKEN env var
|
|
||||||
} else if (auth.hasEnvApiKey) {
|
|
||||||
auth.authenticated = true;
|
auth.authenticated = true;
|
||||||
auth.apiKeyValid = true;
|
auth.apiKeyValid = true;
|
||||||
auth.method = "api_key_env"; // API key from ANTHROPIC_API_KEY env var
|
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 { createInstallClaudeHandler } from "./routes/install-claude.js";
|
||||||
import { createAuthClaudeHandler } from "./routes/auth-claude.js";
|
import { createAuthClaudeHandler } from "./routes/auth-claude.js";
|
||||||
import { createStoreApiKeyHandler } from "./routes/store-api-key.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 { createApiKeysHandler } from "./routes/api-keys.js";
|
||||||
import { createPlatformHandler } from "./routes/platform.js";
|
import { createPlatformHandler } from "./routes/platform.js";
|
||||||
|
import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js";
|
||||||
|
|
||||||
export function createSetupRoutes(): Router {
|
export function createSetupRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -17,8 +19,10 @@ export function createSetupRoutes(): Router {
|
|||||||
router.post("/install-claude", createInstallClaudeHandler());
|
router.post("/install-claude", createInstallClaudeHandler());
|
||||||
router.post("/auth-claude", createAuthClaudeHandler());
|
router.post("/auth-claude", createAuthClaudeHandler());
|
||||||
router.post("/store-api-key", createStoreApiKeyHandler());
|
router.post("/store-api-key", createStoreApiKeyHandler());
|
||||||
|
router.post("/delete-api-key", createDeleteApiKeyHandler());
|
||||||
router.get("/api-keys", createApiKeysHandler());
|
router.get("/api-keys", createApiKeysHandler());
|
||||||
router.get("/platform", createPlatformHandler());
|
router.get("/platform", createPlatformHandler());
|
||||||
|
router.post("/verify-claude-auth", createVerifyClaudeAuthHandler());
|
||||||
|
|
||||||
return router;
|
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);
|
setApiKey(provider, apiKey);
|
||||||
|
|
||||||
// Also set as environment variable and persist to .env
|
// Also set as environment variable and persist to .env
|
||||||
// IMPORTANT: OAuth tokens and API keys must be stored separately
|
if (provider === "anthropic" || provider === "anthropic_oauth_token") {
|
||||||
// - OAuth tokens (subscription auth) -> CLAUDE_CODE_OAUTH_TOKEN
|
// Both API key and OAuth token use ANTHROPIC_API_KEY
|
||||||
// - 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)
|
|
||||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||||
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
|
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
|
||||||
logger.info("[Setup] Stored API key as ANTHROPIC_API_KEY");
|
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();
|
vi.clearAllMocks();
|
||||||
provider = new ClaudeProvider();
|
provider = new ClaudeProvider();
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getName", () => {
|
describe("getName", () => {
|
||||||
@@ -254,15 +253,6 @@ describe("claude-provider.ts", () => {
|
|||||||
expect(result.authenticated).toBe(true);
|
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 () => {
|
it("should return hasApiKey false when no keys present", async () => {
|
||||||
const result = await provider.detectInstallation();
|
const result = await provider.detectInstallation();
|
||||||
|
|
||||||
|
|||||||
@@ -179,9 +179,8 @@ Routes models that:
|
|||||||
|
|
||||||
#### Authentication
|
#### Authentication
|
||||||
|
|
||||||
Requires one of:
|
Requires:
|
||||||
- `ANTHROPIC_API_KEY` environment variable
|
- `ANTHROPIC_API_KEY` environment variable
|
||||||
- `CLAUDE_CODE_OAUTH_TOKEN` environment variable
|
|
||||||
|
|
||||||
#### Example Usage
|
#### Example Usage
|
||||||
|
|
||||||
@@ -704,9 +703,8 @@ describe("Provider Integration", () => {
|
|||||||
### Claude Provider
|
### Claude Provider
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required (one of):
|
# Required:
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
CLAUDE_CODE_OAUTH_TOKEN=...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Codex Provider
|
### Codex Provider
|
||||||
|
|||||||
@@ -580,3 +580,4 @@ The route organization pattern provides:
|
|||||||
5. **Testability** - Functions can be tested independently
|
5. **Testability** - Functions can be tested independently
|
||||||
|
|
||||||
Apply this pattern to all route modules for consistency and improved code quality.
|
Apply this pattern to all route modules for consistency and improved code quality.
|
||||||
|
|
||||||
|
|||||||
22630
package-lock.json
generated
22630
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,5 +29,8 @@
|
|||||||
"test:headed": "npm run test:headed --workspace=apps/app",
|
"test:headed": "npm run test:headed --workspace=apps/app",
|
||||||
"test:server": "npm run test --workspace=apps/server",
|
"test:server": "npm run test --workspace=apps/server",
|
||||||
"test:server:coverage": "npm run test:cov --workspace=apps/server"
|
"test:server:coverage": "npm run test:cov --workspace=apps/server"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user