chore: update project management and API integration

- Added new scripts for server development and full application startup in package.json.
- Enhanced project management by checking for existing projects to avoid duplicates.
- Improved API integration with better error handling and connection checks in the Electron API.
- Updated UI components to reflect changes in project and session management.
- Refactored authentication status display to include more detailed information on methods used.
This commit is contained in:
SuperComboGamer
2025-12-12 00:23:43 -05:00
parent 02a1af3314
commit 4b9bd2641f
44 changed files with 8287 additions and 703 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ node_modules/
# Build outputs
dist/
.next/
node_modules

View File

@@ -0,0 +1,241 @@
/**
* Simplified Electron main process
*
* This version spawns the backend server and uses HTTP API for most operations.
* Only native features (dialogs, shell) use IPC.
*/
const path = require("path");
const { spawn } = require("child_process");
// Load environment variables from .env file
require("dotenv").config({ path: path.join(__dirname, "../.env") });
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
let mainWindow = null;
let serverProcess = null;
const SERVER_PORT = 3008;
// Get icon path - works in both dev and production
function getIconPath() {
return app.isPackaged
? path.join(process.resourcesPath, "app", "public", "logo.png")
: path.join(__dirname, "../public/logo.png");
}
/**
* Start the backend server
*/
async function startServer() {
const isDev = !app.isPackaged;
// Server entry point
const serverPath = isDev
? path.join(__dirname, "../../server/dist/index.js")
: path.join(process.resourcesPath, "server", "index.js");
// Set environment variables for server
const env = {
...process.env,
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"),
};
console.log("[Electron] Starting backend server...");
serverProcess = spawn("node", [serverPath], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
serverProcess.stdout.on("data", (data) => {
console.log(`[Server] ${data.toString().trim()}`);
});
serverProcess.stderr.on("data", (data) => {
console.error(`[Server Error] ${data.toString().trim()}`);
});
serverProcess.on("close", (code) => {
console.log(`[Server] Process exited with code ${code}`);
serverProcess = null;
});
// Wait for server to be ready
await waitForServer();
}
/**
* Wait for server to be available
*/
async function waitForServer(maxAttempts = 30) {
const http = require("http");
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise((resolve, reject) => {
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status: ${res.statusCode}`));
}
});
req.on("error", reject);
req.setTimeout(1000, () => {
req.destroy();
reject(new Error("Timeout"));
});
});
console.log("[Electron] Server is ready");
return;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
throw new Error("Server failed to start");
}
/**
* Create the main window
*/
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1024,
minHeight: 700,
icon: getIconPath(),
webPreferences: {
preload: path.join(__dirname, "preload-simplified.js"),
contextIsolation: true,
nodeIntegration: false,
},
titleBarStyle: "hiddenInset",
backgroundColor: "#0a0a0a",
});
// Load Next.js dev server in development or production build
const isDev = !app.isPackaged;
if (isDev) {
mainWindow.loadURL("http://localhost:3007");
if (process.env.OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools();
}
} else {
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
}
mainWindow.on("closed", () => {
mainWindow = null;
});
}
// App lifecycle
app.whenReady().then(async () => {
// Set app icon (dock icon on macOS)
if (process.platform === "darwin" && app.dock) {
app.dock.setIcon(getIconPath());
}
try {
// Start backend server
await startServer();
// Create window
createWindow();
} catch (error) {
console.error("[Electron] Failed to start:", error);
app.quit();
}
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
// Kill server process
if (serverProcess) {
console.log("[Electron] Stopping server...");
serverProcess.kill();
serverProcess = null;
}
});
// ============================================
// IPC Handlers - Only native features
// ============================================
// Native file dialogs
ipcMain.handle("dialog:openDirectory", async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory", "createDirectory"],
});
return result;
});
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile"],
...options,
});
return result;
});
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
const result = await dialog.showSaveDialog(mainWindow, options);
return result;
});
// Shell operations
ipcMain.handle("shell:openExternal", async (_, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("shell:openPath", async (_, filePath) => {
try {
await shell.openPath(filePath);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// App info
ipcMain.handle("app:getPath", async (_, name) => {
return app.getPath(name);
});
ipcMain.handle("app:getVersion", async () => {
return app.getVersion();
});
ipcMain.handle("app:isPackaged", async () => {
return app.isPackaged;
});
// Ping - for connection check
ipcMain.handle("ping", async () => {
return "pong";
});
// Get server URL for HTTP client
ipcMain.handle("server:getUrl", async () => {
return `http://localhost:${SERVER_PORT}`;
});

View File

@@ -0,0 +1,37 @@
/**
* Simplified Electron preload script
*
* Only exposes native features (dialogs, shell) and server URL.
* All other operations go through HTTP API.
*/
const { contextBridge, ipcRenderer } = require("electron");
// Expose minimal API for native features
contextBridge.exposeInMainWorld("electronAPI", {
// Platform info
platform: process.platform,
isElectron: true,
// Connection check
ping: () => ipcRenderer.invoke("ping"),
// Get server URL for HTTP client
getServerUrl: () => ipcRenderer.invoke("server:getUrl"),
// Native dialogs - better UX than prompt()
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options),
// Shell operations
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath),
// App info
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
getVersion: () => ipcRenderer.invoke("app:getVersion"),
isPackaged: () => ipcRenderer.invoke("app:isPackaged"),
});
console.log("[Preload] Electron API exposed (simplified mode)");

View File

@@ -94,7 +94,7 @@ export default function Home() {
try {
const api = getElectronAPI();
const result = await api.ping();
setIpcConnected(result === "pong" || result === "pong (mock)");
setIpcConnected(result === "pong");
} catch (error) {
console.error("IPC connection failed:", error);
setIpcConnected(false);
@@ -193,8 +193,8 @@ export default function Home() {
<SetupView />
{/* Environment indicator */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
Web Mode (Mock IPC)
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
Web Mode
</div>
)}
</main>
@@ -210,8 +210,8 @@ export default function Home() {
{/* Environment indicator - only show after mount to prevent hydration issues */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
Web Mode (Mock IPC)
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
Web Mode
</div>
)}

View File

@@ -413,14 +413,33 @@ export function Sidebar() {
return;
}
const project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
};
// Check if project already exists (by path) to preserve theme and other settings
const existingProject = projects.find((p) => p.path === path);
let project: Project;
if (existingProject) {
// Update existing project, preserving theme and other properties
project = {
...existingProject,
name, // Update name in case it changed
lastOpened: new Date().toISOString(),
};
// Update the project in the store (this will update the existing entry)
const updatedProjects = projects.map((p) =>
p.id === existingProject.id ? project : p
);
useAppStore.setState({ projects: updatedProjects });
} else {
// Create new project
project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
};
addProject(project);
}
addProject(project);
setCurrentProject(project);
// Check if app_spec.txt exists
@@ -455,7 +474,7 @@ export function Sidebar() {
});
}
}
}, [addProject, setCurrentProject]);
}, [projects, addProject, setCurrentProject]);
const handleRestoreProject = useCallback(
(projectId: string) => {

View File

@@ -4,14 +4,13 @@ import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Plus,
MessageSquare,
@@ -26,7 +25,6 @@ import {
import { cn } from "@/lib/utils";
import type { SessionListItem } from "@/types/electron";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useAppStore } from "@/store/app-store";
// Random session name generator
const adjectives = [

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useRef, useCallback, useEffect } from "react";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Loader2 } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
@@ -98,7 +98,7 @@ export function DescriptionImageDropZone({
});
};
const saveImageToTemp = async (
const saveImageToTemp = useCallback(async (
base64Data: string,
filename: string,
mimeType: string
@@ -107,8 +107,8 @@ export function DescriptionImageDropZone({
const api = getElectronAPI();
// Check if saveImageToTemp method exists
if (!api.saveImageToTemp) {
// Fallback for mock API - return a mock path in .automaker/images
console.log("[DescriptionImageDropZone] Using mock path for image");
// Fallback path when saveImageToTemp is not available
console.log("[DescriptionImageDropZone] Using fallback path for image");
return `.automaker/images/${Date.now()}_${filename}`;
}
@@ -124,7 +124,7 @@ export function DescriptionImageDropZone({
console.error("[DescriptionImageDropZone] Error saving image:", error);
return null;
}
};
}, [currentProject?.path]);
const processFiles = useCallback(
async (files: FileList) => {
@@ -193,7 +193,7 @@ export function DescriptionImageDropZone({
setIsProcessing(false);
},
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages]
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
);
const handleDrop = useCallback(

View File

@@ -149,12 +149,12 @@ export function AgentToolsView() {
setTerminalResult(null);
try {
// Simulate agent requesting terminal command execution
console.log(`[Agent Tool] Requesting to run command: ${terminalCommand}`);
// Terminal command simulation for demonstration purposes
console.log(`[Agent Tool] Simulating command: ${terminalCommand}`);
// In mock mode, simulate terminal output
// In real Electron mode, this would use child_process
const mockOutputs: Record<string, string> = {
// Simulated outputs for common commands (preview mode)
// In production, the agent executes commands via Claude SDK
const simulatedOutputs: Record<string, string> = {
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
pwd: currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
@@ -168,8 +168,8 @@ export function AgentToolsView() {
await new Promise((resolve) => setTimeout(resolve, 500));
const output =
mockOutputs[terminalCommand.toLowerCase()] ||
`Command executed: ${terminalCommand}\n(Mock output - real execution requires Electron mode)`;
simulatedOutputs[terminalCommand.toLowerCase()] ||
`[Preview] ${terminalCommand}\n(Terminal commands are executed by the agent during feature implementation)`;
setTerminalResult({
success: true,

View File

@@ -394,22 +394,25 @@ export function BoardView() {
}, []);
// Load features using features API
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
// If project switched, clear features first to prevent cross-contamination
// Also treat this as an initial load for the new project
if (previousPath !== null && currentPath !== previousPath) {
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
// If project switched, mark it but don't clear features yet
// We'll clear after successful API load to prevent data loss
if (isProjectSwitch) {
console.log(
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}, clearing features`
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`
);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
setFeatures([]);
setPersistedCategories([]); // Also clear categories
}
// Update the ref to track current project
@@ -424,6 +427,7 @@ export function BoardView() {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
// Keep cached features if API is unavailable
return;
}
@@ -441,10 +445,31 @@ export function BoardView() {
thinkingLevel: f.thinkingLevel || "none",
})
);
// Successfully loaded features - now safe to set them
setFeatures(featuresWithIds);
// Only clear categories on project switch AFTER successful load
if (isProjectSwitch) {
setPersistedCategories([]);
}
} else if (!result.success && result.error) {
console.error("[BoardView] API returned error:", result.error);
// If it's a new project or the error indicates no features found,
// that's expected - start with empty array
if (isProjectSwitch) {
setFeatures([]);
setPersistedCategories([]);
}
// Otherwise keep cached features
}
} catch (error) {
console.error("Failed to load features:", error);
// On error, keep existing cached features for the current project
// Only clear on project switch if we have no features from server
if (isProjectSwitch && cachedFeatures.length === 0) {
setFeatures([]);
setPersistedCategories([]);
}
} finally {
setIsLoading(false);
isInitialLoadRef.current = false;
@@ -1475,8 +1500,14 @@ export function BoardView() {
if (isRunning) {
map.in_progress.push(f);
} else {
// Otherwise, use the feature's status
map[f.status].push(f);
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
const status = f.status as ColumnId;
if (map[status]) {
map[status].push(f);
} else {
// Unknown status, default to backlog
map.backlog.push(f);
}
}
});

View File

@@ -61,12 +61,14 @@ export function AuthenticationStatusDisplay({
{claudeAuthStatus.method === "oauth_token_env"
? "Using CLAUDE_CODE_OAUTH_TOKEN"
: claudeAuthStatus.method === "oauth_token"
? "Using stored OAuth token"
? "Using stored OAuth token (claude login)"
: claudeAuthStatus.method === "api_key_env"
? "Using ANTHROPIC_API_KEY"
: claudeAuthStatus.method === "api_key"
? "Using stored API key"
: "Unknown method"}
: claudeAuthStatus.method === "credentials_file"
? "Using credentials file"
: `Using ${claudeAuthStatus.method || "detected"} authentication`}
</span>
</div>
</>
@@ -107,14 +109,16 @@ export function AuthenticationStatusDisplay({
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{codexAuthStatus.method === "cli_verified" ||
codexAuthStatus.method === "cli_tokens"
{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"
: "Unknown method"}
: `Using ${codexAuthStatus.method || "unknown"} authentication`}
</span>
</div>
</>

View File

@@ -68,19 +68,24 @@ export function useCliStatus() {
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
const auth = result.auth;
// Validate method is one of the expected values, default to "none"
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "none"] as const;
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, none
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "none"] as const;
type AuthMethod = typeof validMethods[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: "none";
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid: auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey,
oauthTokenValid: auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
@@ -96,27 +101,30 @@ export function useCliStatus() {
try {
const result = await api.setup.getCodexStatus();
if (result.success && result.auth) {
const auth = result.auth;
// Determine method - prioritize cli_verified and cli_tokens over auth_file
const method =
auth.method === "cli_verified" || auth.method === "cli_tokens"
? auth.method === "cli_verified"
? ("cli_verified" as const)
: ("cli_tokens" as const)
: auth.method === "auth_file"
? ("api_key" as const)
: auth.method === "env_var"
? ("env" as const)
: ("none" as const);
// 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
// Only set apiKeyValid for actual API key methods, not CLI login or subscription
apiKeyValid:
method === "cli_verified" || method === "cli_tokens"
method === "cli_verified" || method === "cli_tokens" || method === "subscription"
? undefined
: auth.hasAuthFile || auth.hasEnvKey,
: auth.hasAuthFile || auth.hasEnvKey || auth.hasEnvApiKey,
hasSubscription: auth.hasSubscription,
cliLoggedIn: auth.cliLoggedIn,
};
setCodexAuthStatus(authStatus);
}

View File

@@ -21,7 +21,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { getElectronAPI, type Project } from "@/lib/electron";
import { initializeProject } from "@/lib/project-init";
import {
FolderOpen,
@@ -105,14 +105,34 @@ export function WelcomeView() {
return;
}
const project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
};
// Check if project already exists (by path) to preserve theme and other settings
const existingProject = projects.find((p) => p.path === path);
let project: Project;
if (existingProject) {
// Update existing project, preserving theme and other properties
project = {
...existingProject,
name, // Update name in case it changed
lastOpened: new Date().toISOString(),
};
// Update the project in the store (this will update the existing entry)
const updatedProjects = projects.map((p) =>
p.id === existingProject.id ? project : p
);
// We need to manually update projects since addProject would create a duplicate
useAppStore.setState({ projects: updatedProjects });
} else {
// Create new project
project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
};
addProject(project);
}
addProject(project);
setCurrentProject(project);
// Show initialization dialog if files were created
@@ -148,7 +168,7 @@ export function WelcomeView() {
setIsOpening(false);
}
},
[addProject, setCurrentProject, analyzeProject]
[projects, addProject, setCurrentProject, analyzeProject]
);
const handleOpenProject = useCallback(async () => {

View File

@@ -443,13 +443,109 @@ export const isElectron = (): boolean => {
return typeof window !== "undefined" && window.isElectron === true;
};
// Get the Electron API or a mock for web development
// Check if backend server is available
let serverAvailable: boolean | null = null;
let serverCheckPromise: Promise<boolean> | null = null;
export const checkServerAvailable = async (): Promise<boolean> => {
if (serverAvailable !== null) return serverAvailable;
if (serverCheckPromise) return serverCheckPromise;
serverCheckPromise = (async () => {
try {
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/health`, {
method: "GET",
signal: AbortSignal.timeout(2000),
});
serverAvailable = response.ok;
} catch {
serverAvailable = false;
}
return serverAvailable;
})();
return serverCheckPromise;
};
// Reset server check (useful for retrying connection)
export const resetServerCheck = (): void => {
serverAvailable = null;
serverCheckPromise = null;
};
// Cached HTTP client instance
let httpClientInstance: ElectronAPI | null = null;
// Check if we're in simplified Electron mode (HTTP backend instead of IPC)
const isSimplifiedElectronMode = (): boolean => {
if (typeof window === "undefined") return false;
const api = window.electronAPI as any;
// Simplified mode has isElectron flag and getServerUrl but NOT readFile
return api?.isElectron === true &&
typeof api?.getServerUrl === "function" &&
typeof api?.readFile !== "function";
};
// Get the Electron API or HTTP client for web mode
// In simplified Electron mode and web mode, uses HTTP client
export const getElectronAPI = (): ElectronAPI => {
// Check if we're in simplified Electron mode (uses HTTP backend)
if (isSimplifiedElectronMode()) {
if (typeof window !== "undefined" && !httpClientInstance) {
const { getHttpApiClient } = require("./http-api-client");
httpClientInstance = getHttpApiClient();
}
return httpClientInstance!;
}
// Full Electron API with IPC
if (isElectron() && window.electronAPI) {
return window.electronAPI;
}
// Return mock API for web development
// Web mode: use HTTP API client
if (typeof window !== "undefined") {
if (!httpClientInstance) {
const { getHttpApiClient } = require("./http-api-client");
httpClientInstance = getHttpApiClient();
}
return httpClientInstance!;
}
// SSR fallback - this shouldn't be called during actual operation
throw new Error("Cannot get Electron API during SSR");
};
// Async version that checks server availability first
export const getElectronAPIAsync = async (): Promise<ElectronAPI> => {
// Simplified Electron mode or web mode: use HTTP client
if (isSimplifiedElectronMode() || !isElectron()) {
if (typeof window !== "undefined") {
const { getHttpApiClient } = await import("./http-api-client");
return getHttpApiClient();
}
}
// Full Electron API with IPC
if (isElectron() && window.electronAPI) {
return window.electronAPI;
}
throw new Error("Cannot get Electron API during SSR");
};
// Check if backend is connected (for showing connection status in UI)
export const isBackendConnected = async (): Promise<boolean> => {
// Full Electron mode: backend is built-in
if (isElectron() && !isSimplifiedElectronMode()) return true;
// Simplified Electron or web mode: check server availability
return await checkServerAvailable();
};
// Mock API for development/fallback when no backend is available
const getMockElectronAPI = (): ElectronAPI => {
return {
ping: async () => "pong (mock)",

View File

@@ -0,0 +1,664 @@
/**
* HTTP API Client for web mode
*
* This client provides the same API as the Electron IPC bridge,
* but communicates with the backend server via HTTP/WebSocket.
*/
import type {
ElectronAPI,
FileResult,
WriteResult,
ReaddirResult,
StatResult,
DialogResult,
SaveImageResult,
AutoModeAPI,
FeaturesAPI,
SuggestionsAPI,
SpecRegenerationAPI,
AutoModeEvent,
SuggestionsEvent,
SpecRegenerationEvent,
FeatureSuggestion,
SuggestionType,
} from "./electron";
import type { Feature } from "@/store/app-store";
import type {
WorktreeAPI,
GitAPI,
ModelDefinition,
ProviderStatus,
} from "@/types/electron";
// Check if we're in simplified Electron mode (Electron with HTTP backend)
const isSimplifiedElectronMode = (): boolean => {
if (typeof window === "undefined") return false;
const api = (window as any).electronAPI;
// Simplified mode has isElectron flag but limited methods
return api?.isElectron === true && typeof api?.getServerUrl === "function";
};
// Check if native Electron dialogs are available
const hasNativeDialogs = (): boolean => {
if (typeof window === "undefined") return false;
const api = (window as any).electronAPI;
return typeof api?.openDirectory === "function";
};
// Server URL - configurable via environment variable or Electron
const getServerUrl = async (): Promise<string> => {
if (typeof window !== "undefined") {
// In simplified Electron mode, get URL from main process
const api = (window as any).electronAPI;
if (api?.getServerUrl) {
try {
return await api.getServerUrl();
} catch {
// Fall through to defaults
}
}
// Check for environment variable
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
if (envUrl) return envUrl;
// Default to localhost for development
return "http://localhost:3008";
}
return "http://localhost:3008";
};
// Synchronous version for constructor (uses default, then updates)
const getServerUrlSync = (): string => {
if (typeof window !== "undefined") {
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
if (envUrl) return envUrl;
}
return "http://localhost:3008";
};
// Get API key from environment variable
const getApiKey = (): string | null => {
if (typeof window !== "undefined") {
return process.env.NEXT_PUBLIC_AUTOMAKER_API_KEY || null;
}
return null;
};
type EventType =
| "agent:stream"
| "auto-mode:event"
| "suggestions:event"
| "spec-regeneration:event";
type EventCallback = (payload: unknown) => void;
/**
* HTTP API Client that implements ElectronAPI interface
*/
export class HttpApiClient implements ElectronAPI {
private serverUrl: string;
private ws: WebSocket | null = null;
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
private reconnectTimer: NodeJS.Timeout | null = null;
private isConnecting = false;
constructor() {
this.serverUrl = getServerUrlSync();
// Update server URL asynchronously if in Electron
this.initServerUrl();
this.connectWebSocket();
}
private async initServerUrl(): Promise<void> {
const url = await getServerUrl();
if (url !== this.serverUrl) {
this.serverUrl = url;
// Reconnect WebSocket with new URL
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.connectWebSocket();
}
}
private connectWebSocket(): void {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
return;
}
this.isConnecting = true;
try {
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log("[HttpApiClient] WebSocket connected");
this.isConnecting = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const callbacks = this.eventCallbacks.get(data.type);
if (callbacks) {
callbacks.forEach((cb) => cb(data.payload));
}
} catch (error) {
console.error("[HttpApiClient] Failed to parse WebSocket message:", error);
}
};
this.ws.onclose = () => {
console.log("[HttpApiClient] WebSocket disconnected");
this.isConnecting = false;
this.ws = null;
// Attempt to reconnect after 5 seconds
if (!this.reconnectTimer) {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connectWebSocket();
}, 5000);
}
};
this.ws.onerror = (error) => {
console.error("[HttpApiClient] WebSocket error:", error);
this.isConnecting = false;
};
} catch (error) {
console.error("[HttpApiClient] Failed to create WebSocket:", error);
this.isConnecting = false;
}
}
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
if (!this.eventCallbacks.has(type)) {
this.eventCallbacks.set(type, new Set());
}
this.eventCallbacks.get(type)!.add(callback);
// Ensure WebSocket is connected
this.connectWebSocket();
return () => {
const callbacks = this.eventCallbacks.get(type);
if (callbacks) {
callbacks.delete(callback);
}
};
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const apiKey = getApiKey();
if (apiKey) {
headers["X-API-Key"] = apiKey;
}
return headers;
}
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "POST",
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return response.json();
}
private async get<T>(endpoint: string): Promise<T> {
const headers = this.getHeaders();
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
return response.json();
}
// Basic operations
async ping(): Promise<string> {
const result = await this.get<{ status: string }>("/api/health");
return result.status === "ok" ? "pong" : "error";
}
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
// Use native Electron shell if available (better UX)
if (hasNativeDialogs()) {
const api = (window as any).electronAPI;
if (api.openExternalLink) {
return api.openExternalLink(url);
}
}
// Web mode: open in new tab
window.open(url, "_blank", "noopener,noreferrer");
return { success: true };
}
// File picker - uses native Electron dialogs when available, otherwise prompt
async openDirectory(): Promise<DialogResult> {
// Use native Electron dialog if available
if (hasNativeDialogs()) {
const api = (window as any).electronAPI;
return api.openDirectory();
}
// Web mode: show a modal to let user type/paste path
const path = prompt("Enter project directory path:");
if (!path) {
return { canceled: true, filePaths: [] };
}
// Validate with server
const result = await this.post<{
success: boolean;
path?: string;
error?: string;
}>("/api/fs/validate-path", { filePath: path });
if (result.success && result.path) {
return { canceled: false, filePaths: [result.path] };
}
alert(result.error || "Invalid path");
return { canceled: true, filePaths: [] };
}
async openFile(options?: object): Promise<DialogResult> {
// Use native Electron dialog if available
if (hasNativeDialogs()) {
const api = (window as any).electronAPI;
return api.openFile(options);
}
// Web mode: prompt for file path
const path = prompt("Enter file path:");
if (!path) {
return { canceled: true, filePaths: [] };
}
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath: path }
);
if (result.success && result.exists) {
return { canceled: false, filePaths: [path] };
}
alert("File does not exist");
return { canceled: true, filePaths: [] };
}
// File system operations
async readFile(filePath: string): Promise<FileResult> {
return this.post("/api/fs/read", { filePath });
}
async writeFile(filePath: string, content: string): Promise<WriteResult> {
return this.post("/api/fs/write", { filePath, content });
}
async mkdir(dirPath: string): Promise<WriteResult> {
return this.post("/api/fs/mkdir", { dirPath });
}
async readdir(dirPath: string): Promise<ReaddirResult> {
return this.post("/api/fs/readdir", { dirPath });
}
async exists(filePath: string): Promise<boolean> {
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath }
);
return result.exists;
}
async stat(filePath: string): Promise<StatResult> {
return this.post("/api/fs/stat", { filePath });
}
async deleteFile(filePath: string): Promise<WriteResult> {
return this.post("/api/fs/delete", { filePath });
}
async trashItem(filePath: string): Promise<WriteResult> {
// In web mode, trash is just delete
return this.deleteFile(filePath);
}
async getPath(name: string): Promise<string> {
// Server provides data directory
if (name === "userData") {
const result = await this.get<{ dataDir: string }>("/api/health/detailed");
return result.dataDir || "/data";
}
return `/data/${name}`;
}
async saveImageToTemp(
data: string,
filename: string,
mimeType: string,
projectPath?: string
): Promise<SaveImageResult> {
return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath });
}
// CLI checks - server-side
async checkClaudeCli(): Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}> {
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 = {
getAvailable: async (): Promise<{
success: boolean;
models?: ModelDefinition[];
error?: string;
}> => {
return this.get("/api/models/available");
},
checkProviders: async (): Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}> => {
return this.get("/api/models/providers");
},
};
async testOpenAIConnection(apiKey?: string): Promise<{
success: boolean;
message?: string;
error?: string;
}> {
return this.post("/api/setup/test-openai", { apiKey });
}
// Setup API
setup = {
getClaudeStatus: (): Promise<{
success: boolean;
status?: string;
installed?: boolean;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
hasCredentialsFile?: boolean;
hasToken?: boolean;
hasStoredOAuthToken?: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
hasEnvOAuthToken?: boolean;
};
error?: string;
}> => 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<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-claude"),
installCodex: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-codex"),
authClaude: (): Promise<{
success: boolean;
token?: string;
requiresManualAuth?: boolean;
terminalOpened?: boolean;
command?: string;
error?: string;
message?: string;
output?: string;
}> => 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: (provider: string, apiKey: string): Promise<{
success: boolean;
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
getApiKeys: (): Promise<{
success: boolean;
hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean;
}> => 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<{
success: boolean;
platform: string;
arch: string;
homeDir: string;
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
}> => this.get("/api/setup/platform"),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
},
onAuthProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
},
};
// Features API
features: FeaturesAPI = {
getAll: (projectPath: string) =>
this.post("/api/features/list", { projectPath }),
get: (projectPath: string, featureId: string) =>
this.post("/api/features/get", { projectPath, featureId }),
create: (projectPath: string, feature: Feature) =>
this.post("/api/features/create", { projectPath, feature }),
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
this.post("/api/features/update", { projectPath, featureId, updates }),
delete: (projectPath: string, featureId: string) =>
this.post("/api/features/delete", { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
this.post("/api/features/agent-output", { projectPath, featureId }),
};
// Auto Mode API
autoMode: AutoModeAPI = {
start: (projectPath: string, maxConcurrency?: number) =>
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
stop: (projectPath: string) =>
this.post("/api/auto-mode/stop", { projectPath }),
stopFeature: (featureId: string) =>
this.post("/api/auto-mode/stop-feature", { featureId }),
status: (projectPath?: string) =>
this.post("/api/auto-mode/status", { projectPath }),
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/resume-feature", { projectPath, featureId }),
contextExists: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
analyzeProject: (projectPath: string) =>
this.post("/api/auto-mode/analyze-project", { projectPath }),
followUpFeature: (
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
) =>
this.post("/api/auto-mode/follow-up-feature", {
projectPath,
featureId,
prompt,
imagePaths,
}),
commitFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent("auto-mode:event", callback as EventCallback);
},
};
// Worktree API
worktree: WorktreeAPI = {
revertFeature: (projectPath: string, featureId: string) =>
this.post("/api/worktree/revert", { projectPath, featureId }),
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
this.post("/api/worktree/merge", { projectPath, featureId, options }),
getInfo: (projectPath: string, featureId: string) =>
this.post("/api/worktree/info", { projectPath, featureId }),
getStatus: (projectPath: string, featureId: string) =>
this.post("/api/worktree/status", { projectPath, featureId }),
list: (projectPath: string) =>
this.post("/api/worktree/list", { projectPath }),
getDiffs: (projectPath: string, featureId: string) =>
this.post("/api/worktree/diffs", { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }),
};
// Git API
git: GitAPI = {
getDiffs: (projectPath: string) =>
this.post("/api/git/diffs", { projectPath }),
getFileDiff: (projectPath: string, filePath: string) =>
this.post("/api/git/file-diff", { projectPath, filePath }),
};
// Suggestions API
suggestions: SuggestionsAPI = {
generate: (projectPath: string, suggestionType?: SuggestionType) =>
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
stop: () => this.post("/api/suggestions/stop"),
status: () => this.get("/api/suggestions/status"),
onEvent: (callback: (event: SuggestionsEvent) => void) => {
return this.subscribeToEvent("suggestions:event", callback as EventCallback);
},
};
// Spec Regeneration API
specRegeneration: SpecRegenerationAPI = {
create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) =>
this.post("/api/spec-regeneration/create", {
projectPath,
projectOverview,
generateFeatures,
}),
generate: (projectPath: string, projectDefinition: string) =>
this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }),
generateFeatures: (projectPath: string) =>
this.post("/api/spec-regeneration/generate-features", { projectPath }),
stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
return this.subscribeToEvent(
"spec-regeneration:event",
callback as EventCallback
);
},
};
// Running Agents API
runningAgents = {
getAll: (): Promise<{
success: boolean;
runningAgents?: Array<{
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
}>;
totalCount?: number;
autoLoopRunning?: boolean;
error?: string;
}> => this.get("/api/running-agents"),
};
}
// Singleton instance
let httpApiClientInstance: HttpApiClient | null = null;
export function getHttpApiClient(): HttpApiClient {
if (!httpApiClientInstance) {
httpApiClientInstance = new HttpApiClient();
}
return httpApiClientInstance;
}

View File

@@ -1138,26 +1138,32 @@ export const useAppStore = create<AppState & AppActions>()(
{
name: "automaker-storage",
partialize: (state) => ({
// Project management
projects: state.projects,
currentProject: state.currentProject,
trashedProjects: state.trashedProjects,
projectHistory: state.projectHistory,
projectHistoryIndex: state.projectHistoryIndex,
// Features - cached locally for faster hydration (authoritative source is server)
features: state.features,
// UI state
currentView: state.currentView,
theme: state.theme,
sidebarOpen: state.sidebarOpen,
apiKeys: state.apiKeys,
chatSessions: state.chatSessions,
chatHistoryOpen: state.chatHistoryOpen,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
// Settings
apiKeys: state.apiKeys,
maxConcurrency: state.maxConcurrency,
autoModeByProject: state.autoModeByProject,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
defaultSkipTests: state.defaultSkipTests,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound,
// Profiles and sessions
aiProfiles: state.aiProfiles,
chatSessions: state.chatSessions,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
}),
}

View File

@@ -10,10 +10,19 @@ export interface CliStatus {
error?: string;
}
// Claude Auth Method - all possible authentication sources
export type ClaudeAuthMethod =
| "oauth_token_env" // CLAUDE_CODE_OAUTH_TOKEN environment variable
| "oauth_token" // Stored OAuth token from claude login
| "api_key_env" // ANTHROPIC_API_KEY environment variable
| "api_key" // Manually stored API key
| "credentials_file" // Generic credentials file detection
| "none";
// Claude Auth Status
export interface ClaudeAuthStatus {
authenticated: boolean;
method: "oauth_token_env" | "oauth_token" | "api_key" | "api_key_env" | "none";
method: ClaudeAuthMethod;
hasCredentialsFile?: boolean;
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
@@ -22,12 +31,23 @@ export interface ClaudeAuthStatus {
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: "api_key" | "env" | "cli_verified" | "cli_tokens" | "none";
method: CodexAuthMethod;
apiKeyValid?: boolean;
mcpConfigured?: boolean;
hasSubscription?: boolean;
cliLoggedIn?: boolean;
error?: string;
}

45
apps/server/.env.example Normal file
View File

@@ -0,0 +1,45 @@
# Automaker Server Configuration
# Copy this file to .env and configure your settings
# ============================================
# REQUIRED
# ============================================
# Your Anthropic API key for Claude models
ANTHROPIC_API_KEY=sk-ant-...
# ============================================
# OPTIONAL - Security
# ============================================
# API key for authenticating requests (leave empty to disable auth)
# If set, all API requests must include X-API-Key header
AUTOMAKER_API_KEY=
# Restrict file operations to these directories (comma-separated)
# Important for security in multi-tenant environments
ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www
# CORS origin - which domains can access the API
# Use "*" for development, set specific origin for production
CORS_ORIGIN=*
# ============================================
# OPTIONAL - Server
# ============================================
# Port to run the server on
PORT=3008
# Data directory for sessions and metadata
DATA_DIR=./data
# ============================================
# OPTIONAL - Additional AI Providers
# ============================================
# OpenAI API key (for Codex CLI support)
OPENAI_API_KEY=
# Google API key (for future Gemini support)
GOOGLE_API_KEY=

55
apps/server/Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# Automaker Backend Server
# Multi-stage build for minimal production image
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY apps/server/package*.json ./apps/server/
# Install dependencies
RUN npm ci --workspace=apps/server
# Copy source
COPY apps/server ./apps/server
# Build TypeScript
RUN npm run build --workspace=apps/server
# Production stage
FROM node:20-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S automaker && \
adduser -S automaker -u 1001
# Copy built files and production dependencies
COPY --from=builder /app/apps/server/dist ./dist
COPY --from=builder /app/apps/server/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
# Create data directory
RUN mkdir -p /data && chown automaker:automaker /data
# Switch to non-root user
USER automaker
# Environment variables
ENV NODE_ENV=production
ENV PORT=3008
ENV DATA_DIR=/data
# Expose port
EXPOSE 3008
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3008/api/health || exit 1
# Start server
CMD ["node", "dist/index.js"]

29
apps/server/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@automaker/server",
"version": "0.1.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/cors": "^2.8.18",
"@types/express": "^5.0.1",
"@types/node": "^20",
"@types/ws": "^8.18.1",
"tsx": "^4.19.4",
"typescript": "^5"
}
}

165
apps/server/src/index.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Automaker Backend Server
*
* Provides HTTP/WebSocket API for both web and Electron modes.
* In Electron mode, this server runs locally.
* In web mode, this server runs on a remote host.
*/
import express from "express";
import cors from "cors";
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
import dotenv from "dotenv";
import { createEventEmitter, type EventEmitter } from "./lib/events.js";
import { initAllowedPaths } from "./lib/security.js";
import { authMiddleware, getAuthStatus } from "./lib/auth.js";
import { createFsRoutes } from "./routes/fs.js";
import { createHealthRoutes } from "./routes/health.js";
import { createAgentRoutes } from "./routes/agent.js";
import { createSessionsRoutes } from "./routes/sessions.js";
import { createFeaturesRoutes } from "./routes/features.js";
import { createAutoModeRoutes } from "./routes/auto-mode.js";
import { createWorktreeRoutes } from "./routes/worktree.js";
import { createGitRoutes } from "./routes/git.js";
import { createSetupRoutes } from "./routes/setup.js";
import { createSuggestionsRoutes } from "./routes/suggestions.js";
import { createModelsRoutes } from "./routes/models.js";
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
// Load environment variables
dotenv.config();
const PORT = parseInt(process.env.PORT || "3008", 10);
const DATA_DIR = process.env.DATA_DIR || "./data";
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (!hasAnthropicKey) {
console.warn(`
╔═══════════════════════════════════════════════════════════════════════╗
║ ⚠️ WARNING: ANTHROPIC_API_KEY not set ║
║ ║
║ The Claude Agent SDK requires ANTHROPIC_API_KEY to function. ║
${hasOAuthToken ? ' You have CLAUDE_CODE_OAUTH_TOKEN set - this is for CLI auth only.' : ''}
║ ║
║ Set your API key: ║
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
║ ║
║ Or add to apps/server/.env: ║
║ ANTHROPIC_API_KEY=sk-ant-... ║
╚═══════════════════════════════════════════════════════════════════════╝
`);
} else {
console.log("[Server] ✓ ANTHROPIC_API_KEY detected");
}
// Initialize security
initAllowedPaths();
// Create Express app
const app = express();
// Middleware
app.use(
cors({
origin: process.env.CORS_ORIGIN || "*",
credentials: true,
})
);
app.use(express.json({ limit: "50mb" }));
// Create shared event emitter for streaming
const events: EventEmitter = createEventEmitter();
// Create services
const agentService = new AgentService(DATA_DIR, events);
const featureLoader = new FeatureLoader();
// Initialize services
(async () => {
await agentService.initialize();
console.log("[Server] Agent service initialized");
})();
// Mount API routes - health is unauthenticated for monitoring
app.use("/api/health", createHealthRoutes());
// Apply authentication to all other routes
app.use("/api", authMiddleware);
app.use("/api/fs", createFsRoutes(events));
app.use("/api/agent", createAgentRoutes(agentService, events));
app.use("/api/sessions", createSessionsRoutes(agentService));
app.use("/api/features", createFeaturesRoutes(featureLoader));
app.use("/api/auto-mode", createAutoModeRoutes(events));
app.use("/api/worktree", createWorktreeRoutes());
app.use("/api/git", createGitRoutes());
app.use("/api/setup", createSetupRoutes());
app.use("/api/suggestions", createSuggestionsRoutes(events));
app.use("/api/models", createModelsRoutes());
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
app.use("/api/running-agents", createRunningAgentsRoutes());
// Create HTTP server
const server = createServer(app);
// WebSocket server for streaming events
const wss = new WebSocketServer({ server, path: "/api/events" });
wss.on("connection", (ws: WebSocket) => {
console.log("[WebSocket] Client connected");
// Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, payload }));
}
});
ws.on("close", () => {
console.log("[WebSocket] Client disconnected");
unsubscribe();
});
ws.on("error", (error) => {
console.error("[WebSocket] Error:", error);
unsubscribe();
});
});
// Start server
server.listen(PORT, () => {
console.log(`
╔═══════════════════════════════════════════════════════╗
║ Automaker Backend Server ║
╠═══════════════════════════════════════════════════════╣
║ HTTP API: http://localhost:${PORT}
║ WebSocket: ws://localhost:${PORT}/api/events ║
║ Health: http://localhost:${PORT}/api/health ║
╚═══════════════════════════════════════════════════════╝
`);
});
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down...");
server.close(() => {
console.log("Server closed");
process.exit(0);
});
});
process.on("SIGINT", () => {
console.log("SIGINT received, shutting down...");
server.close(() => {
console.log("Server closed");
process.exit(0);
});
});

View File

@@ -0,0 +1,62 @@
/**
* Authentication middleware for API security
*
* Supports API key authentication via header or environment variable.
*/
import type { Request, Response, NextFunction } from "express";
// API key from environment (optional - if not set, auth is disabled)
const API_KEY = process.env.AUTOMAKER_API_KEY;
/**
* Authentication middleware
*
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
* If not set, allows all requests (development mode).
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// If no API key is configured, allow all requests
if (!API_KEY) {
next();
return;
}
// Check for API key in header
const providedKey = req.headers["x-api-key"] as string | undefined;
if (!providedKey) {
res.status(401).json({
success: false,
error: "Authentication required. Provide X-API-Key header.",
});
return;
}
if (providedKey !== API_KEY) {
res.status(403).json({
success: false,
error: "Invalid API key.",
});
return;
}
next();
}
/**
* Check if authentication is enabled
*/
export function isAuthEnabled(): boolean {
return !!API_KEY;
}
/**
* Get authentication status for health endpoint
*/
export function getAuthStatus(): { enabled: boolean; method: string } {
return {
enabled: !!API_KEY,
method: API_KEY ? "api_key" : "none",
};
}

View File

@@ -0,0 +1,57 @@
/**
* Event emitter for streaming events to WebSocket clients
*/
export type EventType =
| "agent:stream"
| "auto-mode:event"
| "auto-mode:started"
| "auto-mode:stopped"
| "auto-mode:idle"
| "auto-mode:error"
| "feature:started"
| "feature:completed"
| "feature:stopped"
| "feature:error"
| "feature:progress"
| "feature:tool-use"
| "feature:follow-up-started"
| "feature:follow-up-completed"
| "feature:verified"
| "feature:committed"
| "project:analysis-started"
| "project:analysis-progress"
| "project:analysis-completed"
| "project:analysis-error"
| "suggestions:event"
| "spec-regeneration:event";
export type EventCallback = (type: EventType, payload: unknown) => void;
export interface EventEmitter {
emit: (type: EventType, payload: unknown) => void;
subscribe: (callback: EventCallback) => () => void;
}
export function createEventEmitter(): EventEmitter {
const subscribers = new Set<EventCallback>();
return {
emit(type: EventType, payload: unknown) {
for (const callback of subscribers) {
try {
callback(type, payload);
} catch (error) {
console.error("Error in event subscriber:", error);
}
}
},
subscribe(callback: EventCallback) {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
},
};
}

View File

@@ -0,0 +1,72 @@
/**
* Security utilities for path validation
*/
import path from "path";
// Allowed project directories - loaded from environment
const allowedPaths = new Set<string>();
/**
* Initialize allowed paths from environment variable
*/
export function initAllowedPaths(): void {
const dirs = process.env.ALLOWED_PROJECT_DIRS;
if (dirs) {
for (const dir of dirs.split(",")) {
const trimmed = dir.trim();
if (trimmed) {
allowedPaths.add(path.resolve(trimmed));
}
}
}
// Always allow the data directory
const dataDir = process.env.DATA_DIR;
if (dataDir) {
allowedPaths.add(path.resolve(dataDir));
}
}
/**
* Add a path to the allowed list
*/
export function addAllowedPath(filePath: string): void {
allowedPaths.add(path.resolve(filePath));
}
/**
* Check if a path is allowed
*/
export function isPathAllowed(filePath: string): boolean {
const resolved = path.resolve(filePath);
// Check if the path is under any allowed directory
for (const allowed of allowedPaths) {
if (resolved.startsWith(allowed + path.sep) || resolved === allowed) {
return true;
}
}
return false;
}
/**
* Validate a path and throw if not allowed
*/
export function validatePath(filePath: string): string {
const resolved = path.resolve(filePath);
if (!isPathAllowed(resolved)) {
throw new Error(`Access denied: ${filePath} is not in an allowed directory`);
}
return resolved;
}
/**
* Get list of allowed paths (for debugging)
*/
export function getAllowedPaths(): string[] {
return Array.from(allowedPaths);
}

View File

@@ -0,0 +1,132 @@
/**
* Agent routes - HTTP API for Claude agent interactions
*/
import { Router, type Request, type Response } from "express";
import { AgentService } from "../services/agent-service.js";
import type { EventEmitter } from "../lib/events.js";
export function createAgentRoutes(
agentService: AgentService,
_events: EventEmitter
): Router {
const router = Router();
// Start a conversation
router.post("/start", async (req: Request, res: Response) => {
try {
const { sessionId, workingDirectory } = req.body as {
sessionId: string;
workingDirectory?: string;
};
if (!sessionId) {
res.status(400).json({ success: false, error: "sessionId is required" });
return;
}
const result = await agentService.startConversation({
sessionId,
workingDirectory,
});
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Send a message
router.post("/send", async (req: Request, res: Response) => {
try {
const { sessionId, message, workingDirectory, imagePaths } = req.body as {
sessionId: string;
message: string;
workingDirectory?: string;
imagePaths?: string[];
};
if (!sessionId || !message) {
res
.status(400)
.json({ success: false, error: "sessionId and message are required" });
return;
}
// Start the message processing (don't await - it streams via WebSocket)
agentService
.sendMessage({
sessionId,
message,
workingDirectory,
imagePaths,
})
.catch((error) => {
console.error("[Agent Route] Error sending message:", error);
});
// Return immediately - responses come via WebSocket
res.json({ success: true, message: "Message sent" });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get conversation history
router.post("/history", async (req: Request, res: Response) => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({ success: false, error: "sessionId is required" });
return;
}
const result = agentService.getHistory(sessionId);
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Stop execution
router.post("/stop", async (req: Request, res: Response) => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({ success: false, error: "sessionId is required" });
return;
}
const result = await agentService.stopExecution(sessionId);
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Clear conversation
router.post("/clear", async (req: Request, res: Response) => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({ success: false, error: "sessionId is required" });
return;
}
const result = await agentService.clearSession(sessionId);
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -0,0 +1,263 @@
/**
* Auto Mode routes - HTTP API for autonomous feature implementation
*
* Uses the AutoModeService for real feature execution with Claude Agent SDK
*/
import { Router, type Request, type Response } from "express";
import type { EventEmitter } from "../lib/events.js";
import { AutoModeService } from "../services/auto-mode-service.js";
export function createAutoModeRoutes(events: EventEmitter): Router {
const router = Router();
const autoModeService = new AutoModeService(events);
// Start auto mode loop
router.post("/start", async (req: Request, res: Response) => {
try {
const { projectPath, maxConcurrency } = req.body as {
projectPath: string;
maxConcurrency?: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath is required" });
return;
}
await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3);
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Stop auto mode loop
router.post("/stop", async (req: Request, res: Response) => {
try {
const runningCount = await autoModeService.stopAutoLoop();
res.json({ success: true, runningFeatures: runningCount });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Stop a specific feature
router.post("/stop-feature", async (req: Request, res: Response) => {
try {
const { featureId } = req.body as { featureId: string };
if (!featureId) {
res.status(400).json({ success: false, error: "featureId is required" });
return;
}
const stopped = await autoModeService.stopFeature(featureId);
res.json({ success: true, stopped });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get auto mode status
router.post("/status", async (req: Request, res: Response) => {
try {
const status = autoModeService.getStatus();
res.json({
success: true,
...status,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Run a single feature
router.post("/run-feature", async (req: Request, res: Response) => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {
projectPath: string;
featureId: string;
useWorktrees?: boolean;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
// Start execution in background
autoModeService
.executeFeature(projectPath, featureId, useWorktrees ?? true, false)
.catch((error) => {
console.error(`[AutoMode] Feature ${featureId} error:`, error);
});
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Verify a feature
router.post("/verify-feature", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
const passes = await autoModeService.verifyFeature(projectPath, featureId);
res.json({ success: true, passes });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Resume a feature
router.post("/resume-feature", async (req: Request, res: Response) => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {
projectPath: string;
featureId: string;
useWorktrees?: boolean;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
// Start resume in background
autoModeService
.resumeFeature(projectPath, featureId, useWorktrees ?? true)
.catch((error) => {
console.error(`[AutoMode] Resume feature ${featureId} error:`, error);
});
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Check if context exists for a feature
router.post("/context-exists", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
const exists = await autoModeService.contextExists(projectPath, featureId);
res.json({ success: true, exists });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Analyze project
router.post("/analyze-project", async (req: Request, res: Response) => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath is required" });
return;
}
// Start analysis in background
autoModeService.analyzeProject(projectPath).catch((error) => {
console.error(`[AutoMode] Project analysis error:`, error);
});
res.json({ success: true, message: "Project analysis started" });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Follow up on a feature
router.post("/follow-up-feature", async (req: Request, res: Response) => {
try {
const { projectPath, featureId, prompt, imagePaths } = req.body as {
projectPath: string;
featureId: string;
prompt: string;
imagePaths?: string[];
};
if (!projectPath || !featureId || !prompt) {
res.status(400).json({
success: false,
error: "projectPath, featureId, and prompt are required",
});
return;
}
// Start follow-up in background
autoModeService
.followUpFeature(projectPath, featureId, prompt, imagePaths)
.catch((error) => {
console.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
});
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Commit feature changes
router.post("/commit-feature", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
const commitHash = await autoModeService.commitFeature(projectPath, featureId);
res.json({ success: true, commitHash });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -0,0 +1,159 @@
/**
* Features routes - HTTP API for feature management
*/
import { Router, type Request, type Response } from "express";
import { FeatureLoader, type Feature } from "../services/feature-loader.js";
import { addAllowedPath } from "../lib/security.js";
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
const router = Router();
// List all features for a project
router.post("/list", async (req: Request, res: Response) => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath is required" });
return;
}
// Add project path to allowed paths
addAllowedPath(projectPath);
const features = await featureLoader.getAll(projectPath);
res.json({ success: true, features });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get a single feature
router.post("/get", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
const feature = await featureLoader.get(projectPath, featureId);
if (!feature) {
res.status(404).json({ success: false, error: "Feature not found" });
return;
}
res.json({ success: true, feature });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Create a new feature
router.post("/create", async (req: Request, res: Response) => {
try {
const { projectPath, feature } = req.body as {
projectPath: string;
feature: Partial<Feature>;
};
if (!projectPath || !feature) {
res
.status(400)
.json({ success: false, error: "projectPath and feature are required" });
return;
}
// Add project path to allowed paths
addAllowedPath(projectPath);
const created = await featureLoader.create(projectPath, feature);
res.json({ success: true, feature: created });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Update a feature
router.post("/update", async (req: Request, res: Response) => {
try {
const { projectPath, featureId, updates } = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
};
if (!projectPath || !featureId || !updates) {
res.status(400).json({
success: false,
error: "projectPath, featureId, and updates are required",
});
return;
}
const updated = await featureLoader.update(projectPath, featureId, updates);
res.json({ success: true, feature: updated });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Delete a feature
router.post("/delete", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
const success = await featureLoader.delete(projectPath, featureId);
res.json({ success });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get agent output for a feature
router.post("/agent-output", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId are required" });
return;
}
const content = await featureLoader.getAgentOutput(projectPath, featureId);
res.json({ success: true, content });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -0,0 +1,221 @@
/**
* File system routes
* Provides REST API equivalents for Electron IPC file operations
*/
import { Router, type Request, type Response } from "express";
import fs from "fs/promises";
import path from "path";
import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js";
import type { EventEmitter } from "../lib/events.js";
export function createFsRoutes(_events: EventEmitter): Router {
const router = Router();
// Read file
router.post("/read", async (req: Request, res: Response) => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
return;
}
const resolvedPath = validatePath(filePath);
const content = await fs.readFile(resolvedPath, "utf-8");
res.json({ success: true, content });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Write file
router.post("/write", async (req: Request, res: Response) => {
try {
const { filePath, content } = req.body as {
filePath: string;
content: string;
};
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
return;
}
const resolvedPath = validatePath(filePath);
// Ensure parent directory exists
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, content, "utf-8");
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Create directory
router.post("/mkdir", async (req: Request, res: Response) => {
try {
const { dirPath } = req.body as { dirPath: string };
if (!dirPath) {
res.status(400).json({ success: false, error: "dirPath is required" });
return;
}
const resolvedPath = validatePath(dirPath);
await fs.mkdir(resolvedPath, { recursive: true });
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Read directory
router.post("/readdir", async (req: Request, res: Response) => {
try {
const { dirPath } = req.body as { dirPath: string };
if (!dirPath) {
res.status(400).json({ success: false, error: "dirPath is required" });
return;
}
const resolvedPath = validatePath(dirPath);
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
const result = entries.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
}));
res.json({ success: true, entries: result });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Check if file/directory exists
router.post("/exists", async (req: Request, res: Response) => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
return;
}
// For exists, we check but don't require the path to be pre-allowed
// This allows the UI to validate user-entered paths
const resolvedPath = path.resolve(filePath);
try {
await fs.access(resolvedPath);
res.json({ success: true, exists: true });
} catch {
res.json({ success: true, exists: false });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get file stats
router.post("/stat", async (req: Request, res: Response) => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
return;
}
const resolvedPath = validatePath(filePath);
const stats = await fs.stat(resolvedPath);
res.json({
success: true,
stats: {
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
size: stats.size,
mtime: stats.mtime,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Delete file
router.post("/delete", async (req: Request, res: Response) => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
return;
}
const resolvedPath = validatePath(filePath);
await fs.rm(resolvedPath, { recursive: true });
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Validate and add path to allowed list
// This is the web equivalent of dialog:openDirectory
router.post("/validate-path", async (req: Request, res: Response) => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
return;
}
const resolvedPath = path.resolve(filePath);
// Check if path exists
try {
const stats = await fs.stat(resolvedPath);
if (!stats.isDirectory()) {
res.status(400).json({ success: false, error: "Path is not a directory" });
return;
}
// Add to allowed paths
addAllowedPath(resolvedPath);
res.json({
success: true,
path: resolvedPath,
isAllowed: isPathAllowed(resolvedPath),
});
} catch {
res.status(400).json({ success: false, error: "Path does not exist" });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -0,0 +1,102 @@
/**
* Git routes - HTTP API for git operations (non-worktree)
*/
import { Router, type Request, type Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export function createGitRoutes(): Router {
const router = Router();
// Get diffs for the main project
router.post("/diffs", 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;
}
try {
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: projectPath,
});
const files = status
.split("\n")
.filter(Boolean)
.map((line) => {
const statusChar = line[0];
const filePath = line.slice(3);
const statusMap: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
};
return {
status: statusChar,
path: filePath,
statusText: statusMap[statusChar] || "Unknown",
};
});
res.json({
success: true,
diff,
files,
hasChanges: files.length > 0,
});
} catch {
res.json({ success: true, diff: "", files: [], hasChanges: false });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get diff for a specific file
router.post("/file-diff", async (req: Request, res: Response) => {
try {
const { projectPath, filePath } = req.body as {
projectPath: string;
filePath: string;
};
if (!projectPath || !filePath) {
res
.status(400)
.json({ success: false, error: "projectPath and filePath required" });
return;
}
try {
const { stdout: diff } = await execAsync(`git diff HEAD -- "${filePath}"`, {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
res.json({ success: true, diff, filePath });
} catch {
res.json({ success: true, diff: "", filePath });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -0,0 +1,39 @@
/**
* Health check routes
*/
import { Router } from "express";
import { getAuthStatus } from "../lib/auth.js";
export function createHealthRoutes(): Router {
const router = Router();
// Basic health check
router.get("/", (_req, res) => {
res.json({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || "0.1.0",
});
});
// Detailed health check
router.get("/detailed", (_req, res) => {
res.json({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || "0.1.0",
uptime: process.uptime(),
memory: process.memoryUsage(),
dataDir: process.env.DATA_DIR || "./data",
auth: getAuthStatus(),
env: {
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
},
});
});
return router;
}

View File

@@ -0,0 +1,128 @@
/**
* Models routes - HTTP API for model providers and availability
*/
import { Router, type Request, type Response } from "express";
interface ModelDefinition {
id: string;
name: string;
provider: string;
contextWindow: number;
maxOutputTokens: number;
supportsVision: boolean;
supportsTools: boolean;
}
interface ProviderStatus {
available: boolean;
hasApiKey: boolean;
error?: string;
}
export function createModelsRoutes(): Router {
const router = Router();
// Get available models
router.get("/available", async (_req: Request, res: Response) => {
try {
const models: ModelDefinition[] = [
{
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
provider: "anthropic",
contextWindow: 200000,
maxOutputTokens: 16384,
supportsVision: true,
supportsTools: true,
},
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
provider: "anthropic",
contextWindow: 200000,
maxOutputTokens: 16384,
supportsVision: true,
supportsTools: true,
},
{
id: "claude-3-5-sonnet-20241022",
name: "Claude 3.5 Sonnet",
provider: "anthropic",
contextWindow: 200000,
maxOutputTokens: 8192,
supportsVision: true,
supportsTools: true,
},
{
id: "claude-3-5-haiku-20241022",
name: "Claude 3.5 Haiku",
provider: "anthropic",
contextWindow: 200000,
maxOutputTokens: 8192,
supportsVision: 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 });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Check provider status
router.get("/providers", async (_req: Request, res: Response) => {
try {
const providers: Record<string, ProviderStatus> = {
anthropic: {
available: !!process.env.ANTHROPIC_API_KEY,
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
},
openai: {
available: !!process.env.OPENAI_API_KEY,
hasApiKey: !!process.env.OPENAI_API_KEY,
},
google: {
available: !!process.env.GOOGLE_API_KEY,
hasApiKey: !!process.env.GOOGLE_API_KEY,
},
};
res.json({ success: true, providers });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -0,0 +1,70 @@
/**
* Running Agents routes - HTTP API for tracking active agent executions
*/
import { Router, type Request, type Response } from "express";
import path from "path";
interface RunningAgent {
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
}
// In-memory tracking of running agents (shared with auto-mode service via reference)
const runningAgentsMap = new Map<string, RunningAgent>();
let autoLoopRunning = false;
export function createRunningAgentsRoutes(): Router {
const router = Router();
// Get all running agents
router.get("/", async (_req: Request, res: Response) => {
try {
const runningAgents = Array.from(runningAgentsMap.values());
res.json({
success: true,
runningAgents,
totalCount: runningAgents.length,
autoLoopRunning,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}
// Export functions to update running agents from other services
export function registerRunningAgent(
featureId: string,
projectPath: string,
isAutoMode: boolean
): void {
runningAgentsMap.set(featureId, {
featureId,
projectPath,
projectName: path.basename(projectPath),
isAutoMode,
});
}
export function unregisterRunningAgent(featureId: string): void {
runningAgentsMap.delete(featureId);
}
export function setAutoLoopRunning(running: boolean): void {
autoLoopRunning = running;
}
export function getRunningAgentsCount(): number {
return runningAgentsMap.size;
}
export function isAgentRunning(featureId: string): boolean {
return runningAgentsMap.has(featureId);
}

View File

@@ -0,0 +1,126 @@
/**
* Sessions routes - HTTP API for session management
*/
import { Router, type Request, type Response } from "express";
import { AgentService } from "../services/agent-service.js";
export function createSessionsRoutes(agentService: AgentService): Router {
const router = Router();
// List all sessions
router.get("/", async (req: Request, res: Response) => {
try {
const includeArchived = req.query.includeArchived === "true";
const sessions = await agentService.listSessions(includeArchived);
res.json({ success: true, sessions });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Create a new session
router.post("/", async (req: Request, res: Response) => {
try {
const { name, projectPath, workingDirectory } = req.body as {
name: string;
projectPath?: string;
workingDirectory?: string;
};
if (!name) {
res.status(400).json({ success: false, error: "name is required" });
return;
}
const session = await agentService.createSession(
name,
projectPath,
workingDirectory
);
res.json({ success: true, session });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Update a session
router.put("/:sessionId", async (req: Request, res: Response) => {
try {
const { sessionId } = req.params;
const { name, tags } = req.body as {
name?: string;
tags?: string[];
};
const session = await agentService.updateSession(sessionId, { name, tags });
if (!session) {
res.status(404).json({ success: false, error: "Session not found" });
return;
}
res.json({ success: true, session });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Archive a session
router.post("/:sessionId/archive", async (req: Request, res: Response) => {
try {
const { sessionId } = req.params;
const success = await agentService.archiveSession(sessionId);
if (!success) {
res.status(404).json({ success: false, error: "Session not found" });
return;
}
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Unarchive a session
router.post("/:sessionId/unarchive", async (req: Request, res: Response) => {
try {
const { sessionId } = req.params;
const success = await agentService.unarchiveSession(sessionId);
if (!success) {
res.status(404).json({ success: false, error: "Session not found" });
return;
}
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Delete a session
router.delete("/:sessionId", async (req: Request, res: Response) => {
try {
const { sessionId } = req.params;
const success = await agentService.deleteSession(sessionId);
if (!success) {
res.status(404).json({ success: false, error: "Session not found" });
return;
}
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -0,0 +1,407 @@
/**
* Setup routes - HTTP API for CLI detection, API keys, and platform info
*/
import { Router, type Request, type Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import os from "os";
import path from "path";
import fs from "fs/promises";
const execAsync = promisify(exec);
// Storage for API keys (in-memory for now, should be persisted)
const apiKeys: Record<string, string> = {};
export function createSetupRoutes(): Router {
const router = Router();
// Get Claude CLI status
router.get("/claude-status", async (_req: Request, res: Response) => {
try {
let installed = false;
let version = "";
let cliPath = "";
let method = "none";
// Try to find Claude CLI
try {
const { stdout } = await execAsync("which claude || where claude 2>/dev/null");
cliPath = stdout.trim();
installed = true;
method = "path";
// Get version
try {
const { stdout: versionOut } = await execAsync("claude --version");
version = versionOut.trim();
} catch {
// Version command might not be available
}
} catch {
// Not in PATH, try common locations
const commonPaths = [
path.join(os.homedir(), ".local", "bin", "claude"),
"/usr/local/bin/claude",
path.join(os.homedir(), ".npm-global", "bin", "claude"),
];
for (const p of commonPaths) {
try {
await fs.access(p);
cliPath = p;
installed = true;
method = "local";
break;
} catch {
// Not found at this path
}
}
}
// Check authentication - detect all possible auth methods
let auth = {
authenticated: false,
method: "none" as string,
hasCredentialsFile: false,
hasToken: false,
hasStoredOAuthToken: false,
hasStoredApiKey: !!apiKeys.anthropic,
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY,
hasEnvOAuthToken: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
// Additional fields for detailed status
oauthTokenValid: false,
apiKeyValid: false,
};
// Check for credentials file (OAuth tokens from claude login)
const credentialsPath = path.join(os.homedir(), ".claude", "credentials.json");
try {
const credentialsContent = await fs.readFile(credentialsPath, "utf-8");
const credentials = JSON.parse(credentialsContent);
auth.hasCredentialsFile = true;
// Check what type of token is in credentials
if (credentials.oauth_token || credentials.access_token) {
auth.hasStoredOAuthToken = true;
auth.oauthTokenValid = true;
auth.authenticated = true;
auth.method = "oauth_token"; // Stored OAuth token from credentials file
} else if (credentials.api_key) {
auth.apiKeyValid = true;
auth.authenticated = true;
auth.method = "api_key"; // Stored API key in credentials file
}
} catch {
// No credentials file or invalid format
}
// Environment variables override stored credentials (higher priority)
if (auth.hasEnvOAuthToken) {
auth.authenticated = true;
auth.oauthTokenValid = true;
auth.method = "oauth_token_env"; // OAuth token from CLAUDE_CODE_OAUTH_TOKEN env var
} else if (auth.hasEnvApiKey) {
auth.authenticated = true;
auth.apiKeyValid = true;
auth.method = "api_key_env"; // API key from ANTHROPIC_API_KEY env var
}
// In-memory stored API key (from settings UI)
if (!auth.authenticated && apiKeys.anthropic) {
auth.authenticated = true;
auth.method = "api_key"; // Manually stored API key
}
res.json({
success: true,
status: installed ? "installed" : "not_installed",
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 });
}
});
// 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
router.post("/install-claude", async (_req: Request, res: Response) => {
try {
// In web mode, we can't install CLIs directly
// Return instructions instead
res.json({
success: false,
error:
"CLI installation requires terminal access. Please install manually using: npm install -g @anthropic-ai/claude-code",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// 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
router.post("/auth-claude", async (_req: Request, res: Response) => {
try {
res.json({
success: true,
requiresManualAuth: true,
command: "claude login",
message: "Please run 'claude login' in your terminal to authenticate",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// 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
router.post("/store-api-key", async (req: Request, res: Response) => {
try {
const { provider, apiKey } = req.body as { provider: string; apiKey: string };
if (!provider || !apiKey) {
res.status(400).json({ success: false, error: "provider and apiKey required" });
return;
}
apiKeys[provider] = apiKey;
// Also set as environment variable
if (provider === "anthropic") {
process.env.ANTHROPIC_API_KEY = apiKey;
} else if (provider === "openai") {
process.env.OPENAI_API_KEY = apiKey;
} else if (provider === "google") {
process.env.GOOGLE_API_KEY = apiKey;
}
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get API keys status
router.get("/api-keys", async (_req: Request, res: Response) => {
try {
res.json({
success: true,
hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY,
hasOpenAIKey: !!apiKeys.openai || !!process.env.OPENAI_API_KEY,
hasGoogleKey: !!apiKeys.google || !!process.env.GOOGLE_API_KEY,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// 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
router.get("/platform", async (_req: Request, res: Response) => {
try {
const platform = os.platform();
res.json({
success: true,
platform,
arch: os.arch(),
homeDir: os.homedir(),
isWindows: platform === "win32",
isMac: platform === "darwin",
isLinux: platform === "linux",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// 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;
}

View File

@@ -0,0 +1,412 @@
/**
* Spec Regeneration routes - HTTP API for AI-powered spec generation
*/
import { Router, type Request, type Response } from "express";
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js";
let isRunning = false;
let currentAbortController: AbortController | null = null;
export function createSpecRegenerationRoutes(events: EventEmitter): Router {
const router = Router();
// Create project spec from overview
router.post("/create", async (req: Request, res: Response) => {
try {
const { projectPath, projectOverview, generateFeatures } = req.body as {
projectPath: string;
projectOverview: string;
generateFeatures?: boolean;
};
if (!projectPath || !projectOverview) {
res.status(400).json({
success: false,
error: "projectPath and projectOverview required",
});
return;
}
if (isRunning) {
res.json({ success: false, error: "Spec generation already running" });
return;
}
isRunning = true;
currentAbortController = new AbortController();
// Start generation in background
generateSpec(
projectPath,
projectOverview,
events,
currentAbortController,
generateFeatures
)
.catch((error) => {
console.error("[SpecRegeneration] Error:", error);
events.emit("spec-regeneration:event", {
type: "spec_error",
error: error.message,
});
})
.finally(() => {
isRunning = false;
currentAbortController = null;
});
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Generate from project definition
router.post("/generate", async (req: Request, res: Response) => {
try {
const { projectPath, projectDefinition } = req.body as {
projectPath: string;
projectDefinition: string;
};
if (!projectPath || !projectDefinition) {
res.status(400).json({
success: false,
error: "projectPath and projectDefinition required",
});
return;
}
if (isRunning) {
res.json({ success: false, error: "Spec generation already running" });
return;
}
isRunning = true;
currentAbortController = new AbortController();
generateSpec(
projectPath,
projectDefinition,
events,
currentAbortController,
false
)
.catch((error) => {
console.error("[SpecRegeneration] Error:", error);
events.emit("spec-regeneration:event", {
type: "spec_error",
error: error.message,
});
})
.finally(() => {
isRunning = false;
currentAbortController = null;
});
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Generate features from existing spec
router.post("/generate-features", 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;
}
if (isRunning) {
res.json({ success: false, error: "Generation already running" });
return;
}
isRunning = true;
currentAbortController = new AbortController();
generateFeaturesFromSpec(projectPath, events, currentAbortController)
.catch((error) => {
console.error("[SpecRegeneration] Error:", error);
events.emit("spec-regeneration:event", {
type: "features_error",
error: error.message,
});
})
.finally(() => {
isRunning = false;
currentAbortController = null;
});
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Stop generation
router.post("/stop", async (_req: Request, res: Response) => {
try {
if (currentAbortController) {
currentAbortController.abort();
}
isRunning = false;
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get status
router.get("/status", async (_req: Request, res: Response) => {
try {
res.json({ success: true, isRunning });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}
async function generateSpec(
projectPath: string,
projectOverview: string,
events: EventEmitter,
abortController: AbortController,
generateFeatures?: boolean
) {
const prompt = `You are helping to define a software project specification.
Project Overview:
${projectOverview}
Based on this overview, analyze the project and create a comprehensive specification that includes:
1. **Project Summary** - Brief description of what the project does
2. **Core Features** - Main functionality the project needs
3. **Technical Stack** - Recommended technologies and frameworks
4. **Architecture** - High-level system design
5. **Data Models** - Key entities and their relationships
6. **API Design** - Main endpoints/interfaces needed
7. **User Experience** - Key user flows and interactions
${generateFeatures ? `
Also generate a list of features to implement. For each feature provide:
- ID (lowercase-hyphenated)
- Title
- Description
- Priority (1=high, 2=medium, 3=low)
- Estimated complexity (simple, moderate, complex)
` : ""}
Format your response as markdown. Be specific and actionable.`;
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: "Starting spec generation...\n",
});
const options: Options = {
model: "claude-opus-4-5-20251101",
maxTurns: 10,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController,
};
const stream = query({ prompt, options });
let responseText = "";
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: block.text,
});
} else if (block.type === "tool_use") {
events.emit("spec-regeneration:event", {
type: "spec_tool",
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
// Save spec
const specDir = path.join(projectPath, ".automaker");
const specPath = path.join(specDir, "project-spec.md");
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(specPath, responseText);
events.emit("spec-regeneration:event", {
type: "spec_complete",
specPath,
content: responseText,
});
// If generate features was requested, parse and create them
if (generateFeatures) {
await parseAndCreateFeatures(projectPath, responseText, events);
}
}
async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
abortController: AbortController
) {
// Read existing spec
const specPath = path.join(projectPath, ".automaker", "project-spec.md");
let spec: string;
try {
spec = await fs.readFile(specPath, "utf-8");
} catch {
events.emit("spec-regeneration:event", {
type: "features_error",
error: "No project spec found. Generate spec first.",
});
return;
}
const prompt = `Based on this project specification:
${spec}
Generate a prioritized list of implementable features. For each feature provide:
1. **id**: A unique lowercase-hyphenated identifier
2. **title**: Short descriptive title
3. **description**: What this feature does (2-3 sentences)
4. **priority**: 1 (high), 2 (medium), or 3 (low)
5. **complexity**: "simple", "moderate", or "complex"
6. **dependencies**: Array of feature IDs this depends on (can be empty)
Format as JSON:
{
"features": [
{
"id": "feature-id",
"title": "Feature Title",
"description": "What it does",
"priority": 1,
"complexity": "moderate",
"dependencies": []
}
]
}
Generate 5-15 features that build on each other logically.`;
events.emit("spec-regeneration:event", {
type: "features_progress",
content: "Analyzing spec and generating features...\n",
});
const options: Options = {
model: "claude-sonnet-4-20250514",
maxTurns: 5,
cwd: projectPath,
allowedTools: ["Read", "Glob"],
permissionMode: "acceptEdits",
abortController,
};
const stream = query({ prompt, options });
let responseText = "";
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
events.emit("spec-regeneration:event", {
type: "features_progress",
content: block.text,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
await parseAndCreateFeatures(projectPath, responseText, events);
}
async function parseAndCreateFeatures(
projectPath: string,
content: string,
events: EventEmitter
) {
try {
// Extract JSON from response
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
if (!jsonMatch) {
throw new Error("No valid JSON found in response");
}
const parsed = JSON.parse(jsonMatch[0]);
const featuresDir = path.join(projectPath, ".automaker", "features");
await fs.mkdir(featuresDir, { recursive: true });
const createdFeatures: Array<{ id: string; title: string }> = [];
for (const feature of parsed.features) {
const featureDir = path.join(featuresDir, feature.id);
await fs.mkdir(featureDir, { recursive: true });
const featureData = {
id: feature.id,
title: feature.title,
description: feature.description,
status: "pending",
priority: feature.priority || 2,
complexity: feature.complexity || "moderate",
dependencies: feature.dependencies || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await fs.writeFile(
path.join(featureDir, "feature.json"),
JSON.stringify(featureData, null, 2)
);
createdFeatures.push({ id: feature.id, title: feature.title });
}
events.emit("spec-regeneration:event", {
type: "features_complete",
features: createdFeatures,
count: createdFeatures.length,
});
} catch (error) {
events.emit("spec-regeneration:event", {
type: "features_error",
error: (error as Error).message,
});
}
}

View File

@@ -0,0 +1,192 @@
/**
* Suggestions routes - HTTP API for AI-powered feature suggestions
*/
import { Router, type Request, type Response } from "express";
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import type { EventEmitter } from "../lib/events.js";
let isRunning = false;
let currentAbortController: AbortController | null = null;
export function createSuggestionsRoutes(events: EventEmitter): Router {
const router = Router();
// Generate suggestions
router.post("/generate", async (req: Request, res: Response) => {
try {
const { projectPath, suggestionType = "features" } = req.body as {
projectPath: string;
suggestionType?: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath required" });
return;
}
if (isRunning) {
res.json({ success: false, error: "Suggestions generation is already running" });
return;
}
isRunning = true;
currentAbortController = new AbortController();
// Start generation in background
generateSuggestions(projectPath, suggestionType, events, currentAbortController)
.catch((error) => {
console.error("[Suggestions] Error:", error);
events.emit("suggestions:event", {
type: "suggestions_error",
error: error.message,
});
})
.finally(() => {
isRunning = false;
currentAbortController = null;
});
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Stop suggestions generation
router.post("/stop", async (_req: Request, res: Response) => {
try {
if (currentAbortController) {
currentAbortController.abort();
}
isRunning = false;
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get status
router.get("/status", async (_req: Request, res: Response) => {
try {
res.json({ success: true, isRunning });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}
async function generateSuggestions(
projectPath: string,
suggestionType: string,
events: EventEmitter,
abortController: AbortController
) {
const typePrompts: Record<string, string> = {
features: "Analyze this project and suggest new features that would add value.",
refactoring: "Analyze this project and identify refactoring opportunities.",
security: "Analyze this project for security vulnerabilities and suggest fixes.",
performance: "Analyze this project for performance issues and suggest optimizations.",
};
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
Look at the codebase and provide 3-5 concrete suggestions.
For each suggestion, provide:
1. A category (e.g., "User Experience", "Security", "Performance")
2. A clear description of what to implement
3. Concrete steps to implement it
4. Priority (1=high, 2=medium, 3=low)
5. Brief reasoning for why this would help
Format your response as JSON:
{
"suggestions": [
{
"id": "suggestion-123",
"category": "Category",
"description": "What to implement",
"steps": ["Step 1", "Step 2"],
"priority": 1,
"reasoning": "Why this helps"
}
]
}`;
events.emit("suggestions:event", {
type: "suggestions_progress",
content: `Starting ${suggestionType} analysis...\n`,
});
const options: Options = {
model: "claude-opus-4-5-20251101",
maxTurns: 5,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController,
};
const stream = query({ prompt, options });
let responseText = "";
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
events.emit("suggestions:event", {
type: "suggestions_progress",
content: block.text,
});
} else if (block.type === "tool_use") {
events.emit("suggestions:event", {
type: "suggestions_tool",
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
// Parse suggestions from response
try {
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
events.emit("suggestions:event", {
type: "suggestions_complete",
suggestions: parsed.suggestions.map((s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
})),
});
} else {
throw new Error("No valid JSON found in response");
}
} catch (error) {
// Return generic suggestions if parsing fails
events.emit("suggestions:event", {
type: "suggestions_complete",
suggestions: [
{
id: `suggestion-${Date.now()}-0`,
category: "Analysis",
description: "Review the AI analysis output for insights",
steps: ["Review the generated analysis"],
priority: 1,
reasoning: "The AI provided analysis but suggestions need manual review",
},
],
});
}
}

View File

@@ -0,0 +1,355 @@
/**
* Worktree routes - HTTP API for git worktree operations
*/
import { Router, type Request, type Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
const execAsync = promisify(exec);
export function createWorktreeRoutes(): Router {
const router = Router();
// Check if a path is a git repo
async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
return true;
} catch {
return false;
}
}
// Get worktree info
router.post("/info", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId required" });
return;
}
// Check if worktree exists
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
try {
await fs.access(worktreePath);
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd: worktreePath,
});
res.json({
success: true,
worktreePath,
branchName: stdout.trim(),
});
} catch {
res.json({ success: true, worktreePath: null, branchName: null });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get worktree status
router.post("/status", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId required" });
return;
}
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
try {
await fs.access(worktreePath);
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
});
const files = status
.split("\n")
.filter(Boolean)
.map((line) => line.slice(3));
const { stdout: diffStat } = await execAsync("git diff --stat", {
cwd: worktreePath,
});
const { stdout: logOutput } = await execAsync(
'git log --oneline -5 --format="%h %s"',
{ cwd: worktreePath }
);
res.json({
success: true,
modifiedFiles: files.length,
files,
diffStat: diffStat.trim(),
recentCommits: logOutput.trim().split("\n").filter(Boolean),
});
} catch {
res.json({
success: true,
modifiedFiles: 0,
files: [],
diffStat: "",
recentCommits: [],
});
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// List all worktrees
router.post("/list", 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;
}
if (!(await isGitRepo(projectPath))) {
res.json({ success: true, worktrees: [] });
return;
}
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: projectPath,
});
const worktrees: Array<{ path: string; branch: string }> = [];
const lines = stdout.split("\n");
let current: { path?: string; branch?: string } = {};
for (const line of lines) {
if (line.startsWith("worktree ")) {
current.path = line.slice(9);
} else if (line.startsWith("branch ")) {
current.branch = line.slice(7).replace("refs/heads/", "");
} else if (line === "") {
if (current.path && current.branch) {
worktrees.push({ path: current.path, branch: current.branch });
}
current = {};
}
}
res.json({ success: true, worktrees });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get diffs for a worktree
router.post("/diffs", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId required" });
return;
}
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
try {
await fs.access(worktreePath);
const { stdout: diff } = await execAsync("git diff HEAD", {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
});
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
});
const files = status
.split("\n")
.filter(Boolean)
.map((line) => {
const statusChar = line[0];
const filePath = line.slice(3);
const statusMap: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
};
return {
status: statusChar,
path: filePath,
statusText: statusMap[statusChar] || "Unknown",
};
});
res.json({
success: true,
diff,
files,
hasChanges: files.length > 0,
});
} catch {
res.json({ success: true, diff: "", files: [], hasChanges: false });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Get diff for a specific file
router.post("/file-diff", async (req: Request, res: Response) => {
try {
const { projectPath, featureId, filePath } = req.body as {
projectPath: string;
featureId: string;
filePath: string;
};
if (!projectPath || !featureId || !filePath) {
res.status(400).json({
success: false,
error: "projectPath, featureId, and filePath required",
});
return;
}
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
try {
await fs.access(worktreePath);
const { stdout: diff } = await execAsync(`git diff HEAD -- "${filePath}"`, {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
});
res.json({ success: true, diff, filePath });
} catch {
res.json({ success: true, diff: "", filePath });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Revert feature (remove worktree)
router.post("/revert", async (req: Request, res: Response) => {
try {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId required" });
return;
}
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
try {
// Remove worktree
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
// Delete branch
await execAsync(`git branch -D feature/${featureId}`, { cwd: projectPath });
res.json({ success: true, removedPath: worktreePath });
} catch (error) {
// Worktree might not exist
res.json({ success: true, removedPath: null });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Merge feature (merge worktree branch into main)
router.post("/merge", async (req: Request, res: Response) => {
try {
const { projectPath, featureId, options } = req.body as {
projectPath: string;
featureId: string;
options?: { squash?: boolean; message?: string };
};
if (!projectPath || !featureId) {
res
.status(400)
.json({ success: false, error: "projectPath and featureId required" });
return;
}
const branchName = `feature/${featureId}`;
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
// Get current branch
const { stdout: currentBranch } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: projectPath }
);
// Merge the feature branch
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
await execAsync(mergeCmd, { cwd: projectPath });
// If squash merge, need to commit
if (options?.squash) {
await execAsync(
`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`,
{ cwd: projectPath }
);
}
// Clean up worktree and branch
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
// Cleanup errors are non-fatal
}
res.json({ success: true, mergedBranch: branchName });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -0,0 +1,562 @@
/**
* Agent Service - Runs Claude agents via the Claude Agent SDK
* Manages conversation sessions and streams responses via WebSocket
*/
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
images?: Array<{
data: string;
mimeType: string;
filename: string;
}>;
timestamp: string;
isError?: boolean;
}
interface Session {
messages: Message[];
isRunning: boolean;
abortController: AbortController | null;
workingDirectory: string;
}
interface SessionMetadata {
id: string;
name: string;
projectPath?: string;
workingDirectory: string;
createdAt: string;
updatedAt: string;
archived?: boolean;
tags?: string[];
}
export class AgentService {
private sessions = new Map<string, Session>();
private stateDir: string;
private metadataFile: string;
private events: EventEmitter;
constructor(dataDir: string, events: EventEmitter) {
this.stateDir = path.join(dataDir, "agent-sessions");
this.metadataFile = path.join(dataDir, "sessions-metadata.json");
this.events = events;
}
async initialize(): Promise<void> {
await fs.mkdir(this.stateDir, { recursive: true });
}
/**
* Start or resume a conversation
*/
async startConversation({
sessionId,
workingDirectory,
}: {
sessionId: string;
workingDirectory?: string;
}) {
if (!this.sessions.has(sessionId)) {
const messages = await this.loadSession(sessionId);
this.sessions.set(sessionId, {
messages,
isRunning: false,
abortController: null,
workingDirectory: workingDirectory || process.cwd(),
});
}
const session = this.sessions.get(sessionId)!;
return {
success: true,
messages: session.messages,
sessionId,
};
}
/**
* Send a message to the agent and stream responses
*/
async sendMessage({
sessionId,
message,
workingDirectory,
imagePaths,
}: {
sessionId: string;
message: string;
workingDirectory?: string;
imagePaths?: string[];
}) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
if (session.isRunning) {
throw new Error("Agent is already processing a message");
}
// Read images and convert to base64
const images: Message["images"] = [];
if (imagePaths && imagePaths.length > 0) {
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";
images.push({
data: base64Data,
mimeType: mediaType,
filename: path.basename(imagePath),
});
} catch (error) {
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
}
}
}
// Add user message
const userMessage: Message = {
id: this.generateId(),
role: "user",
content: message,
images: images.length > 0 ? images : undefined,
timestamp: new Date().toISOString(),
};
session.messages.push(userMessage);
session.isRunning = true;
session.abortController = new AbortController();
// Emit user message event
this.emitAgentEvent(sessionId, {
type: "message",
message: userMessage,
});
await this.saveSession(sessionId, session.messages);
try {
const options: Options = {
model: "claude-opus-4-5-20251101",
systemPrompt: this.getSystemPrompt(),
maxTurns: 20,
cwd: workingDirectory || session.workingDirectory,
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: session.abortController!,
};
// Build prompt content
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
message;
if (imagePaths && imagePaths.length > 0) {
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
if (message && message.trim()) {
contentBlocks.push({ type: "text", text: message });
}
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 responseText = "";
const toolUses: Array<{ name: string; input: unknown }> = [];
for await (const msg of stream) {
if (msg.type === "assistant") {
if (msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText += block.text;
if (!currentAssistantMessage) {
currentAssistantMessage = {
id: this.generateId(),
role: "assistant",
content: responseText,
timestamp: new Date().toISOString(),
};
session.messages.push(currentAssistantMessage);
} else {
currentAssistantMessage.content = responseText;
}
this.emitAgentEvent(sessionId, {
type: "stream",
messageId: currentAssistantMessage.id,
content: responseText,
isComplete: false,
});
} else if (block.type === "tool_use") {
const toolUse = {
name: block.name,
input: block.input,
};
toolUses.push(toolUse);
this.emitAgentEvent(sessionId, {
type: "tool_use",
tool: toolUse,
});
}
}
}
} else if (msg.type === "result") {
if (msg.subtype === "success" && msg.result) {
if (currentAssistantMessage) {
currentAssistantMessage.content = msg.result;
responseText = msg.result;
}
}
this.emitAgentEvent(sessionId, {
type: "complete",
messageId: currentAssistantMessage?.id,
content: responseText,
toolUses,
});
}
}
await this.saveSession(sessionId, session.messages);
session.isRunning = false;
session.abortController = null;
return {
success: true,
message: currentAssistantMessage,
};
} catch (error) {
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
session.isRunning = false;
session.abortController = null;
return { success: false, aborted: true };
}
console.error("[AgentService] Error:", error);
session.isRunning = false;
session.abortController = null;
const errorMessage: Message = {
id: this.generateId(),
role: "assistant",
content: `Error: ${(error as Error).message}`,
timestamp: new Date().toISOString(),
isError: true,
};
session.messages.push(errorMessage);
await this.saveSession(sessionId, session.messages);
this.emitAgentEvent(sessionId, {
type: "error",
error: (error as Error).message,
message: errorMessage,
});
throw error;
}
}
/**
* Get conversation history
*/
getHistory(sessionId: string) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
}
return {
success: true,
messages: session.messages,
isRunning: session.isRunning,
};
}
/**
* Stop current agent execution
*/
async stopExecution(sessionId: string) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
}
if (session.abortController) {
session.abortController.abort();
session.isRunning = false;
session.abortController = null;
}
return { success: true };
}
/**
* Clear conversation history
*/
async clearSession(sessionId: string) {
const session = this.sessions.get(sessionId);
if (session) {
session.messages = [];
session.isRunning = false;
await this.saveSession(sessionId, []);
}
return { success: true };
}
// Session management
async loadSession(sessionId: string): Promise<Message[]> {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = await fs.readFile(sessionFile, "utf-8");
return JSON.parse(data);
} catch {
return [];
}
}
async saveSession(sessionId: string, messages: Message[]): Promise<void> {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await fs.writeFile(sessionFile, JSON.stringify(messages, null, 2), "utf-8");
await this.updateSessionTimestamp(sessionId);
} catch (error) {
console.error("[AgentService] Failed to save session:", error);
}
}
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
try {
const data = await fs.readFile(this.metadataFile, "utf-8");
return JSON.parse(data);
} catch {
return {};
}
}
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
await fs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), "utf-8");
}
async updateSessionTimestamp(sessionId: string): Promise<void> {
const metadata = await this.loadMetadata();
if (metadata[sessionId]) {
metadata[sessionId].updatedAt = new Date().toISOString();
await this.saveMetadata(metadata);
}
}
async listSessions(includeArchived = false): Promise<SessionMetadata[]> {
const metadata = await this.loadMetadata();
let sessions = Object.values(metadata);
if (!includeArchived) {
sessions = sessions.filter((s) => !s.archived);
}
return sessions.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
async createSession(
name: string,
projectPath?: string,
workingDirectory?: string
): Promise<SessionMetadata> {
const sessionId = this.generateId();
const metadata = await this.loadMetadata();
const session: SessionMetadata = {
id: sessionId,
name,
projectPath,
workingDirectory: workingDirectory || projectPath || process.cwd(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
metadata[sessionId] = session;
await this.saveMetadata(metadata);
return session;
}
async updateSession(
sessionId: string,
updates: Partial<SessionMetadata>
): Promise<SessionMetadata | null> {
const metadata = await this.loadMetadata();
if (!metadata[sessionId]) return null;
metadata[sessionId] = {
...metadata[sessionId],
...updates,
updatedAt: new Date().toISOString(),
};
await this.saveMetadata(metadata);
return metadata[sessionId];
}
async archiveSession(sessionId: string): Promise<boolean> {
const result = await this.updateSession(sessionId, { archived: true });
return result !== null;
}
async unarchiveSession(sessionId: string): Promise<boolean> {
const result = await this.updateSession(sessionId, { archived: false });
return result !== null;
}
async deleteSession(sessionId: string): Promise<boolean> {
const metadata = await this.loadMetadata();
if (!metadata[sessionId]) return false;
delete metadata[sessionId];
await this.saveMetadata(metadata);
// Delete session file
try {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
await fs.unlink(sessionFile);
} catch {
// File may not exist
}
// Clear from memory
this.sessions.delete(sessionId);
return true;
}
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
this.events.emit("agent:stream", { sessionId, ...data });
}
private getSystemPrompt(): string {
return `You are an AI assistant helping users build software. You are part of the Automaker application,
which is designed to help developers plan, design, and implement software projects autonomously.
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
Your role is to:
- Help users define their project requirements and specifications
- Ask clarifying questions to better understand their needs
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
When discussing projects, help users think through:
- Core functionality and features
- Technical stack choices
- Data models and architecture
- User experience considerations
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds`;
}
private generateId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
}

View File

@@ -0,0 +1,815 @@
/**
* Auto Mode Service - Autonomous feature implementation using Claude Agent SDK
*
* Manages:
* - Worktree creation for isolated development
* - Feature execution with Claude
* - Concurrent execution with max concurrency limits
* - Progress streaming via events
* - Verification and merge workflows
*/
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter, EventType } from "../lib/events.js";
const execAsync = promisify(exec);
interface Feature {
id: string;
title: string;
description: string;
status: string;
priority?: number;
spec?: string;
}
interface RunningFeature {
featureId: string;
projectPath: string;
worktreePath: string | null;
branchName: string | null;
abortController: AbortController;
isAutoMode: boolean;
startTime: number;
}
interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
projectPath: string;
}
export class AutoModeService {
private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>();
private autoLoopRunning = false;
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
constructor(events: EventEmitter) {
this.events = events;
}
/**
* Start the auto mode loop - continuously picks and executes pending features
*/
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> {
if (this.autoLoopRunning) {
throw new Error("Auto mode is already running");
}
this.autoLoopRunning = true;
this.autoLoopAbortController = new AbortController();
this.config = {
maxConcurrency,
useWorktrees: true,
projectPath,
};
this.emitAutoModeEvent("auto_mode_complete", {
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
projectPath,
});
// Run the loop in the background
this.runAutoLoop().catch((error) => {
console.error("[AutoMode] Loop error:", error);
this.emitAutoModeEvent("auto_mode_error", {
error: error.message,
});
});
}
private async runAutoLoop(): Promise<void> {
while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) {
try {
// Check if we have capacity
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
await this.sleep(5000);
continue;
}
// Load pending features
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
if (pendingFeatures.length === 0) {
this.emitAutoModeEvent("auto_mode_complete", {
message: "No pending features - auto mode idle",
});
await this.sleep(10000);
continue;
}
// Find a feature not currently running
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
if (nextFeature) {
// Start feature execution in background
this.executeFeature(
this.config!.projectPath,
nextFeature.id,
this.config!.useWorktrees,
true
).catch((error) => {
console.error(`[AutoMode] Feature ${nextFeature.id} error:`, error);
});
}
await this.sleep(2000);
} catch (error) {
console.error("[AutoMode] Loop iteration error:", error);
await this.sleep(5000);
}
}
this.autoLoopRunning = false;
this.emitAutoModeEvent("auto_mode_complete", {
message: "Auto mode stopped",
});
}
/**
* Stop the auto mode loop
*/
async stopAutoLoop(): Promise<number> {
this.autoLoopRunning = false;
if (this.autoLoopAbortController) {
this.autoLoopAbortController.abort();
this.autoLoopAbortController = null;
}
return this.runningFeatures.size;
}
/**
* Execute a single feature
*/
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = true,
isAutoMode = false
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
const abortController = new AbortController();
const branchName = `feature/${featureId}`;
let worktreePath: string | null = null;
// Setup worktree if enabled
if (useWorktrees) {
worktreePath = await this.setupWorktree(projectPath, featureId, branchName);
}
const workDir = worktreePath || projectPath;
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode,
startTime: Date.now(),
});
// Emit feature start event
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: { id: featureId, title: "Loading...", description: "Feature is starting" },
});
try {
// Load feature details
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Update feature status to in-progress
await this.updateFeatureStatus(projectPath, featureId, "in-progress");
// Build the prompt
const prompt = this.buildFeaturePrompt(feature);
// Run the agent
await this.runAgent(workDir, featureId, prompt, abortController);
// Mark as completed
await this.updateFeatureStatus(projectPath, featureId, "completed");
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`,
projectPath,
});
} catch (error) {
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: false,
message: "Feature stopped by user",
projectPath,
});
} else {
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "failed");
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: (error as Error).message,
projectPath,
});
}
} finally {
this.runningFeatures.delete(featureId);
}
}
/**
* Stop a specific feature
*/
async stopFeature(featureId: string): Promise<boolean> {
const running = this.runningFeatures.get(featureId);
if (!running) {
return false;
}
running.abortController.abort();
return true;
}
/**
* Resume a feature (continues from saved context)
*/
async resumeFeature(
projectPath: string,
featureId: string,
useWorktrees = true
): Promise<void> {
// Check if context exists
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
let hasContext = false;
try {
await fs.access(contextPath);
hasContext = true;
} catch {
// No context
}
if (hasContext) {
// Load previous context and continue
const context = await fs.readFile(contextPath, "utf-8");
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
}
// No context, start fresh
return this.executeFeature(projectPath, featureId, useWorktrees, false);
}
/**
* Follow up on a feature with additional instructions
*/
async followUpFeature(
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
const abortController = new AbortController();
// Check if worktree exists
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
let workDir = projectPath;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree, use project path
}
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath: workDir !== projectPath ? worktreePath : null,
branchName: `feature/${featureId}`,
abortController,
isAutoMode: false,
startTime: Date.now(),
});
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) },
});
try {
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: "Follow-up completed successfully",
projectPath,
});
} catch (error) {
if (!(error instanceof AbortError)) {
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: (error as Error).message,
projectPath,
});
}
} finally {
this.runningFeatures.delete(featureId);
}
}
/**
* Verify a feature's implementation
*/
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
let workDir = projectPath;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
}
// Run verification - check if tests pass, build works, etc.
const verificationChecks = [
{ cmd: "npm run lint", name: "Lint" },
{ cmd: "npm run typecheck", name: "Type check" },
{ cmd: "npm test", name: "Tests" },
{ cmd: "npm run build", name: "Build" },
];
let allPassed = true;
const results: Array<{ check: string; passed: boolean; output?: string }> = [];
for (const check of verificationChecks) {
try {
const { stdout, stderr } = await execAsync(check.cmd, {
cwd: workDir,
timeout: 120000,
});
results.push({ check: check.name, passed: true, output: stdout || stderr });
} catch (error) {
allPassed = false;
results.push({
check: check.name,
passed: false,
output: (error as Error).message,
});
break; // Stop on first failure
}
}
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: allPassed,
message: allPassed
? "All verification checks passed"
: `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`,
});
return allPassed;
}
/**
* Commit feature changes
*/
async commitFeature(projectPath: string, featureId: string): Promise<string | null> {
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
let workDir = projectPath;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
}
try {
// Check for changes
const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir });
if (!status.trim()) {
return null; // No changes
}
// Load feature for commit message
const feature = await this.loadFeature(projectPath, featureId);
const commitMessage = feature
? `feat: ${feature.title}\n\nImplemented by Automaker auto-mode`
: `feat: Feature ${featureId}`;
// Stage and commit
await execAsync("git add -A", { cwd: workDir });
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
cwd: workDir,
});
// Get commit hash
const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir });
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
});
return hash.trim();
} catch (error) {
console.error(`[AutoMode] Commit failed for ${featureId}:`, error);
return null;
}
}
/**
* Check if context exists for a feature
*/
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
try {
await fs.access(contextPath);
return true;
} catch {
return false;
}
}
/**
* Analyze project to gather context
*/
async analyzeProject(projectPath: string): Promise<void> {
const abortController = new AbortController();
const analysisFeatureId = `analysis-${Date.now()}`;
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId: analysisFeatureId,
projectPath,
feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" },
});
const prompt = `Analyze this project and provide a summary of:
1. Project structure and architecture
2. Main technologies and frameworks used
3. Key components and their responsibilities
4. Build and test commands
5. Any existing conventions or patterns
Format your response as a structured markdown document.`;
try {
const options: Options = {
model: "claude-sonnet-4-20250514",
maxTurns: 5,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController,
};
const stream = query({ prompt, options });
let analysisResult = "";
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
analysisResult = block.text;
this.emitAutoModeEvent("auto_mode_progress", {
featureId: analysisFeatureId,
content: block.text,
projectPath,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
analysisResult = msg.result || analysisResult;
}
}
// Save analysis
const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md");
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
await fs.writeFile(analysisPath, analysisResult);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId: analysisFeatureId,
passes: true,
message: "Project analysis completed",
projectPath,
});
} catch (error) {
this.emitAutoModeEvent("auto_mode_error", {
featureId: analysisFeatureId,
error: (error as Error).message,
projectPath,
});
}
}
/**
* Get current status
*/
getStatus(): {
isRunning: boolean;
autoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
} {
return {
isRunning: this.autoLoopRunning || this.runningFeatures.size > 0,
autoLoopRunning: this.autoLoopRunning,
runningFeatures: Array.from(this.runningFeatures.keys()),
runningCount: this.runningFeatures.size,
};
}
// Private helpers
private async setupWorktree(
projectPath: string,
featureId: string,
branchName: string
): Promise<string> {
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
const worktreePath = path.join(worktreesDir, featureId);
await fs.mkdir(worktreesDir, { recursive: true });
// Check if worktree already exists
try {
await fs.access(worktreePath);
return worktreePath;
} catch {
// Create new worktree
}
// Create branch if it doesn't exist
try {
await execAsync(`git branch ${branchName}`, { cwd: projectPath });
} catch {
// Branch may already exist
}
// Create worktree
try {
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, {
cwd: projectPath,
});
} catch (error) {
// Worktree creation failed, fall back to direct execution
console.error(`[AutoMode] Worktree creation failed:`, error);
return projectPath;
}
return worktreePath;
}
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
}
private async updateFeatureStatus(
projectPath: string,
featureId: string,
status: string
): Promise<void> {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
feature.status = status;
feature.updatedAt = new Date().toISOString();
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch {
// Feature file may not exist
}
}
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
const featuresDir = path.join(projectPath, ".automaker", "features");
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const features: Feature[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, "feature.json");
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
if (feature.status === "pending" || feature.status === "ready") {
features.push(feature);
}
} catch {
// Skip invalid features
}
}
}
// Sort by priority
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
} catch {
return [];
}
}
private buildFeaturePrompt(feature: Feature): string {
let prompt = `## Feature Implementation Task
**Feature ID:** ${feature.id}
**Title:** ${feature.title}
**Description:** ${feature.description}
`;
if (feature.spec) {
prompt += `
**Specification:**
${feature.spec}
`;
}
prompt += `
## Instructions
Implement this feature by:
1. First, explore the codebase to understand the existing structure
2. Plan your implementation approach
3. Write the necessary code changes
4. Add or update tests as needed
5. Ensure the code follows existing patterns and conventions
When done, summarize what you implemented and any notes for the developer.`;
return prompt;
}
private async runAgent(
workDir: string,
featureId: string,
prompt: string,
abortController: AbortController,
imagePaths?: string[]
): Promise<void> {
const options: Options = {
model: "claude-opus-4-5-20251101",
maxTurns: 50,
cwd: workDir,
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController,
};
// Build prompt - include image paths for the agent to read
let finalPrompt = prompt;
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 = "";
const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md");
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: block.text,
});
} else if (block.type === "tool_use") {
this.emitAutoModeEvent("auto_mode_tool", {
featureId,
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
}
}
// Save agent output
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, responseText);
} catch {
// May fail if directory doesn't exist
}
}
private async executeFeatureWithContext(
projectPath: string,
featureId: string,
context: string,
useWorktrees: boolean
): Promise<void> {
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
const prompt = `## Continuing Feature Implementation
${this.buildFeaturePrompt(feature)}
## Previous Context
The following is the output from a previous implementation attempt. Continue from where you left off:
${context}
## Instructions
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
return this.executeFeature(projectPath, featureId, useWorktrees, false);
}
/**
* Emit an auto-mode event wrapped in the correct format for the client.
* All auto-mode events are sent as type "auto-mode:event" with the actual
* event type and data in the payload.
*/
private emitAutoModeEvent(
eventType: string,
data: Record<string, unknown>
): void {
// Wrap the event in auto-mode:event format expected by the client
this.events.emit("auto-mode:event", {
type: eventType,
...data,
});
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,263 @@
/**
* Feature Loader - Handles loading and managing features from individual feature folders
* Each feature is stored in .automaker/features/{featureId}/feature.json
*/
import path from "path";
import fs from "fs/promises";
export interface Feature {
id: string;
category: string;
description: string;
steps?: string[];
passes?: boolean;
priority?: number;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
[key: string]: unknown;
}
export class FeatureLoader {
/**
* Get the features directory path
*/
getFeaturesDir(projectPath: string): string {
return path.join(projectPath, ".automaker", "features");
}
/**
* Get the path to a specific feature folder
*/
getFeatureDir(projectPath: string, featureId: string): string {
return path.join(this.getFeaturesDir(projectPath), featureId);
}
/**
* Get the path to a feature's feature.json file
*/
getFeatureJsonPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "feature.json");
}
/**
* Get the path to a feature's agent-output.md file
*/
getAgentOutputPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md");
}
/**
* Generate a new feature ID
*/
generateFeatureId(): string {
return `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
/**
* Get all features for a project
*/
async getAll(projectPath: string): Promise<Feature[]> {
try {
const featuresDir = this.getFeaturesDir(projectPath);
// Check if features directory exists
try {
await fs.access(featuresDir);
} catch {
return [];
}
// Read all feature directories
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
const features: Feature[] = [];
for (const dir of featureDirs) {
const featureId = dir.name;
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await fs.readFile(featureJsonPath, "utf-8");
const feature = JSON.parse(content);
if (!feature.id) {
console.warn(
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
);
continue;
}
features.push(feature);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue;
} else if (error instanceof SyntaxError) {
console.warn(
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
);
} else {
console.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
(error as Error).message
);
}
}
}
// Sort by creation order (feature IDs contain timestamp)
features.sort((a, b) => {
const aTime = a.id ? parseInt(a.id.split("-")[1] || "0") : 0;
const bTime = b.id ? parseInt(b.id.split("-")[1] || "0") : 0;
return aTime - bTime;
});
return features;
} catch (error) {
console.error("[FeatureLoader] Failed to get all features:", error);
return [];
}
}
/**
* Get a single feature by ID
*/
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
console.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
throw error;
}
}
/**
* Create a new feature
*/
async create(projectPath: string, featureData: Partial<Feature>): Promise<Feature> {
const featureId = featureData.id || this.generateFeatureId();
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
// Ensure features directory exists
const featuresDir = this.getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true });
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
// Ensure feature has required fields
const feature: Feature = {
category: featureData.category || "Uncategorized",
description: featureData.description || "",
...featureData,
id: featureId,
};
// Write feature.json
await fs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), "utf-8");
console.log(`[FeatureLoader] Created feature ${featureId}`);
return feature;
}
/**
* Update a feature (partial updates supported)
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Merge updates
const updatedFeature: Feature = { ...feature, ...updates };
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
"utf-8"
);
console.log(`[FeatureLoader] Updated feature ${featureId}`);
return updatedFeature;
}
/**
* Delete a feature
*/
async delete(projectPath: string, featureId: string): Promise<boolean> {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
console.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
return false;
}
}
/**
* Get agent output for a feature
*/
async getAgentOutput(
projectPath: string,
featureId: string
): Promise<string | null> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
console.error(
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
error
);
throw error;
}
}
/**
* Save agent output for a feature
*/
async saveAgentOutput(
projectPath: string,
featureId: string,
content: string
): Promise<void> {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.mkdir(featureDir, { recursive: true });
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.writeFile(agentOutputPath, content, "utf-8");
}
/**
* Delete agent output for a feature
*/
async deleteAgentOutput(projectPath: string, featureId: string): Promise<void> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.unlink(agentOutputPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}
}

20
apps/server/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
# Automaker Docker Compose
# For self-hosting the Automaker backend server
services:
server:
build:
context: .
dockerfile: apps/server/Dockerfile
container_name: automaker-server
restart: unless-stopped
ports:
- "3008:3008"
environment:
# Required
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
# Optional - authentication (leave empty to disable)
- AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-}
# Optional - restrict to specific directories (comma-separated)
- ALLOWED_PROJECT_DIRS=${ALLOWED_PROJECT_DIRS:-/projects}
# Optional - data directory for sessions, etc.
- DATA_DIR=/data
# Optional - CORS origin (default allows all)
- CORS_ORIGIN=${CORS_ORIGIN:-*}
# Optional - additional API keys
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
volumes:
# Persist data between restarts
- automaker-data:/data
# Mount your projects directory (read-write access)
- ${PROJECTS_DIR:-./projects}:/projects
volumes:
automaker-data:

2623
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,13 @@
"dev:electron:debug": "npm run dev:electron:debug --workspace=apps/app",
"dev:electron:wsl": "npm run dev:electron:wsl --workspace=apps/app",
"dev:electron:wsl:gpu": "npm run dev:electron:wsl:gpu --workspace=apps/app",
"dev:server": "npm run dev --workspace=apps/server",
"dev:full": "concurrently \"npm run dev:server\" \"npm run dev:web\"",
"build": "npm run build --workspace=apps/app",
"build:electron": "npm run build:electron --workspace=apps/app",
"build:server": "npm run build --workspace=apps/server",
"build:electron": "npm run build --workspace=apps/app",
"start": "npm run start --workspace=apps/app",
"start:server": "npm run start --workspace=apps/server",
"lint": "npm run lint --workspace=apps/app",
"test": "npm run test --workspace=apps/app",
"test:headed": "npm run test:headed --workspace=apps/app",

267
plan.md Normal file
View File

@@ -0,0 +1,267 @@
# Plan: Full Web Support for Automaker
## Goal
Make the app work fully in web browsers while keeping Electron support. Web mode connects to a backend server (self-hosted or cloud). Electron embeds the same server locally.
## Architecture
```
┌─────────────────────────────────────┐
│ Next.js Frontend │
│ (same code both modes) │
└───────────────┬─────────────────────┘
┌───────────┴───────────┐
│ │
[Web Mode] [Electron Mode]
│ │
HTTP/WebSocket HTTP/WebSocket
to remote server to localhost:3008
│ │
└───────────┬───────────┘
┌───────────────▼─────────────────────┐
│ Backend Server │
│ (apps/server) │
│ - Express + WebSocket │
│ - All services from electron/ │
│ - Claude Agent SDK │
│ - File ops, Git, PTY │
└─────────────────────────────────────┘
```
**Key insight**: Electron uses the same HTTP API - just connects to localhost instead of remote.
---
## New Package: `apps/server`
```
apps/server/
├── package.json
├── src/
│ ├── index.ts # Express server entry
│ ├── routes/
│ │ ├── fs.ts # File system routes
│ │ ├── agent.ts # Agent routes
│ │ ├── sessions.ts # Session routes
│ │ ├── auto-mode.ts # Auto mode routes
│ │ ├── features.ts # Features routes
│ │ ├── worktree.ts # Git worktree routes
│ │ ├── setup.ts # Setup/config routes
│ │ └── suggestions.ts # Feature suggestions routes
│ ├── services/ # Moved from electron/services/
│ │ ├── agent-service.ts
│ │ ├── auto-mode-service.ts
│ │ ├── worktree-manager.ts
│ │ ├── feature-loader.ts
│ │ ├── feature-executor.ts
│ │ └── ...
│ └── lib/
│ ├── events.ts # Event emitter for streaming
│ └── security.ts # Path validation
```
---
## Critical Files to Modify
| File | Change |
|------|--------|
| `apps/app/src/lib/electron.ts` | Add `HttpApiClient` class that implements `ElectronAPI` using fetch/WebSocket |
| `apps/app/electron/main.js` | Simplify to: spawn server + create window (remove 1500+ lines of IPC handlers) |
| `apps/app/electron/preload.js` | Simplify to just expose `isElectron` flag |
| `apps/app/package.json` | Remove server-side deps (Claude SDK, pty) |
| Root `package.json` | Add `apps/server` workspace |
---
## Implementation Phases
### Phase 1: Create Server Package (Foundation)
1. Create `apps/server` with Express + TypeScript setup
2. Add health check endpoint: `GET /api/health`
3. Copy one simple service (feature-loader) and create route
4. Test with curl/Postman
### Phase 2: File System API
1. Create `POST /api/fs/read`, `POST /api/fs/write`, etc.
2. Add path security (allowlist validation)
3. Update `electron.ts` with `HttpApiClient` for fs operations
4. Test: file operations work in web mode
### Phase 3: Agent API with Streaming
1. Add WebSocket server for events (`/api/events`)
2. Migrate `agent-service.js` to TypeScript
3. Create routes: `POST /api/agent/send`, etc.
4. Events stream via WebSocket instead of IPC
5. Test: chat works in web mode
### Phase 4: Sessions & Features API
1. Migrate session management routes
2. Migrate features CRUD routes
3. Test: project/feature management works
### Phase 5: Auto Mode & Worktree
1. Migrate `auto-mode-service.js` (complex - has streaming)
2. Migrate `worktree-manager.js`
3. Test: auto mode runs features in web
### Phase 6: Remaining Services
1. Spec regeneration
2. Feature suggestions
3. Setup/CLI detection
4. Model provider checks
### Phase 7: Simplify Electron
1. Update `main.js` to spawn server process + create window
2. Remove all IPC handlers
3. Electron app uses HTTP like web
4. Test: Electron still works
### Phase 8: Production Ready
1. Add authentication (API key header)
2. Configure CORS for production
3. Add `ALLOWED_PROJECT_DIRS` env for security
4. Docker setup for deployment
5. Update build scripts
---
## API Design Pattern
Convert IPC handlers to REST:
```
IPC: dialog:openDirectory → Web: User types path, POST /api/fs/validate
IPC: fs:readFile → POST /api/fs/read { filePath }
IPC: agent:send → POST /api/agent/send { sessionId, message, ... }
IPC: auto-mode:start → POST /api/auto-mode/start { projectPath }
IPC: features:getAll → GET /api/projects/:path/features
```
Streaming via WebSocket:
```
ws://server/api/events
Events: agent:stream, auto-mode:event, suggestions:event
```
---
## Web-Specific Handling
| Feature | Electron | Web |
|---------|----------|-----|
| File picker | Native dialog | Text input + server validation |
| Open link | shell.openExternal | window.open() |
| Data directory | app.getPath('userData') | Server's DATA_DIR env |
---
## Configuration
**Server `.env`:**
```
PORT=3008
DATA_DIR=/path/to/data
ANTHROPIC_API_KEY=xxx
ALLOWED_PROJECT_DIRS=/home/user/projects
```
**Frontend `.env.local`:**
```
NEXT_PUBLIC_SERVER_URL=http://localhost:3008
```
---
## Estimated Scope
- New files: ~15-20 (server package)
- Modified files: ~5 (electron.ts, main.js, preload.js, package.jsons)
- Deleted lines: ~1500 (IPC handlers from main.js)
- Services to migrate: ~10
---
## Implementation Status
### ✅ ALL PHASES COMPLETE
- [x] **Phase 1**: Server package foundation (`apps/server`)
- Express server with WebSocket support
- Event emitter for streaming
- Security module for path validation
- Health check endpoint
- [x] **Phase 2**: HttpApiClient in frontend
- `apps/app/src/lib/http-api-client.ts` - full implementation
- Modified `electron.ts` to use HTTP client when not in Electron
- No mocks - all calls go through HTTP
- [x] **Phase 3**: Agent API with streaming
- `apps/server/src/services/agent-service.ts`
- `apps/server/src/routes/agent.ts`
- WebSocket streaming for responses
- [x] **Phase 4**: Sessions & Features API
- `apps/server/src/routes/sessions.ts`
- `apps/server/src/services/feature-loader.ts`
- `apps/server/src/routes/features.ts`
- [x] **Phase 5**: Auto Mode & Worktree
- `apps/server/src/services/auto-mode-service.ts` - full implementation with Claude SDK
- `apps/server/src/routes/auto-mode.ts`
- `apps/server/src/routes/worktree.ts`
- `apps/server/src/routes/git.ts`
- [x] **Phase 6**: Remaining services
- `apps/server/src/routes/setup.ts` - CLI detection, API keys, platform info
- `apps/server/src/routes/suggestions.ts` - AI-powered feature suggestions
- `apps/server/src/routes/spec-regeneration.ts` - spec generation from overview
- `apps/server/src/routes/models.ts` - model providers and availability
- `apps/server/src/routes/running-agents.ts` - active agent tracking
- [x] **Phase 7**: Simplify Electron
- `apps/app/electron/main-simplified.js` - spawns server, minimal IPC
- `apps/app/electron/preload-simplified.js` - only native features exposed
- Updated `electron.ts` to detect simplified mode
- Updated `http-api-client.ts` to use native dialogs when available
- [x] **Phase 8**: Production ready
- `apps/server/src/lib/auth.ts` - API key authentication middleware
- `apps/server/Dockerfile` - multi-stage Docker build
- `docker-compose.yml` - easy deployment configuration
- `apps/server/.env.example` - documented configuration
---
## Additional Fixes Applied
### State Persistence
- Features now cached in localStorage via Zustand persist middleware
- Board view properly handles API failures by keeping cached data
- Theme and UI state properly persisted across refreshes
### Authentication Display
- Server now returns proper auth method names: `oauth_token_env`, `oauth_token`, `api_key_env`, `api_key`
- Settings view displays correct auth source (OAuth token, API key, subscription, etc.)
- Added support for Codex subscription detection
- Fixed "Unknown method" display issue
### Bug Fixes
- Fixed board-view.tsx crash when feature status is unknown (defaults to backlog)
- Removed "Mock IPC" label from web mode indicator
- Fixed unused imports and dependency warnings
- Updated API key authentication header support in HTTP client
---
## Summary
The architecture is simple: **one backend server, two ways to access it** (web browser or Electron shell).
- **Web users**: Connect browser to your cloud-hosted server
- **Electron users**: App spawns server locally, connects to localhost
- **Same codebase**: Frontend code unchanged, backend services extracted to standalone server