Merge branch 'main' into move-marketing

This commit is contained in:
Cody Seibert
2025-12-13 20:29:11 -05:00
94 changed files with 13669 additions and 2857 deletions

View File

@@ -29,5 +29,13 @@ jobs:
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Run build:electron - name: Run build:electron
run: npm run build:electron run: npm run build:electron

View File

@@ -48,6 +48,15 @@ jobs:
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Only needed on Linux - macOS and Windows get their bindings automatically
if: matrix.os == 'ubuntu-latest'
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Extract and set version - name: Extract and set version
id: version id: version
shell: bash shell: bash

53
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Test Suite
on:
pull_request:
branches:
- "*"
push:
branches:
- main
- master
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Run server tests with coverage
run: npm run test:server:coverage
env:
NODE_ENV: test
# - name: Upload coverage reports
# uses: codecov/codecov-action@v4
# if: always()
# with:
# files: ./apps/server/coverage/coverage-final.json
# flags: server
# name: server-coverage
# env:
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ dist/
.automaker/ .automaker/
/.automaker/* /.automaker/*
/.automaker/ /.automaker/
/old

6
.npmrc
View File

@@ -8,3 +8,9 @@
# #
# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve # In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
# the correct platform-specific binaries at install time. # the correct platform-specific binaries at install time.
# Include bindings for all platforms in package-lock.json to support CI/CD
# This ensures Linux, macOS, and Windows bindings are all present
# NOTE: Only enable when regenerating package-lock.json, then comment out to keep installs fast
# supportedArchitectures.os=linux,darwin,win32
# supportedArchitectures.cpu=x64,arm64

1
apps/app/.gitignore vendored
View File

@@ -48,3 +48,4 @@ next-env.d.ts
# Electron # Electron
/dist/ /dist/
/server-bundle/

View File

@@ -8,21 +8,114 @@
const path = require("path"); const path = require("path");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const fs = require("fs"); const fs = require("fs");
const http = require("http");
// Load environment variables from .env file
require("dotenv").config({ path: path.join(__dirname, "../.env") });
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron"); const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
// Load environment variables from .env file (development only)
if (!app.isPackaged) {
try {
require("dotenv").config({ path: path.join(__dirname, "../.env") });
} catch (error) {
console.warn("[Electron] dotenv not available:", error.message);
}
}
let mainWindow = null; let mainWindow = null;
let serverProcess = null; let serverProcess = null;
let staticServer = null;
const SERVER_PORT = 3008; const SERVER_PORT = 3008;
const STATIC_PORT = 3007;
// Get icon path - works in both dev and production // Get icon path - works in both dev and production, cross-platform
function getIconPath() { function getIconPath() {
return app.isPackaged // Different icon formats for different platforms
? path.join(process.resourcesPath, "app", "public", "logo.png") let iconFile;
: path.join(__dirname, "../public/logo.png"); if (process.platform === "win32") {
iconFile = "icon.ico";
} else if (process.platform === "darwin") {
iconFile = "logo_larger.png";
} else {
// Linux
iconFile = "logo_larger.png";
}
const iconPath = path.join(__dirname, "../public", iconFile);
// Verify the icon exists
if (!fs.existsSync(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
return null;
}
return iconPath;
}
/**
* Start static file server for production builds
*/
async function startStaticServer() {
const staticPath = path.join(__dirname, "../out");
staticServer = http.createServer((request, response) => {
// Parse the URL and remove query string
let filePath = path.join(staticPath, request.url.split("?")[0]);
// Default to index.html for directory requests
if (filePath.endsWith("/")) {
filePath = path.join(filePath, "index.html");
} else if (!path.extname(filePath)) {
filePath += ".html";
}
// Check if file exists
fs.stat(filePath, (err, stats) => {
if (err || !stats.isFile()) {
// Try index.html for SPA fallback
filePath = path.join(staticPath, "index.html");
}
// Read and serve the file
fs.readFile(filePath, (error, content) => {
if (error) {
response.writeHead(500);
response.end("Server Error");
return;
}
// Set content type based on file extension
const ext = path.extname(filePath);
const contentTypes = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
};
response.writeHead(200, { "Content-Type": contentTypes[ext] || "application/octet-stream" });
response.end(content);
});
});
});
return new Promise((resolve, reject) => {
staticServer.listen(STATIC_PORT, (err) => {
if (err) {
reject(err);
} else {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
resolve();
}
});
});
} }
/** /**
@@ -35,14 +128,18 @@ async function startServer() {
let command, args, serverPath; let command, args, serverPath;
if (isDev) { if (isDev) {
// In development, use tsx to run TypeScript directly // In development, use tsx to run TypeScript directly
// Use the node executable that's running Electron // Use node from PATH (process.execPath in Electron points to Electron, not Node.js)
command = process.execPath; // This is the path to node.exe // spawn() resolves "node" from PATH on all platforms (Windows, Linux, macOS)
command = "node";
serverPath = path.join(__dirname, "../../server/src/index.ts"); serverPath = path.join(__dirname, "../../server/src/index.ts");
// Find tsx CLI - check server node_modules first, then root // Find tsx CLI - check server node_modules first, then root
const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx"); const serverNodeModules = path.join(
__dirname,
"../../server/node_modules/tsx"
);
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx"); const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
let tsxCliPath; let tsxCliPath;
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) { if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs"); tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
@@ -51,30 +148,61 @@ async function startServer() {
} else { } else {
// Last resort: try require.resolve // Last resort: try require.resolve
try { try {
tsxCliPath = require.resolve("tsx/cli.mjs", { paths: [path.join(__dirname, "../../server")] }); tsxCliPath = require.resolve("tsx/cli.mjs", {
paths: [path.join(__dirname, "../../server")],
});
} catch { } catch {
throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); throw new Error(
"Could not find tsx. Please run 'npm install' in the server directory."
);
} }
} }
args = [tsxCliPath, "watch", serverPath]; args = [tsxCliPath, "watch", serverPath];
} else { } else {
// In production, use compiled JavaScript // In production, use compiled JavaScript
command = "node"; command = "node";
serverPath = path.join(process.resourcesPath, "server", "index.js"); serverPath = path.join(process.resourcesPath, "server", "index.js");
args = [serverPath]; args = [serverPath];
// Verify server files exist
if (!fs.existsSync(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
} }
// Set environment variables for server // Set environment variables for server
const serverNodeModules = app.isPackaged
? path.join(process.resourcesPath, "server", "node_modules")
: path.join(__dirname, "../../server/node_modules");
// Set default workspace directory to user's Documents/Automaker
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
// Ensure workspace directory exists
if (!fs.existsSync(defaultWorkspaceDir)) {
try {
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
console.log("[Electron] Created workspace directory:", defaultWorkspaceDir);
} catch (error) {
console.error("[Electron] Failed to create workspace directory:", error);
}
}
const env = { const env = {
...process.env, ...process.env,
PORT: SERVER_PORT.toString(), PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"), DATA_DIR: app.getPath("userData"),
NODE_PATH: serverNodeModules,
WORKSPACE_DIR: process.env.WORKSPACE_DIR || defaultWorkspaceDir,
}; };
console.log("[Electron] Starting backend server..."); console.log("[Electron] Starting backend server...");
console.log("[Electron] Server path:", serverPath);
console.log("[Electron] NODE_PATH:", serverNodeModules);
serverProcess = spawn(command, args, { serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath),
env, env,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
@@ -92,6 +220,11 @@ async function startServer() {
serverProcess = null; serverProcess = null;
}); });
serverProcess.on("error", (err) => {
console.error(`[Server] Failed to start server process:`, err);
serverProcess = null;
});
// Wait for server to be ready // Wait for server to be ready
await waitForServer(); await waitForServer();
} }
@@ -105,13 +238,16 @@ async function waitForServer(maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => { const req = http.get(
if (res.statusCode === 200) { `http://localhost:${SERVER_PORT}/api/health`,
resolve(); (res) => {
} else { if (res.statusCode === 200) {
reject(new Error(`Status: ${res.statusCode}`)); resolve();
} else {
reject(new Error(`Status: ${res.statusCode}`));
}
} }
}); );
req.on("error", reject); req.on("error", reject);
req.setTimeout(1000, () => { req.setTimeout(1000, () => {
req.destroy(); req.destroy();
@@ -132,12 +268,12 @@ async function waitForServer(maxAttempts = 30) {
* Create the main window * Create the main window
*/ */
function createWindow() { function createWindow() {
mainWindow = new BrowserWindow({ const iconPath = getIconPath();
const windowOptions = {
width: 1400, width: 1400,
height: 900, height: 900,
minWidth: 1024, minWidth: 1024,
minHeight: 700, minHeight: 700,
icon: getIconPath(),
webPreferences: { webPreferences: {
preload: path.join(__dirname, "preload.js"), preload: path.join(__dirname, "preload.js"),
contextIsolation: true, contextIsolation: true,
@@ -145,17 +281,20 @@ function createWindow() {
}, },
titleBarStyle: "hiddenInset", titleBarStyle: "hiddenInset",
backgroundColor: "#0a0a0a", backgroundColor: "#0a0a0a",
}); };
// Load Next.js dev server in development or production build // Only set icon if it exists
if (iconPath) {
windowOptions.icon = iconPath;
}
mainWindow = new BrowserWindow(windowOptions);
// Load Next.js dev server in development or static server in production
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
if (isDev) { mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
mainWindow.loadURL("http://localhost:3007"); if (isDev && process.env.OPEN_DEVTOOLS === "true") {
if (process.env.OPEN_DEVTOOLS === "true") { mainWindow.webContents.openDevTools();
mainWindow.webContents.openDevTools();
}
} else {
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
} }
mainWindow.on("closed", () => { mainWindow.on("closed", () => {
@@ -173,10 +312,22 @@ function createWindow() {
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Set app icon (dock icon on macOS) // Set app icon (dock icon on macOS)
if (process.platform === "darwin" && app.dock) { if (process.platform === "darwin" && app.dock) {
app.dock.setIcon(getIconPath()); const iconPath = getIconPath();
if (iconPath) {
try {
app.dock.setIcon(iconPath);
} catch (error) {
console.warn("[Electron] Failed to set dock icon:", error.message);
}
}
} }
try { try {
// Start static file server in production
if (app.isPackaged) {
await startStaticServer();
}
// Start backend server // Start backend server
await startServer(); await startServer();
@@ -207,6 +358,13 @@ app.on("before-quit", () => {
serverProcess.kill(); serverProcess.kill();
serverProcess = null; serverProcess = null;
} }
// Close static server
if (staticServer) {
console.log("[Electron] Stopping static server...");
staticServer.close();
staticServer = null;
}
}); });
// ============================================ // ============================================

View File

@@ -1,6 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "export",
env: { env: {
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "", CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
}, },

View File

@@ -20,7 +20,11 @@
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"", "dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"", "dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
"build": "next build", "build": "next build",
"build:electron": "next build && electron-builder", "build:electron": "node scripts/prepare-server.js && next build && electron-builder",
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
"postinstall": "electron-builder install-app-deps",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "playwright test", "test": "playwright test",
@@ -51,7 +55,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"geist": "^1.5.1", "geist": "^1.5.1",
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"next": "16.0.7", "next": "^16.0.10",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -79,35 +83,46 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"electron": "^39.2.6", "electron": "39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.7", "eslint-config-next": "16.0.7",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5", "typescript": "5.9.3",
"wait-on": "^9.0.3" "wait-on": "^9.0.3"
}, },
"build": { "build": {
"appId": "com.automaker.app", "appId": "com.automaker.app",
"productName": "Automaker", "productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}", "artifactName": "${productName}-${version}-${arch}.${ext}",
"afterPack": "./scripts/rebuild-server-natives.js",
"directories": { "directories": {
"output": "dist" "output": "dist"
}, },
"files": [ "files": [
"electron/**/*", "electron/**/*",
".next/**/*", "out/**/*",
"public/**/*", "public/**/*",
"!node_modules/**/*" "!node_modules/**/*"
], ],
"extraResources": [ "extraResources": [
{ {
"from": ".env", "from": "server-bundle/dist",
"to": "server"
},
{
"from": "server-bundle/node_modules",
"to": "server/node_modules"
},
{
"from": "server-bundle/package.json",
"to": "server/package.json"
},
{
"from": "../../.env",
"to": ".env", "to": ".env",
"filter": [ "filter": ["**/*"]
"**/*"
]
} }
], ],
"mac": { "mac": {
@@ -139,7 +154,7 @@
] ]
} }
], ],
"icon": "public/logo_larger.png" "icon": "public/icon.ico"
}, },
"linux": { "linux": {
"target": [ "target": [

BIN
apps/app/public/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* This script prepares the server for bundling with Electron.
* It copies the server dist and installs production dependencies
* in a way that works with npm workspaces.
*/
import { execSync } from 'child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const APP_DIR = join(__dirname, '..');
const SERVER_DIR = join(APP_DIR, '..', 'server');
const BUNDLE_DIR = join(APP_DIR, 'server-bundle');
console.log('🔧 Preparing server for Electron bundling...\n');
// Step 1: Clean up previous bundle
if (existsSync(BUNDLE_DIR)) {
console.log('🗑️ Cleaning previous server-bundle...');
rmSync(BUNDLE_DIR, { recursive: true });
}
mkdirSync(BUNDLE_DIR, { recursive: true });
// Step 2: Build the server TypeScript
console.log('📦 Building server TypeScript...');
execSync('npm run build', { cwd: SERVER_DIR, stdio: 'inherit' });
// Step 3: Copy server dist
console.log('📋 Copying server dist...');
cpSync(join(SERVER_DIR, 'dist'), join(BUNDLE_DIR, 'dist'), { recursive: true });
// Step 4: Create a minimal package.json for the server
console.log('📝 Creating server package.json...');
const serverPkg = JSON.parse(readFileSync(join(SERVER_DIR, 'package.json'), 'utf-8'));
const bundlePkg = {
name: '@automaker/server-bundle',
version: serverPkg.version,
type: 'module',
main: 'dist/index.js',
dependencies: serverPkg.dependencies
};
writeFileSync(
join(BUNDLE_DIR, 'package.json'),
JSON.stringify(bundlePkg, null, 2)
);
// Step 5: Install production dependencies
console.log('📥 Installing server production dependencies...');
execSync('npm install --omit=dev', {
cwd: BUNDLE_DIR,
stdio: 'inherit',
env: {
...process.env,
// Prevent npm from using workspace resolution
npm_config_workspace: ''
}
});
// Step 6: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...');
try {
execSync('npm rebuild', {
cwd: BUNDLE_DIR,
stdio: 'inherit'
});
console.log('✅ Native modules rebuilt successfully');
} catch (error) {
console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.');
console.warn(' Error:', error.message);
}
console.log('\n✅ Server prepared for bundling at:', BUNDLE_DIR);

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* Electron-builder afterPack hook
* Rebuilds native modules in the server bundle for the target architecture
*/
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const execAsync = promisify(exec);
exports.default = async function(context) {
const { appOutDir, electronPlatformName, arch, packager } = context;
const electronVersion = packager.config.electronVersion;
// Convert arch to string if it's a number (electron-builder sometimes passes indices)
const archNames = ['ia32', 'x64', 'armv7l', 'arm64', 'universal'];
const archStr = typeof arch === 'number' ? archNames[arch] : arch;
console.log(`\n🔨 Rebuilding server native modules for ${electronPlatformName}-${archStr}...`);
// Path to server node_modules in the packaged app
let serverNodeModulesPath;
if (electronPlatformName === 'darwin') {
serverNodeModulesPath = path.join(
appOutDir,
`${packager.appInfo.productName}.app`,
'Contents',
'Resources',
'server',
'node_modules'
);
} else if (electronPlatformName === 'win32') {
serverNodeModulesPath = path.join(
appOutDir,
'resources',
'server',
'node_modules'
);
} else {
serverNodeModulesPath = path.join(
appOutDir,
'resources',
'server',
'node_modules'
);
}
try {
// Rebuild native modules for the target architecture
const rebuildCmd = `npx --yes @electron/rebuild --version=${electronVersion} --arch=${archStr} --force --module-dir="${serverNodeModulesPath}/.."`;
console.log(` Command: ${rebuildCmd}`);
const { stdout, stderr } = await execAsync(rebuildCmd);
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
console.log(`✅ Server native modules rebuilt successfully for ${archStr}\n`);
} catch (error) {
console.error(`❌ Failed to rebuild server native modules:`, error.message);
// Don't fail the build, just warn
}
};

View File

@@ -113,8 +113,8 @@ export function FileBrowserDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh]"> <DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader> <DialogHeader className="pb-2">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-brand-500" /> <FolderOpen className="w-5 h-5 text-brand-500" />
{title} {title}
@@ -124,7 +124,7 @@ export function FileBrowserDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px]"> <div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
{/* Drives selector (Windows only) */} {/* Drives selector (Windows only) */}
{drives.length > 0 && ( {drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border"> <div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
@@ -216,7 +216,7 @@ export function FileBrowserDialog({
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="border-t border-border pt-4 gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>

View File

@@ -238,6 +238,25 @@ export function Sidebar() {
// Ref for project search input // Ref for project search input
const projectSearchInputRef = useRef<HTMLInputElement>(null); const projectSearchInputRef = useRef<HTMLInputElement>(null);
// Auto-collapse sidebar on small screens
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint
const handleResize = () => {
if (mediaQuery.matches && sidebarOpen) {
// Auto-collapse on small screens
toggleSidebar();
}
};
// Check on mount
handleResize();
// Listen for changes
mediaQuery.addEventListener('change', handleResize);
return () => mediaQuery.removeEventListener('change', handleResize);
}, [sidebarOpen, toggleSidebar]);
// Filtered projects based on search query // Filtered projects based on search query
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
if (!projectSearchQuery.trim()) { if (!projectSearchQuery.trim()) {

View File

@@ -198,7 +198,10 @@ export function NewProjectModal({
} }
}; };
const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : ""; // Use platform-specific path separator
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ?
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/';
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : "";
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -121,7 +121,7 @@ type ModelOption = {
label: string; label: string;
description: string; description: string;
badge?: string; badge?: string;
provider: "claude" | "codex"; provider: "claude";
}; };
const CLAUDE_MODELS: ModelOption[] = [ const CLAUDE_MODELS: ModelOption[] = [
@@ -148,37 +148,6 @@ const CLAUDE_MODELS: ModelOption[] = [
}, },
]; ];
const CODEX_MODELS: ModelOption[] = [
{
id: "gpt-5.1-codex-max",
label: "GPT-5.1 Codex Max",
description: "Flagship Codex model tuned for deep coding tasks.",
badge: "Flagship",
provider: "codex",
},
{
id: "gpt-5.1-codex",
label: "GPT-5.1 Codex",
description: "Strong coding performance with lower cost.",
badge: "Standard",
provider: "codex",
},
{
id: "gpt-5.1-codex-mini",
label: "GPT-5.1 Codex Mini",
description: "Fastest Codex option for lightweight edits.",
badge: "Fast",
provider: "codex",
},
{
id: "gpt-5.1",
label: "GPT-5.1",
description: "General-purpose reasoning with solid coding ability.",
badge: "General",
provider: "codex",
},
];
// Profile icon mapping // Profile icon mapping
const PROFILE_ICONS: Record< const PROFILE_ICONS: Record<
string, string,
@@ -1693,12 +1662,8 @@ export function BoardView() {
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{options.map((option) => { {options.map((option) => {
const isSelected = selectedModel === option.id; const isSelected = selectedModel === option.id;
const isCodex = option.provider === "codex";
// Shorter display names for compact view // Shorter display names for compact view
const shortName = option.label const shortName = option.label.replace("Claude ", "");
.replace("Claude ", "")
.replace("GPT-5.1 Codex ", "")
.replace("GPT-5.1 ", "");
return ( return (
<button <button
key={option.id} key={option.id}
@@ -1708,9 +1673,7 @@ export function BoardView() {
className={cn( className={cn(
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors", "flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
isSelected isSelected
? isCodex ? "bg-primary text-primary-foreground border-primary"
? "bg-emerald-600 text-white border-emerald-500"
: "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input" : "bg-background hover:bg-accent border-input"
)} )}
data-testid={`${testIdPrefix}-${option.id}`} data-testid={`${testIdPrefix}-${option.id}`}
@@ -2270,7 +2233,6 @@ export function BoardView() {
const IconComponent = profile.icon const IconComponent = profile.icon
? PROFILE_ICONS[profile.icon] ? PROFILE_ICONS[profile.icon]
: Brain; : Brain;
const isCodex = profile.provider === "codex";
const isSelected = const isSelected =
newFeature.model === profile.model && newFeature.model === profile.model &&
newFeature.thinkingLevel === profile.thinkingLevel; newFeature.thinkingLevel === profile.thinkingLevel;
@@ -2284,13 +2246,6 @@ export function BoardView() {
model: profile.model, model: profile.model,
thinkingLevel: profile.thinkingLevel, thinkingLevel: profile.thinkingLevel,
}); });
if (profile.thinkingLevel === "ultrathink") {
toast.warning("Ultrathink Selected", {
description:
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
duration: 4000,
});
}
}} }}
className={cn( className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all", "flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
@@ -2300,19 +2255,9 @@ export function BoardView() {
)} )}
data-testid={`profile-quick-select-${profile.id}`} data-testid={`profile-quick-select-${profile.id}`}
> >
<div <div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
className={cn(
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
>
{IconComponent && ( {IconComponent && (
<IconComponent <IconComponent className="w-4 h-4 text-primary" />
className={cn(
"w-4 h-4",
isCodex ? "text-emerald-500" : "text-primary"
)}
/>
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -2401,13 +2346,6 @@ export function BoardView() {
...newFeature, ...newFeature,
thinkingLevel: level, thinkingLevel: level,
}); });
if (level === "ultrathink") {
toast.warning("Ultrathink Selected", {
description:
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
duration: 5000,
});
}
}} }}
className={cn( className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]", "flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
@@ -2433,36 +2371,6 @@ export function BoardView() {
)} )}
</div> </div>
)} )}
{/* Separator */}
{(!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Codex Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */}
{(!showProfilesOnly || showAdvancedOptions) && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
OpenAI via Codex CLI
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/50 text-emerald-600 dark:text-emerald-300">
CLI
</span>
</div>
{renderModelOptions(CODEX_MODELS, newFeature.model, (model) =>
setNewFeature({
...newFeature,
model,
thinkingLevel: "none",
})
)}
<p className="text-xs text-muted-foreground">
Codex models do not support thinking levels.
</p>
</div>
)}
</TabsContent> </TabsContent>
{/* Testing Tab */} {/* Testing Tab */}
@@ -2688,7 +2596,6 @@ export function BoardView() {
const IconComponent = profile.icon const IconComponent = profile.icon
? PROFILE_ICONS[profile.icon] ? PROFILE_ICONS[profile.icon]
: Brain; : Brain;
const isCodex = profile.provider === "codex";
const isSelected = const isSelected =
editingFeature.model === profile.model && editingFeature.model === profile.model &&
editingFeature.thinkingLevel === editingFeature.thinkingLevel ===
@@ -2703,13 +2610,6 @@ export function BoardView() {
model: profile.model, model: profile.model,
thinkingLevel: profile.thinkingLevel, thinkingLevel: profile.thinkingLevel,
}); });
if (profile.thinkingLevel === "ultrathink") {
toast.warning("Ultrathink Selected", {
description:
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
duration: 4000,
});
}
}} }}
className={cn( className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all", "flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
@@ -2719,21 +2619,9 @@ export function BoardView() {
)} )}
data-testid={`edit-profile-quick-select-${profile.id}`} data-testid={`edit-profile-quick-select-${profile.id}`}
> >
<div <div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
className={cn(
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
>
{IconComponent && ( {IconComponent && (
<IconComponent <IconComponent className="w-4 h-4 text-primary" />
className={cn(
"w-4 h-4",
isCodex
? "text-emerald-500"
: "text-primary"
)}
/>
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -2813,13 +2701,6 @@ export function BoardView() {
...editingFeature, ...editingFeature,
thinkingLevel: level, thinkingLevel: level,
}); });
if (level === "ultrathink") {
toast.warning("Ultrathink Selected", {
description:
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
duration: 5000,
});
}
}} }}
className={cn( className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]", "flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
@@ -2846,40 +2727,6 @@ export function BoardView() {
)} )}
</div> </div>
)} )}
{/* Separator */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Codex Models Section - Hidden when showProfilesOnly is true and showEditAdvancedOptions is false */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
OpenAI via Codex CLI
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/50 text-emerald-600 dark:text-emerald-300">
CLI
</span>
</div>
{renderModelOptions(
CODEX_MODELS,
(editingFeature.model ?? "opus") as AgentModel,
(model) =>
setEditingFeature({
...editingFeature,
model,
thinkingLevel: "none",
}),
"edit-model-select"
)}
<p className="text-xs text-muted-foreground">
Codex models do not support thinking levels.
</p>
</div>
)}
</TabsContent> </TabsContent>
{/* Testing Tab */} {/* Testing Tab */}

View File

@@ -305,7 +305,10 @@ export function InterviewView() {
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
const fullProjectPath = `${projectPath}/${projectName}`; // Use platform-specific path separator
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ?
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/';
const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
// Create project directory // Create project directory
await api.mkdir(fullProjectPath); await api.mkdir(fullProjectPath);

View File

@@ -41,6 +41,7 @@ import {
GripVertical, GripVertical,
Lock, Lock,
Check, Check,
RefreshCw,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -88,13 +89,6 @@ const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
{ id: "opus", label: "Claude Opus" }, { id: "opus", label: "Claude Opus" },
]; ];
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.1", label: "GPT-5.1" },
];
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [ const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: "none", label: "None" }, { id: "none", label: "None" },
{ id: "low", label: "Low" }, { id: "low", label: "Low" },
@@ -105,9 +99,6 @@ const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
// Helper to determine provider from model // Helper to determine provider from model
function getProviderFromModel(model: AgentModel): ModelProvider { function getProviderFromModel(model: AgentModel): ModelProvider {
if (model.startsWith("gpt")) {
return "codex";
}
return "claude"; return "claude";
} }
@@ -137,7 +128,6 @@ function SortableProfileCard({
}; };
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain; const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isCodex = profile.provider === "codex";
return ( return (
<div <div
@@ -165,18 +155,10 @@ function SortableProfileCard({
{/* Icon */} {/* Icon */}
<div <div
className={cn( className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10"
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
> >
{IconComponent && ( {IconComponent && (
<IconComponent <IconComponent className="w-5 h-5 text-primary" />
className={cn(
"w-5 h-5",
isCodex ? "text-emerald-500" : "text-primary"
)}
/>
)} )}
</div> </div>
@@ -196,12 +178,7 @@ function SortableProfileCard({
</p> </p>
<div className="flex items-center gap-2 mt-2 flex-wrap"> <div className="flex items-center gap-2 mt-2 flex-wrap">
<span <span
className={cn( className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10"
"text-xs px-2 py-0.5 rounded-full border",
isCodex
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
: "border-primary/30 text-primary bg-primary/10"
)}
> >
{profile.model} {profile.model}
</span> </span>
@@ -266,12 +243,9 @@ function ProfileForm({
const supportsThinking = modelSupportsThinking(formData.model); const supportsThinking = modelSupportsThinking(formData.model);
const handleModelChange = (model: AgentModel) => { const handleModelChange = (model: AgentModel) => {
const newProvider = getProviderFromModel(model);
setFormData({ setFormData({
...formData, ...formData,
model, model,
// Reset thinking level when switching to Codex (doesn't support thinking)
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
}); });
}; };
@@ -344,11 +318,11 @@ function ProfileForm({
</div> </div>
</div> </div>
{/* Model Selection - Claude */} {/* Model Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" /> <Brain className="w-4 h-4 text-primary" />
Claude Models Model
</Label> </Label>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => ( {CLAUDE_MODELS.map(({ id, label }) => (
@@ -370,33 +344,7 @@ function ProfileForm({
</div> </div>
</div> </div>
{/* Model Selection - Codex */} {/* Thinking Level */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
Codex Models
</Label>
<div className="flex gap-2 flex-wrap">
{CODEX_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-emerald-600 text-white border-emerald-500"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
</button>
))}
</div>
</div>
{/* Thinking Level - Only for Claude models */}
{supportsThinking && ( {supportsThinking && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
@@ -461,6 +409,7 @@ export function ProfilesView() {
updateAIProfile, updateAIProfile,
removeAIProfile, removeAIProfile,
reorderAIProfiles, reorderAIProfiles,
resetAIProfiles,
} = useAppStore(); } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
@@ -529,6 +478,13 @@ export function ProfilesView() {
}); });
}; };
const handleResetProfiles = () => {
resetAIProfiles();
toast.success("Profiles refreshed", {
description: "Default profiles have been updated to the latest version",
});
};
// Build keyboard shortcuts for profiles view // Build keyboard shortcuts for profiles view
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => { const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = []; const shortcutsList: KeyboardShortcut[] = [];
@@ -568,15 +524,26 @@ export function ProfilesView() {
</p> </p>
</div> </div>
</div> </div>
<HotkeyButton <div className="flex items-center gap-2">
onClick={() => setShowAddDialog(true)} <Button
hotkey={shortcuts.addProfile} variant="outline"
hotkeyActive={false} onClick={handleResetProfiles}
data-testid="add-profile-button" data-testid="refresh-profiles-button"
> className="gap-2"
<Plus className="w-4 h-4 mr-2" /> >
New Profile <RefreshCw className="w-4 h-4" />
</HotkeyButton> Refresh Defaults
</Button>
<HotkeyButton
onClick={() => setShowAddDialog(true)}
hotkey={shortcuts.addProfile}
hotkeyActive={false}
data-testid="add-profile-button"
>
<Plus className="w-4 h-4 mr-2" />
New Profile
</HotkeyButton>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,7 +7,6 @@ import {
Key, Key,
Palette, Palette,
Terminal, Terminal,
Atom,
FlaskConical, FlaskConical,
Trash2, Trash2,
Settings2, Settings2,
@@ -24,7 +23,6 @@ import { DeleteProjectDialog } from "./settings-view/components/delete-project-d
import { SettingsNavigation } from "./settings-view/components/settings-navigation"; import { SettingsNavigation } from "./settings-view/components/settings-navigation";
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section"; import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status"; import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
import { CodexCliStatus } from "./settings-view/cli-status/codex-cli-status";
import { AppearanceSection } from "./settings-view/appearance/appearance-section"; import { AppearanceSection } from "./settings-view/appearance/appearance-section";
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section"; import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section"; import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
@@ -39,7 +37,6 @@ import type { Project as ElectronProject } from "@/lib/electron";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: "api-keys", label: "API Keys", icon: Key }, { id: "api-keys", label: "API Keys", icon: Key },
{ id: "claude", label: "Claude", icon: Terminal }, { id: "claude", label: "Claude", icon: Terminal },
{ id: "codex", label: "Codex", icon: Atom },
{ id: "appearance", label: "Appearance", icon: Palette }, { id: "appearance", label: "Appearance", icon: Palette },
{ id: "audio", label: "Audio", icon: Volume2 }, { id: "audio", label: "Audio", icon: Volume2 },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 }, { id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
@@ -96,11 +93,8 @@ export function SettingsView() {
// Use CLI status hook // Use CLI status hook
const { const {
claudeCliStatus, claudeCliStatus,
codexCliStatus,
isCheckingClaudeCli, isCheckingClaudeCli,
isCheckingCodexCli,
handleRefreshClaudeCli, handleRefreshClaudeCli,
handleRefreshCodexCli,
} = useCliStatus(); } = useCliStatus();
// Use scroll tracking hook // Use scroll tracking hook
@@ -147,15 +141,6 @@ export function SettingsView() {
/> />
)} )}
{/* Codex CLI Status Section */}
{codexCliStatus && (
<CodexCliStatus
status={codexCliStatus}
isChecking={isCheckingCodexCli}
onRefresh={handleRefreshCodexCli}
/>
)}
{/* Appearance Section */} {/* Appearance Section */}
<AppearanceSection <AppearanceSection
effectiveTheme={effectiveTheme} effectiveTheme={effectiveTheme}

View File

@@ -10,7 +10,7 @@ import { useApiKeyManagement } from "./hooks/use-api-key-management";
export function ApiKeysSection() { export function ApiKeysSection() {
const { apiKeys } = useAppStore(); const { apiKeys } = useAppStore();
const { claudeAuthStatus, codexAuthStatus } = useSetupStore(); const { claudeAuthStatus } = useSetupStore();
const { providerConfigParams, apiKeyStatus, handleSave, saved } = const { providerConfigParams, apiKeyStatus, handleSave, saved } =
useApiKeyManagement(); useApiKeyManagement();
@@ -41,7 +41,6 @@ export function ApiKeysSection() {
{/* Authentication Status Display */} {/* Authentication Status Display */}
<AuthenticationStatusDisplay <AuthenticationStatusDisplay
claudeAuthStatus={claudeAuthStatus} claudeAuthStatus={claudeAuthStatus}
codexAuthStatus={codexAuthStatus}
apiKeyStatus={apiKeyStatus} apiKeyStatus={apiKeyStatus}
apiKeys={apiKeys} apiKeys={apiKeys}
/> />

View File

@@ -4,29 +4,24 @@ import {
AlertCircle, AlertCircle,
Info, Info,
Terminal, Terminal,
Atom,
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import type { ClaudeAuthStatus, CodexAuthStatus } from "@/store/setup-store"; import type { ClaudeAuthStatus } from "@/store/setup-store";
interface AuthenticationStatusDisplayProps { interface AuthenticationStatusDisplayProps {
claudeAuthStatus: ClaudeAuthStatus | null; claudeAuthStatus: ClaudeAuthStatus | null;
codexAuthStatus: CodexAuthStatus | null;
apiKeyStatus: { apiKeyStatus: {
hasAnthropicKey: boolean; hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean; hasGoogleKey: boolean;
} | null; } | null;
apiKeys: { apiKeys: {
anthropic: string; anthropic: string;
google: string; google: string;
openai: string;
}; };
} }
export function AuthenticationStatusDisplay({ export function AuthenticationStatusDisplay({
claudeAuthStatus, claudeAuthStatus,
codexAuthStatus,
apiKeyStatus, apiKeyStatus,
apiKeys, apiKeys,
}: AuthenticationStatusDisplayProps) { }: AuthenticationStatusDisplayProps) {
@@ -93,56 +88,6 @@ export function AuthenticationStatusDisplay({
</div> </div>
</div> </div>
{/* Codex/OpenAI Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Atom className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
Codex (OpenAI)
</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{codexAuthStatus?.authenticated ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{codexAuthStatus.method === "subscription"
? "Using Codex subscription (Plus/Team)"
: codexAuthStatus.method === "cli_verified" ||
codexAuthStatus.method === "cli_tokens"
? "Using CLI login (OpenAI account)"
: codexAuthStatus.method === "api_key"
? "Using stored API key"
: codexAuthStatus.method === "env"
? "Using OPENAI_API_KEY"
: `Using ${codexAuthStatus.method || "unknown"} authentication`}
</span>
</div>
</>
) : apiKeyStatus?.hasOpenAIKey ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using environment variable (OPENAI_API_KEY)</span>
</div>
) : apiKeys.openai ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using manual API key from settings</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
<span className="text-xs">Not configured</span>
</div>
)}
</div>
</div>
{/* Google/Gemini Authentication Status */} {/* Google/Gemini Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border"> <div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">

View File

@@ -10,7 +10,6 @@ interface TestResult {
interface ApiKeyStatus { interface ApiKeyStatus {
hasAnthropicKey: boolean; hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean; hasGoogleKey: boolean;
} }
@@ -24,12 +23,10 @@ export function useApiKeyManagement() {
// API key values // API key values
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google); const [googleKey, setGoogleKey] = useState(apiKeys.google);
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
// Visibility toggles // Visibility toggles
const [showAnthropicKey, setShowAnthropicKey] = useState(false); const [showAnthropicKey, setShowAnthropicKey] = useState(false);
const [showGoogleKey, setShowGoogleKey] = useState(false); const [showGoogleKey, setShowGoogleKey] = useState(false);
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
// Test connection states // Test connection states
const [testingConnection, setTestingConnection] = useState(false); const [testingConnection, setTestingConnection] = useState(false);
@@ -38,10 +35,6 @@ export function useApiKeyManagement() {
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>( const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
null null
); );
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(
null
);
// API key status from environment // API key status from environment
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null); const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
@@ -53,7 +46,6 @@ export function useApiKeyManagement() {
useEffect(() => { useEffect(() => {
setAnthropicKey(apiKeys.anthropic); setAnthropicKey(apiKeys.anthropic);
setGoogleKey(apiKeys.google); setGoogleKey(apiKeys.google);
setOpenaiKey(apiKeys.openai);
}, [apiKeys]); }, [apiKeys]);
// Check API key status from environment on mount // Check API key status from environment on mount
@@ -66,7 +58,6 @@ export function useApiKeyManagement() {
if (status.success) { if (status.success) {
setApiKeyStatus({ setApiKeyStatus({
hasAnthropicKey: status.hasAnthropicKey, hasAnthropicKey: status.hasAnthropicKey,
hasOpenAIKey: status.hasOpenAIKey,
hasGoogleKey: status.hasGoogleKey, hasGoogleKey: status.hasGoogleKey,
}); });
} }
@@ -152,68 +143,11 @@ export function useApiKeyManagement() {
} }
}; };
// Test OpenAI connection
const handleTestOpenaiConnection = async () => {
setTestingOpenaiConnection(true);
setOpenaiTestResult(null);
try {
const api = getElectronAPI();
if (api?.testOpenAIConnection) {
const result = await api.testOpenAIConnection(openaiKey);
if (result.success) {
setOpenaiTestResult({
success: true,
message:
result.message || "Connection successful! OpenAI API responded.",
});
} else {
setOpenaiTestResult({
success: false,
message: result.error || "Failed to connect to OpenAI API.",
});
}
} else {
// Fallback to web API test
const response = await fetch("/api/openai/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ apiKey: openaiKey }),
});
const data = await response.json();
if (response.ok && data.success) {
setOpenaiTestResult({
success: true,
message:
data.message || "Connection successful! OpenAI API responded.",
});
} else {
setOpenaiTestResult({
success: false,
message: data.error || "Failed to connect to OpenAI API.",
});
}
}
} catch {
setOpenaiTestResult({
success: false,
message: "Network error. Please check your connection.",
});
} finally {
setTestingOpenaiConnection(false);
}
};
// Save API keys // Save API keys
const handleSave = () => { const handleSave = () => {
setApiKeys({ setApiKeys({
anthropic: anthropicKey, anthropic: anthropicKey,
google: googleKey, google: googleKey,
openai: openaiKey,
}); });
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
@@ -240,15 +174,6 @@ export function useApiKeyManagement() {
onTest: handleTestGeminiConnection, onTest: handleTestGeminiConnection,
result: geminiTestResult, result: geminiTestResult,
}, },
openai: {
value: openaiKey,
setValue: setOpenaiKey,
show: showOpenaiKey,
setShow: setShowOpenaiKey,
testing: testingOpenaiConnection,
onTest: handleTestOpenaiConnection,
result: openaiTestResult,
},
}; };
return { return {

View File

@@ -1,169 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Terminal,
CheckCircle2,
AlertCircle,
RefreshCw,
} from "lucide-react";
import type { CliStatus } from "../shared/types";
interface CliStatusProps {
status: CliStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
export function CodexCliStatus({
status,
isChecking,
onRefresh,
}: CliStatusProps) {
if (!status) return null;
return (
<div
id="codex"
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 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={onRefresh}
disabled={isChecking}
data-testid="refresh-codex-cli"
title="Refresh Codex CLI detection"
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === "installed" ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-green-400">
Codex CLI Installed
</p>
<div className="text-xs text-green-400/80 mt-1 space-y-1">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version:{" "}
<span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path:{" "}
<span className="font-mono text-[10px]">
{status.path}
</span>
</p>
)}
</div>
</div>
</div>
{status.recommendation && (
<p className="text-xs text-muted-foreground">
{status.recommendation}
</p>
)}
</div>
) : status.status === "api_key_only" ? (
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 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="flex-1">
<p className="text-sm font-medium text-blue-400">
API Key Detected - CLI Not Installed
</p>
<p className="text-xs text-blue-400/80 mt-1">
{status.recommendation ||
"OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground-secondary">
Installation Commands:
</p>
<div className="space-y-1">
{status.installCommands.npm && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">npm:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-yellow-400">
Codex CLI Not Detected
</p>
<p className="text-xs text-yellow-400/80 mt-1">
{status.recommendation ||
"Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground-secondary">
Installation Commands:
</p>
<div className="space-y-1">
{status.installCommands.npm && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">npm:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">
macOS (Homebrew):
</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
import { import {
Key, Key,
Terminal, Terminal,
Atom,
Palette, Palette,
LayoutGrid, LayoutGrid,
Settings2, Settings2,
@@ -20,7 +19,6 @@ export interface NavigationItem {
export const NAV_ITEMS: NavigationItem[] = [ export const NAV_ITEMS: NavigationItem[] = [
{ id: "api-keys", label: "API Keys", icon: Key }, { id: "api-keys", label: "API Keys", icon: Key },
{ id: "claude", label: "Claude", icon: Terminal }, { id: "claude", label: "Claude", icon: Terminal },
{ id: "codex", label: "Codex", icon: Atom },
{ id: "appearance", label: "Appearance", icon: Palette }, { id: "appearance", label: "Appearance", icon: Palette },
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid }, { id: "kanban", label: "Kanban Display", icon: LayoutGrid },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 }, { id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },

View File

@@ -59,7 +59,7 @@ export function FeatureDefaultsSection({
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
When enabled, the Add Feature dialog will show only AI profiles When enabled, the Add Feature dialog will show only AI profiles
and hide advanced model tweaking options (Claude SDK, thinking and hide advanced model tweaking options (Claude SDK, thinking
levels, and OpenAI Codex CLI). This creates a cleaner, less levels). This creates a cleaner, less
overwhelming UI. You can always disable this to access advanced overwhelming UI. You can always disable this to access advanced
settings. settings.
</p> </p>

View File

@@ -18,25 +18,17 @@ interface CliStatusResult {
error?: string; error?: string;
} }
interface CodexCliStatusResult extends CliStatusResult {
hasApiKey?: boolean;
}
/** /**
* Custom hook for managing Claude and Codex CLI status * Custom hook for managing Claude CLI status
* Handles checking CLI installation, authentication, and refresh functionality * Handles checking CLI installation, authentication, and refresh functionality
*/ */
export function useCliStatus() { export function useCliStatus() {
const { setClaudeAuthStatus, setCodexAuthStatus } = useSetupStore(); const { setClaudeAuthStatus } = useSetupStore();
const [claudeCliStatus, setClaudeCliStatus] = const [claudeCliStatus, setClaudeCliStatus] =
useState<CliStatusResult | null>(null); useState<CliStatusResult | null>(null);
const [codexCliStatus, setCodexCliStatus] =
useState<CodexCliStatusResult | null>(null);
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false); const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
// Check CLI status on mount // Check CLI status on mount
useEffect(() => { useEffect(() => {
@@ -53,16 +45,6 @@ export function useCliStatus() {
} }
} }
// Check Codex CLI
if (api?.checkCodexCli) {
try {
const status = await api.checkCodexCli();
setCodexCliStatus(status);
} catch (error) {
console.error("Failed to check Codex CLI status:", error);
}
}
// Check Claude auth status (re-fetch on mount to ensure persistence) // Check Claude auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getClaudeStatus) { if (api?.setup?.getClaudeStatus) {
try { try {
@@ -95,47 +77,10 @@ export function useCliStatus() {
console.error("Failed to check Claude auth status:", error); console.error("Failed to check Claude auth status:", error);
} }
} }
// Check Codex auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getCodexStatus) {
try {
const result = await api.setup.getCodexStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
hasSubscription?: boolean;
cliLoggedIn?: boolean;
hasEnvApiKey?: boolean;
};
// Map server method names to client method types
// Server returns: subscription, cli_verified, cli_tokens, api_key, env, none
const validMethods = ["subscription", "cli_verified", "cli_tokens", "api_key", "env", "none"] as const;
type CodexMethod = typeof validMethods[number];
const method: CodexMethod = validMethods.includes(auth.method as CodexMethod)
? (auth.method as CodexMethod)
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key
const authStatus = {
authenticated: auth.authenticated,
method,
// Only set apiKeyValid for actual API key methods, not CLI login or subscription
apiKeyValid:
method === "cli_verified" || method === "cli_tokens" || method === "subscription"
? undefined
: auth.hasAuthFile || auth.hasEnvKey || auth.hasEnvApiKey,
hasSubscription: auth.hasSubscription,
cliLoggedIn: auth.cliLoggedIn,
};
setCodexAuthStatus(authStatus);
}
} catch (error) {
console.error("Failed to check Codex auth status:", error);
}
}
}; };
checkCliStatus(); checkCliStatus();
}, [setClaudeAuthStatus, setCodexAuthStatus]); }, [setClaudeAuthStatus]);
// Refresh Claude CLI status // Refresh Claude CLI status
const handleRefreshClaudeCli = useCallback(async () => { const handleRefreshClaudeCli = useCallback(async () => {
@@ -153,28 +98,9 @@ export function useCliStatus() {
} }
}, []); }, []);
// Refresh Codex CLI status
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);
}
}, []);
return { return {
claudeCliStatus, claudeCliStatus,
codexCliStatus,
isCheckingClaudeCli, isCheckingClaudeCli,
isCheckingCodexCli,
handleRefreshClaudeCli, handleRefreshClaudeCli,
handleRefreshCodexCli,
}; };
} }

View File

@@ -7,7 +7,6 @@ import {
WelcomeStep, WelcomeStep,
CompleteStep, CompleteStep,
ClaudeSetupStep, ClaudeSetupStep,
CodexSetupStep,
} from "./setup-view/steps"; } from "./setup-view/steps";
// Main Setup View // Main Setup View
@@ -17,17 +16,14 @@ export function SetupView() {
setCurrentStep, setCurrentStep,
completeSetup, completeSetup,
setSkipClaudeSetup, setSkipClaudeSetup,
setSkipCodexSetup,
} = useSetupStore(); } = useSetupStore();
const { setCurrentView } = useAppStore(); const { setCurrentView } = useAppStore();
const steps = ["welcome", "claude", "codex", "complete"] as const; const steps = ["welcome", "claude", "complete"] as const;
type StepName = (typeof steps)[number]; type StepName = (typeof steps)[number];
const getStepName = (): StepName => { const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth") if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude"; return "claude";
if (currentStep === "codex_detect" || currentStep === "codex_auth")
return "codex";
if (currentStep === "welcome") return "welcome"; if (currentStep === "welcome") return "welcome";
return "complete"; return "complete";
}; };
@@ -46,10 +42,6 @@ export function SetupView() {
setCurrentStep("claude_detect"); setCurrentStep("claude_detect");
break; break;
case "claude": case "claude":
console.log("[Setup Flow] Moving to codex_detect step");
setCurrentStep("codex_detect");
break;
case "codex":
console.log("[Setup Flow] Moving to complete step"); console.log("[Setup Flow] Moving to complete step");
setCurrentStep("complete"); setCurrentStep("complete");
break; break;
@@ -62,21 +54,12 @@ export function SetupView() {
case "claude": case "claude":
setCurrentStep("welcome"); setCurrentStep("welcome");
break; break;
case "codex":
setCurrentStep("claude_detect");
break;
} }
}; };
const handleSkipClaude = () => { const handleSkipClaude = () => {
console.log("[Setup Flow] Skipping Claude setup"); console.log("[Setup Flow] Skipping Claude setup");
setSkipClaudeSetup(true); setSkipClaudeSetup(true);
setCurrentStep("codex_detect");
};
const handleSkipCodex = () => {
console.log("[Setup Flow] Skipping Codex setup");
setSkipCodexSetup(true);
setCurrentStep("complete"); setCurrentStep("complete");
}; };
@@ -127,15 +110,6 @@ export function SetupView() {
/> />
)} )}
{(currentStep === "codex_detect" ||
currentStep === "codex_auth") && (
<CodexSetupStep
onNext={() => handleNext("codex")}
onBack={() => handleBack("codex")}
onSkip={handleSkipCodex}
/>
)}
{currentStep === "complete" && ( {currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} /> <CompleteStep onFinish={handleFinish} />
)} )}

View File

@@ -2,7 +2,7 @@ import { useState, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
interface UseCliInstallationOptions { interface UseCliInstallationOptions {
cliType: "claude" | "codex"; cliType: "claude";
installApi: () => Promise<any>; installApi: () => Promise<any>;
onProgressEvent?: (callback: (progress: any) => void) => (() => void) | undefined; onProgressEvent?: (callback: (progress: any) => void) => (() => void) | undefined;
onSuccess?: () => void; onSuccess?: () => void;

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
interface UseCliStatusOptions { interface UseCliStatusOptions {
cliType: "claude" | "codex"; cliType: "claude";
statusApi: () => Promise<any>; statusApi: () => Promise<any>;
setCliStatus: (status: any) => void; setCliStatus: (status: any) => void;
setAuthStatus: (status: any) => void; setAuthStatus: (status: any) => void;
@@ -33,65 +33,35 @@ export function useCliStatus({
setCliStatus(cliStatus); setCliStatus(cliStatus);
if (result.auth) { if (result.auth) {
if (cliType === "claude") { // Validate method is one of the expected values, default to "none"
// Validate method is one of the expected values, default to "none" const validMethods = [
const validMethods = [ "oauth_token_env",
"oauth_token_env", "oauth_token",
"oauth_token", "api_key",
"api_key", "api_key_env",
"api_key_env", "credentials_file",
"credentials_file", "cli_authenticated",
"cli_authenticated", "none",
"none", ] as const;
] as const; type AuthMethod = (typeof validMethods)[number];
type AuthMethod = (typeof validMethods)[number]; const method: AuthMethod = validMethods.includes(
const method: AuthMethod = validMethods.includes( result.auth.method as AuthMethod
result.auth.method as AuthMethod )
) ? (result.auth.method as AuthMethod)
? (result.auth.method as AuthMethod) : "none";
: "none"; const authStatus = {
const authStatus = { authenticated: result.auth.authenticated,
authenticated: result.auth.authenticated, method,
method, hasCredentialsFile: false,
hasCredentialsFile: false, oauthTokenValid:
oauthTokenValid: result.auth.hasStoredOAuthToken ||
result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken,
result.auth.hasEnvOAuthToken, apiKeyValid:
apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey, hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
hasEnvOAuthToken: result.auth.hasEnvOAuthToken, hasEnvApiKey: result.auth.hasEnvApiKey,
hasEnvApiKey: result.auth.hasEnvApiKey, };
}; setAuthStatus(authStatus);
setAuthStatus(authStatus);
} else {
// Codex auth status mapping
const mapAuthMethod = (method?: string): any => {
switch (method) {
case "cli_verified":
return "cli_verified";
case "cli_tokens":
return "cli_tokens";
case "auth_file":
return "api_key";
case "env_var":
return "env";
default:
return "none";
}
};
const method = mapAuthMethod(result.auth.method);
const authStatus = {
authenticated: result.auth.authenticated,
method,
apiKeyValid:
method === "cli_verified" || method === "cli_tokens"
? undefined
: result.auth.authenticated,
};
console.log(`[${cliType} Setup] Auth Status:`, authStatus);
setAuthStatus(authStatus);
}
} }
} }
} catch (error) { } catch (error) {

View File

@@ -4,7 +4,7 @@ import { getElectronAPI } from "@/lib/electron";
type AuthState = "idle" | "running" | "success" | "error" | "manual"; type AuthState = "idle" | "running" | "success" | "error" | "manual";
interface UseOAuthAuthenticationOptions { interface UseOAuthAuthenticationOptions {
cliType: "claude" | "codex"; cliType: "claude";
enabled?: boolean; enabled?: boolean;
} }
@@ -70,11 +70,8 @@ export function useOAuthAuthentication({
} }
try { try {
// Call the appropriate auth API based on cliType // Call the auth API
const result = const result = await api.setup.authClaude();
cliType === "claude"
? await api.setup.authClaude()
: await api.setup.authCodex?.();
// Cleanup subscription // Cleanup subscription
if (unsubscribeRef.current) { if (unsubscribeRef.current) {

View File

@@ -1,445 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useSetupStore } from "@/store/setup-store";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import {
CheckCircle2,
Loader2,
Terminal,
Key,
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
AlertCircle,
RefreshCw,
Download,
} from "lucide-react";
import { toast } from "sonner";
import { StatusBadge, TerminalOutput } from "../components";
import {
useCliStatus,
useCliInstallation,
useTokenSave,
} from "../hooks";
interface CodexSetupStepProps {
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
export function CodexSetupStep({
onNext,
onBack,
onSkip,
}: CodexSetupStepProps) {
const {
codexCliStatus,
codexAuthStatus,
setCodexCliStatus,
setCodexAuthStatus,
setCodexInstallProgress,
} = useSetupStore();
const { setApiKeys, apiKeys } = useAppStore();
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
const [apiKey, setApiKey] = useState("");
// Memoize API functions to prevent infinite loops
const statusApi = useCallback(
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
[]
);
const installApi = useCallback(
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
[]
);
// Use custom hooks
const { isChecking, checkStatus } = useCliStatus({
cliType: "codex",
statusApi,
setCliStatus: setCodexCliStatus,
setAuthStatus: setCodexAuthStatus,
});
const onInstallSuccess = useCallback(() => {
checkStatus();
}, [checkStatus]);
const { isInstalling, installProgress, install } = useCliInstallation({
cliType: "codex",
installApi,
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
onSuccess: onInstallSuccess,
});
const { isSaving: isSavingKey, saveToken: saveApiKeyToken } = useTokenSave({
provider: "openai",
onSuccess: () => {
setCodexAuthStatus({
authenticated: true,
method: "api_key",
apiKeyValid: true,
});
setApiKeys({ ...apiKeys, openai: apiKey });
setShowApiKeyInput(false);
checkStatus();
},
});
// Sync install progress to store
useEffect(() => {
setCodexInstallProgress({
isInstalling,
output: installProgress.output,
});
}, [isInstalling, installProgress, setCodexInstallProgress]);
// Check status on mount
useEffect(() => {
checkStatus();
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success("Command copied to clipboard");
};
const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai;
const getAuthMethodLabel = () => {
if (!isAuthenticated) return null;
if (apiKeys.openai) return "API Key (Manual)";
if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)";
if (codexAuthStatus?.method === "env") return "API Key (Environment)";
if (codexAuthStatus?.method === "cli_verified")
return "CLI Login (ChatGPT)";
return "Authenticated";
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<Terminal className="w-8 h-8 text-green-500" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
Codex CLI Setup
</h2>
<p className="text-muted-foreground">
OpenAI&apos;s GPT-5.1 Codex for advanced code generation
</p>
</div>
{/* Status Card */}
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Installation Status</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">CLI Installation</span>
{isChecking ? (
<StatusBadge status="checking" label="Checking..." />
) : codexCliStatus?.installed ? (
<StatusBadge status="installed" label="Installed" />
) : (
<StatusBadge status="not_installed" label="Not Installed" />
)}
</div>
{codexCliStatus?.version && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm font-mono text-foreground">
{codexCliStatus.version}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">Authentication</span>
{isAuthenticated ? (
<div className="flex items-center gap-2">
<StatusBadge status="authenticated" label="Authenticated" />
{getAuthMethodLabel() && (
<span className="text-xs text-muted-foreground">
({getAuthMethodLabel()})
</span>
)}
</div>
) : (
<StatusBadge
status="not_authenticated"
label="Not Authenticated"
/>
)}
</div>
</CardContent>
</Card>
{/* Installation Section */}
{!codexCliStatus?.installed && (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Download className="w-5 h-5" />
Install Codex CLI
</CardTitle>
<CardDescription>
Install via npm (Node.js required)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
npm (Global installation)
</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
npm install -g @openai/codex
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("npm install -g @openai/codex")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{isInstalling && (
<TerminalOutput lines={installProgress.output} />
)}
<div className="flex gap-2">
<Button
onClick={install}
disabled={isInstalling}
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
data-testid="install-codex-button"
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Installing...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Auto Install
</>
)}
</Button>
</div>
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5" />
<p className="text-xs text-yellow-600 dark:text-yellow-400">
Requires Node.js to be installed. If the auto-install fails,
try running the command manually in your terminal.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Authentication Section */}
{!isAuthenticated && (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Key className="w-5 h-5" />
Authentication
</CardTitle>
<CardDescription>Codex requires an OpenAI API key</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{codexCliStatus?.installed && (
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-start gap-3">
<Terminal className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<p className="font-medium text-foreground">
Authenticate via CLI
</p>
<p className="text-sm text-muted-foreground mb-2">
Run this command in your terminal:
</p>
<div className="flex items-center gap-2">
<code className="bg-muted px-3 py-1 rounded text-sm font-mono text-foreground">
codex auth login
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("codex auth login")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
)}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
or enter API key
</span>
</div>
</div>
{showApiKeyInput ? (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="openai-key" className="text-foreground">
OpenAI API Key
</Label>
<Input
id="openai-key"
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="bg-input border-border text-foreground"
data-testid="openai-api-key-input"
/>
<p className="text-xs text-muted-foreground">
Get your API key from{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-green-500 hover:underline"
>
platform.openai.com
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowApiKeyInput(false)}
className="border-border"
>
Cancel
</Button>
<Button
onClick={() => saveApiKeyToken(apiKey)}
disabled={isSavingKey || !apiKey.trim()}
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
data-testid="save-openai-key-button"
>
{isSavingKey ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Save API Key"
)}
</Button>
</div>
</div>
) : (
<Button
variant="outline"
onClick={() => setShowApiKeyInput(true)}
className="w-full border-border"
data-testid="use-openai-key-button"
>
<Key className="w-4 h-4 mr-2" />
Enter OpenAI API Key
</Button>
)}
</CardContent>
</Card>
)}
{/* Success State */}
{isAuthenticated && (
<Card className="bg-green-500/5 border-green-500/20">
<CardContent className="py-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
<CheckCircle2 className="w-6 h-6 text-green-500" />
</div>
<div>
<p className="font-medium text-foreground">
Codex is ready to use!
</p>
<p className="text-sm text-muted-foreground">
{getAuthMethodLabel() &&
`Authenticated via ${getAuthMethodLabel()}. `}
You can proceed to complete setup
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
Skip for now
</Button>
<Button
onClick={onNext}
className="bg-green-500 hover:bg-green-600 text-white"
data-testid="codex-next-button"
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -14,16 +14,13 @@ interface CompleteStepProps {
} }
export function CompleteStep({ onFinish }: CompleteStepProps) { export function CompleteStep({ onFinish }: CompleteStepProps) {
const { claudeCliStatus, claudeAuthStatus, codexCliStatus, codexAuthStatus } = const { claudeCliStatus, claudeAuthStatus } =
useSetupStore(); useSetupStore();
const { apiKeys } = useAppStore(); const { apiKeys } = useAppStore();
const claudeReady = const claudeReady =
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) || (claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
apiKeys.anthropic; apiKeys.anthropic;
const codexReady =
(codexCliStatus?.installed && codexAuthStatus?.authenticated) ||
apiKeys.openai;
return ( return (
<div className="text-center space-y-6"> <div className="text-center space-y-6">
@@ -41,7 +38,7 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto"> <div className="max-w-md mx-auto">
<Card <Card
className={`bg-card/50 border ${ className={`bg-card/50 border ${
claudeReady ? "border-green-500/50" : "border-yellow-500/50" claudeReady ? "border-green-500/50" : "border-yellow-500/50"
@@ -63,28 +60,6 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card
className={`bg-card/50 border ${
codexReady ? "border-green-500/50" : "border-yellow-500/50"
}`}
>
<CardContent className="py-4">
<div className="flex items-center gap-3">
{codexReady ? (
<CheckCircle2 className="w-6 h-6 text-green-500" />
) : (
<AlertCircle className="w-6 h-6 text-yellow-500" />
)}
<div className="text-left">
<p className="font-medium text-foreground">Codex</p>
<p className="text-sm text-muted-foreground">
{codexReady ? "Ready to use" : "Configure later in settings"}
</p>
</div>
</div>
</CardContent>
</Card>
</div> </div>
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto"> <div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">

View File

@@ -2,4 +2,3 @@
export { WelcomeStep } from "./welcome-step"; export { WelcomeStep } from "./welcome-step";
export { CompleteStep } from "./complete-step"; export { CompleteStep } from "./complete-step";
export { ClaudeSetupStep } from "./claude-setup-step"; export { ClaudeSetupStep } from "./claude-setup-step";
export { CodexSetupStep } from "./codex-setup-step";

View File

@@ -24,7 +24,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto"> <div className="grid grid-cols-1 gap-4 max-w-md mx-auto place-items-center">
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors"> <Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
@@ -40,19 +40,6 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Terminal className="w-5 h-5 text-green-500" />
Codex CLI
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
OpenAI&apos;s GPT-5.1 Codex for advanced code generation tasks
</p>
</CardContent>
</Card>
</div> </div>
<Button <Button

View File

@@ -200,7 +200,7 @@ export function WikiView() {
{ {
icon: Cpu, icon: Cpu,
title: "Multi-Model Support", title: "Multi-Model Support",
description: "Claude Haiku/Sonnet/Opus + OpenAI Codex models. Choose the right model for each task.", description: "Claude Haiku/Sonnet/Opus models. Choose the right model for each task.",
}, },
{ {
icon: Brain, icon: Brain,

View File

@@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { ApiKeys } from "@/store/app-store"; import type { ApiKeys } from "@/store/app-store";
export type ProviderKey = "anthropic" | "google" | "openai"; export type ProviderKey = "anthropic" | "google";
export interface ProviderConfig { export interface ProviderConfig {
key: ProviderKey; key: ProviderKey;
@@ -51,22 +51,12 @@ export interface ProviderConfigParams {
onTest: () => Promise<void>; onTest: () => Promise<void>;
result: { success: boolean; message: string } | null; result: { success: boolean; message: string } | null;
}; };
openai: {
value: string;
setValue: Dispatch<SetStateAction<string>>;
show: boolean;
setShow: Dispatch<SetStateAction<boolean>>;
testing: boolean;
onTest: () => Promise<void>;
result: { success: boolean; message: string } | null;
};
} }
export const buildProviderConfigs = ({ export const buildProviderConfigs = ({
apiKeys, apiKeys,
anthropic, anthropic,
google, google,
openai,
}: ProviderConfigParams): ProviderConfig[] => [ }: ProviderConfigParams): ProviderConfig[] => [
{ {
key: "anthropic", key: "anthropic",
@@ -121,29 +111,4 @@ export const buildProviderConfigs = ({
descriptionLinkHref: "https://makersuite.google.com/app/apikey", descriptionLinkHref: "https://makersuite.google.com/app/apikey",
descriptionLinkText: "makersuite.google.com", descriptionLinkText: "makersuite.google.com",
}, },
{
key: "openai",
label: "OpenAI API Key (Codex/GPT)",
inputId: "openai-key",
placeholder: "sk-...",
value: openai.value,
setValue: openai.setValue,
showValue: openai.show,
setShowValue: openai.setShow,
hasStoredKey: apiKeys.openai,
inputTestId: "openai-api-key-input",
toggleTestId: "toggle-openai-visibility",
testButton: {
onClick: openai.onTest,
disabled: !openai.value || openai.testing,
loading: openai.testing,
testId: "test-openai-connection",
},
result: openai.result,
resultTestId: "openai-test-connection-result",
resultMessageTestId: "openai-test-connection-message",
descriptionPrefix: "Used for OpenAI Codex CLI and GPT models. Get your key at",
descriptionLinkHref: "https://platform.openai.com/api-keys",
descriptionLinkText: "platform.openai.com",
},
]; ];

View File

@@ -287,22 +287,6 @@ export interface ElectronAPI {
}; };
error?: string; error?: string;
}>; }>;
checkCodexCli?: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}>;
model?: { model?: {
getAvailable: () => Promise<{ getAvailable: () => Promise<{
success: boolean; success: boolean;
@@ -315,11 +299,6 @@ export interface ElectronAPI {
error?: string; error?: string;
}>; }>;
}; };
testOpenAIConnection?: (apiKey?: string) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
worktree?: WorktreeAPI; worktree?: WorktreeAPI;
git?: GitAPI; git?: GitAPI;
suggestions?: SuggestionsAPI; suggestions?: SuggestionsAPI;
@@ -347,32 +326,11 @@ export interface ElectronAPI {
}; };
error?: string; error?: string;
}>; }>;
getCodexStatus: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none"
hasAuthFile: boolean;
hasEnvKey: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
};
error?: string;
}>;
installClaude: () => Promise<{ installClaude: () => Promise<{
success: boolean; success: boolean;
message?: string; message?: string;
error?: string; error?: string;
}>; }>;
installCodex: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
authClaude: () => Promise<{ authClaude: () => Promise<{
success: boolean; success: boolean;
token?: string; token?: string;
@@ -383,12 +341,6 @@ export interface ElectronAPI {
message?: string; message?: string;
output?: string; output?: string;
}>; }>;
authCodex: (apiKey?: string) => Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
error?: string;
}>;
storeApiKey: ( storeApiKey: (
provider: string, provider: string,
apiKey: string apiKey: string
@@ -396,12 +348,8 @@ export interface ElectronAPI {
getApiKeys: () => Promise<{ getApiKeys: () => Promise<{
success: boolean; success: boolean;
hasAnthropicKey: boolean; hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean; hasGoogleKey: boolean;
}>; }>;
configureCodexMcp: (
projectPath: string
) => Promise<{ success: boolean; configPath?: string; error?: string }>;
getPlatform: () => Promise<{ getPlatform: () => Promise<{
success: boolean; success: boolean;
platform: string; platform: string;
@@ -838,22 +786,11 @@ const getMockElectronAPI = (): ElectronAPI => {
recommendation: "Claude CLI checks are unavailable in the web preview.", recommendation: "Claude CLI checks are unavailable in the web preview.",
}), }),
checkCodexCli: async () => ({
success: false,
status: "not_installed",
recommendation: "Codex CLI checks are unavailable in the web preview.",
}),
model: { model: {
getAvailable: async () => ({ success: true, models: [] }), getAvailable: async () => ({ success: true, models: [] }),
checkProviders: async () => ({ success: true, providers: {} }), checkProviders: async () => ({ success: true, providers: {} }),
}, },
testOpenAIConnection: async () => ({
success: false,
error: "OpenAI connection test is only available in the Electron app.",
}),
// Mock Setup API // Mock Setup API
setup: createMockSetupAPI(), setup: createMockSetupAPI(),
@@ -903,32 +840,11 @@ interface SetupAPI {
}; };
error?: string; error?: string;
}>; }>;
getCodexStatus: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none"
hasAuthFile: boolean;
hasEnvKey: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
};
error?: string;
}>;
installClaude: () => Promise<{ installClaude: () => Promise<{
success: boolean; success: boolean;
message?: string; message?: string;
error?: string; error?: string;
}>; }>;
installCodex: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
authClaude: () => Promise<{ authClaude: () => Promise<{
success: boolean; success: boolean;
token?: string; token?: string;
@@ -939,12 +855,6 @@ interface SetupAPI {
message?: string; message?: string;
output?: string; output?: string;
}>; }>;
authCodex: (apiKey?: string) => Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
error?: string;
}>;
storeApiKey: ( storeApiKey: (
provider: string, provider: string,
apiKey: string apiKey: string
@@ -952,12 +862,8 @@ interface SetupAPI {
getApiKeys: () => Promise<{ getApiKeys: () => Promise<{
success: boolean; success: boolean;
hasAnthropicKey: boolean; hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean; hasGoogleKey: boolean;
}>; }>;
configureCodexMcp: (
projectPath: string
) => Promise<{ success: boolean; configPath?: string; error?: string }>;
getPlatform: () => Promise<{ getPlatform: () => Promise<{
success: boolean; success: boolean;
platform: string; platform: string;
@@ -991,20 +897,6 @@ function createMockSetupAPI(): SetupAPI {
}; };
}, },
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 () => { installClaude: async () => {
console.log("[Mock] Installing Claude CLI"); console.log("[Mock] Installing Claude CLI");
// Simulate installation delay // Simulate installation delay
@@ -1016,16 +908,6 @@ function createMockSetupAPI(): SetupAPI {
}; };
}, },
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 () => { authClaude: async () => {
console.log("[Mock] Auth Claude CLI"); console.log("[Mock] Auth Claude CLI");
return { return {
@@ -1035,18 +917,6 @@ function createMockSetupAPI(): SetupAPI {
}; };
}, },
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) => { storeApiKey: async (provider: string, apiKey: string) => {
console.log("[Mock] Storing API key for:", provider); console.log("[Mock] Storing API key for:", provider);
// In mock mode, we just pretend to store it (it's already in the app store) // In mock mode, we just pretend to store it (it's already in the app store)
@@ -1058,19 +928,10 @@ function createMockSetupAPI(): SetupAPI {
return { return {
success: true, success: true,
hasAnthropicKey: false, hasAnthropicKey: false,
hasOpenAIKey: false,
hasGoogleKey: 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 () => { getPlatform: async () => {
return { return {
success: true, success: true,

View File

@@ -371,25 +371,6 @@ export class HttpApiClient implements ElectronAPI {
return this.get("/api/setup/claude-status"); return this.get("/api/setup/claude-status");
} }
async checkCodexCli(): Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}> {
return this.get("/api/setup/codex-status");
}
// Model API // Model API
model = { model = {
getAvailable: async (): Promise<{ getAvailable: async (): Promise<{
@@ -408,14 +389,6 @@ export class HttpApiClient implements ElectronAPI {
}, },
}; };
async testOpenAIConnection(apiKey?: string): Promise<{
success: boolean;
message?: string;
error?: string;
}> {
return this.post("/api/setup/test-openai", { apiKey });
}
// Setup API // Setup API
setup = { setup = {
getClaudeStatus: (): Promise<{ getClaudeStatus: (): Promise<{
@@ -440,35 +413,12 @@ export class HttpApiClient implements ElectronAPI {
error?: string; error?: string;
}> => this.get("/api/setup/claude-status"), }> => this.get("/api/setup/claude-status"),
getCodexStatus: (): Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
hasAuthFile: boolean;
hasEnvKey: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
};
error?: string;
}> => this.get("/api/setup/codex-status"),
installClaude: (): Promise<{ installClaude: (): Promise<{
success: boolean; success: boolean;
message?: string; message?: string;
error?: string; error?: string;
}> => this.post("/api/setup/install-claude"), }> => this.post("/api/setup/install-claude"),
installCodex: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-codex"),
authClaude: (): Promise<{ authClaude: (): Promise<{
success: boolean; success: boolean;
token?: string; token?: string;
@@ -480,15 +430,6 @@ export class HttpApiClient implements ElectronAPI {
output?: string; output?: string;
}> => this.post("/api/setup/auth-claude"), }> => this.post("/api/setup/auth-claude"),
authCodex: (
apiKey?: string
): Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
error?: string;
}> => this.post("/api/setup/auth-codex", { apiKey }),
storeApiKey: ( storeApiKey: (
provider: string, provider: string,
apiKey: string apiKey: string
@@ -500,18 +441,9 @@ export class HttpApiClient implements ElectronAPI {
getApiKeys: (): Promise<{ getApiKeys: (): Promise<{
success: boolean; success: boolean;
hasAnthropicKey: boolean; hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean; hasGoogleKey: boolean;
}> => this.get("/api/setup/api-keys"), }> => this.get("/api/setup/api-keys"),
configureCodexMcp: (
projectPath: string
): Promise<{
success: boolean;
configPath?: string;
error?: string;
}> => this.post("/api/setup/configure-codex-mcp", { projectPath }),
getPlatform: (): Promise<{ getPlatform: (): Promise<{
success: boolean; success: boolean;
platform: string; platform: string;

View File

@@ -6,26 +6,12 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
/**
* Check if a model is a Codex/OpenAI model (doesn't support thinking)
*/
export function isCodexModel(model?: AgentModel | string): boolean {
if (!model) return false;
const codexModels: string[] = [
"gpt-5.1-codex-max",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5.1",
];
return codexModels.includes(model);
}
/** /**
* Determine if the current model supports extended thinking controls * Determine if the current model supports extended thinking controls
*/ */
export function modelSupportsThinking(model?: AgentModel | string): boolean { export function modelSupportsThinking(model?: AgentModel | string): boolean {
if (!model) return true; // All Claude models support thinking
return !isCodexModel(model); return true;
} }
/** /**
@@ -36,10 +22,6 @@ export function getModelDisplayName(model: AgentModel | string): string {
haiku: "Claude Haiku", haiku: "Claude Haiku",
sonnet: "Claude Sonnet", sonnet: "Claude Sonnet",
opus: "Claude Opus", opus: "Claude Opus",
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
"gpt-5.1-codex": "GPT-5.1 Codex",
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
"gpt-5.1": "GPT-5.1",
}; };
return displayNames[model] || model; return displayNames[model] || model;
} }

View File

@@ -246,19 +246,11 @@ export interface FeatureImagePath {
} }
// Available models for feature execution // Available models for feature execution
// Claude models
export type ClaudeModel = "opus" | "sonnet" | "haiku"; export type ClaudeModel = "opus" | "sonnet" | "haiku";
// OpenAI/Codex models export type AgentModel = ClaudeModel;
export type OpenAIModel =
| "gpt-5.1-codex-max"
| "gpt-5.1-codex"
| "gpt-5.1-codex-mini"
| "gpt-5.1";
// Combined model type
export type AgentModel = ClaudeModel | OpenAIModel;
// Model provider type // Model provider type
export type ModelProvider = "claude" | "codex"; export type ModelProvider = "claude";
// Thinking level (budget_tokens) options // Thinking level (budget_tokens) options
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink"; export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
@@ -570,6 +562,7 @@ export interface AppActions {
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void; updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
removeAIProfile: (id: string) => void; removeAIProfile: (id: string) => void;
reorderAIProfiles: (oldIndex: number, newIndex: number) => void; reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
resetAIProfiles: () => void;
// Project Analysis actions // Project Analysis actions
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void; setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
@@ -657,26 +650,6 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
isBuiltIn: true, isBuiltIn: true,
icon: "Zap", icon: "Zap",
}, },
{
id: "profile-codex-power",
name: "Codex Power",
description: "GPT-5.1 Codex Max for deep coding tasks via OpenAI CLI.",
model: "gpt-5.1-codex-max",
thinkingLevel: "none",
provider: "codex",
isBuiltIn: true,
icon: "Cpu",
},
{
id: "profile-codex-fast",
name: "Codex Fast",
description: "GPT-5.1 Codex Mini for lightweight and quick edits.",
model: "gpt-5.1-codex-mini",
thinkingLevel: "none",
provider: "codex",
isBuiltIn: true,
icon: "Rocket",
},
]; ];
const initialState: AppState = { const initialState: AppState = {
@@ -1356,6 +1329,13 @@ export const useAppStore = create<AppState & AppActions>()(
set({ aiProfiles: profiles }); set({ aiProfiles: profiles });
}, },
resetAIProfiles: () => {
// Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults
const defaultProfileIds = new Set(DEFAULT_AI_PROFILES.map(p => p.id));
const userProfiles = get().aiProfiles.filter(p => !p.isBuiltIn && !defaultProfileIds.has(p.id));
set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] });
},
// Project Analysis actions // Project Analysis actions
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }), setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }), setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),

View File

@@ -32,26 +32,6 @@ export interface ClaudeAuthStatus {
error?: string; error?: string;
} }
// Codex Auth Method - all possible authentication sources
export type CodexAuthMethod =
| "subscription" // Codex/OpenAI Plus or Team subscription
| "cli_verified" // CLI logged in with OpenAI account
| "cli_tokens" // CLI with stored access tokens
| "api_key" // Manually stored API key
| "env" // OPENAI_API_KEY environment variable
| "none";
// Codex Auth Status
export interface CodexAuthStatus {
authenticated: boolean;
method: CodexAuthMethod;
apiKeyValid?: boolean;
mcpConfigured?: boolean;
hasSubscription?: boolean;
cliLoggedIn?: boolean;
error?: string;
}
// Installation Progress // Installation Progress
export interface InstallProgress { export interface InstallProgress {
isInstalling: boolean; isInstalling: boolean;
@@ -65,8 +45,6 @@ export type SetupStep =
| "welcome" | "welcome"
| "claude_detect" | "claude_detect"
| "claude_auth" | "claude_auth"
| "codex_detect"
| "codex_auth"
| "complete"; | "complete";
export interface SetupState { export interface SetupState {
@@ -80,14 +58,8 @@ export interface SetupState {
claudeAuthStatus: ClaudeAuthStatus | null; claudeAuthStatus: ClaudeAuthStatus | null;
claudeInstallProgress: InstallProgress; claudeInstallProgress: InstallProgress;
// Codex CLI state
codexCliStatus: CliStatus | null;
codexAuthStatus: CodexAuthStatus | null;
codexInstallProgress: InstallProgress;
// Setup preferences // Setup preferences
skipClaudeSetup: boolean; skipClaudeSetup: boolean;
skipCodexSetup: boolean;
} }
export interface SetupActions { export interface SetupActions {
@@ -103,15 +75,8 @@ export interface SetupActions {
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void; setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
resetClaudeInstallProgress: () => void; resetClaudeInstallProgress: () => void;
// Codex CLI
setCodexCliStatus: (status: CliStatus | null) => void;
setCodexAuthStatus: (status: CodexAuthStatus | null) => void;
setCodexInstallProgress: (progress: Partial<InstallProgress>) => void;
resetCodexInstallProgress: () => void;
// Preferences // Preferences
setSkipClaudeSetup: (skip: boolean) => void; setSkipClaudeSetup: (skip: boolean) => void;
setSkipCodexSetup: (skip: boolean) => void;
} }
const initialInstallProgress: InstallProgress = { const initialInstallProgress: InstallProgress = {
@@ -130,12 +95,7 @@ const initialState: SetupState = {
claudeAuthStatus: null, claudeAuthStatus: null,
claudeInstallProgress: { ...initialInstallProgress }, claudeInstallProgress: { ...initialInstallProgress },
codexCliStatus: null,
codexAuthStatus: null,
codexInstallProgress: { ...initialInstallProgress },
skipClaudeSetup: false, skipClaudeSetup: false,
skipCodexSetup: false,
}; };
export const useSetupStore = create<SetupState & SetupActions>()( export const useSetupStore = create<SetupState & SetupActions>()(
@@ -171,26 +131,8 @@ export const useSetupStore = create<SetupState & SetupActions>()(
claudeInstallProgress: { ...initialInstallProgress }, 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 // Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
setSkipCodexSetup: (skip) => set({ skipCodexSetup: skip }),
}), }),
{ {
name: "automaker-setup", name: "automaker-setup",
@@ -198,7 +140,6 @@ export const useSetupStore = create<SetupState & SetupActions>()(
isFirstRun: state.isFirstRun, isFirstRun: state.isFirstRun,
setupComplete: state.setupComplete, setupComplete: state.setupComplete,
skipClaudeSetup: state.skipClaudeSetup, skipClaudeSetup: state.skipClaudeSetup,
skipCodexSetup: state.skipCodexSetup,
}), }),
} }
) )

View File

@@ -471,24 +471,6 @@ export interface ElectronAPI {
error?: string; error?: string;
}>; }>;
// Codex CLI Detection API
checkCodexCli: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}>;
// Model Management APIs // Model Management APIs
model: { model: {
// Get all available models from all providers // Get all available models from all providers
@@ -641,7 +623,7 @@ export interface ModelDefinition {
id: string; id: string;
name: string; name: string;
modelString: string; modelString: string;
provider: "claude" | "codex"; provider: "claude";
description?: string; description?: string;
tier?: "basic" | "standard" | "premium"; tier?: "basic" | "standard" | "premium";
default?: boolean; default?: boolean;

View File

@@ -2437,16 +2437,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
progress: 0, progress: 0,
output: [], output: [],
}, },
codexCliStatus: null,
codexAuthStatus: null,
codexInstallProgress: {
isInstalling: false,
currentStep: "",
progress: 0,
output: [],
},
skipClaudeSetup: false, skipClaudeSetup: false,
skipCodexSetup: false,
}, },
version: 0, version: 0,
}; };
@@ -2460,7 +2451,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
currentProject: null, currentProject: null,
theme: "dark", theme: "dark",
sidebarOpen: true, sidebarOpen: true,
apiKeys: { anthropic: "", google: "", openai: "" }, apiKeys: { anthropic: "", google: "" },
chatSessions: [], chatSessions: [],
chatHistoryOpen: false, chatHistoryOpen: false,
maxConcurrency: 3, maxConcurrency: 3,
@@ -2488,7 +2479,6 @@ export async function setupComplete(page: Page): Promise<void> {
setupComplete: true, setupComplete: true,
currentStep: "complete", currentStep: "complete",
skipClaudeSetup: false, skipClaudeSetup: false,
skipCodexSetup: false,
}, },
version: 0, version: 0,
}; };
@@ -2530,14 +2520,6 @@ export async function clickClaudeContinue(page: Page): Promise<void> {
await button.click(); 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 * Click finish on setup complete step
*/ */

View File

@@ -38,9 +38,6 @@ DATA_DIR=./data
# OPTIONAL - Additional AI Providers # OPTIONAL - Additional AI Providers
# ============================================ # ============================================
# OpenAI API key (for Codex CLI support)
OPENAI_API_KEY=
# Google API key (for future Gemini support) # Google API key (for future Gemini support)
GOOGLE_API_KEY= GOOGLE_API_KEY=

View File

@@ -1,2 +1,4 @@
.env .env
data data
node_modules
coverage

View File

@@ -9,7 +9,13 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"lint": "eslint src/" "lint": "eslint src/",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:cov": "vitest run --coverage",
"test:watch": "vitest watch",
"test:unit": "vitest run tests/unit"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.61", "@anthropic-ai/claude-agent-sdk": "^0.1.61",
@@ -24,7 +30,10 @@
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/node": "^20", "@types/node": "^20",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.15",
"@vitest/ui": "^4.0.15",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5" "typescript": "^5",
"vitest": "^4.0.15"
} }
} }

2267
apps/server/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
/**
* Conversation history utilities for processing message history
*
* Provides standardized conversation history handling:
* - Extract text from content (string or array format)
* - Normalize content blocks to array format
* - Format history as plain text for CLI-based providers
* - Convert history to Claude SDK message format
*/
import type { ConversationMessage } from "../providers/types.js";
/**
* Extract plain text from message content (handles both string and array formats)
*
* @param content - Message content (string or array of content blocks)
* @returns Extracted text content
*/
export function extractTextFromContent(
content: string | Array<{ type: string; text?: string; source?: object }>
): string {
if (typeof content === "string") {
return content;
}
// Extract text blocks only
return content
.filter((block) => block.type === "text")
.map((block) => block.text || "")
.join("\n");
}
/**
* Normalize message content to array format
*
* @param content - Message content (string or array)
* @returns Content as array of blocks
*/
export function normalizeContentBlocks(
content: string | Array<{ type: string; text?: string; source?: object }>
): Array<{ type: string; text?: string; source?: object }> {
if (Array.isArray(content)) {
return content;
}
return [{ type: "text", text: content }];
}
/**
* Format conversation history as plain text for CLI-based providers
*
* @param history - Array of conversation messages
* @returns Formatted text with role labels
*/
export function formatHistoryAsText(history: ConversationMessage[]): string {
if (history.length === 0) {
return "";
}
let historyText = "Previous conversation:\n\n";
for (const msg of history) {
const contentText = extractTextFromContent(msg.content);
const role = msg.role === "user" ? "User" : "Assistant";
historyText += `${role}: ${contentText}\n\n`;
}
historyText += "---\n\n";
return historyText;
}
/**
* Convert conversation history to Claude SDK message format
*
* @param history - Array of conversation messages
* @returns Array of Claude SDK formatted messages
*/
export function convertHistoryToMessages(
history: ConversationMessage[]
): Array<{
type: "user" | "assistant";
session_id: string;
message: {
role: "user" | "assistant";
content: Array<{ type: string; text?: string; source?: object }>;
};
parent_tool_use_id: null;
}> {
return history.map((historyMsg) => ({
type: historyMsg.role,
session_id: "",
message: {
role: historyMsg.role,
content: normalizeContentBlocks(historyMsg.content),
},
parent_tool_use_id: null,
}));
}

View File

@@ -0,0 +1,104 @@
/**
* Error handling utilities for standardized error classification
*
* Provides utilities for:
* - Detecting abort/cancellation errors
* - Detecting authentication errors
* - Classifying errors by type
* - Generating user-friendly error messages
*/
/**
* Check if an error is an abort/cancellation error
*
* @param error - The error to check
* @returns True if the error is an abort error
*/
export function isAbortError(error: unknown): boolean {
return (
error instanceof Error &&
(error.name === "AbortError" || error.message.includes("abort"))
);
}
/**
* Check if an error is an authentication/API key error
*
* @param errorMessage - The error message to check
* @returns True if the error is authentication-related
*/
export function isAuthenticationError(errorMessage: string): boolean {
return (
errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed") ||
errorMessage.includes("Fix external API key")
);
}
/**
* Error type classification
*/
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
/**
* Classified error information
*/
export interface ErrorInfo {
type: ErrorType;
message: string;
isAbort: boolean;
isAuth: boolean;
originalError: unknown;
}
/**
* Classify an error into a specific type
*
* @param error - The error to classify
* @returns Classified error information
*/
export function classifyError(error: unknown): ErrorInfo {
const message = error instanceof Error ? error.message : String(error || "Unknown error");
const isAbort = isAbortError(error);
const isAuth = isAuthenticationError(message);
let type: ErrorType;
if (isAuth) {
type = "authentication";
} else if (isAbort) {
type = "abort";
} else if (error instanceof Error) {
type = "execution";
} else {
type = "unknown";
}
return {
type,
message,
isAbort,
isAuth,
originalError: error,
};
}
/**
* Get a user-friendly error message
*
* @param error - The error to convert
* @returns User-friendly error message
*/
export function getUserFriendlyErrorMessage(error: unknown): string {
const info = classifyError(error);
if (info.isAbort) {
return "Operation was cancelled";
}
if (info.isAuth) {
return "Authentication failed. Please check your API key.";
}
return info.message;
}

View File

@@ -0,0 +1,135 @@
/**
* Image handling utilities for processing image files
*
* Provides utilities for:
* - MIME type detection based on file extensions
* - Base64 encoding of image files
* - Content block generation for Claude SDK format
* - Path resolution (relative/absolute)
*/
import fs from "fs/promises";
import path from "path";
/**
* MIME type mapping for image file extensions
*/
const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
} as const;
/**
* Image data with base64 encoding and metadata
*/
export interface ImageData {
base64: string;
mimeType: string;
filename: string;
originalPath: string;
}
/**
* Content block for image (Claude SDK format)
*/
export interface ImageContentBlock {
type: "image";
source: {
type: "base64";
media_type: string;
data: string;
};
}
/**
* Get MIME type for an image file based on extension
*
* @param imagePath - Path to the image file
* @returns MIME type string (defaults to "image/png" for unknown extensions)
*/
export function getMimeTypeForImage(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
return IMAGE_MIME_TYPES[ext] || "image/png";
}
/**
* Read an image file and convert to base64 with metadata
*
* @param imagePath - Path to the image file
* @returns Promise resolving to image data with base64 encoding
* @throws Error if file cannot be read
*/
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
const imageBuffer = await fs.readFile(imagePath);
const base64Data = imageBuffer.toString("base64");
const mimeType = getMimeTypeForImage(imagePath);
return {
base64: base64Data,
mimeType,
filename: path.basename(imagePath),
originalPath: imagePath,
};
}
/**
* Convert image paths to content blocks (Claude SDK format)
* Handles both relative and absolute paths
*
* @param imagePaths - Array of image file paths
* @param workDir - Optional working directory for resolving relative paths
* @returns Promise resolving to array of image content blocks
*/
export async function convertImagesToContentBlocks(
imagePaths: string[],
workDir?: string
): Promise<ImageContentBlock[]> {
const blocks: ImageContentBlock[] = [];
for (const imagePath of imagePaths) {
try {
// Resolve to absolute path if needed
const absolutePath = workDir && !path.isAbsolute(imagePath)
? path.join(workDir, imagePath)
: imagePath;
const imageData = await readImageAsBase64(absolutePath);
blocks.push({
type: "image",
source: {
type: "base64",
media_type: imageData.mimeType,
data: imageData.base64,
},
});
} catch (error) {
console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error);
// Continue processing other images
}
}
return blocks;
}
/**
* Build a list of image paths for text prompts
* Formats image paths as a bulleted list for inclusion in text prompts
*
* @param imagePaths - Array of image file paths
* @returns Formatted string with image paths, or empty string if no images
*/
export function formatImagePathsForPrompt(imagePaths: string[]): string {
if (imagePaths.length === 0) {
return "";
}
let text = "\n\nAttached images:\n";
for (const imagePath of imagePaths) {
text += `- ${imagePath}\n`;
}
return text;
}

View File

@@ -0,0 +1,80 @@
/**
* Model resolution utilities for handling model string mapping
*
* Provides centralized model resolution logic:
* - Maps Claude model aliases to full model strings
* - Provides default models per provider
* - Handles multiple model sources with priority
*/
/**
* Model alias mapping for Claude models
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
} as const;
/**
* Default models per provider
*/
export const DEFAULT_MODELS = {
claude: "claude-opus-4-5-20251101",
} as const;
/**
* Resolve a model key/alias to a full model string
*
* @param modelKey - Model key (e.g., "opus", "gpt-5.2", "claude-sonnet-4-20250514")
* @param defaultModel - Fallback model if modelKey is undefined
* @returns Full model string
*/
export function resolveModelString(
modelKey?: string,
defaultModel: string = DEFAULT_MODELS.claude
): string {
// No model specified - use default
if (!modelKey) {
return defaultModel;
}
// Full Claude model string - pass through unchanged
if (modelKey.includes("claude-")) {
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
return modelKey;
}
// Look up Claude model alias
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
return resolved;
}
// Unknown model key - use default
console.warn(
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
);
return defaultModel;
}
/**
* Get the effective model from multiple sources
* Priority: explicit model > session model > default
*
* @param explicitModel - Explicitly provided model (highest priority)
* @param sessionModel - Model from session (medium priority)
* @param defaultModel - Fallback default model (lowest priority)
* @returns Resolved model string
*/
export function getEffectiveModel(
explicitModel?: string,
sessionModel?: string,
defaultModel?: string
): string {
return resolveModelString(
explicitModel || sessionModel,
defaultModel
);
}

View File

@@ -0,0 +1,79 @@
/**
* Prompt building utilities for constructing prompts with images
*
* Provides standardized prompt building that:
* - Combines text prompts with image attachments
* - Handles content block array generation
* - Optionally includes image paths in text
* - Supports both vision and non-vision models
*/
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
/**
* Content that can be either simple text or structured blocks
*/
export type PromptContent = string | Array<{
type: string;
text?: string;
source?: object;
}>;
/**
* Result of building a prompt with optional images
*/
export interface PromptWithImages {
content: PromptContent;
hasImages: boolean;
}
/**
* Build a prompt with optional image attachments
*
* @param basePrompt - The text prompt
* @param imagePaths - Optional array of image file paths
* @param workDir - Optional working directory for resolving relative paths
* @param includeImagePaths - Whether to append image paths to the text (default: false)
* @returns Promise resolving to prompt content and metadata
*/
export async function buildPromptWithImages(
basePrompt: string,
imagePaths?: string[],
workDir?: string,
includeImagePaths: boolean = false
): Promise<PromptWithImages> {
// No images - return plain text
if (!imagePaths || imagePaths.length === 0) {
return { content: basePrompt, hasImages: false };
}
// Build text content with optional image path listing
let textContent = basePrompt;
if (includeImagePaths) {
textContent += formatImagePathsForPrompt(imagePaths);
}
// Build content blocks array
const contentBlocks: Array<{
type: string;
text?: string;
source?: object;
}> = [];
// Add text block if we have text
if (textContent.trim()) {
contentBlocks.push({ type: "text", text: textContent });
}
// Add image blocks
const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir);
contentBlocks.push(...imageBlocks);
// Return appropriate format
const content: PromptContent =
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
? contentBlocks
: textContent;
return { content, hasImages: true };
}

View File

@@ -0,0 +1,206 @@
/**
* Subprocess management utilities for CLI providers
*/
import { spawn, type ChildProcess } from "child_process";
import readline from "readline";
export interface SubprocessOptions {
command: string;
args: string[];
cwd: string;
env?: Record<string, string>;
abortController?: AbortController;
timeout?: number; // Milliseconds of no output before timeout
}
export interface SubprocessResult {
stdout: string;
stderr: string;
exitCode: number | null;
}
/**
* Spawns a subprocess and streams JSONL output line-by-line
*/
export async function* spawnJSONLProcess(
options: SubprocessOptions
): AsyncGenerator<unknown> {
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
const processEnv = {
...process.env,
...env,
};
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`);
console.log(`[SubprocessManager] Working directory: ${cwd}`);
const childProcess: ChildProcess = spawn(command, args, {
cwd,
env: processEnv,
stdio: ["ignore", "pipe", "pipe"],
});
let stderrOutput = "";
let lastOutputTime = Date.now();
let timeoutHandle: NodeJS.Timeout | null = null;
// Collect stderr for error reporting
if (childProcess.stderr) {
childProcess.stderr.on("data", (data: Buffer) => {
const text = data.toString();
stderrOutput += text;
console.error(`[SubprocessManager] stderr: ${text}`);
});
}
// Setup timeout detection
const resetTimeout = () => {
lastOutputTime = Date.now();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
timeoutHandle = setTimeout(() => {
const elapsed = Date.now() - lastOutputTime;
if (elapsed >= timeout) {
console.error(
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
);
childProcess.kill("SIGTERM");
}
}, timeout);
};
resetTimeout();
// Setup abort handling
if (abortController) {
abortController.signal.addEventListener("abort", () => {
console.log("[SubprocessManager] Abort signal received, killing process");
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
childProcess.kill("SIGTERM");
});
}
// Parse stdout as JSONL (one JSON object per line)
if (childProcess.stdout) {
const rl = readline.createInterface({
input: childProcess.stdout,
crlfDelay: Infinity,
});
try {
for await (const line of rl) {
resetTimeout();
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
yield parsed;
} catch (parseError) {
console.error(
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
parseError
);
// Yield error but continue processing
yield {
type: "error",
error: `Failed to parse output: ${line}`,
};
}
}
} catch (error) {
console.error("[SubprocessManager] Error reading stdout:", error);
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
// Wait for process to exit
const exitCode = await new Promise<number | null>((resolve) => {
childProcess.on("exit", (code) => {
console.log(`[SubprocessManager] Process exited with code: ${code}`);
resolve(code);
});
childProcess.on("error", (error) => {
console.error("[SubprocessManager] Process error:", error);
resolve(null);
});
});
// Handle non-zero exit codes
if (exitCode !== 0 && exitCode !== null) {
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
yield {
type: "error",
error: errorMessage,
};
}
// Process completed successfully
if (exitCode === 0 && !stderrOutput) {
console.log("[SubprocessManager] Process completed successfully");
}
}
/**
* Spawns a subprocess and collects all output
*/
export async function spawnProcess(
options: SubprocessOptions
): Promise<SubprocessResult> {
const { command, args, cwd, env, abortController } = options;
const processEnv = {
...process.env,
...env,
};
return new Promise((resolve, reject) => {
const childProcess = spawn(command, args, {
cwd,
env: processEnv,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
if (childProcess.stdout) {
childProcess.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
});
}
if (childProcess.stderr) {
childProcess.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
}
// Setup abort handling
if (abortController) {
abortController.signal.addEventListener("abort", () => {
childProcess.kill("SIGTERM");
reject(new Error("Process aborted"));
});
}
childProcess.on("exit", (code) => {
resolve({ stdout, stderr, exitCode: code });
});
childProcess.on("error", (error) => {
reject(error);
});
});
}

View File

@@ -0,0 +1,96 @@
/**
* Abstract base class for AI model providers
*/
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ValidationResult,
ModelDefinition,
} from "./types.js";
/**
* Base provider class that all provider implementations must extend
*/
export abstract class BaseProvider {
protected config: ProviderConfig;
protected name: string;
constructor(config: ProviderConfig = {}) {
this.config = config;
this.name = this.getName();
}
/**
* Get the provider name (e.g., "claude", "cursor")
*/
abstract getName(): string;
/**
* Execute a query and stream responses
* @param options Execution options
* @returns AsyncGenerator yielding provider messages
*/
abstract executeQuery(
options: ExecuteOptions
): AsyncGenerator<ProviderMessage>;
/**
* Detect if the provider is installed and configured
* @returns Installation status
*/
abstract detectInstallation(): Promise<InstallationStatus>;
/**
* Get available models for this provider
* @returns Array of model definitions
*/
abstract getAvailableModels(): ModelDefinition[];
/**
* Validate the provider configuration
* @returns Validation result
*/
validateConfig(): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Base validation (can be overridden)
if (!this.config) {
errors.push("Provider config is missing");
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Check if the provider supports a specific feature
* @param feature Feature name (e.g., "vision", "tools", "mcp")
* @returns Whether the feature is supported
*/
supportsFeature(feature: string): boolean {
// Default implementation - override in subclasses
const commonFeatures = ["tools", "text"];
return commonFeatures.includes(feature);
}
/**
* Get provider configuration
*/
getConfig(): ProviderConfig {
return this.config;
}
/**
* Update provider configuration
*/
setConfig(config: Partial<ProviderConfig>): void {
this.config = { ...this.config, ...config };
}
}

View File

@@ -0,0 +1,192 @@
/**
* Claude Provider - Executes queries using Claude Agent SDK
*
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
* with the provider architecture.
*/
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { BaseProvider } from "./base-provider.js";
import { convertHistoryToMessages, normalizeContentBlocks } from "../lib/conversation-utils.js";
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from "./types.js";
export class ClaudeProvider extends BaseProvider {
getName(): string {
return "claude";
}
/**
* Execute a query using Claude Agent SDK
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
const {
prompt,
model,
cwd,
systemPrompt,
maxTurns = 20,
allowedTools,
abortController,
conversationHistory,
} = options;
// Build Claude SDK options
const sdkOptions: Options = {
model,
systemPrompt,
maxTurns,
cwd,
allowedTools: allowedTools || [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController,
};
// Build prompt payload with conversation history
let promptPayload: string | AsyncGenerator<any, void, unknown>;
if (conversationHistory && conversationHistory.length > 0) {
// Multi-turn conversation with history
promptPayload = (async function* () {
// Yield history messages using utility
const historyMessages = convertHistoryToMessages(conversationHistory);
for (const msg of historyMessages) {
yield msg;
}
// Yield current prompt
yield {
type: "user" as const,
session_id: "",
message: {
role: "user" as const,
content: normalizeContentBlocks(prompt),
},
parent_tool_use_id: null,
};
})();
} else if (Array.isArray(prompt)) {
// Multi-part prompt (with images) - no history
promptPayload = (async function* () {
yield {
type: "user" as const,
session_id: "",
message: {
role: "user" as const,
content: prompt,
},
parent_tool_use_id: null,
};
})();
} else {
// Simple text prompt - no history
promptPayload = prompt;
}
// Execute via Claude Agent SDK
const stream = query({ prompt: promptPayload, options: sdkOptions });
// Stream messages directly - they're already in the correct format
for await (const msg of stream) {
yield msg as ProviderMessage;
}
}
/**
* Detect Claude SDK installation (always available via npm)
*/
async detectInstallation(): Promise<InstallationStatus> {
// Claude SDK is always available since it's a dependency
const hasApiKey =
!!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
return {
installed: true,
method: "sdk",
hasApiKey,
authenticated: hasApiKey,
};
}
/**
* Get available Claude models
*/
getAvailableModels(): ModelDefinition[] {
return [
{
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
modelString: "claude-opus-4-5-20251101",
provider: "anthropic",
description: "Most capable Claude model",
contextWindow: 200000,
maxOutputTokens: 16000,
supportsVision: true,
supportsTools: true,
tier: "premium",
default: true,
},
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
modelString: "claude-sonnet-4-20250514",
provider: "anthropic",
description: "Balanced performance and cost",
contextWindow: 200000,
maxOutputTokens: 16000,
supportsVision: true,
supportsTools: true,
tier: "standard",
},
{
id: "claude-3-5-sonnet-20241022",
name: "Claude 3.5 Sonnet",
modelString: "claude-3-5-sonnet-20241022",
provider: "anthropic",
description: "Fast and capable",
contextWindow: 200000,
maxOutputTokens: 8000,
supportsVision: true,
supportsTools: true,
tier: "standard",
},
{
id: "claude-3-5-haiku-20241022",
name: "Claude 3.5 Haiku",
modelString: "claude-3-5-haiku-20241022",
provider: "anthropic",
description: "Fastest Claude model",
contextWindow: 200000,
maxOutputTokens: 8000,
supportsVision: true,
supportsTools: true,
tier: "basic",
},
];
}
/**
* Check if the provider supports a specific feature
*/
supportsFeature(feature: string): boolean {
const supportedFeatures = ["tools", "text", "vision", "thinking"];
return supportedFeatures.includes(feature);
}
}

View File

@@ -0,0 +1,115 @@
/**
* Provider Factory - Routes model IDs to the appropriate provider
*
* This factory implements model-based routing to automatically select
* the correct provider based on the model string. This makes adding
* new providers (Cursor, OpenCode, etc.) trivial - just add one line.
*/
import { BaseProvider } from "./base-provider.js";
import { ClaudeProvider } from "./claude-provider.js";
import type { InstallationStatus } from "./types.js";
export class ProviderFactory {
/**
* Get the appropriate provider for a given model ID
*
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast")
* @returns Provider instance for the model
*/
static getProviderForModel(modelId: string): BaseProvider {
const lowerModel = modelId.toLowerCase();
// Claude models (claude-*, opus, sonnet, haiku)
if (
lowerModel.startsWith("claude-") ||
["haiku", "sonnet", "opus"].includes(lowerModel)
) {
return new ClaudeProvider();
}
// Future providers:
// if (lowerModel.startsWith("cursor-")) {
// return new CursorProvider();
// }
// if (lowerModel.startsWith("opencode-")) {
// return new OpenCodeProvider();
// }
// Default to Claude for unknown models
console.warn(
`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`
);
return new ClaudeProvider();
}
/**
* Get all available providers
*/
static getAllProviders(): BaseProvider[] {
return [
new ClaudeProvider(),
// Future providers...
];
}
/**
* Check installation status for all providers
*
* @returns Map of provider name to installation status
*/
static async checkAllProviders(): Promise<
Record<string, InstallationStatus>
> {
const providers = this.getAllProviders();
const statuses: Record<string, InstallationStatus> = {};
for (const provider of providers) {
const name = provider.getName();
const status = await provider.detectInstallation();
statuses[name] = status;
}
return statuses;
}
/**
* Get provider by name (for direct access if needed)
*
* @param name Provider name (e.g., "claude", "cursor")
* @returns Provider instance or null if not found
*/
static getProviderByName(name: string): BaseProvider | null {
const lowerName = name.toLowerCase();
switch (lowerName) {
case "claude":
case "anthropic":
return new ClaudeProvider();
// Future providers:
// case "cursor":
// return new CursorProvider();
// case "opencode":
// return new OpenCodeProvider();
default:
return null;
}
}
/**
* Get all available models from all providers
*/
static getAllAvailableModels() {
const providers = this.getAllProviders();
const allModels = [];
for (const provider of providers) {
const models = provider.getAvailableModels();
allModels.push(...models);
}
return allModels;
}
}

View File

@@ -0,0 +1,103 @@
/**
* Shared types for AI model providers
*/
/**
* Configuration for a provider instance
*/
export interface ProviderConfig {
apiKey?: string;
cliPath?: string;
env?: Record<string, string>;
}
/**
* Message in conversation history
*/
export interface ConversationMessage {
role: "user" | "assistant";
content: string | Array<{ type: string; text?: string; source?: object }>;
}
/**
* Options for executing a query via a provider
*/
export interface ExecuteOptions {
prompt: string | Array<{ type: string; text?: string; source?: object }>;
model: string;
cwd: string;
systemPrompt?: string;
maxTurns?: number;
allowedTools?: string[];
mcpServers?: Record<string, unknown>;
abortController?: AbortController;
conversationHistory?: ConversationMessage[]; // Previous messages for context
}
/**
* Content block in a provider message (matches Claude SDK format)
*/
export interface ContentBlock {
type: "text" | "tool_use" | "thinking" | "tool_result";
text?: string;
thinking?: string;
name?: string;
input?: unknown;
tool_use_id?: string;
content?: string;
}
/**
* Message returned by a provider (matches Claude SDK streaming format)
*/
export interface ProviderMessage {
type: "assistant" | "user" | "error" | "result";
subtype?: "success" | "error";
session_id?: string;
message?: {
role: "user" | "assistant";
content: ContentBlock[];
};
result?: string;
error?: string;
parent_tool_use_id?: string | null;
}
/**
* Installation status for a provider
*/
export interface InstallationStatus {
installed: boolean;
path?: string;
version?: string;
method?: "cli" | "npm" | "brew" | "sdk";
hasApiKey?: boolean;
authenticated?: boolean;
error?: string;
}
/**
* Validation result
*/
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings?: string[];
}
/**
* Model definition
*/
export interface ModelDefinition {
id: string;
name: string;
modelString: string;
provider: string;
description: string;
contextWindow?: number;
maxOutputTokens?: number;
supportsVision?: boolean;
supportsTools?: boolean;
tier?: "basic" | "standard" | "premium";
default?: boolean;
}

View File

@@ -40,11 +40,12 @@ export function createAgentRoutes(
// Send a message // Send a message
router.post("/send", async (req: Request, res: Response) => { router.post("/send", async (req: Request, res: Response) => {
try { try {
const { sessionId, message, workingDirectory, imagePaths } = req.body as { const { sessionId, message, workingDirectory, imagePaths, model } = req.body as {
sessionId: string; sessionId: string;
message: string; message: string;
workingDirectory?: string; workingDirectory?: string;
imagePaths?: string[]; imagePaths?: string[];
model?: string;
}; };
if (!sessionId || !message) { if (!sessionId || !message) {
@@ -61,6 +62,7 @@ export function createAgentRoutes(
message, message,
workingDirectory, workingDirectory,
imagePaths, imagePaths,
model,
}) })
.catch((error) => { .catch((error) => {
console.error("[Agent Route] Error sending message:", error); console.error("[Agent Route] Error sending message:", error);
@@ -128,5 +130,26 @@ export function createAgentRoutes(
} }
}); });
// Set session model
router.post("/model", async (req: Request, res: Response) => {
try {
const { sessionId, model } = req.body as {
sessionId: string;
model: string;
};
if (!sessionId || !model) {
res.status(400).json({ success: false, error: "sessionId and model are required" });
return;
}
const result = await agentService.setSessionModel(sessionId, model);
res.json({ success: result });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router; return router;
} }

View File

@@ -3,6 +3,7 @@
*/ */
import { Router, type Request, type Response } from "express"; import { Router, type Request, type Response } from "express";
import { ProviderFactory } from "../providers/provider-factory.js";
interface ModelDefinition { interface ModelDefinition {
id: string; id: string;
@@ -63,33 +64,6 @@ export function createModelsRoutes(): Router {
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
}, },
{
id: "gpt-4o",
name: "GPT-4o",
provider: "openai",
contextWindow: 128000,
maxOutputTokens: 16384,
supportsVision: true,
supportsTools: true,
},
{
id: "gpt-4o-mini",
name: "GPT-4o Mini",
provider: "openai",
contextWindow: 128000,
maxOutputTokens: 16384,
supportsVision: true,
supportsTools: true,
},
{
id: "o1",
name: "o1",
provider: "openai",
contextWindow: 200000,
maxOutputTokens: 100000,
supportsVision: true,
supportsTools: false,
},
]; ];
res.json({ success: true, models }); res.json({ success: true, models });
@@ -102,14 +76,13 @@ export function createModelsRoutes(): Router {
// Check provider status // Check provider status
router.get("/providers", async (_req: Request, res: Response) => { router.get("/providers", async (_req: Request, res: Response) => {
try { try {
const providers: Record<string, ProviderStatus> = { // Get installation status from all providers
const statuses = await ProviderFactory.checkAllProviders();
const providers: Record<string, any> = {
anthropic: { anthropic: {
available: !!process.env.ANTHROPIC_API_KEY, available: statuses.claude?.installed || false,
hasApiKey: !!process.env.ANTHROPIC_API_KEY, hasApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
},
openai: {
available: !!process.env.OPENAI_API_KEY,
hasApiKey: !!process.env.OPENAI_API_KEY,
}, },
google: { google: {
available: !!process.env.GOOGLE_API_KEY, available: !!process.env.GOOGLE_API_KEY,

View File

@@ -46,10 +46,11 @@ export function createSessionsRoutes(agentService: AgentService): Router {
// Create a new session // Create a new session
router.post("/", async (req: Request, res: Response) => { router.post("/", async (req: Request, res: Response) => {
try { try {
const { name, projectPath, workingDirectory } = req.body as { const { name, projectPath, workingDirectory, model } = req.body as {
name: string; name: string;
projectPath?: string; projectPath?: string;
workingDirectory?: string; workingDirectory?: string;
model?: string;
}; };
if (!name) { if (!name) {
@@ -60,7 +61,8 @@ export function createSessionsRoutes(agentService: AgentService): Router {
const session = await agentService.createSession( const session = await agentService.createSession(
name, name,
projectPath, projectPath,
workingDirectory workingDirectory,
model
); );
res.json({ success: true, session }); res.json({ success: true, session });
} catch (error) { } catch (error) {
@@ -73,12 +75,13 @@ export function createSessionsRoutes(agentService: AgentService): Router {
router.put("/:sessionId", async (req: Request, res: Response) => { router.put("/:sessionId", async (req: Request, res: Response) => {
try { try {
const { sessionId } = req.params; const { sessionId } = req.params;
const { name, tags } = req.body as { const { name, tags, model } = req.body as {
name?: string; name?: string;
tags?: string[]; tags?: string[];
model?: string;
}; };
const session = await agentService.updateSession(sessionId, { name, tags }); const session = await agentService.updateSession(sessionId, { name, tags, model });
if (!session) { if (!session) {
res.status(404).json({ success: false, error: "Session not found" }); res.status(404).json({ success: false, error: "Session not found" });
return; return;

View File

@@ -230,99 +230,6 @@ export function createSetupRoutes(): Router {
} }
}); });
// Get Codex CLI status
router.get("/codex-status", async (_req: Request, res: Response) => {
try {
let installed = false;
let version = "";
let cliPath = "";
let method = "none";
// Try to find Codex CLI
try {
const { stdout } = await execAsync("which codex || where codex 2>/dev/null");
cliPath = stdout.trim();
installed = true;
method = "path";
try {
const { stdout: versionOut } = await execAsync("codex --version");
version = versionOut.trim();
} catch {
// Version command might not be available
}
} catch {
// Not found
}
// Check for OpenAI/Codex authentication
let auth = {
authenticated: false,
method: "none" as string,
hasAuthFile: false,
hasEnvKey: !!process.env.OPENAI_API_KEY,
hasStoredApiKey: !!apiKeys.openai,
hasEnvApiKey: !!process.env.OPENAI_API_KEY,
// Additional fields for subscription/account detection
hasSubscription: false,
cliLoggedIn: false,
};
// Check for OpenAI CLI auth file (~/.codex/auth.json or similar)
const codexAuthPaths = [
path.join(os.homedir(), ".codex", "auth.json"),
path.join(os.homedir(), ".openai", "credentials"),
path.join(os.homedir(), ".config", "openai", "credentials.json"),
];
for (const authPath of codexAuthPaths) {
try {
const authContent = await fs.readFile(authPath, "utf-8");
const authData = JSON.parse(authContent);
auth.hasAuthFile = true;
// Check for subscription/tokens
if (authData.subscription || authData.plan || authData.account_type) {
auth.hasSubscription = true;
auth.authenticated = true;
auth.method = "subscription"; // Codex subscription (Plus/Team)
} else if (authData.access_token || authData.api_key) {
auth.cliLoggedIn = true;
auth.authenticated = true;
auth.method = "cli_verified"; // CLI logged in with account
}
break;
} catch {
// Auth file not found at this path
}
}
// Environment variable has highest priority
if (auth.hasEnvApiKey) {
auth.authenticated = true;
auth.method = "env"; // OPENAI_API_KEY environment variable
}
// In-memory stored API key (from settings UI)
if (!auth.authenticated && apiKeys.openai) {
auth.authenticated = true;
auth.method = "api_key"; // Manually stored API key
}
res.json({
success: true,
status: installed ? "installed" : "not_installed",
method,
version,
path: cliPath,
auth,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Install Claude CLI // Install Claude CLI
router.post("/install-claude", async (_req: Request, res: Response) => { router.post("/install-claude", async (_req: Request, res: Response) => {
try { try {
@@ -339,20 +246,6 @@ export function createSetupRoutes(): Router {
} }
}); });
// Install Codex CLI
router.post("/install-codex", async (_req: Request, res: Response) => {
try {
res.json({
success: false,
error:
"CLI installation requires terminal access. Please install manually using: npm install -g @openai/codex",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Auth Claude // Auth Claude
router.post("/auth-claude", async (_req: Request, res: Response) => { router.post("/auth-claude", async (_req: Request, res: Response) => {
try { try {
@@ -368,28 +261,6 @@ export function createSetupRoutes(): Router {
} }
}); });
// Auth Codex
router.post("/auth-codex", async (req: Request, res: Response) => {
try {
const { apiKey } = req.body as { apiKey?: string };
if (apiKey) {
apiKeys.openai = apiKey;
process.env.OPENAI_API_KEY = apiKey;
res.json({ success: true });
} else {
res.json({
success: true,
requiresManualAuth: true,
command: "codex auth login",
});
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Store API key // Store API key
router.post("/store-api-key", async (req: Request, res: Response) => { router.post("/store-api-key", async (req: Request, res: Response) => {
try { try {
@@ -416,9 +287,6 @@ export function createSetupRoutes(): Router {
process.env.ANTHROPIC_API_KEY = apiKey; process.env.ANTHROPIC_API_KEY = apiKey;
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey); await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
console.log("[Setup] Stored API key as ANTHROPIC_API_KEY"); console.log("[Setup] Stored API key as ANTHROPIC_API_KEY");
} else if (provider === "openai") {
process.env.OPENAI_API_KEY = apiKey;
await persistApiKeyToEnv("OPENAI_API_KEY", apiKey);
} else if (provider === "google") { } else if (provider === "google") {
process.env.GOOGLE_API_KEY = apiKey; process.env.GOOGLE_API_KEY = apiKey;
await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey); await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey);
@@ -437,7 +305,6 @@ export function createSetupRoutes(): Router {
res.json({ res.json({
success: true, success: true,
hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY, hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY,
hasOpenAIKey: !!apiKeys.openai || !!process.env.OPENAI_API_KEY,
hasGoogleKey: !!apiKeys.google || !!process.env.GOOGLE_API_KEY, hasGoogleKey: !!apiKeys.google || !!process.env.GOOGLE_API_KEY,
}); });
} catch (error) { } catch (error) {
@@ -446,34 +313,6 @@ export function createSetupRoutes(): Router {
} }
}); });
// Configure Codex MCP
router.post("/configure-codex-mcp", async (req: Request, res: Response) => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath required" });
return;
}
// Create .codex directory and config
const codexDir = path.join(projectPath, ".codex");
await fs.mkdir(codexDir, { recursive: true });
const configPath = path.join(codexDir, "config.toml");
const config = `# Codex configuration
[mcp]
enabled = true
`;
await fs.writeFile(configPath, config);
res.json({ success: true, configPath });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get platform info // Get platform info
router.get("/platform", async (_req: Request, res: Response) => { router.get("/platform", async (_req: Request, res: Response) => {
try { try {
@@ -493,29 +332,5 @@ enabled = true
} }
}); });
// Test OpenAI connection
router.post("/test-openai", async (req: Request, res: Response) => {
try {
const { apiKey } = req.body as { apiKey?: string };
const key = apiKey || apiKeys.openai || process.env.OPENAI_API_KEY;
if (!key) {
res.json({ success: false, error: "No OpenAI API key provided" });
return;
}
// Simple test - just verify the key format
if (!key.startsWith("sk-")) {
res.json({ success: false, error: "Invalid OpenAI API key format" });
return;
}
res.json({ success: true, message: "API key format is valid" });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router; return router;
} }

View File

@@ -34,6 +34,8 @@ export function createTemplatesRoutes(): Router {
return; return;
} }
console.log(`[Templates] Clone request - Repo: ${repoUrl}, Project: ${projectName}, Parent: ${parentDir}`);
// Validate repo URL is a valid GitHub URL // Validate repo URL is a valid GitHub URL
const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/; const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/;
if (!githubUrlPattern.test(repoUrl)) { if (!githubUrlPattern.test(repoUrl)) {
@@ -79,12 +81,32 @@ export function createTemplatesRoutes(): Router {
// Ensure parent directory exists // Ensure parent directory exists
try { try {
await fs.mkdir(parentDir, { recursive: true }); // Check if parentDir is a root path (Windows: C:\, D:\, etc. or Unix: /)
const isWindowsRoot = /^[A-Za-z]:\\?$/.test(parentDir);
const isUnixRoot = parentDir === '/' || parentDir === '';
const isRoot = isWindowsRoot || isUnixRoot;
if (isRoot) {
// Root paths always exist, just verify access
console.log(`[Templates] Using root path: ${parentDir}`);
await fs.access(parentDir);
} else {
// Check if parent directory exists
const parentExists = await fs.access(parentDir).then(() => true).catch(() => false);
if (!parentExists) {
console.log(`[Templates] Creating parent directory: ${parentDir}`);
await fs.mkdir(parentDir, { recursive: true });
} else {
console.log(`[Templates] Parent directory exists: ${parentDir}`);
}
}
} catch (error) { } catch (error) {
console.error("[Templates] Failed to create parent directory:", error); const errorMessage = error instanceof Error ? error.message : String(error);
console.error("[Templates] Failed to access parent directory:", parentDir, error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Failed to create parent directory", error: `Failed to access parent directory: ${errorMessage}`,
}); });
return; return;
} }

View File

@@ -1,12 +1,20 @@
/** /**
* Agent Service - Runs Claude agents via the Claude Agent SDK * Agent Service - Runs AI agents via provider architecture
* Manages conversation sessions and streams responses via WebSocket * Manages conversation sessions and streams responses via WebSocket
*/ */
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk"; import { AbortError } from "@anthropic-ai/claude-agent-sdk";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js"; import type { EventEmitter } from "../lib/events.js";
import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
import {
readImageAsBase64,
} from "../lib/image-handler.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { getEffectiveModel } from "../lib/model-resolver.js";
import { isAbortError } from "../lib/error-handler.js";
interface Message { interface Message {
id: string; id: string;
@@ -26,6 +34,7 @@ interface Session {
isRunning: boolean; isRunning: boolean;
abortController: AbortController | null; abortController: AbortController | null;
workingDirectory: string; workingDirectory: string;
model?: string;
} }
interface SessionMetadata { interface SessionMetadata {
@@ -37,6 +46,7 @@ interface SessionMetadata {
updatedAt: string; updatedAt: string;
archived?: boolean; archived?: boolean;
tags?: string[]; tags?: string[];
model?: string;
} }
export class AgentService { export class AgentService {
@@ -91,11 +101,13 @@ export class AgentService {
message, message,
workingDirectory, workingDirectory,
imagePaths, imagePaths,
model,
}: { }: {
sessionId: string; sessionId: string;
message: string; message: string;
workingDirectory?: string; workingDirectory?: string;
imagePaths?: string[]; imagePaths?: string[];
model?: string;
}) { }) {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
@@ -106,27 +118,22 @@ export class AgentService {
throw new Error("Agent is already processing a message"); throw new Error("Agent is already processing a message");
} }
// Update session model if provided
if (model) {
session.model = model;
await this.updateSession(sessionId, { model });
}
// Read images and convert to base64 // Read images and convert to base64
const images: Message["images"] = []; const images: Message["images"] = [];
if (imagePaths && imagePaths.length > 0) { if (imagePaths && imagePaths.length > 0) {
for (const imagePath of imagePaths) { for (const imagePath of imagePaths) {
try { try {
const imageBuffer = await fs.readFile(imagePath); const imageData = await readImageAsBase64(imagePath);
const base64Data = imageBuffer.toString("base64");
const ext = path.extname(imagePath).toLowerCase();
const mimeTypeMap: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
const mediaType = mimeTypeMap[ext] || "image/png";
images.push({ images.push({
data: base64Data, data: imageData.base64,
mimeType: mediaType, mimeType: imageData.mimeType,
filename: path.basename(imagePath), filename: imageData.filename,
}); });
} catch (error) { } catch (error) {
console.error(`[AgentService] Failed to load image ${imagePath}:`, error); console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
@@ -143,6 +150,12 @@ export class AgentService {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
// Build conversation history from existing messages BEFORE adding current message
const conversationHistory = session.messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
session.messages.push(userMessage); session.messages.push(userMessage);
session.isRunning = true; session.isRunning = true;
session.abortController = new AbortController(); session.abortController = new AbortController();
@@ -156,11 +169,23 @@ export class AgentService {
await this.saveSession(sessionId, session.messages); await this.saveSession(sessionId, session.messages);
try { try {
const options: Options = { // Use session model, parameter model, or default
model: "claude-opus-4-5-20251101", const effectiveModel = getEffectiveModel(model, session.model);
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel);
console.log(
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
);
// Build options for provider
const options: ExecuteOptions = {
prompt: "", // Will be set below based on images
model: effectiveModel,
cwd: workingDirectory || session.workingDirectory,
systemPrompt: this.getSystemPrompt(), systemPrompt: this.getSystemPrompt(),
maxTurns: 20, maxTurns: 20,
cwd: workingDirectory || session.workingDirectory,
allowedTools: [ allowedTools: [
"Read", "Read",
"Write", "Write",
@@ -171,73 +196,23 @@ export class AgentService {
"WebSearch", "WebSearch",
"WebFetch", "WebFetch",
], ],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: session.abortController!, abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
}; };
// Build prompt content // Build prompt content with images
let promptContent: string | Array<{ type: string; text?: string; source?: object }> = const { content: promptContent } = await buildPromptWithImages(
message; message,
imagePaths,
undefined, // no workDir for agent service
true // include image paths in text
);
if (imagePaths && imagePaths.length > 0) { // Set the prompt in options
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = []; options.prompt = promptContent;
if (message && message.trim()) { // Execute via provider
contentBlocks.push({ type: "text", text: message }); const stream = provider.executeQuery(options);
}
for (const imagePath of imagePaths) {
try {
const imageBuffer = await fs.readFile(imagePath);
const base64Data = imageBuffer.toString("base64");
const ext = path.extname(imagePath).toLowerCase();
const mimeTypeMap: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
const mediaType = mimeTypeMap[ext] || "image/png";
contentBlocks.push({
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64Data,
},
});
} catch (error) {
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
}
}
if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") {
promptContent = contentBlocks;
}
}
// Build payload
const promptPayload = Array.isArray(promptContent)
? (async function* () {
yield {
type: "user" as const,
session_id: "",
message: {
role: "user" as const,
content: promptContent,
},
parent_tool_use_id: null,
};
})()
: promptContent;
const stream = query({ prompt: promptPayload, options });
let currentAssistantMessage: Message | null = null; let currentAssistantMessage: Message | null = null;
let responseText = ""; let responseText = "";
@@ -245,7 +220,7 @@ export class AgentService {
for await (const msg of stream) { for await (const msg of stream) {
if (msg.type === "assistant") { if (msg.type === "assistant") {
if (msg.message.content) { if (msg.message?.content) {
for (const block of msg.message.content) { for (const block of msg.message.content) {
if (block.type === "text") { if (block.type === "text") {
responseText += block.text; responseText += block.text;
@@ -270,7 +245,7 @@ export class AgentService {
}); });
} else if (block.type === "tool_use") { } else if (block.type === "tool_use") {
const toolUse = { const toolUse = {
name: block.name, name: block.name || "unknown",
input: block.input, input: block.input,
}; };
toolUses.push(toolUse); toolUses.push(toolUse);
@@ -309,7 +284,7 @@ export class AgentService {
message: currentAssistantMessage, message: currentAssistantMessage,
}; };
} catch (error) { } catch (error) {
if (error instanceof AbortError || (error as Error)?.name === "AbortError") { if (isAbortError(error)) {
session.isRunning = false; session.isRunning = false;
session.abortController = null; session.abortController = null;
return { success: false, aborted: true }; return { success: false, aborted: true };
@@ -450,7 +425,8 @@ export class AgentService {
async createSession( async createSession(
name: string, name: string,
projectPath?: string, projectPath?: string,
workingDirectory?: string workingDirectory?: string,
model?: string
): Promise<SessionMetadata> { ): Promise<SessionMetadata> {
const sessionId = this.generateId(); const sessionId = this.generateId();
const metadata = await this.loadMetadata(); const metadata = await this.loadMetadata();
@@ -462,6 +438,7 @@ export class AgentService {
workingDirectory: workingDirectory || projectPath || process.cwd(), workingDirectory: workingDirectory || projectPath || process.cwd(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
model,
}; };
metadata[sessionId] = session; metadata[sessionId] = session;
@@ -470,6 +447,16 @@ export class AgentService {
return session; return session;
} }
async setSessionModel(sessionId: string, model: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (session) {
session.model = model;
await this.updateSession(sessionId, { model });
return true;
}
return false;
}
async updateSession( async updateSession(
sessionId: string, sessionId: string,
updates: Partial<SessionMetadata> updates: Partial<SessionMetadata>

View File

@@ -9,16 +9,17 @@
* - Verification and merge workflows * - Verification and merge workflows
*/ */
import { import { AbortError } from "@anthropic-ai/claude-agent-sdk";
query, import { ProviderFactory } from "../providers/provider-factory.js";
AbortError, import type { ExecuteOptions } from "../providers/types.js";
type Options,
} from "@anthropic-ai/claude-agent-sdk";
import { exec } from "child_process"; import { exec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import type { EventEmitter, EventType } from "../lib/events.js"; import type { EventEmitter, EventType } from "../lib/events.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -29,8 +30,9 @@ interface Feature {
steps?: string[]; steps?: string[];
status: string; status: string;
priority?: number; priority?: number;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>; spec?: string;
[key: string]: unknown; // Allow additional fields model?: string; // Model to use for this feature
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string; [key: string]: unknown }>;
} }
interface RunningFeature { interface RunningFeature {
@@ -222,17 +224,17 @@ export class AutoModeService {
const prompt = this.buildFeaturePrompt(feature); const prompt = this.buildFeaturePrompt(feature);
// Extract image paths from feature // Extract image paths from feature
const imagePaths = this.extractImagePaths(feature.imagePaths, workDir); const imagePaths = feature.imagePaths?.map((img) =>
typeof img === "string" ? img : img.path
// Run the agent with image paths
await this.runAgent(
workDir,
featureId,
prompt,
abortController,
imagePaths
); );
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`);
// Run the agent with the feature's model and images
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model);
// Mark as waiting_approval for user review // Mark as waiting_approval for user review
await this.updateFeatureStatus( await this.updateFeatureStatus(
projectPath, projectPath,
@@ -249,10 +251,9 @@ export class AutoModeService {
projectPath, projectPath,
}); });
} catch (error) { } catch (error) {
if ( const errorInfo = classifyError(error);
error instanceof AbortError ||
(error as Error)?.name === "AbortError" if (errorInfo.isAbort) {
) {
this.emitAutoModeEvent("auto_mode_feature_complete", { this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId, featureId,
passes: false, passes: false,
@@ -260,18 +261,12 @@ export class AutoModeService {
projectPath, projectPath,
}); });
} else { } else {
const errorMessage = (error as Error).message || "Unknown error";
const isAuthError =
errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed");
console.error(`[AutoMode] Feature ${featureId} failed:`, error); console.error(`[AutoMode] Feature ${featureId} failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "backlog"); await this.updateFeatureStatus(projectPath, featureId, "backlog");
this.emitAutoModeEvent("auto_mode_error", { this.emitAutoModeEvent("auto_mode_error", {
featureId, featureId,
error: errorMessage, error: errorInfo.message,
errorType: isAuthError ? "authentication" : "execution", errorType: errorInfo.isAuth ? "authentication" : "execution",
projectPath, projectPath,
}); });
} }
@@ -425,13 +420,93 @@ Address the follow-up instructions above. Review the previous work and make the
}); });
try { try {
await this.runAgent( // Get model from feature (already loaded above)
workDir, const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude);
featureId, console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`);
fullPrompt,
abortController, // Update feature status to in_progress
imagePaths await this.updateFeatureStatus(projectPath, featureId, "in_progress");
);
// Copy follow-up images to feature folder
const copiedImagePaths: string[] = [];
if (imagePaths && imagePaths.length > 0) {
const featureImagesDir = path.join(
projectPath,
".automaker",
"features",
featureId,
"images"
);
await fs.mkdir(featureImagesDir, { recursive: true });
for (const imagePath of imagePaths) {
try {
// Get the filename from the path
const filename = path.basename(imagePath);
const destPath = path.join(featureImagesDir, filename);
// Copy the image
await fs.copyFile(imagePath, destPath);
// Store the relative path (like FeatureLoader does)
const relativePath = path.join(
".automaker",
"features",
featureId,
"images",
filename
);
copiedImagePaths.push(relativePath);
} catch (error) {
console.error(`[AutoMode] Failed to copy follow-up image ${imagePath}:`, error);
}
}
}
// Update feature object with new follow-up images BEFORE building prompt
if (copiedImagePaths.length > 0 && feature) {
const currentImagePaths = feature.imagePaths || [];
const newImagePaths = copiedImagePaths.map((p) => ({
path: p,
filename: path.basename(p),
mimeType: "image/png", // Default, could be improved
}));
feature.imagePaths = [...currentImagePaths, ...newImagePaths];
}
// Combine original feature images with new follow-up images
const allImagePaths: string[] = [];
// Add all images from feature (now includes both original and new)
if (feature?.imagePaths) {
const allPaths = feature.imagePaths.map((img) =>
typeof img === "string" ? img : img.path
);
allImagePaths.push(...allPaths);
}
// Save updated feature.json with new images
if (copiedImagePaths.length > 0 && feature) {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
try {
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch (error) {
console.error(`[AutoMode] Failed to save feature.json:`, error);
}
}
// Use fullPrompt (already built above) with model and all images
await this.runAgent(workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, model);
// Mark as waiting_approval for user review // Mark as waiting_approval for user review
await this.updateFeatureStatus( await this.updateFeatureStatus(
@@ -447,7 +522,7 @@ Address the follow-up instructions above. Review the previous work and make the
projectPath, projectPath,
}); });
} catch (error) { } catch (error) {
if (!(error instanceof AbortError)) { if (!isAbortError(error)) {
this.emitAutoModeEvent("auto_mode_error", { this.emitAutoModeEvent("auto_mode_error", {
featureId, featureId,
error: (error as Error).message, error: (error as Error).message,
@@ -641,23 +716,27 @@ Address the follow-up instructions above. Review the previous work and make the
Format your response as a structured markdown document.`; Format your response as a structured markdown document.`;
try { try {
const options: Options = { // Use default Claude model for analysis (can be overridden in the future)
model: "claude-sonnet-4-20250514", const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
const provider = ProviderFactory.getProviderForModel(analysisModel);
const options: ExecuteOptions = {
prompt,
model: analysisModel,
maxTurns: 5, maxTurns: 5,
cwd: projectPath, cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"], allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController, abortController,
}; };
const stream = query({ prompt, options }); const stream = provider.executeQuery(options);
let analysisResult = ""; let analysisResult = "";
for await (const msg of stream) { for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) { if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) { for (const block of msg.message.content) {
if (block.type === "text") { if (block.type === "text") {
analysisResult = block.text; analysisResult = block.text || "";
this.emitAutoModeEvent("auto_mode_progress", { this.emitAutoModeEvent("auto_mode_progress", {
featureId: analysisFeatureId, featureId: analysisFeatureId,
content: block.text, content: block.text,
@@ -907,6 +986,34 @@ Format your response as a structured markdown document.`;
**Description:** ${feature.description} **Description:** ${feature.description}
`; `;
if (feature.spec) {
prompt += `
**Specification:**
${feature.spec}
`;
}
// Add images note (like old implementation)
if (feature.imagePaths && feature.imagePaths.length > 0) {
const imagesList = feature.imagePaths
.map((img, idx) => {
const path = typeof img === "string" ? img : img.path;
const filename = typeof img === "string" ? path.split("/").pop() : img.filename || path.split("/").pop();
const mimeType = typeof img === "string" ? "image/*" : img.mimeType || "image/*";
return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`;
})
.join("\n");
prompt += `
**📎 Context Images Attached:**
The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read:
${imagesList}
You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing.
`;
}
prompt += ` prompt += `
## Instructions ## Instructions
@@ -927,31 +1034,45 @@ When done, summarize what you implemented and any notes for the developer.`;
featureId: string, featureId: string,
prompt: string, prompt: string,
abortController: AbortController, abortController: AbortController,
imagePaths?: string[] imagePaths?: string[],
model?: string
): Promise<void> { ): Promise<void> {
const options: Options = { const finalModel = resolveModelString(model, DEFAULT_MODELS.claude);
model: "claude-opus-4-5-20251101", console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`);
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(finalModel);
console.log(
`[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"`
);
// Build prompt content with images using utility
const { content: promptContent } = await buildPromptWithImages(
prompt,
imagePaths,
workDir,
false // don't duplicate paths in text
);
const options: ExecuteOptions = {
prompt: promptContent,
model: finalModel,
maxTurns: 50, maxTurns: 50,
cwd: workDir, cwd: workDir,
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], allowedTools: [
permissionMode: "acceptEdits", "Read",
sandbox: { "Write",
enabled: true, "Edit",
autoAllowBashIfSandboxed: true, "Glob",
}, "Grep",
"Bash",
],
abortController, abortController,
}; };
// Build prompt - include image paths for the agent to read // Execute via provider
let finalPrompt = prompt; const stream = provider.executeQuery(options);
if (imagePaths && imagePaths.length > 0) {
finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths
.map((p) => `- ${p}`)
.join("\n")}`;
}
const stream = query({ prompt: finalPrompt, options });
let responseText = ""; let responseText = "";
const outputPath = path.join( const outputPath = path.join(
workDir, workDir,
@@ -962,20 +1083,18 @@ When done, summarize what you implemented and any notes for the developer.`;
); );
for await (const msg of stream) { for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) { if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) { for (const block of msg.message.content) {
if (block.type === "text") { if (block.type === "text") {
responseText = block.text; responseText = block.text || "";
// Check for authentication errors in the response // Check for authentication errors in the response
if ( if (block.text && (block.text.includes("Invalid API key") ||
block.text.includes("Invalid API key") || block.text.includes("authentication_failed") ||
block.text.includes("authentication_failed") || block.text.includes("Fix external API key"))) {
block.text.includes("Fix external API key")
) {
throw new Error( throw new Error(
"Authentication failed: Invalid or expired API key. " + "Authentication failed: Invalid or expired API key. " +
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate." "Please check your ANTHROPIC_API_KEY or GOOGLE_API_KEY, or run 'claude login' to re-authenticate."
); );
} }
@@ -991,23 +1110,10 @@ When done, summarize what you implemented and any notes for the developer.`;
}); });
} }
} }
} else if ( } else if (msg.type === "error") {
msg.type === "assistant" && // Handle error messages
(msg as { error?: string }).error === "authentication_failed" throw new Error(msg.error || "Unknown error");
) {
// Handle authentication error from the SDK
throw new Error(
"Authentication failed: Invalid or expired API key. " +
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
);
} else if (msg.type === "result" && msg.subtype === "success") { } else if (msg.type === "result" && msg.subtype === "success") {
// Check if result indicates an error
if (msg.is_error && msg.result?.includes("Invalid API key")) {
throw new Error(
"Authentication failed: Invalid or expired API key. " +
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
);
}
responseText = msg.result || responseText; responseText = msg.result || responseText;
} }
} }

17
apps/server/tests/fixtures/configs.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
/**
* Configuration fixtures for testing
*/
export const tomlConfigFixture = `
experimental_use_rmcp_client = true
[mcp_servers.automaker-tools]
command = "node"
args = ["/path/to/server.js"]
startup_timeout_sec = 10
tool_timeout_sec = 60
enabled_tools = ["UpdateFeatureStatus"]
[mcp_servers.automaker-tools.env]
AUTOMAKER_PROJECT_PATH = "/path/to/project"
`;

14
apps/server/tests/fixtures/images.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/**
* Image fixtures for testing image handling
*/
// 1x1 transparent PNG base64 data
export const pngBase64Fixture =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
export const imageDataFixture = {
base64: pngBase64Fixture,
mimeType: "image/png",
filename: "test.png",
originalPath: "/path/to/test.png",
};

39
apps/server/tests/fixtures/messages.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
/**
* Message fixtures for testing providers and lib utilities
*/
import type {
ConversationMessage,
ProviderMessage,
ContentBlock,
} from "../../src/providers/types.js";
export const conversationHistoryFixture: ConversationMessage[] = [
{
role: "user",
content: "Hello, can you help me?",
},
{
role: "assistant",
content: "Of course! How can I assist you today?",
},
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "base64data" },
},
],
},
];
export const claudeProviderMessageFixture: ProviderMessage = {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "This is a test response" }],
},
};

View File

@@ -0,0 +1,144 @@
/**
* Helper for creating test git repositories for integration tests
*/
import { exec } from "child_process";
import { promisify } from "util";
import * as fs from "fs/promises";
import * as path from "path";
import * as os from "os";
const execAsync = promisify(exec);
export interface TestRepo {
path: string;
cleanup: () => Promise<void>;
}
/**
* Create a temporary git repository for testing
*/
export async function createTestGitRepo(): Promise<TestRepo> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "automaker-test-"));
// Initialize git repo
await execAsync("git init", { cwd: tmpDir });
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
// Create initial commit
await fs.writeFile(path.join(tmpDir, "README.md"), "# Test Project\n");
await execAsync("git add .", { cwd: tmpDir });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
// Create main branch explicitly
await execAsync("git branch -M main", { cwd: tmpDir });
return {
path: tmpDir,
cleanup: async () => {
try {
// Remove all worktrees first
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: tmpDir,
}).catch(() => ({ stdout: "" }));
const worktrees = stdout
.split("\n\n")
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
return pathLine ? pathLine.replace("worktree ", "") : null;
})
.filter(Boolean);
for (const worktreePath of worktrees) {
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: tmpDir,
});
} catch (err) {
// Ignore errors
}
}
// Remove the repository
await fs.rm(tmpDir, { recursive: true, force: true });
} catch (error) {
console.error("Failed to cleanup test repo:", error);
}
},
};
}
/**
* Create a feature file in the test repo
*/
export async function createTestFeature(
repoPath: string,
featureId: string,
featureData: any
): Promise<void> {
const featuresDir = path.join(repoPath, ".automaker", "features");
const featureDir = path.join(featuresDir, featureId);
await fs.mkdir(featureDir, { recursive: true });
await fs.writeFile(
path.join(featureDir, "feature.json"),
JSON.stringify(featureData, null, 2)
);
}
/**
* Get list of git branches
*/
export async function listBranches(repoPath: string): Promise<string[]> {
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
return stdout
.split("\n")
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
.filter(Boolean);
}
/**
* Get list of git worktrees
*/
export async function listWorktrees(repoPath: string): Promise<string[]> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: repoPath,
});
return stdout
.split("\n\n")
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
return pathLine ? pathLine.replace("worktree ", "") : null;
})
.filter(Boolean) as string[];
} catch {
return [];
}
}
/**
* Check if a branch exists
*/
export async function branchExists(
repoPath: string,
branchName: string
): Promise<boolean> {
const branches = await listBranches(repoPath);
return branches.includes(branchName);
}
/**
* Check if a worktree exists
*/
export async function worktreeExists(
repoPath: string,
worktreePath: string
): Promise<boolean> {
const worktrees = await listWorktrees(repoPath);
return worktrees.some((wt) => wt === worktreePath);
}

View File

@@ -0,0 +1,537 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AutoModeService } from "@/services/auto-mode-service.js";
import { ProviderFactory } from "@/providers/provider-factory.js";
import { FeatureLoader } from "@/services/feature-loader.js";
import {
createTestGitRepo,
createTestFeature,
listBranches,
listWorktrees,
branchExists,
worktreeExists,
type TestRepo,
} from "../helpers/git-test-repo.js";
import * as fs from "fs/promises";
import * as path from "path";
vi.mock("@/providers/provider-factory.js");
describe("auto-mode-service.ts (integration)", () => {
let service: AutoModeService;
let testRepo: TestRepo;
let featureLoader: FeatureLoader;
const mockEvents = {
subscribe: vi.fn(),
emit: vi.fn(),
};
beforeEach(async () => {
vi.clearAllMocks();
service = new AutoModeService(mockEvents as any);
featureLoader = new FeatureLoader();
testRepo = await createTestGitRepo();
});
afterEach(async () => {
// Stop any running auto loops
await service.stopAutoLoop();
// Cleanup test repo
if (testRepo) {
await testRepo.cleanup();
}
});
describe("worktree operations", () => {
it("should create git worktree for feature", async () => {
// Create a test feature
await createTestFeature(testRepo.path, "test-feature-1", {
id: "test-feature-1",
category: "test",
description: "Test feature",
status: "pending",
});
// Mock provider to complete quickly
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Feature implemented" }],
},
};
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
// Execute feature with worktrees enabled
await service.executeFeature(
testRepo.path,
"test-feature-1",
true, // useWorktrees
false // isAutoMode
);
// Verify branch was created
const branches = await listBranches(testRepo.path);
expect(branches).toContain("feature/test-feature-1");
// Note: Worktrees are not automatically cleaned up by the service
// This is expected behavior - manual cleanup is required
}, 30000);
it("should handle error gracefully", async () => {
await createTestFeature(testRepo.path, "test-feature-error", {
id: "test-feature-error",
category: "test",
description: "Test feature that errors",
status: "pending",
});
// Mock provider that throws error
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
throw new Error("Provider error");
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
// Execute feature (should handle error)
await service.executeFeature(
testRepo.path,
"test-feature-error",
true,
false
);
// Verify feature status was updated to backlog (error status)
const feature = await featureLoader.get(testRepo.path, "test-feature-error");
expect(feature?.status).toBe("backlog");
}, 30000);
it("should work without worktrees", async () => {
await createTestFeature(testRepo.path, "test-no-worktree", {
id: "test-no-worktree",
category: "test",
description: "Test without worktree",
status: "pending",
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
// Execute without worktrees
await service.executeFeature(
testRepo.path,
"test-no-worktree",
false, // useWorktrees = false
false
);
// Feature should be updated successfully
const feature = await featureLoader.get(testRepo.path, "test-no-worktree");
expect(feature?.status).toBe("waiting_approval");
}, 30000);
});
describe("feature execution", () => {
it("should execute feature and update status", async () => {
await createTestFeature(testRepo.path, "feature-exec-1", {
id: "feature-exec-1",
category: "ui",
description: "Execute this feature",
status: "pending",
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Implemented the feature" }],
},
};
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
await service.executeFeature(
testRepo.path,
"feature-exec-1",
false, // Don't use worktrees so agent output is saved to main project
false
);
// Check feature status was updated
const feature = await featureLoader.get(testRepo.path, "feature-exec-1");
expect(feature?.status).toBe("waiting_approval");
// Check agent output was saved
const agentOutput = await featureLoader.getAgentOutput(
testRepo.path,
"feature-exec-1"
);
expect(agentOutput).toBeTruthy();
expect(agentOutput).toContain("Implemented the feature");
}, 30000);
it("should handle feature not found", async () => {
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
// Try to execute non-existent feature
await service.executeFeature(
testRepo.path,
"nonexistent-feature",
true,
false
);
// Should emit error event
expect(mockEvents.emit).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
featureId: "nonexistent-feature",
error: expect.stringContaining("not found"),
})
);
}, 30000);
it("should prevent duplicate feature execution", async () => {
await createTestFeature(testRepo.path, "feature-dup", {
id: "feature-dup",
category: "test",
description: "Duplicate test",
status: "pending",
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
// Simulate slow execution
await new Promise((resolve) => setTimeout(resolve, 500));
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
// Start first execution
const promise1 = service.executeFeature(
testRepo.path,
"feature-dup",
false,
false
);
// Try to start second execution (should throw)
await expect(
service.executeFeature(testRepo.path, "feature-dup", false, false)
).rejects.toThrow("already running");
await promise1;
}, 30000);
it("should use feature-specific model", async () => {
await createTestFeature(testRepo.path, "feature-model", {
id: "feature-model",
category: "test",
description: "Model test",
status: "pending",
model: "claude-sonnet-4-20250514",
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
await service.executeFeature(
testRepo.path,
"feature-model",
false,
false
);
// Should have used claude-sonnet-4-20250514
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514");
}, 30000);
});
describe("auto loop", () => {
it("should start and stop auto loop", async () => {
const startPromise = service.startAutoLoop(testRepo.path, 2);
// Give it time to start
await new Promise((resolve) => setTimeout(resolve, 100));
// Stop the loop
const runningCount = await service.stopAutoLoop();
expect(runningCount).toBe(0);
await startPromise.catch(() => {}); // Cleanup
}, 10000);
it("should process pending features in auto loop", async () => {
// Create multiple pending features
await createTestFeature(testRepo.path, "auto-1", {
id: "auto-1",
category: "test",
description: "Auto feature 1",
status: "pending",
});
await createTestFeature(testRepo.path, "auto-2", {
id: "auto-2",
category: "test",
description: "Auto feature 2",
status: "pending",
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
// Start auto loop
const startPromise = service.startAutoLoop(testRepo.path, 2);
// Wait for features to be processed
await new Promise((resolve) => setTimeout(resolve, 3000));
// Stop the loop
await service.stopAutoLoop();
await startPromise.catch(() => {});
// Check that features were updated
const feature1 = await featureLoader.get(testRepo.path, "auto-1");
const feature2 = await featureLoader.get(testRepo.path, "auto-2");
// At least one should have been processed
const processedCount = [feature1, feature2].filter(
(f) => f?.status === "waiting_approval" || f?.status === "in_progress"
).length;
expect(processedCount).toBeGreaterThan(0);
}, 15000);
it("should respect max concurrency", async () => {
// Create 5 features
for (let i = 1; i <= 5; i++) {
await createTestFeature(testRepo.path, `concurrent-${i}`, {
id: `concurrent-${i}`,
category: "test",
description: `Concurrent feature ${i}`,
status: "pending",
});
}
let concurrentCount = 0;
let maxConcurrent = 0;
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
concurrentCount++;
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
// Simulate work
await new Promise((resolve) => setTimeout(resolve, 500));
concurrentCount--;
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
// Start with max concurrency of 2
const startPromise = service.startAutoLoop(testRepo.path, 2);
// Wait for some features to be processed
await new Promise((resolve) => setTimeout(resolve, 3000));
await service.stopAutoLoop();
await startPromise.catch(() => {});
// Max concurrent should not exceed 2
expect(maxConcurrent).toBeLessThanOrEqual(2);
}, 15000);
it("should emit auto mode events", async () => {
const startPromise = service.startAutoLoop(testRepo.path, 1);
// Wait for start event
await new Promise((resolve) => setTimeout(resolve, 100));
// Check start event was emitted
const startEvent = mockEvents.emit.mock.calls.find((call) =>
call[1]?.message?.includes("Auto mode started")
);
expect(startEvent).toBeTruthy();
await service.stopAutoLoop();
await startPromise.catch(() => {});
// Check stop event was emitted (auto_mode_complete event)
const stopEvent = mockEvents.emit.mock.calls.find((call) =>
call[1]?.type === "auto_mode_complete" || call[1]?.message?.includes("stopped")
);
expect(stopEvent).toBeTruthy();
}, 10000);
});
describe("error handling", () => {
it("should handle provider errors gracefully", async () => {
await createTestFeature(testRepo.path, "error-feature", {
id: "error-feature",
category: "test",
description: "Error test",
status: "pending",
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
throw new Error("Provider execution failed");
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
// Should not throw
await service.executeFeature(
testRepo.path,
"error-feature",
true,
false
);
// Feature should be marked as backlog (error status)
const feature = await featureLoader.get(testRepo.path, "error-feature");
expect(feature?.status).toBe("backlog");
}, 30000);
it("should continue auto loop after feature error", async () => {
await createTestFeature(testRepo.path, "fail-1", {
id: "fail-1",
category: "test",
description: "Will fail",
status: "pending",
});
await createTestFeature(testRepo.path, "success-1", {
id: "success-1",
category: "test",
description: "Will succeed",
status: "pending",
});
let callCount = 0;
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
callCount++;
if (callCount === 1) {
throw new Error("First feature fails");
}
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
const startPromise = service.startAutoLoop(testRepo.path, 1);
// Wait for both features to be attempted
await new Promise((resolve) => setTimeout(resolve, 5000));
await service.stopAutoLoop();
await startPromise.catch(() => {});
// Both features should have been attempted
expect(callCount).toBeGreaterThanOrEqual(1);
}, 15000);
});
});

View File

@@ -0,0 +1,16 @@
/**
* Vitest global setup file
* Runs before each test file
*/
import { vi, beforeEach } from "vitest";
// Set test environment variables
process.env.NODE_ENV = "test";
process.env.DATA_DIR = "/tmp/test-data";
process.env.ALLOWED_PROJECT_DIRS = "/tmp/test-projects";
// Reset all mocks before each test
beforeEach(() => {
vi.clearAllMocks();
});

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createMockExpressContext } from "../../utils/mocks.js";
/**
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
* We need to reset modules and reimport for each test to get fresh state.
*/
describe("auth.ts", () => {
beforeEach(() => {
vi.resetModules();
});
describe("authMiddleware - no API key", () => {
it("should call next() when no API key is set", async () => {
delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import("@/lib/auth.js");
const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe("authMiddleware - with API key", () => {
it("should reject request without API key header", async () => {
process.env.AUTOMAKER_API_KEY = "test-secret-key";
const { authMiddleware } = await import("@/lib/auth.js");
const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Authentication required. Provide X-API-Key header.",
});
expect(next).not.toHaveBeenCalled();
});
it("should reject request with invalid API key", async () => {
process.env.AUTOMAKER_API_KEY = "test-secret-key";
const { authMiddleware } = await import("@/lib/auth.js");
const { req, res, next } = createMockExpressContext();
req.headers["x-api-key"] = "wrong-key";
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Invalid API key.",
});
expect(next).not.toHaveBeenCalled();
});
it("should call next() with valid API key", async () => {
process.env.AUTOMAKER_API_KEY = "test-secret-key";
const { authMiddleware } = await import("@/lib/auth.js");
const { req, res, next} = createMockExpressContext();
req.headers["x-api-key"] = "test-secret-key";
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe("isAuthEnabled", () => {
it("should return false when no API key is set", async () => {
delete process.env.AUTOMAKER_API_KEY;
const { isAuthEnabled } = await import("@/lib/auth.js");
expect(isAuthEnabled()).toBe(false);
});
it("should return true when API key is set", async () => {
process.env.AUTOMAKER_API_KEY = "test-key";
const { isAuthEnabled } = await import("@/lib/auth.js");
expect(isAuthEnabled()).toBe(true);
});
});
describe("getAuthStatus", () => {
it("should return disabled status when no API key", async () => {
delete process.env.AUTOMAKER_API_KEY;
const { getAuthStatus } = await import("@/lib/auth.js");
const status = getAuthStatus();
expect(status).toEqual({
enabled: false,
method: "none",
});
});
it("should return enabled status when API key is set", async () => {
process.env.AUTOMAKER_API_KEY = "test-key";
const { getAuthStatus } = await import("@/lib/auth.js");
const status = getAuthStatus();
expect(status).toEqual({
enabled: true,
method: "api_key",
});
});
});
});

View File

@@ -0,0 +1,226 @@
import { describe, it, expect } from "vitest";
import {
extractTextFromContent,
normalizeContentBlocks,
formatHistoryAsText,
convertHistoryToMessages,
} from "@/lib/conversation-utils.js";
import { conversationHistoryFixture } from "../../fixtures/messages.js";
describe("conversation-utils.ts", () => {
describe("extractTextFromContent", () => {
it("should return string content as-is", () => {
const result = extractTextFromContent("Hello world");
expect(result).toBe("Hello world");
});
it("should extract text from single text block", () => {
const content = [{ type: "text", text: "Hello" }];
const result = extractTextFromContent(content);
expect(result).toBe("Hello");
});
it("should extract and join multiple text blocks with newlines", () => {
const content = [
{ type: "text", text: "First block" },
{ type: "text", text: "Second block" },
{ type: "text", text: "Third block" },
];
const result = extractTextFromContent(content);
expect(result).toBe("First block\nSecond block\nThird block");
});
it("should ignore non-text blocks", () => {
const content = [
{ type: "text", text: "Text content" },
{ type: "image", source: { type: "base64", data: "abc" } },
{ type: "text", text: "More text" },
{ type: "tool_use", name: "bash", input: {} },
];
const result = extractTextFromContent(content);
expect(result).toBe("Text content\nMore text");
});
it("should handle blocks without text property", () => {
const content = [
{ type: "text", text: "Valid" },
{ type: "text" } as any,
{ type: "text", text: "Also valid" },
];
const result = extractTextFromContent(content);
expect(result).toBe("Valid\n\nAlso valid");
});
it("should handle empty array", () => {
const result = extractTextFromContent([]);
expect(result).toBe("");
});
it("should handle array with only non-text blocks", () => {
const content = [
{ type: "image", source: {} },
{ type: "tool_use", name: "test" },
];
const result = extractTextFromContent(content);
expect(result).toBe("");
});
});
describe("normalizeContentBlocks", () => {
it("should convert string to content block array", () => {
const result = normalizeContentBlocks("Hello");
expect(result).toEqual([{ type: "text", text: "Hello" }]);
});
it("should return array content as-is", () => {
const content = [
{ type: "text", text: "Hello" },
{ type: "image", source: {} },
];
const result = normalizeContentBlocks(content);
expect(result).toBe(content);
expect(result).toHaveLength(2);
});
it("should handle empty string", () => {
const result = normalizeContentBlocks("");
expect(result).toEqual([{ type: "text", text: "" }]);
});
});
describe("formatHistoryAsText", () => {
it("should return empty string for empty history", () => {
const result = formatHistoryAsText([]);
expect(result).toBe("");
});
it("should format single user message", () => {
const history = [{ role: "user" as const, content: "Hello" }];
const result = formatHistoryAsText(history);
expect(result).toContain("Previous conversation:");
expect(result).toContain("User: Hello");
expect(result).toContain("---");
});
it("should format single assistant message", () => {
const history = [{ role: "assistant" as const, content: "Hi there" }];
const result = formatHistoryAsText(history);
expect(result).toContain("Assistant: Hi there");
});
it("should format multiple messages with correct roles", () => {
const history = conversationHistoryFixture.slice(0, 2);
const result = formatHistoryAsText(history);
expect(result).toContain("User: Hello, can you help me?");
expect(result).toContain("Assistant: Of course! How can I assist you today?");
expect(result).toContain("---");
});
it("should handle messages with array content (multipart)", () => {
const history = [conversationHistoryFixture[2]]; // Has text + image
const result = formatHistoryAsText(history);
expect(result).toContain("What is in this image?");
expect(result).not.toContain("base64"); // Should not include image data
});
it("should format all messages from fixture", () => {
const result = formatHistoryAsText(conversationHistoryFixture);
expect(result).toContain("Previous conversation:");
expect(result).toContain("User: Hello, can you help me?");
expect(result).toContain("Assistant: Of course!");
expect(result).toContain("User: What is in this image?");
expect(result).toContain("---");
});
it("should separate messages with double newlines", () => {
const history = [
{ role: "user" as const, content: "First" },
{ role: "assistant" as const, content: "Second" },
];
const result = formatHistoryAsText(history);
expect(result).toMatch(/User: First\n\nAssistant: Second/);
});
});
describe("convertHistoryToMessages", () => {
it("should convert empty history", () => {
const result = convertHistoryToMessages([]);
expect(result).toEqual([]);
});
it("should convert single message to SDK format", () => {
const history = [{ role: "user" as const, content: "Hello" }];
const result = convertHistoryToMessages(history);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "user",
session_id: "",
message: {
role: "user",
content: [{ type: "text", text: "Hello" }],
},
parent_tool_use_id: null,
});
});
it("should normalize string content to array", () => {
const history = [{ role: "assistant" as const, content: "Response" }];
const result = convertHistoryToMessages(history);
expect(result[0].message.content).toEqual([
{ type: "text", text: "Response" },
]);
});
it("should preserve array content", () => {
const history = [
{
role: "user" as const,
content: [
{ type: "text", text: "Hello" },
{ type: "image", source: {} },
],
},
];
const result = convertHistoryToMessages(history);
expect(result[0].message.content).toHaveLength(2);
expect(result[0].message.content[0]).toEqual({ type: "text", text: "Hello" });
});
it("should convert multiple messages", () => {
const history = conversationHistoryFixture.slice(0, 2);
const result = convertHistoryToMessages(history);
expect(result).toHaveLength(2);
expect(result[0].type).toBe("user");
expect(result[1].type).toBe("assistant");
});
it("should set correct fields for SDK format", () => {
const history = [{ role: "user" as const, content: "Test" }];
const result = convertHistoryToMessages(history);
expect(result[0].session_id).toBe("");
expect(result[0].parent_tool_use_id).toBeNull();
expect(result[0].type).toBe("user");
expect(result[0].message.role).toBe("user");
});
it("should handle all messages from fixture", () => {
const result = convertHistoryToMessages(conversationHistoryFixture);
expect(result).toHaveLength(3);
expect(result[0].message.content).toBeInstanceOf(Array);
expect(result[1].message.content).toBeInstanceOf(Array);
expect(result[2].message.content).toBeInstanceOf(Array);
});
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import {
isAbortError,
isAuthenticationError,
classifyError,
getUserFriendlyErrorMessage,
type ErrorType,
} from "@/lib/error-handler.js";
describe("error-handler.ts", () => {
describe("isAbortError", () => {
it("should detect AbortError by error name", () => {
const error = new Error("Operation cancelled");
error.name = "AbortError";
expect(isAbortError(error)).toBe(true);
});
it("should detect abort error by message content", () => {
const error = new Error("Request was aborted");
expect(isAbortError(error)).toBe(true);
});
it("should return false for non-abort errors", () => {
const error = new Error("Something else went wrong");
expect(isAbortError(error)).toBe(false);
});
it("should return false for non-Error objects", () => {
expect(isAbortError("not an error")).toBe(false);
expect(isAbortError(null)).toBe(false);
expect(isAbortError(undefined)).toBe(false);
});
});
describe("isAuthenticationError", () => {
it("should detect 'Authentication failed' message", () => {
expect(isAuthenticationError("Authentication failed")).toBe(true);
});
it("should detect 'Invalid API key' message", () => {
expect(isAuthenticationError("Invalid API key provided")).toBe(true);
});
it("should detect 'authentication_failed' message", () => {
expect(isAuthenticationError("authentication_failed")).toBe(true);
});
it("should detect 'Fix external API key' message", () => {
expect(isAuthenticationError("Fix external API key configuration")).toBe(true);
});
it("should return false for non-authentication errors", () => {
expect(isAuthenticationError("Network connection error")).toBe(false);
expect(isAuthenticationError("File not found")).toBe(false);
});
it("should be case sensitive", () => {
expect(isAuthenticationError("authentication Failed")).toBe(false);
});
});
describe("classifyError", () => {
it("should classify authentication errors", () => {
const error = new Error("Authentication failed");
const result = classifyError(error);
expect(result.type).toBe("authentication");
expect(result.isAuth).toBe(true);
expect(result.isAbort).toBe(false);
expect(result.message).toBe("Authentication failed");
expect(result.originalError).toBe(error);
});
it("should classify abort errors", () => {
const error = new Error("Operation aborted");
error.name = "AbortError";
const result = classifyError(error);
expect(result.type).toBe("abort");
expect(result.isAbort).toBe(true);
expect(result.isAuth).toBe(false);
expect(result.message).toBe("Operation aborted");
});
it("should prioritize auth over abort if both match", () => {
const error = new Error("Authentication failed and aborted");
const result = classifyError(error);
expect(result.type).toBe("authentication");
expect(result.isAuth).toBe(true);
expect(result.isAbort).toBe(true); // Still detected as abort too
});
it("should classify generic Error as execution error", () => {
const error = new Error("Something went wrong");
const result = classifyError(error);
expect(result.type).toBe("execution");
expect(result.isAuth).toBe(false);
expect(result.isAbort).toBe(false);
});
it("should classify non-Error objects as unknown", () => {
const error = "string error";
const result = classifyError(error);
expect(result.type).toBe("unknown");
expect(result.message).toBe("string error");
});
it("should handle null and undefined", () => {
const nullResult = classifyError(null);
expect(nullResult.type).toBe("unknown");
expect(nullResult.message).toBe("Unknown error");
const undefinedResult = classifyError(undefined);
expect(undefinedResult.type).toBe("unknown");
expect(undefinedResult.message).toBe("Unknown error");
});
});
describe("getUserFriendlyErrorMessage", () => {
it("should return friendly message for abort errors", () => {
const error = new Error("abort");
const result = getUserFriendlyErrorMessage(error);
expect(result).toBe("Operation was cancelled");
});
it("should return friendly message for authentication errors", () => {
const error = new Error("Authentication failed");
const result = getUserFriendlyErrorMessage(error);
expect(result).toBe("Authentication failed. Please check your API key.");
});
it("should return original message for other errors", () => {
const error = new Error("File not found");
const result = getUserFriendlyErrorMessage(error);
expect(result).toBe("File not found");
});
it("should handle non-Error objects", () => {
const result = getUserFriendlyErrorMessage("Custom error");
expect(result).toBe("Custom error");
});
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from "vitest";
import { createEventEmitter, type EventType } from "@/lib/events.js";
describe("events.ts", () => {
describe("createEventEmitter", () => {
it("should emit events to single subscriber", () => {
const emitter = createEventEmitter();
const callback = vi.fn();
emitter.subscribe(callback);
emitter.emit("agent:stream", { message: "test" });
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent:stream", { message: "test" });
});
it("should emit events to multiple subscribers", () => {
const emitter = createEventEmitter();
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
emitter.subscribe(callback1);
emitter.subscribe(callback2);
emitter.subscribe(callback3);
emitter.emit("feature:started", { id: "123" });
expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce();
expect(callback3).toHaveBeenCalledOnce();
expect(callback1).toHaveBeenCalledWith("feature:started", { id: "123" });
});
it("should support unsubscribe functionality", () => {
const emitter = createEventEmitter();
const callback = vi.fn();
const unsubscribe = emitter.subscribe(callback);
emitter.emit("agent:stream", { test: 1 });
expect(callback).toHaveBeenCalledOnce();
unsubscribe();
emitter.emit("agent:stream", { test: 2 });
expect(callback).toHaveBeenCalledOnce(); // Still called only once
});
it("should handle errors in subscribers without crashing", () => {
const emitter = createEventEmitter();
const errorCallback = vi.fn(() => {
throw new Error("Subscriber error");
});
const normalCallback = vi.fn();
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
emitter.subscribe(errorCallback);
emitter.subscribe(normalCallback);
expect(() => {
emitter.emit("feature:error", { error: "test" });
}).not.toThrow();
expect(errorCallback).toHaveBeenCalledOnce();
expect(normalCallback).toHaveBeenCalledOnce();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should emit different event types", () => {
const emitter = createEventEmitter();
const callback = vi.fn();
emitter.subscribe(callback);
const eventTypes: EventType[] = [
"agent:stream",
"auto-mode:started",
"feature:completed",
"project:analysis-progress",
];
eventTypes.forEach((type) => {
emitter.emit(type, { type });
});
expect(callback).toHaveBeenCalledTimes(4);
});
it("should handle emitting without subscribers", () => {
const emitter = createEventEmitter();
expect(() => {
emitter.emit("agent:stream", { test: true });
}).not.toThrow();
});
it("should allow multiple subscriptions and unsubscriptions", () => {
const emitter = createEventEmitter();
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
const unsub1 = emitter.subscribe(callback1);
const unsub2 = emitter.subscribe(callback2);
const unsub3 = emitter.subscribe(callback3);
emitter.emit("feature:started", { test: 1 });
expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce();
expect(callback3).toHaveBeenCalledOnce();
unsub2();
emitter.emit("feature:started", { test: 2 });
expect(callback1).toHaveBeenCalledTimes(2);
expect(callback2).toHaveBeenCalledOnce(); // Still just once
expect(callback3).toHaveBeenCalledTimes(2);
unsub1();
unsub3();
emitter.emit("feature:started", { test: 3 });
expect(callback1).toHaveBeenCalledTimes(2);
expect(callback2).toHaveBeenCalledOnce();
expect(callback3).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
getMimeTypeForImage,
readImageAsBase64,
convertImagesToContentBlocks,
formatImagePathsForPrompt,
} from "@/lib/image-handler.js";
import { pngBase64Fixture } from "../../fixtures/images.js";
import * as fs from "fs/promises";
vi.mock("fs/promises");
describe("image-handler.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getMimeTypeForImage", () => {
it("should return correct MIME type for .jpg", () => {
expect(getMimeTypeForImage("test.jpg")).toBe("image/jpeg");
expect(getMimeTypeForImage("/path/to/test.jpg")).toBe("image/jpeg");
});
it("should return correct MIME type for .jpeg", () => {
expect(getMimeTypeForImage("test.jpeg")).toBe("image/jpeg");
});
it("should return correct MIME type for .png", () => {
expect(getMimeTypeForImage("test.png")).toBe("image/png");
});
it("should return correct MIME type for .gif", () => {
expect(getMimeTypeForImage("test.gif")).toBe("image/gif");
});
it("should return correct MIME type for .webp", () => {
expect(getMimeTypeForImage("test.webp")).toBe("image/webp");
});
it("should be case-insensitive", () => {
expect(getMimeTypeForImage("test.PNG")).toBe("image/png");
expect(getMimeTypeForImage("test.JPG")).toBe("image/jpeg");
expect(getMimeTypeForImage("test.GIF")).toBe("image/gif");
expect(getMimeTypeForImage("test.WEBP")).toBe("image/webp");
});
it("should default to image/png for unknown extensions", () => {
expect(getMimeTypeForImage("test.unknown")).toBe("image/png");
expect(getMimeTypeForImage("test.txt")).toBe("image/png");
expect(getMimeTypeForImage("test")).toBe("image/png");
});
it("should handle paths with multiple dots", () => {
expect(getMimeTypeForImage("my.image.file.jpg")).toBe("image/jpeg");
});
});
describe("readImageAsBase64", () => {
it("should read image and return base64 data", async () => {
const mockBuffer = Buffer.from(pngBase64Fixture, "base64");
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
const result = await readImageAsBase64("/path/to/test.png");
expect(result).toMatchObject({
base64: pngBase64Fixture,
mimeType: "image/png",
filename: "test.png",
originalPath: "/path/to/test.png",
});
expect(fs.readFile).toHaveBeenCalledWith("/path/to/test.png");
});
it("should handle different image formats", async () => {
const mockBuffer = Buffer.from("jpeg-data");
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
const result = await readImageAsBase64("/path/to/photo.jpg");
expect(result.mimeType).toBe("image/jpeg");
expect(result.filename).toBe("photo.jpg");
expect(result.base64).toBe(mockBuffer.toString("base64"));
});
it("should extract filename from path", async () => {
const mockBuffer = Buffer.from("data");
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
const result = await readImageAsBase64("/deep/nested/path/image.webp");
expect(result.filename).toBe("image.webp");
});
it("should throw error if file cannot be read", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"));
await expect(readImageAsBase64("/nonexistent.png")).rejects.toThrow(
"File not found"
);
});
});
describe("convertImagesToContentBlocks", () => {
it("should convert single image to content block", async () => {
const mockBuffer = Buffer.from(pngBase64Fixture, "base64");
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
const result = await convertImagesToContentBlocks(["/path/test.png"]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: pngBase64Fixture,
},
});
});
it("should convert multiple images to content blocks", async () => {
const mockBuffer = Buffer.from("test-data");
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
const result = await convertImagesToContentBlocks([
"/a.png",
"/b.jpg",
"/c.webp",
]);
expect(result).toHaveLength(3);
expect(result[0].source.media_type).toBe("image/png");
expect(result[1].source.media_type).toBe("image/jpeg");
expect(result[2].source.media_type).toBe("image/webp");
});
it("should resolve relative paths with workDir", async () => {
const mockBuffer = Buffer.from("data");
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
await convertImagesToContentBlocks(["relative.png"], "/work/dir");
// Use path-agnostic check since Windows uses backslashes
const calls = vi.mocked(fs.readFile).mock.calls;
expect(calls[0][0]).toMatch(/relative\.png$/);
expect(calls[0][0]).toContain("work");
expect(calls[0][0]).toContain("dir");
});
it("should handle absolute paths without workDir", async () => {
const mockBuffer = Buffer.from("data");
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
await convertImagesToContentBlocks(["/absolute/path.png"]);
expect(fs.readFile).toHaveBeenCalledWith("/absolute/path.png");
});
it("should continue processing on individual image errors", async () => {
vi.mocked(fs.readFile)
.mockResolvedValueOnce(Buffer.from("ok1"))
.mockRejectedValueOnce(new Error("Failed"))
.mockResolvedValueOnce(Buffer.from("ok2"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await convertImagesToContentBlocks([
"/a.png",
"/b.png",
"/c.png",
]);
expect(result).toHaveLength(2); // Only successful images
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should return empty array for empty input", async () => {
const result = await convertImagesToContentBlocks([]);
expect(result).toEqual([]);
});
it("should handle undefined workDir", async () => {
const mockBuffer = Buffer.from("data");
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
const result = await convertImagesToContentBlocks(["/test.png"], undefined);
expect(result).toHaveLength(1);
expect(fs.readFile).toHaveBeenCalledWith("/test.png");
});
});
describe("formatImagePathsForPrompt", () => {
it("should format single image path as bulleted list", () => {
const result = formatImagePathsForPrompt(["/path/image.png"]);
expect(result).toContain("\n\nAttached images:");
expect(result).toContain("- /path/image.png");
});
it("should format multiple image paths as bulleted list", () => {
const result = formatImagePathsForPrompt([
"/path/a.png",
"/path/b.jpg",
"/path/c.webp",
]);
expect(result).toContain("Attached images:");
expect(result).toContain("- /path/a.png");
expect(result).toContain("- /path/b.jpg");
expect(result).toContain("- /path/c.webp");
});
it("should return empty string for empty array", () => {
const result = formatImagePathsForPrompt([]);
expect(result).toBe("");
});
it("should start with double newline", () => {
const result = formatImagePathsForPrompt(["/test.png"]);
expect(result.startsWith("\n\n")).toBe(true);
});
it("should handle paths with special characters", () => {
const result = formatImagePathsForPrompt(["/path/with spaces/image.png"]);
expect(result).toContain("- /path/with spaces/image.png");
});
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
resolveModelString,
getEffectiveModel,
CLAUDE_MODEL_MAP,
DEFAULT_MODELS,
} from "@/lib/model-resolver.js";
describe("model-resolver.ts", () => {
let consoleSpy: any;
beforeEach(() => {
consoleSpy = {
log: vi.spyOn(console, "log").mockImplementation(() => {}),
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
};
});
afterEach(() => {
consoleSpy.log.mockRestore();
consoleSpy.warn.mockRestore();
});
describe("resolveModelString", () => {
it("should resolve 'haiku' alias to full model string", () => {
const result = resolveModelString("haiku");
expect(result).toBe("claude-haiku-4-5");
});
it("should resolve 'sonnet' alias to full model string", () => {
const result = resolveModelString("sonnet");
expect(result).toBe("claude-sonnet-4-20250514");
});
it("should resolve 'opus' alias to full model string", () => {
const result = resolveModelString("opus");
expect(result).toBe("claude-opus-4-5-20251101");
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('Resolved model alias: "opus"')
);
});
it("should treat unknown models as falling back to default", () => {
const models = ["o1", "o1-mini", "o3", "gpt-5.2", "unknown-model"];
models.forEach((model) => {
const result = resolveModelString(model);
// Should fall back to default since these aren't supported
expect(result).toBe(DEFAULT_MODELS.claude);
});
});
it("should pass through full Claude model strings", () => {
const models = [
"claude-opus-4-5-20251101",
"claude-sonnet-4-20250514",
"claude-haiku-4-5",
];
models.forEach((model) => {
const result = resolveModelString(model);
expect(result).toBe(model);
});
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Using full Claude model string")
);
});
it("should return default model when modelKey is undefined", () => {
const result = resolveModelString(undefined);
expect(result).toBe(DEFAULT_MODELS.claude);
});
it("should return custom default model when provided", () => {
const customDefault = "custom-model";
const result = resolveModelString(undefined, customDefault);
expect(result).toBe(customDefault);
});
it("should return default for unknown model key", () => {
const result = resolveModelString("unknown-model");
expect(result).toBe(DEFAULT_MODELS.claude);
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining('Unknown model key "unknown-model"')
);
});
it("should handle empty string", () => {
const result = resolveModelString("");
expect(result).toBe(DEFAULT_MODELS.claude);
});
});
describe("getEffectiveModel", () => {
it("should prioritize explicit model over session and default", () => {
const result = getEffectiveModel("opus", "haiku", "gpt-5.2");
expect(result).toBe("claude-opus-4-5-20251101");
});
it("should use session model when explicit is not provided", () => {
const result = getEffectiveModel(undefined, "sonnet", "gpt-5.2");
expect(result).toBe("claude-sonnet-4-20250514");
});
it("should use default when neither explicit nor session is provided", () => {
const customDefault = "claude-haiku-4-5";
const result = getEffectiveModel(undefined, undefined, customDefault);
expect(result).toBe(customDefault);
});
it("should use Claude default when no arguments provided", () => {
const result = getEffectiveModel();
expect(result).toBe(DEFAULT_MODELS.claude);
});
it("should handle explicit empty strings as undefined", () => {
const result = getEffectiveModel("", "haiku");
expect(result).toBe("claude-haiku-4-5");
});
});
describe("CLAUDE_MODEL_MAP", () => {
it("should have haiku, sonnet, opus mappings", () => {
expect(CLAUDE_MODEL_MAP).toHaveProperty("haiku");
expect(CLAUDE_MODEL_MAP).toHaveProperty("sonnet");
expect(CLAUDE_MODEL_MAP).toHaveProperty("opus");
});
it("should have valid Claude model strings", () => {
expect(CLAUDE_MODEL_MAP.haiku).toContain("haiku");
expect(CLAUDE_MODEL_MAP.sonnet).toContain("sonnet");
expect(CLAUDE_MODEL_MAP.opus).toContain("opus");
});
});
describe("DEFAULT_MODELS", () => {
it("should have claude default", () => {
expect(DEFAULT_MODELS).toHaveProperty("claude");
});
it("should have valid default model", () => {
expect(DEFAULT_MODELS.claude).toContain("claude");
});
});
});

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { buildPromptWithImages } from "@/lib/prompt-builder.js";
import * as imageHandler from "@/lib/image-handler.js";
vi.mock("@/lib/image-handler.js");
describe("prompt-builder.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("buildPromptWithImages", () => {
it("should return plain text when no images provided", async () => {
const result = await buildPromptWithImages("Hello world");
expect(result).toEqual({
content: "Hello world",
hasImages: false,
});
});
it("should return plain text when imagePaths is empty array", async () => {
const result = await buildPromptWithImages("Hello world", []);
expect(result).toEqual({
content: "Hello world",
hasImages: false,
});
});
it("should build content blocks with single image", async () => {
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "base64data" },
},
]);
const result = await buildPromptWithImages("Describe this image", [
"/test.png",
]);
expect(result.hasImages).toBe(true);
expect(Array.isArray(result.content)).toBe(true);
const content = result.content as Array<any>;
expect(content).toHaveLength(2);
expect(content[0]).toEqual({ type: "text", text: "Describe this image" });
expect(content[1].type).toBe("image");
});
it("should build content blocks with multiple images", async () => {
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "data1" },
},
{
type: "image",
source: { type: "base64", media_type: "image/jpeg", data: "data2" },
},
]);
const result = await buildPromptWithImages("Analyze these", [
"/a.png",
"/b.jpg",
]);
expect(result.hasImages).toBe(true);
const content = result.content as Array<any>;
expect(content).toHaveLength(3); // 1 text + 2 images
expect(content[0].type).toBe("text");
expect(content[1].type).toBe("image");
expect(content[2].type).toBe("image");
});
it("should include image paths in text when requested", async () => {
vi.mocked(imageHandler.formatImagePathsForPrompt).mockReturnValue(
"\n\nAttached images:\n- /test.png"
);
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "data" },
},
]);
const result = await buildPromptWithImages(
"Base prompt",
["/test.png"],
undefined,
true
);
expect(imageHandler.formatImagePathsForPrompt).toHaveBeenCalledWith([
"/test.png",
]);
const content = result.content as Array<any>;
expect(content[0].text).toContain("Base prompt");
expect(content[0].text).toContain("Attached images:");
});
it("should not include image paths by default", async () => {
vi.mocked(imageHandler.formatImagePathsForPrompt).mockReturnValue(
"\n\nAttached images:\n- /test.png"
);
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "data" },
},
]);
const result = await buildPromptWithImages("Base prompt", ["/test.png"]);
expect(imageHandler.formatImagePathsForPrompt).not.toHaveBeenCalled();
const content = result.content as Array<any>;
expect(content[0].text).toBe("Base prompt");
});
it("should pass workDir to convertImagesToContentBlocks", async () => {
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "data" },
},
]);
await buildPromptWithImages("Test", ["/test.png"], "/work/dir");
expect(imageHandler.convertImagesToContentBlocks).toHaveBeenCalledWith(
["/test.png"],
"/work/dir"
);
});
it("should handle empty text content", async () => {
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "data" },
},
]);
const result = await buildPromptWithImages("", ["/test.png"]);
expect(result.hasImages).toBe(true);
// When text is empty/whitespace, should only have image blocks
const content = result.content as Array<any>;
expect(content.every((block) => block.type === "image")).toBe(true);
});
it("should trim text content before checking if empty", async () => {
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "data" },
},
]);
const result = await buildPromptWithImages(" ", ["/test.png"]);
const content = result.content as Array<any>;
// Whitespace-only text should be excluded
expect(content.every((block) => block.type === "image")).toBe(true);
});
it("should return text when only one block and it's text", async () => {
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([]);
const result = await buildPromptWithImages("Just text", ["/missing.png"]);
// If no images are successfully loaded, should return just the text
expect(result.content).toBe("Just text");
expect(result.hasImages).toBe(true); // Still true because images were requested
});
it("should handle workDir with relative paths", async () => {
vi.mocked(imageHandler.convertImagesToContentBlocks).mockResolvedValue([
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "data" },
},
]);
await buildPromptWithImages(
"Test",
["relative.png"],
"/absolute/work/dir"
);
expect(imageHandler.convertImagesToContentBlocks).toHaveBeenCalledWith(
["relative.png"],
"/absolute/work/dir"
);
});
});
});

View File

@@ -0,0 +1,297 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import path from "path";
/**
* Note: security.ts maintains module-level state (allowed paths Set).
* We need to reset modules and reimport for each test to get fresh state.
*/
describe("security.ts", () => {
beforeEach(() => {
vi.resetModules();
});
describe("initAllowedPaths", () => {
it("should parse comma-separated directories from environment", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
process.env.DATA_DIR = "";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/path1"));
expect(allowed).toContain(path.resolve("/path2"));
expect(allowed).toContain(path.resolve("/path3"));
});
it("should trim whitespace from paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = " /path1 , /path2 , /path3 ";
process.env.DATA_DIR = "";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/path1"));
expect(allowed).toContain(path.resolve("/path2"));
});
it("should always include DATA_DIR if set", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
process.env.DATA_DIR = "/data/dir";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/data/dir"));
});
it("should handle empty ALLOWED_PROJECT_DIRS", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
process.env.DATA_DIR = "/data";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toHaveLength(1);
expect(allowed[0]).toBe(path.resolve("/data"));
});
it("should skip empty entries in comma list", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,,/path2, ,/path3";
process.env.DATA_DIR = "";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toHaveLength(3);
});
});
describe("addAllowedPath", () => {
it("should add path to allowed list", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
process.env.DATA_DIR = "";
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
await import("@/lib/security.js");
initAllowedPaths();
addAllowedPath("/new/path");
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/new/path"));
});
it("should resolve relative paths before adding", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
process.env.DATA_DIR = "";
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
await import("@/lib/security.js");
initAllowedPaths();
addAllowedPath("./relative/path");
const allowed = getAllowedPaths();
const cwd = process.cwd();
expect(allowed).toContain(path.resolve(cwd, "./relative/path"));
});
});
describe("isPathAllowed", () => {
it("should allow paths under allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/allowed/project/file.txt")).toBe(true);
expect(isPathAllowed("/allowed/project/subdir/file.txt")).toBe(true);
expect(isPathAllowed("/allowed/project/deep/nested/file.txt")).toBe(true);
});
it("should allow the exact allowed directory", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/allowed/project")).toBe(true);
});
it("should reject paths outside allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/not/allowed/file.txt")).toBe(false);
expect(isPathAllowed("/tmp/file.txt")).toBe(false);
expect(isPathAllowed("/etc/passwd")).toBe(false);
});
it("should block path traversal attempts", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
// These should resolve outside the allowed directory
expect(isPathAllowed("/allowed/project/../../../etc/passwd")).toBe(false);
expect(isPathAllowed("/allowed/project/../../other/file.txt")).toBe(false);
});
it("should resolve relative paths correctly", async () => {
const cwd = process.cwd();
process.env.ALLOWED_PROJECT_DIRS = cwd;
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("./file.txt")).toBe(true);
expect(isPathAllowed("./subdir/file.txt")).toBe(true);
});
it("should reject paths that are parents of allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project/subdir";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/allowed/project")).toBe(false);
expect(isPathAllowed("/allowed")).toBe(false);
});
it("should handle multiple allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/path1/file.txt")).toBe(true);
expect(isPathAllowed("/path2/file.txt")).toBe(true);
expect(isPathAllowed("/path3/file.txt")).toBe(true);
expect(isPathAllowed("/path4/file.txt")).toBe(false);
});
});
describe("validatePath", () => {
it("should return resolved path for allowed paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.DATA_DIR = "";
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const result = validatePath("/allowed/file.txt");
expect(result).toBe(path.resolve("/allowed/file.txt"));
});
it("should throw error for disallowed paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.DATA_DIR = "";
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(() => validatePath("/disallowed/file.txt")).toThrow("Access denied");
expect(() => validatePath("/disallowed/file.txt")).toThrow(
"not in an allowed directory"
);
});
it("should include the file path in error message", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.DATA_DIR = "";
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(() => validatePath("/bad/path.txt")).toThrow("/bad/path.txt");
});
it("should resolve paths before validation", async () => {
const cwd = process.cwd();
process.env.ALLOWED_PROJECT_DIRS = cwd;
process.env.DATA_DIR = "";
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const result = validatePath("./file.txt");
expect(result).toBe(path.resolve(cwd, "./file.txt"));
});
});
describe("getAllowedPaths", () => {
it("should return array of allowed paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2";
process.env.DATA_DIR = "/data";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const result = getAllowedPaths();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
});
it("should return resolved paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/test";
process.env.DATA_DIR = "";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const result = getAllowedPaths();
expect(result[0]).toBe(path.resolve("/test"));
});
});
});

View File

@@ -0,0 +1,482 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
spawnJSONLProcess,
spawnProcess,
type SubprocessOptions,
} from "@/lib/subprocess-manager.js";
import * as cp from "child_process";
import { EventEmitter } from "events";
import { Readable } from "stream";
import { collectAsyncGenerator } from "../../utils/helpers.js";
vi.mock("child_process");
describe("subprocess-manager.ts", () => {
let consoleSpy: any;
beforeEach(() => {
vi.clearAllMocks();
consoleSpy = {
log: vi.spyOn(console, "log").mockImplementation(() => {}),
error: vi.spyOn(console, "error").mockImplementation(() => {}),
};
});
afterEach(() => {
consoleSpy.log.mockRestore();
consoleSpy.error.mockRestore();
});
/**
* Helper to create a mock ChildProcess with stdout/stderr streams
*/
function createMockProcess(config: {
stdoutLines?: string[];
stderrLines?: string[];
exitCode?: number;
error?: Error;
delayMs?: number;
}) {
const mockProcess = new EventEmitter() as any;
// Create readable streams for stdout and stderr
const stdout = new Readable({ read() {} });
const stderr = new Readable({ read() {} });
mockProcess.stdout = stdout;
mockProcess.stderr = stderr;
mockProcess.kill = vi.fn();
// Use process.nextTick to ensure readline interface is set up first
process.nextTick(() => {
// Emit stderr lines immediately
if (config.stderrLines) {
for (const line of config.stderrLines) {
stderr.emit("data", Buffer.from(line));
}
}
// Emit stdout lines with small delays to ensure readline processes them
const emitLines = async () => {
if (config.stdoutLines) {
for (const line of config.stdoutLines) {
stdout.push(line + "\n");
// Small delay to allow readline to process
await new Promise((resolve) => setImmediate(resolve));
}
}
// Small delay before ending stream
await new Promise((resolve) => setImmediate(resolve));
stdout.push(null); // End stdout
// Small delay before exit
await new Promise((resolve) =>
setTimeout(resolve, config.delayMs ?? 10)
);
// Emit exit or error
if (config.error) {
mockProcess.emit("error", config.error);
} else {
mockProcess.emit("exit", config.exitCode ?? 0);
}
};
emitLines();
});
return mockProcess;
}
describe("spawnJSONLProcess", () => {
const baseOptions: SubprocessOptions = {
command: "test-command",
args: ["arg1", "arg2"],
cwd: "/test/dir",
};
it("should yield parsed JSONL objects line by line", async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"start","id":1}',
'{"type":"progress","value":50}',
'{"type":"complete","result":"success"}',
],
exitCode: 0,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(3);
expect(results[0]).toEqual({ type: "start", id: 1 });
expect(results[1]).toEqual({ type: "progress", value: 50 });
expect(results[2]).toEqual({ type: "complete", result: "success" });
});
it("should skip empty lines", async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"first"}',
"",
" ",
'{"type":"second"}',
],
exitCode: 0,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: "first" });
expect(results[1]).toEqual({ type: "second" });
});
it("should yield error for malformed JSON and continue processing", async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"valid"}',
'{invalid json}',
'{"type":"also_valid"}',
],
exitCode: 0,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(3);
expect(results[0]).toEqual({ type: "valid" });
expect(results[1]).toMatchObject({
type: "error",
error: expect.stringContaining("Failed to parse output"),
});
expect(results[2]).toEqual({ type: "also_valid" });
});
it("should collect stderr output", async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"test"}'],
stderrLines: ["Warning: something happened", "Error: critical issue"],
exitCode: 0,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
await collectAsyncGenerator(generator);
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Warning: something happened")
);
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Error: critical issue")
);
});
it("should yield error on non-zero exit code", async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"started"}'],
stderrLines: ["Process failed with error"],
exitCode: 1,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: "started" });
expect(results[1]).toMatchObject({
type: "error",
error: expect.stringContaining("Process failed with error"),
});
});
it("should yield error with exit code when stderr is empty", async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"test"}'],
exitCode: 127,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[1]).toMatchObject({
type: "error",
error: "Process exited with code 127",
});
});
it("should handle process spawn errors", async () => {
const mockProcess = createMockProcess({
error: new Error("Command not found"),
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
const results = await collectAsyncGenerator(generator);
// When process.on('error') fires, exitCode is null
// The generator should handle this gracefully
expect(results).toEqual([]);
});
it("should kill process on AbortController signal", async () => {
const abortController = new AbortController();
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"start"}'],
exitCode: 0,
delayMs: 100, // Delay to allow abort
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess({
...baseOptions,
abortController,
});
// Start consuming the generator
const promise = collectAsyncGenerator(generator);
// Abort after a short delay
setTimeout(() => abortController.abort(), 20);
await promise;
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Abort signal received")
);
});
// Note: Timeout behavior tests are omitted from unit tests as they involve
// complex timing interactions that are difficult to mock reliably.
// These scenarios are better covered by integration tests with real subprocesses.
it("should spawn process with correct arguments", async () => {
const mockProcess = createMockProcess({ exitCode: 0 });
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const options: SubprocessOptions = {
command: "my-command",
args: ["--flag", "value"],
cwd: "/work/dir",
env: { CUSTOM_VAR: "test" },
};
const generator = spawnJSONLProcess(options);
await collectAsyncGenerator(generator);
expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], {
cwd: "/work/dir",
env: expect.objectContaining({ CUSTOM_VAR: "test" }),
stdio: ["ignore", "pipe", "pipe"],
});
});
it("should merge env with process.env", async () => {
const mockProcess = createMockProcess({ exitCode: 0 });
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const options: SubprocessOptions = {
command: "test",
args: [],
cwd: "/test",
env: { CUSTOM: "value" },
};
const generator = spawnJSONLProcess(options);
await collectAsyncGenerator(generator);
expect(cp.spawn).toHaveBeenCalledWith(
"test",
[],
expect.objectContaining({
env: expect.objectContaining({
CUSTOM: "value",
// Should also include existing process.env
NODE_ENV: process.env.NODE_ENV,
}),
})
);
});
it("should handle complex JSON objects", async () => {
const complexObject = {
type: "complex",
nested: { deep: { value: [1, 2, 3] } },
array: [{ id: 1 }, { id: 2 }],
string: "with \"quotes\" and \\backslashes",
};
const mockProcess = createMockProcess({
stdoutLines: [JSON.stringify(complexObject)],
exitCode: 0,
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const generator = spawnJSONLProcess(baseOptions);
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(1);
expect(results[0]).toEqual(complexObject);
});
});
describe("spawnProcess", () => {
const baseOptions: SubprocessOptions = {
command: "test-command",
args: ["arg1"],
cwd: "/test",
};
it("should collect stdout and stderr", async () => {
const mockProcess = new EventEmitter() as any;
const stdout = new Readable({ read() {} });
const stderr = new Readable({ read() {} });
mockProcess.stdout = stdout;
mockProcess.stderr = stderr;
mockProcess.kill = vi.fn();
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
stdout.push("line 1\n");
stdout.push("line 2\n");
stdout.push(null);
stderr.push("error 1\n");
stderr.push("error 2\n");
stderr.push(null);
mockProcess.emit("exit", 0);
}, 10);
const result = await spawnProcess(baseOptions);
expect(result.stdout).toBe("line 1\nline 2\n");
expect(result.stderr).toBe("error 1\nerror 2\n");
expect(result.exitCode).toBe(0);
});
it("should return correct exit code", async () => {
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new Readable({ read() {} });
mockProcess.stderr = new Readable({ read() {} });
mockProcess.kill = vi.fn();
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 42);
}, 10);
const result = await spawnProcess(baseOptions);
expect(result.exitCode).toBe(42);
});
it("should handle process errors", async () => {
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new Readable({ read() {} });
mockProcess.stderr = new Readable({ read() {} });
mockProcess.kill = vi.fn();
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
mockProcess.emit("error", new Error("Spawn failed"));
}, 10);
await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed");
});
it("should handle AbortController signal", async () => {
const abortController = new AbortController();
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new Readable({ read() {} });
mockProcess.stderr = new Readable({ read() {} });
mockProcess.kill = vi.fn();
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => abortController.abort(), 20);
await expect(
spawnProcess({ ...baseOptions, abortController })
).rejects.toThrow("Process aborted");
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
});
it("should spawn with correct options", async () => {
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new Readable({ read() {} });
mockProcess.stderr = new Readable({ read() {} });
mockProcess.kill = vi.fn();
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 0);
}, 10);
const options: SubprocessOptions = {
command: "my-cmd",
args: ["--verbose"],
cwd: "/my/dir",
env: { MY_VAR: "value" },
};
await spawnProcess(options);
expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], {
cwd: "/my/dir",
env: expect.objectContaining({ MY_VAR: "value" }),
stdio: ["ignore", "pipe", "pipe"],
});
});
it("should handle empty stdout and stderr", async () => {
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new Readable({ read() {} });
mockProcess.stderr = new Readable({ read() {} });
mockProcess.kill = vi.fn();
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 0);
}, 10);
const result = await spawnProcess(baseOptions);
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
expect(result.exitCode).toBe(0);
});
});
});

View File

@@ -0,0 +1,242 @@
import { describe, it, expect } from "vitest";
import { BaseProvider } from "@/providers/base-provider.js";
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from "@/providers/types.js";
// Concrete implementation for testing the abstract class
class TestProvider extends BaseProvider {
getName(): string {
return "test-provider";
}
async *executeQuery(
_options: ExecuteOptions
): AsyncGenerator<ProviderMessage> {
yield { type: "text", text: "test response" };
}
async detectInstallation(): Promise<InstallationStatus> {
return { installed: true };
}
getAvailableModels(): ModelDefinition[] {
return [
{ id: "test-model-1", name: "Test Model 1", description: "A test model" },
];
}
}
describe("base-provider.ts", () => {
describe("constructor", () => {
it("should initialize with empty config when none provided", () => {
const provider = new TestProvider();
expect(provider.getConfig()).toEqual({});
});
it("should initialize with provided config", () => {
const config: ProviderConfig = {
apiKey: "test-key",
baseUrl: "https://test.com",
};
const provider = new TestProvider(config);
expect(provider.getConfig()).toEqual(config);
});
it("should call getName() during initialization", () => {
const provider = new TestProvider();
expect(provider.getName()).toBe("test-provider");
});
});
describe("validateConfig", () => {
it("should return valid when config exists", () => {
const provider = new TestProvider({ apiKey: "test" });
const result = provider.validateConfig();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
it("should return invalid when config is undefined", () => {
// Create provider without config
const provider = new TestProvider();
// Manually set config to undefined to test edge case
(provider as any).config = undefined;
const result = provider.validateConfig();
expect(result.valid).toBe(false);
expect(result.errors).toContain("Provider config is missing");
});
it("should return valid for empty config object", () => {
const provider = new TestProvider({});
const result = provider.validateConfig();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should include warnings array in result", () => {
const provider = new TestProvider();
const result = provider.validateConfig();
expect(result).toHaveProperty("warnings");
expect(Array.isArray(result.warnings)).toBe(true);
});
});
describe("supportsFeature", () => {
it("should support 'tools' feature", () => {
const provider = new TestProvider();
expect(provider.supportsFeature("tools")).toBe(true);
});
it("should support 'text' feature", () => {
const provider = new TestProvider();
expect(provider.supportsFeature("text")).toBe(true);
});
it("should not support unknown features", () => {
const provider = new TestProvider();
expect(provider.supportsFeature("vision")).toBe(false);
expect(provider.supportsFeature("mcp")).toBe(false);
expect(provider.supportsFeature("unknown")).toBe(false);
});
it("should be case-sensitive", () => {
const provider = new TestProvider();
expect(provider.supportsFeature("TOOLS")).toBe(false);
expect(provider.supportsFeature("Text")).toBe(false);
});
});
describe("getConfig", () => {
it("should return current config", () => {
const config: ProviderConfig = {
apiKey: "test-key",
model: "test-model",
};
const provider = new TestProvider(config);
expect(provider.getConfig()).toEqual(config);
});
it("should return same reference", () => {
const config: ProviderConfig = { apiKey: "test" };
const provider = new TestProvider(config);
const retrieved1 = provider.getConfig();
const retrieved2 = provider.getConfig();
expect(retrieved1).toBe(retrieved2);
});
});
describe("setConfig", () => {
it("should merge partial config with existing config", () => {
const provider = new TestProvider({ apiKey: "original-key" });
provider.setConfig({ model: "new-model" });
expect(provider.getConfig()).toEqual({
apiKey: "original-key",
model: "new-model",
});
});
it("should override existing fields", () => {
const provider = new TestProvider({ apiKey: "old-key", model: "old-model" });
provider.setConfig({ apiKey: "new-key" });
expect(provider.getConfig()).toEqual({
apiKey: "new-key",
model: "old-model",
});
});
it("should accept empty object", () => {
const provider = new TestProvider({ apiKey: "test" });
const originalConfig = provider.getConfig();
provider.setConfig({});
expect(provider.getConfig()).toEqual(originalConfig);
});
it("should handle multiple updates", () => {
const provider = new TestProvider();
provider.setConfig({ apiKey: "key1" });
provider.setConfig({ model: "model1" });
provider.setConfig({ baseUrl: "https://test.com" });
expect(provider.getConfig()).toEqual({
apiKey: "key1",
model: "model1",
baseUrl: "https://test.com",
});
});
it("should preserve other fields when updating one field", () => {
const provider = new TestProvider({
apiKey: "key",
model: "model",
baseUrl: "https://test.com",
});
provider.setConfig({ model: "new-model" });
expect(provider.getConfig()).toEqual({
apiKey: "key",
model: "new-model",
baseUrl: "https://test.com",
});
});
});
describe("abstract methods", () => {
it("should require getName implementation", () => {
const provider = new TestProvider();
expect(typeof provider.getName).toBe("function");
expect(provider.getName()).toBe("test-provider");
});
it("should require executeQuery implementation", async () => {
const provider = new TestProvider();
expect(typeof provider.executeQuery).toBe("function");
const generator = provider.executeQuery({
prompt: "test",
projectDirectory: "/test",
});
const result = await generator.next();
expect(result.value).toEqual({ type: "text", text: "test response" });
});
it("should require detectInstallation implementation", async () => {
const provider = new TestProvider();
expect(typeof provider.detectInstallation).toBe("function");
const status = await provider.detectInstallation();
expect(status).toHaveProperty("installed");
});
it("should require getAvailableModels implementation", () => {
const provider = new TestProvider();
expect(typeof provider.getAvailableModels).toBe("function");
const models = provider.getAvailableModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,398 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ClaudeProvider } from "@/providers/claude-provider.js";
import * as sdk from "@anthropic-ai/claude-agent-sdk";
import { collectAsyncGenerator } from "../../utils/helpers.js";
vi.mock("@anthropic-ai/claude-agent-sdk");
describe("claude-provider.ts", () => {
let provider: ClaudeProvider;
beforeEach(() => {
vi.clearAllMocks();
provider = new ClaudeProvider();
delete process.env.ANTHROPIC_API_KEY;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
});
describe("getName", () => {
it("should return 'claude' as provider name", () => {
expect(provider.getName()).toBe("claude");
});
});
describe("executeQuery", () => {
it("should execute simple text query", async () => {
const mockMessages = [
{ type: "text", text: "Response 1" },
{ type: "text", text: "Response 2" },
];
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
for (const msg of mockMessages) {
yield msg;
}
})()
);
const generator = provider.executeQuery({
prompt: "Hello",
cwd: "/test",
});
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: "text", text: "Response 1" });
expect(results[1]).toEqual({ type: "text", text: "Response 2" });
});
it("should pass correct options to SDK", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const generator = provider.executeQuery({
prompt: "Test prompt",
model: "claude-opus-4-5-20251101",
cwd: "/test/dir",
systemPrompt: "You are helpful",
maxTurns: 10,
allowedTools: ["Read", "Write"],
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test prompt",
options: expect.objectContaining({
model: "claude-opus-4-5-20251101",
systemPrompt: "You are helpful",
maxTurns: 10,
cwd: "/test/dir",
allowedTools: ["Read", "Write"],
permissionMode: "acceptEdits",
}),
});
});
it("should use default allowed tools when not specified", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test",
options: expect.objectContaining({
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
}),
});
});
it("should enable sandbox by default", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test",
options: expect.objectContaining({
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
});
});
it("should pass abortController if provided", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const abortController = new AbortController();
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
abortController,
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test",
options: expect.objectContaining({
abortController,
}),
});
});
it("should handle conversation history", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const conversationHistory = [
{ role: "user" as const, content: "Previous message" },
{ role: "assistant" as const, content: "Previous response" },
];
const generator = provider.executeQuery({
prompt: "Current message",
cwd: "/test",
conversationHistory,
});
await collectAsyncGenerator(generator);
// Should pass an async generator as prompt
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
expect(typeof callArgs.prompt).not.toBe("string");
});
it("should handle array prompt (with images)", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const arrayPrompt = [
{ type: "text", text: "Describe this" },
{ type: "image", source: { type: "base64", data: "..." } },
];
const generator = provider.executeQuery({
prompt: arrayPrompt as any,
cwd: "/test",
});
await collectAsyncGenerator(generator);
// Should pass an async generator as prompt for array inputs
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
expect(typeof callArgs.prompt).not.toBe("string");
});
it("should use maxTurns default of 20", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test",
options: expect.objectContaining({
maxTurns: 20,
}),
});
});
});
describe("detectInstallation", () => {
it("should return installed with SDK method", async () => {
const result = await provider.detectInstallation();
expect(result.installed).toBe(true);
expect(result.method).toBe("sdk");
});
it("should detect ANTHROPIC_API_KEY", async () => {
process.env.ANTHROPIC_API_KEY = "test-key";
const result = await provider.detectInstallation();
expect(result.hasApiKey).toBe(true);
expect(result.authenticated).toBe(true);
});
it("should detect CLAUDE_CODE_OAUTH_TOKEN", async () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-token";
const result = await provider.detectInstallation();
expect(result.hasApiKey).toBe(true);
expect(result.authenticated).toBe(true);
});
it("should return hasApiKey false when no keys present", async () => {
const result = await provider.detectInstallation();
expect(result.hasApiKey).toBe(false);
expect(result.authenticated).toBe(false);
});
});
describe("getAvailableModels", () => {
it("should return 4 Claude models", () => {
const models = provider.getAvailableModels();
expect(models).toHaveLength(4);
});
it("should include Claude Opus 4.5", () => {
const models = provider.getAvailableModels();
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
expect(opus).toBeDefined();
expect(opus?.name).toBe("Claude Opus 4.5");
expect(opus?.provider).toBe("anthropic");
});
it("should include Claude Sonnet 4", () => {
const models = provider.getAvailableModels();
const sonnet = models.find((m) => m.id === "claude-sonnet-4-20250514");
expect(sonnet).toBeDefined();
expect(sonnet?.name).toBe("Claude Sonnet 4");
});
it("should include Claude 3.5 Sonnet", () => {
const models = provider.getAvailableModels();
const sonnet35 = models.find(
(m) => m.id === "claude-3-5-sonnet-20241022"
);
expect(sonnet35).toBeDefined();
});
it("should include Claude 3.5 Haiku", () => {
const models = provider.getAvailableModels();
const haiku = models.find((m) => m.id === "claude-3-5-haiku-20241022");
expect(haiku).toBeDefined();
});
it("should mark Opus as default", () => {
const models = provider.getAvailableModels();
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
expect(opus?.default).toBe(true);
});
it("should all support vision and tools", () => {
const models = provider.getAvailableModels();
models.forEach((model) => {
expect(model.supportsVision).toBe(true);
expect(model.supportsTools).toBe(true);
});
});
it("should have correct context windows", () => {
const models = provider.getAvailableModels();
models.forEach((model) => {
expect(model.contextWindow).toBe(200000);
});
});
it("should have modelString field matching id", () => {
const models = provider.getAvailableModels();
models.forEach((model) => {
expect(model.modelString).toBe(model.id);
});
});
});
describe("supportsFeature", () => {
it("should support 'tools' feature", () => {
expect(provider.supportsFeature("tools")).toBe(true);
});
it("should support 'text' feature", () => {
expect(provider.supportsFeature("text")).toBe(true);
});
it("should support 'vision' feature", () => {
expect(provider.supportsFeature("vision")).toBe(true);
});
it("should support 'thinking' feature", () => {
expect(provider.supportsFeature("thinking")).toBe(true);
});
it("should not support 'mcp' feature", () => {
expect(provider.supportsFeature("mcp")).toBe(false);
});
it("should not support 'cli' feature", () => {
expect(provider.supportsFeature("cli")).toBe(false);
});
it("should not support unknown features", () => {
expect(provider.supportsFeature("unknown")).toBe(false);
});
});
describe("validateConfig", () => {
it("should validate config from base class", () => {
const result = provider.validateConfig();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
describe("config management", () => {
it("should get and set config", () => {
provider.setConfig({ apiKey: "test-key" });
const config = provider.getConfig();
expect(config.apiKey).toBe("test-key");
});
it("should merge config updates", () => {
provider.setConfig({ apiKey: "key1" });
provider.setConfig({ model: "model1" });
const config = provider.getConfig();
expect(config.apiKey).toBe("key1");
expect(config.model).toBe("model1");
});
});
});

View File

@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ProviderFactory } from "@/providers/provider-factory.js";
import { ClaudeProvider } from "@/providers/claude-provider.js";
describe("provider-factory.ts", () => {
let consoleSpy: any;
beforeEach(() => {
consoleSpy = {
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
};
});
afterEach(() => {
consoleSpy.warn.mockRestore();
});
describe("getProviderForModel", () => {
describe("Claude models (claude-* prefix)", () => {
it("should return ClaudeProvider for claude-opus-4-5-20251101", () => {
const provider = ProviderFactory.getProviderForModel(
"claude-opus-4-5-20251101"
);
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for claude-sonnet-4-20250514", () => {
const provider = ProviderFactory.getProviderForModel(
"claude-sonnet-4-20250514"
);
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for claude-haiku-4-5", () => {
const provider = ProviderFactory.getProviderForModel("claude-haiku-4-5");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should be case-insensitive for claude models", () => {
const provider = ProviderFactory.getProviderForModel(
"CLAUDE-OPUS-4-5-20251101"
);
expect(provider).toBeInstanceOf(ClaudeProvider);
});
});
describe("Claude aliases", () => {
it("should return ClaudeProvider for 'haiku'", () => {
const provider = ProviderFactory.getProviderForModel("haiku");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for 'sonnet'", () => {
const provider = ProviderFactory.getProviderForModel("sonnet");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for 'opus'", () => {
const provider = ProviderFactory.getProviderForModel("opus");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should be case-insensitive for aliases", () => {
const provider1 = ProviderFactory.getProviderForModel("HAIKU");
const provider2 = ProviderFactory.getProviderForModel("Sonnet");
const provider3 = ProviderFactory.getProviderForModel("Opus");
expect(provider1).toBeInstanceOf(ClaudeProvider);
expect(provider2).toBeInstanceOf(ClaudeProvider);
expect(provider3).toBeInstanceOf(ClaudeProvider);
});
});
describe("Unknown models", () => {
it("should default to ClaudeProvider for unknown model", () => {
const provider = ProviderFactory.getProviderForModel("unknown-model-123");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should warn when defaulting to Claude", () => {
ProviderFactory.getProviderForModel("random-model");
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("Unknown model prefix")
);
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("random-model")
);
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("defaulting to Claude")
);
});
it("should handle empty string", () => {
const provider = ProviderFactory.getProviderForModel("");
expect(provider).toBeInstanceOf(ClaudeProvider);
expect(consoleSpy.warn).toHaveBeenCalled();
});
it("should default to ClaudeProvider for gpt models (not supported)", () => {
const provider = ProviderFactory.getProviderForModel("gpt-5.2");
expect(provider).toBeInstanceOf(ClaudeProvider);
expect(consoleSpy.warn).toHaveBeenCalled();
});
it("should default to ClaudeProvider for o-series models (not supported)", () => {
const provider = ProviderFactory.getProviderForModel("o1");
expect(provider).toBeInstanceOf(ClaudeProvider);
expect(consoleSpy.warn).toHaveBeenCalled();
});
});
});
describe("getAllProviders", () => {
it("should return array of all providers", () => {
const providers = ProviderFactory.getAllProviders();
expect(Array.isArray(providers)).toBe(true);
});
it("should include ClaudeProvider", () => {
const providers = ProviderFactory.getAllProviders();
const hasClaudeProvider = providers.some(
(p) => p instanceof ClaudeProvider
);
expect(hasClaudeProvider).toBe(true);
});
it("should return exactly 1 provider", () => {
const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(1);
});
it("should create new instances each time", () => {
const providers1 = ProviderFactory.getAllProviders();
const providers2 = ProviderFactory.getAllProviders();
expect(providers1[0]).not.toBe(providers2[0]);
});
});
describe("checkAllProviders", () => {
it("should return installation status for all providers", async () => {
const statuses = await ProviderFactory.checkAllProviders();
expect(statuses).toHaveProperty("claude");
});
it("should call detectInstallation on each provider", async () => {
const statuses = await ProviderFactory.checkAllProviders();
expect(statuses.claude).toHaveProperty("installed");
});
it("should return correct provider names as keys", async () => {
const statuses = await ProviderFactory.checkAllProviders();
const keys = Object.keys(statuses);
expect(keys).toContain("claude");
expect(keys).toHaveLength(1);
});
});
describe("getProviderByName", () => {
it("should return ClaudeProvider for 'claude'", () => {
const provider = ProviderFactory.getProviderByName("claude");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for 'anthropic'", () => {
const provider = ProviderFactory.getProviderByName("anthropic");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should be case-insensitive", () => {
const provider1 = ProviderFactory.getProviderByName("CLAUDE");
const provider2 = ProviderFactory.getProviderByName("ANTHROPIC");
expect(provider1).toBeInstanceOf(ClaudeProvider);
expect(provider2).toBeInstanceOf(ClaudeProvider);
});
it("should return null for unknown provider", () => {
const provider = ProviderFactory.getProviderByName("unknown");
expect(provider).toBeNull();
});
it("should return null for empty string", () => {
const provider = ProviderFactory.getProviderByName("");
expect(provider).toBeNull();
});
it("should create new instance each time", () => {
const provider1 = ProviderFactory.getProviderByName("claude");
const provider2 = ProviderFactory.getProviderByName("claude");
expect(provider1).not.toBe(provider2);
expect(provider1).toBeInstanceOf(ClaudeProvider);
expect(provider2).toBeInstanceOf(ClaudeProvider);
});
});
describe("getAllAvailableModels", () => {
it("should return array of models", () => {
const models = ProviderFactory.getAllAvailableModels();
expect(Array.isArray(models)).toBe(true);
});
it("should include models from all providers", () => {
const models = ProviderFactory.getAllAvailableModels();
expect(models.length).toBeGreaterThan(0);
});
it("should return models with required fields", () => {
const models = ProviderFactory.getAllAvailableModels();
models.forEach((model) => {
expect(model).toHaveProperty("id");
expect(model).toHaveProperty("name");
expect(typeof model.id).toBe("string");
expect(typeof model.name).toBe("string");
});
});
it("should include Claude models", () => {
const models = ProviderFactory.getAllAvailableModels();
// Claude models should include claude-* in their IDs
const hasClaudeModels = models.some((m) =>
m.id.toLowerCase().includes("claude")
);
expect(hasClaudeModels).toBe(true);
});
});
});

View File

@@ -0,0 +1,361 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AgentService } from "@/services/agent-service.js";
import { ProviderFactory } from "@/providers/provider-factory.js";
import * as fs from "fs/promises";
import * as imageHandler from "@/lib/image-handler.js";
import * as promptBuilder from "@/lib/prompt-builder.js";
import { collectAsyncGenerator } from "../../utils/helpers.js";
vi.mock("fs/promises");
vi.mock("@/providers/provider-factory.js");
vi.mock("@/lib/image-handler.js");
vi.mock("@/lib/prompt-builder.js");
describe("agent-service.ts", () => {
let service: AgentService;
const mockEvents = {
subscribe: vi.fn(),
emit: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
service = new AgentService("/test/data", mockEvents as any);
});
describe("initialize", () => {
it("should create state directory", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.initialize();
expect(fs.mkdir).toHaveBeenCalledWith(
expect.stringContaining("agent-sessions"),
{ recursive: true }
);
});
});
describe("startConversation", () => {
it("should create new session with empty messages", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await service.startConversation({
sessionId: "session-1",
workingDirectory: "/test/dir",
});
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
expect(result.sessionId).toBe("session-1");
});
it("should load existing session", async () => {
const existingMessages = [
{
id: "msg-1",
role: "user",
content: "Hello",
timestamp: "2024-01-01T00:00:00Z",
},
];
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify(existingMessages)
);
const result = await service.startConversation({
sessionId: "session-1",
workingDirectory: "/test/dir",
});
expect(result.success).toBe(true);
expect(result.messages).toEqual(existingMessages);
});
it("should use process.cwd() if no working directory provided", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await service.startConversation({
sessionId: "session-1",
});
expect(result.success).toBe(true);
});
it("should reuse existing session if already started", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
// Start session first time
await service.startConversation({
sessionId: "session-1",
});
// Start again with same ID
const result = await service.startConversation({
sessionId: "session-1",
});
expect(result.success).toBe(true);
// Should only read file once
expect(fs.readFile).toHaveBeenCalledTimes(1);
});
});
describe("sendMessage", () => {
beforeEach(async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: "session-1",
workingDirectory: "/test/dir",
});
});
it("should throw if session not found", async () => {
await expect(
service.sendMessage({
sessionId: "nonexistent",
message: "Hello",
})
).rejects.toThrow("Session nonexistent not found");
});
it("should process message and stream responses", async () => {
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Response" }],
},
};
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Hello",
hasImages: false,
});
const result = await service.sendMessage({
sessionId: "session-1",
message: "Hello",
workingDirectory: "/custom/dir",
});
expect(result.success).toBe(true);
expect(mockEvents.emit).toHaveBeenCalled();
});
it("should handle images in message", async () => {
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(imageHandler.readImageAsBase64).mockResolvedValue({
base64: "base64data",
mimeType: "image/png",
filename: "test.png",
originalPath: "/path/test.png",
});
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Check image",
hasImages: true,
});
await service.sendMessage({
sessionId: "session-1",
message: "Check this",
imagePaths: ["/path/test.png"],
});
expect(imageHandler.readImageAsBase64).toHaveBeenCalledWith(
"/path/test.png"
);
});
it("should handle failed image loading gracefully", async () => {
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(imageHandler.readImageAsBase64).mockRejectedValue(
new Error("Image not found")
);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Check image",
hasImages: false,
});
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await service.sendMessage({
sessionId: "session-1",
message: "Check this",
imagePaths: ["/path/test.png"],
});
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should use custom model if provided", async () => {
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Hello",
hasImages: false,
});
await service.sendMessage({
sessionId: "session-1",
message: "Hello",
model: "claude-sonnet-4-20250514",
});
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith("claude-sonnet-4-20250514");
});
it("should save session messages", async () => {
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: "Hello",
hasImages: false,
});
await service.sendMessage({
sessionId: "session-1",
message: "Hello",
});
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe("stopExecution", () => {
it("should stop execution for a session", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
await service.startConversation({
sessionId: "session-1",
});
// Should return success
const result = await service.stopExecution("session-1");
expect(result.success).toBeDefined();
});
});
describe("getHistory", () => {
it("should return message history", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
await service.startConversation({
sessionId: "session-1",
});
const history = service.getHistory("session-1");
expect(history).toBeDefined();
expect(history?.messages).toEqual([]);
});
it("should handle non-existent session", () => {
const history = service.getHistory("nonexistent");
expect(history).toBeDefined(); // Returns error object
});
});
describe("clearSession", () => {
it("should clear session messages", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: "session-1",
});
await service.clearSession("session-1");
const history = service.getHistory("session-1");
expect(history?.messages).toEqual([]);
expect(fs.writeFile).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AutoModeService } from "@/services/auto-mode-service.js";
describe("auto-mode-service.ts", () => {
let service: AutoModeService;
const mockEvents = {
subscribe: vi.fn(),
emit: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
service = new AutoModeService(mockEvents as any);
});
describe("constructor", () => {
it("should initialize with event emitter", () => {
expect(service).toBeDefined();
});
});
describe("startAutoLoop", () => {
it("should throw if auto mode is already running", async () => {
// Start first loop
const promise1 = service.startAutoLoop("/test/project", 3);
// Try to start second loop
await expect(
service.startAutoLoop("/test/project", 3)
).rejects.toThrow("already running");
// Cleanup
await service.stopAutoLoop();
await promise1.catch(() => {});
});
it("should emit auto mode start event", async () => {
const promise = service.startAutoLoop("/test/project", 3);
// Give it time to emit the event
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockEvents.emit).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
message: expect.stringContaining("Auto mode started"),
})
);
// Cleanup
await service.stopAutoLoop();
await promise.catch(() => {});
});
});
describe("stopAutoLoop", () => {
it("should stop the auto loop", async () => {
const promise = service.startAutoLoop("/test/project", 3);
const runningCount = await service.stopAutoLoop();
expect(runningCount).toBe(0);
await promise.catch(() => {});
});
it("should return 0 when not running", async () => {
const runningCount = await service.stopAutoLoop();
expect(runningCount).toBe(0);
});
});
});

View File

@@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FeatureLoader } from "@/services/feature-loader.js";
import * as fs from "fs/promises";
import path from "path";
vi.mock("fs/promises");
describe("feature-loader.ts", () => {
let loader: FeatureLoader;
const testProjectPath = "/test/project";
beforeEach(() => {
vi.clearAllMocks();
loader = new FeatureLoader();
});
describe("getFeaturesDir", () => {
it("should return features directory path", () => {
const result = loader.getFeaturesDir(testProjectPath);
expect(result).toContain("test");
expect(result).toContain("project");
expect(result).toContain(".automaker");
expect(result).toContain("features");
});
});
describe("getFeatureImagesDir", () => {
it("should return feature images directory path", () => {
const result = loader.getFeatureImagesDir(testProjectPath, "feature-123");
expect(result).toContain("features");
expect(result).toContain("feature-123");
expect(result).toContain("images");
});
});
describe("getFeatureDir", () => {
it("should return feature directory path", () => {
const result = loader.getFeatureDir(testProjectPath, "feature-123");
expect(result).toContain("features");
expect(result).toContain("feature-123");
});
});
describe("getFeatureJsonPath", () => {
it("should return feature.json path", () => {
const result = loader.getFeatureJsonPath(testProjectPath, "feature-123");
expect(result).toContain("features");
expect(result).toContain("feature-123");
expect(result).toContain("feature.json");
});
});
describe("getAgentOutputPath", () => {
it("should return agent-output.md path", () => {
const result = loader.getAgentOutputPath(testProjectPath, "feature-123");
expect(result).toContain("features");
expect(result).toContain("feature-123");
expect(result).toContain("agent-output.md");
});
});
describe("generateFeatureId", () => {
it("should generate unique feature ID with timestamp", () => {
const id1 = loader.generateFeatureId();
const id2 = loader.generateFeatureId();
expect(id1).toMatch(/^feature-\d+-[a-z0-9]+$/);
expect(id2).toMatch(/^feature-\d+-[a-z0-9]+$/);
expect(id1).not.toBe(id2);
});
it("should start with 'feature-'", () => {
const id = loader.generateFeatureId();
expect(id).toMatch(/^feature-/);
});
});
describe("getAll", () => {
it("should return empty array when features directory doesn't exist", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
const result = await loader.getAll(testProjectPath);
expect(result).toEqual([]);
});
it("should load all features from feature directories", async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-1", isDirectory: () => true } as any,
{ name: "feature-2", isDirectory: () => true } as any,
{ name: "file.txt", isDirectory: () => false } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-1",
category: "ui",
description: "Feature 1",
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-2",
category: "backend",
description: "Feature 2",
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("feature-1");
expect(result[1].id).toBe("feature-2");
});
it("should skip features without id field", async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-1", isDirectory: () => true } as any,
{ name: "feature-2", isDirectory: () => true } as any,
]);
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
category: "ui",
description: "Missing ID",
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-2",
category: "backend",
description: "Feature 2",
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("feature-2");
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("missing required 'id' field")
);
consoleSpy.mockRestore();
});
it("should skip features with missing feature.json", async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-1", isDirectory: () => true } as any,
{ name: "feature-2", isDirectory: () => true } as any,
]);
const error: any = new Error("File not found");
error.code = "ENOENT";
vi.mocked(fs.readFile)
.mockRejectedValueOnce(error)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-2",
category: "backend",
description: "Feature 2",
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("feature-2");
});
it("should handle malformed JSON gracefully", async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-1", isDirectory: () => true } as any,
]);
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.mocked(fs.readFile).mockResolvedValue("invalid json{");
const result = await loader.getAll(testProjectPath);
expect(result).toEqual([]);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should sort features by creation order (timestamp)", async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "feature-3", isDirectory: () => true } as any,
{ name: "feature-1", isDirectory: () => true } as any,
{ name: "feature-2", isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-3000-xyz",
category: "ui",
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-1000-abc",
category: "ui",
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: "feature-2000-def",
category: "ui",
})
);
const result = await loader.getAll(testProjectPath);
expect(result).toHaveLength(3);
expect(result[0].id).toBe("feature-1000-abc");
expect(result[1].id).toBe("feature-2000-def");
expect(result[2].id).toBe("feature-3000-xyz");
});
});
describe("get", () => {
it("should return feature by ID", async () => {
const featureData = {
id: "feature-123",
category: "ui",
description: "Test feature",
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(featureData));
const result = await loader.get(testProjectPath, "feature-123");
expect(result).toEqual(featureData);
});
it("should return null when feature doesn't exist", async () => {
const error: any = new Error("File not found");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await loader.get(testProjectPath, "feature-123");
expect(result).toBeNull();
});
it("should throw on other errors", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
await expect(
loader.get(testProjectPath, "feature-123")
).rejects.toThrow("Permission denied");
});
});
describe("create", () => {
it("should create new feature", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const featureData = {
category: "ui",
description: "New feature",
};
const result = await loader.create(testProjectPath, featureData);
expect(result).toMatchObject({
category: "ui",
description: "New feature",
id: expect.stringMatching(/^feature-/),
});
expect(fs.writeFile).toHaveBeenCalled();
});
it("should use provided ID if given", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await loader.create(testProjectPath, {
id: "custom-id",
category: "ui",
description: "Test",
});
expect(result.id).toBe("custom-id");
});
it("should set default category if not provided", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await loader.create(testProjectPath, {
description: "Test",
});
expect(result.category).toBe("Uncategorized");
});
});
describe("update", () => {
it("should update existing feature", async () => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
id: "feature-123",
category: "ui",
description: "Old description",
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await loader.update(testProjectPath, "feature-123", {
description: "New description",
});
expect(result.description).toBe("New description");
expect(result.category).toBe("ui");
expect(fs.writeFile).toHaveBeenCalled();
});
it("should throw if feature doesn't exist", async () => {
const error: any = new Error("File not found");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
await expect(
loader.update(testProjectPath, "feature-123", {})
).rejects.toThrow("not found");
});
});
describe("delete", () => {
it("should delete feature directory", async () => {
vi.mocked(fs.rm).mockResolvedValue(undefined);
const result = await loader.delete(testProjectPath, "feature-123");
expect(result).toBe(true);
expect(fs.rm).toHaveBeenCalledWith(
expect.stringContaining("feature-123"),
{ recursive: true, force: true }
);
});
it("should return false on error", async () => {
vi.mocked(fs.rm).mockRejectedValue(new Error("Permission denied"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await loader.delete(testProjectPath, "feature-123");
expect(result).toBe(false);
consoleSpy.mockRestore();
});
});
describe("getAgentOutput", () => {
it("should return agent output content", async () => {
vi.mocked(fs.readFile).mockResolvedValue("Agent output content");
const result = await loader.getAgentOutput(testProjectPath, "feature-123");
expect(result).toBe("Agent output content");
});
it("should return null when file doesn't exist", async () => {
const error: any = new Error("File not found");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await loader.getAgentOutput(testProjectPath, "feature-123");
expect(result).toBeNull();
});
it("should throw on other errors", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
await expect(
loader.getAgentOutput(testProjectPath, "feature-123")
).rejects.toThrow("Permission denied");
});
});
describe("saveAgentOutput", () => {
it("should save agent output to file", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await loader.saveAgentOutput(
testProjectPath,
"feature-123",
"Output content"
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining("agent-output.md"),
"Output content",
"utf-8"
);
});
});
describe("deleteAgentOutput", () => {
it("should delete agent output file", async () => {
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await loader.deleteAgentOutput(testProjectPath, "feature-123");
expect(fs.unlink).toHaveBeenCalledWith(
expect.stringContaining("agent-output.md")
);
});
it("should handle missing file gracefully", async () => {
const error: any = new Error("File not found");
error.code = "ENOENT";
vi.mocked(fs.unlink).mockRejectedValue(error);
// Should not throw
await expect(
loader.deleteAgentOutput(testProjectPath, "feature-123")
).resolves.toBeUndefined();
});
it("should throw on other errors", async () => {
vi.mocked(fs.unlink).mockRejectedValue(new Error("Permission denied"));
await expect(
loader.deleteAgentOutput(testProjectPath, "feature-123")
).rejects.toThrow("Permission denied");
});
});
});

View File

@@ -0,0 +1,567 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TerminalService, getTerminalService } from "@/services/terminal-service.js";
import * as pty from "node-pty";
import * as os from "os";
import * as fs from "fs";
vi.mock("node-pty");
vi.mock("fs");
vi.mock("os");
describe("terminal-service.ts", () => {
let service: TerminalService;
let mockPtyProcess: any;
beforeEach(() => {
vi.clearAllMocks();
service = new TerminalService();
// Mock PTY process
mockPtyProcess = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess);
vi.mocked(os.homedir).mockReturnValue("/home/user");
vi.mocked(os.platform).mockReturnValue("linux");
vi.mocked(os.arch).mockReturnValue("x64");
});
afterEach(() => {
service.cleanup();
});
describe("detectShell", () => {
it("should detect PowerShell Core on Windows when available", () => {
vi.mocked(os.platform).mockReturnValue("win32");
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
return path === "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
});
const result = service.detectShell();
expect(result.shell).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe");
expect(result.args).toEqual([]);
});
it("should fall back to PowerShell on Windows if Core not available", () => {
vi.mocked(os.platform).mockReturnValue("win32");
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
return path === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
});
const result = service.detectShell();
expect(result.shell).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
expect(result.args).toEqual([]);
});
it("should fall back to cmd.exe on Windows if no PowerShell", () => {
vi.mocked(os.platform).mockReturnValue("win32");
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = service.detectShell();
expect(result.shell).toBe("cmd.exe");
expect(result.args).toEqual([]);
});
it("should detect user shell on macOS", () => {
vi.mocked(os.platform).mockReturnValue("darwin");
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/zsh" });
vi.mocked(fs.existsSync).mockReturnValue(true);
const result = service.detectShell();
expect(result.shell).toBe("/bin/zsh");
expect(result.args).toEqual(["--login"]);
});
it("should fall back to zsh on macOS if user shell not available", () => {
vi.mocked(os.platform).mockReturnValue("darwin");
vi.spyOn(process, "env", "get").mockReturnValue({});
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
return path === "/bin/zsh";
});
const result = service.detectShell();
expect(result.shell).toBe("/bin/zsh");
expect(result.args).toEqual(["--login"]);
});
it("should fall back to bash on macOS if zsh not available", () => {
vi.mocked(os.platform).mockReturnValue("darwin");
vi.spyOn(process, "env", "get").mockReturnValue({});
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = service.detectShell();
expect(result.shell).toBe("/bin/bash");
expect(result.args).toEqual(["--login"]);
});
it("should detect user shell on Linux", () => {
vi.mocked(os.platform).mockReturnValue("linux");
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
vi.mocked(fs.existsSync).mockReturnValue(true);
const result = service.detectShell();
expect(result.shell).toBe("/bin/bash");
expect(result.args).toEqual(["--login"]);
});
it("should fall back to bash on Linux if user shell not available", () => {
vi.mocked(os.platform).mockReturnValue("linux");
vi.spyOn(process, "env", "get").mockReturnValue({});
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
return path === "/bin/bash";
});
const result = service.detectShell();
expect(result.shell).toBe("/bin/bash");
expect(result.args).toEqual(["--login"]);
});
it("should fall back to sh on Linux if bash not available", () => {
vi.mocked(os.platform).mockReturnValue("linux");
vi.spyOn(process, "env", "get").mockReturnValue({});
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = service.detectShell();
expect(result.shell).toBe("/bin/sh");
expect(result.args).toEqual([]);
});
it("should detect WSL and use appropriate shell", () => {
vi.mocked(os.platform).mockReturnValue("linux");
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2");
const result = service.detectShell();
expect(result.shell).toBe("/bin/bash");
expect(result.args).toEqual(["--login"]);
});
});
describe("isWSL", () => {
it("should return true if /proc/version contains microsoft", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2");
expect(service.isWSL()).toBe(true);
});
it("should return true if /proc/version contains wsl", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-wsl2");
expect(service.isWSL()).toBe(true);
});
it("should return true if WSL_DISTRO_NAME is set", () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.spyOn(process, "env", "get").mockReturnValue({ WSL_DISTRO_NAME: "Ubuntu" });
expect(service.isWSL()).toBe(true);
});
it("should return true if WSLENV is set", () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.spyOn(process, "env", "get").mockReturnValue({ WSLENV: "PATH/l" });
expect(service.isWSL()).toBe(true);
});
it("should return false if not in WSL", () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.spyOn(process, "env", "get").mockReturnValue({});
expect(service.isWSL()).toBe(false);
});
it("should return false if error reading /proc/version", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error("Permission denied");
});
expect(service.isWSL()).toBe(false);
});
});
describe("getPlatformInfo", () => {
it("should return platform information", () => {
vi.mocked(os.platform).mockReturnValue("linux");
vi.mocked(os.arch).mockReturnValue("x64");
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const info = service.getPlatformInfo();
expect(info.platform).toBe("linux");
expect(info.arch).toBe("x64");
expect(info.defaultShell).toBe("/bin/bash");
expect(typeof info.isWSL).toBe("boolean");
});
});
describe("createSession", () => {
it("should create a new terminal session", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession({
cwd: "/test/dir",
cols: 100,
rows: 30,
});
expect(session.id).toMatch(/^term-/);
expect(session.cwd).toBe("/test/dir");
expect(session.shell).toBe("/bin/bash");
expect(pty.spawn).toHaveBeenCalledWith(
"/bin/bash",
["--login"],
expect.objectContaining({
cwd: "/test/dir",
cols: 100,
rows: 30,
})
);
});
it("should use default cols and rows if not provided", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
service.createSession();
expect(pty.spawn).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
cols: 80,
rows: 24,
})
);
});
it("should fall back to home directory if cwd does not exist", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockImplementation(() => {
throw new Error("ENOENT");
});
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession({
cwd: "/nonexistent",
});
expect(session.cwd).toBe("/home/user");
});
it("should fall back to home directory if cwd is not a directory", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession({
cwd: "/file.txt",
});
expect(session.cwd).toBe("/home/user");
});
it("should fix double slashes in path", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession({
cwd: "//test/dir",
});
expect(session.cwd).toBe("/test/dir");
});
it("should preserve WSL UNC paths", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession({
cwd: "//wsl$/Ubuntu/home",
});
expect(session.cwd).toBe("//wsl$/Ubuntu/home");
});
it("should handle data events from PTY", () => {
vi.useFakeTimers();
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const dataCallback = vi.fn();
service.onData(dataCallback);
service.createSession();
// Simulate data event
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
onDataHandler("test data");
// Wait for throttled output
vi.advanceTimersByTime(20);
expect(dataCallback).toHaveBeenCalled();
vi.useRealTimers();
});
it("should handle exit events from PTY", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const exitCallback = vi.fn();
service.onExit(exitCallback);
const session = service.createSession();
// Simulate exit event
const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0];
onExitHandler({ exitCode: 0 });
expect(exitCallback).toHaveBeenCalledWith(session.id, 0);
expect(service.getSession(session.id)).toBeUndefined();
});
});
describe("write", () => {
it("should write data to existing session", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession();
const result = service.write(session.id, "ls\n");
expect(result).toBe(true);
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls\n");
});
it("should return false for non-existent session", () => {
const result = service.write("nonexistent", "data");
expect(result).toBe(false);
expect(mockPtyProcess.write).not.toHaveBeenCalled();
});
});
describe("resize", () => {
it("should resize existing session", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession();
const result = service.resize(session.id, 120, 40);
expect(result).toBe(true);
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
});
it("should return false for non-existent session", () => {
const result = service.resize("nonexistent", 120, 40);
expect(result).toBe(false);
expect(mockPtyProcess.resize).not.toHaveBeenCalled();
});
it("should handle resize errors", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
mockPtyProcess.resize.mockImplementation(() => {
throw new Error("Resize failed");
});
const session = service.createSession();
const result = service.resize(session.id, 120, 40);
expect(result).toBe(false);
});
});
describe("killSession", () => {
it("should kill existing session", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession();
const result = service.killSession(session.id);
expect(result).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalled();
expect(service.getSession(session.id)).toBeUndefined();
});
it("should return false for non-existent session", () => {
const result = service.killSession("nonexistent");
expect(result).toBe(false);
});
it("should handle kill errors", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
mockPtyProcess.kill.mockImplementation(() => {
throw new Error("Kill failed");
});
const session = service.createSession();
const result = service.killSession(session.id);
expect(result).toBe(false);
});
});
describe("getSession", () => {
it("should return existing session", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession();
const retrieved = service.getSession(session.id);
expect(retrieved).toBe(session);
});
it("should return undefined for non-existent session", () => {
const retrieved = service.getSession("nonexistent");
expect(retrieved).toBeUndefined();
});
});
describe("getScrollback", () => {
it("should return scrollback buffer for existing session", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session = service.createSession();
session.scrollbackBuffer = "test scrollback";
const scrollback = service.getScrollback(session.id);
expect(scrollback).toBe("test scrollback");
});
it("should return null for non-existent session", () => {
const scrollback = service.getScrollback("nonexistent");
expect(scrollback).toBeNull();
});
});
describe("getAllSessions", () => {
it("should return all active sessions", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session1 = service.createSession({ cwd: "/dir1" });
const session2 = service.createSession({ cwd: "/dir2" });
const sessions = service.getAllSessions();
expect(sessions).toHaveLength(2);
expect(sessions[0].id).toBe(session1.id);
expect(sessions[1].id).toBe(session2.id);
expect(sessions[0].cwd).toBe("/dir1");
expect(sessions[1].cwd).toBe("/dir2");
});
it("should return empty array if no sessions", () => {
const sessions = service.getAllSessions();
expect(sessions).toEqual([]);
});
});
describe("onData and onExit", () => {
it("should allow subscribing and unsubscribing from data events", () => {
const callback = vi.fn();
const unsubscribe = service.onData(callback);
expect(typeof unsubscribe).toBe("function");
unsubscribe();
});
it("should allow subscribing and unsubscribing from exit events", () => {
const callback = vi.fn();
const unsubscribe = service.onExit(callback);
expect(typeof unsubscribe).toBe("function");
unsubscribe();
});
});
describe("cleanup", () => {
it("should clean up all sessions", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
const session1 = service.createSession();
const session2 = service.createSession();
service.cleanup();
expect(service.getSession(session1.id)).toBeUndefined();
expect(service.getSession(session2.id)).toBeUndefined();
expect(service.getAllSessions()).toHaveLength(0);
});
it("should handle cleanup errors gracefully", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" });
mockPtyProcess.kill.mockImplementation(() => {
throw new Error("Kill failed");
});
service.createSession();
expect(() => service.cleanup()).not.toThrow();
});
});
describe("getTerminalService", () => {
it("should return singleton instance", () => {
const instance1 = getTerminalService();
const instance2 = getTerminalService();
expect(instance1).toBe(instance2);
});
});
});

View File

@@ -0,0 +1,38 @@
/**
* Test helper functions
*/
/**
* Collect all values from an async generator
*/
export async function collectAsyncGenerator<T>(gen: AsyncGenerator<T>): Promise<T[]> {
const results: T[] = [];
for await (const item of gen) {
results.push(item);
}
return results;
}
/**
* Wait for a condition to be true
*/
export async function waitFor(
condition: () => boolean,
timeout = 1000,
interval = 10
): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeout) {
throw new Error("Timeout waiting for condition");
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
/**
* Create a temporary directory for tests
*/
export function createTempDir(): string {
return `/tmp/test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}

View File

@@ -0,0 +1,107 @@
/**
* Mock utilities for testing
* Provides reusable mocks for common dependencies
*/
import { vi } from "vitest";
import type { ChildProcess } from "child_process";
import { EventEmitter } from "events";
import type { Readable } from "stream";
/**
* Mock child_process.spawn for subprocess tests
*/
export function createMockChildProcess(options: {
stdout?: string[];
stderr?: string[];
exitCode?: number | null;
shouldError?: boolean;
}): ChildProcess {
const { stdout = [], stderr = [], exitCode = 0, shouldError = false } = options;
const mockProcess = new EventEmitter() as any;
// Create mock stdout stream
mockProcess.stdout = new EventEmitter() as Readable;
mockProcess.stderr = new EventEmitter() as Readable;
mockProcess.kill = vi.fn();
// Simulate async output
process.nextTick(() => {
// Emit stdout lines
for (const line of stdout) {
mockProcess.stdout.emit("data", Buffer.from(line + "\n"));
}
// Emit stderr lines
for (const line of stderr) {
mockProcess.stderr.emit("data", Buffer.from(line + "\n"));
}
// Emit exit or error
if (shouldError) {
mockProcess.emit("error", new Error("Process error"));
} else {
mockProcess.emit("exit", exitCode);
}
});
return mockProcess as ChildProcess;
}
/**
* Mock fs/promises for file system tests
*/
export function createMockFs() {
return {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
access: vi.fn(),
stat: vi.fn(),
};
}
/**
* Mock Express request/response/next for middleware tests
*/
export function createMockExpressContext() {
const req = {
headers: {},
body: {},
params: {},
query: {},
} as any;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
} as any;
const next = vi.fn();
return { req, res, next };
}
/**
* Mock AbortController for async operation tests
*/
export function createMockAbortController() {
const controller = new AbortController();
const originalAbort = controller.abort.bind(controller);
controller.abort = vi.fn(originalAbort);
return controller;
}
/**
* Mock Claude SDK query function
*/
export function createMockClaudeQuery(messages: any[] = []) {
return vi.fn(async function* ({ prompt, options }: any) {
for (const msg of messages) {
yield msg;
}
});
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals", "node"],
"moduleResolution": "Bundler",
"module": "ESNext"
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
reporters: ['verbose'],
globals: true,
environment: "node",
setupFiles: ["./tests/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.d.ts",
"src/index.ts",
"src/routes/**", // Routes are better tested with integration tests
],
thresholds: {
lines: 65,
functions: 75,
branches: 58,
statements: 65,
},
},
include: ["tests/**/*.test.ts", "tests/**/*.spec.ts"],
exclude: ["**/node_modules/**", "**/dist/**"],
mockReset: true,
restoreMocks: true,
clearMocks: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

786
docs/server/providers.md Normal file
View File

@@ -0,0 +1,786 @@
# Provider Architecture Reference
This document describes the modular provider architecture in `apps/server/src/providers/` that enables support for multiple AI model providers (Claude SDK, OpenAI Codex CLI, and future providers like Cursor, OpenCode, etc.).
---
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Provider Interface](#provider-interface)
3. [Available Providers](#available-providers)
4. [Provider Factory](#provider-factory)
5. [Adding New Providers](#adding-new-providers)
6. [Provider Types](#provider-types)
7. [Best Practices](#best-practices)
---
## Architecture Overview
The provider architecture separates AI model execution logic from business logic, enabling clean abstraction and easy extensibility.
### Architecture Diagram
```
┌─────────────────────────────────────────┐
│ AgentService / AutoModeService │
│ (No provider logic) │
└──────────────────┬──────────────────────┘
┌─────────▼──────────┐
│ ProviderFactory │ Model-based routing
│ (Routes by model) │ "gpt-*" → Codex
└─────────┬──────────┘ "claude-*" → Claude
┌────────────┴────────────┐
│ │
┌─────▼──────┐ ┌──────▼──────┐
│ Claude │ │ Codex │
│ Provider │ │ Provider │
│ (Agent SDK)│ │ (CLI Spawn) │
└────────────┘ └─────────────┘
```
### Key Benefits
-**Adding new providers**: Only 1 new file + 1 line in factory
-**Services remain clean**: No provider-specific logic
-**All providers implement same interface**: Consistent behavior
-**Model prefix determines provider**: Automatic routing
-**Easy to test**: Each provider can be tested independently
---
## Provider Interface
**Location**: `apps/server/src/providers/base-provider.ts`
All providers must extend `BaseProvider` and implement the required methods.
### BaseProvider Abstract Class
```typescript
export abstract class BaseProvider {
protected config: ProviderConfig;
constructor(config: ProviderConfig = {}) {
this.config = config;
}
/**
* Get provider name (e.g., "claude", "codex")
*/
abstract getName(): string;
/**
* Execute a query and stream responses
*/
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
/**
* Detect provider installation status
*/
abstract detectInstallation(): Promise<InstallationStatus>;
/**
* Get available models for this provider
*/
abstract getAvailableModels(): ModelDefinition[];
/**
* Check if provider supports a specific feature (optional)
*/
supportsFeature(feature: string): boolean {
return false;
}
}
```
### Shared Types
**Location**: `apps/server/src/providers/types.ts`
#### ExecuteOptions
Input configuration for executing queries:
```typescript
export interface ExecuteOptions {
prompt: string | Array<{ type: string; text?: string; source?: object }>;
model: string;
cwd: string;
systemPrompt?: string;
maxTurns?: number;
allowedTools?: string[];
mcpServers?: Record<string, unknown>;
abortController?: AbortController;
conversationHistory?: ConversationMessage[];
}
```
#### ProviderMessage
Output messages streamed from providers:
```typescript
export interface ProviderMessage {
type: "assistant" | "user" | "error" | "result";
subtype?: "success" | "error";
message?: {
role: "user" | "assistant";
content: ContentBlock[];
};
result?: string;
error?: string;
}
```
#### ContentBlock
Individual content blocks in messages:
```typescript
export interface ContentBlock {
type: "text" | "tool_use" | "thinking" | "tool_result";
text?: string;
thinking?: string;
name?: string;
input?: unknown;
tool_use_id?: string;
content?: string;
}
```
---
## Available Providers
### 1. Claude Provider (SDK-based)
**Location**: `apps/server/src/providers/claude-provider.ts`
Uses `@anthropic-ai/claude-agent-sdk` for direct SDK integration.
#### Features
- ✅ Native multi-turn conversation support
- ✅ Vision support (images)
- ✅ Tool use (Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch)
- ✅ Thinking blocks (extended thinking)
- ✅ Streaming responses
- ✅ No CLI installation required (npm dependency)
#### Model Detection
Routes models that:
- Start with `"claude-"` (e.g., `"claude-opus-4-5-20251101"`)
- Are Claude aliases: `"opus"`, `"sonnet"`, `"haiku"`
#### Authentication
Requires one of:
- `ANTHROPIC_API_KEY` environment variable
- `CLAUDE_CODE_OAUTH_TOKEN` environment variable
#### Example Usage
```typescript
const provider = new ClaudeProvider();
const stream = provider.executeQuery({
prompt: "What is 2+2?",
model: "claude-opus-4-5-20251101",
cwd: "/project/path",
systemPrompt: "You are a helpful assistant.",
maxTurns: 20,
allowedTools: ["Read", "Write", "Bash"],
abortController: new AbortController(),
conversationHistory: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi! How can I help?" }
]
});
for await (const msg of stream) {
if (msg.type === "assistant") {
console.log(msg.message?.content);
}
}
```
#### Conversation History Handling
Uses `convertHistoryToMessages()` utility to convert history to SDK format:
```typescript
const historyMessages = convertHistoryToMessages(conversationHistory);
for (const msg of historyMessages) {
yield msg; // Yield to SDK
}
```
---
### 2. Codex Provider (CLI-based)
**Location**: `apps/server/src/providers/codex-provider.ts`
Spawns OpenAI Codex CLI as a subprocess and converts JSONL output to provider format.
#### Features
- ✅ Subprocess execution (`codex exec --model <model> --json --full-auto`)
- ✅ JSONL stream parsing
- ✅ Supports GPT-5.1/5.2 Codex models
- ✅ Vision support (GPT-5.1, GPT-5.2)
- ✅ Tool use via MCP servers
- ✅ Timeout detection (30s no output)
- ✅ Abort signal handling
#### Model Detection
Routes models that:
- Start with `"gpt-"` (e.g., `"gpt-5.2"`, `"gpt-5.1-codex-max"`)
- Start with `"o"` (e.g., `"o1"`, `"o1-mini"`)
#### Available Models
| Model | Description | Context | Max Output | Vision |
|-------|-------------|---------|------------|--------|
| `gpt-5.2` | Latest Codex model | 256K | 32K | Yes |
| `gpt-5.1-codex-max` | Maximum capability | 256K | 32K | Yes |
| `gpt-5.1-codex` | Standard Codex | 256K | 32K | Yes |
| `gpt-5.1-codex-mini` | Lightweight | 256K | 16K | No |
| `gpt-5.1` | General-purpose | 256K | 32K | Yes |
#### Authentication
Supports two methods:
1. **CLI login**: `codex login` (OAuth tokens stored in `~/.codex/auth.json`)
2. **API key**: `OPENAI_API_KEY` environment variable
#### Installation Detection
Uses `CodexCliDetector` to check:
- PATH for `codex` command
- npm global: `npm list -g @openai/codex`
- Homebrew (macOS): `/opt/homebrew/bin/codex`
- Common paths: `~/.local/bin/codex`, `/usr/local/bin/codex`
#### Example Usage
```typescript
const provider = new CodexProvider();
const stream = provider.executeQuery({
prompt: "Fix the bug in main.ts",
model: "gpt-5.2",
cwd: "/project/path",
systemPrompt: "You are an expert TypeScript developer.",
abortController: new AbortController()
});
for await (const msg of stream) {
if (msg.type === "assistant") {
console.log(msg.message?.content);
} else if (msg.type === "error") {
console.error(msg.error);
}
}
```
#### JSONL Event Conversion
Codex CLI outputs JSONL events that get converted to `ProviderMessage` format:
| Codex Event | Provider Message |
|-------------|------------------|
| `item.completed` (reasoning) | `{ type: "assistant", content: [{ type: "thinking" }] }` |
| `item.completed` (agent_message) | `{ type: "assistant", content: [{ type: "text" }] }` |
| `item.completed` (command_execution) | `{ type: "assistant", content: [{ type: "text", text: "```bash\n...\n```" }] }` |
| `item.started` (command_execution) | `{ type: "assistant", content: [{ type: "tool_use" }] }` |
| `item.updated` (todo_list) | `{ type: "assistant", content: [{ type: "text", text: "**Updated Todo List:**..." }] }` |
| `thread.completed` | `{ type: "result", subtype: "success" }` |
| `error` | `{ type: "error", error: "..." }` |
#### Conversation History Handling
Uses `formatHistoryAsText()` utility to prepend history as text context (CLI doesn't support native multi-turn):
```typescript
const historyText = formatHistoryAsText(conversationHistory);
combinedPrompt = `${historyText}Current request:\n${combinedPrompt}`;
```
#### MCP Server Configuration
**Location**: `apps/server/src/providers/codex-config-manager.ts`
Manages TOML configuration for MCP servers:
```typescript
await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);
```
Generates `.codex/config.toml`:
```toml
[mcp_servers.automaker-tools]
command = "node"
args = ["/path/to/mcp-server.js"]
enabled_tools = ["UpdateFeatureStatus"]
```
---
## Provider Factory
**Location**: `apps/server/src/providers/provider-factory.ts`
Routes requests to the appropriate provider based on model string.
### Model-Based Routing
```typescript
export class ProviderFactory {
/**
* Get provider for a specific model
*/
static getProviderForModel(modelId: string): BaseProvider {
const lowerModel = modelId.toLowerCase();
// OpenAI/Codex models
if (lowerModel.startsWith("gpt-") || lowerModel.startsWith("o")) {
return new CodexProvider();
}
// Claude models
if (lowerModel.startsWith("claude-") ||
["haiku", "sonnet", "opus"].includes(lowerModel)) {
return new ClaudeProvider();
}
// Default to Claude
return new ClaudeProvider();
}
/**
* Check installation status of all providers
*/
static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
const claude = new ClaudeProvider();
const codex = new CodexProvider();
return {
claude: await claude.detectInstallation(),
codex: await codex.detectInstallation(),
};
}
}
```
### Usage in Services
```typescript
import { ProviderFactory } from "../providers/provider-factory.js";
// In AgentService or AutoModeService
const provider = ProviderFactory.getProviderForModel(model);
const stream = provider.executeQuery(options);
for await (const msg of stream) {
// Handle messages (format is consistent across all providers)
}
```
---
## Adding New Providers
### Step 1: Create Provider File
Create `apps/server/src/providers/[name]-provider.ts`:
```typescript
import { BaseProvider } from "./base-provider.js";
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from "./types.js";
export class CursorProvider extends BaseProvider {
getName(): string {
return "cursor";
}
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Implementation here
// 1. Spawn cursor CLI or use SDK
// 2. Convert output to ProviderMessage format
// 3. Yield messages
}
async detectInstallation(): Promise<InstallationStatus> {
// Check if cursor is installed
// Return { installed: boolean, path?: string, version?: string }
}
getAvailableModels(): ModelDefinition[] {
return [
{
id: "cursor-premium",
name: "Cursor Premium",
modelString: "cursor-premium",
provider: "cursor",
description: "Cursor's premium model",
contextWindow: 200000,
maxOutputTokens: 8192,
supportsVision: true,
supportsTools: true,
tier: "premium",
default: true,
}
];
}
supportsFeature(feature: string): boolean {
const supportedFeatures = ["tools", "text", "vision"];
return supportedFeatures.includes(feature);
}
}
```
### Step 2: Add Routing in Factory
Update `apps/server/src/providers/provider-factory.ts`:
```typescript
import { CursorProvider } from "./cursor-provider.js";
static getProviderForModel(modelId: string): BaseProvider {
const lowerModel = modelId.toLowerCase();
// Cursor models
if (lowerModel.startsWith("cursor-")) {
return new CursorProvider();
}
// ... existing routing
}
static async checkAllProviders() {
const cursor = new CursorProvider();
return {
claude: await claude.detectInstallation(),
codex: await codex.detectInstallation(),
cursor: await cursor.detectInstallation(), // NEW
};
}
```
### Step 3: Update Models List
Update `apps/server/src/routes/models.ts`:
```typescript
{
id: "cursor-premium",
name: "Cursor Premium",
provider: "cursor",
contextWindow: 200000,
maxOutputTokens: 8192,
supportsVision: true,
supportsTools: true,
}
```
### Step 4: Done!
No changes needed in:
- ✅ AgentService
- ✅ AutoModeService
- ✅ Any business logic
The provider architecture handles everything automatically.
---
## Provider Types
### SDK-Based Providers (like Claude)
**Characteristics**:
- Direct SDK/library integration
- No subprocess spawning
- Native multi-turn support
- Streaming via async generators
**Example**: ClaudeProvider using `@anthropic-ai/claude-agent-sdk`
**Advantages**:
- Lower latency
- More control over options
- Easier error handling
- No CLI installation required
---
### CLI-Based Providers (like Codex)
**Characteristics**:
- Subprocess spawning
- JSONL stream parsing
- Text-based conversation history
- Requires CLI installation
**Example**: CodexProvider using `codex exec --json`
**Advantages**:
- Access to CLI-only features
- No SDK dependency
- Can use any CLI tool
**Implementation Pattern**:
1. Use `spawnJSONLProcess()` from `subprocess-manager.ts`
2. Convert JSONL events to `ProviderMessage` format
3. Handle authentication (CLI login or API key)
4. Implement timeout detection
---
## Best Practices
### 1. Message Format Consistency
All providers MUST output the same `ProviderMessage` format so services can handle them uniformly:
```typescript
// ✅ Correct - Consistent format
yield {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Response" }]
}
};
// ❌ Incorrect - Provider-specific format
yield {
customType: "response",
data: "Response"
};
```
### 2. Error Handling
Always yield error messages, never throw:
```typescript
// ✅ Correct
try {
// ...
} catch (error) {
yield {
type: "error",
error: (error as Error).message
};
return;
}
// ❌ Incorrect
throw new Error("Provider failed");
```
### 3. Abort Signal Support
Respect the abort controller:
```typescript
if (abortController?.signal.aborted) {
yield { type: "error", error: "Operation cancelled" };
return;
}
```
### 4. Conversation History
- **SDK providers**: Use `convertHistoryToMessages()` and yield messages
- **CLI providers**: Use `formatHistoryAsText()` and prepend to prompt
### 5. Image Handling
- **Vision models**: Pass images as content blocks
- **Non-vision models**: Extract text only using utilities
### 6. Logging
Use consistent logging prefixes:
```typescript
console.log(`[${this.getName()}Provider] Operation started`);
console.error(`[${this.getName()}Provider] Error:`, error);
```
### 7. Installation Detection
Implement thorough detection:
- Check multiple installation methods
- Verify authentication
- Return detailed status
### 8. Model Definitions
Provide accurate model metadata:
```typescript
{
id: "model-id",
name: "Human-readable name",
modelString: "exact-model-string",
provider: "provider-name",
description: "What this model is good for",
contextWindow: 200000,
maxOutputTokens: 8192,
supportsVision: true,
supportsTools: true,
tier: "premium" | "standard" | "basic",
default: false
}
```
---
## Testing Providers
### Unit Tests
Test each provider method independently:
```typescript
describe("ClaudeProvider", () => {
it("should detect installation", async () => {
const provider = new ClaudeProvider();
const status = await provider.detectInstallation();
expect(status.installed).toBe(true);
expect(status.method).toBe("sdk");
});
it("should stream messages correctly", async () => {
const provider = new ClaudeProvider();
const messages = [];
for await (const msg of provider.executeQuery(options)) {
messages.push(msg);
}
expect(messages.length).toBeGreaterThan(0);
expect(messages[0].type).toBe("assistant");
});
});
```
### Integration Tests
Test provider interaction with services:
```typescript
describe("Provider Integration", () => {
it("should work with AgentService", async () => {
const provider = ProviderFactory.getProviderForModel("claude-opus-4-5-20251101");
// Test full workflow
});
});
```
---
## Environment Variables
### Claude Provider
```bash
# Required (one of):
ANTHROPIC_API_KEY=sk-ant-...
CLAUDE_CODE_OAUTH_TOKEN=...
```
### Codex Provider
```bash
# Required (one of):
OPENAI_API_KEY=sk-...
# OR run: codex login
# Optional:
CODEX_CLI_PATH=/custom/path/to/codex
```
---
## Troubleshooting
### Provider Not Found
**Problem**: `ProviderFactory.getProviderForModel()` returns wrong provider
**Solution**: Check model string prefix in factory routing
### Authentication Errors
**Problem**: Provider fails with auth error
**Solution**:
1. Check environment variables
2. For CLI providers, verify CLI login status
3. Check `detectInstallation()` output
### JSONL Parsing Errors (CLI providers)
**Problem**: Failed to parse JSONL line
**Solution**:
1. Check CLI output format
2. Verify JSON is valid
3. Add error handling for malformed lines
### Timeout Issues (CLI providers)
**Problem**: Subprocess hangs
**Solution**:
1. Increase timeout in `spawnJSONLProcess` options
2. Check CLI process for hangs
3. Verify abort signal handling
---
## Future Provider Ideas
Potential providers to add:
1. **Cursor Provider** (`cursor-*`)
- CLI-based
- Code completion specialist
2. **OpenCode Provider** (`opencode-*`)
- SDK or CLI-based
- Open-source alternative
3. **Gemini Provider** (`gemini-*`)
- Google's AI models
- SDK-based via `@google/generative-ai`
4. **Ollama Provider** (`ollama-*`)
- Local model hosting
- CLI or HTTP API
Each would follow the same pattern:
1. Create `[name]-provider.ts` implementing `BaseProvider`
2. Add routing in `provider-factory.ts`
3. Update models list
4. Done! ✅

672
docs/server/utilities.md Normal file
View File

@@ -0,0 +1,672 @@
# Server Utilities Reference
This document describes all utility modules available in `apps/server/src/lib/`. These utilities provide reusable functionality for image handling, prompt building, model resolution, conversation management, and error handling.
---
## Table of Contents
1. [Image Handler](#image-handler)
2. [Prompt Builder](#prompt-builder)
3. [Model Resolver](#model-resolver)
4. [Conversation Utils](#conversation-utils)
5. [Error Handler](#error-handler)
6. [Subprocess Manager](#subprocess-manager)
7. [Events](#events)
8. [Auth](#auth)
9. [Security](#security)
---
## Image Handler
**Location**: `apps/server/src/lib/image-handler.ts`
Centralized utilities for processing image files, including MIME type detection, base64 encoding, and content block generation for Claude SDK format.
### Functions
#### `getMimeTypeForImage(imagePath: string): string`
Get MIME type for an image file based on its extension.
**Supported formats**:
- `.jpg`, `.jpeg``image/jpeg`
- `.png``image/png`
- `.gif``image/gif`
- `.webp``image/webp`
- Default: `image/png`
**Example**:
```typescript
import { getMimeTypeForImage } from "../lib/image-handler.js";
const mimeType = getMimeTypeForImage("/path/to/image.jpg");
// Returns: "image/jpeg"
```
---
#### `readImageAsBase64(imagePath: string): Promise<ImageData>`
Read an image file and convert to base64 with metadata.
**Returns**: `ImageData`
```typescript
interface ImageData {
base64: string; // Base64-encoded image data
mimeType: string; // MIME type
filename: string; // File basename
originalPath: string; // Original file path
}
```
**Example**:
```typescript
const imageData = await readImageAsBase64("/path/to/photo.png");
console.log(imageData.base64); // "iVBORw0KG..."
console.log(imageData.mimeType); // "image/png"
console.log(imageData.filename); // "photo.png"
```
---
#### `convertImagesToContentBlocks(imagePaths: string[], workDir?: string): Promise<ImageContentBlock[]>`
Convert image paths to content blocks in Claude SDK format. Handles both relative and absolute paths.
**Parameters**:
- `imagePaths` - Array of image file paths
- `workDir` - Optional working directory for resolving relative paths
**Returns**: Array of `ImageContentBlock`
```typescript
interface ImageContentBlock {
type: "image";
source: {
type: "base64";
media_type: string;
data: string;
};
}
```
**Example**:
```typescript
const imageBlocks = await convertImagesToContentBlocks(
["./screenshot.png", "/absolute/path/diagram.jpg"],
"/project/root"
);
// Use in prompt content
const contentBlocks = [
{ type: "text", text: "Analyze these images:" },
...imageBlocks
];
```
---
#### `formatImagePathsForPrompt(imagePaths: string[]): string`
Format image paths as a bulleted list for inclusion in text prompts.
**Returns**: Formatted string with image paths, or empty string if no images.
**Example**:
```typescript
const pathsList = formatImagePathsForPrompt([
"/screenshots/login.png",
"/diagrams/architecture.png"
]);
// Returns:
// "\n\nAttached images:\n- /screenshots/login.png\n- /diagrams/architecture.png\n"
```
---
## Prompt Builder
**Location**: `apps/server/src/lib/prompt-builder.ts`
Standardized prompt building that combines text prompts with image attachments.
### Functions
#### `buildPromptWithImages(basePrompt: string, imagePaths?: string[], workDir?: string, includeImagePaths: boolean = false): Promise<PromptWithImages>`
Build a prompt with optional image attachments.
**Parameters**:
- `basePrompt` - The text prompt
- `imagePaths` - Optional array of image file paths
- `workDir` - Optional working directory for resolving relative paths
- `includeImagePaths` - Whether to append image paths to the text (default: false)
**Returns**: `PromptWithImages`
```typescript
interface PromptWithImages {
content: PromptContent; // string | Array<ContentBlock>
hasImages: boolean;
}
type PromptContent = string | Array<{
type: string;
text?: string;
source?: object;
}>;
```
**Example**:
```typescript
import { buildPromptWithImages } from "../lib/prompt-builder.js";
// Without images
const { content } = await buildPromptWithImages("What is 2+2?");
// content: "What is 2+2?" (simple string)
// With images
const { content, hasImages } = await buildPromptWithImages(
"Analyze this screenshot",
["/path/to/screenshot.png"],
"/project/root",
true // include image paths in text
);
// content: [
// { type: "text", text: "Analyze this screenshot\n\nAttached images:\n- /path/to/screenshot.png\n" },
// { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
// ]
// hasImages: true
```
**Use Cases**:
- **AgentService**: Set `includeImagePaths: true` to list paths for Read tool access
- **AutoModeService**: Set `includeImagePaths: false` to avoid duplication in feature descriptions
---
## Model Resolver
**Location**: `apps/server/src/lib/model-resolver.ts`
Centralized model string mapping and resolution for handling model aliases and provider detection.
### Constants
#### `CLAUDE_MODEL_MAP`
Model alias mapping for Claude models.
```typescript
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
} as const;
```
#### `DEFAULT_MODELS`
Default models per provider.
```typescript
export const DEFAULT_MODELS = {
claude: "claude-opus-4-5-20251101",
openai: "gpt-5.2",
} as const;
```
### Functions
#### `resolveModelString(modelKey?: string, defaultModel: string = DEFAULT_MODELS.claude): string`
Resolve a model key/alias to a full model string.
**Logic**:
1. If `modelKey` is undefined → return `defaultModel`
2. If starts with `"gpt-"` or `"o"` → pass through (OpenAI/Codex model)
3. If includes `"claude-"` → pass through (full Claude model string)
4. If in `CLAUDE_MODEL_MAP` → return mapped value
5. Otherwise → return `defaultModel` with warning
**Example**:
```typescript
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
resolveModelString("opus");
// Returns: "claude-opus-4-5-20251101"
// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-5-20251101""
resolveModelString("gpt-5.2");
// Returns: "gpt-5.2"
// Logs: "[ModelResolver] Using OpenAI/Codex model: gpt-5.2"
resolveModelString("claude-sonnet-4-20250514");
// Returns: "claude-sonnet-4-20250514"
// Logs: "[ModelResolver] Using full Claude model string: claude-sonnet-4-20250514"
resolveModelString("invalid-model");
// Returns: "claude-opus-4-5-20251101"
// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-5-20251101""
```
---
#### `getEffectiveModel(explicitModel?: string, sessionModel?: string, defaultModel?: string): string`
Get the effective model from multiple sources with priority.
**Priority**: explicit model > session model > default model
**Example**:
```typescript
import { getEffectiveModel } from "../lib/model-resolver.js";
// Explicit model takes precedence
getEffectiveModel("sonnet", "opus");
// Returns: "claude-sonnet-4-20250514"
// Falls back to session model
getEffectiveModel(undefined, "haiku");
// Returns: "claude-haiku-4-5"
// Falls back to default
getEffectiveModel(undefined, undefined, "gpt-5.2");
// Returns: "gpt-5.2"
```
---
## Conversation Utils
**Location**: `apps/server/src/lib/conversation-utils.ts`
Standardized conversation history processing for both SDK-based and CLI-based providers.
### Types
```typescript
import type { ConversationMessage } from "../providers/types.js";
interface ConversationMessage {
role: "user" | "assistant";
content: string | Array<{ type: string; text?: string; source?: object }>;
}
```
### Functions
#### `extractTextFromContent(content: string | Array<ContentBlock>): string`
Extract plain text from message content (handles both string and array formats).
**Example**:
```typescript
import { extractTextFromContent } from "../lib/conversation-utils.js";
// String content
extractTextFromContent("Hello world");
// Returns: "Hello world"
// Array content
extractTextFromContent([
{ type: "text", text: "Hello" },
{ type: "image", source: {...} },
{ type: "text", text: "world" }
]);
// Returns: "Hello\nworld"
```
---
#### `normalizeContentBlocks(content: string | Array<ContentBlock>): Array<ContentBlock>`
Normalize message content to array format.
**Example**:
```typescript
// String → array
normalizeContentBlocks("Hello");
// Returns: [{ type: "text", text: "Hello" }]
// Array → pass through
normalizeContentBlocks([{ type: "text", text: "Hello" }]);
// Returns: [{ type: "text", text: "Hello" }]
```
---
#### `formatHistoryAsText(history: ConversationMessage[]): string`
Format conversation history as plain text for CLI-based providers (e.g., Codex).
**Returns**: Formatted text with role labels, or empty string if no history.
**Example**:
```typescript
const history = [
{ role: "user", content: "What is 2+2?" },
{ role: "assistant", content: "2+2 equals 4." }
];
const formatted = formatHistoryAsText(history);
// Returns:
// "Previous conversation:
//
// User: What is 2+2?
//
// Assistant: 2+2 equals 4.
//
// ---
//
// "
```
---
#### `convertHistoryToMessages(history: ConversationMessage[]): Array<SDKMessage>`
Convert conversation history to Claude SDK message format.
**Returns**: Array of SDK-formatted messages ready to yield in async generator.
**Example**:
```typescript
const history = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" }
];
const messages = convertHistoryToMessages(history);
// Returns:
// [
// {
// type: "user",
// session_id: "",
// message: {
// role: "user",
// content: [{ type: "text", text: "Hello" }]
// },
// parent_tool_use_id: null
// },
// {
// type: "assistant",
// session_id: "",
// message: {
// role: "assistant",
// content: [{ type: "text", text: "Hi there!" }]
// },
// parent_tool_use_id: null
// }
// ]
```
---
## Error Handler
**Location**: `apps/server/src/lib/error-handler.ts`
Standardized error classification and handling utilities.
### Types
```typescript
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
export interface ErrorInfo {
type: ErrorType;
message: string;
isAbort: boolean;
isAuth: boolean;
originalError: unknown;
}
```
### Functions
#### `isAbortError(error: unknown): boolean`
Check if an error is an abort/cancellation error.
**Example**:
```typescript
import { isAbortError } from "../lib/error-handler.js";
try {
// ... operation
} catch (error) {
if (isAbortError(error)) {
console.log("Operation was cancelled");
return { success: false, aborted: true };
}
}
```
---
#### `isAuthenticationError(errorMessage: string): boolean`
Check if an error is an authentication/API key error.
**Detects**:
- "Authentication failed"
- "Invalid API key"
- "authentication_failed"
- "Fix external API key"
**Example**:
```typescript
if (isAuthenticationError(error.message)) {
console.error("Please check your API key configuration");
}
```
---
#### `classifyError(error: unknown): ErrorInfo`
Classify an error into a specific type.
**Example**:
```typescript
import { classifyError } from "../lib/error-handler.js";
try {
// ... operation
} catch (error) {
const errorInfo = classifyError(error);
switch (errorInfo.type) {
case "authentication":
// Handle auth errors
break;
case "abort":
// Handle cancellation
break;
case "execution":
// Handle other errors
break;
}
}
```
---
#### `getUserFriendlyErrorMessage(error: unknown): string`
Get a user-friendly error message.
**Example**:
```typescript
try {
// ... operation
} catch (error) {
const friendlyMessage = getUserFriendlyErrorMessage(error);
// "Operation was cancelled" for abort errors
// "Authentication failed. Please check your API key." for auth errors
// Original error message for other errors
}
```
---
## Subprocess Manager
**Location**: `apps/server/src/lib/subprocess-manager.ts`
Utilities for spawning CLI processes and parsing JSONL streams (used by Codex provider).
### Types
```typescript
export interface SubprocessOptions {
command: string;
args: string[];
cwd: string;
env?: Record<string, string>;
abortController?: AbortController;
timeout?: number; // Milliseconds of no output before timeout
}
export interface SubprocessResult {
stdout: string;
stderr: string;
exitCode: number | null;
}
```
### Functions
#### `async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown>`
Spawns a subprocess and streams JSONL output line-by-line.
**Features**:
- Parses each line as JSON
- Handles abort signals
- 30-second timeout detection for hanging processes
- Collects stderr for error reporting
- Continues processing other lines if one fails to parse
**Example**:
```typescript
import { spawnJSONLProcess } from "../lib/subprocess-manager.js";
const stream = spawnJSONLProcess({
command: "codex",
args: ["exec", "--model", "gpt-5.2", "--json", "--full-auto", "Fix the bug"],
cwd: "/project/path",
env: { OPENAI_API_KEY: "sk-..." },
abortController: new AbortController(),
timeout: 30000
});
for await (const event of stream) {
console.log("Received event:", event);
// Process JSONL events
}
```
---
#### `async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult>`
Spawns a subprocess and collects all output.
**Example**:
```typescript
const result = await spawnProcess({
command: "git",
args: ["status"],
cwd: "/project/path"
});
console.log(result.stdout); // Git status output
console.log(result.exitCode); // 0 for success
```
---
## Events
**Location**: `apps/server/src/lib/events.ts`
Event emitter system for WebSocket communication.
**Documented separately** - see existing codebase for event types and usage.
---
## Auth
**Location**: `apps/server/src/lib/auth.ts`
Authentication utilities for API endpoints.
**Documented separately** - see existing codebase for authentication flow.
---
## Security
**Location**: `apps/server/src/lib/security.ts`
Security utilities for input validation and sanitization.
**Documented separately** - see existing codebase for security patterns.
---
## Best Practices
### When to Use Which Utility
1. **Image handling** → Always use `image-handler.ts` utilities
- ✅ Do: `convertImagesToContentBlocks(imagePaths, workDir)`
- ❌ Don't: Manually read files and encode base64
2. **Prompt building** → Use `prompt-builder.ts` for consistency
- ✅ Do: `buildPromptWithImages(text, images, workDir, includePathsInText)`
- ❌ Don't: Manually construct content block arrays
3. **Model resolution** → Use `model-resolver.ts` for all model handling
- ✅ Do: `resolveModelString(feature.model, DEFAULT_MODELS.claude)`
- ❌ Don't: Inline model mapping logic
4. **Error handling** → Use `error-handler.ts` for classification
- ✅ Do: `if (isAbortError(error)) { ... }`
- ❌ Don't: `if (error instanceof AbortError || error.name === "AbortError") { ... }`
### Importing Utilities
Always use `.js` extension in imports for ESM compatibility:
```typescript
// ✅ Correct
import { buildPromptWithImages } from "../lib/prompt-builder.js";
// ❌ Incorrect
import { buildPromptWithImages } from "../lib/prompt-builder";
```
---
## Testing Utilities
When writing tests for utilities:
1. **Unit tests** - Test each function in isolation
2. **Integration tests** - Test utilities working together
3. **Mock external dependencies** - File system, child processes
Example:
```typescript
describe("image-handler", () => {
it("should detect MIME type correctly", () => {
expect(getMimeTypeForImage("photo.jpg")).toBe("image/jpeg");
expect(getMimeTypeForImage("diagram.png")).toBe("image/png");
});
});
```

3372
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,10 +18,15 @@
"build": "npm run build --workspace=apps/app", "build": "npm run build --workspace=apps/app",
"build:server": "npm run build --workspace=apps/server", "build:server": "npm run build --workspace=apps/server",
"build:electron": "npm run build:electron --workspace=apps/app", "build:electron": "npm run build:electron --workspace=apps/app",
"build:electron:win": "npm run build:electron:win --workspace=apps/app",
"build:electron:mac": "npm run build:electron:mac --workspace=apps/app",
"build:electron:linux": "npm run build:electron:linux --workspace=apps/app",
"start": "npm run start --workspace=apps/app", "start": "npm run start --workspace=apps/app",
"start:server": "npm run start --workspace=apps/server", "start:server": "npm run start --workspace=apps/server",
"lint": "npm run lint --workspace=apps/app", "lint": "npm run lint --workspace=apps/app",
"test": "npm run test --workspace=apps/app", "test": "npm run test --workspace=apps/app",
"test:headed": "npm run test:headed --workspace=apps/app" "test:headed": "npm run test:headed --workspace=apps/app",
"test:server": "npm run test --workspace=apps/server",
"test:server:coverage": "npm run test:cov --workspace=apps/server"
} }
} }