Merge branch 'main' of github.com:webdevcody/automaker

This commit is contained in:
Cody Seibert
2025-12-10 14:29:45 -05:00
21 changed files with 3934 additions and 186 deletions

108
README.md Normal file
View File

@@ -0,0 +1,108 @@
# 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
```
**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.
**Step 6:** MOST IMPORANT: Run the Following after all is setup
```bash
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
```
## Features
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
- 📁 **Context Management** - Add context files to help AI agents understand your project better
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
-**Concurrent Processing** - Configure concurrency to process multiple features simultaneously
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
## Tech Stack
- [Next.js](https://nextjs.org) - React framework
- [Electron](https://www.electronjs.org/) - Desktop application framework
- [Tailwind CSS](https://tailwindcss.com/) - Styling
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## License
See [LICENSE](../LICENSE) for details.

View File

@@ -818,8 +818,23 @@ ipcMain.handle(
ipcMain.handle("claude:check-cli", async () => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const info = claudeCliDetector.getInstallationInfo();
return { success: true, ...info };
const path = require("path");
const credentialsPath = path.join(app.getPath("userData"), "credentials.json");
const fullStatus = claudeCliDetector.getFullStatus(credentialsPath);
// Return in format expected by settings view (status: "installed" | "not_installed")
return {
success: true,
status: fullStatus.installed ? "installed" : "not_installed",
method: fullStatus.auth?.method || null,
version: fullStatus.version || null,
path: fullStatus.path || null,
authenticated: fullStatus.auth?.authenticated || false,
recommendation: fullStatus.installed
? null
: "Install Claude Code CLI for optimal performance with ultrathink.",
installCommands: fullStatus.installed ? null : claudeCliDetector.getInstallCommands(),
};
} catch (error) {
console.error("[IPC] claude:check-cli error:", error);
return { success: false, error: error.message };
@@ -1363,3 +1378,233 @@ ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => {
return { success: false, error: error.message };
}
});
// ============================================================================
// Setup & CLI Management IPC Handlers
// ============================================================================
/**
* Get comprehensive Claude CLI status including auth
*/
ipcMain.handle("setup:claude-status", async () => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const credentialsPath = path.join(app.getPath("userData"), "credentials.json");
const result = claudeCliDetector.getFullStatus(credentialsPath);
console.log("[IPC] setup:claude-status result:", result);
return result;
} catch (error) {
console.error("[IPC] setup:claude-status error:", error);
return { success: false, error: error.message };
}
});
/**
* Get comprehensive Codex CLI status including auth
*/
ipcMain.handle("setup:codex-status", async () => {
try {
const codexCliDetector = require("./services/codex-cli-detector");
const info = codexCliDetector.getFullStatus();
return { success: true, ...info };
} catch (error) {
console.error("[IPC] setup:codex-status error:", error);
return { success: false, error: error.message };
}
});
/**
* Install Claude CLI
*/
ipcMain.handle("setup:install-claude", async (event) => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const sendProgress = (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:install-progress", {
cli: "claude",
...progress
});
}
};
const result = await claudeCliDetector.installCli(sendProgress);
return { success: true, ...result };
} catch (error) {
console.error("[IPC] setup:install-claude error:", error);
return { success: false, error: error.message || error.error };
}
});
/**
* Install Codex CLI
*/
ipcMain.handle("setup:install-codex", async (event) => {
try {
const codexCliDetector = require("./services/codex-cli-detector");
const sendProgress = (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:install-progress", {
cli: "codex",
...progress
});
}
};
const result = await codexCliDetector.installCli(sendProgress);
return { success: true, ...result };
} catch (error) {
console.error("[IPC] setup:install-codex error:", error);
return { success: false, error: error.message || error.error };
}
});
/**
* Authenticate Claude CLI (manual auth required)
*/
ipcMain.handle("setup:auth-claude", async (event) => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const sendProgress = (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:auth-progress", {
cli: "claude",
...progress
});
}
};
const result = await claudeCliDetector.runSetupToken(sendProgress);
return { success: true, ...result };
} catch (error) {
console.error("[IPC] setup:auth-claude error:", error);
return { success: false, error: error.message || error.error };
}
});
/**
* Authenticate Codex CLI with optional API key
*/
ipcMain.handle("setup:auth-codex", async (event, { apiKey }) => {
try {
const codexCliDetector = require("./services/codex-cli-detector");
const sendProgress = (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:auth-progress", {
cli: "codex",
...progress
});
}
};
const result = await codexCliDetector.authenticate(apiKey, sendProgress);
return { success: true, ...result };
} catch (error) {
console.error("[IPC] setup:auth-codex error:", error);
return { success: false, error: error.message || error.error };
}
});
/**
* Store API key or OAuth token securely (using app's userData)
* @param {string} provider - Provider name (anthropic, openai, google, anthropic_oauth_token)
* @param {string} apiKey - The API key or OAuth token to store
*/
ipcMain.handle("setup:store-api-key", async (_, { provider, apiKey }) => {
try {
console.log("[IPC] setup:store-api-key called for provider:", provider);
const configPath = path.join(app.getPath("userData"), "credentials.json");
let credentials = {};
// Read existing credentials
try {
const content = await fs.readFile(configPath, "utf-8");
credentials = JSON.parse(content);
} catch (e) {
// File doesn't exist, start fresh
}
// Store the new key/token
credentials[provider] = apiKey;
// Write back
await fs.writeFile(configPath, JSON.stringify(credentials, null, 2), "utf-8");
console.log("[IPC] setup:store-api-key stored successfully for:", provider);
return { success: true };
} catch (error) {
console.error("[IPC] setup:store-api-key error:", error);
return { success: false, error: error.message };
}
});
/**
* Get stored API keys and tokens
*/
ipcMain.handle("setup:get-api-keys", async () => {
try {
const configPath = path.join(app.getPath("userData"), "credentials.json");
try {
const content = await fs.readFile(configPath, "utf-8");
const credentials = JSON.parse(content);
// Return which keys/tokens exist (not the actual values for security)
return {
success: true,
hasAnthropicKey: !!credentials.anthropic,
hasAnthropicOAuthToken: !!credentials.anthropic_oauth_token,
hasOpenAIKey: !!credentials.openai,
hasGoogleKey: !!credentials.google
};
} catch (e) {
return {
success: true,
hasAnthropicKey: false,
hasAnthropicOAuthToken: false,
hasOpenAIKey: false,
hasGoogleKey: false
};
}
} catch (error) {
console.error("[IPC] setup:get-api-keys error:", error);
return { success: false, error: error.message };
}
});
/**
* Configure Codex MCP server for a project
*/
ipcMain.handle("setup:configure-codex-mcp", async (_, { projectPath }) => {
try {
const codexConfigManager = require("./services/codex-config-manager");
const mcpServerPath = path.join(__dirname, "services", "mcp-server-factory.js");
const configPath = await codexConfigManager.configureMcpServer(projectPath, mcpServerPath);
return { success: true, configPath };
} catch (error) {
console.error("[IPC] setup:configure-codex-mcp error:", error);
return { success: false, error: error.message };
}
});
/**
* Get platform information
*/
ipcMain.handle("setup:get-platform", async () => {
const os = require("os");
return {
success: true,
platform: process.platform,
arch: process.arch,
homeDir: os.homedir(),
isWindows: process.platform === "win32",
isMac: process.platform === "darwin",
isLinux: process.platform === "linux"
};
});

View File

@@ -252,6 +252,59 @@ contextBridge.exposeInMainWorld("electronAPI", {
};
},
},
// Setup & CLI Management API
setup: {
// Get comprehensive Claude CLI status
getClaudeStatus: () => ipcRenderer.invoke("setup:claude-status"),
// Get comprehensive Codex CLI status
getCodexStatus: () => ipcRenderer.invoke("setup:codex-status"),
// Install Claude CLI
installClaude: () => ipcRenderer.invoke("setup:install-claude"),
// Install Codex CLI
installCodex: () => ipcRenderer.invoke("setup:install-codex"),
// Authenticate Claude CLI
authClaude: () => ipcRenderer.invoke("setup:auth-claude"),
// Authenticate Codex CLI with optional API key
authCodex: (apiKey) => ipcRenderer.invoke("setup:auth-codex", { apiKey }),
// Store API key securely
storeApiKey: (provider, apiKey) =>
ipcRenderer.invoke("setup:store-api-key", { provider, apiKey }),
// Get stored API keys status
getApiKeys: () => ipcRenderer.invoke("setup:get-api-keys"),
// Configure Codex MCP server for a project
configureCodexMcp: (projectPath) =>
ipcRenderer.invoke("setup:configure-codex-mcp", { projectPath }),
// Get platform information
getPlatform: () => ipcRenderer.invoke("setup:get-platform"),
// Listen for installation progress
onInstallProgress: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("setup:install-progress", subscription);
return () => {
ipcRenderer.removeListener("setup:install-progress", subscription);
};
},
// Listen for auth progress
onAuthProgress: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("setup:auth-progress", subscription);
return () => {
ipcRenderer.removeListener("setup:auth-progress", subscription);
};
},
},
});
// Also expose a flag to detect if we're in Electron

View File

@@ -1,71 +1,185 @@
const { execSync } = require('child_process');
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* Claude CLI Detector
*
* Authentication options:
* 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token to the app
* 2. API Key (Pay-per-use): User provides their Anthropic API key directly
*/
class ClaudeCliDetector {
/**
* Check if Claude Code CLI is installed and accessible
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'sdk'|'none' }
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'none' }
*/
static detectClaudeInstallation() {
try {
// Method 1: Check if 'claude' command is in PATH
try {
const claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
};
} catch (error) {
// CLI not in PATH, check local installation
}
// Method 2: Check for local installation
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
if (fs.existsSync(localClaudePath)) {
/**
* Try to get updated PATH from shell config files
* This helps detect CLI installations that modify shell config but haven't updated the current process PATH
*/
static getUpdatedPathFromShellConfig() {
const homeDir = os.homedir();
const shell = process.env.SHELL || '/bin/bash';
const shellName = path.basename(shell);
// Common shell config files
const configFiles = [];
if (shellName.includes('zsh')) {
configFiles.push(path.join(homeDir, '.zshrc'));
configFiles.push(path.join(homeDir, '.zshenv'));
configFiles.push(path.join(homeDir, '.zprofile'));
} else if (shellName.includes('bash')) {
configFiles.push(path.join(homeDir, '.bashrc'));
configFiles.push(path.join(homeDir, '.bash_profile'));
configFiles.push(path.join(homeDir, '.profile'));
}
// Also check common locations
const commonPaths = [
path.join(homeDir, '.local', 'bin'),
path.join(homeDir, '.cargo', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
path.join(homeDir, 'bin'),
];
// Try to extract PATH additions from config files
for (const configFile of configFiles) {
if (fs.existsSync(configFile)) {
try {
const version = execSync(`${localClaudePath} --version`, { encoding: 'utf-8' }).trim();
return {
installed: true,
path: localClaudePath,
version: version,
method: 'cli-local'
};
const content = fs.readFileSync(configFile, 'utf-8');
// Look for PATH exports that might include claude installation paths
const pathMatches = content.match(/export\s+PATH=["']?([^"'\n]+)["']?/g);
if (pathMatches) {
for (const match of pathMatches) {
const pathValue = match.replace(/export\s+PATH=["']?/, '').replace(/["']?$/, '');
const paths = pathValue.split(':').filter(p => p && !p.includes('$'));
commonPaths.push(...paths);
}
}
} catch (error) {
// Local CLI exists but may not be executable
// Ignore errors reading config files
}
}
}
return [...new Set(commonPaths)]; // Remove duplicates
}
static detectClaudeInstallation() {
console.log('[ClaudeCliDetector] Detecting Claude installation...');
try {
// Method 1: Check if 'claude' command is in PATH (Unix)
if (process.platform !== 'win32') {
try {
const claudePath = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim();
if (claudePath) {
const version = this.getClaudeVersion(claudePath);
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
};
}
} catch (error) {
// CLI not in PATH, continue checking other locations
}
}
// Method 3: Check Windows path
// Method 2: Check Windows path
if (process.platform === 'win32') {
try {
const claudePath = execSync('where claude', { encoding: 'utf-8' }).trim();
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
};
const claudePath = execSync('where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
if (claudePath) {
const version = this.getClaudeVersion(claudePath);
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
};
}
} catch (error) {
// Not found
// Not found on Windows
}
}
// Method 4: SDK mode (using OAuth token)
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
// Method 3: Check for local installation
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
if (fs.existsSync(localClaudePath)) {
const version = this.getClaudeVersion(localClaudePath);
console.log('[ClaudeCliDetector] Found local claude at:', localClaudePath, 'version:', version);
return {
installed: true,
path: null,
version: 'SDK Mode',
method: 'sdk'
path: localClaudePath,
version: version,
method: 'cli-local'
};
}
// Method 4: Check common installation locations (including those from shell config)
const commonPaths = this.getUpdatedPathFromShellConfig();
const binaryNames = ['claude', 'claude-code'];
for (const basePath of commonPaths) {
for (const binaryName of binaryNames) {
const claudePath = path.join(basePath, binaryName);
if (fs.existsSync(claudePath)) {
try {
const version = this.getClaudeVersion(claudePath);
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
};
} catch (error) {
// File exists but can't get version, might not be executable
}
}
}
}
// Method 5: Try to source shell config and check PATH again (for Unix)
if (process.platform !== 'win32') {
try {
const shell = process.env.SHELL || '/bin/bash';
const shellName = path.basename(shell);
const homeDir = os.homedir();
let sourceCmd = '';
if (shellName.includes('zsh')) {
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`;
} else if (shellName.includes('bash')) {
sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`;
}
if (sourceCmd) {
const claudePath = execSync(`bash -c "${sourceCmd}"`, { encoding: 'utf-8', timeout: 2000 }).trim();
if (claudePath && claudePath.startsWith('/')) {
const version = this.getClaudeVersion(claudePath);
console.log('[ClaudeCliDetector] Found claude via shell config at:', claudePath, 'version:', version);
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
};
}
}
} catch (error) {
// Failed to source shell config or find claude
}
}
console.log('[ClaudeCliDetector] Claude CLI not found');
return {
installed: false,
path: null,
@@ -85,35 +199,223 @@ class ClaudeCliDetector {
}
/**
* Get installation recommendations
* Get Claude CLI version
* @param {string} claudePath Path to claude executable
* @returns {string|null} Version string or null
*/
static getInstallationInfo() {
static getClaudeVersion(claudePath) {
try {
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
encoding: 'utf-8',
timeout: 5000
}).trim();
return version || null;
} catch (error) {
return null;
}
}
/**
* Get authentication status
* Checks for:
* 1. OAuth token stored in app's credentials (from `claude setup-token`)
* 2. API key stored in app's credentials
* 3. API key in environment variable
*
* @param {string} appCredentialsPath Path to app's credentials.json
* @returns {Object} Authentication status
*/
static getAuthStatus(appCredentialsPath) {
console.log('[ClaudeCliDetector] Checking auth status...');
const envApiKey = process.env.ANTHROPIC_API_KEY;
console.log('[ClaudeCliDetector] Env ANTHROPIC_API_KEY:', !!envApiKey);
// Check app's stored credentials
let storedOAuthToken = null;
let storedApiKey = null;
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
try {
const content = fs.readFileSync(appCredentialsPath, 'utf-8');
const credentials = JSON.parse(content);
storedOAuthToken = credentials.anthropic_oauth_token || null;
storedApiKey = credentials.anthropic || credentials.anthropic_api_key || null;
console.log('[ClaudeCliDetector] App credentials:', {
hasOAuthToken: !!storedOAuthToken,
hasApiKey: !!storedApiKey
});
} catch (error) {
console.error('[ClaudeCliDetector] Error reading app credentials:', error);
}
}
// Determine authentication method
// Priority: Stored OAuth Token > Stored API Key > Env API Key
let authenticated = false;
let method = 'none';
if (storedOAuthToken) {
authenticated = true;
method = 'oauth_token';
console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)');
} else if (storedApiKey) {
authenticated = true;
method = 'api_key';
console.log('[ClaudeCliDetector] Using stored API key');
} else if (envApiKey) {
authenticated = true;
method = 'api_key_env';
console.log('[ClaudeCliDetector] Using environment API key');
} else {
console.log('[ClaudeCliDetector] No authentication found');
}
const result = {
authenticated,
method,
hasStoredOAuthToken: !!storedOAuthToken,
hasStoredApiKey: !!storedApiKey,
hasEnvApiKey: !!envApiKey
};
console.log('[ClaudeCliDetector] Auth status result:', result);
return result;
}
/**
* Get full status including installation and auth
* @param {string} appCredentialsPath Path to app's credentials.json
* @returns {Object} Full status
*/
static getFullStatus(appCredentialsPath) {
const installation = this.detectClaudeInstallation();
const auth = this.getAuthStatus(appCredentialsPath);
return {
success: true,
status: installation.installed ? 'installed' : 'not_installed',
installed: installation.installed,
path: installation.path,
version: installation.version,
method: installation.method,
auth
};
}
/**
* Get installation commands for different platforms
* @returns {Object} Installation commands
*/
static getInstallCommands() {
return {
macos: 'curl -fsSL https://claude.ai/install.sh | bash',
windows: 'irm https://claude.ai/install.ps1 | iex',
linux: 'curl -fsSL https://claude.ai/install.sh | bash'
};
}
/**
* Install Claude CLI using the official script
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Installation result
*/
static async installCli(onProgress) {
return new Promise((resolve, reject) => {
const platform = process.platform;
let command, args;
if (platform === 'win32') {
command = 'powershell';
args = ['-Command', 'irm https://claude.ai/install.ps1 | iex'];
} else {
command = 'bash';
args = ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'];
}
console.log('[ClaudeCliDetector] Installing Claude CLI...');
const proc = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
shell: false
});
let output = '';
let errorOutput = '';
proc.stdout.on('data', (data) => {
const text = data.toString();
output += text;
if (onProgress) {
onProgress({ type: 'stdout', data: text });
}
});
proc.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
if (onProgress) {
onProgress({ type: 'stderr', data: text });
}
});
proc.on('close', (code) => {
if (code === 0) {
console.log('[ClaudeCliDetector] Installation completed successfully');
resolve({
success: true,
output,
message: 'Claude CLI installed successfully'
});
} else {
console.error('[ClaudeCliDetector] Installation failed with code:', code);
reject({
success: false,
error: errorOutput || `Installation failed with code ${code}`,
output
});
}
});
proc.on('error', (error) => {
console.error('[ClaudeCliDetector] Installation error:', error);
reject({
success: false,
error: error.message,
output
});
});
});
}
/**
* Get instructions for setup-token command
* @returns {Object} Setup token instructions
*/
static getSetupTokenInstructions() {
const detection = this.detectClaudeInstallation();
if (detection.installed) {
if (!detection.installed) {
return {
status: 'installed',
method: detection.method,
version: detection.version,
path: detection.path,
recommendation: detection.method === 'cli'
? 'Using Claude Code CLI - optimal for long-running tasks'
: 'Using SDK mode - works well but CLI may provide better performance'
success: false,
error: 'Claude CLI is not installed. Please install it first.',
installCommands: this.getInstallCommands()
};
}
return {
status: 'not_installed',
recommendation: 'Consider installing Claude Code CLI for better performance with ultrathink',
installCommands: {
macos: 'curl -fsSL claude.ai/install.sh | bash',
windows: 'irm https://claude.ai/install.ps1 | iex',
linux: 'curl -fsSL claude.ai/install.sh | bash',
npm: 'npm install -g @anthropic-ai/claude-code'
}
success: true,
command: 'claude setup-token',
instructions: [
'1. Open your terminal',
'2. Run: claude setup-token',
'3. Follow the prompts to authenticate',
'4. Copy the token that is displayed',
'5. Paste the token in the field below'
],
note: 'This token is from your Claude subscription and allows you to use Claude without API charges.'
};
}
}
module.exports = ClaudeCliDetector;

View File

@@ -1,4 +1,4 @@
const { execSync } = require('child_process');
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
@@ -11,6 +11,205 @@ const os = require('os');
* for code generation and agentic tasks.
*/
class CodexCliDetector {
/**
* Get the path to Codex config directory
* @returns {string} Path to .codex directory
*/
static getConfigDir() {
return path.join(os.homedir(), '.codex');
}
/**
* Get the path to Codex auth file
* @returns {string} Path to auth.json
*/
static getAuthPath() {
return path.join(this.getConfigDir(), 'auth.json');
}
/**
* Check Codex authentication status
* @returns {Object} Authentication status
*/
static checkAuth() {
console.log('[CodexCliDetector] Checking auth status...');
try {
const authPath = this.getAuthPath();
const envApiKey = process.env.OPENAI_API_KEY;
console.log('[CodexCliDetector] Auth path:', authPath);
console.log('[CodexCliDetector] Has env API key:', !!envApiKey);
// First, try to verify authentication using codex CLI command if available
try {
const detection = this.detectCodexInstallation();
if (detection.installed) {
try {
// Use 'codex login status' to verify authentication
const statusOutput = execSync(`"${detection.path || 'codex'}" login status 2>/dev/null`, {
encoding: 'utf-8',
timeout: 5000
});
// If command succeeds and shows logged in status
if (statusOutput && (statusOutput.includes('Logged in') || statusOutput.includes('Authenticated'))) {
const result = {
authenticated: true,
method: 'cli_verified',
hasAuthFile: fs.existsSync(authPath),
hasEnvKey: !!envApiKey,
authPath
};
console.log('[CodexCliDetector] Auth result (cli_verified):', result);
return result;
}
} catch (statusError) {
// status command failed, continue with file-based check
}
}
} catch (verifyError) {
// CLI verification failed, continue with file-based check
}
// Check if auth file exists
if (fs.existsSync(authPath)) {
const content = fs.readFileSync(authPath, 'utf-8');
const auth = JSON.parse(content);
// Check for token object structure (from codex auth login)
// Structure: { token: { Id_token, access_token, refresh_token }, last_refresh: ... }
if (auth.token && typeof auth.token === 'object') {
const token = auth.token;
if (token.Id_token || token.access_token || token.refresh_token || token.id_token) {
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
}
// Check for various possible auth fields that codex might use
if (auth.api_key || auth.openai_api_key || auth.access_token || auth.apiKey) {
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
// Also check if the file has any meaningful content (non-empty object)
const keys = Object.keys(auth);
if (keys.length > 0) {
// File exists and has content, likely authenticated
// Try to verify by checking if codex command works
try {
const detection = this.detectCodexInstallation();
if (detection.installed) {
// Try to verify auth by running a simple command
try {
execSync(`"${detection.path || 'codex'}" --version 2>/dev/null`, {
encoding: 'utf-8',
timeout: 3000
});
// If command succeeds, assume authenticated
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
} catch (cmdError) {
// Command failed, but file exists - might still be authenticated
// Return authenticated if file has content
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
}
} catch (verifyError) {
// Verification failed, but file exists with content
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
}
}
// Check environment variable
if (envApiKey) {
const result = {
authenticated: true,
method: 'env_var',
hasAuthFile: false,
hasEnvKey: true,
authPath
};
console.log('[CodexCliDetector] Auth result (env_var):', result);
return result;
}
// If auth file exists but we didn't find standard keys,
// check if codex CLI is installed and try to verify auth
if (fs.existsSync(authPath)) {
try {
const detection = this.detectCodexInstallation();
if (detection.installed) {
// Auth file exists and CLI is installed - likely authenticated
// The file existing is a good indicator that login was successful
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
} catch (verifyError) {
// Verification attempt failed, but file exists
// Assume authenticated if file exists
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
}
const result = {
authenticated: false,
method: 'none',
hasAuthFile: false,
hasEnvKey: false,
authPath
};
console.log('[CodexCliDetector] Auth result (not authenticated):', result);
return result;
} catch (error) {
console.error('[CodexCliDetector] Error checking auth:', error);
const result = {
authenticated: false,
method: 'none',
error: error.message
};
console.log('[CodexCliDetector] Auth result (error):', result);
return result;
}
}
/**
* Check if Codex CLI is installed and accessible
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'npm'|'brew'|'none' }
@@ -224,6 +423,171 @@ class CodexCliDetector {
static getDefaultModel() {
return 'gpt-5.1-codex-max';
}
/**
* Get comprehensive installation info including auth status
* @returns {Object} Full status object
*/
static getFullStatus() {
const installation = this.detectCodexInstallation();
const auth = this.checkAuth();
const info = this.getInstallationInfo();
return {
...info,
auth,
installation
};
}
/**
* Install Codex CLI using npm
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Installation result
*/
static async installCli(onProgress) {
return new Promise((resolve, reject) => {
const command = 'npm';
const args = ['install', '-g', '@openai/codex@latest'];
const proc = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
shell: true
});
let output = '';
let errorOutput = '';
proc.stdout.on('data', (data) => {
const text = data.toString();
output += text;
if (onProgress) {
onProgress({ type: 'stdout', data: text });
}
});
proc.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
// npm often outputs progress to stderr
if (onProgress) {
onProgress({ type: 'stderr', data: text });
}
});
proc.on('close', (code) => {
if (code === 0) {
resolve({
success: true,
output,
message: 'Codex CLI installed successfully'
});
} else {
reject({
success: false,
error: errorOutput || `Installation failed with code ${code}`,
output
});
}
});
proc.on('error', (error) => {
reject({
success: false,
error: error.message,
output
});
});
});
}
/**
* Authenticate Codex CLI - opens browser for OAuth or stores API key
* @param {string} apiKey Optional API key to store
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Authentication result
*/
static async authenticate(apiKey, onProgress) {
return new Promise((resolve, reject) => {
const detection = this.detectCodexInstallation();
if (!detection.installed) {
reject({
success: false,
error: 'Codex CLI is not installed'
});
return;
}
const codexPath = detection.path || 'codex';
if (apiKey) {
// Store API key directly using codex auth command
const proc = spawn(codexPath, ['auth', 'login', '--api-key', apiKey], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: false
});
let output = '';
let errorOutput = '';
proc.stdout.on('data', (data) => {
const text = data.toString();
output += text;
if (onProgress) {
onProgress({ type: 'stdout', data: text });
}
});
proc.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
if (onProgress) {
onProgress({ type: 'stderr', data: text });
}
});
proc.on('close', (code) => {
if (code === 0) {
resolve({
success: true,
output,
message: 'Codex CLI authenticated successfully'
});
} else {
reject({
success: false,
error: errorOutput || `Authentication failed with code ${code}`,
output
});
}
});
proc.on('error', (error) => {
reject({
success: false,
error: error.message,
output
});
});
} else {
// Require manual authentication
if (onProgress) {
onProgress({
type: 'info',
data: 'Please run the following command in your terminal to authenticate:\n\ncodex auth login\n\nThen return here to continue setup.'
});
}
resolve({
success: true,
requiresManualAuth: true,
command: `${codexPath} auth login`,
message: 'Please authenticate Codex CLI manually'
});
}
});
}
}
module.exports = CodexCliDetector;

View File

@@ -408,16 +408,16 @@ class FeatureExecutor {
if (provider?.ensureAuthEnv && !provider.ensureAuthEnv()) {
// Check if CLI is installed to provide better error message
let authMsg =
"Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.";
"Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication.";
try {
const claudeCliDetector = require("./claude-cli-detector");
const detection = claudeCliDetector.detectClaudeInstallation();
if (detection.installed && detection.method === "cli") {
authMsg =
"Claude CLI is installed but not authenticated. Run `claude login` to authenticate, or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.";
"Claude CLI is installed but not authenticated. Go to Settings > Setup to provide your subscription token (from `claude setup-token`) or API key.";
} else {
authMsg =
"Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.";
"Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication, or set ANTHROPIC_API_KEY environment variable.";
}
} catch (err) {
// Fallback to default message

View File

@@ -94,9 +94,42 @@ class ClaudeProvider extends ModelProvider {
this.sdk = null;
}
/**
* Try to load credentials from the app's own credentials.json file.
* This is where we store OAuth tokens and API keys that users enter in the setup wizard.
* Returns { oauthToken, apiKey } or null values if not found.
*/
loadTokenFromAppCredentials() {
try {
const fs = require('fs');
const path = require('path');
const { app } = require('electron');
const credentialsPath = path.join(app.getPath('userData'), 'credentials.json');
if (!fs.existsSync(credentialsPath)) {
console.log('[ClaudeProvider] App credentials file does not exist:', credentialsPath);
return { oauthToken: null, apiKey: null };
}
const raw = fs.readFileSync(credentialsPath, 'utf-8');
const parsed = JSON.parse(raw);
// Check for OAuth token first (from claude setup-token), then API key
const oauthToken = parsed.anthropic_oauth_token || null;
const apiKey = parsed.anthropic || parsed.anthropic_api_key || null;
console.log('[ClaudeProvider] App credentials check - OAuth token:', !!oauthToken, ', API key:', !!apiKey);
return { oauthToken, apiKey };
} catch (err) {
console.warn('[ClaudeProvider] Failed to read app credentials:', err?.message);
return { oauthToken: null, apiKey: null };
}
}
/**
* Try to load a Claude OAuth token from the local CLI config (~/.claude/config.json).
* Returns the token string or null if not found.
* NOTE: Claude's credentials.json is encrypted, so we only try config.json
*/
loadTokenFromCliConfig() {
try {
@@ -117,30 +150,44 @@ class ClaudeProvider extends ModelProvider {
}
ensureAuthEnv() {
// If API key or token already present, keep as-is.
// If API key or token already present in environment, keep as-is.
if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN) {
console.log('[ClaudeProvider] Auth already present in environment');
return true;
}
// Try to hydrate from CLI login config
// Priority 1: Try to load from app's own credentials (setup wizard)
const appCredentials = this.loadTokenFromAppCredentials();
if (appCredentials.oauthToken) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = appCredentials.oauthToken;
console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from app credentials');
return true;
}
if (appCredentials.apiKey) {
process.env.ANTHROPIC_API_KEY = appCredentials.apiKey;
console.log('[ClaudeProvider] Loaded ANTHROPIC_API_KEY from app credentials');
return true;
}
// Priority 2: Try to hydrate from CLI login config (legacy)
const token = this.loadTokenFromCliConfig();
if (token) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from ~/.claude/config.json');
return true;
}
// Check if CLI is installed but not logged in
try {
const claudeCliDetector = require('./claude-cli-detector');
const detection = claudeCliDetector.detectClaudeInstallation();
if (detection.installed && detection.method === 'cli') {
console.error('[ClaudeProvider] Claude CLI is installed but not logged in. Run `claude login` to authenticate.');
console.error('[ClaudeProvider] Claude CLI is installed but not authenticated. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.');
} else {
console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)');
console.error('[ClaudeProvider] No Anthropic auth found. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.');
}
} catch (err) {
console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)');
console.error('[ClaudeProvider] No Anthropic auth found. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.');
}
return false;
}
@@ -156,17 +203,17 @@ class ClaudeProvider extends ModelProvider {
}
async *executeQuery(options) {
// Ensure we have auth; fall back to CLI login token if available.
// Ensure we have auth; fall back to app credentials or CLI login token if available.
if (!this.ensureAuthEnv()) {
// Check if CLI is installed to provide better error message
let msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.';
let msg = 'Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication.';
try {
const claudeCliDetector = require('./claude-cli-detector');
const detection = claudeCliDetector.detectClaudeInstallation();
if (detection.installed && detection.method === 'cli') {
msg = 'Claude CLI is installed but not authenticated. Run `claude login` to authenticate, or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.';
msg = 'Claude CLI is installed but not authenticated. Go to Settings > Setup to provide your subscription token (from `claude setup-token`) or API key.';
} else {
msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.';
msg = 'Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication, or set ANTHROPIC_API_KEY environment variable.';
}
} catch (err) {
// Fallback to default message
@@ -239,11 +286,11 @@ class ClaudeProvider extends ModelProvider {
validateConfig() {
const errors = [];
// Ensure auth is available (try to auto-load from CLI config)
// Ensure auth is available (try to auto-load from app credentials or CLI config)
this.ensureAuthEnv();
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN && !process.env.ANTHROPIC_API_KEY) {
errors.push('No Claude authentication found. Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY, or run `claude login` to populate ~/.claude/config.json.');
errors.push('No Claude authentication found. Go to Settings > Setup to configure your subscription token or API key.');
}
return {

View File

@@ -11,11 +11,14 @@ import { AgentToolsView } from "@/components/views/agent-tools-view";
import { InterviewView } from "@/components/views/interview-view";
import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { SetupView } from "@/components/views/setup-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
export default function Home() {
const { currentView, setIpcConnected, theme, currentProject } = useAppStore();
const { currentView, setCurrentView, setIpcConnected, theme, currentProject } = useAppStore();
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
// Compute the effective theme: project theme takes priority over global theme
@@ -27,6 +30,24 @@ export default function Home() {
setIsMounted(true);
}, []);
// Check if this is first run and redirect to setup if needed
useEffect(() => {
console.log("[Setup Flow] Checking setup state:", {
isMounted,
isFirstRun,
setupComplete,
currentView,
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
});
if (isMounted && isFirstRun && !setupComplete) {
console.log("[Setup Flow] Redirecting to setup wizard (first run, not complete)");
setCurrentView("setup");
} else if (isMounted && setupComplete) {
console.log("[Setup Flow] Setup already complete, showing normal view");
}
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
// Test IPC connection on mount
useEffect(() => {
const testConnection = async () => {
@@ -100,6 +121,8 @@ export default function Home() {
switch (currentView) {
case "welcome":
return <WelcomeView />;
case "setup":
return <SetupView />;
case "board":
return <BoardView />;
case "spec":
@@ -121,6 +144,21 @@ export default function Home() {
}
};
// Setup view is full-screen without sidebar
if (currentView === "setup") {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<SetupView />
{/* Environment indicator */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
Web Mode (Mock IPC)
</div>
)}
</main>
);
}
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />

View File

@@ -64,9 +64,7 @@ import {
import { Button } from "@/components/ui/button";
import {
useKeyboardShortcuts,
NAV_SHORTCUTS,
UI_SHORTCUTS,
ACTION_SHORTCUTS,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI, Project, TrashedProject } from "@/lib/electron";
@@ -221,6 +219,9 @@ export function Sidebar() {
theme: globalTheme,
} = useAppStore();
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// State for project picker dropdown
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
const [showTrashDialog, setShowTrashDialog] = useState(false);
@@ -496,13 +497,13 @@ export function Sidebar() {
id: "board",
label: "Kanban Board",
icon: LayoutGrid,
shortcut: NAV_SHORTCUTS.board,
shortcut: shortcuts.board,
},
{
id: "agent",
label: "Agent Runner",
icon: Bot,
shortcut: NAV_SHORTCUTS.agent,
shortcut: shortcuts.agent,
},
],
},
@@ -513,25 +514,25 @@ export function Sidebar() {
id: "spec",
label: "Spec Editor",
icon: FileText,
shortcut: NAV_SHORTCUTS.spec,
shortcut: shortcuts.spec,
},
{
id: "context",
label: "Context",
icon: BookOpen,
shortcut: NAV_SHORTCUTS.context,
shortcut: shortcuts.context,
},
{
id: "tools",
label: "Agent Tools",
icon: Wrench,
shortcut: NAV_SHORTCUTS.tools,
shortcut: shortcuts.tools,
},
{
id: "profiles",
label: "AI Profiles",
icon: UserCircle,
shortcut: NAV_SHORTCUTS.profiles,
shortcut: shortcuts.profiles,
},
],
},
@@ -573,26 +574,26 @@ export function Sidebar() {
// Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcuts: KeyboardShortcut[] = [];
const shortcutsList: KeyboardShortcut[] = [];
// Sidebar toggle shortcut - always available
shortcuts.push({
key: UI_SHORTCUTS.toggleSidebar,
shortcutsList.push({
key: shortcuts.toggleSidebar,
action: () => toggleSidebar(),
description: "Toggle sidebar",
});
// Open project shortcut - opens the folder selection dialog directly
shortcuts.push({
key: ACTION_SHORTCUTS.openProject,
shortcutsList.push({
key: shortcuts.openProject,
action: () => handleOpenFolder(),
description: "Open folder selection dialog",
});
// Project picker shortcut - only when we have projects
if (projects.length > 0) {
shortcuts.push({
key: ACTION_SHORTCUTS.projectPicker,
shortcutsList.push({
key: shortcuts.projectPicker,
action: () => setIsProjectPickerOpen((prev) => !prev),
description: "Toggle project picker",
});
@@ -600,13 +601,13 @@ export function Sidebar() {
// Project cycling shortcuts - only when we have project history
if (projectHistory.length > 1) {
shortcuts.push({
key: ACTION_SHORTCUTS.cyclePrevProject,
shortcutsList.push({
key: shortcuts.cyclePrevProject,
action: () => cyclePrevProject(),
description: "Cycle to previous project (MRU)",
});
shortcuts.push({
key: ACTION_SHORTCUTS.cycleNextProject,
shortcutsList.push({
key: shortcuts.cycleNextProject,
action: () => cycleNextProject(),
description: "Cycle to next project (LRU)",
});
@@ -617,7 +618,7 @@ export function Sidebar() {
navSections.forEach((section) => {
section.items.forEach((item) => {
if (item.shortcut) {
shortcuts.push({
shortcutsList.push({
key: item.shortcut,
action: () => setCurrentView(item.id as any),
description: `Navigate to ${item.label}`,
@@ -627,15 +628,16 @@ export function Sidebar() {
});
// Add settings shortcut
shortcuts.push({
key: NAV_SHORTCUTS.settings,
shortcutsList.push({
key: shortcuts.settings,
action: () => setCurrentView("settings"),
description: "Navigate to Settings",
});
}
return shortcuts;
return shortcutsList;
}, [
shortcuts,
currentProject,
setCurrentView,
toggleSidebar,
@@ -644,6 +646,7 @@ export function Sidebar() {
projectHistory.length,
cyclePrevProject,
cycleNextProject,
navSections,
]);
// Register keyboard shortcuts
@@ -682,7 +685,7 @@ export function Sidebar() {
className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70"
data-testid="sidebar-toggle-shortcut"
>
{UI_SHORTCUTS.toggleSidebar}
{shortcuts.toggleSidebar}
</span>
</div>
</button>
@@ -735,12 +738,12 @@ export function Sidebar() {
<button
onClick={handleOpenFolder}
className="group flex items-center justify-center flex-1 px-3 py-2.5 rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border"
title={`Open Folder (${ACTION_SHORTCUTS.openProject})`}
title={`Open Folder (${shortcuts.openProject})`}
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 shrink-0" />
<span className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
{ACTION_SHORTCUTS.openProject}
{shortcuts.openProject}
</span>
</button>
<button
@@ -782,7 +785,7 @@ export function Sidebar() {
className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="project-picker-shortcut"
>
{ACTION_SHORTCUTS.projectPicker}
{shortcuts.projectPicker}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
@@ -889,14 +892,14 @@ export function Sidebar() {
<Undo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Previous</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{ACTION_SHORTCUTS.cyclePrevProject}
{shortcuts.cyclePrevProject}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
<Redo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Next</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{ACTION_SHORTCUTS.cycleNextProject}
{shortcuts.cycleNextProject}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
@@ -1052,7 +1055,7 @@ export function Sidebar() {
)}
data-testid="shortcut-settings"
>
{NAV_SHORTCUTS.settings}
{shortcuts.settings}
</span>
)}
{!sidebarOpen && (

View File

@@ -24,7 +24,8 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { SessionListItem } from "@/types/electron";
import { ACTION_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useAppStore } from "@/store/app-store";
// Random session name generator
const adjectives = [
@@ -61,6 +62,7 @@ export function SessionManager({
isCurrentSessionThinking = false,
onQuickCreateRef,
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
@@ -246,12 +248,12 @@ export function SessionManager({
size="sm"
onClick={handleQuickCreateSession}
data-testid="new-session-button"
title={`New Session (${ACTION_SHORTCUTS.newSession})`}
title={`New Session (${shortcuts.newSession})`}
>
<Plus className="w-4 h-4 mr-1" />
New
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground">
{ACTION_SHORTCUTS.newSession}
{shortcuts.newSession}
</span>
</Button>
)}

View File

@@ -26,12 +26,13 @@ import { Markdown } from "@/components/ui/markdown";
import type { ImageAttachment } from "@/store/app-store";
import {
useKeyboardShortcuts,
ACTION_SHORTCUTS,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState("");
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
@@ -417,12 +418,12 @@ export function AgentView() {
// Keyboard shortcuts for agent view
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcuts: KeyboardShortcut[] = [];
const shortcutsList: KeyboardShortcut[] = [];
// New session shortcut - only when in agent view with a project
if (currentProject) {
shortcuts.push({
key: ACTION_SHORTCUTS.newSession,
shortcutsList.push({
key: shortcuts.newSession,
action: () => {
if (quickCreateSessionRef.current) {
quickCreateSessionRef.current();
@@ -432,8 +433,8 @@ export function AgentView() {
});
}
return shortcuts;
}, [currentProject]);
return shortcutsList;
}, [currentProject, shortcuts]);
// Register keyboard shortcuts
useKeyboardShortcuts(agentShortcuts);

View File

@@ -86,7 +86,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { useAutoMode } from "@/hooks/use-auto-mode";
import {
useKeyboardShortcuts,
ACTION_SHORTCUTS,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { useWindowState } from "@/hooks/use-window-state";
@@ -189,6 +189,7 @@ export function BoardView() {
showProfilesOnly,
aiProfiles,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
@@ -292,14 +293,14 @@ export function BoardView() {
// Keyboard shortcuts for this view
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcuts: KeyboardShortcut[] = [
const shortcutsList: KeyboardShortcut[] = [
{
key: ACTION_SHORTCUTS.addFeature,
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: "Add new feature",
},
{
key: ACTION_SHORTCUTS.startNext,
key: shortcuts.startNext,
action: () => startNextFeaturesRef.current(),
description: "Start next features from backlog",
},
@@ -309,7 +310,7 @@ export function BoardView() {
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
// Keys 1-9 for first 9 cards, 0 for 10th card
const key = index === 9 ? "0" : String(index + 1);
shortcuts.push({
shortcutsList.push({
key,
action: () => {
setOutputFeature(feature);
@@ -319,8 +320,8 @@ export function BoardView() {
});
});
return shortcuts;
}, [inProgressFeaturesForShortcuts]);
return shortcutsList;
}, [inProgressFeaturesForShortcuts, shortcuts]);
useKeyboardShortcuts(boardShortcuts);
// Prevent hydration issues
@@ -1567,7 +1568,7 @@ export function BoardView() {
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground inline-flex items-center justify-center"
data-testid="shortcut-add-feature"
>
{ACTION_SHORTCUTS.addFeature}
{shortcuts.addFeature}
</span>
</Button>
</div>
@@ -1636,7 +1637,7 @@ export function BoardView() {
<FastForward className="w-3 h-3 mr-1" />
Start Next
<span className="ml-1 px-1 py-0.5 text-[9px] font-mono rounded bg-accent border border-border-glass">
{ACTION_SHORTCUTS.startNext}
{shortcuts.startNext}
</span>
</Button>
)}

View File

@@ -19,7 +19,7 @@ import {
} from "lucide-react";
import {
useKeyboardShortcuts,
ACTION_SHORTCUTS,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
@@ -43,6 +43,7 @@ interface ContextFile {
export function ContextView() {
const { currentProject } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -63,12 +64,12 @@ export function ContextView() {
const contextShortcuts: KeyboardShortcut[] = useMemo(
() => [
{
key: ACTION_SHORTCUTS.addContextFile,
key: shortcuts.addContextFile,
action: () => setIsAddDialogOpen(true),
description: "Add new context file",
},
],
[]
[shortcuts]
);
useKeyboardShortcuts(contextShortcuts);
@@ -374,7 +375,7 @@ export function ContextView() {
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-secondary border border-border"
data-testid="shortcut-add-context-file"
>
{ACTION_SHORTCUTS.addContextFile}
{shortcuts.addContextFile}
</span>
</Button>
</div>

View File

@@ -9,7 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { cn, modelSupportsThinking } from "@/lib/utils";
import {
useKeyboardShortcuts,
ACTION_SHORTCUTS,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
@@ -440,6 +440,7 @@ function ProfileForm({
export function ProfilesView() {
const { aiProfiles, addAIProfile, updateAIProfile, removeAIProfile, reorderAIProfiles } =
useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
@@ -508,17 +509,17 @@ export function ProfilesView() {
// Build keyboard shortcuts for profiles view
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcuts: KeyboardShortcut[] = [];
const shortcutsList: KeyboardShortcut[] = [];
// Add profile shortcut - when in profiles view
shortcuts.push({
key: ACTION_SHORTCUTS.addProfile,
shortcutsList.push({
key: shortcuts.addProfile,
action: () => setShowAddDialog(true),
description: "Create new profile",
});
return shortcuts;
}, []);
return shortcutsList;
}, [shortcuts]);
// Register keyboard shortcuts for profiles view
useKeyboardShortcuts(profilesShortcuts);
@@ -549,7 +550,7 @@ export function ProfilesView() {
<Plus className="w-4 h-4 mr-2" />
New Profile
<span className="hidden lg:flex items-center justify-center ml-2 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground">
{ACTION_SHORTCUTS.addProfile}
{shortcuts.addProfile}
</span>
</Button>
</div>

View File

@@ -1,7 +1,8 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS } from "@/store/app-store";
import type { KeyboardShortcuts } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -38,6 +39,8 @@ import {
GitBranch,
TestTube,
Settings2,
RefreshCw,
RotateCcw,
} from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { Checkbox } from "@/components/ui/checkbox";
@@ -57,6 +60,7 @@ const NAV_ITEMS = [
{ id: "codex", label: "Codex", icon: Atom },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
];
@@ -78,6 +82,9 @@ export function SettingsView() {
setShowProfilesOnly,
currentProject,
moveProjectToTrash,
keyboardShortcuts,
setKeyboardShortcut,
resetKeyboardShortcuts,
} = useAppStore();
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google);
@@ -134,6 +141,11 @@ export function SettingsView() {
} | null>(null);
const [activeSection, setActiveSection] = useState("api-keys");
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
const [editingShortcut, setEditingShortcut] = useState<string | null>(null);
const [shortcutValue, setShortcutValue] = useState("");
const [shortcutError, setShortcutError] = useState<string | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -339,6 +351,36 @@ export function SettingsView() {
}
};
const handleRefreshClaudeCli = useCallback(async () => {
setIsCheckingClaudeCli(true);
try {
const api = getElectronAPI();
if (api?.checkClaudeCli) {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
}
} catch (error) {
console.error("Failed to refresh Claude CLI status:", error);
} finally {
setIsCheckingClaudeCli(false);
}
}, []);
const handleRefreshCodexCli = useCallback(async () => {
setIsCheckingCodexCli(true);
try {
const api = getElectronAPI();
if (api?.checkCodexCli) {
const status = await api.checkCodexCli();
setCodexCliStatus(status);
}
} catch (error) {
console.error("Failed to refresh Codex CLI status:", error);
} finally {
setIsCheckingCodexCli(false);
}
}, []);
const handleSave = () => {
setApiKeys({
anthropic: anthropicKey,
@@ -729,11 +771,27 @@ export function SettingsView() {
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Terminal className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Claude Code CLI
</h2>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Claude Code CLI
</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshClaudeCli}
disabled={isCheckingClaudeCli}
data-testid="refresh-claude-cli"
title="Refresh Claude CLI detection"
>
<RefreshCw
className={`w-4 h-4 ${
isCheckingClaudeCli ? "animate-spin" : ""
}`}
/>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Claude Code CLI provides better performance for long-running
@@ -853,11 +911,27 @@ export function SettingsView() {
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Terminal className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-foreground">
OpenAI Codex CLI
</h2>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-foreground">
OpenAI Codex CLI
</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshCodexCli}
disabled={isCheckingCodexCli}
data-testid="refresh-codex-cli"
title="Refresh Codex CLI detection"
>
<RefreshCw
className={`w-4 h-4 ${
isCheckingCodexCli ? "animate-spin" : ""
}`}
/>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Codex CLI enables GPT-5.1 Codex models for autonomous coding
@@ -1269,6 +1343,393 @@ export function SettingsView() {
</div>
</div>
{/* Keyboard Shortcuts Section */}
<div
id="keyboard"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Settings2 className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Keyboard Shortcuts
</h2>
</div>
<p className="text-sm text-muted-foreground">
Customize keyboard shortcuts for navigation and actions. Click
on any shortcut to edit it.
</p>
</div>
<div className="p-6 space-y-6">
{/* Navigation Shortcuts */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
Navigation
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="text-xs h-7"
data-testid="reset-shortcuts-button"
>
<RotateCcw className="w-3 h-3 mr-1" />
Reset All to Defaults
</Button>
</div>
<div className="space-y-2">
{[
{ key: "board" as keyof KeyboardShortcuts, label: "Kanban Board" },
{ key: "agent" as keyof KeyboardShortcuts, label: "Agent Runner" },
{ key: "spec" as keyof KeyboardShortcuts, label: "Spec Editor" },
{ key: "context" as keyof KeyboardShortcuts, label: "Context" },
{ key: "tools" as keyof KeyboardShortcuts, label: "Agent Tools" },
{ key: "profiles" as keyof KeyboardShortcuts, label: "AI Profiles" },
{ key: "settings" as keyof KeyboardShortcuts, label: "Settings" },
].map(({ key, label }) => (
<div
key={key}
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{editingShortcut === key ? (
<>
<Input
value={shortcutValue}
onChange={(e) => {
const value = e.target.value.toUpperCase();
setShortcutValue(value);
// Check for conflicts
const conflict = Object.entries(keyboardShortcuts).find(
([k, v]) => k !== key && v.toUpperCase() === value
);
if (conflict) {
setShortcutError(`Already used by ${conflict[0]}`);
} else {
setShortcutError(null);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
} else if (e.key === "Escape") {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
className="w-24 h-8 text-center font-mono"
placeholder="Key"
maxLength={2}
autoFocus
data-testid={`edit-shortcut-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
if (!shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
disabled={!!shortcutError || !shortcutValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}}
data-testid={`cancel-shortcut-${key}`}
>
<AlertCircle className="w-4 h-4" />
</Button>
</>
) : (
<>
<button
onClick={() => {
setEditingShortcut(key);
setShortcutValue(keyboardShortcuts[key]);
setShortcutError(null);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
)}
</>
)}
</div>
</div>
))}
</div>
{shortcutError && (
<p className="text-xs text-red-400">{shortcutError}</p>
)}
</div>
{/* UI Shortcuts */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">
UI Controls
</h3>
<div className="space-y-2">
{[
{ key: "toggleSidebar" as keyof KeyboardShortcuts, label: "Toggle Sidebar" },
].map(({ key, label }) => (
<div
key={key}
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{editingShortcut === key ? (
<>
<Input
value={shortcutValue}
onChange={(e) => {
const value = e.target.value;
setShortcutValue(value);
// Check for conflicts
const conflict = Object.entries(keyboardShortcuts).find(
([k, v]) => k !== key && v === value
);
if (conflict) {
setShortcutError(`Already used by ${conflict[0]}`);
} else {
setShortcutError(null);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
} else if (e.key === "Escape") {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
className="w-24 h-8 text-center font-mono"
placeholder="Key"
maxLength={2}
autoFocus
data-testid={`edit-shortcut-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
if (!shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
disabled={!!shortcutError || !shortcutValue}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}}
>
<AlertCircle className="w-4 h-4" />
</Button>
</>
) : (
<>
<button
onClick={() => {
setEditingShortcut(key);
setShortcutValue(keyboardShortcuts[key]);
setShortcutError(null);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
)}
</>
)}
</div>
</div>
))}
</div>
</div>
{/* Action Shortcuts */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">
Actions
</h3>
<div className="space-y-2">
{[
{ key: "addFeature" as keyof KeyboardShortcuts, label: "Add Feature" },
{ key: "addContextFile" as keyof KeyboardShortcuts, label: "Add Context File" },
{ key: "startNext" as keyof KeyboardShortcuts, label: "Start Next Features" },
{ key: "newSession" as keyof KeyboardShortcuts, label: "New Session" },
{ key: "openProject" as keyof KeyboardShortcuts, label: "Open Project" },
{ key: "projectPicker" as keyof KeyboardShortcuts, label: "Project Picker" },
{ key: "cyclePrevProject" as keyof KeyboardShortcuts, label: "Previous Project" },
{ key: "cycleNextProject" as keyof KeyboardShortcuts, label: "Next Project" },
{ key: "addProfile" as keyof KeyboardShortcuts, label: "Add Profile" },
].map(({ key, label }) => (
<div
key={key}
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{editingShortcut === key ? (
<>
<Input
value={shortcutValue}
onChange={(e) => {
const value = e.target.value.toUpperCase();
setShortcutValue(value);
// Check for conflicts
const conflict = Object.entries(keyboardShortcuts).find(
([k, v]) => k !== key && v.toUpperCase() === value
);
if (conflict) {
setShortcutError(`Already used by ${conflict[0]}`);
} else {
setShortcutError(null);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
} else if (e.key === "Escape") {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
className="w-24 h-8 text-center font-mono"
placeholder="Key"
maxLength={2}
autoFocus
data-testid={`edit-shortcut-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
if (!shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
disabled={!!shortcutError || !shortcutValue}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}}
>
<AlertCircle className="w-4 h-4" />
</Button>
</>
) : (
<>
<button
onClick={() => {
setEditingShortcut(key);
setShortcutValue(keyboardShortcuts[key]);
setShortcutError(null);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
)}
</>
)}
</div>
</div>
))}
</div>
</div>
{/* Information */}
<div className="flex items-start gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-400">
About Keyboard Shortcuts
</p>
<p className="text-blue-400/80 text-xs mt-1">
Shortcuts won&apos;t trigger when typing in input fields. Use
single keys (A-Z, 0-9) or special keys like ` (backtick).
Changes take effect immediately.
</p>
</div>
</div>
</div>
</div>
{/* Feature Defaults Section */}
<div
id="defaults"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
export interface KeyboardShortcut {
key: string;
@@ -97,36 +98,10 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
}
/**
* Shortcut definitions for navigation
* Hook to get current keyboard shortcuts from store
* This replaces the static constants and allows customization
*/
export const NAV_SHORTCUTS: Record<string, string> = {
board: "K", // K for Kanban
agent: "A", // A for Agent
spec: "D", // D for Document (Spec)
context: "C", // C for Context
tools: "T", // T for Tools
settings: "S", // S for Settings
profiles: "M", // M for Models/profiles
};
/**
* Shortcut definitions for UI controls
*/
export const UI_SHORTCUTS: Record<string, string> = {
toggleSidebar: "`", // Backtick to toggle sidebar
};
/**
* Shortcut definitions for add buttons
*/
export const ACTION_SHORTCUTS: Record<string, string> = {
addFeature: "N", // N for New feature
addContextFile: "F", // F for File (add context file)
startNext: "G", // G for Grab (start next features from backlog)
newSession: "N", // N for New session (in agent view)
openProject: "O", // O for Open project (navigate to welcome view)
projectPicker: "P", // P for Project picker
cyclePrevProject: "Q", // Q for previous project (cycle back through MRU)
cycleNextProject: "E", // E for next project (cycle forward through MRU)
addProfile: "N", // N for New profile (when in profiles view)
};
export function useKeyboardShortcutsConfig() {
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
return keyboardShortcuts;
}

View File

@@ -172,6 +172,54 @@ export interface ElectronAPI {
git?: GitAPI;
suggestions?: SuggestionsAPI;
specRegeneration?: SpecRegenerationAPI;
setup?: {
getClaudeStatus: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
hasCredentialsFile: boolean;
hasToken: boolean;
};
error?: string;
}>;
getCodexStatus: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
hasAuthFile: boolean;
hasEnvKey: boolean;
};
error?: string;
}>;
installClaude: () => Promise<{ success: boolean; message?: string; error?: string }>;
installCodex: () => Promise<{ success: boolean; message?: string; error?: string }>;
authClaude: () => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>;
authCodex: (apiKey?: string) => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>;
storeApiKey: (provider: string, apiKey: string) => Promise<{ success: boolean; error?: string }>;
getApiKeys: () => Promise<{ success: boolean; hasAnthropicKey: boolean; hasOpenAIKey: boolean; hasGoogleKey: boolean }>;
configureCodexMcp: (projectPath: string) => Promise<{ success: boolean; configPath?: string; error?: string }>;
getPlatform: () => Promise<{
success: boolean;
platform: string;
arch: string;
homeDir: string;
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
};
}
// Note: Window interface is declared in @/types/electron.d.ts
@@ -461,6 +509,9 @@ export const getElectronAPI = (): ElectronAPI => {
error: "OpenAI connection test is only available in the Electron app.",
}),
// Mock Setup API
setup: createMockSetupAPI(),
// Mock Auto Mode API
autoMode: createMockAutoModeAPI(),
@@ -478,6 +529,175 @@ export const getElectronAPI = (): ElectronAPI => {
};
};
// Setup API interface
interface SetupAPI {
getClaudeStatus: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
hasCredentialsFile: boolean;
hasToken: boolean;
};
error?: string;
}>;
getCodexStatus: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
hasAuthFile: boolean;
hasEnvKey: boolean;
};
error?: string;
}>;
installClaude: () => Promise<{ success: boolean; message?: string; error?: string }>;
installCodex: () => Promise<{ success: boolean; message?: string; error?: string }>;
authClaude: () => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>;
authCodex: (apiKey?: string) => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>;
storeApiKey: (provider: string, apiKey: string) => Promise<{ success: boolean; error?: string }>;
getApiKeys: () => Promise<{ success: boolean; hasAnthropicKey: boolean; hasOpenAIKey: boolean; hasGoogleKey: boolean }>;
configureCodexMcp: (projectPath: string) => Promise<{ success: boolean; configPath?: string; error?: string }>;
getPlatform: () => Promise<{
success: boolean;
platform: string;
arch: string;
homeDir: string;
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
}
// Mock Setup API implementation
function createMockSetupAPI(): SetupAPI {
return {
getClaudeStatus: async () => {
console.log("[Mock] Getting Claude status");
return {
success: true,
status: "not_installed",
auth: {
authenticated: false,
method: "none",
hasCredentialsFile: false,
hasToken: false,
},
};
},
getCodexStatus: async () => {
console.log("[Mock] Getting Codex status");
return {
success: true,
status: "not_installed",
auth: {
authenticated: false,
method: "none",
hasAuthFile: false,
hasEnvKey: false,
},
};
},
installClaude: async () => {
console.log("[Mock] Installing Claude CLI");
// Simulate installation delay
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
success: false,
error: "CLI installation is only available in the Electron app. Please run the command manually.",
};
},
installCodex: async () => {
console.log("[Mock] Installing Codex CLI");
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
success: false,
error: "CLI installation is only available in the Electron app. Please run the command manually.",
};
},
authClaude: async () => {
console.log("[Mock] Auth Claude CLI");
return {
success: true,
requiresManualAuth: true,
command: "claude login",
};
},
authCodex: async (apiKey?: string) => {
console.log("[Mock] Auth Codex CLI", { hasApiKey: !!apiKey });
if (apiKey) {
return { success: true };
}
return {
success: true,
requiresManualAuth: true,
command: "codex auth login",
};
},
storeApiKey: async (provider: string, apiKey: string) => {
console.log("[Mock] Storing API key for:", provider);
// In mock mode, we just pretend to store it (it's already in the app store)
return { success: true };
},
getApiKeys: async () => {
console.log("[Mock] Getting API keys");
return {
success: true,
hasAnthropicKey: false,
hasOpenAIKey: false,
hasGoogleKey: false,
};
},
configureCodexMcp: async (projectPath: string) => {
console.log("[Mock] Configuring Codex MCP for:", projectPath);
return {
success: true,
configPath: `${projectPath}/.codex/config.toml`,
};
},
getPlatform: async () => {
return {
success: true,
platform: "darwin",
arch: "arm64",
homeDir: "/Users/mock",
isWindows: false,
isMac: true,
isLinux: false,
};
},
onInstallProgress: (callback) => {
// Mock progress events
return () => {};
},
onAuthProgress: (callback) => {
// Mock auth events
return () => {};
},
};
}
// Mock Worktree API implementation
function createMockWorktreeAPI(): WorktreeAPI {
return {

View File

@@ -4,6 +4,7 @@ import type { Project, TrashedProject } from "@/lib/electron";
export type ViewMode =
| "welcome"
| "setup"
| "spec"
| "board"
| "agent"
@@ -36,6 +37,60 @@ export interface ApiKeys {
openai: string;
}
// Keyboard Shortcuts
export interface KeyboardShortcuts {
// Navigation shortcuts
board: string;
agent: string;
spec: string;
context: string;
tools: string;
settings: string;
profiles: string;
// UI shortcuts
toggleSidebar: string;
// Action shortcuts
addFeature: string;
addContextFile: string;
startNext: string;
newSession: string;
openProject: string;
projectPicker: string;
cyclePrevProject: string;
cycleNextProject: string;
addProfile: string;
}
// Default keyboard shortcuts
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
// Navigation
board: "K",
agent: "A",
spec: "D",
context: "C",
tools: "T",
settings: "S",
profiles: "M",
// UI
toggleSidebar: "`",
// Actions
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile)
// This is intentional as they are context-specific and only active in their respective views
addFeature: "N", // Only active in board view
addContextFile: "F", // Only active in context view
startNext: "G", // Only active in board view
newSession: "N", // Only active in agent view
openProject: "O", // Global shortcut
projectPicker: "P", // Global shortcut
cyclePrevProject: "Q", // Global shortcut
cycleNextProject: "E", // Global shortcut
addProfile: "N", // Only active in profiles view
};
export interface ImageAttachment {
id: string;
data: string; // base64 encoded image data
@@ -202,6 +257,9 @@ export interface AppState {
// Profile Display Settings
showProfilesOnly: boolean; // When true, hide model tweaking options and show only profile selection
// Keyboard Shortcuts
keyboardShortcuts: KeyboardShortcuts; // User-defined keyboard shortcuts
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
@@ -302,6 +360,11 @@ export interface AppActions {
// Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void;
// Keyboard Shortcuts actions
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) => void;
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
resetKeyboardShortcuts: () => void;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, "id">) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
@@ -403,6 +466,7 @@ const initialState: AppState = {
defaultSkipTests: false, // Default to TDD mode (tests enabled)
useWorktrees: false, // Default to disabled (worktree feature is experimental)
showProfilesOnly: false, // Default to showing all options (not profiles only)
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
@@ -906,6 +970,29 @@ export const useAppStore = create<AppState & AppActions>()(
// Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
// Keyboard Shortcuts actions
setKeyboardShortcut: (key, value) => {
set({
keyboardShortcuts: {
...get().keyboardShortcuts,
[key]: value,
},
});
},
setKeyboardShortcuts: (shortcuts) => {
set({
keyboardShortcuts: {
...get().keyboardShortcuts,
...shortcuts,
},
});
},
resetKeyboardShortcuts: () => {
set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS });
},
// AI Profile actions
addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random()
@@ -984,6 +1071,7 @@ export const useAppStore = create<AppState & AppActions>()(
defaultSkipTests: state.defaultSkipTests,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
}),

View File

@@ -0,0 +1,182 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// CLI Installation Status
export interface CliStatus {
installed: boolean;
path: string | null;
version: string | null;
method: string;
error?: string;
}
// Claude Auth Status
export interface ClaudeAuthStatus {
authenticated: boolean;
method: "oauth" | "api_key" | "none";
hasCredentialsFile: boolean;
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
error?: string;
}
// Codex Auth Status
export interface CodexAuthStatus {
authenticated: boolean;
method: "api_key" | "env" | "none";
apiKeyValid?: boolean;
mcpConfigured?: boolean;
error?: string;
}
// Installation Progress
export interface InstallProgress {
isInstalling: boolean;
currentStep: string;
progress: number; // 0-100
output: string[];
error?: string;
}
export type SetupStep =
| "welcome"
| "claude_detect"
| "claude_auth"
| "codex_detect"
| "codex_auth"
| "complete";
export interface SetupState {
// Setup wizard state
isFirstRun: boolean;
setupComplete: boolean;
currentStep: SetupStep;
// Claude CLI state
claudeCliStatus: CliStatus | null;
claudeAuthStatus: ClaudeAuthStatus | null;
claudeInstallProgress: InstallProgress;
// Codex CLI state
codexCliStatus: CliStatus | null;
codexAuthStatus: CodexAuthStatus | null;
codexInstallProgress: InstallProgress;
// Setup preferences
skipClaudeSetup: boolean;
skipCodexSetup: boolean;
}
export interface SetupActions {
// Setup flow
setCurrentStep: (step: SetupStep) => void;
completeSetup: () => void;
resetSetup: () => void;
setIsFirstRun: (isFirstRun: boolean) => void;
// Claude CLI
setClaudeCliStatus: (status: CliStatus | null) => void;
setClaudeAuthStatus: (status: ClaudeAuthStatus | null) => void;
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
resetClaudeInstallProgress: () => void;
// Codex CLI
setCodexCliStatus: (status: CliStatus | null) => void;
setCodexAuthStatus: (status: CodexAuthStatus | null) => void;
setCodexInstallProgress: (progress: Partial<InstallProgress>) => void;
resetCodexInstallProgress: () => void;
// Preferences
setSkipClaudeSetup: (skip: boolean) => void;
setSkipCodexSetup: (skip: boolean) => void;
}
const initialInstallProgress: InstallProgress = {
isInstalling: false,
currentStep: "",
progress: 0,
output: [],
};
const initialState: SetupState = {
isFirstRun: true,
setupComplete: false,
currentStep: "welcome",
claudeCliStatus: null,
claudeAuthStatus: null,
claudeInstallProgress: { ...initialInstallProgress },
codexCliStatus: null,
codexAuthStatus: null,
codexInstallProgress: { ...initialInstallProgress },
skipClaudeSetup: false,
skipCodexSetup: false,
};
export const useSetupStore = create<SetupState & SetupActions>()(
persist(
(set, get) => ({
...initialState,
// Setup flow
setCurrentStep: (step) => set({ currentStep: step }),
completeSetup: () => set({ setupComplete: true, currentStep: "complete" }),
resetSetup: () => set({
...initialState,
isFirstRun: false, // Don't reset first run flag
}),
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
// Claude CLI
setClaudeCliStatus: (status) => set({ claudeCliStatus: status }),
setClaudeAuthStatus: (status) => set({ claudeAuthStatus: status }),
setClaudeInstallProgress: (progress) => set({
claudeInstallProgress: {
...get().claudeInstallProgress,
...progress,
},
}),
resetClaudeInstallProgress: () => set({
claudeInstallProgress: { ...initialInstallProgress },
}),
// Codex CLI
setCodexCliStatus: (status) => set({ codexCliStatus: status }),
setCodexAuthStatus: (status) => set({ codexAuthStatus: status }),
setCodexInstallProgress: (progress) => set({
codexInstallProgress: {
...get().codexInstallProgress,
...progress,
},
}),
resetCodexInstallProgress: () => set({
codexInstallProgress: { ...initialInstallProgress },
}),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
setSkipCodexSetup: (skip) => set({ skipCodexSetup: skip }),
}),
{
name: "automaker-setup",
partialize: (state) => ({
isFirstRun: state.isFirstRun,
setupComplete: state.setupComplete,
skipClaudeSetup: state.skipClaudeSetup,
skipCodexSetup: state.skipCodexSetup,
}),
}
)
);

View File

@@ -2338,3 +2338,173 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
options
);
}
// ============================================================================
// Setup View Utilities
// ============================================================================
/**
* Set up the app store to show setup view (simulate first run)
*/
export async function setupFirstRun(page: Page): Promise<void> {
await page.addInitScript(() => {
// Clear any existing setup state to simulate first run
localStorage.removeItem("automaker-setup");
localStorage.removeItem("automaker-storage");
// Set up the setup store state for first run
const setupState = {
state: {
isFirstRun: true,
setupComplete: false,
currentStep: "welcome",
claudeCliStatus: null,
claudeAuthStatus: null,
claudeInstallProgress: {
isInstalling: false,
currentStep: "",
progress: 0,
output: [],
},
codexCliStatus: null,
codexAuthStatus: null,
codexInstallProgress: {
isInstalling: false,
currentStep: "",
progress: 0,
output: [],
},
skipClaudeSetup: false,
skipCodexSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
// Also set up app store to show setup view
const appState = {
state: {
projects: [],
currentProject: null,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "", openai: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
isAutoModeRunning: false,
runningAutoTasks: [],
autoModeActivityLog: [],
currentView: "setup",
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(appState));
});
}
/**
* Set up the app to skip the setup wizard (setup already complete)
*/
export async function setupComplete(page: Page): Promise<void> {
await page.addInitScript(() => {
// Mark setup as complete
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
skipCodexSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
});
}
/**
* Navigate to the setup view directly
*/
export async function navigateToSetup(page: Page): Promise<void> {
await setupFirstRun(page);
await page.goto("/");
await page.waitForLoadState("networkidle");
await waitForElement(page, "setup-view", { timeout: 10000 });
}
/**
* Wait for setup view to be visible
*/
export async function waitForSetupView(page: Page): Promise<Locator> {
return waitForElement(page, "setup-view", { timeout: 10000 });
}
/**
* Click "Get Started" button on setup welcome step
*/
export async function clickSetupGetStarted(page: Page): Promise<void> {
const button = await getByTestId(page, "setup-start-button");
await button.click();
}
/**
* Click continue on Claude setup step
*/
export async function clickClaudeContinue(page: Page): Promise<void> {
const button = await getByTestId(page, "claude-next-button");
await button.click();
}
/**
* Click continue on Codex setup step
*/
export async function clickCodexContinue(page: Page): Promise<void> {
const button = await getByTestId(page, "codex-next-button");
await button.click();
}
/**
* Click finish on setup complete step
*/
export async function clickSetupFinish(page: Page): Promise<void> {
const button = await getByTestId(page, "setup-finish-button");
await button.click();
}
/**
* Enter Anthropic API key in setup
*/
export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<void> {
// Click "Use Anthropic API Key Instead" button
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
await useApiKeyButton.click();
// Enter the API key
const input = await getByTestId(page, "anthropic-api-key-input");
await input.fill(apiKey);
// Click save button
const saveButton = await getByTestId(page, "save-anthropic-key-button");
await saveButton.click();
}
/**
* Enter OpenAI API key in setup
*/
export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise<void> {
// Click "Enter OpenAI API Key" button
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
await useApiKeyButton.click();
// Enter the API key
const input = await getByTestId(page, "openai-api-key-input");
await input.fill(apiKey);
// Click save button
const saveButton = await getByTestId(page, "save-openai-key-button");
await saveButton.click();
}