From 72106109a2e607482fba908ab4322852d8db3cd6 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:53:27 +0100 Subject: [PATCH] Fix/windows issues (#471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): Claude CLI detection failing on Windows On Windows, npm installs CLI tools as .cmd batch wrappers alongside bash scripts for Git Bash/Cygwin. Two issues prevented detection: 1. findExecutable() checked for extensionless files first, finding the unusable bash script before the .cmd wrapper 2. execFileSync() cannot execute .cmd files without shell: true Changes: - Reorder extension search to prioritize .exe/.cmd over extensionless - Add shell: true when validating .cmd/.bat files on Windows Both changes are Windows-specific and don't affect macOS behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(windows): terminal shortcuts and invoke Claude not working - Fix Ctrl+T/W shortcuts not working inside terminals on Windows xterm.js was capturing Ctrl+T as ^T character instead of letting it bubble up to window handler. Added T and W to custom key handler bypass list in useXterm.ts - Fix "Invoke Claude" button failing on Windows with path error buildCdCommand() was using single quotes which cmd.exe doesn't recognize. Now uses platform-appropriate quoting (double quotes on Windows, single quotes on Unix) - Improve terminal auto-naming to skip common commands Expanded skip list to include claude, git, npm, node, python, and other shell/dev commands that don't represent meaningful work. Terminal naming should come from actual task descriptions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(windows): reduce installer size by 75% (~300MB savings) Strip unnecessary files from Python site-packages during bundling: - Remove googleapiclient/discovery_cache/documents (92MB cached API docs) - Remove claude_agent_sdk/_bundled (224MB bundled Claude CLI) - Remove pythonwin directory (9MB Windows IDE) - Remove .chm help files (2.6MB) The Claude Agent SDK will fall back to the system-installed Claude Code CLI, which is already a prerequisite for Auto-Claude (required for 'claude setup-token'). Site-packages reduced from 446MB to 111MB (75% reduction). Expected installer size: ~100MB instead of ~206MB. * fix(frontend): sync project tabs with settings by removing project on tab close Closing a project tab now removes the project from the app entirely, keeping tabs and settings dropdown in sync. Files remain on disk so users can re-add projects later. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * refactor(settings): remove redundant Claude Auth project settings tab Claude authentication is already available in the app-level Integrations section, making this project-level tab redundant. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(onboarding): add one-click Ollama installation for Windows/macOS/Linux When users select Ollama as their embedding provider during onboarding but don't have it installed, they now see an "Install Ollama" button that opens their preferred terminal with the official install command. Changes: - Add checkOllamaInstalled() to detect if Ollama binary exists on system - Add installOllama() to open terminal with platform-specific install: - Windows: winget install --id Ollama.Ollama - macOS/Linux: curl -fsSL https://ollama.com/install.sh | sh - Update OllamaModelSelector to show install UI when Ollama not found - Fix Windows terminal handling for commands with pipes (PowerShell) - Use fire-and-forget pattern so UI doesn't hang waiting for terminal - Add i18n translations (English & French) for install UI The install uses the user's preferred terminal from the DevTools onboarding step, respecting their configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(ipc-handlers): enhance task status persistence in implementation plan - Added functionality to persist task status updates to the implementation plan file, preventing inconsistencies between UI and file state during task refresh. - Implemented error handling for file read/write operations to ensure robustness in status updates. - Updated task execution handlers to reflect changes in task status, including transitions to 'human_review' and 'backlog'. - Introduced critical comments to clarify the importance of status persistence in maintaining accurate task states. This update improves the reliability of task status management across the application. * fix(security): address PR review findings for Windows issues - Fix command injection vulnerability in buildCdCommand by using escapeShellArgWindows() to properly escape cmd.exe metacharacters - Add confirmation dialog before removing project on tab close - Add documentation explaining why skipCommands list exists - Add security comment for shell: true usage in Claude CLI validation - Remove unused EnvironmentSettings component (dead code cleanup) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(security): address follow-up PR review findings - Fix command injection in Windows terminal by escaping PowerShell metacharacters (backticks, double quotes, dollar signs) - Fix Git Bash to use passed command parameter instead of hardcoded value - Add shared plan-file-utils with mutex locking for thread-safe updates - Refactor agent-events-handlers and execution-handlers to use shared persistence utility, eliminating code duplication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(security): strengthen shell escaping and document sync limitations - Add escaping for parentheses, semicolons, ampersands in PowerShell - Add escaping for semicolons, pipes, exclamation marks in Git Bash - Add comprehensive documentation warning about persistPlanStatusSync bypassing the async locking mechanism Note: The CRITICAL finding about EnvironmentSettings in SectionRouter.tsx is a false positive - grep confirms no such import exists in the file. Line 5 imports SecuritySettings, not EnvironmentSettings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: address code scanning and TypeScript errors - Fix TypeScript error in plan-file-utils.ts (generic type assertion) - Add security comment for ollamaPath explaining hardcoded paths - Remove useless isLoading conditional in OllamaModelSelector.tsx Note: The EnvironmentSettings import finding remains a false positive - grep confirms no such import exists in SectionRouter.tsx. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(ui): restore Retry button disabled state for defensive programming Restored disabled={isLoading} and animate-spin on Retry buttons. FALSE POSITIVES in review (verified via grep/sed): - CRITICAL "EnvironmentSettings import at line 5": Line 5 is actually `import { SecuritySettings }` - no EnvironmentSettings import exists - LOW "Dead code escape functions": Functions ARE used at lines 268, 275 in openTerminalWithCommand for PowerShell and Git Bash escaping The review tool appears to be using cached/stale file data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(security): address CodeQL security alerts - Fix 6 HIGH TOCTOU race conditions by removing existsSync checks and using try/catch with ENOENT detection instead - Fix MEDIUM command injection by using execFileSync instead of execSync for Ollama path detection (avoids shell interpretation) - Fix unused imports in execution-handlers.ts - Remove useless isLoading conditional and add explanatory comment about React batching behavior preventing double-clicks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(build): remove TOCTOU race condition in download-python script Replace existsSync check with try/catch pattern to avoid race condition between existence check and file operations. Handle ENOENT as no-op. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: address PR review issues for error handling and i18n - Add error handling with toast notification for project removal in App.tsx - Replace hardcoded strings with i18n translation keys in ProjectSettingsContent.tsx - Add translation keys for projectSettings.noProjectSelected (en/fr) - Add removeProject.error translation key to dialogs.json (en/fr) - Add errors.unknownError translation key to common.json (en/fr) - Refactor terminal command escaping in claude-code-handlers.ts - Remove unused imports in execution-handlers.ts - Add explanatory comment for React batching in OllamaModelSelector.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(app): replace toast with inline error display in remove project dialog Toast function doesn't exist in this codebase. Use inline error display with AlertCircle icon to show removal errors in the dialog itself. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- apps/frontend/scripts/download-python.cjs | 35 +++ apps/frontend/src/main/cli-tool-manager.ts | 9 + apps/frontend/src/main/env-utils.ts | 5 +- .../ipc-handlers/agent-events-handlers.ts | 33 ++- .../main/ipc-handlers/claude-code-handlers.ts | 243 +++++++++++++---- .../src/main/ipc-handlers/memory-handlers.ts | 197 +++++++++++++- .../ipc-handlers/task/execution-handlers.ts | 83 +++--- .../main/ipc-handlers/task/plan-file-utils.ts | 249 ++++++++++++++++++ apps/frontend/src/preload/api/project-api.ts | 12 + apps/frontend/src/renderer/App.tsx | 70 ++++- .../onboarding/OllamaModelSelector.tsx | 173 ++++++++++-- .../project-settings/EnvironmentSettings.tsx | 248 ----------------- .../components/project-settings/index.ts | 1 - .../components/settings/AppSettings.tsx | 1 - .../settings/ProjectSettingsContent.tsx | 19 +- .../settings/sections/SectionRouter.tsx | 39 --- .../components/terminal/useAutoNaming.ts | 37 ++- .../renderer/components/terminal/useXterm.ts | 6 + .../renderer/lib/mocks/infrastructure-mock.ts | 16 ++ apps/frontend/src/shared/constants/ipc.ts | 2 + .../src/shared/i18n/locales/en/common.json | 1 + .../src/shared/i18n/locales/en/dialogs.json | 7 + .../shared/i18n/locales/en/onboarding.json | 18 ++ .../src/shared/i18n/locales/en/settings.json | 6 + .../src/shared/i18n/locales/fr/common.json | 1 + .../src/shared/i18n/locales/fr/dialogs.json | 7 + .../shared/i18n/locales/fr/onboarding.json | 18 ++ .../src/shared/i18n/locales/fr/settings.json | 6 + apps/frontend/src/shared/types/ipc.ts | 6 + .../frontend/src/shared/utils/shell-escape.ts | 10 + 30 files changed, 1139 insertions(+), 419 deletions(-) create mode 100644 apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts delete mode 100644 apps/frontend/src/renderer/components/project-settings/EnvironmentSettings.tsx 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)} && `; }