From 8e65f0b338cad9b9a96927b0f63e0e2914b88c67 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 02:14:52 -0500 Subject: [PATCH] refactor: streamline Electron API integration and enhance UI components - Removed unused Electron API methods and simplified the main process. - Introduced a new workspace picker modal for improved project selection. - Enhanced error handling for authentication issues across various components. - Updated UI styles for dark mode support and added new CSS variables. - Refactored session management to utilize a centralized API access method. - Added server routes for workspace management, including directory listing and configuration checks. --- apps/app/electron/main.js | 1799 +---------------- apps/app/electron/preload.js | 410 +--- apps/app/src/app/globals.css | 80 +- apps/app/src/app/layout.tsx | 2 + apps/app/src/components/session-manager.tsx | 49 +- .../src/components/ui/course-promo-badge.tsx | 136 ++ .../ui/description-image-dropzone.tsx | 17 +- apps/app/src/components/views/board-view.tsx | 22 +- .../app/src/components/views/welcome-view.tsx | 58 +- .../src/components/workspace-picker-modal.tsx | 154 ++ apps/app/src/hooks/use-auto-mode.ts | 17 +- apps/app/src/hooks/use-electron-agent.ts | 51 +- apps/app/src/lib/electron.ts | 145 +- apps/app/src/lib/http-api-client.ts | 195 +- apps/app/src/types/electron.d.ts | 1 + apps/server/.gitignore | 1 + .../msg_1765523524581_xhk6u45v2.json | 14 + apps/server/data/sessions-metadata.json | 10 + apps/server/src/index.ts | 8 +- apps/server/src/routes/fs.ts | 98 + apps/server/src/routes/setup.ts | 46 +- apps/server/src/routes/workspace.ts | 113 ++ apps/server/src/services/auto-mode-service.ts | 32 +- apps/server/src/services/feature-loader.ts | 149 +- 24 files changed, 1217 insertions(+), 2390 deletions(-) create mode 100644 apps/app/src/components/ui/course-promo-badge.tsx create mode 100644 apps/app/src/components/workspace-picker-modal.tsx create mode 100644 apps/server/.gitignore create mode 100644 apps/server/data/agent-sessions/msg_1765523524581_xhk6u45v2.json create mode 100644 apps/server/data/sessions-metadata.json create mode 100644 apps/server/src/routes/workspace.ts diff --git a/apps/app/electron/main.js b/apps/app/electron/main.js index 80271332..70e2511c 100644 --- a/apps/app/electron/main.js +++ b/apps/app/electron/main.js @@ -1,22 +1,10 @@ const path = require("path"); - -// Load environment variables from .env file -require("dotenv").config({ path: path.join(__dirname, "../.env") }); - -const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron"); -const fs = require("fs/promises"); -const agentService = require("./agent-service"); -const autoModeService = require("./auto-mode-service"); -const worktreeManager = require("./services/worktree-manager"); -const featureSuggestionsService = require("./services/feature-suggestions-service"); -const specRegenerationService = require("./services/spec-regeneration-service"); +const { app, BrowserWindow, shell } = require("electron"); let mainWindow = null; // Get icon path - works in both dev and production function getIconPath() { - // In dev: __dirname is electron/, so ../public/logo.png - // In production: public folder is included in the app bundle return app.isPackaged ? path.join(process.resourcesPath, "app", "public", "logo.png") : path.join(__dirname, "../public/logo.png"); @@ -50,33 +38,23 @@ function createWindow() { mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html")); } + // Handle external links - open in default browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: "deny" }; + }); + mainWindow.on("closed", () => { mainWindow = null; }); } -app.whenReady().then(async () => { +app.whenReady().then(() => { // Set app icon (dock icon on macOS) if (process.platform === "darwin" && app.dock) { app.dock.setIcon(getIconPath()); } - // Initialize agent service - const appDataPath = app.getPath("userData"); - await agentService.initialize(appDataPath); - - // Pre-load allowed paths from agent history to prevent breaking "Recent Projects" - try { - const sessions = await agentService.listSessions({ includeArchived: true }); - sessions.forEach((session) => { - if (session.projectPath) { - addAllowedPath(session.projectPath); - } - }); - } catch (error) { - console.error("Failed to load sessions for security whitelist:", error); - } - createWindow(); app.on("activate", () => { @@ -91,1764 +69,3 @@ app.on("window-all-closed", () => { app.quit(); } }); - -// Track allowed paths for file operations (security) -const allowedPaths = new Set(); - -/** - * Add a path to the allowed list - */ -function addAllowedPath(pathToAdd) { - if (!pathToAdd) return; - allowedPaths.add(path.resolve(pathToAdd)); -} - -/** - * Check if a file path is allowed (must be within an allowed directory) - */ -function isPathAllowed(filePath) { - const resolvedPath = path.resolve(filePath); - - // Allow access to app data directory (for logs, temp images etc) - const appDataPath = app.getPath("userData"); - if (resolvedPath.startsWith(appDataPath)) return true; - - // Check against all allowed project paths - for (const allowedPath of allowedPaths) { - // Check if path starts with allowed directory - // Ensure we don't match "/foo/bar" against "/foo/b" - if ( - resolvedPath === allowedPath || - resolvedPath.startsWith(allowedPath + path.sep) - ) { - return true; - } - } - - return false; -} - -// IPC Handlers - -// Dialog handlers -ipcMain.handle("dialog:openDirectory", async () => { - const result = await dialog.showOpenDialog(mainWindow, { - properties: ["openDirectory", "createDirectory"], - }); - - if (!result.canceled && result.filePaths.length > 0) { - result.filePaths.forEach((p) => addAllowedPath(p)); - } - - return result; -}); - -ipcMain.handle("dialog:openFile", async (_, options = {}) => { - const result = await dialog.showOpenDialog(mainWindow, { - properties: ["openFile"], - ...options, - }); - - if (!result.canceled && result.filePaths.length > 0) { - // Allow reading the specific file selected - result.filePaths.forEach((p) => addAllowedPath(p)); - } - - return result; -}); - -// File system handlers -ipcMain.handle("fs:readFile", async (_, filePath) => { - try { - // Security check - if (!isPathAllowed(filePath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const content = await fs.readFile(filePath, "utf-8"); - return { success: true, content }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("fs:writeFile", async (_, filePath, content) => { - try { - // Security check - if (!isPathAllowed(filePath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - await fs.writeFile(filePath, content, "utf-8"); - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("fs:mkdir", async (_, dirPath) => { - try { - // Security check - if (!isPathAllowed(dirPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - await fs.mkdir(dirPath, { recursive: true }); - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("fs:readdir", async (_, dirPath) => { - try { - // Security check - if (!isPathAllowed(dirPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - const result = entries.map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isFile: entry.isFile(), - })); - return { success: true, entries: result }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("fs:exists", async (_, filePath) => { - try { - // Exists check is generally safe, but we can restrict it too for strict privacy - if (!isPathAllowed(filePath)) { - return false; - } - - await fs.access(filePath); - return true; - } catch { - return false; - } -}); - -ipcMain.handle("fs:stat", async (_, filePath) => { - try { - // Security check - if (!isPathAllowed(filePath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const stats = await fs.stat(filePath); - return { - success: true, - stats: { - isDirectory: stats.isDirectory(), - isFile: stats.isFile(), - size: stats.size, - mtime: stats.mtime, - }, - }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("fs:deleteFile", async (_, filePath) => { - try { - // Security check - if (!isPathAllowed(filePath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - await fs.unlink(filePath); - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("fs:trashItem", async (_, targetPath) => { - try { - // Security check - if (!isPathAllowed(targetPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - await shell.trashItem(targetPath); - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -// App data path -ipcMain.handle("app:getPath", (_, name) => { - return app.getPath(name); -}); - -// Save image to .automaker/images directory -ipcMain.handle( - "app:saveImageToTemp", - async (_, { data, filename, mimeType, projectPath }) => { - try { - // Use .automaker/images directory instead of /tmp - // If projectPath is provided, use it; otherwise fall back to app data directory - let imagesDir; - if (projectPath) { - imagesDir = path.join(projectPath, ".automaker", "images"); - } else { - // Fallback for cases where project isn't loaded yet - const appDataPath = app.getPath("userData"); - imagesDir = path.join(appDataPath, "images"); - } - - await fs.mkdir(imagesDir, { recursive: true }); - - // Generate unique filename with unique ID - const uniqueId = `${Date.now()}-${Math.random() - .toString(36) - .substring(2, 11)}`; - const safeName = filename.replace(/[^a-zA-Z0-9.-]/g, "_"); - const imageFilePath = path.join(imagesDir, `${uniqueId}_${safeName}`); - - // Remove data URL prefix if present (data:image/png;base64,...) - const base64Data = data.includes(",") ? data.split(",")[1] : data; - - // Write image to file - await fs.writeFile(imageFilePath, base64Data, "base64"); - - return { success: true, path: imageFilePath }; - } catch (error) { - console.error("[IPC] Failed to save image:", error); - return { success: false, error: error.message }; - } - } -); - -// IPC ping for testing communication -ipcMain.handle("ping", () => { - return "pong"; -}); - -// Open external link in default browser -ipcMain.handle("shell:openExternal", async (_, url) => { - try { - await shell.openExternal(url); - return { success: true }; - } catch (error) { - console.error("[IPC] shell:openExternal error:", error); - return { success: false, error: error.message }; - } -}); - -// ============================================================================ -// Agent IPC Handlers -// ============================================================================ - -/** - * Start or resume a conversation session - */ -ipcMain.handle("agent:start", async (_, { sessionId, workingDirectory }) => { - try { - return await agentService.startConversation({ - sessionId, - workingDirectory, - }); - } catch (error) { - console.error("[IPC] agent:start error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Send a message to the agent - returns immediately, streams via events - */ -ipcMain.handle( - "agent:send", - async (event, { sessionId, message, workingDirectory, imagePaths }) => { - try { - // Create a function to send updates to the renderer - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("agent:stream", { - sessionId, - ...data, - }); - } - }; - - // Start processing (runs in background) - agentService - .sendMessage({ - sessionId, - message, - workingDirectory, - imagePaths, - sendToRenderer, - }) - .catch((error) => { - console.error("[IPC] agent:send background error:", error); - sendToRenderer({ - type: "error", - error: error.message, - }); - }); - - // Return immediately - return { success: true }; - } catch (error) { - console.error("[IPC] agent:send error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Get conversation history - */ -ipcMain.handle("agent:getHistory", (_, { sessionId }) => { - try { - return agentService.getHistory(sessionId); - } catch (error) { - console.error("[IPC] agent:getHistory error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Stop current agent execution - */ -ipcMain.handle("agent:stop", async (_, { sessionId }) => { - try { - return await agentService.stopExecution(sessionId); - } catch (error) { - console.error("[IPC] agent:stop error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Clear conversation history - */ -ipcMain.handle("agent:clear", async (_, { sessionId }) => { - try { - return await agentService.clearSession(sessionId); - } catch (error) { - console.error("[IPC] agent:clear error:", error); - return { success: false, error: error.message }; - } -}); - -// ============================================================================ -// Session Management IPC Handlers -// ============================================================================ - -/** - * List all sessions - */ -ipcMain.handle("sessions:list", async (_, { includeArchived }) => { - try { - const sessions = await agentService.listSessions({ includeArchived }); - return { success: true, sessions }; - } catch (error) { - console.error("[IPC] sessions:list error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Create a new session - */ -ipcMain.handle( - "sessions:create", - async (_, { name, projectPath, workingDirectory }) => { - try { - // Add project path to allowed paths - addAllowedPath(projectPath); - if (workingDirectory) addAllowedPath(workingDirectory); - - return await agentService.createSession({ - name, - projectPath, - workingDirectory, - }); - } catch (error) { - console.error("[IPC] sessions:create error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Update session metadata - */ -ipcMain.handle("sessions:update", async (_, { sessionId, name, tags }) => { - try { - return await agentService.updateSession({ sessionId, name, tags }); - } catch (error) { - console.error("[IPC] sessions:update error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Archive a session - */ -ipcMain.handle("sessions:archive", async (_, { sessionId }) => { - try { - return await agentService.archiveSession(sessionId); - } catch (error) { - console.error("[IPC] sessions:archive error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Unarchive a session - */ -ipcMain.handle("sessions:unarchive", async (_, { sessionId }) => { - try { - return await agentService.unarchiveSession(sessionId); - } catch (error) { - console.error("[IPC] sessions:unarchive error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Delete a session permanently - */ -ipcMain.handle("sessions:delete", async (_, { sessionId }) => { - try { - return await agentService.deleteSession(sessionId); - } catch (error) { - console.error("[IPC] sessions:delete error:", error); - return { success: false, error: error.message }; - } -}); - -// ============================================================================ -// Auto Mode IPC Handlers -// ============================================================================ - -/** - * Start auto mode - autonomous feature implementation - */ -ipcMain.handle( - "auto-mode:start", - async (_, { projectPath, maxConcurrency }) => { - try { - // Add project path to allowed paths - addAllowedPath(projectPath); - - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.start({ - projectPath, - sendToRenderer, - maxConcurrency, - }); - } catch (error) { - console.error("[IPC] auto-mode:start error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Stop auto mode for a specific project - */ -ipcMain.handle("auto-mode:stop", async (_, { projectPath }) => { - try { - return await autoModeService.stop({ projectPath }); - } catch (error) { - console.error("[IPC] auto-mode:stop error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get auto mode status (optionally for a specific project) - */ -ipcMain.handle("auto-mode:status", (_, { projectPath } = {}) => { - try { - return { success: true, ...autoModeService.getStatus({ projectPath }) }; - } catch (error) { - console.error("[IPC] auto-mode:status error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Run a specific feature - */ -ipcMain.handle( - "auto-mode:run-feature", - async (_, { projectPath, featureId, useWorktrees = false }) => { - try { - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.runFeature({ - projectPath, - featureId, - sendToRenderer, - useWorktrees, - }); - } catch (error) { - console.error("[IPC] auto-mode:run-feature error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Verify a specific feature by running its tests - */ -ipcMain.handle( - "auto-mode:verify-feature", - async (_, { projectPath, featureId }) => { - try { - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.verifyFeature({ - projectPath, - featureId, - sendToRenderer, - }); - } catch (error) { - console.error("[IPC] auto-mode:verify-feature error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Resume a specific feature with previous context - */ -ipcMain.handle( - "auto-mode:resume-feature", - async (_, { projectPath, featureId }) => { - try { - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.resumeFeature({ - projectPath, - featureId, - sendToRenderer, - }); - } catch (error) { - console.error("[IPC] auto-mode:resume-feature error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Check if a context file exists for a feature - */ -ipcMain.handle( - "auto-mode:context-exists", - async (_, { projectPath, featureId }) => { - try { - const contextPath = path.join( - projectPath, - ".automaker", - "context", - `${featureId}.md` - ); - try { - await fs.access(contextPath); - return { success: true, exists: true }; - } catch { - return { success: true, exists: false }; - } - } catch (error) { - console.error("[IPC] auto-mode:context-exists error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Analyze a new project - kicks off an agent to analyze the codebase - * and update the app_spec.txt with tech stack and implemented features - */ -ipcMain.handle("auto-mode:analyze-project", async (_, { projectPath }) => { - try { - // Add project path to allowed paths - addAllowedPath(projectPath); - - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.analyzeProject({ - projectPath, - sendToRenderer, - }); - } catch (error) { - console.error("[IPC] auto-mode:analyze-project error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Stop a specific feature - */ -ipcMain.handle("auto-mode:stop-feature", async (_, { featureId }) => { - try { - return await autoModeService.stopFeature({ featureId }); - } catch (error) { - console.error("[IPC] auto-mode:stop-feature error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Follow-up on a feature with additional prompt - */ -ipcMain.handle( - "auto-mode:follow-up-feature", - async (_, { projectPath, featureId, prompt, imagePaths }) => { - try { - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.followUpFeature({ - projectPath, - featureId, - prompt, - imagePaths, - sendToRenderer, - }); - } catch (error) { - console.error("[IPC] auto-mode:follow-up-feature error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Commit changes for a feature (no further work, just commit) - */ -ipcMain.handle( - "auto-mode:commit-feature", - async (_, { projectPath, featureId }) => { - try { - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.commitFeature({ - projectPath, - featureId, - sendToRenderer, - }); - } catch (error) { - console.error("[IPC] auto-mode:commit-feature error:", error); - return { success: false, error: error.message }; - } - } -); - -// ============================================================================ -// Claude CLI Detection IPC Handlers -// ============================================================================ - -/** - * Check Claude Code CLI installation status - */ -ipcMain.handle("claude:check-cli", async () => { - try { - const claudeCliDetector = require("./services/claude-cli-detector"); - const path = require("path"); - const credentialsPath = path.join( - app.getPath("userData"), - "credentials.json" - ); - const fullStatus = claudeCliDetector.getFullStatus(credentialsPath); - - // Return in format expected by settings view (status: "installed" | "not_installed") - return { - success: true, - status: fullStatus.installed ? "installed" : "not_installed", - method: fullStatus.auth?.method || null, - version: fullStatus.version || null, - path: fullStatus.path || null, - authenticated: fullStatus.auth?.authenticated || false, - recommendation: fullStatus.installed - ? null - : "Install Claude Code CLI for optimal performance with ultrathink.", - installCommands: fullStatus.installed - ? null - : claudeCliDetector.getInstallCommands(), - }; - } catch (error) { - console.error("[IPC] claude:check-cli error:", error); - return { success: false, error: error.message }; - } -}); - -// ============================================================================ -// Codex CLI Detection IPC Handlers -// ============================================================================ - -/** - * Check Codex CLI installation status - */ -ipcMain.handle("codex:check-cli", async () => { - try { - const codexCliDetector = require("./services/codex-cli-detector"); - const info = codexCliDetector.getInstallationInfo(); - return { success: true, ...info }; - } catch (error) { - console.error("[IPC] codex:check-cli error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get all available models from all providers - */ -ipcMain.handle("model:get-available", async () => { - try { - const { ModelProviderFactory } = require("./services/model-provider"); - const models = ModelProviderFactory.getAllModels(); - return { success: true, models }; - } catch (error) { - console.error("[IPC] model:get-available error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Check all provider installation status - */ -ipcMain.handle("model:check-providers", async () => { - try { - const { ModelProviderFactory } = require("./services/model-provider"); - const status = await ModelProviderFactory.checkAllProviders(); - return { success: true, providers: status }; - } catch (error) { - console.error("[IPC] model:check-providers error:", error); - return { success: false, error: error.message }; - } -}); - -// ============================================================================ -// MCP Server IPC Handlers -// ============================================================================ - -/** - * Handle MCP server callback for updating feature status - * This can be called by the MCP server script via HTTP or other communication mechanism - * Note: The MCP server script runs as a separate process, so it can't directly use Electron IPC. - * For now, the MCP server calls featureLoader.updateFeatureStatus directly. - * This handler is here for future extensibility (e.g., HTTP endpoint bridge). - */ -ipcMain.handle( - "mcp:update-feature-status", - async (_, { featureId, status, projectPath, summary }) => { - try { - const featureLoader = require("./services/feature-loader"); - await featureLoader.updateFeatureStatus(featureId, status, projectPath, { - summary, - }); - - // Notify renderer if window is available - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("mcp:feature-status-updated", { - featureId, - status, - projectPath, - summary, - }); - } - - return { success: true }; - } catch (error) { - console.error("[IPC] mcp:update-feature-status error:", error); - return { success: false, error: error.message }; - } - } -); - -// ============================================================================ -// Feature Suggestions IPC Handlers -// ============================================================================ - -// Track running suggestions analysis -let suggestionsExecution = null; - -/** - * Generate feature suggestions by analyzing the project - * @param {string} projectPath - The path to the project - * @param {string} suggestionType - Type of suggestions: "features", "refactoring", "security", "performance" - */ -ipcMain.handle( - "suggestions:generate", - async (_, { projectPath, suggestionType = "features" }) => { - try { - // Check if already running - if (suggestionsExecution && suggestionsExecution.isActive()) { - return { - success: false, - error: "Suggestions generation is already running", - }; - } - - // Create execution context - suggestionsExecution = { - abortController: null, - query: null, - isActive: () => suggestionsExecution !== null, - }; - - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("suggestions:event", data); - } - }; - - // Start generating suggestions (runs in background) - featureSuggestionsService - .generateSuggestions( - projectPath, - sendToRenderer, - suggestionsExecution, - suggestionType - ) - .catch((error) => { - console.error("[IPC] suggestions:generate background error:", error); - sendToRenderer({ - type: "suggestions_error", - error: error.message, - }); - }) - .finally(() => { - suggestionsExecution = null; - }); - - // Return immediately - return { success: true }; - } catch (error) { - console.error("[IPC] suggestions:generate error:", error); - suggestionsExecution = null; - return { success: false, error: error.message }; - } - } -); - -/** - * Stop the current suggestions generation - */ -ipcMain.handle("suggestions:stop", async () => { - try { - if (suggestionsExecution && suggestionsExecution.abortController) { - suggestionsExecution.abortController.abort(); - } - suggestionsExecution = null; - return { success: true }; - } catch (error) { - console.error("[IPC] suggestions:stop error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get suggestions generation status - */ -ipcMain.handle("suggestions:status", () => { - return { - success: true, - isRunning: suggestionsExecution !== null && suggestionsExecution.isActive(), - }; -}); - -// ============================================================================ -// OpenAI API Handlers -// ============================================================================ - -/** - * Test OpenAI API connection - */ -ipcMain.handle("openai:test-connection", async (_, { apiKey }) => { - try { - // Simple test using fetch to OpenAI API - const response = await fetch("https://api.openai.com/v1/models", { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey || process.env.OPENAI_API_KEY}`, - "Content-Type": "application/json", - }, - }); - - if (response.ok) { - const data = await response.json(); - return { - success: true, - message: `Connected successfully. Found ${ - data.data?.length || 0 - } models.`, - }; - } else { - const error = await response.json(); - return { - success: false, - error: error.error?.message || "Failed to connect to OpenAI API", - }; - } - } catch (error) { - console.error("[IPC] openai:test-connection error:", error); - return { success: false, error: error.message }; - } -}); - -// ============================================================================ -// Worktree Management IPC Handlers -// ============================================================================ - -/** - * Revert feature changes by removing the worktree - * This effectively discards all changes made by the agent - */ -ipcMain.handle( - "worktree:revert-feature", - async (_, { projectPath, featureId }) => { - try { - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.revertFeature({ - projectPath, - featureId, - sendToRenderer, - }); - } catch (error) { - console.error("[IPC] worktree:revert-feature error:", error); - return { success: false, error: error.message }; - } - } -); - -// ============================================================================ -// Spec Regeneration IPC Handlers -// ============================================================================ - -// Track running spec regeneration -let specRegenerationExecution = null; - -/** - * Regenerate the app spec based on project definition - */ -ipcMain.handle( - "spec-regeneration:generate", - async (_, { projectPath, projectDefinition }) => { - try { - // Add project path to allowed paths - addAllowedPath(projectPath); - - // Check if already running - if (specRegenerationExecution && specRegenerationExecution.isActive()) { - return { - success: false, - error: "Spec regeneration is already running", - }; - } - - // Create execution context - specRegenerationExecution = { - abortController: null, - query: null, - isActive: () => specRegenerationExecution !== null, - }; - - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("spec-regeneration:event", data); - } - }; - - // Start regenerating spec (runs in background) - specRegenerationService - .regenerateSpec( - projectPath, - projectDefinition, - sendToRenderer, - specRegenerationExecution - ) - .catch((error) => { - console.error( - "[IPC] spec-regeneration:generate background error:", - error - ); - sendToRenderer({ - type: "spec_regeneration_error", - error: error.message, - }); - }) - .finally(() => { - specRegenerationExecution = null; - }); - - // Return immediately - return { success: true }; - } catch (error) { - console.error("[IPC] spec-regeneration:generate error:", error); - specRegenerationExecution = null; - return { success: false, error: error.message }; - } - } -); - -/** - * Stop the current spec regeneration - */ -ipcMain.handle("spec-regeneration:stop", async () => { - try { - if ( - specRegenerationExecution && - specRegenerationExecution.abortController - ) { - specRegenerationExecution.abortController.abort(); - } - specRegenerationExecution = null; - return { success: true }; - } catch (error) { - console.error("[IPC] spec-regeneration:stop error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get spec regeneration status - */ -ipcMain.handle("spec-regeneration:status", () => { - return { - success: true, - isRunning: - specRegenerationExecution !== null && - specRegenerationExecution.isActive(), - currentPhase: specRegenerationService.getCurrentPhase(), - }; -}); - -/** - * Create initial app spec for a new project - */ -ipcMain.handle( - "spec-regeneration:create", - async (_, { projectPath, projectOverview, generateFeatures = true }) => { - try { - // Add project path to allowed paths - addAllowedPath(projectPath); - - // Check if already running - if (specRegenerationExecution && specRegenerationExecution.isActive()) { - return { success: false, error: "Spec creation is already running" }; - } - - // Create execution context - specRegenerationExecution = { - abortController: null, - query: null, - isActive: () => specRegenerationExecution !== null, - }; - - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("spec-regeneration:event", data); - } - }; - - // Start creating spec (runs in background) - specRegenerationService - .createInitialSpec( - projectPath, - projectOverview, - sendToRenderer, - specRegenerationExecution, - generateFeatures - ) - .catch((error) => { - console.error( - "[IPC] spec-regeneration:create background error:", - error - ); - sendToRenderer({ - type: "spec_regeneration_error", - error: error.message, - }); - }) - .finally(() => { - specRegenerationExecution = null; - }); - - // Return immediately - return { success: true }; - } catch (error) { - console.error("[IPC] spec-regeneration:create error:", error); - specRegenerationExecution = null; - return { success: false, error: error.message }; - } - } -); - -/** - * Generate features from existing app_spec.txt - * This allows users to generate features retroactively without regenerating the spec - */ -ipcMain.handle( - "spec-regeneration:generate-features", - async (_, { projectPath }) => { - try { - // Add project path to allowed paths - addAllowedPath(projectPath); - - // Check if already running - if (specRegenerationExecution && specRegenerationExecution.isActive()) { - return { - success: false, - error: "Spec regeneration is already running", - }; - } - - // Create execution context - specRegenerationExecution = { - abortController: null, - query: null, - isActive: () => specRegenerationExecution !== null, - }; - - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("spec-regeneration:event", data); - } - }; - - // Start generating features (runs in background) - specRegenerationService - .generateFeaturesOnly( - projectPath, - sendToRenderer, - specRegenerationExecution - ) - .catch((error) => { - console.error( - "[IPC] spec-regeneration:generate-features background error:", - error - ); - sendToRenderer({ - type: "spec_regeneration_error", - error: error.message, - }); - }) - .finally(() => { - specRegenerationExecution = null; - }); - - // Return immediately - return { success: true }; - } catch (error) { - console.error("[IPC] spec-regeneration:generate-features error:", error); - specRegenerationExecution = null; - return { success: false, error: error.message }; - } - } -); - -/** - * Merge feature worktree changes back to main branch - */ -ipcMain.handle( - "worktree:merge-feature", - async (_, { projectPath, featureId, options }) => { - try { - const sendToRenderer = (data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("auto-mode:event", data); - } - }; - - return await autoModeService.mergeFeature({ - projectPath, - featureId, - options, - sendToRenderer, - }); - } catch (error) { - console.error("[IPC] worktree:merge-feature error:", error); - return { success: false, error: error.message }; - } - } -); -/** - * Get worktree info for a feature - */ -ipcMain.handle("worktree:get-info", async (_, { projectPath, featureId }) => { - try { - return await autoModeService.getWorktreeInfo({ projectPath, featureId }); - } catch (error) { - console.error("[IPC] worktree:get-info error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get worktree status (changed files, commits) - */ -ipcMain.handle("worktree:get-status", async (_, { projectPath, featureId }) => { - try { - return await autoModeService.getWorktreeStatus({ projectPath, featureId }); - } catch (error) { - console.error("[IPC] worktree:get-status error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * List all feature worktrees - */ -ipcMain.handle("worktree:list", async (_, { projectPath }) => { - try { - return await autoModeService.listWorktrees({ projectPath }); - } catch (error) { - console.error("[IPC] worktree:list error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get file diffs for a worktree - */ -ipcMain.handle("worktree:get-diffs", async (_, { projectPath, featureId }) => { - try { - return await autoModeService.getFileDiffs({ projectPath, featureId }); - } catch (error) { - console.error("[IPC] worktree:get-diffs error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get diff for a specific file in a worktree - */ -ipcMain.handle( - "worktree:get-file-diff", - async (_, { projectPath, featureId, filePath }) => { - try { - return await autoModeService.getFileDiff({ - projectPath, - featureId, - filePath, - }); - } catch (error) { - console.error("[IPC] worktree:get-file-diff error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Get file diffs for the main project (non-worktree) - */ -ipcMain.handle("git:get-diffs", async (_, { projectPath }) => { - try { - return await worktreeManager.getFileDiffs(projectPath); - } catch (error) { - console.error("[IPC] git:get-diffs error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get diff for a specific file in the main project (non-worktree) - */ -ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => { - try { - return await worktreeManager.getFileDiff(projectPath, filePath); - } catch (error) { - console.error("[IPC] git:get-file-diff error:", error); - return { success: false, error: error.message }; - } -}); - -// ============================================================================ -// Setup & CLI Management IPC Handlers -// ============================================================================ - -/** - * Get comprehensive Claude CLI status including auth - */ -ipcMain.handle("setup:claude-status", async () => { - try { - const claudeCliDetector = require("./services/claude-cli-detector"); - const credentialsPath = path.join( - app.getPath("userData"), - "credentials.json" - ); - const result = claudeCliDetector.getFullStatus(credentialsPath); - return result; - } catch (error) { - console.error("[IPC] setup:claude-status error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get comprehensive Codex CLI status including auth - */ -ipcMain.handle("setup:codex-status", async () => { - try { - const codexCliDetector = require("./services/codex-cli-detector"); - const info = codexCliDetector.getFullStatus(); - return { success: true, ...info }; - } catch (error) { - console.error("[IPC] setup:codex-status error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Install Claude CLI - */ -ipcMain.handle("setup:install-claude", async (event) => { - try { - const claudeCliDetector = require("./services/claude-cli-detector"); - - const sendProgress = (progress) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("setup:install-progress", { - cli: "claude", - ...progress, - }); - } - }; - - const result = await claudeCliDetector.installCli(sendProgress); - return { success: true, ...result }; - } catch (error) { - console.error("[IPC] setup:install-claude error:", error); - return { success: false, error: error.message || error.error }; - } -}); - -/** - * Install Codex CLI - */ -ipcMain.handle("setup:install-codex", async (event) => { - try { - const codexCliDetector = require("./services/codex-cli-detector"); - - const sendProgress = (progress) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("setup:install-progress", { - cli: "codex", - ...progress, - }); - } - }; - - const result = await codexCliDetector.installCli(sendProgress); - return { success: true, ...result }; - } catch (error) { - console.error("[IPC] setup:install-codex error:", error); - return { success: false, error: error.message || error.error }; - } -}); - -/** - * Authenticate Claude CLI (manual auth required) - */ -ipcMain.handle("setup:auth-claude", async (event) => { - try { - const claudeCliDetector = require("./services/claude-cli-detector"); - - const sendProgress = (progress) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("setup:auth-progress", { - cli: "claude", - ...progress, - }); - } - }; - - const result = await claudeCliDetector.runSetupToken(sendProgress); - return { success: true, ...result }; - } catch (error) { - console.error("[IPC] setup:auth-claude error:", error); - return { success: false, error: error.message || error.error }; - } -}); - -/** - * Authenticate Codex CLI with optional API key - */ -ipcMain.handle("setup:auth-codex", async (event, { apiKey }) => { - try { - const codexCliDetector = require("./services/codex-cli-detector"); - - const sendProgress = (progress) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("setup:auth-progress", { - cli: "codex", - ...progress, - }); - } - }; - - const result = await codexCliDetector.authenticate(apiKey, sendProgress); - return { success: true, ...result }; - } catch (error) { - console.error("[IPC] setup:auth-codex error:", error); - return { success: false, error: error.message || error.error }; - } -}); - -/** - * Store API key or OAuth token securely (using app's userData) - * @param {string} provider - Provider name (anthropic, openai, google, anthropic_oauth_token) - * @param {string} apiKey - The API key or OAuth token to store - */ -ipcMain.handle("setup:store-api-key", async (_, { provider, apiKey }) => { - try { - const configPath = path.join(app.getPath("userData"), "credentials.json"); - let credentials = {}; - - // Read existing credentials - try { - const content = await fs.readFile(configPath, "utf-8"); - credentials = JSON.parse(content); - } catch (e) { - // File doesn't exist, start fresh - } - - // Store the new key/token - credentials[provider] = apiKey; - - // Write back - await fs.writeFile( - configPath, - JSON.stringify(credentials, null, 2), - "utf-8" - ); - - return { success: true }; - } catch (error) { - console.error("[IPC] setup:store-api-key error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get stored API keys and tokens - */ -ipcMain.handle("setup:get-api-keys", async () => { - try { - const configPath = path.join(app.getPath("userData"), "credentials.json"); - - try { - const content = await fs.readFile(configPath, "utf-8"); - const credentials = JSON.parse(content); - - // Return which keys/tokens exist (not the actual values for security) - return { - success: true, - hasAnthropicKey: !!credentials.anthropic, - hasAnthropicOAuthToken: !!credentials.anthropic_oauth_token, - hasOpenAIKey: !!credentials.openai, - hasGoogleKey: !!credentials.google, - }; - } catch (e) { - return { - success: true, - hasAnthropicKey: false, - hasAnthropicOAuthToken: false, - hasOpenAIKey: false, - hasGoogleKey: false, - }; - } - } catch (error) { - console.error("[IPC] setup:get-api-keys error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Configure Codex MCP server for a project - */ -ipcMain.handle("setup:configure-codex-mcp", async (_, { projectPath }) => { - try { - const codexConfigManager = require("./services/codex-config-manager"); - const mcpServerPath = path.join( - __dirname, - "services", - "mcp-server-factory.js" - ); - - const configPath = await codexConfigManager.configureMcpServer( - projectPath, - mcpServerPath - ); - - return { success: true, configPath }; - } catch (error) { - console.error("[IPC] setup:configure-codex-mcp error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get platform information - */ -ipcMain.handle("setup:get-platform", async () => { - const os = require("os"); - return { - success: true, - platform: process.platform, - arch: process.arch, - homeDir: os.homedir(), - isWindows: process.platform === "win32", - isMac: process.platform === "darwin", - isLinux: process.platform === "linux", - }; -}); - -// ============================================================================ -// Features IPC Handlers -// ============================================================================ - -/** - * Get all features for a project - */ -ipcMain.handle("features:getAll", async (_, { projectPath }) => { - try { - // Security check - if (!isPathAllowed(projectPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const featureLoader = require("./services/feature-loader"); - const features = await featureLoader.getAll(projectPath); - return { success: true, features }; - } catch (error) { - console.error("[IPC] features:getAll error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get a single feature by ID - */ -ipcMain.handle("features:get", async (_, { projectPath, featureId }) => { - try { - // Security check - if (!isPathAllowed(projectPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const featureLoader = require("./services/feature-loader"); - const feature = await featureLoader.get(projectPath, featureId); - if (!feature) { - return { success: false, error: "Feature not found" }; - } - return { success: true, feature }; - } catch (error) { - console.error("[IPC] features:get error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Create a new feature - */ -ipcMain.handle("features:create", async (_, { projectPath, feature }) => { - try { - // Security check - if (!isPathAllowed(projectPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const featureLoader = require("./services/feature-loader"); - const createdFeature = await featureLoader.create(projectPath, feature); - return { success: true, feature: createdFeature }; - } catch (error) { - console.error("[IPC] features:create error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Update a feature (partial updates supported) - */ -ipcMain.handle( - "features:update", - async (_, { projectPath, featureId, updates }) => { - try { - // Security check - if (!isPathAllowed(projectPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const featureLoader = require("./services/feature-loader"); - const updatedFeature = await featureLoader.update( - projectPath, - featureId, - updates - ); - return { success: true, feature: updatedFeature }; - } catch (error) { - console.error("[IPC] features:update error:", error); - return { success: false, error: error.message }; - } - } -); - -/** - * Delete a feature and its folder - */ -ipcMain.handle("features:delete", async (_, { projectPath, featureId }) => { - try { - // Security check - if (!isPathAllowed(projectPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const featureLoader = require("./services/feature-loader"); - await featureLoader.delete(projectPath, featureId); - return { success: true }; - } catch (error) { - console.error("[IPC] features:delete error:", error); - return { success: false, error: error.message }; - } -}); - -/** - * Get agent output for a feature - */ -ipcMain.handle( - "features:getAgentOutput", - async (_, { projectPath, featureId }) => { - try { - // Security check - if (!isPathAllowed(projectPath)) { - return { - success: false, - error: "Access denied: Path is outside allowed project directories", - }; - } - - const featureLoader = require("./services/feature-loader"); - const content = await featureLoader.getAgentOutput( - projectPath, - featureId - ); - return { success: true, content }; - } catch (error) { - console.error("[IPC] features:getAgentOutput error:", error); - return { success: false, error: error.message }; - } - } -); - -// ============================================================================ -// Running Agents IPC Handlers -// ============================================================================ - -/** - * Get all currently running agents across all projects - */ -ipcMain.handle("running-agents:getAll", () => { - try { - const status = autoModeService.getStatus(); - const allStatuses = autoModeService.getAllProjectStatuses(); - - // Build a list of running agents with their details - const runningAgents = []; - - for (const [projectPath, projectStatus] of Object.entries(allStatuses)) { - for (const featureId of projectStatus.runningFeatures) { - runningAgents.push({ - featureId, - projectPath, - projectName: projectPath.split(/[/\\]/).pop() || projectPath, - isAutoMode: projectStatus.isRunning, - }); - } - } - - return { - success: true, - runningAgents, - totalCount: status.runningCount, - autoLoopRunning: status.autoLoopRunning, - }; - } catch (error) { - console.error("[IPC] running-agents:getAll error:", error); - return { success: false, error: error.message }; - } -}); diff --git a/apps/app/electron/preload.js b/apps/app/electron/preload.js index 85a31baa..4d802527 100644 --- a/apps/app/electron/preload.js +++ b/apps/app/electron/preload.js @@ -1,404 +1,10 @@ -const { contextBridge, ipcRenderer } = require("electron"); +const { contextBridge } = require("electron"); -// Expose protected methods that allow the renderer process to use -// the ipcRenderer without exposing the entire object -contextBridge.exposeInMainWorld("electronAPI", { - // IPC test - ping: () => ipcRenderer.invoke("ping"), - - // Shell APIs - openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url), - - // Dialog APIs - openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"), - openFile: (options) => ipcRenderer.invoke("dialog:openFile", options), - - // File system APIs - readFile: (filePath) => ipcRenderer.invoke("fs:readFile", filePath), - writeFile: (filePath, content) => - ipcRenderer.invoke("fs:writeFile", filePath, content), - mkdir: (dirPath) => ipcRenderer.invoke("fs:mkdir", dirPath), - readdir: (dirPath) => ipcRenderer.invoke("fs:readdir", dirPath), - exists: (filePath) => ipcRenderer.invoke("fs:exists", filePath), - stat: (filePath) => ipcRenderer.invoke("fs:stat", filePath), - deleteFile: (filePath) => ipcRenderer.invoke("fs:deleteFile", filePath), - trashItem: (filePath) => ipcRenderer.invoke("fs:trashItem", filePath), - - // App APIs - getPath: (name) => ipcRenderer.invoke("app:getPath", name), - saveImageToTemp: (data, filename, mimeType, projectPath) => - ipcRenderer.invoke("app:saveImageToTemp", { - data, - filename, - mimeType, - projectPath, - }), - - // Agent APIs - agent: { - // Start or resume a conversation - start: (sessionId, workingDirectory) => - ipcRenderer.invoke("agent:start", { sessionId, workingDirectory }), - - // Send a message to the agent - send: (sessionId, message, workingDirectory, imagePaths) => - ipcRenderer.invoke("agent:send", { - sessionId, - message, - workingDirectory, - imagePaths, - }), - - // Get conversation history - getHistory: (sessionId) => - ipcRenderer.invoke("agent:getHistory", { sessionId }), - - // Stop current execution - stop: (sessionId) => ipcRenderer.invoke("agent:stop", { sessionId }), - - // Clear conversation - clear: (sessionId) => ipcRenderer.invoke("agent:clear", { sessionId }), - - // Subscribe to streaming events - onStream: (callback) => { - const subscription = (_, data) => callback(data); - ipcRenderer.on("agent:stream", subscription); - // Return unsubscribe function - return () => ipcRenderer.removeListener("agent:stream", subscription); - }, - }, - - // Session Management APIs - sessions: { - // List all sessions - list: (includeArchived) => - ipcRenderer.invoke("sessions:list", { includeArchived }), - - // Create a new session - create: (name, projectPath, workingDirectory) => - ipcRenderer.invoke("sessions:create", { - name, - projectPath, - workingDirectory, - }), - - // Update session metadata - update: (sessionId, name, tags) => - ipcRenderer.invoke("sessions:update", { sessionId, name, tags }), - - // Archive a session - archive: (sessionId) => - ipcRenderer.invoke("sessions:archive", { sessionId }), - - // Unarchive a session - unarchive: (sessionId) => - ipcRenderer.invoke("sessions:unarchive", { sessionId }), - - // Delete a session permanently - delete: (sessionId) => ipcRenderer.invoke("sessions:delete", { sessionId }), - }, - - // Auto Mode API - autoMode: { - // Start auto mode for a specific project - start: (projectPath, maxConcurrency) => - ipcRenderer.invoke("auto-mode:start", { projectPath, maxConcurrency }), - - // Stop auto mode for a specific project - stop: (projectPath) => ipcRenderer.invoke("auto-mode:stop", { projectPath }), - - // Get auto mode status (optionally for a specific project) - status: (projectPath) => ipcRenderer.invoke("auto-mode:status", { projectPath }), - - // Run a specific feature - runFeature: (projectPath, featureId, useWorktrees) => - ipcRenderer.invoke("auto-mode:run-feature", { - projectPath, - featureId, - useWorktrees, - }), - - // Verify a specific feature by running its tests - verifyFeature: (projectPath, featureId) => - ipcRenderer.invoke("auto-mode:verify-feature", { - projectPath, - featureId, - }), - - // Resume a specific feature with previous context - resumeFeature: (projectPath, featureId) => - ipcRenderer.invoke("auto-mode:resume-feature", { - projectPath, - featureId, - }), - - // Check if context file exists for a feature - contextExists: (projectPath, featureId) => - ipcRenderer.invoke("auto-mode:context-exists", { - projectPath, - featureId, - }), - - // Analyze a new project - kicks off an agent to analyze codebase - analyzeProject: (projectPath) => - ipcRenderer.invoke("auto-mode:analyze-project", { projectPath }), - - // Stop a specific feature - stopFeature: (featureId) => - ipcRenderer.invoke("auto-mode:stop-feature", { featureId }), - - // Follow-up on a feature with additional prompt - followUpFeature: (projectPath, featureId, prompt, imagePaths) => - ipcRenderer.invoke("auto-mode:follow-up-feature", { - projectPath, - featureId, - prompt, - imagePaths, - }), - - // Commit changes for a feature - commitFeature: (projectPath, featureId) => - ipcRenderer.invoke("auto-mode:commit-feature", { - projectPath, - featureId, - }), - - // Listen for auto mode events - onEvent: (callback) => { - const subscription = (_, data) => callback(data); - ipcRenderer.on("auto-mode:event", subscription); - - // Return unsubscribe function - return () => { - ipcRenderer.removeListener("auto-mode:event", subscription); - }; - }, - }, - - // Claude CLI Detection API - checkClaudeCli: () => ipcRenderer.invoke("claude:check-cli"), - - // Codex CLI Detection API - checkCodexCli: () => ipcRenderer.invoke("codex:check-cli"), - - // Model Management APIs - model: { - // Get all available models from all providers - getAvailable: () => ipcRenderer.invoke("model:get-available"), - - // Check all provider installation status - checkProviders: () => ipcRenderer.invoke("model:check-providers"), - }, - - // OpenAI API - testOpenAIConnection: (apiKey) => - ipcRenderer.invoke("openai:test-connection", { apiKey }), - - // Worktree Management APIs - worktree: { - // Revert feature changes by removing the worktree - revertFeature: (projectPath, featureId) => - ipcRenderer.invoke("worktree:revert-feature", { projectPath, featureId }), - - // Merge feature worktree changes back to main branch - mergeFeature: (projectPath, featureId, options) => - ipcRenderer.invoke("worktree:merge-feature", { - projectPath, - featureId, - options, - }), - - // Get worktree info for a feature - getInfo: (projectPath, featureId) => - ipcRenderer.invoke("worktree:get-info", { projectPath, featureId }), - - // Get worktree status (changed files, commits) - getStatus: (projectPath, featureId) => - ipcRenderer.invoke("worktree:get-status", { projectPath, featureId }), - - // List all feature worktrees - list: (projectPath) => ipcRenderer.invoke("worktree:list", { projectPath }), - - // Get file diffs for a feature worktree - getDiffs: (projectPath, featureId) => - ipcRenderer.invoke("worktree:get-diffs", { projectPath, featureId }), - - // Get diff for a specific file in a worktree - getFileDiff: (projectPath, featureId, filePath) => - ipcRenderer.invoke("worktree:get-file-diff", { - projectPath, - featureId, - filePath, - }), - }, - - // Git Operations APIs (for non-worktree operations) - git: { - // Get file diffs for the main project - getDiffs: (projectPath) => - ipcRenderer.invoke("git:get-diffs", { projectPath }), - - // Get diff for a specific file in the main project - getFileDiff: (projectPath, filePath) => - ipcRenderer.invoke("git:get-file-diff", { projectPath, filePath }), - }, - - // Feature Suggestions API - suggestions: { - // Generate feature suggestions - // suggestionType can be: "features", "refactoring", "security", "performance" - generate: (projectPath, suggestionType = "features") => - ipcRenderer.invoke("suggestions:generate", { projectPath, suggestionType }), - - // Stop generating suggestions - stop: () => ipcRenderer.invoke("suggestions:stop"), - - // Get suggestions status - status: () => ipcRenderer.invoke("suggestions:status"), - - // Listen for suggestions events - onEvent: (callback) => { - const subscription = (_, data) => callback(data); - ipcRenderer.on("suggestions:event", subscription); - - // Return unsubscribe function - return () => { - ipcRenderer.removeListener("suggestions:event", subscription); - }; - }, - }, - - // Spec Regeneration API - specRegeneration: { - // Create initial app spec for a new project - create: (projectPath, projectOverview, generateFeatures = true) => - ipcRenderer.invoke("spec-regeneration:create", { - projectPath, - projectOverview, - generateFeatures, - }), - - // Regenerate the app spec - generate: (projectPath, projectDefinition) => - ipcRenderer.invoke("spec-regeneration:generate", { - projectPath, - projectDefinition, - }), - - // Generate features from existing app_spec.txt - generateFeatures: (projectPath) => - ipcRenderer.invoke("spec-regeneration:generate-features", { - projectPath, - }), - - // Stop regenerating spec - stop: () => ipcRenderer.invoke("spec-regeneration:stop"), - - // Get regeneration status - status: () => ipcRenderer.invoke("spec-regeneration:status"), - - // Listen for regeneration events - onEvent: (callback) => { - const subscription = (_, data) => callback(data); - ipcRenderer.on("spec-regeneration:event", subscription); - - // Return unsubscribe function - return () => { - ipcRenderer.removeListener("spec-regeneration:event", subscription); - }; - }, - }, - - // Setup & CLI Management API - setup: { - // Get comprehensive Claude CLI status - getClaudeStatus: () => ipcRenderer.invoke("setup:claude-status"), - - // Get comprehensive Codex CLI status - getCodexStatus: () => ipcRenderer.invoke("setup:codex-status"), - - // Install Claude CLI - installClaude: () => ipcRenderer.invoke("setup:install-claude"), - - // Install Codex CLI - installCodex: () => ipcRenderer.invoke("setup:install-codex"), - - // Authenticate Claude CLI - authClaude: () => ipcRenderer.invoke("setup:auth-claude"), - - // Authenticate Codex CLI with optional API key - authCodex: (apiKey) => ipcRenderer.invoke("setup:auth-codex", { apiKey }), - - // Store API key securely - storeApiKey: (provider, apiKey) => - ipcRenderer.invoke("setup:store-api-key", { provider, apiKey }), - - // Get stored API keys status - getApiKeys: () => ipcRenderer.invoke("setup:get-api-keys"), - - // Configure Codex MCP server for a project - configureCodexMcp: (projectPath) => - ipcRenderer.invoke("setup:configure-codex-mcp", { projectPath }), - - // Get platform information - getPlatform: () => ipcRenderer.invoke("setup:get-platform"), - - // Listen for installation progress - onInstallProgress: (callback) => { - const subscription = (_, data) => callback(data); - ipcRenderer.on("setup:install-progress", subscription); - return () => { - ipcRenderer.removeListener("setup:install-progress", subscription); - }; - }, - - // Listen for auth progress - onAuthProgress: (callback) => { - const subscription = (_, data) => callback(data); - ipcRenderer.on("setup:auth-progress", subscription); - return () => { - ipcRenderer.removeListener("setup:auth-progress", subscription); - }; - }, - }, - - // Features API - features: { - // Get all features for a project - getAll: (projectPath) => - ipcRenderer.invoke("features:getAll", { projectPath }), - - // Get a single feature by ID - get: (projectPath, featureId) => - ipcRenderer.invoke("features:get", { projectPath, featureId }), - - // Create a new feature - create: (projectPath, feature) => - ipcRenderer.invoke("features:create", { projectPath, feature }), - - // Update a feature (partial updates supported) - update: (projectPath, featureId, updates) => - ipcRenderer.invoke("features:update", { - projectPath, - featureId, - updates, - }), - - // Delete a feature and its folder - delete: (projectPath, featureId) => - ipcRenderer.invoke("features:delete", { projectPath, featureId }), - - // Get agent output for a feature - getAgentOutput: (projectPath, featureId) => - ipcRenderer.invoke("features:getAgentOutput", { projectPath, featureId }), - }, - - // Running Agents API - runningAgents: { - // Get all running agents across all projects - getAll: () => ipcRenderer.invoke("running-agents:getAll"), - }, -}); - -// Also expose a flag to detect if we're in Electron +// Only expose a flag to detect Electron environment +// All API calls go through HTTP to the backend server contextBridge.exposeInMainWorld("isElectron", true); + +// Expose platform info for UI purposes +contextBridge.exposeInMainWorld("electronPlatform", process.platform); + +console.log("[Preload] Electron flag exposed (HTTP-only mode)"); diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 36444bdb..2f7dc659 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -143,6 +143,80 @@ --running-indicator-text: oklch(0.6 0.22 265); } +/* Apply dark mode immediately based on system preference (before JS runs) */ +@media (prefers-color-scheme: dark) { + :root { + /* Deep dark backgrounds - zinc-950 family */ + --background: oklch(0.04 0 0); /* zinc-950 */ + --background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */ + --background-80: oklch(0.04 0 0 / 0.8); /* zinc-950/80 */ + + /* Text colors following hierarchy */ + --foreground: oklch(1 0 0); /* text-white */ + --foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */ + --foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */ + + /* Card and popover backgrounds */ + --card: oklch(0.14 0 0); + --card-foreground: oklch(1 0 0); + --popover: oklch(0.10 0 0); + --popover-foreground: oklch(1 0 0); + + /* Brand colors - purple/violet theme */ + --primary: oklch(0.55 0.25 265); + --primary-foreground: oklch(1 0 0); + --brand-400: oklch(0.6 0.22 265); + --brand-500: oklch(0.55 0.25 265); + --brand-600: oklch(0.5 0.28 270); + + /* Glass morphism borders and accents */ + --secondary: oklch(1 0 0 / 0.05); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.176 0 0); + --muted-foreground: oklch(0.588 0 0); + --accent: oklch(1 0 0 / 0.1); + --accent-foreground: oklch(1 0 0); + + /* Borders with transparency for glass effect */ + --border: oklch(0.176 0 0); + --border-glass: oklch(1 0 0 / 0.1); + --destructive: oklch(0.6 0.25 25); + --input: oklch(0.04 0 0 / 0.8); + --ring: oklch(0.55 0.25 265); + + /* Chart colors with brand theme */ + --chart-1: oklch(0.55 0.25 265); + --chart-2: oklch(0.65 0.2 160); + --chart-3: oklch(0.75 0.2 70); + --chart-4: oklch(0.6 0.25 300); + --chart-5: oklch(0.6 0.25 20); + + /* Sidebar with glass morphism */ + --sidebar: oklch(0.04 0 0 / 0.5); + --sidebar-foreground: oklch(1 0 0); + --sidebar-primary: oklch(0.55 0.25 265); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(1 0 0 / 0.05); + --sidebar-accent-foreground: oklch(1 0 0); + --sidebar-border: oklch(1 0 0 / 0.1); + --sidebar-ring: oklch(0.55 0.25 265); + + /* Action button colors */ + --action-view: oklch(0.6 0.25 265); + --action-view-hover: oklch(0.55 0.27 270); + --action-followup: oklch(0.6 0.2 230); + --action-followup-hover: oklch(0.55 0.22 230); + --action-commit: oklch(0.55 0.2 140); + --action-commit-hover: oklch(0.5 0.22 140); + --action-verify: oklch(0.55 0.2 140); + --action-verify-hover: oklch(0.5 0.22 140); + + /* Running indicator - Purple */ + --running-indicator: oklch(0.6 0.25 265); + --running-indicator-text: oklch(0.65 0.22 265); + } +} + .light { /* Explicit light mode - same as root but ensures it overrides any dark defaults */ --background: oklch(1 0 0); /* White */ @@ -211,10 +285,10 @@ --foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */ --foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */ - /* Glass morphism effects */ - --card: oklch(0.04 0 0 / 0.5); /* zinc-950/50 with transparency */ + /* Card and popover backgrounds */ + --card: oklch(0.14 0 0); /* slightly lighter than background for contrast */ --card-foreground: oklch(1 0 0); - --popover: oklch(0.04 0 0 / 0.8); /* zinc-950/80 for popover */ + --popover: oklch(0.10 0 0); /* slightly lighter than background */ --popover-foreground: oklch(1 0 0); /* Brand colors - purple/violet theme */ diff --git a/apps/app/src/app/layout.tsx b/apps/app/src/app/layout.tsx index 2d7df503..b303aeec 100644 --- a/apps/app/src/app/layout.tsx +++ b/apps/app/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { GeistSans } from "geist/font/sans"; import { GeistMono } from "geist/font/mono"; import { Toaster } from "sonner"; +import { CoursePromoBadge } from "@/components/ui/course-promo-badge"; import "./globals.css"; export const metadata: Metadata = { title: "Automaker - Autonomous AI Development Studio", @@ -20,6 +21,7 @@ export default function RootLayout({ > {children} + ); diff --git a/apps/app/src/components/session-manager.tsx b/apps/app/src/components/session-manager.tsx index 7cafacdc..6cf53e68 100644 --- a/apps/app/src/components/session-manager.tsx +++ b/apps/app/src/components/session-manager.tsx @@ -25,6 +25,7 @@ import { import { cn } from "@/lib/utils"; import type { SessionListItem } from "@/types/electron"; import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts"; +import { getElectronAPI } from "@/lib/electron"; // Random session name generator const adjectives = [ @@ -115,14 +116,15 @@ export function SessionManager({ // Check running state for all sessions const checkRunningSessions = async (sessionList: SessionListItem[]) => { - if (!window.electronAPI?.agent) return; + const api = getElectronAPI(); + if (!api?.agent) return; const runningIds = new Set(); // Check each session's running state for (const session of sessionList) { try { - const result = await window.electronAPI.agent.getHistory(session.id); + const result = await api.agent.getHistory(session.id); if (result.success && result.isRunning) { runningIds.add(session.id); } @@ -140,10 +142,11 @@ export function SessionManager({ // Load sessions const loadSessions = async () => { - if (!window.electronAPI?.sessions) return; + const api = getElectronAPI(); + if (!api?.sessions) return; // Always load all sessions and filter client-side - const result = await window.electronAPI.sessions.list(true); + const result = await api.sessions.list(true); if (result.success && result.sessions) { setSessions(result.sessions); // Check running state for all sessions @@ -171,39 +174,41 @@ export function SessionManager({ // Create new session with random name const handleCreateSession = async () => { - if (!window.electronAPI?.sessions) return; + const api = getElectronAPI(); + if (!api?.sessions) return; const sessionName = newSessionName.trim() || generateRandomSessionName(); - const result = await window.electronAPI.sessions.create( + const result = await api.sessions.create( sessionName, projectPath, projectPath ); - if (result.success && result.sessionId) { + if (result.success && result.session?.id) { setNewSessionName(""); setIsCreating(false); await loadSessions(); - onSelectSession(result.sessionId); + onSelectSession(result.session.id); } }; // Create new session directly with a random name (one-click) const handleQuickCreateSession = async () => { - if (!window.electronAPI?.sessions) return; + const api = getElectronAPI(); + if (!api?.sessions) return; const sessionName = generateRandomSessionName(); - const result = await window.electronAPI.sessions.create( + const result = await api.sessions.create( sessionName, projectPath, projectPath ); - if (result.success && result.sessionId) { + if (result.success && result.session?.id) { await loadSessions(); - onSelectSession(result.sessionId); + onSelectSession(result.session.id); } }; @@ -221,9 +226,10 @@ export function SessionManager({ // Rename session const handleRenameSession = async (sessionId: string) => { - if (!editingName.trim() || !window.electronAPI?.sessions) return; + const api = getElectronAPI(); + if (!editingName.trim() || !api?.sessions) return; - const result = await window.electronAPI.sessions.update( + const result = await api.sessions.update( sessionId, editingName, undefined @@ -238,9 +244,10 @@ export function SessionManager({ // Archive session const handleArchiveSession = async (sessionId: string) => { - if (!window.electronAPI?.sessions) return; + const api = getElectronAPI(); + if (!api?.sessions) return; - const result = await window.electronAPI.sessions.archive(sessionId); + const result = await api.sessions.archive(sessionId); if (result.success) { // If the archived session was currently selected, deselect it if (currentSessionId === sessionId) { @@ -252,9 +259,10 @@ export function SessionManager({ // Unarchive session const handleUnarchiveSession = async (sessionId: string) => { - if (!window.electronAPI?.sessions) return; + const api = getElectronAPI(); + if (!api?.sessions) return; - const result = await window.electronAPI.sessions.unarchive(sessionId); + const result = await api.sessions.unarchive(sessionId); if (result.success) { await loadSessions(); } @@ -262,10 +270,11 @@ export function SessionManager({ // Delete session const handleDeleteSession = async (sessionId: string) => { - if (!window.electronAPI?.sessions) return; + const api = getElectronAPI(); + if (!api?.sessions) return; if (!confirm("Are you sure you want to delete this session?")) return; - const result = await window.electronAPI.sessions.delete(sessionId); + const result = await api.sessions.delete(sessionId); if (result.success) { await loadSessions(); if (currentSessionId === sessionId) { diff --git a/apps/app/src/components/ui/course-promo-badge.tsx b/apps/app/src/components/ui/course-promo-badge.tsx new file mode 100644 index 00000000..b94baf13 --- /dev/null +++ b/apps/app/src/components/ui/course-promo-badge.tsx @@ -0,0 +1,136 @@ +"use client"; + +import * as React from "react"; +import { Sparkles, Rocket, X, ExternalLink, Code, MessageSquare, Brain, Terminal } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "./dialog"; +import { Button } from "./button"; + +export function CoursePromoBadge() { + const [open, setOpen] = React.useState(false); + const [dismissed, setDismissed] = React.useState(false); + + if (dismissed) { + return null; + } + + return ( + <> +
+ +
+ + + + + + + Learn Agentic AI Development + + + Master the tools and techniques behind modern AI-assisted coding + + + +
+
+

+ Did you know Automaker was built entirely through agentic coding? + Want to learn how? Check out the course! +

+
+ +

+ Agentic Jumpstart teaches you + how to leverage AI tools to build software faster and smarter than ever before. +

+ +
+
+
+ +
+
+

Claude Code Mastery

+

+ Learn to use Claude Code effectively for autonomous development workflows +

+
+
+ +
+
+ +
+
+

Cursor & AI IDEs

+

+ Master Cursor and other AI-powered development environments +

+
+
+ +
+
+ +
+
+

Prompting Techniques

+

+ Craft effective prompts that get you the results you need +

+
+
+ +
+
+ +
+
+

Context Engineering

+

+ Structure your projects and context for optimal AI collaboration +

+
+
+
+
+ + + + + +
+
+ + ); +} diff --git a/apps/app/src/components/ui/description-image-dropzone.tsx b/apps/app/src/components/ui/description-image-dropzone.tsx index 5b3b1a9c..df685082 100644 --- a/apps/app/src/components/ui/description-image-dropzone.tsx +++ b/apps/app/src/components/ui/description-image-dropzone.tsx @@ -83,6 +83,13 @@ export function DescriptionImageDropZone({ const fileInputRef = useRef(null); const currentProject = useAppStore((state) => state.currentProject); + // Construct server URL for loading saved images + const getImageServerUrl = useCallback((imagePath: string): string => { + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const projectPath = currentProject?.path || ""; + return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; + }, [currentProject?.path]); + const fileToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -374,7 +381,15 @@ export function DescriptionImageDropZone({ className="max-w-full max-h-full object-contain" /> ) : ( - + {image.filename} { + // If image fails to load, hide it + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> )} {/* Remove button */} diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index edd819b1..23d332a9 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -583,10 +583,24 @@ export function BoardView() { } loadFeatures(); - // Show error toast - toast.error("Agent encountered an error", { - description: event.error || "Check the logs for details", - }); + + // Check for authentication errors and show a more helpful message + const isAuthError = event.errorType === "authentication" || + (event.error && ( + event.error.includes("Authentication failed") || + event.error.includes("Invalid API key") + )); + + if (isAuthError) { + toast.error("Authentication Failed", { + description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.", + duration: 10000, + }); + } else { + toast.error("Agent encountered an error", { + description: event.error || "Check the logs for details", + }); + } } }); diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index 0cdef02d..9128c179 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -40,6 +40,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { toast } from "sonner"; +import { WorkspacePickerModal } from "@/components/workspace-picker-modal"; +import { getHttpApiClient } from "@/lib/http-api-client"; export function WelcomeView() { const { projects, addProject, setCurrentProject, setCurrentView } = @@ -57,6 +59,7 @@ export function WelcomeView() { projectName: string; projectPath: string; } | null>(null); + const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); /** * Kick off project analysis agent to analyze the codebase @@ -172,17 +175,51 @@ export function WelcomeView() { ); const handleOpenProject = useCallback(async () => { - const api = getElectronAPI(); - const result = await api.openDirectory(); + try { + // Check if workspace is configured + const httpClient = getHttpApiClient(); + const configResult = await httpClient.workspace.getConfig(); - if (!result.canceled && result.filePaths[0]) { - const path = result.filePaths[0]; - // Extract folder name from path (works on both Windows and Mac/Linux) - const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; - await initializeAndOpenProject(path, name); + if (configResult.success && configResult.configured) { + // Show workspace picker modal + setShowWorkspacePicker(true); + } else { + // Fall back to current behavior (native dialog or manual input) + const api = getElectronAPI(); + const result = await api.openDirectory(); + + if (!result.canceled && result.filePaths[0]) { + const path = result.filePaths[0]; + // Extract folder name from path (works on both Windows and Mac/Linux) + const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + await initializeAndOpenProject(path, name); + } + } + } catch (error) { + console.error("[Welcome] Failed to check workspace config:", error); + // Fall back to current behavior on error + const api = getElectronAPI(); + const result = await api.openDirectory(); + + if (!result.canceled && result.filePaths[0]) { + const path = result.filePaths[0]; + const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + await initializeAndOpenProject(path, name); + } } }, [initializeAndOpenProject]); + /** + * Handle selecting a project from workspace picker + */ + const handleWorkspaceSelect = useCallback( + async (path: string, name: string) => { + setShowWorkspacePicker(false); + await initializeAndOpenProject(path, name); + }, + [initializeAndOpenProject] + ); + /** * Handle clicking on a recent project */ @@ -621,6 +658,13 @@ export function WelcomeView() { + {/* Workspace Picker Modal */} + + {/* Loading overlay when opening project */} {isOpening && (
void; + onSelect: (path: string, name: string) => void; +} + +export function WorkspacePickerModal({ + open, + onOpenChange, + onSelect, +}: WorkspacePickerModalProps) { + const [isLoading, setIsLoading] = useState(false); + const [directories, setDirectories] = useState([]); + const [error, setError] = useState(null); + + const loadDirectories = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const client = getHttpApiClient(); + const result = await client.workspace.getDirectories(); + + if (result.success && result.directories) { + setDirectories(result.directories); + } else { + setError(result.error || "Failed to load directories"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load directories"); + } finally { + setIsLoading(false); + } + }, []); + + // Load directories when modal opens + useEffect(() => { + if (open) { + loadDirectories(); + } + }, [open, loadDirectories]); + + const handleSelect = (dir: WorkspaceDirectory) => { + onSelect(dir.path, dir.name); + }; + + return ( + + + + + + Select Project + + + Choose a project from your workspace directory + + + +
+ {isLoading && ( +
+ +

Loading projects...

+
+ )} + + {error && !isLoading && ( +
+
+ +
+

{error}

+ +
+ )} + + {!isLoading && !error && directories.length === 0 && ( +
+
+ +
+

+ No projects found in workspace directory +

+
+ )} + + {!isLoading && !error && directories.length > 0 && ( +
+ {directories.map((dir) => ( + + ))} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/app/src/hooks/use-auto-mode.ts b/apps/app/src/hooks/use-auto-mode.ts index b572a3fa..6852ec64 100644 --- a/apps/app/src/hooks/use-auto-mode.ts +++ b/apps/app/src/hooks/use-auto-mode.ts @@ -121,11 +121,26 @@ export function useAutoMode() { case "auto_mode_error": console.error("[AutoMode Error]", event.error); if (event.featureId && event.error) { + // Check for authentication errors and provide a more helpful message + const isAuthError = event.errorType === "authentication" || + event.error.includes("Authentication failed") || + event.error.includes("Invalid API key"); + + const errorMessage = isAuthError + ? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.` + : event.error; + addAutoModeActivity({ featureId: event.featureId, type: "error", - message: event.error, + message: errorMessage, + errorType: isAuthError ? "authentication" : "execution", }); + + // Remove the task from running since it failed + if (eventProjectId) { + removeRunningTask(eventProjectId, event.featureId); + } } break; diff --git a/apps/app/src/hooks/use-electron-agent.ts b/apps/app/src/hooks/use-electron-agent.ts index 2746e472..39342b83 100644 --- a/apps/app/src/hooks/use-electron-agent.ts +++ b/apps/app/src/hooks/use-electron-agent.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { Message, StreamEvent } from "@/types/electron"; import { useMessageQueue } from "./use-message-queue"; import type { ImageAttachment } from "@/store/app-store"; +import { getElectronAPI } from "@/lib/electron"; interface UseElectronAgentOptions { sessionId: string; @@ -44,8 +45,9 @@ export function useElectronAgent({ // Send message directly to the agent (bypassing queue) const sendMessageDirectly = useCallback( async (content: string, images?: ImageAttachment[]) => { - if (!window.electronAPI?.agent) { - setError("Electron API not available"); + const api = getElectronAPI(); + if (!api?.agent) { + setError("API not available"); return; } @@ -64,10 +66,10 @@ export function useElectronAgent({ // Save images to .automaker/images and get paths let imagePaths: string[] | undefined; - if (images && images.length > 0) { + if (images && images.length > 0 && api.saveImageToTemp) { imagePaths = []; for (const image of images) { - const result = await window.electronAPI.saveImageToTemp( + const result = await api.saveImageToTemp( image.data, image.filename, image.mimeType, @@ -82,7 +84,7 @@ export function useElectronAgent({ } } - const result = await window.electronAPI.agent.send( + const result = await api.agent!.send( sessionId, content, workingDirectory, @@ -120,8 +122,9 @@ export function useElectronAgent({ // Initialize connection and load history useEffect(() => { - if (!window.electronAPI?.agent) { - setError("Electron API not available. Please run in Electron."); + const api = getElectronAPI(); + if (!api?.agent) { + setError("API not available."); return; } @@ -142,7 +145,7 @@ export function useElectronAgent({ try { console.log("[useElectronAgent] Starting session:", sessionId); - const result = await window.electronAPI.agent.start( + const result = await api.agent!.start( sessionId, workingDirectory ); @@ -155,7 +158,7 @@ export function useElectronAgent({ setIsConnected(true); // Check if the agent is currently running for this session - const historyResult = await window.electronAPI.agent.getHistory(sessionId); + const historyResult = await api.agent!.getHistory(sessionId); if (mounted && historyResult.success) { const isRunning = historyResult.isRunning || false; console.log("[useElectronAgent] Session running state:", isRunning); @@ -190,7 +193,8 @@ export function useElectronAgent({ // Subscribe to streaming events useEffect(() => { - if (!window.electronAPI?.agent) return; + const api = getElectronAPI(); + if (!api?.agent) return; if (!sessionId) return; // Don't subscribe if no session console.log("[useElectronAgent] Subscribing to stream events for session:", sessionId); @@ -282,7 +286,7 @@ export function useElectronAgent({ } }; - unsubscribeRef.current = window.electronAPI.agent.onStream(handleStream); + unsubscribeRef.current = api.agent!.onStream(handleStream as (data: unknown) => void); return () => { if (unsubscribeRef.current) { @@ -296,8 +300,9 @@ export function useElectronAgent({ // Send a message to the agent const sendMessage = useCallback( async (content: string, images?: ImageAttachment[]) => { - if (!window.electronAPI?.agent) { - setError("Electron API not available"); + const api = getElectronAPI(); + if (!api?.agent) { + setError("API not available"); return; } @@ -317,10 +322,10 @@ export function useElectronAgent({ // Save images to .automaker/images and get paths let imagePaths: string[] | undefined; - if (images && images.length > 0) { + if (images && images.length > 0 && api.saveImageToTemp) { imagePaths = []; for (const image of images) { - const result = await window.electronAPI.saveImageToTemp( + const result = await api.saveImageToTemp( image.data, image.filename, image.mimeType, @@ -335,7 +340,7 @@ export function useElectronAgent({ } } - const result = await window.electronAPI.agent.send( + const result = await api.agent!.send( sessionId, content, workingDirectory, @@ -359,14 +364,15 @@ export function useElectronAgent({ // Stop current execution const stopExecution = useCallback(async () => { - if (!window.electronAPI?.agent) { - setError("Electron API not available"); + const api = getElectronAPI(); + if (!api?.agent) { + setError("API not available"); return; } try { console.log("[useElectronAgent] Stopping execution"); - const result = await window.electronAPI.agent.stop(sessionId); + const result = await api.agent!.stop(sessionId); if (!result.success) { setError(result.error || "Failed to stop execution"); @@ -381,14 +387,15 @@ export function useElectronAgent({ // Clear conversation history const clearHistory = useCallback(async () => { - if (!window.electronAPI?.agent) { - setError("Electron API not available"); + const api = getElectronAPI(); + if (!api?.agent) { + setError("API not available"); return; } try { console.log("[useElectronAgent] Clearing history"); - const result = await window.electronAPI.agent.clear(sessionId); + const result = await api.agent!.clear(sessionId); if (result.success) { setMessages([]); diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts index 367e519e..2d141836 100644 --- a/apps/app/src/lib/electron.ts +++ b/apps/app/src/lib/electron.ts @@ -1,4 +1,5 @@ // Type definitions for Electron IPC API +import type { SessionListItem, Message } from "@/types/electron"; export interface FileEntry { name: string; @@ -413,6 +414,59 @@ export interface ElectronAPI { onInstallProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void; }; + agent?: { + start: (sessionId: string, workingDirectory?: string) => Promise<{ + success: boolean; + messages?: Message[]; + error?: string; + }>; + send: ( + sessionId: string, + message: string, + workingDirectory?: string, + imagePaths?: string[] + ) => Promise<{ success: boolean; error?: string }>; + getHistory: (sessionId: string) => Promise<{ + success: boolean; + messages?: Message[]; + isRunning?: boolean; + error?: string; + }>; + stop: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + clear: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + onStream: (callback: (data: unknown) => void) => () => void; + }; + sessions?: { + list: (includeArchived?: boolean) => Promise<{ + success: boolean; + sessions?: SessionListItem[]; + error?: string; + }>; + create: ( + name: string, + projectPath: string, + workingDirectory?: string + ) => Promise<{ + success: boolean; + session?: { + id: string; + name: string; + projectPath: string; + workingDirectory?: string; + createdAt: string; + updatedAt: string; + }; + error?: string; + }>; + update: ( + sessionId: string, + name?: string, + tags?: string[] + ) => Promise<{ success: boolean; error?: string }>; + archive: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + unarchive: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + }; } // Note: Window interface is declared in @/types/electron.d.ts @@ -438,7 +492,7 @@ const STORAGE_KEYS = { // Mock file system using localStorage const mockFileSystem: Record = {}; -// Check if we're in Electron +// Check if we're in Electron (for UI indicators only) export const isElectron = (): boolean => { return typeof window !== "undefined" && window.isElectron === true; }; @@ -478,72 +532,50 @@ export const resetServerCheck = (): void => { // 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 +/** + * Get the HTTP API client + * + * All API calls go through HTTP to the backend server. + * This is the only transport mode supported. + */ 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!; + if (typeof window === "undefined") { + throw new Error("Cannot get API during SSR"); } - // Full Electron API with IPC - if (isElectron() && window.electronAPI) { - return window.electronAPI; + if (!httpClientInstance) { + const { getHttpApiClient } = require("./http-api-client"); + httpClientInstance = getHttpApiClient(); } - - // 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"); + return httpClientInstance!; }; -// Async version that checks server availability first +// Async version (same as sync since HTTP client is synchronously instantiated) export const getElectronAPIAsync = async (): Promise => { - // 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"); + return getElectronAPI(); }; // Check if backend is connected (for showing connection status in UI) export const isBackendConnected = async (): Promise => { - // Full Electron mode: backend is built-in - if (isElectron() && !isSimplifiedElectronMode()) return true; - // Simplified Electron or web mode: check server availability return await checkServerAvailable(); }; +/** + * Get the current API mode being used + * Always returns "http" since that's the only mode now + */ +export const getCurrentApiMode = (): "http" => { + return "http"; +}; + +// Debug helpers +if (typeof window !== "undefined") { + (window as any).__checkApiMode = () => { + console.log("Current API mode:", getCurrentApiMode()); + console.log("isElectron():", isElectron()); + }; +} + // Mock API for development/fallback when no backend is available const getMockElectronAPI = (): ElectronAPI => { return { @@ -1962,7 +1994,9 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI { } mockSpecRegenerationRunning = true; - console.log(`[Mock] Generating features from existing spec for: ${projectPath}`); + console.log( + `[Mock] Generating features from existing spec for: ${projectPath}` + ); // Simulate async feature generation simulateFeatureGeneration(projectPath); @@ -2149,7 +2183,8 @@ async function simulateFeatureGeneration(projectPath: string) { mockSpecRegenerationPhase = "initialization"; emitSpecRegenerationEvent({ type: "spec_regeneration_progress", - content: "[Phase: initialization] Starting feature generation from existing app_spec.txt...\n", + content: + "[Phase: initialization] Starting feature generation from existing app_spec.txt...\n", }); await new Promise((resolve) => { diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 123709de..6441b582 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -23,6 +23,7 @@ import type { FeatureSuggestion, SuggestionType, } from "./electron"; +import type { Message, SessionListItem } from "@/types/electron"; import type { Feature } from "@/store/app-store"; import type { WorktreeAPI, @@ -31,46 +32,9 @@ import type { 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 => { - 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 => { +// Server URL - configurable via environment variable +const getServerUrl = (): string => { if (typeof window !== "undefined") { const envUrl = process.env.NEXT_PUBLIC_SERVER_URL; if (envUrl) return envUrl; @@ -78,6 +42,7 @@ const getServerUrlSync = (): string => { return "http://localhost:3008"; }; + // Get API key from environment variable const getApiKey = (): string | null => { if (typeof window !== "undefined") { @@ -105,25 +70,10 @@ export class HttpApiClient implements ElectronAPI { private isConnecting = false; constructor() { - this.serverUrl = getServerUrlSync(); - // Update server URL asynchronously if in Electron - this.initServerUrl(); + this.serverUrl = getServerUrl(); this.connectWebSocket(); } - private async initServerUrl(): Promise { - 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; @@ -222,6 +172,23 @@ export class HttpApiClient implements ElectronAPI { return response.json(); } + private async put(endpoint: string, body?: unknown): Promise { + const response = await fetch(`${this.serverUrl}${endpoint}`, { + method: "PUT", + headers: this.getHeaders(), + body: body ? JSON.stringify(body) : undefined, + }); + return response.json(); + } + + private async httpDelete(endpoint: string): Promise { + const response = await fetch(`${this.serverUrl}${endpoint}`, { + method: "DELETE", + headers: this.getHeaders(), + }); + return response.json(); + } + // Basic operations async ping(): Promise { const result = await this.get<{ status: string }>("/api/health"); @@ -229,27 +196,13 @@ export class HttpApiClient implements ElectronAPI { } 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 + // Open in new tab window.open(url, "_blank", "noopener,noreferrer"); return { success: true }; } - // File picker - uses native Electron dialogs when available, otherwise prompt + // File picker - uses prompt for path input async openDirectory(): Promise { - // 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: [] }; @@ -271,13 +224,7 @@ export class HttpApiClient implements ElectronAPI { } async openFile(options?: object): Promise { - // Use native Electron dialog if available - if (hasNativeDialogs()) { - const api = (window as any).electronAPI; - return api.openFile(options); - } - - // Web mode: prompt for file path + // Prompt for file path const path = prompt("Enter file path:"); if (!path) { return { canceled: true, filePaths: [] }; @@ -651,6 +598,98 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.get("/api/running-agents"), }; + + // Workspace API + workspace = { + getConfig: (): Promise<{ + success: boolean; + configured: boolean; + workspaceDir?: string; + error?: string; + }> => this.get("/api/workspace/config"), + + getDirectories: (): Promise<{ + success: boolean; + directories?: Array<{ name: string; path: string }>; + error?: string; + }> => this.get("/api/workspace/directories"), + }; + + // Agent API + agent = { + start: (sessionId: string, workingDirectory?: string): Promise<{ + success: boolean; + messages?: Message[]; + error?: string; + }> => this.post("/api/agent/start", { sessionId, workingDirectory }), + + send: ( + sessionId: string, + message: string, + workingDirectory?: string, + imagePaths?: string[] + ): Promise<{ success: boolean; error?: string }> => + this.post("/api/agent/send", { sessionId, message, workingDirectory, imagePaths }), + + getHistory: (sessionId: string): Promise<{ + success: boolean; + messages?: Message[]; + isRunning?: boolean; + error?: string; + }> => this.post("/api/agent/history", { sessionId }), + + stop: (sessionId: string): Promise<{ success: boolean; error?: string }> => + this.post("/api/agent/stop", { sessionId }), + + clear: (sessionId: string): Promise<{ success: boolean; error?: string }> => + this.post("/api/agent/clear", { sessionId }), + + onStream: (callback: (data: unknown) => void): (() => void) => { + return this.subscribeToEvent("agent:stream", callback as EventCallback); + }, + }; + + // Sessions API + sessions = { + list: (includeArchived?: boolean): Promise<{ + success: boolean; + sessions?: SessionListItem[]; + error?: string; + }> => this.get(`/api/sessions?includeArchived=${includeArchived || false}`), + + create: ( + name: string, + projectPath: string, + workingDirectory?: string + ): Promise<{ + success: boolean; + session?: { + id: string; + name: string; + projectPath: string; + workingDirectory?: string; + createdAt: string; + updatedAt: string; + }; + error?: string; + }> => this.post("/api/sessions", { name, projectPath, workingDirectory }), + + update: ( + sessionId: string, + name?: string, + tags?: string[] + ): Promise<{ success: boolean; error?: string }> => + this.put(`/api/sessions/${sessionId}`, { name, tags }), + + archive: (sessionId: string): Promise<{ success: boolean; error?: string }> => + this.post(`/api/sessions/${sessionId}/archive`, {}), + + unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> => + this.post(`/api/sessions/${sessionId}/unarchive`, {}), + + delete: (sessionId: string): Promise<{ success: boolean; error?: string }> => + this.httpDelete(`/api/sessions/${sessionId}`), + }; } // Singleton instance diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts index a816218c..a640fe02 100644 --- a/apps/app/src/types/electron.d.ts +++ b/apps/app/src/types/electron.d.ts @@ -192,6 +192,7 @@ export type AutoModeEvent = | { type: "auto_mode_error"; error: string; + errorType?: "authentication" | "execution"; featureId?: string; projectId?: string; projectPath?: string; diff --git a/apps/server/.gitignore b/apps/server/.gitignore new file mode 100644 index 00000000..2eea525d --- /dev/null +++ b/apps/server/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/apps/server/data/agent-sessions/msg_1765523524581_xhk6u45v2.json b/apps/server/data/agent-sessions/msg_1765523524581_xhk6u45v2.json new file mode 100644 index 00000000..8fe81fea --- /dev/null +++ b/apps/server/data/agent-sessions/msg_1765523524581_xhk6u45v2.json @@ -0,0 +1,14 @@ +[ + { + "id": "msg_1765523527444_d8xyk8rh9", + "role": "user", + "content": "this is a test", + "timestamp": "2025-12-12T07:12:07.444Z" + }, + { + "id": "msg_1765523534075_351i0rcft", + "role": "assistant", + "content": "Hello! This is a test response. I'm working correctly and ready to help you with your software development projects.\n\nIs there something specific you'd like to work on today? I can help you with:\n\n- **Planning a new project** - Define requirements, architecture, and technical approach\n- **Writing code** - Create new files, implement features, or build components\n- **Editing existing code** - Modify, refactor, or fix bugs in your codebase\n- **Running commands** - Execute tests, builds, or other terminal commands\n- **Exploring your codebase** - Search for patterns, understand existing code structure\n\nJust let me know what you need!", + "timestamp": "2025-12-12T07:12:14.075Z" + } +] \ No newline at end of file diff --git a/apps/server/data/sessions-metadata.json b/apps/server/data/sessions-metadata.json new file mode 100644 index 00000000..a5192ecd --- /dev/null +++ b/apps/server/data/sessions-metadata.json @@ -0,0 +1,10 @@ +{ + "msg_1765523524581_xhk6u45v2": { + "id": "msg_1765523524581_xhk6u45v2", + "name": "Bright Agent 2", + "projectPath": "/Users/webdevcody/Workspace/automaker", + "workingDirectory": "/Users/webdevcody/Workspace/automaker", + "createdAt": "2025-12-12T07:12:04.582Z", + "updatedAt": "2025-12-12T07:12:14.382Z" + } +} \ No newline at end of file diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6590d13e..2c4821b2 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -28,6 +28,7 @@ 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 { createWorkspaceRoutes } from "./routes/workspace.js"; import { AgentService } from "./services/agent-service.js"; import { FeatureLoader } from "./services/feature-loader.js"; @@ -47,7 +48,11 @@ if (!hasAnthropicKey) { ║ ⚠️ 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.' : ''} +║ ${ + hasOAuthToken + ? " You have CLAUDE_CODE_OAUTH_TOKEN set - this is for CLI auth only." + : "" + } ║ ║ ║ Set your API key: ║ ║ export ANTHROPIC_API_KEY="sk-ant-..." ║ @@ -106,6 +111,7 @@ app.use("/api/suggestions", createSuggestionsRoutes(events)); app.use("/api/models", createModelsRoutes()); app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); app.use("/api/running-agents", createRunningAgentsRoutes()); +app.use("/api/workspace", createWorkspaceRoutes()); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index 581cd335..6258fce7 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -217,5 +217,103 @@ export function createFsRoutes(_events: EventEmitter): Router { } }); + // Save image to .automaker/images directory + router.post("/save-image", async (req: Request, res: Response) => { + try { + const { data, filename, mimeType, projectPath } = req.body as { + data: string; + filename: string; + mimeType: string; + projectPath: string; + }; + + if (!data || !filename || !projectPath) { + res.status(400).json({ + success: false, + error: "data, filename, and projectPath are required", + }); + return; + } + + // Create .automaker/images directory if it doesn't exist + const imagesDir = path.join(projectPath, ".automaker", "images"); + await fs.mkdir(imagesDir, { recursive: true }); + + // Decode base64 data (remove data URL prefix if present) + const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + + // Generate unique filename with timestamp + const timestamp = Date.now(); + const ext = path.extname(filename) || ".png"; + const baseName = path.basename(filename, ext); + const uniqueFilename = `${baseName}-${timestamp}${ext}`; + const filePath = path.join(imagesDir, uniqueFilename); + + // Write file + await fs.writeFile(filePath, buffer); + + // Add project path to allowed paths if not already + addAllowedPath(projectPath); + + res.json({ success: true, path: filePath }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // Serve image files + router.get("/image", async (req: Request, res: Response) => { + try { + const { path: imagePath, projectPath } = req.query as { + path?: string; + projectPath?: string; + }; + + if (!imagePath) { + res.status(400).json({ success: false, error: "path is required" }); + return; + } + + // Resolve full path + const fullPath = path.isAbsolute(imagePath) + ? imagePath + : projectPath + ? path.join(projectPath, imagePath) + : imagePath; + + // Check if file exists + try { + await fs.access(fullPath); + } catch { + res.status(404).json({ success: false, error: "Image not found" }); + return; + } + + // Read the file + const buffer = await fs.readFile(fullPath); + + // Determine MIME type from extension + const ext = path.extname(fullPath).toLowerCase(); + const mimeTypes: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + }; + + res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream"); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.send(buffer); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + return router; } diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 1017db8b..aeb0e5d2 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -11,9 +11,46 @@ import fs from "fs/promises"; const execAsync = promisify(exec); -// Storage for API keys (in-memory for now, should be persisted) +// Storage for API keys (in-memory cache) const apiKeys: Record = {}; +// Helper to persist API keys to .env file +async function persistApiKeyToEnv(key: string, value: string): Promise { + const envPath = path.join(process.cwd(), ".env"); + + try { + let envContent = ""; + try { + envContent = await fs.readFile(envPath, "utf-8"); + } catch { + // .env file doesn't exist, we'll create it + } + + // Parse existing env content + const lines = envContent.split("\n"); + const keyRegex = new RegExp(`^${key}=`); + let found = false; + const newLines = lines.map((line) => { + if (keyRegex.test(line)) { + found = true; + return `${key}=${value}`; + } + return line; + }); + + if (!found) { + // Add the key at the end + newLines.push(`${key}=${value}`); + } + + await fs.writeFile(envPath, newLines.join("\n")); + console.log(`[Setup] Persisted ${key} to .env file`); + } catch (error) { + console.error(`[Setup] Failed to persist ${key} to .env:`, error); + throw error; + } +} + export function createSetupRoutes(): Router { const router = Router(); @@ -301,13 +338,16 @@ export function createSetupRoutes(): Router { apiKeys[provider] = apiKey; - // Also set as environment variable - if (provider === "anthropic") { + // Also set as environment variable and persist to .env + if (provider === "anthropic" || provider === "anthropic_oauth_token") { process.env.ANTHROPIC_API_KEY = apiKey; + await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey); } else if (provider === "openai") { process.env.OPENAI_API_KEY = apiKey; + await persistApiKeyToEnv("OPENAI_API_KEY", apiKey); } else if (provider === "google") { process.env.GOOGLE_API_KEY = apiKey; + await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey); } res.json({ success: true }); diff --git a/apps/server/src/routes/workspace.ts b/apps/server/src/routes/workspace.ts new file mode 100644 index 00000000..6cac419c --- /dev/null +++ b/apps/server/src/routes/workspace.ts @@ -0,0 +1,113 @@ +/** + * Workspace routes + * Provides API endpoints for workspace directory management + */ + +import { Router, type Request, type Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { addAllowedPath } from "../lib/security.js"; + +export function createWorkspaceRoutes(): Router { + const router = Router(); + + // Get workspace configuration status + router.get("/config", async (_req: Request, res: Response) => { + try { + const workspaceDir = process.env.WORKSPACE_DIR; + + if (!workspaceDir) { + res.json({ + success: true, + configured: false, + }); + return; + } + + // Check if the directory exists + try { + const stats = await fs.stat(workspaceDir); + if (!stats.isDirectory()) { + res.json({ + success: true, + configured: false, + error: "WORKSPACE_DIR is not a valid directory", + }); + return; + } + + // Add workspace dir to allowed paths + addAllowedPath(workspaceDir); + + res.json({ + success: true, + configured: true, + workspaceDir, + }); + } catch { + res.json({ + success: true, + configured: false, + error: "WORKSPACE_DIR path does not exist", + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + // List directories in workspace + router.get("/directories", async (_req: Request, res: Response) => { + try { + const workspaceDir = process.env.WORKSPACE_DIR; + + if (!workspaceDir) { + res.status(400).json({ + success: false, + error: "WORKSPACE_DIR is not configured", + }); + return; + } + + // Check if directory exists + try { + await fs.stat(workspaceDir); + } catch { + res.status(400).json({ + success: false, + error: "WORKSPACE_DIR path does not exist", + }); + return; + } + + // Add workspace dir to allowed paths + addAllowedPath(workspaceDir); + + // Read directory contents + const entries = await fs.readdir(workspaceDir, { withFileTypes: true }); + + // Filter to directories only and map to result format + const directories = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => ({ + name: entry.name, + path: path.join(workspaceDir, entry.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + // Add each directory to allowed paths + directories.forEach((dir) => addAllowedPath(dir.path)); + + res.json({ + success: true, + directories, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index bcc22625..77eb42ec 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -220,11 +220,17 @@ export class AutoModeService { projectPath, }); } else { + const errorMessage = (error as Error).message || "Unknown error"; + const isAuthError = errorMessage.includes("Authentication failed") || + errorMessage.includes("Invalid API key") || + errorMessage.includes("authentication_failed"); + console.error(`[AutoMode] Feature ${featureId} failed:`, error); await this.updateFeatureStatus(projectPath, featureId, "failed"); this.emitAutoModeEvent("auto_mode_error", { featureId, - error: (error as Error).message, + error: errorMessage, + errorType: isAuthError ? "authentication" : "execution", projectPath, }); } @@ -741,6 +747,17 @@ When done, summarize what you implemented and any notes for the developer.`; for (const block of msg.message.content) { if (block.type === "text") { responseText = block.text; + + // Check for authentication errors in the response + if (block.text.includes("Invalid API key") || + block.text.includes("authentication_failed") || + block.text.includes("Fix external API key")) { + throw new Error( + "Authentication failed: Invalid or expired API key. " + + "Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate." + ); + } + this.emitAutoModeEvent("auto_mode_progress", { featureId, content: block.text, @@ -753,7 +770,20 @@ When done, summarize what you implemented and any notes for the developer.`; }); } } + } else if (msg.type === "assistant" && (msg as { error?: string }).error === "authentication_failed") { + // Handle authentication error from the SDK + throw new Error( + "Authentication failed: Invalid or expired API key. " + + "Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate." + ); } else if (msg.type === "result" && msg.subtype === "success") { + // Check if result indicates an error + if (msg.is_error && msg.result?.includes("Invalid API key")) { + throw new Error( + "Authentication failed: Invalid or expired API key. " + + "Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate." + ); + } responseText = msg.result || responseText; } } diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 04084eb4..9a90c6eb 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -25,6 +25,123 @@ export class FeatureLoader { return path.join(projectPath, ".automaker", "features"); } + /** + * Get the images directory path for a feature + */ + getFeatureImagesDir(projectPath: string, featureId: string): string { + return path.join(this.getFeatureDir(projectPath, featureId), "images"); + } + + /** + * Delete images that were removed from a feature + */ + private async deleteOrphanedImages( + projectPath: string, + oldPaths: Array | undefined, + newPaths: Array | undefined + ): Promise { + if (!oldPaths || oldPaths.length === 0) { + return; + } + + // Build sets of paths for comparison + const oldPathSet = new Set( + oldPaths.map((p) => (typeof p === "string" ? p : p.path)) + ); + const newPathSet = new Set( + (newPaths || []).map((p) => (typeof p === "string" ? p : p.path)) + ); + + // Find images that were removed + for (const oldPath of oldPathSet) { + if (!newPathSet.has(oldPath)) { + try { + const fullPath = path.isAbsolute(oldPath) + ? oldPath + : path.join(projectPath, oldPath); + + await fs.unlink(fullPath); + console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`); + } catch (error) { + // Ignore errors when deleting (file may already be gone) + console.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error); + } + } + } + } + + /** + * Copy images from temp directory to feature directory and update paths + */ + private async migrateImages( + projectPath: string, + featureId: string, + imagePaths?: Array + ): Promise | undefined> { + if (!imagePaths || imagePaths.length === 0) { + return imagePaths; + } + + const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId); + await fs.mkdir(featureImagesDir, { recursive: true }); + + const updatedPaths: Array = []; + + for (const imagePath of imagePaths) { + try { + const originalPath = typeof imagePath === "string" ? imagePath : imagePath.path; + + // Skip if already in feature directory + if (originalPath.includes(`/features/${featureId}/images/`)) { + updatedPaths.push(imagePath); + continue; + } + + // Resolve the full path + const fullOriginalPath = path.isAbsolute(originalPath) + ? originalPath + : path.join(projectPath, originalPath); + + // Check if file exists + try { + await fs.access(fullOriginalPath); + } catch { + console.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`); + continue; + } + + // Get filename and create new path + const filename = path.basename(originalPath); + const newPath = path.join(featureImagesDir, filename); + const relativePath = `.automaker/features/${featureId}/images/${filename}`; + + // Copy the file + await fs.copyFile(fullOriginalPath, newPath); + console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${relativePath}`); + + // Try to delete the original temp file + try { + await fs.unlink(fullOriginalPath); + } catch { + // Ignore errors when deleting temp file + } + + // Update the path in the result + if (typeof imagePath === "string") { + updatedPaths.push(relativePath); + } else { + updatedPaths.push({ ...imagePath, path: relativePath }); + } + } catch (error) { + console.error(`[FeatureLoader] Failed to migrate image:`, error); + // Keep original path if migration fails + updatedPaths.push(imagePath); + } + } + + return updatedPaths; + } + /** * Get the path to a specific feature folder */ @@ -151,12 +268,20 @@ export class FeatureLoader { // Create feature directory await fs.mkdir(featureDir, { recursive: true }); + // Migrate images from temp directory to feature directory + const migratedImagePaths = await this.migrateImages( + projectPath, + featureId, + featureData.imagePaths + ); + // Ensure feature has required fields const feature: Feature = { category: featureData.category || "Uncategorized", description: featureData.description || "", ...featureData, id: featureId, + imagePaths: migratedImagePaths, }; // Write feature.json @@ -179,8 +304,30 @@ export class FeatureLoader { throw new Error(`Feature ${featureId} not found`); } + // Handle image path changes + let updatedImagePaths = updates.imagePaths; + if (updates.imagePaths !== undefined) { + // Delete orphaned images (images that were removed) + await this.deleteOrphanedImages( + projectPath, + feature.imagePaths, + updates.imagePaths + ); + + // Migrate any new images + updatedImagePaths = await this.migrateImages( + projectPath, + featureId, + updates.imagePaths + ); + } + // Merge updates - const updatedFeature: Feature = { ...feature, ...updates }; + const updatedFeature: Feature = { + ...feature, + ...updates, + ...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}), + }; // Write back to file const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);