diff --git a/apps/frontend/scripts/download-python.cjs b/apps/frontend/scripts/download-python.cjs index 5bea9113..215af7db 100644 --- a/apps/frontend/scripts/download-python.cjs +++ b/apps/frontend/scripts/download-python.cjs @@ -46,6 +46,8 @@ const STRIP_PATTERNS = { '.pytest_cache', '.mypy_cache', '__pypackages__', + // Windows-specific bloat + 'pythonwin', // PyWin32 IDE - not needed (9MB) ], // File extensions to remove extensions: [ @@ -68,6 +70,7 @@ const STRIP_PATTERNS = { '.gitignore', '.gitattributes', '.editorconfig', + '.chm', // Windows help files - not needed ], // Specific files to remove files: [ @@ -97,6 +100,12 @@ const STRIP_PATTERNS = { 'conftest.py', 'pytest.ini', ], + // Specific paths within packages to remove (relative to package directory) + // Format: 'package_name/subpath' - removes the entire subpath + packagePaths: [ + 'googleapiclient/discovery_cache/documents', // Cached Google API discovery docs (92MB!) + 'claude_agent_sdk/_bundled', // Bundled Claude CLI (224MB!) - users have it installed separately + ], // Packages that should NEVER be bundled (too large, specialized) // If these appear in dependencies, warn and skip blockedPackages: [ @@ -455,6 +464,32 @@ function stripSitePackages(sitePackagesDir) { const sizeBefore = getDirectorySize(sitePackagesDir); let removedCount = 0; + // First, remove specific package paths (e.g., googleapiclient/discovery_cache/documents) + // Use try/catch instead of existsSync to avoid TOCTOU race conditions + if (STRIP_PATTERNS.packagePaths) { + for (const pkgPath of STRIP_PATTERNS.packagePaths) { + const fullPath = path.join(sitePackagesDir, pkgPath); + try { + // Get size first (may throw ENOENT if path doesn't exist) + let pathSize = 0; + try { + pathSize = getDirectorySize(fullPath); + } catch { + // Path doesn't exist or can't get size - skip + continue; + } + fs.rmSync(fullPath, { recursive: true, force: true }); + console.log(`[download-python] Removed ${pkgPath} (${formatBytes(pathSize)})`); + removedCount++; + } catch (err) { + // ENOENT means file was already gone - not an error + if (err.code !== 'ENOENT') { + console.warn(`[download-python] Failed to remove ${pkgPath}: ${err.message}`); + } + } + } + } + function shouldRemoveDir(name) { return STRIP_PATTERNS.dirs.includes(name.toLowerCase()); } diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index fd7ae128..e54dd43b 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -674,10 +674,19 @@ class CLIToolManager { */ private validateClaude(claudeCmd: string): ToolValidation { try { + // On Windows, .cmd files need shell: true to execute properly. + // SECURITY NOTE: shell: true is safe here because: + // 1. claudeCmd comes from internal path detection (user config or known system paths) + // 2. Only '--version' is passed as an argument (no user input) + // If claudeCmd origin ever changes to accept user input, use escapeShellArgWindows. + const needsShell = process.platform === 'win32' && + (claudeCmd.endsWith('.cmd') || claudeCmd.endsWith('.bat')); + const version = execFileSync(claudeCmd, ['--version'], { encoding: 'utf-8', timeout: 5000, windowsHide: true, + shell: needsShell, }).trim(); // Claude CLI version output format: "claude-code version X.Y.Z" or similar diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index b51eb34e..9a1325ce 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -157,9 +157,10 @@ export function findExecutable(command: string): string | null { const pathSeparator = process.platform === 'win32' ? ';' : ':'; const pathDirs = (env.PATH || '').split(pathSeparator); - // On Windows, also check with common extensions + // On Windows, check Windows-native extensions first (.exe, .cmd) before + // extensionless files (which are typically bash/sh scripts for Git Bash/Cygwin) const extensions = process.platform === 'win32' - ? ['', '.exe', '.cmd', '.bat', '.ps1'] + ? ['.exe', '.cmd', '.bat', '.ps1', ''] : ['']; for (const dir of pathDirs) { diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index 59235734..cbe4a67b 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -1,6 +1,5 @@ import type { BrowserWindow } from 'electron'; import path from 'path'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants'; import type { SDKRateLimitInfo, @@ -15,6 +14,7 @@ import { titleGenerator } from '../title-generator'; import { fileWatcher } from '../file-watcher'; import { projectStore } from '../project-store'; import { notificationService } from '../notification-service'; +import { persistPlanStatusSync, getPlanPath } from './task/plan-file-utils'; /** @@ -92,6 +92,15 @@ export function registerAgenteventsHandlers( if (task && project) { const taskTitle = task.title || task.specId; + const planPath = getPlanPath(project, task); + + // Use shared utility for persisting status (prevents race conditions) + const persistStatus = (status: TaskStatus) => { + const persisted = persistPlanStatusSync(planPath, status); + if (persisted) { + console.log(`[Task ${taskId}] Persisted status to plan: ${status}`); + } + }; if (code === 0) { notificationService.notifyReviewNeeded(taskTitle, project.id, taskId); @@ -105,6 +114,7 @@ export function registerAgenteventsHandlers( if (isActiveStatus && !hasIncompleteSubtasks) { console.log(`[Task ${taskId}] Fallback: Moving to human_review (process exited successfully)`); + persistStatus('human_review'); mainWindow.webContents.send( IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, @@ -113,6 +123,7 @@ export function registerAgenteventsHandlers( } } else { notificationService.notifyTaskFailed(taskTitle, project.id, taskId); + persistStatus('human_review'); mainWindow.webContents.send( IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, @@ -148,6 +159,26 @@ export function registerAgenteventsHandlers( taskId, newStatus ); + + // CRITICAL: Persist status to plan file to prevent flip-flop on task list refresh + // When getTasks() is called, it reads status from the plan file. Without persisting, + // the status in the file might differ from the UI, causing inconsistent state. + // Uses shared utility with locking to prevent race conditions. + try { + const projects = projectStore.getProjects(); + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + const task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + const planPath = getPlanPath(p, task); + persistPlanStatusSync(planPath, newStatus); + break; + } + } + } catch (err) { + // Ignore persistence errors - UI will still work, just might flip on refresh + console.warn('[execution-progress] Could not persist status:', err); + } } } }); diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index 32ada422..2f93f83a 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -92,6 +92,39 @@ export function escapeAppleScriptString(str: string): string { return str.replace(/'/g, "'\\''"); } +/** + * Escape a string for safe use in PowerShell -Command context. + * PowerShell requires escaping backticks, double quotes, dollar signs, + * parentheses, semicolons, and ampersands. + */ +export function escapePowerShellCommand(str: string): string { + return str + .replace(/`/g, '``') // Escape backticks (PowerShell escape char) + .replace(/"/g, '`"') // Escape double quotes + .replace(/\$/g, '`$') // Escape dollar signs (variable expansion) + .replace(/\(/g, '`(') // Escape opening parentheses + .replace(/\)/g, '`)') // Escape closing parentheses + .replace(/;/g, '`;') // Escape semicolons (statement separator) + .replace(/&/g, '`&'); // Escape ampersands (call operator) +} + +/** + * Escape a string for safe use in Git Bash -c context. + * Bash requires escaping single quotes, double quotes, backslashes, and other metacharacters. + */ +export function escapeGitBashCommand(str: string): string { + // For bash -c with double quotes, escape: backslash, double quote, dollar, backtick, + // semicolon, pipe, and exclamation mark (all bash metacharacters that could allow command injection) + return str + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/"/g, '\\"') // Escape double quotes + .replace(/\$/g, '\\$') // Escape dollar signs + .replace(/`/g, '\\`') // Escape backticks + .replace(/;/g, '\\;') // Escape semicolons (command separator) + .replace(/\|/g, '\\|') // Escape pipes (command piping) + .replace(/!/g, '\\!'); // Escape exclamation marks (history expansion) +} + /** * Open a terminal with the given command * Uses the user's preferred terminal from settings @@ -204,58 +237,172 @@ export async function openTerminalWithCommand(command: string): Promise { } else if (platform === 'win32') { // Windows: Use appropriate terminal - // Values match SupportedTerminal type: 'windowsterminal', 'powershell', 'cmd', 'conemu', 'cmder', 'gitbash' + // Values match SupportedTerminal type: 'windowsterminal', 'powershell', 'cmd', 'conemu', 'cmder', + // 'gitbash', 'alacritty', 'wezterm', 'hyper', 'tabby', 'cygwin', 'msys2' const terminalId = preferredTerminal?.toLowerCase() || 'powershell'; console.log('[Claude Code] Using terminal:', terminalId); + console.log('[Claude Code] Command to run:', command); - if (terminalId === 'windowsterminal') { - // Windows Terminal - spawn('wt.exe', ['powershell.exe', '-NoExit', '-Command', command], { - detached: true, stdio: 'ignore', shell: false, - }).unref(); - } else if (terminalId === 'cmd') { - // Command Prompt - spawn('cmd.exe', ['/K', command], { - detached: true, stdio: 'ignore', shell: false, - }).unref(); - } else if (terminalId === 'conemu') { - // ConEmu - spawn('ConEmu64.exe', ['-run', 'powershell.exe', '-NoExit', '-Command', command], { - detached: true, stdio: 'ignore', shell: false, - }).unref(); - } else if (terminalId === 'cmder') { - // Cmder (ConEmu-based) - spawn('cmder.exe', ['/TASK', 'powershell', '/CMD', command], { - detached: true, stdio: 'ignore', shell: false, - }).unref(); - } else if (terminalId === 'gitbash') { - // Git Bash - spawn('C:\\Program Files\\Git\\git-bash.exe', ['-c', command], { - detached: true, stdio: 'ignore', shell: false, - }).unref(); - } else if (terminalId === 'hyper') { - // Hyper - spawn('hyper.exe', [], { detached: true, stdio: 'ignore', shell: false }).unref(); - // Note: Hyper doesn't support direct command execution, user needs to paste - } else if (terminalId === 'tabby') { - // Tabby - spawn('tabby.exe', [], { detached: true, stdio: 'ignore', shell: false }).unref(); - } else if (terminalId === 'alacritty') { - // Alacritty on Windows - spawn('alacritty.exe', ['-e', 'powershell.exe', '-NoExit', '-Command', command], { - detached: true, stdio: 'ignore', shell: false, - }).unref(); - } else if (terminalId === 'wezterm') { - // WezTerm on Windows - spawn('wezterm.exe', ['start', '--', 'powershell.exe', '-NoExit', '-Command', command], { - detached: true, stdio: 'ignore', shell: false, - }).unref(); - } else { - // Default: PowerShell (handles 'powershell', 'system', or any unknown value) - spawn('powershell.exe', ['-NoExit', '-Command', command], { - detached: true, stdio: 'ignore', shell: false, - }).unref(); + // For Windows, use exec with a properly formed command string + // This is more reliable than spawn for complex PowerShell commands with pipes + const { exec } = require('child_process'); + + const runWindowsCommand = (cmdString: string): Promise => { + return new Promise((resolve) => { + console.log(`[Claude Code] Executing: ${cmdString}`); + // Fire and forget - don't wait for the terminal to close + // The -NoExit flag keeps the terminal open, so we can't wait for exec to complete + const child = exec(cmdString, { windowsHide: false }); + + // Detach from the child process so we don't wait for it + child.unref?.(); + + // Resolve immediately after starting the process + // Give it a brief moment to ensure the window opens + setTimeout(() => resolve(), 300); + }); + }; + + try { + // Escape command for PowerShell context to prevent command injection + const escapedCommand = escapePowerShellCommand(command); + + if (terminalId === 'windowsterminal') { + // Windows Terminal - open new tab with PowerShell + await runWindowsCommand(`wt new-tab powershell -NoExit -Command "${escapedCommand}"`); + } else if (terminalId === 'gitbash') { + // Git Bash - use the passed command (escaped for bash context) + const escapedBashCommand = escapeGitBashCommand(command); + const gitBashPaths = [ + 'C:\\Program Files\\Git\\git-bash.exe', + 'C:\\Program Files (x86)\\Git\\git-bash.exe', + ]; + let gitBashPath = gitBashPaths.find(p => existsSync(p)); + if (gitBashPath) { + await runWindowsCommand(`"${gitBashPath}" -c "${escapedBashCommand}"`); + } else { + throw new Error('Git Bash not found'); + } + } else if (terminalId === 'alacritty') { + // Alacritty + await runWindowsCommand(`start alacritty -e powershell -NoExit -Command "${escapedCommand}"`); + } else if (terminalId === 'wezterm') { + // WezTerm + await runWindowsCommand(`start wezterm start -- powershell -NoExit -Command "${escapedCommand}"`); + } else if (terminalId === 'cmd') { + // Command Prompt - use cmd /k to run command and keep window open + // Note: cmd.exe uses its own escaping rules, so we pass the raw command + // and let cmd handle it. The command is typically PowerShell-formatted + // for install scripts, so we run PowerShell from cmd. + await runWindowsCommand(`start cmd /k "powershell -NoExit -Command ${escapedCommand}"`); + } else if (terminalId === 'conemu') { + // ConEmu - open with PowerShell tab running the command + const conemuPaths = [ + 'C:\\Program Files\\ConEmu\\ConEmu64.exe', + 'C:\\Program Files (x86)\\ConEmu\\ConEmu.exe', + ]; + const conemuPath = conemuPaths.find(p => existsSync(p)); + if (conemuPath) { + // ConEmu uses -run to specify the command to execute + await runWindowsCommand(`start "" "${conemuPath}" -run "powershell -NoExit -Command ${escapedCommand}"`); + } else { + // Fall back to PowerShell if ConEmu not found + console.warn('[Claude Code] ConEmu not found, falling back to PowerShell'); + await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); + } + } else if (terminalId === 'cmder') { + // Cmder - portable console emulator for Windows + const cmderPaths = [ + 'C:\\cmder\\Cmder.exe', + 'C:\\tools\\cmder\\Cmder.exe', + path.join(process.env.CMDER_ROOT || '', 'Cmder.exe'), + ].filter(p => p); // Remove empty paths + const cmderPath = cmderPaths.find(p => existsSync(p)); + if (cmderPath) { + // Cmder uses /TASK for predefined tasks or /START for directory, but we can use /C for command + await runWindowsCommand(`start "" "${cmderPath}" /SINGLE /START "" /TASK "powershell -NoExit -Command ${escapedCommand}"`); + } else { + // Fall back to PowerShell if Cmder not found + console.warn('[Claude Code] Cmder not found, falling back to PowerShell'); + await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); + } + } else if (terminalId === 'hyper') { + // Hyper - Electron-based terminal + const hyperPaths = [ + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Hyper', 'Hyper.exe'), + path.join(process.env.USERPROFILE || '', 'AppData', 'Local', 'Programs', 'Hyper', 'Hyper.exe'), + ]; + const hyperPath = hyperPaths.find(p => existsSync(p)); + if (hyperPath) { + // Launch Hyper and it will pick up the shell; send command via PowerShell since Hyper + // doesn't have a built-in way to run commands on startup + await runWindowsCommand(`start "" "${hyperPath}"`); + console.log('[Claude Code] Hyper opened - command must be pasted manually'); + } else { + console.warn('[Claude Code] Hyper not found, falling back to PowerShell'); + await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); + } + } else if (terminalId === 'tabby') { + // Tabby (formerly Terminus) - modern terminal for Windows + const tabbyPaths = [ + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Tabby', 'Tabby.exe'), + path.join(process.env.USERPROFILE || '', 'AppData', 'Local', 'Programs', 'Tabby', 'Tabby.exe'), + ]; + const tabbyPath = tabbyPaths.find(p => existsSync(p)); + if (tabbyPath) { + // Tabby opens with default shell; similar to Hyper, no command line arg for running commands + await runWindowsCommand(`start "" "${tabbyPath}"`); + console.log('[Claude Code] Tabby opened - command must be pasted manually'); + } else { + console.warn('[Claude Code] Tabby not found, falling back to PowerShell'); + await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); + } + } else if (terminalId === 'cygwin') { + // Cygwin terminal + const cygwinPaths = [ + 'C:\\cygwin64\\bin\\mintty.exe', + 'C:\\cygwin\\bin\\mintty.exe', + ]; + const cygwinPath = cygwinPaths.find(p => existsSync(p)); + if (cygwinPath) { + // mintty with bash, escaping for bash context + const escapedBashCommand = escapeGitBashCommand(command); + await runWindowsCommand(`"${cygwinPath}" -e /bin/bash -lc "${escapedBashCommand}"`); + } else { + console.warn('[Claude Code] Cygwin not found, falling back to PowerShell'); + await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); + } + } else if (terminalId === 'msys2') { + // MSYS2 terminal + const msys2Paths = [ + 'C:\\msys64\\msys2_shell.cmd', + 'C:\\msys64\\mingw64.exe', + 'C:\\msys64\\usr\\bin\\mintty.exe', + ]; + const msys2Path = msys2Paths.find(p => existsSync(p)); + if (msys2Path) { + const escapedBashCommand = escapeGitBashCommand(command); + if (msys2Path.endsWith('.cmd')) { + // Use the shell launcher script + await runWindowsCommand(`"${msys2Path}" -mingw64 -c "${escapedBashCommand}"`); + } else { + // Use mintty directly + await runWindowsCommand(`"${msys2Path}" -e /bin/bash -lc "${escapedBashCommand}"`); + } + } else { + console.warn('[Claude Code] MSYS2 not found, falling back to PowerShell'); + await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); + } + } else { + // Default: PowerShell (handles 'powershell', 'system', or any unknown value) + // Use 'start' command to open a new PowerShell window + // The command is wrapped in double quotes and passed via -Command + await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); + } + } catch (err) { + console.error('[Claude Code] Terminal execution failed:', err); + throw new Error(`Failed to open terminal: ${err instanceof Error ? err.message : 'Unknown error'}`); } } else { // Linux: Use preferred terminal or try common emulators diff --git a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts index 7e098ecb..5b8c6d05 100644 --- a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts @@ -6,9 +6,10 @@ */ import { ipcMain, app } from 'electron'; -import { spawn } from 'child_process'; +import { spawn, execFileSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; +import * as os from 'os'; import { IPC_CHANNELS } from '../../shared/constants'; import type { IPCResult, @@ -25,6 +26,7 @@ import { import { validateOpenAIApiKey } from '../api-validation-service'; import { parsePythonCommand } from '../python-detector'; import { getConfiguredPythonPath } from '../python-env-manager'; +import { openTerminalWithCommand } from './claude-code-handlers'; /** * Ollama Service Status @@ -85,6 +87,148 @@ interface OllamaPullResult { output: string[]; // Log messages from pull operation } +/** + * Ollama Installation Status + * Information about whether Ollama is installed on the system + */ +interface OllamaInstallStatus { + installed: boolean; // Whether Ollama binary is found on the system + path?: string; // Path to Ollama binary (if found) + version?: string; // Installed version (if available) +} + +/** + * Check if Ollama is installed on the system by looking for the binary. + * Checks common installation paths and PATH environment variable. + * + * @returns {OllamaInstallStatus} Installation status with path if found + */ +function checkOllamaInstalled(): OllamaInstallStatus { + const platform = process.platform; + + // Common paths to check based on platform + const pathsToCheck: string[] = []; + + if (platform === 'win32') { + // Windows: Check common installation paths + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + pathsToCheck.push( + path.join(localAppData, 'Programs', 'Ollama', 'ollama.exe'), + path.join(localAppData, 'Ollama', 'ollama.exe'), + 'C:\\Program Files\\Ollama\\ollama.exe', + 'C:\\Program Files (x86)\\Ollama\\ollama.exe' + ); + } else if (platform === 'darwin') { + // macOS: Check common paths + pathsToCheck.push( + '/usr/local/bin/ollama', + '/opt/homebrew/bin/ollama', + path.join(os.homedir(), '.local', 'bin', 'ollama') + ); + } else { + // Linux: Check common paths + pathsToCheck.push( + '/usr/local/bin/ollama', + '/usr/bin/ollama', + path.join(os.homedir(), '.local', 'bin', 'ollama') + ); + } + + // Check each path + // SECURITY NOTE: ollamaPath values come from the hardcoded pathsToCheck array above, + // not from user input or environment variables. These are known system installation paths. + for (const ollamaPath of pathsToCheck) { + if (fs.existsSync(ollamaPath)) { + // Try to get version - use execFileSync to avoid shell injection + let version: string | undefined; + try { + const versionOutput = execFileSync(ollamaPath, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }).toString().trim(); + // Parse version from output like "ollama version 0.1.23" + const match = versionOutput.match(/(\d+\.\d+\.\d+)/); + if (match) { + version = match[1]; + } + } catch { + // Couldn't get version, but binary exists + } + + return { + installed: true, + path: ollamaPath, + version, + }; + } + } + + // Also check if ollama is in PATH using where/which command + // Use execFileSync with explicit command to avoid shell injection + try { + const whichCmd = platform === 'win32' ? 'where.exe' : 'which'; + const ollamaPath = execFileSync(whichCmd, ['ollama'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }).toString().trim().split('\n')[0]; // Get first result on Windows + + if (ollamaPath && fs.existsSync(ollamaPath)) { + let version: string | undefined; + try { + // Use the discovered path directly with execFileSync + const versionOutput = execFileSync(ollamaPath, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }).toString().trim(); + const match = versionOutput.match(/(\d+\.\d+\.\d+)/); + if (match) { + version = match[1]; + } + } catch { + // Couldn't get version + } + + return { + installed: true, + path: ollamaPath, + version, + }; + } + } catch { + // Not in PATH + } + + return { installed: false }; +} + +/** + * Get the platform-specific install command for Ollama + * Uses the official Ollama installation methods + * + * Windows: Uses winget (Windows Package Manager) + * - Official method per https://winstall.app/apps/Ollama.Ollama + * - Winget is pre-installed on Windows 10 (1709+) and Windows 11 + * + * macOS/Linux: Uses official install script from https://ollama.com/download + * + * @returns {string} The install command to run in terminal + */ +function getOllamaInstallCommand(): string { + if (process.platform === 'win32') { + // Windows: Use winget (Windows Package Manager) + // This is an official installation method for Ollama on Windows + // Reference: https://winstall.app/apps/Ollama.Ollama + return 'winget install --id Ollama.Ollama --accept-source-agreements'; + } else { + // macOS/Linux: Use shell script from official Ollama + // Reference: https://ollama.com/download + return 'curl -fsSL https://ollama.com/install.sh | sh'; + } +} + /** * Execute the ollama_model_detector.py Python script. * Spawns a subprocess to run Ollama detection/management commands with a 10-second timeout. @@ -427,7 +571,56 @@ export function registerMemoryHandlers(): void { }; } } - ); + ); + + // Check if Ollama is installed (binary exists on system) + ipcMain.handle( + IPC_CHANNELS.OLLAMA_CHECK_INSTALLED, + async (): Promise> => { + try { + const installStatus = checkOllamaInstalled(); + return { + success: true, + data: installStatus, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check Ollama installation', + }; + } + } + ); + + // Install Ollama (opens terminal with official install command) + ipcMain.handle( + IPC_CHANNELS.OLLAMA_INSTALL, + async (): Promise> => { + try { + const command = getOllamaInstallCommand(); + console.log('[Ollama] Platform:', process.platform); + console.log('[Ollama] Install command:', command); + console.log('[Ollama] Opening terminal...'); + + await openTerminalWithCommand(command); + console.log('[Ollama] Terminal opened successfully'); + + return { + success: true, + data: { command }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : ''; + console.error('[Ollama] Install failed:', errorMsg); + console.error('[Ollama] Error stack:', errorStack); + return { + success: false, + error: `Failed to open terminal for installation: ${errorMsg}`, + }; + } + } + ); // ============================================ // Ollama Model Discovery & Management diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index c4f78c2f..c1403b79 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -9,6 +9,12 @@ import { fileWatcher } from '../../file-watcher'; import { findTaskAndProject } from './shared'; import { checkGitStatus } from '../../project-initializer'; import { getClaudeProfileManager } from '../../claude-profile-manager'; +import { + getPlanPath, + persistPlanStatus, + persistPlanStatusSync, + createPlanIfNotExists +} from './plan-file-utils'; /** * Helper function to check subtask completion status @@ -169,6 +175,18 @@ export function registerTaskExecutionHandlers( ); } + // CRITICAL: Persist status to implementation_plan.json to prevent status flip-flop + // When getTasks() is called (on refresh), it reads status from the plan file. + // Without persisting here, the old status (e.g., 'human_review') would override + // the in-memory 'in_progress' status, causing the task to flip back and forth. + // Uses shared utility for consistency with agent-events-handlers.ts + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + const persisted = persistPlanStatusSync(planPath, 'in_progress'); + if (persisted) { + console.warn('[TASK_START] Updated plan status to: in_progress'); + } + // Note: Plan file may not exist yet for new tasks - that's fine (persistPlanStatusSync handles ENOENT) + // Notify status change mainWindow.webContents.send( IPC_CHANNELS.TASK_STATUS_CHANGE, @@ -185,6 +203,20 @@ export function registerTaskExecutionHandlers( agentManager.killTask(taskId); fileWatcher.unwatch(taskId); + // Find task and project to update the plan file + const { task, project } = findTaskAndProject(taskId); + + if (task && project) { + // Persist status to implementation_plan.json to prevent status flip-flop on refresh + // Uses shared utility for consistency with agent-events-handlers.ts + const planPath = getPlanPath(project, task); + const persisted = persistPlanStatusSync(planPath, 'backlog'); + if (persisted) { + console.warn('[TASK_STOP] Updated plan status to backlog'); + } + // Note: File not found is expected for tasks without a plan file (persistPlanStatusSync handles ENOENT) + } + const mainWindow = getMainWindow(); if (mainWindow) { mainWindow.webContents.send( @@ -381,55 +413,18 @@ export function registerTaskExecutionHandlers( } } - // Get the spec directory + // Get the spec directory and plan path using shared utility const specsBaseDir = getSpecsDir(project.autoBuildPath); - const specDir = path.join( - project.path, - specsBaseDir, - task.specId - ); - - // Update implementation_plan.json if it exists - const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + const specDir = path.join(project.path, specsBaseDir, task.specId); + const planPath = getPlanPath(project, task); try { - if (existsSync(planPath)) { - const planContent = readFileSync(planPath, 'utf-8'); - const plan = JSON.parse(planContent); + // Use shared utility for thread-safe plan file updates + const persisted = await persistPlanStatus(planPath, status); - // Store the exact UI status - project-store.ts will map it back - plan.status = status; - // Also store mapped version for Python compatibility - plan.planStatus = status === 'in_progress' ? 'in_progress' - : status === 'ai_review' ? 'review' - : status === 'human_review' ? 'review' - : status === 'done' ? 'completed' - : 'pending'; - plan.updated_at = new Date().toISOString(); - - writeFileSync(planPath, JSON.stringify(plan, null, 2)); - } else { + if (!persisted) { // If no implementation plan exists yet, create a basic one - const plan = { - feature: task.title, - description: task.description || '', - created_at: task.createdAt.toISOString(), - updated_at: new Date().toISOString(), - status: status, // Store exact UI status for persistence - planStatus: status === 'in_progress' ? 'in_progress' - : status === 'ai_review' ? 'review' - : status === 'human_review' ? 'review' - : status === 'done' ? 'completed' - : 'pending', - phases: [] - }; - - // Ensure spec directory exists - if (!existsSync(specDir)) { - mkdirSync(specDir, { recursive: true }); - } - - writeFileSync(planPath, JSON.stringify(plan, null, 2)); + await createPlanIfNotExists(planPath, task, status); } // Auto-stop task when status changes AWAY from 'in_progress' and process IS running diff --git a/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts b/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts new file mode 100644 index 00000000..6d810f3a --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts @@ -0,0 +1,249 @@ +/** + * Plan File Utilities + * + * Provides thread-safe operations for reading and writing implementation_plan.json files. + * Uses an in-memory lock to serialize updates and prevent race conditions when multiple + * IPC handlers try to update the same plan file concurrently. + * + * IMPORTANT LIMITATION: + * The synchronous function `persistPlanStatusSync` does NOT participate in the locking + * mechanism. It bypasses the async lock entirely, which means: + * - It can race with concurrent async operations (persistPlanStatus, updatePlanFile, etc.) + * - It should ONLY be used when you are certain no async operations are pending on the same file + * - Prefer using the async `persistPlanStatus` whenever possible + * + * If you need synchronous behavior, ensure that: + * 1. No async plan operations are in flight for the same file path + * 2. The calling context truly cannot use async/await (e.g., synchronous event handlers) + */ + +import path from 'path'; +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants'; +import type { TaskStatus, Project, Task } from '../../../shared/types'; + +// In-memory locks for plan file operations +// Key: plan file path, Value: Promise chain for serializing operations +const planLocks = new Map>(); + +/** + * Serialize operations on a specific plan file to prevent race conditions. + * Each operation waits for the previous one to complete before starting. + */ +async function withPlanLock(planPath: string, operation: () => Promise): Promise { + // Get or create the lock chain for this file + const currentLock = planLocks.get(planPath) || Promise.resolve(); + + // Create a new promise that will resolve after our operation completes + let resolve: () => void; + const newLock = new Promise((r) => { resolve = r; }); + planLocks.set(planPath, newLock); + + try { + // Wait for any previous operation to complete + await currentLock; + // Execute our operation + return await operation(); + } finally { + // Release the lock + resolve!(); + // Clean up if this was the last operation + if (planLocks.get(planPath) === newLock) { + planLocks.delete(planPath); + } + } +} + +/** + * Check if an error is a "file not found" error + */ +function isFileNotFoundError(err: unknown): boolean { + return (err as NodeJS.ErrnoException).code === 'ENOENT'; +} + +/** + * Get the plan file path for a task + */ +export function getPlanPath(project: Project, task: Task): string { + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsBaseDir, task.specId); + return path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); +} + +/** + * Map UI TaskStatus to Python-compatible planStatus + */ +export function mapStatusToPlanStatus(status: TaskStatus): string { + switch (status) { + case 'in_progress': + return 'in_progress'; + case 'ai_review': + case 'human_review': + return 'review'; + case 'done': + return 'completed'; + default: + return 'pending'; + } +} + +/** + * Persist task status to implementation_plan.json file. + * This is thread-safe and prevents race conditions when multiple handlers update the same file. + * + * @param planPath - Path to the implementation_plan.json file + * @param status - The TaskStatus to persist + * @returns true if status was persisted, false if plan file doesn't exist + */ +export async function persistPlanStatus(planPath: string, status: TaskStatus): Promise { + return withPlanLock(planPath, async () => { + try { + // Read file directly without existence check to avoid TOCTOU race condition + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + plan.status = status; + plan.planStatus = mapStatusToPlanStatus(status); + plan.updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + return true; + } catch (err) { + // File not found is expected - return false + if (isFileNotFoundError(err)) { + return false; + } + console.warn(`[plan-file-utils] Could not persist status to ${planPath}:`, err); + return false; + } + }); +} + +/** + * Persist task status synchronously (for use in event handlers where async isn't practical). + * + * WARNING: This function bypasses the async locking mechanism entirely! + * + * This means it can race with concurrent async operations (persistPlanStatus, updatePlanFile, + * createPlanIfNotExists) that may be in flight for the same file. Using this function while + * async operations are pending can result in: + * - Lost updates (this write may overwrite changes from an async operation, or vice versa) + * - Corrupted JSON (if writes interleave at the filesystem level) + * - Inconsistent state between what was written and what the async operation expected to read + * + * ONLY use this function when ALL of the following conditions are met: + * 1. You are in a synchronous context that cannot use async/await (e.g., certain event handlers) + * 2. You are certain no async plan operations are pending or in-flight for this file path + * 3. No other code will initiate async plan operations until this function returns + * + * When possible, prefer using the async `persistPlanStatus` function instead, which properly + * participates in the locking mechanism and prevents race conditions. + * + * @param planPath - Path to the implementation_plan.json file + * @param status - The TaskStatus to persist + * @returns true if status was persisted, false otherwise + */ +export function persistPlanStatusSync(planPath: string, status: TaskStatus): boolean { + try { + // Read file directly without existence check to avoid TOCTOU race condition + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + plan.status = status; + plan.planStatus = mapStatusToPlanStatus(status); + plan.updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + return true; + } catch (err) { + // File not found is expected - return false + if (isFileNotFoundError(err)) { + return false; + } + console.warn(`[plan-file-utils] Could not persist status to ${planPath}:`, err); + return false; + } +} + +/** + * Read and update the plan file atomically. + * + * @param planPath - Path to the implementation_plan.json file + * @param updater - Function that receives the current plan and returns the updated plan + * @returns The updated plan, or null if the file doesn't exist + */ +export async function updatePlanFile>( + planPath: string, + updater: (plan: T) => T +): Promise { + return withPlanLock(planPath, async () => { + try { + // Read file directly without existence check to avoid TOCTOU race condition + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent) as T; + + const updatedPlan = updater(plan); + // Add updated_at timestamp - use type assertion since T extends Record + (updatedPlan as Record).updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(updatedPlan, null, 2)); + return updatedPlan; + } catch (err) { + // File not found is expected - return null + if (isFileNotFoundError(err)) { + return null; + } + console.warn(`[plan-file-utils] Could not update plan at ${planPath}:`, err); + return null; + } + }); +} + +/** + * Create a new plan file if it doesn't exist. + * + * @param planPath - Path to the implementation_plan.json file + * @param task - The task to create the plan for + * @param status - Initial status for the plan + */ +export async function createPlanIfNotExists( + planPath: string, + task: Task, + status: TaskStatus +): Promise { + return withPlanLock(planPath, async () => { + // Try to read the file first - if it exists, do nothing + try { + readFileSync(planPath, 'utf-8'); + return; // File exists, nothing to do + } catch (err) { + if (!isFileNotFoundError(err)) { + throw err; // Re-throw unexpected errors + } + // File doesn't exist, continue to create it + } + + const plan = { + feature: task.title, + description: task.description || '', + created_at: task.createdAt.toISOString(), + updated_at: new Date().toISOString(), + status: status, + planStatus: mapStatusToPlanStatus(status), + phases: [] + }; + + // Ensure directory exists - use try/catch pattern + const planDir = path.dirname(planPath); + try { + mkdirSync(planDir, { recursive: true }); + } catch (err) { + // Directory might already exist or be created concurrently - that's fine + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw err; + } + } + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + }); +} diff --git a/apps/frontend/src/preload/api/project-api.ts b/apps/frontend/src/preload/api/project-api.ts index 3ad83a00..3852c9e4 100644 --- a/apps/frontend/src/preload/api/project-api.ts +++ b/apps/frontend/src/preload/api/project-api.ts @@ -105,6 +105,12 @@ export interface ProjectAPI { version?: string; message?: string; }>>; + checkOllamaInstalled: () => Promise>; + installOllama: () => Promise>; listOllamaModels: (baseUrl?: string) => Promise ({ checkOllamaStatus: (baseUrl?: string) => ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_CHECK_STATUS, baseUrl), + checkOllamaInstalled: () => + ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_CHECK_INSTALLED), + + installOllama: () => + ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_INSTALL), + listOllamaModels: (baseUrl?: string) => ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_LIST_MODELS, baseUrl), diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 1c186455..8ddeb48a 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -49,7 +49,7 @@ import { OnboardingWizard } from './components/onboarding'; import { AppUpdateNotification } from './components/AppUpdateNotification'; import { ProactiveSwapListener } from './components/ProactiveSwapListener'; import { GitHubSetupModal } from './components/GitHubSetupModal'; -import { useProjectStore, loadProjects, addProject, initializeProject } from './stores/project-store'; +import { useProjectStore, loadProjects, addProject, initializeProject, removeProject } from './stores/project-store'; import { useTaskStore, loadTasks } from './stores/task-store'; import { useSettingsStore, loadSettings } from './stores/settings-store'; import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store'; @@ -113,7 +113,6 @@ export function App() { const getProjectTabs = useProjectStore((state) => state.getProjectTabs); const openProjectIds = useProjectStore((state) => state.openProjectIds); const openProjectTab = useProjectStore((state) => state.openProjectTab); - const closeProjectTab = useProjectStore((state) => state.closeProjectTab); const setActiveProject = useProjectStore((state) => state.setActiveProject); const reorderTabs = useProjectStore((state) => state.reorderTabs); const tasks = useTaskStore((state) => state.tasks); @@ -142,6 +141,11 @@ export function App() { const [showGitHubSetup, setShowGitHubSetup] = useState(false); const [gitHubSetupProject, setGitHubSetupProject] = useState(null); + // Remove project confirmation state + const [showRemoveProjectDialog, setShowRemoveProjectDialog] = useState(false); + const [removeProjectError, setRemoveProjectError] = useState(null); + const [projectToRemove, setProjectToRemove] = useState(null); + // Setup drag sensors const sensors = useSensors( useSensor(PointerSensor, { @@ -483,7 +487,39 @@ export function App() { }; const handleProjectTabClose = (projectId: string) => { - closeProjectTab(projectId); + // Show confirmation dialog before removing the project + const project = projects.find(p => p.id === projectId); + if (project) { + setProjectToRemove(project); + setShowRemoveProjectDialog(true); + } + }; + + const handleConfirmRemoveProject = () => { + if (projectToRemove) { + try { + // Clear any previous error + setRemoveProjectError(null); + // Remove the project from the app (files are preserved on disk for re-adding later) + removeProject(projectToRemove.id); + // Only clear dialog state on success + setShowRemoveProjectDialog(false); + setProjectToRemove(null); + } catch (err) { + // Log error and keep dialog open so user can retry or cancel + console.error('[App] Failed to remove project:', err); + // Show error in dialog + setRemoveProjectError( + err instanceof Error ? err.message : t('common:errors.unknownError') + ); + } + } + }; + + const handleCancelRemoveProject = () => { + setShowRemoveProjectDialog(false); + setProjectToRemove(null); + setRemoveProjectError(null); }; // Handle drag start - set the active dragged project @@ -894,6 +930,34 @@ export function App() { /> )} + {/* Remove Project Confirmation Dialog */} + { + if (!open) handleCancelRemoveProject(); + }}> + + + {t('removeProject.title')} + + {t('removeProject.description', { projectName: projectToRemove?.name || '' })} + + + {removeProjectError && ( +
+ + {removeProjectError} +
+ )} + + + + +
+
+ {/* Rate Limit Modal - shows when Claude Code hits usage limits (terminal) */} diff --git a/apps/frontend/src/renderer/components/onboarding/OllamaModelSelector.tsx b/apps/frontend/src/renderer/components/onboarding/OllamaModelSelector.tsx index 6dd752ec..97257ec9 100644 --- a/apps/frontend/src/renderer/components/onboarding/OllamaModelSelector.tsx +++ b/apps/frontend/src/renderer/components/onboarding/OllamaModelSelector.tsx @@ -1,15 +1,19 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { Check, Download, Loader2, AlertCircle, - RefreshCw + RefreshCw, + ExternalLink } from 'lucide-react'; import { Button } from '../ui/button'; import { cn } from '../../lib/utils'; import { useDownloadStore } from '../../stores/download-store'; +type OllamaState = 'checking' | 'not-installed' | 'not-running' | 'available'; + interface OllamaModel { name: string; description: string; @@ -104,10 +108,16 @@ export function OllamaModelSelector({ disabled = false, className, }: OllamaModelSelectorProps) { + const { t } = useTranslation('onboarding'); const [models, setModels] = useState(RECOMMENDED_MODELS); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [ollamaAvailable, setOllamaAvailable] = useState(true); + const [ollamaState, setOllamaState] = useState('checking'); + const [isInstalling, setIsInstalling] = useState(false); + const [installSuccess, setInstallSuccess] = useState(false); + + // Track timeout for cleanup on unmount + const installCheckTimeoutRef = useRef | null>(null); // Use global download store for tracking downloads const downloads = useDownloadStore((state) => state.downloads); @@ -116,8 +126,8 @@ export function OllamaModelSelector({ const failDownload = useDownloadStore((state) => state.failDownload); /** - * Checks Ollama service status and fetches list of installed embedding models. - * Updates component state with installation status for each recommended model. + * Checks if Ollama is installed, running, and fetches installed models. + * Updates component state based on Ollama availability. * * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the request * @returns {Promise} @@ -125,19 +135,30 @@ export function OllamaModelSelector({ const checkInstalledModels = async (abortSignal?: AbortSignal) => { setIsLoading(true); setError(null); + setOllamaState('checking'); try { - // Check Ollama status first - const statusResult = await window.electronAPI.checkOllamaStatus(); + // First check if Ollama is installed (binary exists) + const installResult = await window.electronAPI.checkOllamaInstalled(); if (abortSignal?.aborted) return; - if (!statusResult?.success || !statusResult?.data?.running) { - setOllamaAvailable(false); + if (!installResult?.success || !installResult?.data?.installed) { + setOllamaState('not-installed'); setIsLoading(false); return; } - setOllamaAvailable(true); + // Ollama is installed, now check if it's running + const statusResult = await window.electronAPI.checkOllamaStatus(); + if (abortSignal?.aborted) return; + + if (!statusResult?.success || !statusResult?.data?.running) { + setOllamaState('not-running'); + setIsLoading(false); + return; + } + + setOllamaState('available'); // Get list of installed embedding models const result = await window.electronAPI.listOllamaEmbeddingModels(); @@ -186,11 +207,46 @@ export function OllamaModelSelector({ } }; + /** + * Install Ollama by opening terminal with the official install command. + */ + const handleInstallOllama = async () => { + setIsInstalling(true); + setError(null); + + try { + const result = await window.electronAPI.installOllama(); + if (result?.success) { + setInstallSuccess(true); + // Clear any existing timeout before setting a new one + if (installCheckTimeoutRef.current) { + clearTimeout(installCheckTimeoutRef.current); + } + // Re-check after a delay to give user time to complete installation + installCheckTimeoutRef.current = setTimeout(() => { + checkInstalledModels(); + }, 5000); + } else { + setError(result?.error || 'Failed to start Ollama installation'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to install Ollama'); + } finally { + setIsInstalling(false); + } + }; + // Fetch installed models on mount with cleanup useEffect(() => { const controller = new AbortController(); checkInstalledModels(controller.signal); - return () => controller.abort(); + return () => { + controller.abort(); + // Clean up the install check timeout to prevent setState on unmounted component + if (installCheckTimeoutRef.current) { + clearTimeout(installCheckTimeoutRef.current); + } + }; }, []); // Progress is now handled globally by the download store listener initialized in App.tsx @@ -245,15 +301,99 @@ export function OllamaModelSelector({ ); } - if (!ollamaAvailable) { + // Ollama not installed - show install option + if (ollamaState === 'not-installed') { + return ( +
+
+ +
+

+ {t('ollama.notInstalled.title')} +

+

+ {t('ollama.notInstalled.description')} +

+ + {/* Install success message */} + {installSuccess && ( +
+

+ {t('ollama.notInstalled.installSuccess')} +

+
+ )} + + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + +
+ + {/* Note: isLoading is always false when this block renders because we only show + this block after setIsLoading(false) is called. However, clicking Retry calls + checkInstalledModels() which immediately sets isLoading=true, triggering a + re-render that shows the loading block instead. This React batching behavior + naturally prevents double-clicks without needing the disabled prop. */} + + +
+ +

+ {t('ollama.notInstalled.fallbackNote')} +

+
+
+
+ ); + } + + // Ollama installed but not running + if (ollamaState === 'not-running') { return (
-

Ollama not running

+

+ {t('ollama.notRunning.title')} +

- Start Ollama to use local embedding models. Memory will still work with keyword search. + {t('ollama.notRunning.description')}

+

+ {t('ollama.notRunning.fallbackNote')} +

diff --git a/apps/frontend/src/renderer/components/project-settings/EnvironmentSettings.tsx b/apps/frontend/src/renderer/components/project-settings/EnvironmentSettings.tsx deleted file mode 100644 index eeb86a37..00000000 --- a/apps/frontend/src/renderer/components/project-settings/EnvironmentSettings.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Key, - ExternalLink, - ChevronDown, - ChevronUp, - Loader2, - Globe, - Check, - Star, - Settings, - Users -} from 'lucide-react'; -import { Button } from '../ui/button'; -import { Label } from '../ui/label'; -import { cn } from '../../lib/utils'; -import type { ProjectEnvConfig, ClaudeProfile } from '../../../shared/types'; - -interface EnvironmentSettingsProps { - envConfig: ProjectEnvConfig | null; - isLoadingEnv: boolean; - envError: string | null; - updateEnvConfig: (updates: Partial) => void; - - // Claude auth state - isCheckingClaudeAuth: boolean; - claudeAuthStatus: 'checking' | 'authenticated' | 'not_authenticated' | 'error'; - handleClaudeSetup: () => Promise; - - // Password visibility (kept for interface compatibility but not used) - showClaudeToken: boolean; - setShowClaudeToken: React.Dispatch>; - - // Collapsible section - expanded: boolean; - onToggle: () => void; -} - -export function EnvironmentSettings({ - envConfig, - isLoadingEnv, - envError, - isCheckingClaudeAuth, - claudeAuthStatus, - handleClaudeSetup, - expanded, - onToggle -}: EnvironmentSettingsProps) { - // Load global Claude profiles to show active account - const [claudeProfiles, setClaudeProfiles] = useState([]); - const [activeProfileId, setActiveProfileId] = useState(null); - const [isLoadingProfiles, setIsLoadingProfiles] = useState(false); - - useEffect(() => { - const loadProfiles = async () => { - setIsLoadingProfiles(true); - try { - const result = await window.electronAPI.getClaudeProfiles(); - if (result.success && result.data) { - setClaudeProfiles(result.data.profiles); - setActiveProfileId(result.data.activeProfileId); - } - } catch (err) { - console.error('Failed to load Claude profiles:', err); - } finally { - setIsLoadingProfiles(false); - } - }; - loadProfiles(); - }, []); - - const activeProfile = claudeProfiles.find(p => p.id === activeProfileId); - const hasAuthenticatedProfiles = claudeProfiles.some(p => p.oauthToken); - - return ( -
- - - {expanded && ( -
- {isLoadingEnv || isLoadingProfiles ? ( -
- - Loading configuration... -
- ) : envConfig ? ( - <> - {/* Inheritance Info */} -
-
- -
-

- Using Global Authentication -

-

- Claude authentication is managed in{' '} - Settings → Integrations. - All projects share the same Claude accounts. -

-
-
-
- - {/* Active Account Display */} - {hasAuthenticatedProfiles ? ( -
-
-
-
- - -
-
- -
- - {activeProfile ? ( -
-
- {activeProfile.name.charAt(0).toUpperCase()} -
-
-
- {activeProfile.name} - - - Active - - {(activeProfile.oauthToken || (activeProfile.isDefault && activeProfile.configDir)) ? ( - - - Authenticated - - ) : ( - - Needs Auth - - )} -
- {activeProfile.email && ( - {activeProfile.email} - )} -
-
- ) : claudeProfiles.length > 0 ? ( -

- No active account selected. Go to Settings → Integrations to select an account. -

- ) : null} - - {/* Show other authenticated accounts */} - {claudeProfiles.filter(p => p.id !== activeProfileId && p.oauthToken).length > 0 && ( -
-

- Other authenticated accounts (used for rate limit fallback): -

-
- {claudeProfiles - .filter(p => p.id !== activeProfileId && p.oauthToken) - .map(profile => ( -
-
- {profile.name.charAt(0).toUpperCase()} -
- {profile.name} -
- )) - } -
-
- )} -
- ) : ( - /* No accounts configured */ -
-
- -

No Claude Accounts Configured

-

- Add a Claude account in the global settings to use Auto-Build. -

- -
-
- )} - - ) : envError ? ( -

{envError}

- ) : null} -
- )} -
- ); -} diff --git a/apps/frontend/src/renderer/components/project-settings/index.ts b/apps/frontend/src/renderer/components/project-settings/index.ts index 4880d35d..2bc87916 100644 --- a/apps/frontend/src/renderer/components/project-settings/index.ts +++ b/apps/frontend/src/renderer/components/project-settings/index.ts @@ -1,6 +1,5 @@ // Note: ProjectSettings component is deprecated - use unified AppSettings instead export { GeneralSettings } from './GeneralSettings'; -export { EnvironmentSettings } from './EnvironmentSettings'; export { IntegrationSettings } from './IntegrationSettings'; export { SecuritySettings } from './SecuritySettings'; export { useProjectSettings } from './hooks/useProjectSettings'; diff --git a/apps/frontend/src/renderer/components/settings/AppSettings.tsx b/apps/frontend/src/renderer/components/settings/AppSettings.tsx index 6bb1efab..ba2d2eb4 100644 --- a/apps/frontend/src/renderer/components/settings/AppSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/AppSettings.tsx @@ -87,7 +87,6 @@ const appNavItemsConfig: NavItemConfig[] = [ const projectNavItemsConfig: NavItemConfig[] = [ { id: 'general', icon: Settings2 }, - { id: 'claude', icon: Key }, { id: 'linear', icon: Zap }, { id: 'github', icon: Github }, { id: 'gitlab', icon: GitLabIcon }, diff --git a/apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx b/apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx index b46c3e85..47f4e101 100644 --- a/apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx +++ b/apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { LinearTaskImportModal } from '../LinearTaskImportModal'; import { SettingsSection } from './SettingsSection'; import { useProjectSettings, UseProjectSettingsReturn } from '../project-settings/hooks/useProjectSettings'; @@ -9,7 +10,7 @@ import { SectionRouter } from './sections/SectionRouter'; import { createHookProxy } from './utils/hookProxyFactory'; import type { Project } from '../../../shared/types'; -export type ProjectSettingsSection = 'general' | 'claude' | 'linear' | 'github' | 'gitlab' | 'memory'; +export type ProjectSettingsSection = 'general' | 'linear' | 'github' | 'gitlab' | 'memory'; interface ProjectSettingsContentProps { project: Project | undefined; @@ -28,12 +29,14 @@ export function ProjectSettingsContent({ isOpen, onHookReady }: ProjectSettingsContentProps) { + const { t } = useTranslation('settings'); + // Show empty state if no project selected if (!project) { return ( @@ -81,8 +84,6 @@ function ProjectSettingsContentInner({ isLoadingEnv, envError, updateEnvConfig, - showClaudeToken, - setShowClaudeToken, showLinearKey, setShowLinearKey, showOpenAIKey, @@ -97,14 +98,11 @@ function ProjectSettingsContentInner({ setShowGitLabToken, gitLabConnectionStatus, isCheckingGitLab, - isCheckingClaudeAuth, - claudeAuthStatus, showLinearImportModal, setShowLinearImportModal, linearConnectionStatus, isCheckingLinear, handleInitialize, - handleClaudeSetup, error } = hook; @@ -134,8 +132,6 @@ function ProjectSettingsContentInner({ isLoadingEnv={isLoadingEnv} envError={envError} updateEnvConfig={updateEnvConfig} - showClaudeToken={showClaudeToken} - setShowClaudeToken={setShowClaudeToken} showLinearKey={showLinearKey} setShowLinearKey={setShowLinearKey} showOpenAIKey={showOpenAIKey} @@ -148,12 +144,9 @@ function ProjectSettingsContentInner({ setShowGitLabToken={setShowGitLabToken} gitLabConnectionStatus={gitLabConnectionStatus} isCheckingGitLab={isCheckingGitLab} - isCheckingClaudeAuth={isCheckingClaudeAuth} - claudeAuthStatus={claudeAuthStatus} linearConnectionStatus={linearConnectionStatus} isCheckingLinear={isCheckingLinear} handleInitialize={handleInitialize} - handleClaudeSetup={handleClaudeSetup} onOpenLinearImport={() => setShowLinearImportModal(true)} /> diff --git a/apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx b/apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx index 663f6d27..ec171deb 100644 --- a/apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx +++ b/apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx @@ -2,7 +2,6 @@ import { useTranslation } from 'react-i18next'; import type { Project, ProjectSettings as ProjectSettingsType, AutoBuildVersionInfo, ProjectEnvConfig, LinearSyncStatus, GitHubSyncStatus, GitLabSyncStatus } from '../../../../shared/types'; import { SettingsSection } from '../SettingsSection'; import { GeneralSettings } from '../../project-settings/GeneralSettings'; -import { EnvironmentSettings } from '../../project-settings/EnvironmentSettings'; import { SecuritySettings } from '../../project-settings/SecuritySettings'; import { LinearIntegration } from '../integrations/LinearIntegration'; import { GitHubIntegration } from '../integrations/GitHubIntegration'; @@ -22,8 +21,6 @@ interface SectionRouterProps { isLoadingEnv: boolean; envError: string | null; updateEnvConfig: (updates: Partial) => void; - showClaudeToken: boolean; - setShowClaudeToken: React.Dispatch>; showLinearKey: boolean; setShowLinearKey: React.Dispatch>; showOpenAIKey: boolean; @@ -36,12 +33,9 @@ interface SectionRouterProps { setShowGitLabToken: React.Dispatch>; gitLabConnectionStatus: GitLabSyncStatus | null; isCheckingGitLab: boolean; - isCheckingClaudeAuth: boolean; - claudeAuthStatus: 'checking' | 'authenticated' | 'not_authenticated' | 'error'; linearConnectionStatus: LinearSyncStatus | null; isCheckingLinear: boolean; handleInitialize: () => Promise; - handleClaudeSetup: () => Promise; onOpenLinearImport: () => void; } @@ -61,8 +55,6 @@ export function SectionRouter({ isLoadingEnv, envError, updateEnvConfig, - showClaudeToken, - setShowClaudeToken, showLinearKey, setShowLinearKey, showOpenAIKey, @@ -75,12 +67,9 @@ export function SectionRouter({ setShowGitLabToken, gitLabConnectionStatus, isCheckingGitLab, - isCheckingClaudeAuth, - claudeAuthStatus, linearConnectionStatus, isCheckingLinear, handleInitialize, - handleClaudeSetup, onOpenLinearImport }: SectionRouterProps) { const { t } = useTranslation('settings'); @@ -104,34 +93,6 @@ export function SectionRouter({ ); - case 'claude': - return ( - - - {}} - /> - - - ); - case 'linear': return ( ({ + success: true, + data: { + installed: true, + path: '/usr/local/bin/ollama', + version: '0.1.0', + } + }), + + installOllama: async () => ({ + success: true, + data: { + command: 'curl -fsSL https://ollama.com/install.sh | sh', + } + }), + listOllamaModels: async () => ({ success: true, data: { diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index b5f22d7a..5169f934 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -377,6 +377,8 @@ export const IPC_CHANNELS = { // Ollama model detection and management OLLAMA_CHECK_STATUS: 'ollama:checkStatus', + OLLAMA_CHECK_INSTALLED: 'ollama:checkInstalled', + OLLAMA_INSTALL: 'ollama:install', OLLAMA_LIST_MODELS: 'ollama:listModels', OLLAMA_LIST_EMBEDDING_MODELS: 'ollama:listEmbeddingModels', OLLAMA_PULL_MODEL: 'ollama:pullModel', diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index 48d5d7e2..c5e9be9e 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -50,6 +50,7 @@ }, "errors": { "generic": "An error occurred", + "unknownError": "An unknown error occurred", "networkError": "Network error", "notFound": "Not found", "unauthorized": "Unauthorized" diff --git a/apps/frontend/src/shared/i18n/locales/en/dialogs.json b/apps/frontend/src/shared/i18n/locales/en/dialogs.json index cd6e1247..161d628b 100644 --- a/apps/frontend/src/shared/i18n/locales/en/dialogs.json +++ b/apps/frontend/src/shared/i18n/locales/en/dialogs.json @@ -118,5 +118,12 @@ "thinkingLevel": "Thinking Level", "cancel": "Cancel", "apply": "Apply" + }, + "removeProject": { + "title": "Remove Project?", + "description": "This will remove \"{{projectName}}\" from the app. Your files will be preserved on disk and you can re-add the project later.", + "cancel": "Cancel", + "remove": "Remove", + "error": "Failed to remove project" } } diff --git a/apps/frontend/src/shared/i18n/locales/en/onboarding.json b/apps/frontend/src/shared/i18n/locales/en/onboarding.json index 34c4bca8..38525755 100644 --- a/apps/frontend/src/shared/i18n/locales/en/onboarding.json +++ b/apps/frontend/src/shared/i18n/locales/en/onboarding.json @@ -116,5 +116,23 @@ "noToolsDetected": "No additional tools detected (VS Code and system terminal will be used)", "custom": "Custom...", "saveAndContinue": "Save & Continue" + }, + "ollama": { + "notInstalled": { + "title": "Ollama not installed", + "description": "Ollama provides free, local embedding models for semantic search. Install it with one click to enable this feature.", + "installSuccess": "Installation started in your terminal. Complete the installation there, then click Retry.", + "installButton": "Install Ollama", + "installing": "Installing...", + "retry": "Retry", + "learnMore": "Learn more", + "fallbackNote": "Memory will still work with keyword search even without Ollama." + }, + "notRunning": { + "title": "Ollama not running", + "description": "Ollama is installed but not running. Start Ollama to use local embedding models.", + "retry": "Retry", + "fallbackNote": "Memory will still work with keyword search even without embeddings." + } } } diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index f9196432..a39a135e 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -319,6 +319,12 @@ "helpTitle": "Reporting Issues", "helpText": "When reporting bugs, click \"Copy Debug Info\" to get system information and recent errors that help us diagnose the issue." }, + "projectSettings": { + "noProjectSelected": { + "title": "No Project Selected", + "description": "Select a project from the sidebar to configure its settings." + } + }, "mcp": { "title": "MCP Server Overview", "titleWithProject": "MCP Server Overview for {{projectName}}", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index fd0a06c6..4c261afe 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -50,6 +50,7 @@ }, "errors": { "generic": "Une erreur s'est produite", + "unknownError": "Une erreur inconnue s'est produite", "networkError": "Erreur réseau", "notFound": "Non trouvé", "unauthorized": "Non autorisé" diff --git a/apps/frontend/src/shared/i18n/locales/fr/dialogs.json b/apps/frontend/src/shared/i18n/locales/fr/dialogs.json index 191ded86..e51972c6 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/dialogs.json +++ b/apps/frontend/src/shared/i18n/locales/fr/dialogs.json @@ -118,5 +118,12 @@ "thinkingLevel": "Niveau de réflexion", "cancel": "Annuler", "apply": "Appliquer" + }, + "removeProject": { + "title": "Retirer le projet ?", + "description": "Ceci va retirer \"{{projectName}}\" de l'application. Vos fichiers seront préservés sur le disque et vous pourrez ré-ajouter le projet plus tard.", + "cancel": "Annuler", + "remove": "Retirer", + "error": "Échec de la suppression du projet" } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/onboarding.json b/apps/frontend/src/shared/i18n/locales/fr/onboarding.json index fbb38af8..1a05ac04 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/onboarding.json +++ b/apps/frontend/src/shared/i18n/locales/fr/onboarding.json @@ -116,5 +116,23 @@ "noToolsDetected": "Aucun outil supplémentaire détecté (VS Code et le terminal système seront utilisés)", "custom": "Personnalisé...", "saveAndContinue": "Enregistrer et continuer" + }, + "ollama": { + "notInstalled": { + "title": "Ollama non installé", + "description": "Ollama fournit des modèles d'embeddings locaux gratuits pour la recherche sémantique. Installez-le en un clic pour activer cette fonctionnalité.", + "installSuccess": "Installation lancée dans votre terminal. Terminez l'installation là-bas, puis cliquez sur Réessayer.", + "installButton": "Installer Ollama", + "installing": "Installation...", + "retry": "Réessayer", + "learnMore": "En savoir plus", + "fallbackNote": "La mémoire fonctionnera toujours avec la recherche par mots-clés même sans Ollama." + }, + "notRunning": { + "title": "Ollama non démarré", + "description": "Ollama est installé mais non démarré. Lancez Ollama pour utiliser les modèles d'embeddings locaux.", + "retry": "Réessayer", + "fallbackNote": "La mémoire fonctionnera toujours avec la recherche par mots-clés même sans embeddings." + } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index 5adab02d..4e373971 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -319,6 +319,12 @@ "helpTitle": "Signaler des problèmes", "helpText": "Lors du signalement de bugs, cliquez sur \"Copier les infos de débogage\" pour obtenir les informations système et les erreurs récentes qui nous aident à diagnostiquer le problème." }, + "projectSettings": { + "noProjectSelected": { + "title": "Aucun projet sélectionné", + "description": "Sélectionnez un projet dans la barre latérale pour configurer ses paramètres." + } + }, "mcp": { "title": "Aperçu des serveurs MCP", "titleWithProject": "Aperçu des serveurs MCP pour {{projectName}}", diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 0e6925d4..ccbee86f 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -680,6 +680,12 @@ export interface ElectronAPI { version?: string; message?: string; }>>; + checkOllamaInstalled: () => Promise>; + installOllama: () => Promise>; listOllamaModels: (baseUrl?: string) => Promise' && " string, or empty string if path is undefined @@ -48,6 +49,15 @@ export function buildCdCommand(path: string | undefined): string { if (!path) { return ''; } + + // Windows cmd.exe uses double quotes, Unix shells use single quotes + if (process.platform === 'win32') { + // On Windows, escape cmd.exe metacharacters (& | < > ^) that could enable command injection, + // then wrap in double quotes. Using escapeShellArgWindows for proper escaping. + const escaped = escapeShellArgWindows(path); + return `cd "${escaped}" && `; + } + return `cd ${escapeShellPath(path)} && `; }