diff --git a/.gitignore b/.gitignore index e0acc0dc5b..83af5c300b 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ venv/ ENV/ env/ .conda/ +.envs/ # Testing .pytest_cache/ @@ -168,4 +169,5 @@ OPUS_ANALYSIS_AND_IDEAS.md # Auto Claude generated files .security-key -/shared_docs \ No newline at end of file +/shared_docs +tmpclaude* diff --git a/.husky/pre-commit b/.husky/pre-commit index 02d51b167c..d915335867 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -131,7 +131,7 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then echo "$STAGED_PY_FILES" | xargs git add fi else - echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff" + echo "⚠️ Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff" fi # Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed) diff --git a/CLAUDE.md b/CLAUDE.md index 45327eba3d..2c3e3f2cbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,10 +14,11 @@ Auto Claude is a multi-agent autonomous coding framework that builds software th autonomous-coding/ ├── apps/ │ ├── backend/ # Python backend/CLI - ALL agent logic lives here -│ │ ├── core/ # Client, auth, security +│ │ ├── core/ # Client, auth, security, git provider detection │ │ ├── agents/ # Agent implementations │ │ ├── spec_agents/ # Spec creation agents -│ │ ├── integrations/ # Graphiti, Linear, GitHub +│ │ ├── runners/ # GitHub & GitLab automation +│ │ ├── integrations/ # Graphiti, Linear │ │ └── prompts/ # Agent system prompts │ └── frontend/ # Electron desktop UI ├── guides/ # Documentation @@ -195,6 +196,8 @@ See [RELEASE.md](RELEASE.md) for detailed release process documentation. **Integrations:** - **linear_updater.py** - Optional Linear integration for progress tracking - **runners/github/** - GitHub Issues & PRs automation +- **runners/gitlab/** - GitLab Issues & MRs automation +- **core/git_provider.py** - Multi-provider support (GitHub, GitLab) with auto-detection - **Electron MCP** - E2E testing integration for QA agents (Chrome DevTools Protocol) - Enabled with `ELECTRON_MCP_ENABLED=true` in `.env` - Allows QA agents to interact with running Electron app @@ -237,7 +240,7 @@ main (user's branch) **Key principles:** - ONE branch per spec (`auto-claude/{spec-name}`) - Parallel work uses subagents (agent decides when to spawn) -- NO automatic pushes to GitHub - user controls when to push +- NO automatic pushes to remote (GitHub/GitLab) - user controls when to push - User reviews in spec worktree (`.worktrees/{spec-name}/`) - Final merge: spec branch → main (after user approval) @@ -248,6 +251,65 @@ main (user's branch) 4. User runs `--merge` to add to their project 5. User pushes to remote when ready +### Git Provider Support + +Auto Claude supports both **GitHub** and **GitLab** for pull request / merge request creation: + +**Configuration:** +- **Per-project setting** in project settings UI or `.env` file +- **Options**: Auto-detect (default), GitHub, GitLab +- **Auto-detection**: Parses git remote URL to determine provider + - `github.com` → Uses GitHub (`gh pr create`) + - `gitlab.com` → Uses GitLab (`glab mr create`) + - Self-hosted GitLab instances also supported (e.g., `gitlab.mycompany.com`) + - Unknown/no remote → Defaults to GitHub + +**CLI Requirements:** +- **GitHub**: Requires `gh` CLI installed and authenticated + - Install: `brew install gh` (macOS), `scoop install gh` (Windows), `sudo apt install gh` (Linux) + - Authenticate: `gh auth login` + - More info: https://cli.github.com/ +- **GitLab**: Requires `glab` CLI installed and authenticated + - Install: `brew install glab` (macOS), `scoop install glab` (Windows) + - Download: https://gitlab.com/gitlab-org/cli/-/releases + - Authenticate: `glab auth login` + +**Configuration Options:** + +*In Project Settings UI:* +1. Open project settings +2. Expand "Git Provider Settings" +3. Choose: Auto-detect (recommended), GitHub, or GitLab + +*In `.env` file:* + +```bash +# Set git provider (optional, defaults to auto-detect) +GIT_PROVIDER=auto # auto | github | gitlab +``` + +**Example Usage:** + +```bash +# Auto-detect provider from git remote (default) +python run.py --spec 001 --create-pr + +# Works automatically with both: +# - git@github.com:user/repo.git → Creates GitHub PR using gh CLI +# - git@gitlab.com:user/repo.git → Creates GitLab MR using glab CLI +``` + +**Provider Detection Logic:** +1. **Explicit setting** (if gitProvider is "github" or "gitlab") → Use that provider +2. **Auto-detect** (if gitProvider is "auto" or not set) → Parse git remote URL +3. **Fallback** (if no remote or unknown) → Default to GitHub + +**Implementation Details:** +- Detection: `apps/backend/core/git_provider.py` +- GitHub PR creation: `apps/backend/core/worktree.py` (`_create_github_pr()`) +- GitLab MR creation: `apps/backend/core/worktree.py` (`_create_gitlab_mr()`) +- UI settings: `apps/frontend/src/renderer/components/project-settings/IntegrationSettings.tsx` + ### Contributing to Upstream **CRITICAL: When submitting PRs to AndyMik90/Auto-Claude, always target the `develop` branch, NOT `main`.** diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000000..a657cc9924 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,60 @@ +## Summary + +This PR implements comprehensive Conda environment management for Auto Claude, enabling isolated Python environments at both application and project levels. It also includes significant Windows/PowerShell compatibility fixes to ensure reliable terminal integration across platforms. + +### Key Features + +- **Conda Detection Service**: Automatically detects conda installations across OS-specific locations (miniconda, anaconda, mambaforge) +- **Application-Level Environment**: Managed conda env at `~/miniconda3/envs/auto-claude` using `apps/backend/requirements.txt` +- **Project-Level Environments**: Self-contained environments in `.envs//` within each project +- **Automatic Terminal Activation**: Conda environments auto-activate when opening terminals for configured projects +- **Cross-Platform Activation Scripts**: Generated scripts for CMD, PowerShell, and Bash +- **VS Code Integration**: Auto-generated `.code-workspace` files with conda terminal profiles +- **Python Version Detection**: Parses `requirements.txt`, `pyproject.toml`, and `environment.yml` for version constraints + +### Windows Compatibility Fixes + +- Added `windowsHide: true` to all spawn calls to prevent console window popups +- Fixed PowerShell command syntax (`;` instead of `&&`, `$env:PATH=` instead of `PATH=`) +- Platform-aware shell escaping (PowerShell uses `''` for quotes, bash uses `'\''`) +- Added `pathWasModified` optimization to skip unnecessary PATH modifications (eliminates massive PATH echo) +- PowerShell-specific conda activation using init scripts + +## Test plan + +- [ ] **Conda Detection**: Open App Settings > Paths, verify conda installation detected +- [ ] **App Env Setup**: Click "Setup Auto Claude Environment", verify stepper completes +- [ ] **Project Toggle**: Enable conda env for a project in Project Settings > General +- [ ] **Project Env Setup**: Navigate to Python Env section, run setup, verify environment created +- [ ] **Terminal Activation**: Open terminal for conda-enabled project, verify env activates automatically +- [ ] **Windows Popup**: Verify no external PowerShell windows appear during operations +- [ ] **Connect Claude (Windows)**: Click "Connect Claude", verify PowerShell syntax works without errors +- [ ] **Settings Persistence**: Navigate away and back, verify all conda settings retained + +## Files Changed + +### New Files (11) +- `conda-detector.ts` - Conda installation detection service +- `conda-env-manager.ts` - Environment creation and management +- `conda-workspace-generator.ts` - VS Code workspace file generation +- `conda-project-structure.ts` - Project structure detection (pure-python vs mixed) +- `conda-handlers.ts` - IPC handlers for conda operations +- `conda-api.ts` - Preload API for renderer access +- `PythonEnvSettings.tsx` - Project-level Python environment settings UI +- `CondaSetupWizard.tsx` - Multi-step setup wizard with progress +- `CondaDetectionDisplay.tsx` - Conda detection status display +- `useCondaSetup.ts` - React hook for setup progress +- `conda.ts` - TypeScript type definitions + +### Modified Files (48) +- Terminal integration: `pty-manager.ts`, `terminal-lifecycle.ts`, `terminal-manager.ts` +- Claude integration: `claude-cli-utils.ts`, `claude-integration-handler.ts` +- Shell utilities: `shell-escape.ts`, `env-utils.ts` +- Settings UI: `GeneralSettings.tsx`, `AppSettings.tsx`, `ProjectSettingsContent.tsx` +- IPC handlers: Various handlers with `windowsHide` fixes +- Type definitions: `settings.ts`, `project.ts`, `terminal.ts`, `ipc.ts` +- i18n: English and French translation files + +--- + +Generated with [Claude Code](https://claude.ai/code) diff --git a/apps/backend/agents/coder.py b/apps/backend/agents/coder.py index f0b7ff23dc..4b7d2d94a1 100644 --- a/apps/backend/agents/coder.py +++ b/apps/backend/agents/coder.py @@ -40,6 +40,7 @@ from recovery import RecoveryManager from security.constants import PROJECT_DIR_ENV_VAR from task_logger import ( + LogEntryType, LogPhase, get_task_logger, ) @@ -55,6 +56,7 @@ print_key_value, print_status, ) +from user_feedback import get_unread_feedback from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE from .memory_manager import debug_memory_system_status, get_graphiti_context @@ -247,6 +249,30 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: subtask_id = next_subtask.get("id") if next_subtask else None phase_name = next_subtask.get("phase_name") if next_subtask else None + # Check for unread user feedback before starting this subtask + unread_feedback = get_unread_feedback(spec_dir) + feedback_text = None + if unread_feedback: + # Combine all unread feedback messages with their ACTUAL indices from full list + # unread_feedback is now a list of (actual_index, feedback_dict) tuples + feedback_messages = [] + for actual_idx, fb in unread_feedback: + msg = fb.get("message", "") + timestamp = fb.get("timestamp", "") + # Use actual_idx so agent knows the correct index for mark_feedback_read tool + feedback_messages.append(f"[Index {actual_idx}] [{timestamp}]\n{msg}") + feedback_text = "\n\n".join(feedback_messages) + + # Log feedback detection to task_logs.json (visible in UI) + task_logger = get_task_logger(spec_dir) + if task_logger: + task_logger.log( + content=f"USER FEEDBACK DETECTED - {len(unread_feedback)} unread feedback item(s) will be incorporated into this subtask", + entry_type=LogEntryType.INFO, + phase=LogPhase.CODING, + print_to_console=True, + ) + # Update status for this session status_manager.update_session(iteration) if phase_name: @@ -476,6 +502,17 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: if sync_spec_to_source(spec_dir, source_spec_dir): print_status("Implementation plan synced to main project", "success") + # Show plan statistics + from implementation_plan import ImplementationPlan + + plan = ImplementationPlan.load(spec_dir / "implementation_plan.json") + total_phases = len(plan.phases) + total_subtasks = sum(len(p.subtasks) for p in plan.phases) + print_status( + f"Planning complete: {total_phases} phase(s), {total_subtasks} subtask(s)", + "success", + ) + # Handle session status if status == "complete": # Don't emit COMPLETE here - subtasks are done but QA hasn't run yet diff --git a/apps/backend/agents/tools_pkg/tools/feedback.py b/apps/backend/agents/tools_pkg/tools/feedback.py new file mode 100644 index 0000000000..64b79d09fc --- /dev/null +++ b/apps/backend/agents/tools_pkg/tools/feedback.py @@ -0,0 +1,165 @@ +""" +Feedback Management Tools +========================== + +Tools for managing user feedback in task_metadata.json. +""" + +import json +from pathlib import Path +from typing import Any + +try: + from claude_agent_sdk import tool + + SDK_TOOLS_AVAILABLE = True +except ImportError: + SDK_TOOLS_AVAILABLE = False + tool = None + + +def create_feedback_tools(spec_dir: Path, _project_dir: Path) -> list: + """ + Create feedback management tools. + + Args: + spec_dir: Path to the spec directory + project_dir: Path to the project root + + Returns: + List of feedback tool functions + """ + if not SDK_TOOLS_AVAILABLE: + return [] + + tools = [] + + # ------------------------------------------------------------------------- + # Tool: mark_feedback_read + # ------------------------------------------------------------------------- + @tool( + "mark_feedback_read", + "Mark specific user feedback items as read after incorporating them. " + "Call this when you have successfully addressed feedback corrections. " + "Provide the indices as a comma-separated string (e.g., '0' or '0,1,2').", + {"feedback_indices": str}, + ) + async def mark_feedback_read(args: dict[str, Any]) -> dict[str, Any]: + """Mark specific feedback entries as read in task_metadata.json.""" + feedback_indices_str = args.get("feedback_indices", "") + + # Parse comma-separated string into list of integers + try: + if not feedback_indices_str or not feedback_indices_str.strip(): + return { + "content": [ + { + "type": "text", + "text": "Error: feedback_indices cannot be empty. Provide indices as comma-separated string (e.g., '0' or '0,1,2').", + } + ] + } + + feedback_indices = [ + int(idx.strip()) for idx in feedback_indices_str.split(",") + ] + except ValueError as e: + error_msg = f"Error: feedback_indices must be comma-separated integers (e.g., '0' or '0,1,2'), got '{feedback_indices_str}'" + return { + "content": [ + { + "type": "text", + "text": error_msg, + } + ] + } + + if not feedback_indices: + return { + "content": [ + { + "type": "text", + "text": "Error: feedback_indices cannot be empty. Specify which feedback items you addressed.", + } + ] + } + + metadata_file = spec_dir / "task_metadata.json" + if not metadata_file.exists(): + return { + "content": [ + { + "type": "text", + "text": "Error: task_metadata.json not found", + } + ] + } + + try: + with open(metadata_file) as f: + metadata = json.load(f) + + feedback_list = metadata.get("feedback", []) + if not feedback_list: + return { + "content": [ + { + "type": "text", + "text": "No feedback entries found in task_metadata.json", + } + ] + } + + # Mark specified feedback items as read + marked_count = 0 + invalid_indices = [] + for idx in feedback_indices: + if 0 <= idx < len(feedback_list): + feedback_list[idx]["read"] = True + marked_count += 1 + else: + invalid_indices.append(idx) + + if invalid_indices: + return { + "content": [ + { + "type": "text", + "text": f"Error: Invalid feedback indices: {invalid_indices}. " + f"Valid range is 0-{len(feedback_list) - 1}", + } + ] + } + + # Save updated metadata + with open(metadata_file, "w") as f: + json.dump(metadata, f, indent=2) + + return { + "content": [ + { + "type": "text", + "text": f"Successfully marked {marked_count} feedback item(s) as read: {feedback_indices}", + } + ] + } + + except json.JSONDecodeError as e: + return { + "content": [ + { + "type": "text", + "text": f"Error: Invalid JSON in task_metadata.json: {e}", + } + ] + } + except Exception as e: + return { + "content": [ + {"type": "text", "text": f"Error marking feedback as read: {e}"} + ] + } + + tools.append(mark_feedback_read) + + return tools diff --git a/apps/backend/core/git_provider.py b/apps/backend/core/git_provider.py new file mode 100644 index 0000000000..12a41b79d0 --- /dev/null +++ b/apps/backend/core/git_provider.py @@ -0,0 +1,94 @@ +""" +Git Provider Detection +====================== + +Auto-detect git provider from remote URL and provide provider-specific operations. +""" + +from __future__ import annotations + +import subprocess +from enum import Enum +from pathlib import Path + + +class GitProvider(str, Enum): + """Git provider types.""" + + GITHUB = "github" + GITLAB = "gitlab" + AUTO_DETECT = "auto" # Special value for auto-detection + + +def detect_provider_from_remote(project_dir: Path) -> GitProvider: + """ + Detect git provider by parsing remote.origin.url. + + Args: + project_dir: Project directory path + + Returns: + GitProvider.GITHUB or GitProvider.GITLAB + Defaults to GitProvider.GITHUB if cannot detect + """ + try: + result = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + cwd=project_dir, + capture_output=True, + text=True, + timeout=5, + ) + + if result.returncode != 0: + return GitProvider.GITHUB # Default fallback + + remote_url = result.stdout.strip().lower() + + # GitLab detection patterns + if any( + pattern in remote_url + for pattern in [ + "gitlab.com", + "gitlab:", + "/gitlab/", + "@gitlab.", + "//gitlab.", # https://gitlab.mycompany.com + ":gitlab.", # git@gitlab.mycompany.com + ] + ): + return GitProvider.GITLAB + + # GitHub detection patterns (default) + return GitProvider.GITHUB + + except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.CalledProcessError): + return GitProvider.GITHUB # Fail-safe default + + +def get_provider_config(project_dir: Path, provider_setting: str | None) -> GitProvider: + """ + Get final provider choice using fallback chain: + 1. Explicit project setting (if not "auto") + 2. Auto-detection from git remote + 3. Default to GitHub + + Args: + project_dir: Project directory path + provider_setting: User's choice from settings ("auto", "github", "gitlab", or None) + + Returns: + GitProvider enum value (never returns AUTO_DETECT) + """ + # Normalize provider setting to lowercase for case-insensitive comparison + normalized_setting = provider_setting.lower() if provider_setting else None + + # If user explicitly chose a provider (not auto), use it + if normalized_setting and normalized_setting != "auto": + try: + return GitProvider(normalized_setting) + except ValueError: + pass # Invalid value, fall through to auto-detect + + # Auto-detect or fallback + return detect_provider_from_remote(project_dir) diff --git a/apps/backend/core/worktree.py b/apps/backend/core/worktree.py index 88cc1d064d..8490e37836 100644 --- a/apps/backend/core/worktree.py +++ b/apps/backend/core/worktree.py @@ -32,6 +32,71 @@ T = TypeVar("T") +# Cached CLI executable paths +_cached_glab_path: str | None = None + + +def get_glab_executable() -> str: + """Find the glab CLI executable, with platform-specific fallbacks. + + Returns the path to glab executable. On Windows, checks multiple sources: + 1. shutil.which (if glab is in PATH) + 2. Common installation locations (scoop, chocolatey) + + Caches the result after first successful find. + """ + global _cached_glab_path + + # Return cached result if available + if _cached_glab_path is not None: + return _cached_glab_path + + # 1. Try shutil.which (works if glab is in PATH) + glab_path = shutil.which("glab") + if glab_path: + _cached_glab_path = glab_path + return glab_path + + # 2. Windows-specific: check common installation locations + if os.name == "nt": + common_paths = [ + # Scoop installation + os.path.expandvars(r"%USERPROFILE%\scoop\shims\glab.exe"), + # Chocolatey installation + os.path.expandvars(r"%PROGRAMDATA%\chocolatey\bin\glab.exe"), + # Manual installation in Program Files + os.path.expandvars(r"%PROGRAMFILES%\glab\glab.exe"), + os.path.expandvars(r"%LOCALAPPDATA%\Programs\glab\glab.exe"), + ] + for path in common_paths: + try: + if os.path.isfile(path): + _cached_glab_path = path + return path + except OSError: + continue + + # 3. Try 'where' command with shell=True (more reliable on Windows) + try: + result = subprocess.run( + "where glab", + capture_output=True, + text=True, + timeout=5, + shell=True, + ) + if result.returncode == 0 and result.stdout.strip(): + found_path = result.stdout.strip().split("\n")[0].strip() + if found_path and os.path.isfile(found_path): + _cached_glab_path = found_path + return found_path + except (subprocess.TimeoutExpired, OSError): + pass + + # Default fallback - let subprocess handle it (may fail) + _cached_glab_path = "glab" + return "glab" + def _is_retryable_network_error(stderr: str) -> bool: """Check if an error is a retryable network/connection issue.""" @@ -1079,6 +1144,159 @@ def do_create_pr() -> tuple[bool, PullRequestResult | None, str]: error="gh CLI not found. Install from https://cli.github.com/", ) + def _create_gitlab_mr( + self, + info: "WorktreeInfo", + target: str, + title: str, + body: str, + draft: bool, + ) -> PullRequestResult: + """Create GitLab MR using glab CLI.""" + spec_name = info.spec_name + + # Build glab mr create command + glab_args = [ + get_glab_executable(), + "mr", + "create", + "--target-branch", + target, + "--source-branch", + info.branch, + "--title", + title, + "--description", + body, + ] + if draft: + glab_args.append("--draft") + + def is_mr_retryable(stderr: str) -> bool: + """Check if MR creation error is retryable.""" + return _is_retryable_network_error(stderr) or _is_retryable_http_error( + stderr + ) + + def do_create_mr() -> tuple[bool, PullRequestResult | None, str]: + """Execute MR creation for retry wrapper.""" + try: + result = subprocess.run( + glab_args, + cwd=info.path, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, # Same timeout as GitHub + ) + + # Check for "already exists" case + if result.returncode != 0 and "already exists" in result.stderr.lower(): + existing_url = self._get_existing_gitlab_mr_url(spec_name, target) + return ( + True, + PullRequestResult( + success=True, + pr_url=existing_url, + already_exists=True, + ), + "", + ) + + if result.returncode == 0: + # Extract MR URL from output + # GitLab uses /merge_requests/ or /-/merge_requests/ + mr_url: str | None = result.stdout.strip() + if not mr_url.startswith("http"): + match = re.search( + r"https://[^\s]+/-?/merge_requests/\d+", result.stdout + ) + if match: + mr_url = match.group(0) + else: + mr_url = None + + return ( + True, + PullRequestResult( + success=True, + pr_url=mr_url, + already_exists=False, + ), + "", + ) + + return (False, None, result.stderr) + + except FileNotFoundError: + raise # glab CLI not installed + + max_retries = 3 + try: + result, last_error = _with_retry( + operation=do_create_mr, + max_retries=max_retries, + is_retryable=is_mr_retryable, + ) + + if result: + return result + + if last_error == "Operation timed out": + return PullRequestResult( + success=False, + error=f"MR creation timed out after {max_retries} attempts.", + ) + + return PullRequestResult( + success=False, + error=f"Failed to create MR: {last_error}", + ) + + except FileNotFoundError: + return PullRequestResult( + success=False, + error="glab CLI not found. Install from https://gitlab.com/gitlab-org/cli", + ) + + def _get_existing_gitlab_mr_url( + self, spec_name: str, target_branch: str + ) -> str | None: + """Get URL of existing GitLab MR.""" + try: + info = self.get_worktree_info(spec_name) + if not info: + return None + + result = subprocess.run( + [ + get_glab_executable(), + "mr", + "list", + "--source-branch", + info.branch, + "--target-branch", + target_branch, + ], + cwd=info.path, + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + # Parse glab output for MR URL + match = re.search( + r"https://[^\s]+/-?/merge_requests/\d+", result.stdout + ) + if match: + return match.group(0) + + return None + except Exception: + return None + def _extract_spec_summary(self, spec_name: str) -> str: """Extract a summary from spec.md for PR body.""" worktree_path = self.get_worktree_path(spec_name) diff --git a/apps/backend/prompts/planner.md b/apps/backend/prompts/planner.md index 3209b5212b..a394d4c293 100644 --- a/apps/backend/prompts/planner.md +++ b/apps/backend/prompts/planner.md @@ -6,6 +6,49 @@ You are the **first agent** in an autonomous development process. Your job is to --- +## CRITICAL REQUIREMENT: SUBTASKS ARE MANDATORY + +**YOUR PLAN MUST INCLUDE AT LEAST ONE PHASE WITH AT LEAST ONE SUBTASK.** + +**This is NOT optional.** An empty implementation plan will cause the build to fail immediately. The validation system will reject your plan if: +- You create zero phases +- You create phases with zero subtasks +- Your phases array is empty + +**If you cannot determine detailed subtasks**, create a minimal plan with basic work units: +- At minimum: "Research", "Implementation", "Testing" subtasks +- Break work down into the smallest units you can identify +- Use the existing codebase patterns to guide your breakdown +- When in doubt, create MORE subtasks rather than fewer + +**Example MINIMUM acceptable plan:** + +```json +{ + "feature": "Feature name", + "workflow_type": "feature", + "phases": [ + { + "id": "phase-1", + "name": "Implementation", + "type": "implementation", + "subtasks": [ + { + "id": "1.1", + "description": "Implement the core feature", + "service": "backend", + "status": "pending" + } + ] + } + ] +} +``` + +**NEVER create an empty plan. The build will fail and you will waste tokens.** + +--- + ## WHY SUBTASKS, NOT TESTS? Tests verify outcomes. Subtasks define implementation steps. diff --git a/apps/backend/prompts/roadmap_discovery.md b/apps/backend/prompts/roadmap_discovery.md index b1f6fcceee..e4117f9fc5 100644 --- a/apps/backend/prompts/roadmap_discovery.md +++ b/apps/backend/prompts/roadmap_discovery.md @@ -129,6 +129,12 @@ Make reasonable inferences. If the README doesn't specify, infer from: - A library → likely for other developers - An API → likely for integration/automation use cases +**After completing this phase, signal progress:** + +```bash +echo "[ROADMAP_PROGRESS] 52 Discovered target audience and pain points" +``` + --- ## PHASE 3: ASSESS CURRENT STATE (AUTONOMOUS) diff --git a/apps/backend/runners/gitlab/providers/gitlab_provider.py b/apps/backend/runners/gitlab/providers/gitlab_provider.py new file mode 100644 index 0000000000..cca65764bf --- /dev/null +++ b/apps/backend/runners/gitlab/providers/gitlab_provider.py @@ -0,0 +1,400 @@ +""" +GitLab Provider Implementation +=============================== + +Implements the GitProvider protocol for GitLab using the GitLab API. +Wraps the existing GitLabClient functionality. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +# Import GitLab client from parent package +try: + from ..glab_client import GitLabClient, GitLabConfig +except (ImportError, ValueError, SystemError): + from glab_client import GitLabClient, GitLabConfig + +# Import protocol from GitHub providers (shared protocol) +from ...github.providers.protocol import ( + IssueData, + IssueFilters, + LabelData, + PRData, + PRFilters, + ProviderType, + ReviewData, +) + + +@dataclass +class GitLabProvider: + """ + GitLab implementation of the GitProvider protocol. + + Uses the GitLab API for all operations. + + Usage: + config = GitLabConfig( + token="glpat-xxx", + project="group/project", + instance_url="https://gitlab.com" + ) + provider = GitLabProvider(config=config) + pr = await provider.fetch_pr(123) # MR in GitLab terms + await provider.post_review(123, review) + """ + + _config: GitLabConfig + _glab_client: GitLabClient | None = None + _project_dir: str | None = None + + def __post_init__(self): + if self._glab_client is None: + from pathlib import Path + + project_dir = Path(self._project_dir) if self._project_dir else Path.cwd() + self._glab_client = GitLabClient( + project_dir=project_dir, + config=self._config, + ) + + @property + def provider_type(self) -> ProviderType: + return ProviderType.GITLAB + + @property + def repo(self) -> str: + """Get the project in group/project format.""" + return self._config.project + + @property + def glab_client(self) -> GitLabClient: + """Get the underlying GitLabClient.""" + return self._glab_client + + # ------------------------------------------------------------------------- + # Pull Request Operations (Merge Requests in GitLab) + # ------------------------------------------------------------------------- + + async def fetch_pr(self, number: int) -> PRData: + """ + Fetch a merge request by IID. + + Note: In GitLab, this is called a Merge Request (MR), but we map it + to PRData for protocol compatibility. + """ + mr = self.glab_client.get_mr(number) + changes = self.glab_client.get_mr_changes(number) + + # Parse dates + created_at = self._parse_gitlab_date(mr.get("created_at", "")) + updated_at = self._parse_gitlab_date(mr.get("updated_at", "")) + + # Extract file changes + files = [] + for change in changes.get("changes", []): + files.append( + { + "filename": change.get("new_path", change.get("old_path", "")), + "status": self._map_change_type(change), + "additions": 0, # GitLab doesn't provide per-file stats in changes + "deletions": 0, + "changes": 0, + "patch": change.get("diff", ""), + } + ) + + return PRData( + number=mr["iid"], + title=mr["title"], + body=mr.get("description", ""), + author=mr["author"]["username"], + state=self._map_mr_state(mr["state"]), + source_branch=mr["source_branch"], + target_branch=mr["target_branch"], + additions=changes.get("additions", 0) if "additions" in changes else 0, + deletions=changes.get("deletions", 0) if "deletions" in changes else 0, + changed_files=len(files), + files=files, + diff=self.glab_client.get_mr_diff(number), + url=mr["web_url"], + created_at=created_at, + updated_at=updated_at, + labels=mr.get("labels", []), + reviewers=[r["username"] for r in mr.get("reviewers", [])], + is_draft=mr.get("draft", False) or mr.get("work_in_progress", False), + mergeable=mr.get("merge_status") in ["can_be_merged", "unchecked"], + provider=ProviderType.GITLAB, + raw_data=mr, + ) + + async def fetch_prs(self, filters: PRFilters | None = None) -> list[PRData]: + """ + Fetch merge requests with optional filters. + """ + # For now, return empty list - would need to implement list_mr in glab_client + # This is a placeholder for the full implementation + return [] + + async def fetch_pr_diff(self, number: int) -> str: + """Fetch the diff for a merge request.""" + return self.glab_client.get_mr_diff(number) + + async def post_review( + self, + pr_number: int, + review: ReviewData, + ) -> int: + """ + Post a review to a merge request. + + In GitLab, reviews are posted as notes (comments) on the MR. + """ + # Build review body from findings + body_parts = [review.body] if review.body else [] + + if review.findings: + body_parts.append("\n## Review Findings\n") + for finding in review.findings: + severity_emoji = { + "critical": "🔴", + "high": "🟠", + "medium": "🟡", + "low": "🔵", + "info": "ℹ️", + }.get(finding.severity, "•") + + body_parts.append(f"\n### {severity_emoji} {finding.title}") + body_parts.append(f"**Severity:** {finding.severity.title()}") + body_parts.append(f"**Category:** {finding.category}") + if finding.file: + location = f"{finding.file}" + if finding.line: + location += f":L{finding.line}" + body_parts.append(f"**Location:** `{location}`") + body_parts.append(f"\n{finding.description}") + if finding.suggested_fix: + body_parts.append( + f"\n**Suggested Fix:**\n```\n{finding.suggested_fix}\n```" + ) + + full_body = "\n".join(body_parts) + + # Post as a note + result = self.glab_client.post_mr_note(pr_number, full_body) + + # If event is approve, also approve the MR + if review.event == "approve": + self.glab_client.approve_mr(pr_number) + + return result.get("id", 0) + + async def merge_pr( + self, + pr_number: int, + merge_method: str = "merge", + commit_title: str | None = None, + ) -> bool: + """ + Merge a merge request. + + Args: + pr_number: MR IID + merge_method: merge or squash (GitLab doesn't support rebase via API) + commit_title: Not used in GitLab API + """ + try: + squash = merge_method == "squash" + self.glab_client.merge_mr(pr_number, squash=squash) + return True + except Exception: + return False + + async def close_pr( + self, + pr_number: int, + comment: str | None = None, + ) -> bool: + """ + Close a merge request without merging. + + GitLab doesn't have a direct close endpoint - would need to add to glab_client. + """ + if comment: + self.glab_client.post_mr_note(pr_number, comment) + # TODO: Implement MR closing in glab_client + return False + + # ------------------------------------------------------------------------- + # Issue Operations + # ------------------------------------------------------------------------- + + async def fetch_issue(self, number: int) -> IssueData: + """ + Fetch an issue by IID. + + TODO: Implement issue operations in glab_client. + """ + raise NotImplementedError("Issue operations not yet implemented for GitLab") + + async def fetch_issues( + self, filters: IssueFilters | None = None + ) -> list[IssueData]: + """Fetch issues with optional filters.""" + raise NotImplementedError("Issue operations not yet implemented for GitLab") + + async def create_issue( + self, + title: str, + body: str, + labels: list[str] | None = None, + assignees: list[str] | None = None, + ) -> IssueData: + """Create a new issue.""" + raise NotImplementedError("Issue operations not yet implemented for GitLab") + + async def close_issue( + self, + number: int, + comment: str | None = None, + ) -> bool: + """Close an issue.""" + raise NotImplementedError("Issue operations not yet implemented for GitLab") + + async def add_comment( + self, + issue_or_pr_number: int, + body: str, + ) -> int: + """ + Add a comment to a merge request. + + Note: Currently only supports MRs. Issue comment support requires + implementing issue operations in glab_client first. + """ + result = self.glab_client.post_mr_note(issue_or_pr_number, body) + return result.get("id", 0) + + # ------------------------------------------------------------------------- + # Label Operations + # ------------------------------------------------------------------------- + + async def apply_labels( + self, + issue_or_pr_number: int, + labels: list[str], + ) -> None: + """Apply labels to an issue or MR.""" + # TODO: Implement label operations in glab_client + pass + + async def remove_labels( + self, + issue_or_pr_number: int, + labels: list[str], + ) -> None: + """Remove labels from an issue or MR.""" + # TODO: Implement label operations in glab_client + pass + + async def create_label( + self, + label: LabelData, + ) -> None: + """Create a label in the repository.""" + # TODO: Implement label operations in glab_client + pass + + async def list_labels(self) -> list[LabelData]: + """List all labels in the repository.""" + # TODO: Implement label operations in glab_client + return [] + + # ------------------------------------------------------------------------- + # Repository Operations + # ------------------------------------------------------------------------- + + async def get_repository_info(self) -> dict[str, Any]: + """Get repository information.""" + # TODO: Implement in glab_client + return {} + + async def get_default_branch(self) -> str: + """Get the default branch name.""" + # TODO: Implement in glab_client + return "main" + + async def check_permissions(self, username: str) -> str: + """Check a user's permission level on the repository.""" + # TODO: Implement in glab_client + return "read" + + # ------------------------------------------------------------------------- + # API Operations (Low-level) + # ------------------------------------------------------------------------- + + async def api_get( + self, + endpoint: str, + params: dict[str, Any] | None = None, + ) -> Any: + """Make a GET request to the GitLab API.""" + if params: + from urllib.parse import urlencode + + query_string = urlencode(params) + endpoint = ( + f"{endpoint}?{query_string}" + if "?" not in endpoint + else f"{endpoint}&{query_string}" + ) + return self.glab_client._fetch(endpoint, method="GET") + + async def api_post( + self, + endpoint: str, + data: dict[str, Any] | None = None, + ) -> Any: + """Make a POST request to the GitLab API.""" + return self.glab_client._fetch(endpoint, method="POST", data=data) + + # ------------------------------------------------------------------------- + # Helper Methods + # ------------------------------------------------------------------------- + + def _parse_gitlab_date(self, date_str: str) -> datetime: + """Parse GitLab ISO 8601 date string.""" + if not date_str: + return datetime.now(timezone.utc) + try: + # GitLab uses ISO 8601 format + return datetime.fromisoformat(date_str.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return datetime.now(timezone.utc) + + def _map_mr_state(self, state: str) -> str: + """Map GitLab MR state to protocol state.""" + # GitLab states: opened, closed, merged, locked + mapping = { + "opened": "open", + "closed": "closed", + "merged": "merged", + "locked": "closed", + } + return mapping.get(state, state) + + def _map_change_type(self, change: dict) -> str: + """Map GitLab change type to GitHub-style status.""" + # GitLab: new_file, renamed_file, deleted_file, modified + if change.get("new_file"): + return "added" + elif change.get("deleted_file"): + return "removed" + elif change.get("renamed_file"): + return "renamed" + else: + return "modified" diff --git a/apps/backend/runners/providers/__init__.py b/apps/backend/runners/providers/__init__.py new file mode 100644 index 0000000000..2b90e6ab2b --- /dev/null +++ b/apps/backend/runners/providers/__init__.py @@ -0,0 +1,11 @@ +""" +Git Provider Factory +==================== + +Factory for creating provider instances based on configuration. +Provides a unified interface for GitHub, GitLab, Bitbucket, etc. +""" + +from .factory import ProviderConfig, create_provider + +__all__ = ["ProviderConfig", "create_provider"] diff --git a/apps/backend/runners/providers/example.py b/apps/backend/runners/providers/example.py new file mode 100644 index 0000000000..467ba17928 --- /dev/null +++ b/apps/backend/runners/providers/example.py @@ -0,0 +1,209 @@ +""" +Provider Factory Examples +========================= + +Examples showing how to use the provider factory for different git hosts. +""" + +from pathlib import Path + +from ..github.providers.protocol import ProviderType +from .factory import ProviderConfig, create_provider + + +async def example_github(): + """Example: Using GitHub provider.""" + config = ProviderConfig( + provider_type=ProviderType.GITHUB, + github_repo="owner/repo", + project_dir=Path.cwd(), + ) + + provider = create_provider(config) + + # Fetch a pull request + pr = await provider.fetch_pr(123) + print(f"GitHub PR #{pr.number}: {pr.title}") + print(f"Author: {pr.author}") + print(f"State: {pr.state}") + print(f"Changed files: {pr.changed_files}") + + # Post a review + from ..github.providers.protocol import ReviewData, ReviewFinding + + review = ReviewData( + pr_number=123, + event="comment", + body="Great work! Just a few suggestions:", + findings=[ + ReviewFinding( + id="1", + severity="medium", + category="style", + title="Consider using const", + description="Variables that don't change should use const instead of let", + file="src/index.js", + line=10, + suggested_fix="const API_URL = 'https://api.example.com';", + ) + ], + ) + + await provider.post_review(123, review) + print("Review posted!") + + +async def example_gitlab(): + """Example: Using GitLab provider.""" + config = ProviderConfig( + provider_type=ProviderType.GITLAB, + gitlab_project="group/project", + gitlab_token="glpat-xxxxxxxxxxxxxxxxxxxx", + gitlab_instance_url="https://gitlab.com", + project_dir=Path.cwd(), + ) + + provider = create_provider(config) + + # Fetch a merge request (GitLab's term for PR) + mr = await provider.fetch_pr(456) + print(f"GitLab MR !{mr.number}: {mr.title}") + print(f"Author: {mr.author}") + print(f"State: {mr.state}") + print(f"Changed files: {mr.changed_files}") + + # Same review interface works! + from ..github.providers.protocol import ReviewData + + review = ReviewData( + pr_number=456, + event="approve", + body="LGTM! Approving.", + findings=[], + ) + + await provider.post_review(456, review) + print("Review posted and MR approved!") + + +async def example_provider_agnostic(provider_type: str, pr_number: int): + """ + Example: Provider-agnostic code that works with ANY provider. + + This demonstrates the power of the factory pattern - the same code + works regardless of which git host you're using. + """ + # Configuration would come from project settings + if provider_type == "github": + config = ProviderConfig( + provider_type=ProviderType.GITHUB, + github_repo="owner/repo", + ) + elif provider_type == "gitlab": + config = ProviderConfig( + provider_type=ProviderType.GITLAB, + gitlab_project="group/project", + gitlab_token="glpat-xxx", + ) + else: + raise ValueError(f"Unknown provider: {provider_type}") + + # Create provider using factory + provider = create_provider(config) + + # This code works for BOTH GitHub and GitLab! + pr = await provider.fetch_pr(pr_number) + diff = await provider.fetch_pr_diff(pr_number) + + print(f"Analyzing {provider.provider_type.value.upper()} PR #{pr.number}") + print(f"Title: {pr.title}") + print(f"Author: {pr.author}") + print(f"Files changed: {pr.changed_files}") + print(f"Lines: +{pr.additions} -{pr.deletions}") + print(f"\nDiff preview: {diff[:200]}...") + + # Perform automated review (same for both providers!) + from ..github.providers.protocol import ReviewData, ReviewFinding + + findings = [] + + # Check for large PRs + if pr.changed_files > 10: + findings.append( + ReviewFinding( + id="large-pr", + severity="info", + category="process", + title="Large PR", + description=f"This PR changes {pr.changed_files} files. " + "Consider breaking it into smaller PRs for easier review.", + ) + ) + + # Check for missing description + if not pr.body or len(pr.body) < 50: + findings.append( + ReviewFinding( + id="missing-description", + severity="low", + category="documentation", + title="PR description is too short", + description="Please add a detailed description explaining what this PR does and why.", + ) + ) + + if findings: + review = ReviewData( + pr_number=pr_number, + event="comment", + body="Automated review completed. Please address the findings below:", + findings=findings, + ) + await provider.post_review(pr_number, review) + print(f"\nPosted automated review with {len(findings)} findings") + else: + print("\nNo issues found!") + + +async def example_from_env_config(): + """Example: Creating provider from project env config.""" + from .factory import create_provider_from_env + + # This would typically come from reading project/.env + env_config = { + "githubRepo": "owner/repo", + "githubToken": "ghp_xxx", + # OR for GitLab: + # "gitlabProject": "group/project", + # "gitlabToken": "glpat-xxx", + # "gitlabInstanceUrl": "https://gitlab.com" + } + + provider = create_provider_from_env( + provider_type="github", # or "gitlab" + project_dir=Path.cwd(), + env_config=env_config, + ) + + pr = await provider.fetch_pr(789) + print(f"Fetched PR: {pr.title}") + + +if __name__ == "__main__": + # Run examples + print("=" * 60) + print("GitHub Example") + print("=" * 60) + # asyncio.run(example_github()) + + print("\n" + "=" * 60) + print("GitLab Example") + print("=" * 60) + # asyncio.run(example_gitlab()) + + print("\n" + "=" * 60) + print("Provider-Agnostic Example") + print("=" * 60) + # asyncio.run(example_provider_agnostic("github", 123)) + + print("\nExamples ready to run - uncomment the asyncio.run() calls above") diff --git a/apps/backend/runners/providers/factory.py b/apps/backend/runners/providers/factory.py new file mode 100644 index 0000000000..e8ad3a436b --- /dev/null +++ b/apps/backend/runners/providers/factory.py @@ -0,0 +1,179 @@ +""" +Provider Factory +================ + +Factory for creating git provider instances based on configuration. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..github.providers.github_provider import GitHubProvider +from ..github.providers.protocol import GitProvider, ProviderType +from ..gitlab.glab_client import GitLabConfig +from ..gitlab.providers.gitlab_provider import GitLabProvider + + +@dataclass +class ProviderConfig: + """ + Configuration for creating a git provider. + + The provider type determines which implementation to use. + Additional fields are provider-specific. + """ + + provider_type: ProviderType | str + project_dir: Path | str | None = None + + # GitHub-specific + github_repo: str | None = None + # NOTE: github_token is a placeholder for future API-based auth. + # Currently GitHubProvider uses gh CLI which handles its own authentication. + github_token: str | None = None + + # GitLab-specific + gitlab_project: str | None = None + gitlab_token: str | None = None + gitlab_instance_url: str = "https://gitlab.com" + + # Bitbucket-specific (future) + bitbucket_workspace: str | None = None + bitbucket_repo: str | None = None + bitbucket_token: str | None = None + + # Generic options + enable_rate_limiting: bool = True + + def __post_init__(self): + """Normalize provider type to enum.""" + if isinstance(self.provider_type, str): + self.provider_type = ProviderType(self.provider_type.lower()) + + +def create_provider(config: ProviderConfig) -> GitProvider: + """ + Factory function to create a git provider based on configuration. + + Args: + config: Provider configuration specifying type and credentials + + Returns: + Provider instance implementing GitProvider protocol + + Raises: + ValueError: If provider type is unsupported or required config is missing + + Examples: + # GitHub + config = ProviderConfig( + provider_type=ProviderType.GITHUB, + github_repo="owner/repo", + project_dir="/path/to/project" + ) + provider = create_provider(config) + + # GitLab + config = ProviderConfig( + provider_type=ProviderType.GITLAB, + gitlab_project="group/project", + gitlab_token="glpat-xxx", + gitlab_instance_url="https://gitlab.com" + ) + provider = create_provider(config) + """ + provider_type = config.provider_type + + if provider_type == ProviderType.GITHUB: + return _create_github_provider(config) + elif provider_type == ProviderType.GITLAB: + return _create_gitlab_provider(config) + elif provider_type == ProviderType.BITBUCKET: + raise NotImplementedError("Bitbucket provider not yet implemented") + elif provider_type == ProviderType.GITEA: + raise NotImplementedError("Gitea provider not yet implemented") + elif provider_type == ProviderType.AZURE_DEVOPS: + raise NotImplementedError("Azure DevOps provider not yet implemented") + else: + raise ValueError(f"Unsupported provider type: {provider_type}") + + +def _create_github_provider(config: ProviderConfig) -> GitHubProvider: + """Create a GitHub provider instance.""" + if not config.github_repo: + raise ValueError("github_repo is required for GitHub provider") + + return GitHubProvider( + _repo=config.github_repo, + _project_dir=str(config.project_dir) if config.project_dir else None, + enable_rate_limiting=config.enable_rate_limiting, + ) + + +def _create_gitlab_provider(config: ProviderConfig) -> GitLabProvider: + """Create a GitLab provider instance.""" + if not config.gitlab_project: + raise ValueError("gitlab_project is required for GitLab provider") + if not config.gitlab_token: + raise ValueError("gitlab_token is required for GitLab provider") + + gitlab_config = GitLabConfig( + token=config.gitlab_token, + project=config.gitlab_project, + instance_url=config.gitlab_instance_url, + ) + + return GitLabProvider( + _config=gitlab_config, + _project_dir=str(config.project_dir) if config.project_dir else None, + ) + + +def create_provider_from_env( + provider_type: str, + project_dir: Path, + env_config: dict[str, Any], +) -> GitProvider: + """ + Convenience function to create a provider from environment config. + + This is useful when you have a project's env config dict and want to + create the appropriate provider. + + Args: + provider_type: "github" or "gitlab" + project_dir: Project directory path + env_config: Environment config dict from project settings + + Returns: + Provider instance + + Example: + # env_config from project .env file: + env_config = { + "githubRepo": "owner/repo", + "githubToken": "ghp_xxx", + # ... or ... + "gitlabProject": "group/project", + "gitlabToken": "glpat-xxx", + "gitlabInstanceUrl": "https://gitlab.com" + } + + provider = create_provider_from_env("github", project_dir, env_config) + """ + config = ProviderConfig( + provider_type=provider_type, + project_dir=project_dir, + # GitHub + github_repo=env_config.get("githubRepo"), + github_token=env_config.get("githubToken"), + # GitLab + gitlab_project=env_config.get("gitlabProject"), + gitlab_token=env_config.get("gitlabToken"), + gitlab_instance_url=env_config.get("gitlabInstanceUrl", "https://gitlab.com"), + ) + + return create_provider(config) diff --git a/apps/backend/user_feedback.py b/apps/backend/user_feedback.py new file mode 100644 index 0000000000..c1cc7250e2 --- /dev/null +++ b/apps/backend/user_feedback.py @@ -0,0 +1,22 @@ +""" +User feedback management module stub. + +This module provides functions for managing user feedback on tasks. +Currently a stub implementation - returns empty results. +""" + +from typing import Any + + +def get_unread_feedback(spec_dir: str) -> list[tuple[int, dict[str, Any]]]: + """ + Get unread user feedback for a spec. + + Args: + spec_dir: Path to the spec directory + + Returns: + List of tuples (index, feedback_dict) for unread feedback. + Currently returns empty list as stub implementation. + """ + return [] diff --git a/apps/frontend/electron.vite.config.ts b/apps/frontend/electron.vite.config.ts index 6ceaa51fd5..8a88d806db 100644 --- a/apps/frontend/electron.vite.config.ts +++ b/apps/frontend/electron.vite.config.ts @@ -89,9 +89,11 @@ export default defineConfig({ '**/.worktrees/**', '**/.auto-claude/**', '**/out/**', + '**/.envs/**', // Conda environments - can have hundreds of files // Ignore the parent autonomous-coding directory's worktrees resolve(__dirname, '../.worktrees/**'), resolve(__dirname, '../.auto-claude/**'), + resolve(__dirname, '../.envs/**'), ] } } diff --git a/apps/frontend/src/main/__tests__/conda-project-structure.test.ts b/apps/frontend/src/main/__tests__/conda-project-structure.test.ts new file mode 100644 index 0000000000..8ba514d3e5 --- /dev/null +++ b/apps/frontend/src/main/__tests__/conda-project-structure.test.ts @@ -0,0 +1,578 @@ +/** + * Tests for conda-project-structure.ts + * + * Tests project structure detection with cross-platform support. + * Uses platform mocking and describe.each for OS matrix testing. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; + +// Mock fs module +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), +})); + +// Import mocked modules +import { existsSync, readdirSync, statSync } from 'fs'; + +// Import module under test after mocks are set up +import { + detectProjectStructure, + getPythonEnvPath, + getScriptsPath, + getWorkspaceFilePath, +} from '../conda-project-structure'; + +/** + * Platform configuration for parameterized tests + */ +interface PlatformConfig { + name: string; + platform: NodeJS.Platform; + pathSep: string; + projectRoot: string; +} + +/** + * Platform configurations for OS matrix testing + */ +const platforms: PlatformConfig[] = [ + { + name: 'Windows', + platform: 'win32', + pathSep: '\\', + projectRoot: 'C:\\Users\\test\\project', + }, + { + name: 'macOS', + platform: 'darwin', + pathSep: '/', + projectRoot: '/Users/test/project', + }, + { + name: 'Linux', + platform: 'linux', + pathSep: '/', + projectRoot: '/home/test/project', + }, +]; + +/** + * Normalize path to forward slashes for platform-agnostic comparison. + * This allows tests to use consistent path separators regardless of OS. + */ +function normalizePath(p: string): string { + return p.replace(/\\/g, '/'); +} + +/** + * Helper to check if current platform is Windows + */ +function isWindows(): boolean { + return process.platform === 'win32'; +} + +/** + * Helper to check if current platform is macOS + */ +function isMacOS(): boolean { + return process.platform === 'darwin'; +} + +/** + * Helper to check if current platform is Linux + */ +function isLinux(): boolean { + return process.platform === 'linux'; +} + +describe('conda-project-structure', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Restore original platform after each test + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + configurable: true, + }); + }); + + /** + * Helper to set the platform for a test + */ + function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + writable: true, + configurable: true, + }); + } + + /** + * Helper to create a file existence mock based on existing files array. + * All paths are normalized to forward slashes for platform-agnostic comparison. + */ + function mockFileSystem( + existingFiles: string[], + directories: string[] = [], + directoryContents: Record = {} + ): void { + (vi.mocked(existsSync) as any).mockImplementation((filePath: string) => { + const normalized = normalizePath(filePath.toString()); + return ( + existingFiles.some((f) => normalizePath(f) === normalized) || + directories.some((d) => normalizePath(d) === normalized) + ); + }); + + (vi.mocked(readdirSync) as any).mockImplementation((dirPath: string) => { + const normalized = normalizePath(dirPath.toString()); + return (directoryContents[normalized] || []) as any; + }); + + (vi.mocked(statSync) as any).mockImplementation((filePath: string) => { + const normalized = normalizePath(filePath.toString()); + return { + isDirectory: () => directories.some((d) => normalizePath(d) === normalized), + isFile: () => existingFiles.some((f) => normalizePath(f) === normalized), + } as any; + }); + } + + describe.each(platforms)('Platform: $name', ({ platform, projectRoot }) => { + beforeEach(() => { + setPlatform(platform); + }); + + describe('detectProjectStructure', () => { + it('should detect pure Python project with pyproject.toml at root', () => { + const pyprojectPath = path.join(projectRoot, 'pyproject.toml'); + + mockFileSystem([pyprojectPath], [], { + [normalizePath(projectRoot)]: ['pyproject.toml'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('pure-python'); + expect(result.pythonRoot).toBe(projectRoot); + expect(result.hasDotnet).toBe(false); + expect(result.pyprojectPath).toBe(pyprojectPath); + }); + + it('should detect pure Python project with requirements.txt at root', () => { + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem([requirementsPath], [], { + [normalizePath(projectRoot)]: ['requirements.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('pure-python'); + expect(result.pythonRoot).toBe(projectRoot); + expect(result.requirementsFiles).toContain(requirementsPath); + }); + + it('should detect pure Python project with setup.py at root', () => { + const setupPyPath = path.join(projectRoot, 'setup.py'); + + mockFileSystem([setupPyPath], [], { + [normalizePath(projectRoot)]: ['setup.py'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('pure-python'); + expect(result.pythonRoot).toBe(projectRoot); + }); + + it('should detect mixed project with .NET at root', () => { + const csprojPath = path.join(projectRoot, 'MyApp.csproj'); + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem([csprojPath, requirementsPath], [], { + [normalizePath(projectRoot)]: ['MyApp.csproj', 'requirements.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('mixed'); + expect(result.hasDotnet).toBe(true); + expect(result.pythonRoot).toBe(projectRoot); + }); + + it('should detect mixed project with .sln file', () => { + const slnPath = path.join(projectRoot, 'MySolution.sln'); + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem([slnPath, requirementsPath], [], { + [normalizePath(projectRoot)]: ['MySolution.sln', 'requirements.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('mixed'); + expect(result.hasDotnet).toBe(true); + }); + + it('should detect mixed project with src/python structure', () => { + const srcDir = path.join(projectRoot, 'src'); + const srcPythonDir = path.join(srcDir, 'python'); + const pyprojectPath = path.join(srcPythonDir, 'pyproject.toml'); + + mockFileSystem( + [pyprojectPath], + [srcDir, srcPythonDir], + { + [normalizePath(srcPythonDir)]: ['pyproject.toml'], + } + ); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('mixed'); + expect(result.pythonRoot).toBe(srcPythonDir); + expect(result.pyprojectPath).toBe(pyprojectPath); + }); + + it('should detect Node.js in hasOtherLanguages when package.json present with Python', () => { + // Note: When Python indicators are present at root, the project is still + // considered "pure-python" even with other languages. The other languages + // are tracked in hasOtherLanguages array. + const packageJsonPath = path.join(projectRoot, 'package.json'); + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem([packageJsonPath, requirementsPath], [], { + [normalizePath(projectRoot)]: ['package.json', 'requirements.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + // Project with Python at root is considered "pure-python" but tracks other languages + expect(result.type).toBe('pure-python'); + expect(result.hasOtherLanguages).toContain('node'); + }); + + it('should detect multiple requirements files', () => { + const reqPath = path.join(projectRoot, 'requirements.txt'); + const reqDevPath = path.join(projectRoot, 'requirements-dev.txt'); + + mockFileSystem([reqPath, reqDevPath], [], { + [normalizePath(projectRoot)]: ['requirements.txt', 'requirements-dev.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.requirementsFiles).toContain(reqPath); + expect(result.requirementsFiles).toContain(reqDevPath); + }); + + it('should detect requirements in requirements/ subdirectory', () => { + const pyprojectPath = path.join(projectRoot, 'pyproject.toml'); + const reqDir = path.join(projectRoot, 'requirements'); + const baseReqPath = path.join(reqDir, 'base.txt'); + const devReqPath = path.join(reqDir, 'dev.txt'); + + mockFileSystem( + [pyprojectPath, baseReqPath, devReqPath], + [reqDir], + { + [normalizePath(projectRoot)]: ['pyproject.toml', 'requirements'], + [normalizePath(reqDir)]: ['base.txt', 'dev.txt'], + } + ); + + const result = detectProjectStructure(projectRoot); + + expect(result.requirementsFiles).toContain(baseReqPath); + expect(result.requirementsFiles).toContain(devReqPath); + }); + + it('should detect .NET projects in src/ subdirectory', () => { + const srcDir = path.join(projectRoot, 'src'); + const appDir = path.join(srcDir, 'MyApp'); + const csprojPath = path.join(appDir, 'MyApp.csproj'); + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem( + [csprojPath, requirementsPath], + [srcDir, appDir], + { + [normalizePath(projectRoot)]: ['requirements.txt', 'src'], + [normalizePath(srcDir)]: ['MyApp'], + [normalizePath(appDir)]: ['MyApp.csproj'], + } + ); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('mixed'); + expect(result.hasDotnet).toBe(true); + }); + + it('should handle empty/non-existent project directory gracefully', () => { + mockFileSystem([], [], { + [normalizePath(projectRoot)]: [], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('pure-python'); + expect(result.pythonRoot).toBe(projectRoot); + expect(result.hasDotnet).toBe(false); + expect(result.hasOtherLanguages).toEqual([]); + }); + + it('should detect Go in hasOtherLanguages when go.mod present with Python', () => { + // Note: When Python indicators are present at root, the project is still + // considered "pure-python" even with other languages. + const goModPath = path.join(projectRoot, 'go.mod'); + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem([goModPath, requirementsPath], [], { + [normalizePath(projectRoot)]: ['go.mod', 'requirements.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('pure-python'); + expect(result.hasOtherLanguages).toContain('go'); + }); + + it('should detect Rust in hasOtherLanguages when Cargo.toml present with Python', () => { + // Note: When Python indicators are present at root, the project is still + // considered "pure-python" even with other languages. + const cargoPath = path.join(projectRoot, 'Cargo.toml'); + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem([cargoPath, requirementsPath], [], { + [normalizePath(projectRoot)]: ['Cargo.toml', 'requirements.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('pure-python'); + expect(result.hasOtherLanguages).toContain('rust'); + }); + + it('should detect Java in hasOtherLanguages when pom.xml present with Python', () => { + // Note: When Python indicators are present at root, the project is still + // considered "pure-python" even with other languages. + const pomPath = path.join(projectRoot, 'pom.xml'); + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem([pomPath, requirementsPath], [], { + [normalizePath(projectRoot)]: ['pom.xml', 'requirements.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('pure-python'); + expect(result.hasOtherLanguages).toContain('java'); + }); + + it('should detect Ruby in hasOtherLanguages when Gemfile present with Python', () => { + // Note: When Python indicators are present at root, the project is still + // considered "pure-python" even with other languages. + const gemfilePath = path.join(projectRoot, 'Gemfile'); + const requirementsPath = path.join(projectRoot, 'requirements.txt'); + + mockFileSystem([gemfilePath, requirementsPath], [], { + [normalizePath(projectRoot)]: ['Gemfile', 'requirements.txt'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('pure-python'); + expect(result.hasOtherLanguages).toContain('ruby'); + }); + + it('should detect mixed project when only other language (no Python) is present', () => { + // When no Python indicators are present at root, but other languages are, + // it should be marked as "mixed" + const packageJsonPath = path.join(projectRoot, 'package.json'); + + mockFileSystem([packageJsonPath], [], { + [normalizePath(projectRoot)]: ['package.json'], + }); + + const result = detectProjectStructure(projectRoot); + + expect(result.type).toBe('mixed'); + expect(result.hasOtherLanguages).toContain('node'); + }); + }); + + describe('getPythonEnvPath', () => { + it('should return correct env path for pure Python project', () => { + const pyprojectPath = path.join(projectRoot, 'pyproject.toml'); + + mockFileSystem([pyprojectPath], [], { + [normalizePath(projectRoot)]: ['pyproject.toml'], + }); + + const result = getPythonEnvPath(projectRoot, 'myproject'); + + expect(result).toBe(path.join(projectRoot, '.envs', 'myproject')); + }); + + it('should return correct env path for mixed project with src/python', () => { + const srcDir = path.join(projectRoot, 'src'); + const srcPythonDir = path.join(srcDir, 'python'); + const pyprojectPath = path.join(srcPythonDir, 'pyproject.toml'); + + mockFileSystem( + [pyprojectPath], + [srcDir, srcPythonDir], + { + [normalizePath(srcPythonDir)]: ['pyproject.toml'], + } + ); + + const result = getPythonEnvPath(projectRoot, 'myproject'); + + expect(result).toBe(path.join(srcPythonDir, '.envs', 'myproject')); + }); + }); + + describe('getScriptsPath', () => { + it('should return correct scripts path for pure Python project', () => { + const pyprojectPath = path.join(projectRoot, 'pyproject.toml'); + + mockFileSystem([pyprojectPath], [], { + [normalizePath(projectRoot)]: ['pyproject.toml'], + }); + + const result = getScriptsPath(projectRoot); + + expect(result).toBe(path.join(projectRoot, '.envs', 'scripts')); + }); + + it('should return correct scripts path for mixed project with src/python', () => { + const srcDir = path.join(projectRoot, 'src'); + const srcPythonDir = path.join(srcDir, 'python'); + const pyprojectPath = path.join(srcPythonDir, 'pyproject.toml'); + + mockFileSystem( + [pyprojectPath], + [srcDir, srcPythonDir], + { + [normalizePath(srcPythonDir)]: ['pyproject.toml'], + } + ); + + const result = getScriptsPath(projectRoot); + + expect(result).toBe(path.join(srcPythonDir, '.envs', 'scripts')); + }); + }); + + describe('getWorkspaceFilePath', () => { + it('should return correct workspace path for pure Python project', () => { + const pyprojectPath = path.join(projectRoot, 'pyproject.toml'); + + mockFileSystem([pyprojectPath], [], { + [normalizePath(projectRoot)]: ['pyproject.toml'], + }); + + const result = getWorkspaceFilePath(projectRoot, 'myproject'); + + expect(result).toBe(path.join(projectRoot, 'myproject.code-workspace')); + }); + + it('should return correct workspace path for mixed project with src/python', () => { + const srcDir = path.join(projectRoot, 'src'); + const srcPythonDir = path.join(srcDir, 'python'); + const pyprojectPath = path.join(srcPythonDir, 'pyproject.toml'); + + mockFileSystem( + [pyprojectPath], + [srcDir, srcPythonDir], + { + [normalizePath(srcPythonDir)]: ['pyproject.toml'], + } + ); + + const result = getWorkspaceFilePath(projectRoot, 'myproject'); + + expect(result).toBe(path.join(srcPythonDir, 'myproject.code-workspace')); + }); + }); + }); + + describe('Platform helper functions', () => { + describe('isWindows', () => { + it('should return true on Windows', () => { + setPlatform('win32'); + expect(isWindows()).toBe(true); + expect(isMacOS()).toBe(false); + expect(isLinux()).toBe(false); + }); + }); + + describe('isMacOS', () => { + it('should return true on macOS', () => { + setPlatform('darwin'); + expect(isWindows()).toBe(false); + expect(isMacOS()).toBe(true); + expect(isLinux()).toBe(false); + }); + }); + + describe('isLinux', () => { + it('should return true on Linux', () => { + setPlatform('linux'); + expect(isWindows()).toBe(false); + expect(isMacOS()).toBe(false); + expect(isLinux()).toBe(true); + }); + }); + }); + + describe('Path handling edge cases', () => { + describe.each(platforms)('Platform: $name', ({ platform, projectRoot }) => { + beforeEach(() => { + setPlatform(platform); + }); + + it('should handle paths with spaces', () => { + const projectWithSpaces = platform === 'win32' + ? 'C:\\Users\\My User\\My Project' + : '/home/my user/my project'; + const pyprojectPath = path.join(projectWithSpaces, 'pyproject.toml'); + + mockFileSystem([pyprojectPath], [], { + [normalizePath(projectWithSpaces)]: ['pyproject.toml'], + }); + + const result = detectProjectStructure(projectWithSpaces); + + expect(result.pythonRoot).toBe(projectWithSpaces); + }); + + it('should handle special characters in paths', () => { + const projectWithSpecial = platform === 'win32' + ? 'C:\\Users\\test\\project-with-dashes_and_underscores' + : '/home/test/project-with-dashes_and_underscores'; + const pyprojectPath = path.join(projectWithSpecial, 'pyproject.toml'); + + mockFileSystem([pyprojectPath], [], { + [normalizePath(projectWithSpecial)]: ['pyproject.toml'], + }); + + const result = detectProjectStructure(projectWithSpecial); + + expect(result.pythonRoot).toBe(projectWithSpecial); + }); + }); + }); +}); diff --git a/apps/frontend/src/main/agent/agent-events.ts b/apps/frontend/src/main/agent/agent-events.ts index 99dd9d6b9f..980bcdc9a9 100644 --- a/apps/frontend/src/main/agent/agent-events.ts +++ b/apps/frontend/src/main/agent/agent-events.ts @@ -172,10 +172,37 @@ export class AgentEvents { /** * Parse roadmap progress from log output */ - parseRoadmapProgress(log: string, currentPhase: string, currentProgress: number): { phase: string; progress: number } { + parseRoadmapProgress(log: string, currentPhase: string, currentProgress: number): { phase: string; progress: number; message?: string } { let phase = currentPhase; let progress = currentProgress; + let message: string | undefined; + + // Check for granular progress markers from discovery agent + // Supports both "[ROADMAP_PROGRESS] 50 message" and "[ROADMAP_PROGRESS] 50" (no message) + const progressMatch = log.match(/\[ROADMAP_PROGRESS\]\s+(\d+)(?:\s+(.*))?/); + if (progressMatch) { + const newProgress = parseInt(progressMatch[1], 10); + const progressMessage = progressMatch[2]?.trim() || 'Processing...'; + + // Only update if progress is moving forward + if (newProgress > currentProgress) { + progress = newProgress; + message = progressMessage; + + // Update phase based on progress value + if (newProgress >= 40 && newProgress < 70) { + phase = 'discovering'; + } else if (newProgress >= 70 && newProgress < 100) { + phase = 'generating'; + } else if (newProgress >= 100) { + phase = 'complete'; + } + } + + return { phase, progress, message }; + } + // Phase transition markers (coarser-grained) if (log.includes('PROJECT ANALYSIS')) { phase = 'analyzing'; progress = 20; diff --git a/apps/frontend/src/main/agent/agent-manager.ts b/apps/frontend/src/main/agent/agent-manager.ts index 7ce8790954..62280f069b 100644 --- a/apps/frontend/src/main/agent/agent-manager.ts +++ b/apps/frontend/src/main/agent/agent-manager.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'events'; import path from 'path'; -import { existsSync } from 'fs'; +import os from 'os'; +import { existsSync, writeFileSync, unlinkSync } from 'fs'; import { AgentState } from './agent-state'; import { AgentEvents } from './agent-events'; import { AgentProcessManager } from './agent-process'; @@ -135,7 +136,10 @@ export class AgentManager extends EventEmitter { const combinedEnv = this.processManager.getCombinedEnv(projectPath); // spec_runner.py will auto-start run.py after spec creation completes - const args = [specRunnerPath, '--task', taskDescription, '--project-dir', projectPath]; + // Write task description to a temp file to avoid command-line quoting issues + const tempTaskFile = path.join(os.tmpdir(), `task-${Date.now()}-${Math.random().toString(36).substring(7)}.txt`); + writeFileSync(tempTaskFile, taskDescription, 'utf-8'); + const args = [specRunnerPath, '--task-file', tempTaskFile, '--project-dir', projectPath]; // Pass spec directory if provided (for UI-created tasks that already have a directory) if (specDir) { @@ -177,6 +181,23 @@ export class AgentManager extends EventEmitter { // Note: This is spec-creation but it chains to task-execution via run.py await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); + + // Clean up temp file when process exits or errors + const cleanupTempFile = () => { + try { + if (existsSync(tempTaskFile)) { + unlinkSync(tempTaskFile); + } + } catch { + // Ignore cleanup errors + } + }; + this.once('exit', (exitTaskId: string) => { + if (exitTaskId === taskId) cleanupTempFile(); + }); + this.once('error', (errorTaskId: string) => { + if (errorTaskId === taskId) cleanupTempFile(); + }); } /** diff --git a/apps/frontend/src/main/agent/agent-process.test.ts b/apps/frontend/src/main/agent/agent-process.test.ts index 82ff736886..976450465b 100644 --- a/apps/frontend/src/main/agent/agent-process.test.ts +++ b/apps/frontend/src/main/agent/agent-process.test.ts @@ -46,6 +46,36 @@ vi.mock('child_process', async (importOriginal) => { }; }); +// Mock fs module to provide realistic test data for readFileSync calls +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn((filePath: string) => { + // Return true for .env files and activation scripts in test scenarios + if (typeof filePath === 'string' && (filePath.endsWith('.env') || filePath.includes('terminal.cmd'))) { + return false; // Most tests don't need .env files + } + return actual.existsSync(filePath); + }), + readFileSync: vi.fn((filePath: unknown, encoding?: unknown) => { + // Provide realistic mock data for different file types + if (typeof filePath === 'string') { + if (filePath.endsWith('.env')) { + // Return realistic .env content instead of empty string + return '# Test environment file\nTEST_VAR=test_value\nANOTHER_VAR=another_value\n'; + } + if (filePath.includes('terminal.cmd')) { + // Return realistic conda terminal.cmd content + return '@echo off\ncall %USERPROFILE%\\miniconda3\\condabin\\conda.bat activate "myenv"\ncmd /k\n'; + } + } + // Fall back to actual implementation for other files + return actual.readFileSync(filePath as string, encoding as BufferEncoding); + }) + }; +}); + // Mock project-initializer to avoid child_process.execSync issues vi.mock('../project-initializer', () => ({ getAutoBuildPath: vi.fn(() => '/fake/auto-build'), @@ -92,7 +122,8 @@ vi.mock('../rate-limit-detector', () => ({ vi.mock('../python-detector', () => ({ findPythonCommand: vi.fn(() => 'python'), - parsePythonCommand: vi.fn(() => ['python', []]) + parsePythonCommand: vi.fn(() => ['python', []]), + validatePythonPath: vi.fn((path: string) => ({ valid: true, sanitizedPath: path })) })); // Mock python-env-manager for ensurePythonEnvReady tests diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 807d882b0e..b7dfab1ed5 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -16,8 +16,9 @@ import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailu import { getAPIProfileEnv } from '../services/profile'; import { projectStore } from '../project-store'; import { getClaudeProfileManager } from '../claude-profile-manager'; -import { parsePythonCommand, validatePythonPath } from '../python-detector'; +import { parsePythonCommand, validatePythonPath, isValidActivationScript } from '../python-detector'; import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager'; +import { isWindows } from '../python-path-utils'; import { buildMemoryEnvVars } from '../memory-env-builder'; import { readSettingsFile } from '../settings-utils'; import type { AppSettings } from '../../shared/types/settings'; @@ -26,8 +27,72 @@ import { getAugmentedEnv } from '../env-utils'; import { getToolInfo } from '../cli-tool-manager'; +/** + * Sanitize and validate shell/command interpreter paths from environment variables. + * Prevents command injection through malicious COMSPEC or SHELL values. + */ +function sanitizeShellPath(envValue: string | undefined, defaultValue: string): string { + if (!envValue) { + return defaultValue; + } + + // Normalize path separators for comparison + const normalized = envValue.trim(); + + // Reject empty or whitespace-only values + if (!normalized) { + return defaultValue; + } + + // Reject paths containing command injection characters + // These characters could be used to chain commands or inject arguments + const dangerousChars = /[;&|`$<>(){}[\]!*?~#]/; + if (dangerousChars.test(normalized)) { + console.warn('[AgentProcess] Rejected shell path with dangerous characters:', normalized); + return defaultValue; + } + + // Reject paths with newlines or carriage returns (command injection) + if (/[\r\n]/.test(normalized)) { + console.warn('[AgentProcess] Rejected shell path with newline characters'); + return defaultValue; + } + + // For Windows COMSPEC, validate it looks like a valid Windows executable path + if (isWindows()) { + // Must end with .exe, .cmd, or .bat (case-insensitive) + if (!/\.(exe|cmd|bat)$/i.test(normalized)) { + console.warn('[AgentProcess] Rejected COMSPEC - does not end with valid extension:', normalized); + return defaultValue; + } + + // Should look like a Windows path (drive letter or UNC path) + // Allow: C:\Windows\system32\cmd.exe, \\server\share\cmd.exe + if (!/^([a-zA-Z]:\\|\\\\)/.test(normalized)) { + console.warn('[AgentProcess] Rejected COMSPEC - invalid Windows path format:', normalized); + return defaultValue; + } + } else { + // For Unix SHELL, validate it looks like a valid Unix path + // Must start with / (absolute path) + if (!normalized.startsWith('/')) { + console.warn('[AgentProcess] Rejected SHELL - not an absolute path:', normalized); + return defaultValue; + } + + // Should not contain double slashes (except at start for some edge cases) + // and should look like a reasonable shell path + if (/\/\//.test(normalized.substring(1))) { + console.warn('[AgentProcess] Rejected SHELL - contains double slashes:', normalized); + return defaultValue; + } + } + + return normalized; +} + function deriveGitBashPath(gitExePath: string): string | null { - if (process.platform !== 'win32') { + if (!isWindows()) { return null; } @@ -125,7 +190,7 @@ export class AgentProcessManager { // On Windows, detect and pass git-bash path for Claude Code CLI // Electron can detect git via where.exe, but Python subprocess may not have the same PATH const gitBashEnv: Record = {}; - if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) { + if (isWindows() && !process.env.CLAUDE_CODE_GIT_BASH_PATH) { try { const gitInfo = getToolInfo('git'); if (gitInfo.found && gitInfo.path) { @@ -484,14 +549,28 @@ export class AgentProcessManager { // Parse Python commandto handle space-separated commands like "py -3" const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath()); - const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], { + + // Note: Conda activation via pythonActivationScript is not currently in AppSettings + // When this feature is added, uncomment and update the activation script logic below + // For now, use Python directly without activation script + // Add -u flag for unbuffered output (critical for subprocess communication) + const finalCommand = pythonCommand; + const finalArgs = [...pythonBaseArgs, '-u', ...args]; + + // Debug: Log the exact command being spawned + console.warn('[AgentProcess] Spawning command:', finalCommand); + console.warn('[AgentProcess] With args:', JSON.stringify(finalArgs)); + console.warn('[AgentProcess] CWD:', cwd); + + const childProcess = spawn(finalCommand, finalArgs, { cwd, env: { ...env, // Already includes process.env, extraEnv, profileEnv, PYTHONUNBUFFERED, PYTHONUTF8 ...pythonEnv, // Include Python environment (PYTHONPATH for bundled packages) ...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode ...apiProfileEnv // Include active API profile config (highest priority for ANTHROPIC_* vars) - } + }, + ...(isWindows() && { windowsHide: true }) }); this.state.addProcess(taskId, { diff --git a/apps/frontend/src/main/agent/agent-queue.ts b/apps/frontend/src/main/agent/agent-queue.ts index 279ecc2b70..7e8ae9e549 100644 --- a/apps/frontend/src/main/agent/agent-queue.ts +++ b/apps/frontend/src/main/agent/agent-queue.ts @@ -14,6 +14,7 @@ import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { stripAnsiCodes } from '../../shared/utils/ansi-sanitizer'; import { parsePythonCommand } from '../python-detector'; import { pythonEnvManager } from '../python-env-manager'; +import { isWindows, getPathDelimiter } from '../python-path-utils'; import { transformIdeaFromSnakeCase, transformSessionFromSnakeCase } from '../ipc-handlers/ideation/transformers'; import { transformRoadmapFromSnakeCase } from '../ipc-handlers/roadmap/transformers'; import type { RawIdea } from '../ipc-handlers/ideation/types'; @@ -21,6 +22,9 @@ import type { RawIdea } from '../ipc-handlers/ideation/types'; /** Maximum length for status messages displayed in progress UI */ const STATUS_MESSAGE_MAX_LENGTH = 200; +/** Increment for tool-call based micro-progress (per tool call) */ +const TOOL_PROGRESS_INCREMENT = 2; + /** * Formats a raw log line for display as a status message. * Strips ANSI escape codes, extracts the first line, and truncates to max length. @@ -280,7 +284,7 @@ export class AgentQueueManager { if (autoBuildSource) { pythonPathParts.push(autoBuildSource); } - const combinedPythonPath = pythonPathParts.join(process.platform === 'win32' ? ';' : ':'); + const combinedPythonPath = pythonPathParts.join(getPathDelimiter()); // Build final environment with proper precedence: // 1. process.env (system) @@ -316,7 +320,8 @@ export class AgentQueueManager { const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath); const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], { cwd, - env: finalEnv + env: finalEnv, + ...(isWindows() && { windowsHide: true }) }); this.state.addProcess(projectId, { @@ -607,7 +612,7 @@ export class AgentQueueManager { if (autoBuildSource) { pythonPathParts.push(autoBuildSource); } - const combinedPythonPath = pythonPathParts.join(process.platform === 'win32' ? ';' : ':'); + const combinedPythonPath = pythonPathParts.join(getPathDelimiter()); // Build final environment with proper precedence: // 1. process.env (system) @@ -643,7 +648,8 @@ export class AgentQueueManager { const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath); const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], { cwd, - env: finalEnv + env: finalEnv, + ...(isWindows() && { windowsHide: true }) }); this.state.addProcess(projectId, { @@ -658,6 +664,8 @@ export class AgentQueueManager { // Track progress through output let progressPhase = 'analyzing'; let progressPercent = 10; + // Track tool calls for micro-progress + let toolCallCount = 0; // Collect output for rate limit detection let allRoadmapOutput = ''; @@ -683,15 +691,58 @@ export class AgentQueueManager { // Parse progress using AgentEvents const progressUpdate = this.events.parseRoadmapProgress(log, progressPhase, progressPercent); - progressPhase = progressUpdate.phase; - progressPercent = progressUpdate.progress; - // Emit progress update - this.emitter.emit('roadmap-progress', projectId, { - phase: progressPhase, - progress: progressPercent, - message: formatStatusMessage(log) - }); + // Track tool usage for micro-progress + const isToolCall = log.includes('[Tool:') && !log.includes('Error in hook'); + const isHookError = log.includes('Error in hook callback'); + + if (isToolCall && progressPhase === 'discovering' && progressPercent >= 40 && progressPercent < 70) { + // Discovery phase: 40% -> 67% based on tool usage + toolCallCount++; + const toolProgress = Math.min(67, 40 + Math.floor(toolCallCount * TOOL_PROGRESS_INCREMENT)); + if (toolProgress > progressPercent) { + progressPercent = toolProgress; + this.emitter.emit('roadmap-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: `Analyzing project (${toolCallCount} operations)...` + }); + } + } else if (isToolCall && progressPhase === 'generating' && progressPercent >= 70 && progressPercent < 100) { + // Feature generation phase: 70% -> 97% based on tool usage + toolCallCount++; + const toolProgress = Math.min(97, 70 + Math.floor(toolCallCount * TOOL_PROGRESS_INCREMENT)); + if (toolProgress > progressPercent) { + progressPercent = toolProgress; + this.emitter.emit('roadmap-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: `Generating features (${toolCallCount} operations)...` + }); + } + } + + // Only emit progress update if phase or progress actually changed (and not a hook error) + const hasProgressChanged = progressUpdate.phase !== progressPhase || progressUpdate.progress !== progressPercent; + + if (hasProgressChanged && !isHookError) { + // Reset tool count on phase change + if (progressUpdate.phase !== progressPhase) { + toolCallCount = 0; + } + + progressPhase = progressUpdate.phase; + // Avoid regressing progress after tool-driven bumps - only update if new progress > current + if (progressUpdate.progress > progressPercent) { + progressPercent = progressUpdate.progress; + } + + this.emitter.emit('roadmap-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: progressUpdate.message || log.trim().substring(0, 200) // Use parsed message if available + }); + } }); // Handle stderr - explicitly decode as UTF-8 diff --git a/apps/frontend/src/main/changelog/generator.ts b/apps/frontend/src/main/changelog/generator.ts index 6fa75c06fb..bd2af8a67b 100644 --- a/apps/frontend/src/main/changelog/generator.ts +++ b/apps/frontend/src/main/changelog/generator.ts @@ -143,9 +143,11 @@ export class ChangelogGenerator extends EventEmitter { // Parse Python command to handle space-separated commands like "py -3" const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath); + const isWindows = process.platform === 'win32'; const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], { cwd: this.autoBuildSourcePath, - env: spawnEnv + env: spawnEnv, + ...(isWindows && { windowsHide: true }) }); this.generationProcesses.set(projectId, childProcess); diff --git a/apps/frontend/src/main/changelog/version-suggester.ts b/apps/frontend/src/main/changelog/version-suggester.ts index 6d4a9b9126..73a0b22d58 100644 --- a/apps/frontend/src/main/changelog/version-suggester.ts +++ b/apps/frontend/src/main/changelog/version-suggester.ts @@ -4,6 +4,7 @@ import type { GitCommit } from '../../shared/types'; import { getProfileEnv } from '../rate-limit-detector'; import { parsePythonCommand } from '../python-detector'; import { getAugmentedEnv } from '../env-utils'; +import { isWindows } from '../python-path-utils'; interface VersionSuggestion { version: string; @@ -57,7 +58,8 @@ export class VersionSuggester { const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath); const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], { cwd: this.autoBuildSourcePath, - env: spawnEnv + env: spawnEnv, + ...(isWindows() ? { windowsHide: true } : {}) }); let output = ''; @@ -213,7 +215,7 @@ except Exception as e: */ private buildSpawnEnvironment(): Record { const homeDir = os.homedir(); - const isWindows = process.platform === 'win32'; + const windowsEnv = isWindows(); // Use getAugmentedEnv() to ensure common tool paths are available // even when app is launched from Finder/Dock @@ -226,7 +228,7 @@ except Exception as e: ...augmentedEnv, ...profileEnv, // Ensure critical env vars are set for claude CLI - ...(isWindows ? { USERPROFILE: homeDir } : { HOME: homeDir }), + ...(windowsEnv ? { USERPROFILE: homeDir } : { HOME: homeDir }), USER: process.env.USER || process.env.USERNAME || 'user', PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', diff --git a/apps/frontend/src/main/claude-cli-utils.ts b/apps/frontend/src/main/claude-cli-utils.ts index 49a0c49c71..eb38ce31ab 100644 --- a/apps/frontend/src/main/claude-cli-utils.ts +++ b/apps/frontend/src/main/claude-cli-utils.ts @@ -1,23 +1,31 @@ import path from 'path'; import { getAugmentedEnv, getAugmentedEnvAsync } from './env-utils'; import { getToolPath, getToolPathAsync } from './cli-tool-manager'; +import { isWindows, getPathDelimiter } from './python-path-utils'; export type ClaudeCliInvocation = { command: string; env: Record; + /** True if PATH was modified to include the CLI directory (wasn't already in PATH) */ + pathWasModified: boolean; }; -function ensureCommandDirInPath(command: string, env: Record): Record { +type PathCheckResult = { + env: Record; + wasModified: boolean; +}; + +function ensureCommandDirInPath(command: string, env: Record): PathCheckResult { if (!path.isAbsolute(command)) { - return env; + return { env, wasModified: false }; } - const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const pathSeparator = getPathDelimiter(); const commandDir = path.dirname(command); const currentPath = env.PATH || ''; const pathEntries = currentPath.split(pathSeparator); const normalizedCommandDir = path.normalize(commandDir); - const hasCommandDir = process.platform === 'win32' + const hasCommandDir = isWindows() ? pathEntries .map((entry) => path.normalize(entry).toLowerCase()) .includes(normalizedCommandDir.toLowerCase()) @@ -26,12 +34,17 @@ function ensureCommandDirInPath(command: string, env: Record): R .includes(normalizedCommandDir); if (hasCommandDir) { - return env; + // Command dir already in PATH - no modification needed + return { env, wasModified: false }; } + // Need to add command dir to PATH return { - ...env, - PATH: [commandDir, currentPath].filter(Boolean).join(pathSeparator), + env: { + ...env, + PATH: [commandDir, currentPath].filter(Boolean).join(pathSeparator), + }, + wasModified: true, }; } @@ -44,10 +57,12 @@ function ensureCommandDirInPath(command: string, env: Record): R export function getClaudeCliInvocation(): ClaudeCliInvocation { const command = getToolPath('claude'); const env = getAugmentedEnv(); + const { env: updatedEnv, wasModified } = ensureCommandDirInPath(command, env); return { command, - env: ensureCommandDirInPath(command, env), + env: updatedEnv, + pathWasModified: wasModified, }; } @@ -69,9 +84,11 @@ export async function getClaudeCliInvocationAsync(): Promise = { + win32: [ + path.join(os.homedir(), 'miniconda3'), + path.join(os.homedir(), 'Miniconda3'), + 'C:\\miniconda3', + 'C:\\Miniconda3', + path.join(os.homedir(), 'anaconda3'), + path.join(os.homedir(), 'Anaconda3'), + 'C:\\anaconda3', + 'C:\\Anaconda3', + 'C:\\ProgramData\\miniconda3', + 'C:\\ProgramData\\Anaconda3', + path.join(os.homedir(), 'mambaforge'), + path.join(os.homedir(), 'miniforge3'), + // Only include LOCALAPPDATA path if the env var is defined (prevents relative path search) + ...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'miniconda3')] : []), + ], + darwin: [ + path.join(os.homedir(), 'miniconda3'), + path.join(os.homedir(), 'anaconda3'), + path.join(os.homedir(), 'mambaforge'), + path.join(os.homedir(), 'miniforge3'), + '/opt/miniconda3', + '/opt/anaconda3', + '/opt/homebrew/Caskroom/miniconda/base', + '/usr/local/Caskroom/miniconda/base', + ], + linux: [ + path.join(os.homedir(), 'miniconda3'), + path.join(os.homedir(), 'anaconda3'), + path.join(os.homedir(), 'mambaforge'), + path.join(os.homedir(), 'miniforge3'), + '/opt/conda', + '/opt/miniconda3', + '/opt/anaconda3', + ], +}; + +/** + * Cache for detection results + * Includes timestamp for potential TTL-based invalidation + */ +let detectionCache: CondaDetectionResult | null = null; + +/** + * Get the path to the Conda executable within an installation directory + * + * @param condaPath - Base path of Conda installation + * @returns Full path to conda executable + */ +function getCondaExecutablePath(condaPath: string): string { + if (isWindows()) { + return path.join(condaPath, 'Scripts', 'conda.exe'); + } + return path.join(condaPath, 'bin', 'conda'); +} + +/** + * Determine the type of Conda installation from its path + * + * Analyzes the installation path to identify whether it's + * Miniconda, Anaconda, or Mambaforge. + * + * @param condaPath - Path to the Conda installation + * @returns The type of Conda installation + */ +export function determineCondaType(condaPath: string): CondaDistributionType { + const lowerPath = condaPath.toLowerCase(); + + if (lowerPath.includes('mambaforge')) { + return 'mambaforge'; + } + if (lowerPath.includes('miniforge')) { + return 'miniforge'; + } + if (lowerPath.includes('miniconda')) { + return 'miniconda'; + } + if (lowerPath.includes('anaconda')) { + return 'anaconda'; + } + + return 'unknown'; +} + +/** + * Get the version of a Conda executable + * + * Runs `conda --version` and parses the output. + * + * @param condaExe - Path to the conda executable + * @returns Version string (e.g., "24.1.2") or null if unable to determine + */ +export function getCondaVersion(condaExe: string): string | null { + try { + const output = execFileSync(condaExe, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }).trim(); + + // Output format: "conda 24.1.2" + const match = output.match(/conda\s+(\d+\.\d+\.\d+)/i); + return match ? match[1] : null; + } catch { + return null; + } +} + +/** + * Get the version of a Conda executable asynchronously + * + * Non-blocking version of getCondaVersion for use in async contexts. + * + * @param condaExe - Path to the conda executable + * @returns Promise resolving to version string or null + */ +async function getCondaVersionAsync(condaExe: string): Promise { + try { + const { stdout } = await execFileAsync(condaExe, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }); + + const output = stdout.trim(); + const match = output.match(/conda\s+(\d+\.\d+\.\d+)/i); + return match ? match[1] : null; + } catch { + return null; + } +} + +/** + * Validate a specific Conda path and return installation info (sync) + * + * Checks if the path exists, has a valid conda executable, + * and can report its version. Blocking version for contexts where + * async is not available. + * + * @param condaPath - Path to potential Conda installation + * @returns CondaInstallation object if valid, null otherwise + */ +function validateCondaPathSync(condaPath: string): CondaInstallation | null { + if (!existsSync(condaPath)) { + return null; + } + + const executablePath = getCondaExecutablePath(condaPath); + if (!existsSync(executablePath)) { + return null; + } + + const version = getCondaVersion(executablePath); + if (!version) { + return null; + } + + return { + path: condaPath, + condaExe: executablePath, + executablePath, + version, + type: determineCondaType(condaPath), + }; +} + +/** + * Validate a specific Conda path and return installation info + * + * Checks if the path exists, has a valid conda executable, + * and can report its version. + * + * @param condaPath - Path to potential Conda installation + * @returns Promise resolving to CondaInstallation or null + */ +export async function validateCondaPath( + condaPath: string +): Promise { + try { + await fsPromises.access(condaPath); + } catch { + return null; + } + + const executablePath = getCondaExecutablePath(condaPath); + try { + await fsPromises.access(executablePath); + } catch { + return null; + } + + const version = await getCondaVersionAsync(executablePath); + if (!version) { + return null; + } + + return { + path: condaPath, + condaExe: executablePath, + executablePath, + version, + type: determineCondaType(condaPath), + }; +} + +/** + * Get the platform key for CONDA_SEARCH_PATHS lookup. + * Uses platform abstraction instead of direct process.platform access. + */ +function getPlatformKey(): string { + if (isWindows()) { + return 'win32'; + } + // For non-Windows, use process.platform directly (darwin, linux, etc.) + return process.platform; +} + +/** + * Get the search paths for Conda installations, filtering out empty paths + */ +function getValidSearchPaths(): string[] { + const searchPaths = CONDA_SEARCH_PATHS[getPlatformKey()] || []; + return searchPaths.filter((p) => p && p.length > 0); +} + +/** + * Build the detection result and update cache + */ +function buildDetectionResult(installations: CondaInstallation[]): CondaDetectionResult { + const result: CondaDetectionResult = { + found: installations.length > 0, + installations, + preferred: installations.length > 0 ? installations[0] : null, + timestamp: Date.now(), + }; + + detectionCache = result; + + if (result.found) { + console.warn( + `[Conda] Detection complete: ${installations.length} installation(s) found` + ); + } else { + console.warn('[Conda] Detection complete: No Conda installations found'); + } + + return result; +} + +/** + * Log a found installation + */ +function logFoundInstallation(installation: CondaInstallation): void { + console.warn( + `[Conda] Found ${installation.type} at ${installation.path} (v${installation.version})` + ); +} + +/** + * Detect all Conda installations on the system + * + * Searches OS-specific paths for Conda installations and validates each one. + * Results are cached for the session to avoid repeated filesystem scans. + * + * @param forceRefresh - If true, bypasses cache and performs fresh detection + * @returns Promise resolving to CondaDetectionResult + */ +export async function detectCondaInstallations( + forceRefresh = false +): Promise { + if (!forceRefresh && detectionCache) { + console.warn( + `[Conda] Using cached detection result (${detectionCache.installations.length} installations)` + ); + return detectionCache; + } + + console.warn('[Conda] Detecting Conda installations...'); + + const installations: CondaInstallation[] = []; + + for (const condaPath of getValidSearchPaths()) { + const installation = await validateCondaPath(condaPath); + if (installation) { + logFoundInstallation(installation); + installations.push(installation); + } + } + + return buildDetectionResult(installations); +} + +/** + * Detect Conda installations synchronously + * + * Synchronous version of detectCondaInstallations for use in contexts + * where async is not available. Uses cache if available. + * + * @param forceRefresh - If true, bypasses cache and performs fresh detection + * @returns CondaDetectionResult + */ +export function detectCondaInstallationsSync( + forceRefresh = false +): CondaDetectionResult { + if (!forceRefresh && detectionCache) { + console.warn( + `[Conda] Using cached detection result (${detectionCache.installations.length} installations)` + ); + return detectionCache; + } + + console.warn('[Conda] Detecting Conda installations (sync)...'); + + const installations: CondaInstallation[] = []; + + for (const condaPath of getValidSearchPaths()) { + const installation = validateCondaPathSync(condaPath); + if (installation) { + logFoundInstallation(installation); + installations.push(installation); + } + } + + return buildDetectionResult(installations); +} + +/** + * Clear the Conda detection cache + * + * Call this if Conda installations may have changed (e.g., after user + * installs or removes Conda). + */ +export function clearCondaCache(): void { + detectionCache = null; + console.warn('[Conda] Cache cleared'); +} + +/** + * Get the current cached detection result without triggering detection + * + * @returns Cached CondaDetectionResult or null if no cache exists + */ +export function getCachedCondaResult(): CondaDetectionResult | null { + return detectionCache; +} diff --git a/apps/frontend/src/main/conda-env-manager.ts b/apps/frontend/src/main/conda-env-manager.ts new file mode 100644 index 0000000000..caa592c2a4 --- /dev/null +++ b/apps/frontend/src/main/conda-env-manager.ts @@ -0,0 +1,1021 @@ +/** + * Conda Environment Manager + * + * Manages Conda environments for Python projects: + * - Creating environments with specific Python versions + * - Installing dependencies from requirements files + * - Verifying existing environments + * - Generating activation scripts for different shells + * - Deleting environments + * + * Uses async generators for progress reporting during long-running operations. + */ + +import { execFile, spawn } from 'child_process'; +import { existsSync, promises as fsPromises } from 'fs'; +import path from 'path'; +import { promisify } from 'util'; +import type { + PythonVersionResult, + PythonVersionConstraint, + CondaEnvConfig, + CondaEnvValidation, + SetupProgress, + ActivationScripts, +} from '../shared/types/conda'; +import { detectCondaInstallations } from './conda-detector'; +import { getCondaPythonPath, getCondaPipPath } from './python-path-utils'; + +const execFileAsync = promisify(execFile); + +// Default Python version when no version is specified in project files +const DEFAULT_PYTHON_VERSION = '3.12'; + +// Alias for backward compatibility within this module +const getPythonPath = getCondaPythonPath; + +/** + * Parse Python version from environment.yml file + * + * Looks for patterns like: + * - python=3.12 + * - python>=3.12 + * - python=3.12.* + */ +async function parseEnvironmentYml( + filePath: string +): Promise { + try { + const content = await fsPromises.readFile(filePath, 'utf-8'); + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + + // Match python version in dependencies section + // Patterns: python=3.12, python>=3.12, python=3.12.*, python==3.12 + const match = trimmed.match( + /^-?\s*python\s*([=>')) { + constraint = 'minimum'; + } else if (operator.includes('<')) { + constraint = 'range'; + } + + return { + version, + source: 'environment.yml', + constraint, + raw: trimmed, + }; + } + } + } catch { + // File doesn't exist or can't be read + } + return null; +} + +/** + * Parse Python version from pyproject.toml file + * + * Looks for patterns like: + * - requires-python = ">=3.12" + * - requires-python = ">=3.12,<4.0" + * - python = "^3.12" + */ +async function parsePyprojectToml( + filePath: string +): Promise { + try { + const content = await fsPromises.readFile(filePath, 'utf-8'); + + // Match requires-python in [project] section + const requiresPythonMatch = content.match( + /requires-python\s*=\s*["']([^"']+)["']/ + ); + + if (requiresPythonMatch) { + const raw = requiresPythonMatch[1]; + // Extract version number from constraint + const versionMatch = raw.match(/(\d+\.\d+(?:\.\d+)?)/); + + if (versionMatch) { + let constraint: PythonVersionConstraint = 'exact'; + if (raw.includes('>=') || raw.includes('>')) { + constraint = 'minimum'; + } else if (raw.includes(',') || raw.includes('<')) { + constraint = 'range'; + } + + return { + version: versionMatch[1], + source: 'pyproject.toml', + constraint, + raw, + }; + } + } + + // Also check Poetry's python field: python = "^3.12" + // Use [\s\S] instead of . with s flag for cross-line matching + const poetryPythonMatch = content.match( + /\[tool\.poetry\.dependencies\][\s\S]*?python\s*=\s*["']([^"']+)["']/ + ); + + if (poetryPythonMatch) { + const raw = poetryPythonMatch[1]; + const versionMatch = raw.match(/(\d+\.\d+(?:\.\d+)?)/); + + if (versionMatch) { + return { + version: versionMatch[1], + source: 'pyproject.toml', + constraint: raw.startsWith('^') || raw.startsWith('~') ? 'minimum' : 'exact', + raw, + }; + } + } + } catch { + // File doesn't exist or can't be read + } + return null; +} + +/** + * Parse Python version from requirements.txt file + * + * Looks for patterns like: + * - # python>=3.12 (comment at top) + * - # python 3.12 + * - PEP 508 markers (not common but supported) + */ +async function parseRequirementsTxt( + filePath: string +): Promise { + try { + const content = await fsPromises.readFile(filePath, 'utf-8'); + const lines = content.split('\n'); + + // Only check first 10 lines for version comment + const headerLines = lines.slice(0, 10); + + for (const line of headerLines) { + const trimmed = line.trim(); + + // Match comment patterns: # python>=3.12, # python 3.12, # Python version: 3.12 + const match = trimmed.match( + /^#\s*python\s*(?:version\s*:?\s*)?([=>')) { + constraint = 'minimum'; + } else if (operator.includes('<')) { + constraint = 'range'; + } + + return { + version, + source: 'comment', + constraint, + raw: trimmed, + }; + } + } + + // Check for python_requires marker in any line (PEP 508) + for (const line of lines) { + if (line.includes('python_version')) { + const match = line.match(/python_version\s*([=>') ? 'minimum' : 'exact', + raw: line.trim(), + }; + } + } + } + } catch { + // File doesn't exist or can't be read + } + return null; +} + +/** + * Parse Python version from .python-version file (pyenv) + */ +async function parsePythonVersionFile( + filePath: string +): Promise { + try { + const content = await fsPromises.readFile(filePath, 'utf-8'); + const version = content.trim(); + + // Extract major.minor from version string (e.g., "3.12.1" -> "3.12") + const match = version.match(/^(\d+\.\d+)/); + if (match) { + return { + version: match[1], + source: '.python-version', + constraint: 'exact', + raw: version, + }; + } + } catch { + // File doesn't exist or can't be read + } + return null; +} + +/** + * Parse Python version from runtime.txt (Heroku) + */ +async function parseRuntimeTxt( + filePath: string +): Promise { + try { + const content = await fsPromises.readFile(filePath, 'utf-8'); + const line = content.trim(); + + // Match pattern: python-3.12.1 + const match = line.match(/^python-(\d+\.\d+)/i); + if (match) { + return { + version: match[1], + source: 'runtime.txt', + constraint: 'exact', + raw: line, + }; + } + } catch { + // File doesn't exist or can't be read + } + return null; +} + +/** + * Parse Python version from project files + * + * Checks files in order of priority: + * 1. environment.yml (conda environment file) + * 2. pyproject.toml (modern Python projects) + * 3. requirements.txt (comments or markers) + * 4. .python-version (pyenv) + * 5. runtime.txt (Heroku) + * 6. Default: 3.12 + * + * @param projectPath - Path to the project directory + * @returns Promise resolving to PythonVersionResult with detected or default version + */ +export async function parseRequiredPythonVersionAsync( + projectPath: string +): Promise { + // Check files in priority order + + // 1. environment.yml + const environmentYmlPath = path.join(projectPath, 'environment.yml'); + if (existsSync(environmentYmlPath)) { + const result = await parseEnvironmentYml(environmentYmlPath); + if (result) return result; + } + + // 2. pyproject.toml + const pyprojectTomlPath = path.join(projectPath, 'pyproject.toml'); + if (existsSync(pyprojectTomlPath)) { + const result = await parsePyprojectToml(pyprojectTomlPath); + if (result) return result; + } + + // 3. requirements.txt + const requirementsTxtPath = path.join(projectPath, 'requirements.txt'); + if (existsSync(requirementsTxtPath)) { + const result = await parseRequirementsTxt(requirementsTxtPath); + if (result) return result; + } + + // 4. .python-version (pyenv) + const pythonVersionPath = path.join(projectPath, '.python-version'); + if (existsSync(pythonVersionPath)) { + const result = await parsePythonVersionFile(pythonVersionPath); + if (result) return result; + } + + // 5. runtime.txt (Heroku) + const runtimeTxtPath = path.join(projectPath, 'runtime.txt'); + if (existsSync(runtimeTxtPath)) { + const result = await parseRuntimeTxt(runtimeTxtPath); + if (result) return result; + } + + // Default fallback + return { + version: DEFAULT_PYTHON_VERSION, + source: 'default', + constraint: 'minimum', + raw: `Default Python ${DEFAULT_PYTHON_VERSION}`, + }; +} + +/** + * Run a command and capture output + */ +async function runCommand( + command: string, + args: string[], + options: { cwd?: string; timeout?: number } = {} +): Promise<{ stdout: string; stderr: string }> { + return execFileAsync(command, args, { + encoding: 'utf-8', + timeout: options.timeout || 300000, // 5 minutes default + cwd: options.cwd, + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, // 10MB buffer + }); +} + +/** + * Run a command as a spawned process with streaming output + */ +function spawnCommand( + command: string, + args: string[], + options: { cwd?: string } = {} +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + // Don't use shell: true - it adds overhead and can cause issues + // conda.exe can be called directly without a shell wrapper + const proc = spawn(command, args, { + cwd: options.cwd, + windowsHide: true, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('error', (err) => { + reject(err); + }); + + proc.on('close', (code) => { + resolve({ code: code ?? 1, stdout, stderr }); + }); + }); +} + +/** + * Create a new Conda environment + * + * Creates a prefix-based environment at the specified path with the + * requested Python version. Yields progress updates throughout the process. + * + * @param config - Environment configuration + * @yields SetupProgress updates during creation + */ +export async function* createEnvironment( + config: CondaEnvConfig +): AsyncGenerator { + const { envPath, pythonVersion, condaInstallation } = config; + + // Step 1: Detect Conda + yield { + step: 'detecting', + message: 'Detecting conda installation...', + timestamp: new Date().toISOString(), + }; + + let condaExe: string; + let condaBase: string; + + if (condaInstallation) { + condaExe = condaInstallation.condaExe; + condaBase = condaInstallation.path; + } else { + const detection = await detectCondaInstallations(); + if (!detection.found || !detection.preferred) { + yield { + step: 'error', + message: 'No Conda installation found', + detail: 'Please install Miniconda or Anaconda first', + timestamp: new Date().toISOString(), + }; + return; + } + condaExe = detection.preferred.condaExe; + condaBase = detection.preferred.path; + } + + // Step 2: Create environment + yield { + step: 'creating', + message: `Creating environment at ${envPath}...`, + progress: 10, + timestamp: new Date().toISOString(), + }; + + // Check if environment already exists and delete it (for reinstall) + if (existsSync(envPath)) { + yield { + step: 'creating', + message: 'Removing existing environment...', + detail: envPath, + progress: 15, + timestamp: new Date().toISOString(), + }; + + try { + await fsPromises.rm(envPath, { recursive: true, force: true }); + } catch (err) { + const errorMsg = String(err); + console.error('[CondaEnvManager] Failed to remove environment:', errorMsg); + // Provide helpful error message based on error type + let userMessage = 'Failed to remove existing environment'; + if (errorMsg.includes('EBUSY') || errorMsg.includes('EPERM') || errorMsg.includes('in use')) { + userMessage = 'Environment is in use. Close VS Code and any terminals using this environment, then try again.'; + } + yield { + step: 'error', + message: userMessage, + detail: errorMsg, + timestamp: new Date().toISOString(), + }; + return; + } + } + + // Ensure parent directory exists + const envParentDir = path.dirname(envPath); + try { + await fsPromises.mkdir(envParentDir, { recursive: true }); + } catch (err) { + yield { + step: 'error', + message: `Failed to create directory: ${envParentDir}`, + detail: String(err), + timestamp: new Date().toISOString(), + }; + return; + } + + // Step 3: Run conda create + yield { + step: 'installing-python', + message: `Installing Python ${pythonVersion}...`, + progress: 30, + timestamp: new Date().toISOString(), + }; + + try { + const { code, stdout, stderr } = await spawnCommand(condaExe, [ + 'create', + '-p', + envPath, + `python=${pythonVersion}`, + '-y', + '--no-default-packages', + ]); + + if (code !== 0) { + yield { + step: 'error', + message: 'Failed to create Conda environment', + detail: stderr || stdout, + timestamp: new Date().toISOString(), + }; + return; + } + + yield { + step: 'installing-python', + message: 'Python installation complete', + detail: stdout, + progress: 60, + timestamp: new Date().toISOString(), + }; + } catch (err) { + yield { + step: 'error', + message: 'Failed to run conda create', + detail: String(err), + timestamp: new Date().toISOString(), + }; + return; + } + + // Step 4: Verify Python installation + yield { + step: 'verifying-python', + message: 'Verifying Python installation...', + progress: 70, + timestamp: new Date().toISOString(), + }; + + const pythonExe = getPythonPath(envPath); + + try { + const { stdout } = await runCommand(pythonExe, ['--version']); + yield { + step: 'verifying-python', + message: `Python verified: ${stdout.trim()}`, + progress: 80, + timestamp: new Date().toISOString(), + }; + } catch (err) { + yield { + step: 'error', + message: 'Failed to verify Python installation', + detail: String(err), + timestamp: new Date().toISOString(), + }; + return; + } + + // Step 5: Generate activation scripts + yield { + step: 'generating-scripts', + message: 'Generating activation scripts...', + progress: 90, + timestamp: new Date().toISOString(), + }; + + try { + const scripts = await generateActivationScripts(envPath, condaBase); + yield { + step: 'generating-scripts', + message: 'Activation scripts generated', + detail: `Scripts at: ${path.dirname(scripts.bat)}`, + progress: 95, + timestamp: new Date().toISOString(), + }; + } catch (err) { + // Non-fatal - environment is still usable + yield { + step: 'generating-scripts', + message: 'Warning: Could not generate activation scripts', + detail: String(err), + progress: 95, + timestamp: new Date().toISOString(), + }; + } + + // Step 6: Finalizing (warn about Windows indexing delay) + yield { + step: 'finalizing', + message: 'Finalizing environment...', + detail: 'This may take up to a minute while Windows indexes the new files', + progress: 97, + timestamp: new Date().toISOString(), + }; + + // Brief pause to allow system to settle before marking complete + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Step 7: Complete + yield { + step: 'complete', + message: 'Environment ready', + detail: `Environment created at ${envPath}`, + progress: 100, + timestamp: new Date().toISOString(), + }; +} + +/** + * Verify an existing Conda environment + * + * Checks that the environment exists, has a working Python installation, + * and reports the installed Python version. + * + * @param envPath - Path to the environment to verify + * @returns Validation result + */ +export async function verifyEnvironment( + envPath: string +): Promise { + // Check environment directory exists + try { + await fsPromises.access(envPath); + } catch { + return { + valid: false, + error: 'env_not_found', + message: `Environment not found at ${envPath}`, + envPath, + }; + } + + // Check Python executable exists + const pythonExe = getPythonPath(envPath); + try { + await fsPromises.access(pythonExe); + } catch { + return { + valid: false, + error: 'python_missing', + message: 'Python executable not found in environment', + envPath, + }; + } + + // Get Python version + let pythonVersion: string | undefined; + try { + const { stdout } = await runCommand(pythonExe, ['--version']); + const match = stdout.match(/Python\s+(\d+\.\d+\.\d+)/i); + pythonVersion = match ? match[1] : undefined; + } catch { + return { + valid: false, + error: 'env_broken', + message: 'Failed to run Python - environment may be corrupted', + envPath, + }; + } + + // Get package count (with short timeout to avoid blocking) + // This is informational only - don't block verification on it + let packageCount: number | undefined; + const pipExe = getCondaPipPath(envPath); + try { + // Short 5-second timeout for pip list - if it takes longer, skip it + const { stdout } = await runCommand(pipExe, ['list', '--format=json'], { timeout: 5000 }); + const packages = JSON.parse(stdout); + packageCount = Array.isArray(packages) ? packages.length : undefined; + } catch { + // Non-fatal - pip might not be available or timed out + // Skip package count rather than blocking + } + + return { + valid: true, + pythonVersion, + packageCount, + message: `Python ${pythonVersion}${packageCount !== undefined ? ` with ${packageCount} packages` : ''}`, + envPath, + depsInstalled: (packageCount ?? 0) > 5, // More than just base packages + }; +} + +/** + * Install dependencies from a requirements file + * + * Runs pip install -r on the specified requirements file within the environment. + * Yields progress updates during installation. + * + * @param envPath - Path to the environment + * @param requirementsPath - Path to requirements.txt file + * @yields SetupProgress updates during installation + */ +export async function* installDependencies( + envPath: string, + requirementsPath: string +): AsyncGenerator { + yield { + step: 'installing-deps', + message: 'Installing dependencies...', + detail: `From: ${requirementsPath}`, + progress: 0, + timestamp: new Date().toISOString(), + }; + + // Verify requirements file exists + try { + await fsPromises.access(requirementsPath); + } catch { + yield { + step: 'error', + message: 'Requirements file not found', + detail: requirementsPath, + timestamp: new Date().toISOString(), + }; + return; + } + + const pipExe = getCondaPipPath(envPath); + + // Verify pip exists + try { + await fsPromises.access(pipExe); + } catch { + yield { + step: 'error', + message: 'pip not found in environment', + detail: pipExe, + timestamp: new Date().toISOString(), + }; + return; + } + + yield { + step: 'installing-deps', + message: 'Running pip install...', + progress: 20, + timestamp: new Date().toISOString(), + }; + + try { + const { code, stdout, stderr } = await spawnCommand(pipExe, [ + 'install', + '-r', + requirementsPath, + ]); + + if (code !== 0) { + yield { + step: 'error', + message: 'pip install failed', + detail: stderr || stdout, + timestamp: new Date().toISOString(), + }; + return; + } + + yield { + step: 'installing-deps', + message: 'Dependencies installed successfully', + detail: stdout, + progress: 100, + timestamp: new Date().toISOString(), + }; + } catch (err) { + yield { + step: 'error', + message: 'Failed to run pip install', + detail: String(err), + timestamp: new Date().toISOString(), + }; + } +} + +/** + * Generate activation scripts for different shells + * + * Creates activate.bat, activate.ps1, and activate.sh in /activate/ + * Also writes .conda_base file containing the Conda base path. + * + * @param envPath - Path to the environment + * @param condaBase - Path to the Conda installation + * @returns Generated script paths + */ +export async function generateActivationScripts( + envPath: string, + condaBase: string +): Promise { + const activateDir = path.join(envPath, 'activate'); + await fsPromises.mkdir(activateDir, { recursive: true }); + + // Normalize paths for scripts + const envPathNormalized = envPath.replace(/\\/g, '/'); + const condaBaseNormalized = condaBase.replace(/\\/g, '/'); + + // Windows CMD batch script + const batContent = `@echo off +REM Conda environment activation script for Windows CMD +REM Generated by Auto-Claude + +set "CONDA_BASE=${condaBase}" +set "ENV_PATH=${envPath}" + +REM Initialize conda +call "%CONDA_BASE%\\Scripts\\activate.bat" "%CONDA_BASE%" + +REM Activate the environment +call conda activate "%ENV_PATH%" + +echo Activated environment: %ENV_PATH% +`; + + // PowerShell script + const ps1Content = `# Conda environment activation script for PowerShell +# Generated by Auto-Claude + +$CONDA_BASE = "${condaBase.replace(/\\/g, '\\\\')}" +$ENV_PATH = "${envPath.replace(/\\/g, '\\\\')}" + +# Initialize conda +& "$CONDA_BASE\\Scripts\\activate.ps1" "$CONDA_BASE" + +# Activate the environment +conda activate "$ENV_PATH" + +Write-Host "Activated environment: $ENV_PATH" +`; + + // Bash script + const shContent = `#!/bin/bash +# Conda environment activation script for Bash +# Generated by Auto-Claude + +CONDA_BASE="${condaBaseNormalized}" +ENV_PATH="${envPathNormalized}" + +# Initialize conda +source "$CONDA_BASE/etc/profile.d/conda.sh" + +# Activate the environment +conda activate "$ENV_PATH" + +echo "Activated environment: $ENV_PATH" +`; + + // Write scripts + const batPath = path.join(activateDir, 'activate.bat'); + const ps1Path = path.join(activateDir, 'activate.ps1'); + const shPath = path.join(activateDir, 'activate.sh'); + const condaBasePath = path.join(activateDir, '.conda_base'); + + await Promise.all([ + fsPromises.writeFile(batPath, batContent, 'utf-8'), + fsPromises.writeFile(ps1Path, ps1Content, 'utf-8'), + fsPromises.writeFile(shPath, shContent, { encoding: 'utf-8', mode: 0o755 }), + fsPromises.writeFile(condaBasePath, condaBase, 'utf-8'), + ]); + + return { + bat: batPath, + ps1: ps1Path, + sh: shPath, + condaBase, + envPath, + }; +} + +/** + * Delete a Conda environment + * + * Removes the environment directory and all its contents. + * + * @param envPath - Path to the environment to delete + * @returns true if deleted successfully, false otherwise + */ +export async function deleteEnvironment(envPath: string): Promise { + try { + await fsPromises.access(envPath); + } catch { + // Environment doesn't exist - consider this success + return true; + } + + try { + await fsPromises.rm(envPath, { recursive: true, force: true }); + return true; + } catch (err) { + console.error(`[CondaEnvManager] Failed to delete environment: ${err}`); + return false; + } +} + +/** + * Delete activation scripts for a project + * + * Removes the workspace file and PowerShell init script generated + * for a project's Conda environment. Respects project structure + * (pure-python vs mixed projects with src/python/). + * + * @param projectPath - Path to the project directory + * @param projectName - Name of the project (used for file naming) + * @returns true if deleted successfully, false otherwise + */ +export async function deleteActivationScripts( + projectPath: string, + projectName: string +): Promise { + // Import here to avoid circular dependency + const { detectProjectStructure } = await import('./conda-project-structure'); + + try { + // Detect project structure to find the correct root for scripts + const structure = detectProjectStructure(projectPath); + const pythonRoot = structure.pythonRoot; + + const filesToDelete = [ + // VS Code workspace file (in pythonRoot) + path.join(pythonRoot, `${projectName}.code-workspace`), + // PowerShell init script (in pythonRoot/scripts) + path.join(pythonRoot, 'scripts', `init-${projectName}.ps1`), + ]; + + console.warn(`[CondaEnvManager] Deleting activation scripts from: ${pythonRoot}`); + + let allDeleted = true; + for (const filePath of filesToDelete) { + try { + if (existsSync(filePath)) { + await fsPromises.unlink(filePath); + console.warn(`[CondaEnvManager] Deleted: ${filePath}`); + } else { + console.warn(`[CondaEnvManager] File not found (skipping): ${filePath}`); + } + } catch (err) { + console.error(`[CondaEnvManager] Failed to delete ${filePath}: ${err}`); + allDeleted = false; + } + } + + return allDeleted; + } catch (err) { + console.error(`[CondaEnvManager] Failed to delete activation scripts: ${err}`); + return false; + } +} + +/** + * Check if pip install would succeed (dry-run) + * + * Runs pip install --dry-run to check for dependency conflicts + * without actually installing anything. + * + * @param envPath - Path to the environment + * @param requirementsPath - Path to requirements.txt file + * @returns Compatibility result with any issues found + */ +export async function checkDependencyCompatibility( + envPath: string, + requirementsPath: string +): Promise<{ compatible: boolean; issues: string[] }> { + const issues: string[] = []; + + // Verify requirements file exists + try { + await fsPromises.access(requirementsPath); + } catch { + return { + compatible: false, + issues: [`Requirements file not found: ${requirementsPath}`], + }; + } + + const pipExe = getCondaPipPath(envPath); + + // Verify pip exists + try { + await fsPromises.access(pipExe); + } catch { + return { + compatible: false, + issues: [`pip not found in environment: ${pipExe}`], + }; + } + + try { + // Run pip install with --dry-run flag + const { code, stdout, stderr } = await spawnCommand(pipExe, [ + 'install', + '-r', + requirementsPath, + '--dry-run', + ]); + + if (code !== 0) { + // Parse stderr for specific issues + const lines = (stderr || stdout).split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if ( + trimmed.includes('ERROR:') || + trimmed.includes('Could not find') || + trimmed.includes('conflict') || + trimmed.includes('incompatible') + ) { + issues.push(trimmed); + } + } + + if (issues.length === 0) { + issues.push('pip dry-run failed - check requirements file syntax'); + } + + return { compatible: false, issues }; + } + + return { compatible: true, issues: [] }; + } catch (err) { + return { + compatible: false, + issues: [`Failed to run pip: ${err}`], + }; + } +} diff --git a/apps/frontend/src/main/conda-project-structure.ts b/apps/frontend/src/main/conda-project-structure.ts new file mode 100644 index 0000000000..0419cb3f77 --- /dev/null +++ b/apps/frontend/src/main/conda-project-structure.ts @@ -0,0 +1,310 @@ +/** + * Conda Project Structure Detection + * + * Detects whether a project is pure Python or mixed with other languages. + * Used to determine where Python environments and workspace files should be placed. + * + * For pure Python projects: environments go at project root + * For mixed projects (e.g., dotnet+python): environments go in src/python subdirectory + */ + +import { existsSync, readdirSync, statSync } from 'fs'; +import path from 'path'; +import type { ProjectStructure, ProjectType } from '../shared/types/conda'; + +/** + * Python project indicator files that suggest a pure Python project when at root + */ +const PYTHON_ROOT_INDICATORS = [ + 'pyproject.toml', + 'setup.py', + 'setup.cfg', + 'requirements.txt', + 'Pipfile', + 'poetry.lock', +]; + +/** + * File patterns that indicate other languages in the project + */ +const LANGUAGE_INDICATORS: Record = { + dotnet: ['*.csproj', '*.fsproj', '*.vbproj', '*.sln'], + node: ['package.json'], + java: ['pom.xml', 'build.gradle', 'build.gradle.kts'], + go: ['go.mod'], + rust: ['Cargo.toml'], + ruby: ['Gemfile'], +}; + +/** + * Check if a file exists at the given path + */ +function fileExists(filePath: string): boolean { + try { + return existsSync(filePath); + } catch { + return false; + } +} + +/** + * Check if any files matching a glob pattern exist in the directory + * Simple implementation: checks for common file extensions + */ +function hasFilesWithExtension(directory: string, extension: string): boolean { + try { + const files = readdirSync(directory); + return files.some((file: string) => file.endsWith(extension)); + } catch { + return false; + } +} + +/** + * Find all requirements files in a directory + */ +function findRequirementsFiles(directory: string): string[] { + const found: string[] = []; + + try { + const files = readdirSync(directory); + + // Check for requirements*.txt files + for (const file of files) { + if (file.startsWith('requirements') && file.endsWith('.txt')) { + found.push(path.join(directory, file)); + } + } + + // Check for requirements/ directory + const reqDir = path.join(directory, 'requirements'); + if (fileExists(reqDir)) { + try { + const stat = statSync(reqDir); + if (stat.isDirectory()) { + const reqFiles = readdirSync(reqDir); + for (const file of reqFiles) { + if (file.endsWith('.txt')) { + found.push(path.join(reqDir, file)); + } + } + } + } catch { + // Ignore errors + } + } + } catch { + // Ignore errors + } + + return found; +} + +/** + * Check if the project has .NET files (*.csproj, *.sln, etc.) + */ +function detectDotnet(projectPath: string): boolean { + // Check root directory for .sln files + if (hasFilesWithExtension(projectPath, '.sln')) { + return true; + } + if (hasFilesWithExtension(projectPath, '.csproj')) { + return true; + } + if (hasFilesWithExtension(projectPath, '.fsproj')) { + return true; + } + if (hasFilesWithExtension(projectPath, '.vbproj')) { + return true; + } + + // Check common dotnet source directories + const srcDir = path.join(projectPath, 'src'); + if (fileExists(srcDir)) { + if (hasFilesWithExtension(srcDir, '.csproj')) { + return true; + } + // Check subdirectories of src for .csproj files + try { + const srcSubdirs = readdirSync(srcDir); + for (const subdir of srcSubdirs) { + const subdirPath = path.join(srcDir, subdir); + try { + const stat = statSync(subdirPath); + if (stat.isDirectory() && hasFilesWithExtension(subdirPath, '.csproj')) { + return true; + } + } catch { + continue; + } + } + } catch { + // Ignore errors reading directory + } + } + + return false; +} + +/** + * Detect other languages present in the project + */ +function detectOtherLanguages(projectPath: string): string[] { + const detected: string[] = []; + + for (const [language, indicators] of Object.entries(LANGUAGE_INDICATORS)) { + if (language === 'dotnet') { + // Dotnet is handled separately with hasDotnet + continue; + } + + // Check for non-glob indicators + for (const indicator of indicators) { + if (!indicator.includes('*') && fileExists(path.join(projectPath, indicator))) { + if (!detected.includes(language)) { + detected.push(language); + } + break; + } + } + } + + return detected; +} + +/** + * Check if the project has a src/python directory structure + * This indicates a mixed project where Python code is in a subdirectory + */ +function hasSrcPythonStructure(projectPath: string): boolean { + const srcPythonPath = path.join(projectPath, 'src', 'python'); + if (!fileExists(srcPythonPath)) { + return false; + } + + // Verify it looks like a Python project directory + return PYTHON_ROOT_INDICATORS.some((indicator) => + fileExists(path.join(srcPythonPath, indicator)) + ); +} + +/** + * Check if the project is a pure Python project (Python files at root) + */ +function isPurePythonProject(projectPath: string): boolean { + // Check for Python project indicators at root + const hasPythonAtRoot = PYTHON_ROOT_INDICATORS.some((indicator) => + fileExists(path.join(projectPath, indicator)) + ); + + if (!hasPythonAtRoot) { + return false; + } + + // If there's a src/python structure, it's likely a mixed project + if (hasSrcPythonStructure(projectPath)) { + return false; + } + + // If there are other major languages, it might be mixed + const hasDotnet = detectDotnet(projectPath); + if (hasDotnet) { + return false; + } + + return true; +} + +/** + * Find pyproject.toml in a directory + */ +function findPyprojectToml(directory: string): string | undefined { + const pyprojectPath = path.join(directory, 'pyproject.toml'); + return fileExists(pyprojectPath) ? pyprojectPath : undefined; +} + +/** + * Detect the project structure to determine where Python environments should go. + * + * @param projectPath - Absolute path to the project root + * @returns ProjectStructure with type, pythonRoot, and detected languages + */ +export function detectProjectStructure(projectPath: string): ProjectStructure { + const hasDotnet = detectDotnet(projectPath); + const hasOtherLanguages = detectOtherLanguages(projectPath); + + // Check for src/python structure (indicates mixed project) + if (hasSrcPythonStructure(projectPath)) { + const pythonRoot = path.join(projectPath, 'src', 'python'); + return { + type: 'mixed', + pythonRoot, + hasDotnet, + hasOtherLanguages, + requirementsFiles: findRequirementsFiles(pythonRoot), + pyprojectPath: findPyprojectToml(pythonRoot), + }; + } + + // Check if it's a pure Python project + if (isPurePythonProject(projectPath)) { + return { + type: 'pure-python', + pythonRoot: projectPath, + hasDotnet: false, + hasOtherLanguages, + requirementsFiles: findRequirementsFiles(projectPath), + pyprojectPath: findPyprojectToml(projectPath), + }; + } + + // Default: if we detect other languages but no src/python, + // still use project root for Python (user may add Python later) + // But mark it as mixed if other languages are present + const isMixed = hasDotnet || hasOtherLanguages.length > 0; + const projectType: ProjectType = isMixed ? 'mixed' : 'pure-python'; + + return { + type: projectType, + pythonRoot: projectPath, + hasDotnet, + hasOtherLanguages, + requirementsFiles: findRequirementsFiles(projectPath), + pyprojectPath: findPyprojectToml(projectPath), + }; +} + +/** + * Get the path where Python environments (.envs directory) should be placed. + * + * @param projectPath - Absolute path to the project root + * @param projectName - Name of the project (used in environment directory name) + * @returns Absolute path to the environment directory (e.g., /project/.envs/myproject) + */ +export function getPythonEnvPath(projectPath: string, projectName: string): string { + const structure = detectProjectStructure(projectPath); + return path.join(structure.pythonRoot, '.envs', projectName); +} + +/** + * Get the path where activation scripts should be placed. + * + * @param projectPath - Absolute path to the project root + * @returns Absolute path to the scripts directory (e.g., /project/.envs/scripts) + */ +export function getScriptsPath(projectPath: string): string { + const structure = detectProjectStructure(projectPath); + return path.join(structure.pythonRoot, '.envs', 'scripts'); +} + +/** + * Get the path where the VS Code workspace file should be placed. + * + * @param projectPath - Absolute path to the project root + * @param projectName - Name of the project (used in workspace filename) + * @returns Absolute path to the workspace file (e.g., /project/myproject.code-workspace) + */ +export function getWorkspaceFilePath(projectPath: string, projectName: string): string { + const structure = detectProjectStructure(projectPath); + return path.join(structure.pythonRoot, `${projectName}.code-workspace`); +} diff --git a/apps/frontend/src/main/conda-workspace-generator.ts b/apps/frontend/src/main/conda-workspace-generator.ts new file mode 100644 index 0000000000..3cd4687170 --- /dev/null +++ b/apps/frontend/src/main/conda-workspace-generator.ts @@ -0,0 +1,220 @@ +/** + * Conda Workspace Generator + * + * Generates VS Code workspace files and PowerShell init scripts for + * Conda environment integration. Creates configuration files that allow + * VS Code to automatically activate the correct Conda environment when + * opening a terminal. + * + * Generated files: + * - .code-workspace: VS Code workspace with Conda terminal profile + * - scripts/init-.ps1: PowerShell init script for environment activation + */ + +import { existsSync, promises as fsPromises } from 'fs'; +import path from 'path'; +import type { VsCodeWorkspaceConfig } from '../shared/types/conda'; +import { detectProjectStructure } from './conda-project-structure'; + +/** + * Generate VS Code workspace file content + * + * Creates a workspace configuration that: + * - Defines a custom terminal profile for the Conda environment + * - Sets the default terminal to use the Conda environment + * - Configures Python extension settings for the environment + * + * @param config - Workspace configuration options + * @returns JSON string of the workspace file content + */ +export async function generateVsCodeWorkspace( + config: VsCodeWorkspaceConfig +): Promise { + const { projectName } = config; + + // Build workspace configuration object + const workspace = { + folders: [{ path: '.' }], + settings: { + 'terminal.integrated.profiles.windows': { + [`${projectName} (Conda)`]: { + source: 'PowerShell', + icon: 'terminal-powershell', + color: 'terminal.ansiGreen', + args: [ + '-NoLogo', + '-NoExit', + '-File', + `\${workspaceFolder}\\scripts\\init-${projectName}.ps1`, + ], + }, + }, + 'terminal.integrated.defaultProfile.windows': `${projectName} (Conda)`, + 'python.terminal.activateEnvironment': false, + 'python.defaultInterpreterPath': `\${workspaceFolder}\\.envs\\${projectName}\\python.exe`, + 'python.analysis.typeCheckingMode': 'basic', + 'python.analysis.diagnosticMode': 'workspace', + 'python.analysis.exclude': ['.envs'], + }, + }; + + return JSON.stringify(workspace, null, 2); +} + +/** + * Generate PowerShell init script content + * + * Creates a PowerShell script that: + * - Reads the Conda base path from the environment config + * - Initializes the Conda shell hook + * - Activates the project-specific Conda environment + * + * @param config - Workspace configuration options + * @returns PowerShell script content + */ +export async function generatePowerShellInitScript( + config: VsCodeWorkspaceConfig +): Promise { + // Note: condaBase is stored in config but the script reads it from .conda_base file + // at runtime for portability (in case Conda is moved/reinstalled) + const { projectName } = config; + + const script = `# Auto-generated by Auto Claude - Conda environment activation for ${projectName} +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectRoot = Split-Path -Parent $ScriptDir + +# Read conda base path from config +$CondaBasePath = Get-Content "$ProjectRoot\\.envs\\${projectName}\\.conda_base" -ErrorAction SilentlyContinue +if (-not $CondaBasePath) { + $CondaBasePath = "$Env:USERPROFILE\\miniconda3" +} + +# Activate conda environment +& "$CondaBasePath\\shell\\condabin\\conda-hook.ps1" +& conda activate "$ProjectRoot\\.envs\\${projectName}" + +# Override the prompt to show just the project name instead of full path +$Env:CONDA_PROMPT_MODIFIER = "(${projectName}) " + +Write-Host "Activated conda environment: ${projectName}" -ForegroundColor Green +`; + + return script; +} + +/** + * Generate all workspace files (VS Code workspace + PowerShell init script) + * + * This is the main entry point for generating workspace configuration files. + * It uses project structure detection to determine the correct locations + * for all generated files. + * + * @param projectPath - Absolute path to the project root + * @param projectName - Display name for the project + * @param condaBase - Path to the Conda installation + * @returns Object containing paths to generated files + */ +export async function generateWorkspaceFiles( + projectPath: string, + projectName: string, + condaBase: string +): Promise<{ workspacePath: string; initScriptPath: string }> { + // Detect project structure to determine where files should go + const structure = detectProjectStructure(projectPath); + const pythonRoot = structure.pythonRoot; + + // Build configuration object + const config: VsCodeWorkspaceConfig = { + projectName, + pythonRoot, + envPath: path.join(pythonRoot, '.envs', projectName), + condaBase, + }; + + // Ensure scripts directory exists + const scriptsDir = path.join(pythonRoot, 'scripts'); + if (!existsSync(scriptsDir)) { + console.warn(`[Conda Workspace] Creating scripts directory: ${scriptsDir}`); + await fsPromises.mkdir(scriptsDir, { recursive: true }); + } + + // Generate VS Code workspace file + const workspaceContent = await generateVsCodeWorkspace(config); + const workspacePath = path.join(pythonRoot, `${projectName}.code-workspace`); + await fsPromises.writeFile(workspacePath, workspaceContent, 'utf-8'); + console.warn(`[Conda Workspace] Generated workspace file: ${workspacePath}`); + + // Generate PowerShell init script + const initScriptContent = await generatePowerShellInitScript(config); + const initScriptPath = path.join(scriptsDir, `init-${projectName}.ps1`); + await fsPromises.writeFile(initScriptPath, initScriptContent, 'utf-8'); + console.warn(`[Conda Workspace] Generated init script: ${initScriptPath}`); + + return { workspacePath, initScriptPath }; +} + +/** + * Ensure .gitignore includes .envs/ directory + * + * Checks if .gitignore exists and contains an entry for .envs/. + * If not present, appends the entry to the file. Creates .gitignore + * if it doesn't exist. + * + * @param projectPath - Absolute path to the project root + */ +export async function ensureGitignore(projectPath: string): Promise { + // Use project structure detection to find the correct root + const structure = detectProjectStructure(projectPath); + const gitignorePath = path.join(structure.pythonRoot, '.gitignore'); + const entry = '.envs/'; + + try { + let content = ''; + + // Try to read existing .gitignore directly (avoid check-then-act race condition) + try { + content = await fsPromises.readFile(gitignorePath, 'utf-8'); + + // Check if .envs/ is already in .gitignore + const lines = content.split(/\r?\n/); + const hasEnvsEntry = lines.some((line) => { + const trimmed = line.trim(); + return trimmed === entry || trimmed === '.envs'; + }); + + if (hasEnvsEntry) { + console.warn( + `[Conda Workspace] .gitignore already contains ${entry}` + ); + return; + } + + // Ensure there's a newline at the end before appending + if (content.length > 0 && !content.endsWith('\n')) { + content += '\n'; + } + } catch (readError: unknown) { + // File doesn't exist, start with empty content + if ( + readError instanceof Error && + 'code' in readError && + readError.code === 'ENOENT' + ) { + content = ''; + } else { + throw readError; + } + } + + // Append .envs/ entry + content += `${entry}\n`; + await fsPromises.writeFile(gitignorePath, content, 'utf-8'); + console.warn(`[Conda Workspace] Added ${entry} to .gitignore`); + } catch (error) { + console.error( + `[Conda Workspace] Failed to update .gitignore at ${gitignorePath}:`, + error + ); + throw error; + } +} diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index 5f592c0562..eb561f4a42 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -520,6 +520,8 @@ export function getSpawnOptions( return { ...baseOptions, shell: shouldUseShell(command), + // Hide console window on Windows to prevent external windows from appearing + ...(process.platform === 'win32' && { windowsHide: baseOptions?.windowsHide ?? true }), }; } diff --git a/apps/frontend/src/main/insights/insights-executor.ts b/apps/frontend/src/main/insights/insights-executor.ts index 0c349b3480..3b4ca9954c 100644 --- a/apps/frontend/src/main/insights/insights-executor.ts +++ b/apps/frontend/src/main/insights/insights-executor.ts @@ -13,6 +13,7 @@ import type { import { MODEL_ID_MAP } from '../../shared/constants'; import { InsightsConfig } from './config'; import { detectRateLimit, createSDKRateLimitInfo } from '../rate-limit-detector'; +import { getSpawnOptions } from '../env-utils'; /** * Message processor result @@ -118,10 +119,11 @@ export class InsightsExecutor extends EventEmitter { } // Spawn Python process - const proc = spawn(this.config.getPythonPath(), args, { + const pythonPath = this.config.getPythonPath(); + const proc = spawn(pythonPath, args, getSpawnOptions(pythonPath, { cwd: autoBuildSource, env: processEnv - }); + })); this.activeSessions.set(projectId, proc); 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 e257bd6339..c78d4996e7 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -19,6 +19,7 @@ import type { ClaudeCodeVersionInfo, ClaudeInstallationList, ClaudeInstallationI import { getToolInfo, configureTools, sortNvmVersionDirs, getClaudeDetectionPaths, type ExecFileAsyncOptionsWithVerbatim } from '../cli-tool-manager'; import { readSettingsFile, writeSettingsFile } from '../settings-utils'; import { isSecurePath } from '../utils/windows-paths'; +import { isWindows, isMac, isLinux } from '../python-path-utils'; import semver from 'semver'; const execFileAsync = promisify(execFile); @@ -36,10 +37,10 @@ const VERSION_LIST_CACHE_DURATION_MS = 60 * 60 * 1000; // 1 hour for version lis */ async function validateClaudeCliAsync(cliPath: string): Promise<[boolean, string | null]> { try { - const isWindows = process.platform === 'win32'; + const isWindowsPlatform = process.platform === 'win32'; // Security validation: reject paths with shell metacharacters or directory traversal - if (isWindows && !isSecurePath(cliPath)) { + if (isWindowsPlatform && !isSecurePath(cliPath)) { throw new Error(`Claude CLI path failed security validation: ${cliPath}`); } @@ -55,7 +56,7 @@ async function validateClaudeCliAsync(cliPath: string): Promise<[boolean, string // /d = disable AutoRun registry commands // /s = strip first and last quotes, preserving inner quotes // /c = run command then terminate - if (isWindows && /\.(cmd|bat)$/i.test(cliPath)) { + if (isWindowsPlatform && /\.(cmd|bat)$/i.test(cliPath)) { // Get cmd.exe path from environment or use default const cmdExe = process.env.ComSpec || path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe'); @@ -103,7 +104,7 @@ async function scanClaudeInstallations(activePath: string | null): Promise(); const homeDir = os.homedir(); - const isWindows = process.platform === 'win32'; + const isWindowsPlatform = process.platform === 'win32'; // Get detection paths from cli-tool-manager (single source of truth) const detectionPaths = getClaudeDetectionPaths(homeDir); @@ -143,7 +144,7 @@ async function scanClaudeInstallations(activePath: string | null): Promise p.trim()); for (const p of paths) { @@ -168,7 +169,7 @@ async function scanClaudeInstallations(activePath: string | null): Promisenul; claude install --force latest'; @@ -386,15 +387,14 @@ export function escapeGitBashCommand(str: string): string { * Uses the user's preferred terminal from settings * Supports macOS, Windows, and Linux terminals */ -export async function openTerminalWithCommand(command: string): Promise { - const platform = process.platform; +export async function openTerminalWithCommand(command: string, keepOpen: boolean = true): Promise { const settings = readSettingsFile(); const preferredTerminal = settings?.preferredTerminal as string | undefined; - console.log('[Claude Code] Platform:', platform); + console.log('[Claude Code] Platform:', process.platform); console.log('[Claude Code] Preferred terminal:', preferredTerminal); - if (platform === 'darwin') { + if (isMac()) { // macOS: Use AppleScript to open terminal with command const escapedCommand = escapeAppleScriptString(command); let script: string; @@ -491,7 +491,7 @@ export async function openTerminalWithCommand(command: string): Promise { console.log('[Claude Code] Running AppleScript...'); execFileSync('osascript', ['-e', script], { stdio: 'pipe' }); - } else if (platform === 'win32') { + } else if (isWindows()) { // Windows: Use appropriate terminal // Values match SupportedTerminal type: 'windowsterminal', 'powershell', 'cmd', 'conemu', 'cmder', // 'gitbash', 'alacritty', 'wezterm', 'hyper', 'tabby', 'cygwin', 'msys2' @@ -524,9 +524,12 @@ export async function openTerminalWithCommand(command: string): Promise { // Escape command for PowerShell context to prevent command injection const escapedCommand = escapePowerShellCommand(command); + // Use -NoExit or nothing based on keepOpen parameter + const psFlag = keepOpen ? '-NoExit ' : ''; + if (terminalId === 'windowsterminal') { // Windows Terminal - open new tab with PowerShell - await runWindowsCommand(`wt new-tab powershell -NoExit -Command "${escapedCommand}"`); + await runWindowsCommand(`wt new-tab powershell ${psFlag}-Command "${escapedCommand}"`); } else if (terminalId === 'gitbash') { // Git Bash - use the passed command (escaped for bash context) const escapedBashCommand = escapeGitBashCommand(command); @@ -542,16 +545,17 @@ export async function openTerminalWithCommand(command: string): Promise { } } else if (terminalId === 'alacritty') { // Alacritty - await runWindowsCommand(`start alacritty -e powershell -NoExit -Command "${escapedCommand}"`); + await runWindowsCommand(`start alacritty -e powershell ${psFlag}-Command "${escapedCommand}"`); } else if (terminalId === 'wezterm') { // WezTerm - await runWindowsCommand(`start wezterm start -- powershell -NoExit -Command "${escapedCommand}"`); + await runWindowsCommand(`start wezterm start -- powershell ${psFlag}-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}"`); + const cmdFlag = keepOpen ? '/k' : '/c'; + await runWindowsCommand(`start cmd ${cmdFlag} "powershell ${psFlag}-Command ${escapedCommand}"`); } else if (terminalId === 'conemu') { // ConEmu - open with PowerShell tab running the command const conemuPaths = [ @@ -561,11 +565,11 @@ export async function openTerminalWithCommand(command: string): Promise { 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}"`); + await runWindowsCommand(`start "" "${conemuPath}" -run "powershell ${psFlag}-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}"`); + await runWindowsCommand(`start powershell ${psFlag}-Command "${escapedCommand}"`); } } else if (terminalId === 'cmder') { // Cmder - portable console emulator for Windows @@ -577,11 +581,11 @@ export async function openTerminalWithCommand(command: string): Promise { 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}"`); + await runWindowsCommand(`start "" "${cmderPath}" /SINGLE /START "" /TASK "powershell ${psFlag}-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}"`); + await runWindowsCommand(`start powershell ${psFlag}-Command "${escapedCommand}"`); } } else if (terminalId === 'hyper') { // Hyper - Electron-based terminal @@ -597,7 +601,7 @@ export async function openTerminalWithCommand(command: string): Promise { 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}"`); + await runWindowsCommand(`start powershell ${psFlag}-Command "${escapedCommand}"`); } } else if (terminalId === 'tabby') { // Tabby (formerly Terminus) - modern terminal for Windows @@ -612,7 +616,7 @@ export async function openTerminalWithCommand(command: string): Promise { 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}"`); + await runWindowsCommand(`start powershell ${psFlag}-Command "${escapedCommand}"`); } } else if (terminalId === 'cygwin') { // Cygwin terminal @@ -627,7 +631,7 @@ export async function openTerminalWithCommand(command: string): Promise { 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}"`); + await runWindowsCommand(`start powershell ${psFlag}-Command "${escapedCommand}"`); } } else if (terminalId === 'msys2') { // MSYS2 terminal @@ -648,13 +652,13 @@ export async function openTerminalWithCommand(command: string): Promise { } } else { console.warn('[Claude Code] MSYS2 not found, falling back to PowerShell'); - await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); + await runWindowsCommand(`start powershell ${psFlag}-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}"`); + await runWindowsCommand(`start powershell ${psFlag}-Command "${escapedCommand}"`); } } catch (err) { console.error('[Claude Code] Terminal execution failed:', err); @@ -739,15 +743,31 @@ export async function openTerminalWithCommand(command: string): Promise { { cmd: 'xterm', args: ['-e', 'bash', '-c', bashCommand] }, ]; + // Helper to try spawning a terminal and propagate errors before detaching + const trySpawnTerminal = (cmd: string, args: string[]): Promise => { + return new Promise((resolve) => { + const child = spawn(cmd, args, { detached: true, stdio: 'ignore' }); + + // Listen for spawn errors (e.g., ENOENT when command not found) + child.on('error', () => { + resolve(false); + }); + + // Give a brief window for errors to propagate before detaching + setTimeout(() => { + child.unref(); + resolve(true); + }, 50); + }); + }; + let opened = false; for (const { cmd, args } of terminals) { - try { - spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); + const success = await trySpawnTerminal(cmd, args); + if (success) { opened = true; console.log('[Claude Code] Opened terminal:', cmd); break; - } catch { - continue; } } diff --git a/apps/frontend/src/main/ipc-handlers/conda-handlers.ts b/apps/frontend/src/main/ipc-handlers/conda-handlers.ts new file mode 100644 index 0000000000..9f358c1d3b --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/conda-handlers.ts @@ -0,0 +1,622 @@ +/** + * Conda IPC Handlers + * + * IPC handlers for Conda environment management: + * - Detection: Detect and refresh Conda installations + * - App-level env: Setup and check the auto-claude environment + * - Project-level env: Setup, check, delete project environments + * - Utilities: Parse Python version, install dependencies + * + * All handlers return IPCResult for consistent error handling. + * Progress is streamed via CONDA_SETUP_PROGRESS events. + */ + +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import path from 'path'; + +import { IPC_CHANNELS } from '../../shared/constants'; +import type { IPCResult } from '../../shared/types'; +import type { + CondaDetectionResult, + CondaEnvConfig, + CondaEnvValidation, + PythonVersionResult, + SetupProgress, + CondaProjectPaths, +} from '../../shared/types/conda'; + +import { + detectCondaInstallations, +} from '../conda-detector'; +import { + createEnvironment, + verifyEnvironment, + installDependencies, + deleteEnvironment, + deleteActivationScripts, + parseRequiredPythonVersionAsync, + generateActivationScripts, +} from '../conda-env-manager'; +import { + generateWorkspaceFiles, +} from '../conda-workspace-generator'; +import { + getPythonEnvPath, + getScriptsPath, + getWorkspaceFilePath, + detectProjectStructure, +} from '../conda-project-structure'; + +/** + * Register all Conda-related IPC handlers + * + * @param getMainWindow - Function to get the current main window for sending events + */ +export function registerCondaHandlers( + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Detection Handlers + // ============================================ + + /** + * Detect Conda installations on the system + * Uses cached results unless forceRefresh is requested + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_DETECT, + async (): Promise> => { + try { + const result = await detectCondaInstallations(false); + return { success: true, data: result }; + } catch (error) { + console.error('[CONDA_DETECT] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to detect Conda installations', + }; + } + } + ); + + /** + * Force refresh Conda detection cache + * Performs a fresh scan of the system for Conda installations + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_REFRESH, + async (): Promise> => { + try { + const result = await detectCondaInstallations(true); + return { success: true, data: result }; + } catch (error) { + console.error('[CONDA_REFRESH] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to refresh Conda detection', + }; + } + } + ); + + // ============================================ + // App-level Environment Handlers + // ============================================ + + /** + * Setup the auto-claude Conda environment + * Creates environment and streams progress events to renderer + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_SETUP_AUTO_CLAUDE, + async (_, config: CondaEnvConfig): Promise> => { + try { + const mainWindow = getMainWindow(); + + for await (const progress of createEnvironment(config)) { + // Send progress updates to renderer + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, progress); + + // Check for error state + if (progress.step === 'error') { + return { + success: false, + error: progress.message, + }; + } + } + + return { success: true }; + } catch (error) { + console.error('[CONDA_SETUP_AUTO_CLAUDE] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to setup auto-claude environment', + }; + } + } + ); + + /** + * Check the status of the auto-claude environment + * Validates the environment exists and is properly configured + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_CHECK_AUTO_CLAUDE, + async (_, envPath: string): Promise> => { + try { + const validation = await verifyEnvironment(envPath); + return { success: true, data: validation }; + } catch (error) { + console.error('[CONDA_CHECK_AUTO_CLAUDE] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check auto-claude environment', + }; + } + } + ); + + // ============================================ + // Project-level Environment Handlers + // ============================================ + + /** + * Setup a project-specific Conda environment + * Creates environment at project/.envs/ and generates workspace files + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_SETUP_PROJECT_ENV, + async ( + _, + projectPath: string, + projectName: string, + pythonVersionParam?: string + ): Promise> => { + try { + const mainWindow = getMainWindow(); + + // Send initial progress - detecting conda + const detectingProgress: SetupProgress = { + step: 'detecting', + message: 'Searching for Conda installations...', + detail: 'Checking common installation paths (miniconda, anaconda, mambaforge)', + progress: 10, + timestamp: new Date().toISOString(), + }; + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, detectingProgress); + + // Detect conda installation first + const detection = await detectCondaInstallations(false); + if (!detection.found || !detection.preferred) { + const errorProgress: SetupProgress = { + step: 'error', + message: 'No Conda installation found. Please install Miniconda or Anaconda first.', + progress: 0, + timestamp: new Date().toISOString(), + }; + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, errorProgress); + return { + success: false, + error: 'No Conda installation found. Please install Miniconda or Anaconda first.', + }; + } + + // Send progress - found conda + const foundProgress: SetupProgress = { + step: 'detecting', + message: `Found ${detection.preferred.type} at ${detection.preferred.path}`, + detail: `Version: ${detection.preferred.version}`, + progress: 20, + timestamp: new Date().toISOString(), + }; + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, foundProgress); + + // Determine Python version to use + let pythonVersionToUse: string; + let pythonVersionSource: string; + + if (pythonVersionParam) { + // Use the version explicitly selected by the user + pythonVersionToUse = pythonVersionParam; + pythonVersionSource = 'user selection'; + + // Send progress - using user-selected version + const userVersionProgress: SetupProgress = { + step: 'analyzing', + message: `Using Python ${pythonVersionToUse}`, + detail: `Detected from: ${pythonVersionSource}`, + progress: 30, + timestamp: new Date().toISOString(), + }; + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, userVersionProgress); + } else { + // Auto-detect from project files + const analyzingProgress: SetupProgress = { + step: 'analyzing', + message: 'Analyzing project Python requirements...', + detail: 'Checking pyproject.toml, requirements.txt, and other config files', + progress: 25, + timestamp: new Date().toISOString(), + }; + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, analyzingProgress); + + // Parse Python version from project files + const pythonVersion = await parseRequiredPythonVersionAsync(projectPath); + pythonVersionToUse = pythonVersion.version; + pythonVersionSource = `${pythonVersion.source} (${pythonVersion.raw})`; + + // Send progress - found python version + const pythonFoundProgress: SetupProgress = { + step: 'analyzing', + message: `Using Python ${pythonVersionToUse}`, + detail: `Detected from: ${pythonVersionSource}`, + progress: 30, + timestamp: new Date().toISOString(), + }; + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, pythonFoundProgress); + } + + // Build environment path using project structure (respects src/python for mixed projects) + const envPath = getPythonEnvPath(projectPath, projectName); + + // Build config for environment creation + const config: CondaEnvConfig = { + envPath, + pythonVersion: pythonVersionToUse, + condaInstallation: detection.preferred, + }; + + // Stream progress from environment creation + for await (const progress of createEnvironment(config)) { + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, progress); + + if (progress.step === 'error') { + return { + success: false, + error: progress.message, + }; + } + } + + // Generate workspace files after environment is created + let workspacePath: string | undefined; + try { + const result = await generateWorkspaceFiles( + projectPath, + projectName, + detection.preferred.path + ); + workspacePath = result.workspacePath; + + // Send workspace generation progress + const workspaceProgress: SetupProgress = { + step: 'generating-scripts', + message: 'Generated VS Code workspace file', + detail: workspacePath, + progress: 95, + timestamp: new Date().toISOString(), + }; + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, workspaceProgress); + } catch (workspaceError) { + // Non-fatal - environment is still usable without workspace file + console.warn('[CONDA_SETUP_PROJECT_ENV] Failed to generate workspace files:', workspaceError); + } + + // Send final complete event + const completeProgress: SetupProgress = { + step: 'complete', + message: 'Environment ready', + detail: `Environment created at ${envPath}`, + progress: 100, + timestamp: new Date().toISOString(), + }; + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, completeProgress); + + return { + success: true, + data: { + envPath, + workspacePath, + }, + }; + } catch (error) { + console.error('[CONDA_SETUP_PROJECT_ENV] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to setup project environment', + }; + } + } + ); + + /** + * Check the status of a project environment + * Validates the environment exists and reports Python version/package count + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_CHECK_PROJECT_ENV, + async (_, envPath: string): Promise> => { + try { + const validation = await verifyEnvironment(envPath); + return { success: true, data: validation }; + } catch (error) { + console.error('[CONDA_CHECK_PROJECT_ENV] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check project environment', + }; + } + } + ); + + /** + * Delete a project environment + * Removes the environment directory and all its contents + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_DELETE_PROJECT_ENV, + async (_, envPath: string): Promise> => { + try { + const deleted = await deleteEnvironment(envPath); + if (!deleted) { + return { + success: false, + error: 'Failed to delete environment - it may be in use', + }; + } + return { success: true }; + } catch (error) { + console.error('[CONDA_DELETE_PROJECT_ENV] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete project environment', + }; + } + } + ); + + /** + * Delete activation scripts for a project + * Removes the workspace file and PowerShell init script + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_DELETE_ACTIVATION_SCRIPTS, + async (_, projectPath: string): Promise> => { + try { + // Extract project name from path + const projectName = path.basename(projectPath); + const deleted = await deleteActivationScripts(projectPath, projectName); + if (!deleted) { + return { + success: false, + error: 'Failed to delete some activation scripts', + }; + } + return { success: true }; + } catch (error) { + console.error('[CONDA_DELETE_ACTIVATION_SCRIPTS] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete activation scripts', + }; + } + } + ); + + /** + * Regenerate workspace/activation scripts for an existing environment + * Useful when Conda installation path changes or scripts are corrupted + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_REGENERATE_SCRIPTS, + async ( + _, + envPath: string, + projectPath: string + ): Promise> => { + try { + // First verify the environment exists + const validation = await verifyEnvironment(envPath); + if (!validation.valid) { + return { + success: false, + error: validation.message || 'Environment is not valid', + }; + } + + // Detect Conda to get the base path + const detection = await detectCondaInstallations(); + if (!detection.found || !detection.preferred) { + return { + success: false, + error: 'No Conda installation found', + }; + } + + // Regenerate activation scripts in the environment + await generateActivationScripts(envPath, detection.preferred.path); + + // Regenerate workspace files + const projectName = path.basename(projectPath); + const result = await generateWorkspaceFiles( + projectPath, + projectName, + detection.preferred.path + ); + + return { + success: true, + data: result, + }; + } catch (error) { + console.error('[CONDA_REGENERATE_SCRIPTS] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to regenerate scripts', + }; + } + } + ); + + // ============================================ + // Utility Handlers + // ============================================ + + /** + * Parse Python version requirements from project files + * Checks pyproject.toml, requirements.txt, .python-version, etc. + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_GET_PYTHON_VERSION, + async (_, projectPath: string): Promise> => { + try { + const result = await parseRequiredPythonVersionAsync(projectPath); + return { success: true, data: result }; + } catch (error) { + console.error('[CONDA_GET_PYTHON_VERSION] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to parse Python version', + }; + } + } + ); + + /** + * Install dependencies from a requirements file + * Streams progress events during installation + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_INSTALL_DEPS, + async ( + _, + envPath: string, + requirementsPath: string + ): Promise> => { + try { + const mainWindow = getMainWindow(); + + for await (const progress of installDependencies(envPath, requirementsPath)) { + mainWindow?.webContents.send(IPC_CHANNELS.CONDA_SETUP_PROGRESS, progress); + + if (progress.step === 'error') { + return { + success: false, + error: progress.message, + }; + } + } + + return { success: true }; + } catch (error) { + console.error('[CONDA_INSTALL_DEPS] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to install dependencies', + }; + } + } + ); + + /** + * Get computed paths for a project's Conda environment + * Returns paths based on detected project structure (pure-python vs mixed) + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_GET_PROJECT_PATHS, + async ( + _, + projectPath: string, + projectName: string + ): Promise> => { + try { + // Detect project structure + const structure = detectProjectStructure(projectPath); + + // Get computed paths + const envPath = getPythonEnvPath(projectPath, projectName); + const workspacePath = getWorkspaceFilePath(projectPath, projectName); + const scriptsPath = getScriptsPath(projectPath); + + // Compute relative paths for display + const pythonRootRelative = path.relative(projectPath, structure.pythonRoot) || '.'; + const envPathRelative = `.envs/${projectName}/`; + const scriptsPathRelative = `scripts/`; + const workspaceFile = `${projectName}.code-workspace`; + + return { + success: true, + data: { + projectType: structure.type, + pythonRoot: structure.pythonRoot, + pythonRootRelative: pythonRootRelative === '.' ? '' : pythonRootRelative, + envPath, + envPathRelative, + workspacePath, + workspaceFile, + scriptsPath, + scriptsPathRelative, + }, + }; + } catch (error) { + console.error('[CONDA_GET_PROJECT_PATHS] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get project paths', + }; + } + } + ); + + /** + * List available Python versions for environment creation + * Returns a list of common Python versions that can be installed via conda + */ + ipcMain.handle( + IPC_CHANNELS.CONDA_LIST_PYTHON_VERSIONS, + async ( + _, + projectPath?: string + ): Promise> => { + try { + // Common Python versions available via conda (from newest to oldest) + const commonVersions = ['3.13', '3.12', '3.11', '3.10', '3.9', '3.8']; + + // Default recommendation + let recommended = '3.12'; + let detectedVersion: string | undefined; + + // If project path is provided, detect the required version + if (projectPath) { + try { + const pythonVersion = await parseRequiredPythonVersionAsync(projectPath); + detectedVersion = pythonVersion.version; + // Use detected version as recommended if it's in our list + if (commonVersions.includes(pythonVersion.version)) { + recommended = pythonVersion.version; + } + } catch { + // Ignore errors - just use default + } + } + + return { + success: true, + data: { + versions: commonVersions, + recommended, + detectedVersion, + }, + }; + } catch (error) { + console.error('[CONDA_LIST_PYTHON_VERSIONS] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list Python versions', + }; + } + } + ); +} diff --git a/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts b/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts index f632b6de54..996753e0e6 100644 --- a/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts @@ -23,7 +23,7 @@ import { import { loadFileBasedMemories } from './memory-data-handlers'; import { parsePythonCommand } from '../../python-detector'; import { getConfiguredPythonPath } from '../../python-env-manager'; -import { getAugmentedEnv } from '../../env-utils'; +import { getAugmentedEnv, getSpawnOptions } from '../../env-utils'; /** * Load project index from file @@ -177,10 +177,10 @@ export function registerProjectContextHandlers( analyzerPath, '--project-dir', project.path, '--output', indexOutputPath - ], { + ], getSpawnOptions(pythonCommand, { cwd: project.path, env: getAugmentedEnv() - }); + })); proc.stdout?.on('data', (data) => { stdout += data.toString(); diff --git a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts index 81d8cd81c9..8eee0e2f2b 100644 --- a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts @@ -242,9 +242,11 @@ export function registerStartGhAuth(): void { const args = ['auth', 'login', '--web', '--scopes', 'repo']; debugLog('Spawning: gh', args); + const isWindows = process.platform === 'win32'; const ghProcess = spawn('gh', args, { stdio: ['pipe', 'pipe', 'pipe'], - env: getAugmentedEnv() + env: getAugmentedEnv(), + ...(isWindows && { windowsHide: true }) }); let output = ''; diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index cbd45da51a..778a1c52c9 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -22,7 +22,7 @@ import { getGitHubConfig, githubFetch } from "./utils"; import { readSettingsFile } from "../../settings-utils"; import { getAugmentedEnv } from "../../env-utils"; import { getMemoryService, getDefaultDbPath } from "../../memory-service"; -import type { Project, AppSettings } from "../../../shared/types"; +import type { Project, AppSettings, MergeReadiness } from "../../../shared/types"; import { createContextLogger } from "./utils/logger"; import { withProjectOrNull } from "./utils/project-middleware"; import { createIPCCommunicators } from "./utils/ipc-communicator"; @@ -180,22 +180,7 @@ export interface NewCommitsCheck { isMergeFromBase?: boolean; } -/** - * Lightweight merge readiness check result - * Used for real-time validation of AI verdict freshness - */ -export interface MergeReadiness { - /** PR is in draft mode */ - isDraft: boolean; - /** GitHub's mergeable status */ - mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; - /** Branch is behind base branch (out of date) */ - isBehind: boolean; - /** Simplified CI status */ - ciStatus: "passing" | "failing" | "pending" | "none"; - /** List of blockers that contradict a "ready to merge" verdict */ - blockers: string[]; -} +// MergeReadiness is imported from shared/types/integrations.ts /** * PR review memory stored in the memory layer diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts index e5bf026577..cc390f9129 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts @@ -113,6 +113,7 @@ export function runPythonSubprocess( const child = spawn(pythonCommand, [...pythonBaseArgs, ...options.args], { cwd: options.cwd, env: subprocessEnv, + ...(process.platform === 'win32' && { windowsHide: true }) }); const promise = new Promise>((resolve) => { diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts index f1a76fb387..f837670c25 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts @@ -10,6 +10,7 @@ import type { IPCResult } from '../../../shared/types'; import { getAugmentedEnv, findExecutable } from '../../env-utils'; import { openTerminalWithCommand } from '../claude-code-handlers'; import type { GitLabAuthStartResult } from './types'; +import { isWindows, isMac } from '../../python-path-utils'; const DEFAULT_GITLAB_URL = 'https://gitlab.com'; @@ -96,13 +97,40 @@ export function registerCheckGlabCli(): void { } debugLog('glab CLI found at:', glabPath); - const versionOutput = execFileSync('glab', ['--version'], { - encoding: 'utf-8', - stdio: 'pipe', - env: getAugmentedEnv() - }); - const version = versionOutput.trim().split('\n')[0]; - debugLog('glab version:', version); + // Get version using version command (more reliable than --version flag) + let version: string | undefined; + try { + const versionOutput = execFileSync(glabPath, ['version'], { + encoding: 'utf-8', + stdio: 'pipe', + env: getAugmentedEnv() + }); + console.log('[GitLab OAuth] Raw version output (using "version"):', versionOutput); + + // Extract version from output like "glab version 1.80.4" or "1.80.4" + const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+)/); + if (versionMatch) { + version = versionMatch[1]; + } + } catch (versionError) { + // Fallback to --version flag + try { + const versionOutput = execFileSync(glabPath, ['--version'], { + encoding: 'utf-8', + stdio: 'pipe', + env: getAugmentedEnv() + }); + console.log('[GitLab OAuth] Raw version output (using "--version"):', versionOutput); + + const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+)/); + if (versionMatch) { + version = versionMatch[1]; + } + } catch { + console.warn('[GitLab OAuth] Could not determine glab version'); + } + } + console.log('[GitLab OAuth] Parsed version:', version); return { success: true, @@ -129,13 +157,12 @@ export function registerInstallGlabCli(): void { async (): Promise> => { debugLog('installGitLabCli handler called'); try { - const platform = process.platform; let command: string; - if (platform === 'darwin') { + if (isMac()) { // macOS: Use Homebrew command = 'brew install glab'; - } else if (platform === 'win32') { + } else if (isWindows()) { // Windows: Use winget command = 'winget install --id GitLab.glab'; } else { @@ -244,7 +271,8 @@ export function registerStartGlabAuth(): void { const glabProcess = spawn('glab', args, { stdio: ['pipe', 'pipe', 'pipe'], - env: getAugmentedEnv() + env: getAugmentedEnv(), + ...(isWindows() && { windowsHide: true }) }); let output = ''; diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/utils.ts b/apps/frontend/src/main/ipc-handlers/gitlab/utils.ts index 0421323876..8e0dc6917e 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/utils.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/utils.ts @@ -103,6 +103,42 @@ function getTokenFromGlabCli(instanceUrl?: string): string | null { } } +/** + * Authenticate glab CLI with a token + * This allows Auto Claude to automatically configure glab when users save their GitLab token + * @returns true if authentication succeeded, false otherwise + */ +export function authenticateGlabCli(token: string, instanceUrl?: string): boolean { + try { + const safeToken = sanitizeToken(token); + if (!safeToken) return false; + + const normalized = parseInstanceUrl(instanceUrl || DEFAULT_GITLAB_URL); + if (!normalized) return false; + + const hostname = new URL(normalized).hostname; + + // Use --stdin to pass token securely + const args = ['auth', 'login', '--stdin', '--hostname', hostname]; + + // Use execFileSync with input option for secure token passing + // Use explicit stdio array for cross-platform compatibility with input option + // Add timeout to prevent UI hangs if glab is unresponsive + execFileSync('glab', args, { + input: safeToken + '\n', + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...getAugmentedEnv(), GLAB_NO_PROMPT: '1' }, + timeout: 30000 // 30 second timeout to prevent UI hangs + }); + + return true; + } catch (error) { + console.error('[GitLab] Failed to authenticate glab CLI:', error); + return false; + } +} + // GitLab environment variable keys (must match env-handlers.ts) const GITLAB_ENV_KEYS = { ENABLED: 'GITLAB_ENABLED', diff --git a/apps/frontend/src/main/ipc-handlers/index.ts b/apps/frontend/src/main/ipc-handlers/index.ts index b3ee57212b..fe557a1b00 100644 --- a/apps/frontend/src/main/ipc-handlers/index.ts +++ b/apps/frontend/src/main/ipc-handlers/index.ts @@ -32,6 +32,7 @@ import { registerDebugHandlers } from './debug-handlers'; import { registerClaudeCodeHandlers } from './claude-code-handlers'; import { registerMcpHandlers } from './mcp-handlers'; import { registerProfileHandlers } from './profile-handlers'; +import { registerCondaHandlers } from './conda-handlers'; import { registerTerminalWorktreeIpcHandlers } from './terminal'; import { notificationService } from '../notification-service'; @@ -118,6 +119,9 @@ export function setupIpcHandlers( // API Profile handlers (custom Anthropic-compatible endpoints) registerProfileHandlers(); + // Conda environment management handlers + registerCondaHandlers(getMainWindow); + console.warn('[IPC] All handler modules registered successfully'); } @@ -144,5 +148,6 @@ export { registerDebugHandlers, registerClaudeCodeHandlers, registerMcpHandlers, - registerProfileHandlers + registerProfileHandlers, + registerCondaHandlers }; diff --git a/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts b/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts index 50e16973e4..8baf516ca8 100644 --- a/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts @@ -196,6 +196,7 @@ async function checkCommandHealth(server: CustomMcpServer, startTime: number): P const command = process.platform === 'win32' ? 'where' : 'which'; const proc = spawn(command, [server.command!], { timeout: 5000, + ...(process.platform === 'win32' && { windowsHide: true }) }); let found = false; @@ -422,6 +423,7 @@ async function testCommandConnection(server: CustomMcpServer, startTime: number) stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000, // OS-level timeout for reliable process termination shell: process.platform === 'win32', // Required for Windows to run npx.cmd + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; diff --git a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts index 72d786a261..b0d46eb5c1 100644 --- a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts @@ -313,6 +313,7 @@ async function executeOllamaDetector( // Use sanitized Python environment to prevent PYTHONHOME contamination // Fixes "Could not find platform independent libraries" error on Windows env: pythonEnvManager.getPythonEnv(), + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; @@ -789,6 +790,7 @@ export function registerMemoryHandlers(): void { // Use sanitized Python environment to prevent PYTHONHOME contamination // Fixes "Could not find platform independent libraries" error on Windows env: pythonEnvManager.getPythonEnv(), + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 968c44def7..1ef5720137 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -21,6 +21,13 @@ import { setUpdateChannel, setUpdateChannelWithDowngradeCheck } from '../app-upd import { getSettingsPath, readSettingsFile } from '../settings-utils'; import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform, preWarmToolCache } from '../cli-tool-manager'; import { parseEnvFile } from './utils'; +import { + validatePythonPackages, + installPythonRequirements, + validatePythonEnvironment, + reinstallPythonEnvironment, + getEnvironmentPathFromScript, +} from '../python-env-manager'; const settingsPath = getSettingsPath(); @@ -414,9 +421,11 @@ export function registerSettingsHandlers( IPC_CHANNELS.SHELL_OPEN_EXTERNAL, async (_, url: string): Promise => { // Validate URL scheme to prevent opening dangerous protocols + // Allow: http/https (web links), vscode (VS Code workspace opener) try { const parsedUrl = new URL(url); - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + const allowedProtocols = ['http:', 'https:', 'vscode:']; + if (!allowedProtocols.includes(parsedUrl.protocol)) { console.warn(`[SHELL_OPEN_EXTERNAL] Blocked URL with unsafe protocol: ${parsedUrl.protocol}`); throw new Error(`Unsafe URL protocol: ${parsedUrl.protocol}`); } @@ -432,6 +441,21 @@ export function registerSettingsHandlers( } ); + ipcMain.handle( + IPC_CHANNELS.SHELL_SHOW_ITEM_IN_FOLDER, + async (_, filePath: string): Promise => { + // Validate path exists and show it in the native file explorer + if (!filePath || typeof filePath !== 'string') { + throw new Error('File path is required'); + } + const resolvedPath = path.resolve(filePath); + if (!existsSync(resolvedPath)) { + throw new Error(`Path does not exist: ${resolvedPath}`); + } + shell.showItemInFolder(resolvedPath); + } + ); + ipcMain.handle( IPC_CHANNELS.SHELL_OPEN_TERMINAL, async (_, dirPath: string): Promise> => { @@ -761,4 +785,86 @@ export function registerSettingsHandlers( } } ); + + // ============================================ + // Python Package Validation + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.PYTHON_VALIDATE_PACKAGES, + async (event, { pythonPath, activationScript }: { pythonPath: string; activationScript?: string }) => { + try { + console.log('[PYTHON_VALIDATE_PACKAGES] Starting validation...'); + const validation = await validatePythonPackages( + pythonPath, + activationScript, + (current: number, total: number, packageName: string) => { + event.sender.send(IPC_CHANNELS.PYTHON_VALIDATION_PROGRESS, { current, total, packageName }); + } + ); + console.log('[PYTHON_VALIDATE_PACKAGES] Validation complete:', validation); + return { success: true, data: validation }; + } catch (error) { + console.error('[PYTHON_VALIDATE_PACKAGES] Error:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to validate packages' }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.PYTHON_INSTALL_REQUIREMENTS, + async (event, { pythonPath, activationScript }: { pythonPath: string; activationScript?: string }) => { + try { + await installPythonRequirements(pythonPath, activationScript, (progress: string) => { + event.sender.send(IPC_CHANNELS.PYTHON_INSTALL_PROGRESS, progress); + }); + return { success: true }; + } catch (error) { + console.error('[PYTHON_INSTALL_REQUIREMENTS] Error:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to install requirements' }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.PYTHON_VALIDATE_ENVIRONMENT, + async (_event, { activationScript }: { activationScript: string }) => { + try { + const validation = await validatePythonEnvironment(activationScript); + console.log('[PYTHON_VALIDATE_ENVIRONMENT] Validation result:', validation); + return { success: true, data: validation }; + } catch (error) { + console.error('[PYTHON_VALIDATE_ENVIRONMENT] Error:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to validate environment' }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.PYTHON_REINSTALL_ENVIRONMENT, + async (event, { activationScript, pythonVersion }: { activationScript: string; pythonVersion?: string }) => { + try { + const envPath = getEnvironmentPathFromScript(activationScript); + if (!envPath) { + return { success: false, error: 'Could not extract environment path from activation script' }; + } + + event.sender.send(IPC_CHANNELS.PYTHON_REINSTALL_PROGRESS, { step: 'Starting environment reinstall', completed: 0, total: 3 }); + + const result = await reinstallPythonEnvironment( + envPath, + pythonVersion || '3.12', + (step: string, completed: number, total: number) => { + event.sender.send(IPC_CHANNELS.PYTHON_REINSTALL_PROGRESS, { step, completed, total }); + } + ); + + console.log('[PYTHON_REINSTALL_ENVIRONMENT] Result:', result); + return { success: true, data: result }; + } catch (error) { + console.error('[PYTHON_REINSTALL_ENVIRONMENT] Error:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to reinstall environment' }; + } + } + ); } diff --git a/apps/frontend/src/main/ipc-handlers/task/shared.ts b/apps/frontend/src/main/ipc-handlers/task/shared.ts index a72e9b8136..c3c2f5fce2 100644 --- a/apps/frontend/src/main/ipc-handlers/task/shared.ts +++ b/apps/frontend/src/main/ipc-handlers/task/shared.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import type { Task, Project } from '../../../shared/types'; import { projectStore } from '../../project-store'; @@ -20,3 +21,16 @@ export const findTaskAndProject = (taskId: string): { task: Task | undefined; pr return { task, project }; }; + +/** + * Get the spec directory path for a task + * Uses specsPath if available, otherwise uses project path + */ +export const getSpecDir = (task: Task, project: Project): string => { + // If task has specsPath set (full path to specs dir), use it directly + if (task.specsPath) { + return task.specsPath; + } + // Otherwise, construct from project path + return path.join(project.path, '.auto-claude', 'specs', task.specId); +}; diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 272dcc4e6b..6ee4bb067d 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -1994,7 +1994,8 @@ export function registerWorktreeHandlers( UTILITY_MODEL_ID: utilitySettings.modelId, UTILITY_THINKING_BUDGET: utilitySettings.thinkingBudget === null ? '' : (utilitySettings.thinkingBudget?.toString() || '') }, - stdio: ['ignore', 'pipe', 'pipe'] // Don't connect stdin to avoid blocking + stdio: ['ignore', 'pipe', 'pipe'], // Don't connect stdin to avoid blocking + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; @@ -2482,7 +2483,8 @@ export function registerWorktreeHandlers( const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath); const previewProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], { cwd: sourcePath, - env: { ...process.env, ...previewPythonEnv, ...previewProfileEnv, PYTHONUNBUFFERED: '1', PYTHONUTF8: '1', DEBUG: 'true' } + env: { ...process.env, ...previewPythonEnv, ...previewProfileEnv, PYTHONUNBUFFERED: '1', PYTHONUTF8: '1', DEBUG: 'true' }, + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; @@ -3017,7 +3019,8 @@ export function registerWorktreeHandlers( PYTHONUNBUFFERED: '1', PYTHONUTF8: '1' }, - stdio: ['ignore', 'pipe', 'pipe'] + stdio: ['ignore', 'pipe', 'pipe'], + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; diff --git a/apps/frontend/src/main/memory-service.ts b/apps/frontend/src/main/memory-service.ts index 6efc625edf..d079468c82 100644 --- a/apps/frontend/src/main/memory-service.ts +++ b/apps/frontend/src/main/memory-service.ts @@ -213,6 +213,7 @@ async function executeQuery( timeout, // Use pythonEnv which combines sanitized env + site-packages for real_ladybug env: pythonEnv, + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; @@ -355,6 +356,7 @@ async function executeSemanticQuery( stdio: ['ignore', 'pipe', 'pipe'], env, timeout, + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; diff --git a/apps/frontend/src/main/python-detector.ts b/apps/frontend/src/main/python-detector.ts index e15bcd4512..776439e664 100644 --- a/apps/frontend/src/main/python-detector.ts +++ b/apps/frontend/src/main/python-detector.ts @@ -475,3 +475,28 @@ export function getValidatedPythonPath(providedPath: string | undefined, service console.error(`[${serviceName}] Invalid Python path rejected: ${validation.reason}`); return findPythonCommand() || 'python'; } + +/** + * Validate that an activation script path is safe to execute. + * Only allows paths matching known conda environment patterns. + */ +export function isValidActivationScript(scriptPath: string): boolean { + if (!scriptPath) return true; // Empty is valid (no activation) + + const CONDA_ACTIVATION_PATTERNS = [ + // Windows conda + /^[A-Za-z]:\\.*\\anaconda\d*\\Scripts\\activate\.bat$/i, + /^[A-Za-z]:\\.*\\miniconda\d*\\Scripts\\activate\.bat$/i, + /^[A-Za-z]:\\.*\\anaconda\d*\\envs\\[^\\]+\\Scripts\\activate\.bat$/i, + /^[A-Za-z]:\\.*\\miniconda\d*\\envs\\[^\\]+\\Scripts\\activate\.bat$/i, + /^[A-Za-z]:\\.*\\\.conda\\envs\\[^\\]+\\Scripts\\activate\.bat$/i, + // Unix conda + /^.*\/anaconda\d*\/bin\/activate$/, + /^.*\/miniconda\d*\/bin\/activate$/, + /^.*\/anaconda\d*\/envs\/[^/]+\/bin\/activate$/, + /^.*\/miniconda\d*\/envs\/[^/]+\/bin\/activate$/, + /^.*\/.conda\/envs\/[^/]+\/bin\/activate$/, + ]; + + return CONDA_ACTIVATION_PATTERNS.some(pattern => pattern.test(scriptPath)); +} diff --git a/apps/frontend/src/main/python-env-manager.ts b/apps/frontend/src/main/python-env-manager.ts index 84c8e77cac..119b132669 100644 --- a/apps/frontend/src/main/python-env-manager.ts +++ b/apps/frontend/src/main/python-env-manager.ts @@ -1,10 +1,20 @@ import { spawn, execSync, ChildProcess } from 'child_process'; -import { existsSync, readdirSync } from 'fs'; +import { existsSync, readdirSync, readFileSync } from 'fs'; import path from 'path'; +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { fileURLToPath } from 'url'; + +const execAsync = promisify(exec); import { EventEmitter } from 'events'; + +// ESM-compatible __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); import { app } from 'electron'; import { findPythonCommand, getBundledPythonPath } from './python-detector'; import { isLinux, isWindows } from './platform'; +import { getVenvPythonPath as getVenvPythonPathUtil } from './python-path-utils'; export interface PythonEnvStatus { ready: boolean; @@ -67,12 +77,7 @@ export class PythonEnvManager extends EventEmitter { const venvPath = this.getVenvBasePath(); if (!venvPath) return null; - const venvPython = - isWindows() - ? path.join(venvPath, 'Scripts', 'python.exe') - : path.join(venvPath, 'bin', 'python'); - - return venvPython; + return getVenvPythonPathUtil(venvPath); } /** @@ -104,11 +109,11 @@ export class PythonEnvManager extends EventEmitter { const sitePackagesPath = path.join(process.resourcesPath, 'python-site-packages'); if (existsSync(sitePackagesPath)) { - console.log(`[PythonEnvManager] Found bundled site-packages at: ${sitePackagesPath}`); + console.warn(`[PythonEnvManager] Found bundled site-packages at: ${sitePackagesPath}`); return sitePackagesPath; } - console.log(`[PythonEnvManager] Bundled site-packages not found at: ${sitePackagesPath}`); + console.warn(`[PythonEnvManager] Bundled site-packages not found at: ${sitePackagesPath}`); return null; } @@ -160,7 +165,7 @@ export class PythonEnvManager extends EventEmitter { // Log missing packages for debugging for (const pkg of missingPackages) { - console.log( + console.warn( `[PythonEnvManager] Missing critical package: ${pkg} at ${path.join(sitePackagesPath, pkg)}` ); } @@ -176,9 +181,9 @@ export class PythonEnvManager extends EventEmitter { // Also check marker for logging purposes const markerPath = path.join(sitePackagesPath, '.bundled'); if (existsSync(markerPath)) { - console.log(`[PythonEnvManager] Found bundle marker and all critical packages`); + console.warn(`[PythonEnvManager] Found bundle marker and all critical packages`); } else { - console.log(`[PythonEnvManager] Found critical packages (marker missing)`); + console.warn(`[PythonEnvManager] Found critical packages (marker missing)`); } return true; } @@ -239,7 +244,7 @@ if sys.version_info >= (3, 12): // If this is the bundled Python path, use it directly const bundledPath = getBundledPythonPath(); if (bundledPath && pythonCmd === bundledPath) { - console.log(`[PythonEnvManager] Using bundled Python: ${bundledPath}`); + console.warn(`[PythonEnvManager] Using bundled Python: ${bundledPath}`); return bundledPath; } @@ -251,7 +256,7 @@ if sys.version_info >= (3, 12): timeout: 5000 }).toString().trim(); - console.log(`[PythonEnvManager] Found Python at: ${pythonPath}`); + console.warn(`[PythonEnvManager] Found Python at: ${pythonPath}`); return pythonPath; } catch (err) { console.error(`[PythonEnvManager] Failed to get Python path for ${pythonCmd}:`, err); @@ -286,7 +291,8 @@ if sys.version_info >= (3, 12): return new Promise((resolve) => { const proc = spawn(systemPython, ['-m', 'venv', venvPath], { cwd: this.autoBuildSourcePath!, - stdio: 'pipe' + stdio: 'pipe', + ...(process.platform === 'win32' && { windowsHide: true }) }); // Track the process for cleanup on app exit @@ -357,7 +363,8 @@ if sys.version_info >= (3, 12): return new Promise((resolve) => { const proc = spawn(venvPython, ['-m', 'ensurepip'], { cwd: this.autoBuildSourcePath!, - stdio: 'pipe' + stdio: 'pipe', + ...(process.platform === 'win32' && { windowsHide: true }) }); let stderr = ''; @@ -411,7 +418,8 @@ if sys.version_info >= (3, 12): // Use python -m pip for better compatibility across Python versions const proc = spawn(venvPython, ['-m', 'pip', 'install', '-r', requirementsPath], { cwd: this.autoBuildSourcePath!, - stdio: 'pipe' + stdio: 'pipe', + ...(process.platform === 'win32' && { windowsHide: true }) }); let stdout = ''; @@ -782,3 +790,692 @@ export function getConfiguredPythonPath(): string { // Fall back to system/bundled Python return findPythonCommand() || 'python'; } + +/** + * Get requirements.txt path (bundled in packaged app, or dev mode) + */ +function getRequirementsTxtPath(): string | null { + if (app.isPackaged) { + const bundled = path.join(process.resourcesPath, 'backend', 'requirements.txt'); + if (existsSync(bundled)) return bundled; + } + const dev = path.join(__dirname, '..', '..', '..', 'backend', 'requirements.txt'); + if (existsSync(dev)) return dev; + return null; +} + +/** + * Build shell command that activates conda then runs Python + */ +function buildPythonCommandWithActivation( + pythonPath: string, + activationScript?: string +): string { + if (!activationScript || !existsSync(activationScript)) { + return pythonPath; + } + + if (process.platform === 'win32') { + // Check if it's a PowerShell script (.ps1) + if (activationScript.toLowerCase().endsWith('.ps1')) { + // PowerShell: & "script.ps1"; python + return `powershell -NoProfile -Command "& '${activationScript}'; & '${pythonPath}'"`; + } else { + // Batch file: call activate.bat && python + return `call "${activationScript}" && "${pythonPath}"`; + } + } else { + return `source "${activationScript}" && "${pythonPath}"`; + } +} + +/** + * Parse requirements.txt and extract package names + * Filters out packages that have platform-specific markers that don't match the current platform + */ +function parseRequirementsTxt(requirementsPath: string): string[] { + const content = readFileSync(requirementsPath, 'utf-8'); + const packages: string[] = []; + const currentPlatform = process.platform; // 'win32', 'linux', 'darwin' + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) continue; + + // Skip packages with environment markers that won't be installed + // For example: tomli>=2.0.0; python_version < "3.11" won't be installed on Python 3.12 + if (trimmed.includes('python_version < "3.11"') || trimmed.includes("python_version < '3.11'")) { + continue; // Skip packages only for Python < 3.11 + } + + // Skip packages with platform markers that don't match current platform + // secretstorage>=3.3.3; sys_platform == "linux" should be skipped on Windows + if (trimmed.includes('sys_platform')) { + // Check for Linux-only packages on non-Linux + if (trimmed.includes('sys_platform == "linux"') || trimmed.includes("sys_platform == 'linux'")) { + if (currentPlatform !== 'linux') { + continue; // Skip Linux-only packages on Windows/macOS + } + } + // Check for Windows-only packages on non-Windows + if (trimmed.includes('sys_platform == "win32"') || trimmed.includes("sys_platform == 'win32'")) { + if (currentPlatform !== 'win32') { + continue; // Skip Windows-only packages on Linux/macOS + } + } + // Check for macOS-only packages on non-macOS + if (trimmed.includes('sys_platform == "darwin"') || trimmed.includes("sys_platform == 'darwin'")) { + if (currentPlatform !== 'darwin') { + continue; // Skip macOS-only packages on Windows/Linux + } + } + } + + // Extract package name (before version specifier or semicolon) + const match = trimmed.match(/^([a-zA-Z0-9._-]+)/); + if (match) { + packages.push(match[1]); + } + } + + return packages; +} + +/** + * Get Python installation location (where packages will be installed) + */ +export async function getPythonInstallLocation( + pythonPath: string, + activationScript?: string +): Promise { + // Use Python directly without activation for location detection + const pythonCmd = pythonPath; + + try { + const { stdout, stderr } = await execAsync(`"${pythonCmd}" -c "import sys; print(sys.prefix)"`, { + timeout: 5000 + }); + return (stdout || stderr).trim(); + } catch (error) { + throw new Error(`Failed to get Python installation location: ${error}`); + } +} + +/** + * Validate if Python packages are installed + * Uses pip list to check all packages from requirements.txt efficiently + */ +export async function validatePythonPackages( + pythonPath: string, + activationScript?: string, + onProgress?: (current: number, total: number, packageName: string) => void +): Promise<{ allInstalled: boolean; missingPackages: string[]; installLocation: string }> { + const requirementsPath = getRequirementsTxtPath(); + if (!requirementsPath) { + throw new Error('requirements.txt not found'); + } + + // For validation/installation, use the Python executable directly + // without activation script. If pointing to a conda env's python.exe, + // it already knows its packages. Activation scripts are only needed + // for interactive terminals. + const pythonCmd = pythonPath; + + // Get installation location + let installLocation = ''; + try { + installLocation = await getPythonInstallLocation(pythonPath, activationScript); + console.warn('[validatePythonPackages] Install location:', installLocation); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[validatePythonPackages] Failed to get install location:', errorMsg); + installLocation = `Unknown location (${errorMsg})`; + } + + // Get list of installed packages + onProgress?.(1, 2, 'Getting installed packages'); + let installedPackages: Set; + try { + const { stdout } = await execAsync(`"${pythonCmd}" -m pip list --format=freeze`, { + timeout: 30000 + }); + const pipList = stdout; + + // Parse pip list output (format: package-name==version) + installedPackages = new Set( + pipList + .split('\n') + .map(line => line.split('==')[0].toLowerCase().trim()) + .filter(Boolean) + ); + } catch (error) { + throw new Error(`Failed to get installed packages: ${error}`); + } + + // Parse requirements.txt to get required packages + onProgress?.(2, 2, 'Checking requirements'); + const requiredPackages = parseRequirementsTxt(requirementsPath); + const missingPackages: string[] = []; + + for (const pkg of requiredPackages) { + const normalizedPkg = pkg.toLowerCase(); + if (!installedPackages.has(normalizedPkg)) { + missingPackages.push(pkg); + } + } + + return { + allInstalled: missingPackages.length === 0, + missingPackages, + installLocation + }; +} + +/** + * Install Python requirements from requirements.txt + */ +export async function installPythonRequirements( + pythonPath: string, + activationScript?: string, + onProgress?: (message: string) => void +): Promise { + const requirementsPath = getRequirementsTxtPath(); + if (!requirementsPath) { + throw new Error('requirements.txt not found'); + } + + onProgress?.('Installing Python dependencies...'); + + return new Promise((resolve, reject) => { + // Use Python directly without shell to avoid quote issues + const proc = spawn(pythonPath, ['-m', 'pip', 'install', '-r', requirementsPath], { + stdio: 'pipe' + }); + + proc.stdout?.on('data', (data) => onProgress?.(data.toString())); + proc.stderr?.on('data', (data) => onProgress?.(data.toString())); + + proc.on('close', (code) => { + if (code === 0) { + onProgress?.('Installation complete'); + resolve(); + } else { + reject(new Error(`pip install failed with code ${code}`)); + } + }); + + proc.on('error', reject); + }); +} + +/** + * Validate Python environment (version, existence) + */ +export async function validatePythonEnvironment( + activationScript: string +): Promise<{ + valid: boolean; + pythonPath: string | null; + version: string | null; + error: string | null; + status: 'valid' | 'missing' | 'wrong_version' | 'error'; +}> { + try { + // Extract environment path from activation script + const envPath = getEnvironmentPathFromScript(activationScript); + if (!envPath) { + return { + valid: false, + pythonPath: null, + version: null, + error: 'Could not extract environment path from activation script', + status: 'error' + }; + } + + // Determine Python executable name based on platform + const pythonExeName = process.platform === 'win32' ? 'python.exe' : 'python'; + const pythonPath = path.join(envPath, process.platform === 'win32' ? '' : 'bin', pythonExeName); + + // Check if Python executable exists + if (!existsSync(pythonPath)) { + return { + valid: false, + pythonPath, + version: null, + error: `Python executable not found: ${pythonPath}`, + status: 'missing' + }; + } + + // Get Python version (async to avoid blocking) + try { + const { stdout, stderr } = await execAsync(`"${pythonPath}" --version`, { + timeout: 5000 + }); + const versionOutput = (stdout || stderr).trim(); + + // Parse version "Python 3.12.1" -> (3, 12) + const versionMatch = versionOutput.match(/Python (\d+)\.(\d+)/); + if (!versionMatch) { + return { + valid: false, + pythonPath, + version: versionOutput, + error: 'Could not parse Python version', + status: 'error' + }; + } + + const major = parseInt(versionMatch[1]); + const minor = parseInt(versionMatch[2]); + + // Check version requirement (3.12+) + if (major < 3 || (major === 3 && minor < 12)) { + return { + valid: false, + pythonPath, + version: versionOutput, + error: `Python version ${versionOutput} is below required 3.12`, + status: 'wrong_version' + }; + } + + // All checks passed + return { + valid: true, + pythonPath, + version: versionOutput, + error: null, + status: 'valid' + }; + } catch (error) { + return { + valid: false, + pythonPath, + version: null, + error: `Failed to get Python version: ${error instanceof Error ? error.message : String(error)}`, + status: 'error' + }; + } + } catch (error) { + return { + valid: false, + pythonPath: null, + version: null, + error: `Validation failed: ${error instanceof Error ? error.message : String(error)}`, + status: 'error' + }; + } +} + +/** + * Reinstall Python environment by nuking and recreating with conda + */ +export async function reinstallPythonEnvironment( + environmentPath: string, + pythonVersion: string = '3.12', + onProgress?: (step: string, completed: number, total: number) => void +): Promise<{ + success: boolean; + environmentPath: string | null; + pythonVersion: string | null; + error: string | null; + stepsCompleted: string[]; +}> { + const stepsCompleted: string[] = []; + + try { + // Step 1: Remove existing environment + onProgress?.('Removing existing environment', 0, 3); + if (existsSync(environmentPath)) { + try { + const fs = await import('fs/promises'); + await fs.rm(environmentPath, { recursive: true, force: true }); + stepsCompleted.push(`Removed existing environment: ${environmentPath}`); + } catch (error) { + return { + success: false, + environmentPath, + pythonVersion: null, + error: `Failed to remove existing environment: ${error instanceof Error ? error.message : String(error)}`, + stepsCompleted + }; + } + } + + // Step 2: Find conda executable (cross-platform) + onProgress?.('Finding conda executable', 1, 3); + + let condaPaths: string[]; + if (process.platform === 'win32') { + // Windows: look for conda.bat in common locations + condaPaths = [ + path.join(process.env.USERPROFILE || '', 'miniconda3', 'condabin', 'conda.bat'), + path.join(process.env.USERPROFILE || '', 'anaconda3', 'condabin', 'conda.bat'), + path.join(process.env.LOCALAPPDATA || '', 'miniconda3', 'condabin', 'conda.bat'), + path.join(process.env.LOCALAPPDATA || '', 'anaconda3', 'condabin', 'conda.bat'), + process.env.CONDA_EXE || '' + ]; + } else { + // Linux/macOS: look for conda in common locations + const homeDir = process.env.HOME || ''; + condaPaths = [ + path.join(homeDir, 'miniconda3', 'bin', 'conda'), + path.join(homeDir, 'anaconda3', 'bin', 'conda'), + path.join('/opt', 'miniconda3', 'bin', 'conda'), + path.join('/opt', 'anaconda3', 'bin', 'conda'), + path.join('/usr', 'local', 'miniconda3', 'bin', 'conda'), + path.join('/usr', 'local', 'anaconda3', 'bin', 'conda'), + process.env.CONDA_EXE || '' + ]; + } + + let condaExe: string | null = null; + for (const condaPath of condaPaths) { + if (condaPath && existsSync(condaPath)) { + condaExe = condaPath; + break; + } + } + + // If not found in specific paths, try PATH + if (!condaExe) { + try { + const { stdout } = await execAsync(process.platform === 'win32' ? 'where conda' : 'which conda', { + timeout: 5000 + }); + const foundPath = stdout.trim().split('\n')[0]; + if (foundPath && existsSync(foundPath)) { + condaExe = foundPath; + } + } catch (error) { + // conda not in PATH + } + } + + if (!condaExe) { + return { + success: false, + environmentPath, + pythonVersion: null, + error: 'Could not find conda executable. Please ensure conda is installed and in PATH.', + stepsCompleted + }; + } + + stepsCompleted.push(`Found conda: ${condaExe}`); + + // Step 3: Create new conda environment (async via spawn) + onProgress?.('Creating new conda environment', 2, 3); + return new Promise((resolve) => { + const proc = spawn(condaExe!, [ + 'create', + '-p', + environmentPath, + `python=${pythonVersion}`, + '-y' + ], { + stdio: 'pipe', + shell: true // Required for .bat/.cmd files on Windows + }); + + let stderr = ''; + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', async (code) => { + if (code !== 0) { + resolve({ + success: false, + environmentPath, + pythonVersion: null, + error: `Conda create failed with code ${code}: ${stderr}`, + stepsCompleted + }); + return; + } + + stepsCompleted.push(`Created conda environment with Python ${pythonVersion}`); + + // Step 4: Verify Python installation (cross-platform) + const pythonExeName = process.platform === 'win32' ? 'python.exe' : 'python'; + const pythonExe = path.join(environmentPath, process.platform === 'win32' ? '' : 'bin', pythonExeName); + if (!existsSync(pythonExe)) { + resolve({ + success: false, + environmentPath, + pythonVersion: null, + error: `Python executable not found after installation: ${pythonExe}`, + stepsCompleted + }); + return; + } + + // Get installed Python version + try { + const { stdout, stderr } = await execAsync(`"${pythonExe}" --version`, { + timeout: 5000 + }); + const installedVersion = (stdout || stderr).trim(); + stepsCompleted.push(`Verified Python installation: ${installedVersion}`); + + resolve({ + success: true, + environmentPath, + pythonVersion: installedVersion, + error: null, + stepsCompleted + }); + } catch (error) { + resolve({ + success: false, + environmentPath, + pythonVersion: null, + error: `Failed to verify Python installation: ${error instanceof Error ? error.message : String(error)}`, + stepsCompleted + }); + } + }); + + proc.on('error', (error) => { + resolve({ + success: false, + environmentPath, + pythonVersion: null, + error: `Failed to start conda: ${error.message}`, + stepsCompleted + }); + }); + }); + } catch (error) { + return { + success: false, + environmentPath, + pythonVersion: null, + error: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, + stepsCompleted + }; + } +} + +/** + * Expand environment variables in a path (cross-platform) + * Handles both Windows (%VAR%) and Unix ($VAR or ${VAR}) syntax + */ +function expandEnvironmentVariables(pathStr: string): string { + // Windows: Replace %VARIABLE% with the actual environment variable value + let expanded = pathStr.replace(/%([^%]+)%/g, (_, varName) => { + return process.env[varName] || `%${varName}%`; + }); + + // Unix: Replace $VARIABLE or ${VARIABLE} with the actual environment variable value + expanded = expanded.replace(/\$\{([^}]+)\}/g, (_, varName) => { + return process.env[varName] || `\${${varName}}`; + }); + + expanded = expanded.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, varName) => { + return process.env[varName] || `$${varName}`; + }); + + return expanded; +} + +/** + * Extract environment path from activation script (cross-platform) + * Supports both Windows (.bat/.cmd) and Unix (.sh) activation scripts + */ +export function getEnvironmentPathFromScript(activationScript: string): string | null { + try { + if (!existsSync(activationScript)) { + return null; + } + + const scriptContent = readFileSync(activationScript, 'utf-8'); + + // Detect script type + const isWindowsScript = activationScript.endsWith('.bat') || activationScript.endsWith('.cmd') || activationScript.endsWith('.ps1'); + const isPowerShellScript = activationScript.endsWith('.ps1'); + + for (const line of scriptContent.split('\n')) { + const trimmedLine = line.trim(); + + // Skip empty lines and comments + if (!trimmedLine) { + continue; + } + + // Skip Windows batch comments (:: or REM) + if (isWindowsScript && !isPowerShellScript && (trimmedLine.startsWith('::') || trimmedLine.startsWith('REM'))) { + continue; + } + + // Skip Unix and PowerShell comments (#) + if ((!isWindowsScript || isPowerShellScript) && trimmedLine.startsWith('#')) { + continue; + } + + // Pattern 1: conda activate (works on all platforms) + if (trimmedLine.includes('conda activate')) { + const match = trimmedLine.match(/conda\s+activate\s+(.+)/i); + if (match) { + let envPath = match[1].trim().replace(/['"]/g, '').replace(/\s+$/, ''); + if (envPath) { + envPath = expandEnvironmentVariables(envPath); + return envPath; + } + } + } + + // Pattern 2 (Windows): call "path\to\activate.bat" + if (isWindowsScript && trimmedLine.includes('activate.bat')) { + const match = trimmedLine.match(/activate\.bat["']?\s+(.+)/i); + if (match) { + let envPath = match[1].trim().replace(/['"]/g, '').replace(/\s+$/, ''); + if (envPath) { + envPath = expandEnvironmentVariables(envPath); + return envPath; + } + } + } + + // Pattern 3 (Unix): source activate or . activate + if (!isWindowsScript && (trimmedLine.includes('source activate') || /^\.\s+activate/.test(trimmedLine))) { + const match = trimmedLine.match(/(?:source|\.)\s+activate\s+(.+)/i); + if (match) { + let envPath = match[1].trim().replace(/['"]/g, '').replace(/\s+$/, ''); + if (envPath) { + envPath = expandEnvironmentVariables(envPath); + return envPath; + } + } + } + + // Pattern 4 (Windows): SET CONDA_PREFIX= + if (isWindowsScript && (trimmedLine.startsWith('SET CONDA_PREFIX=') || trimmedLine.startsWith('set CONDA_PREFIX='))) { + const match = trimmedLine.match(/SET\s+CONDA_PREFIX=(.+)/i); + if (match) { + let envPath = match[1].trim().replace(/['"]/g, '').replace(/\s+$/, ''); + if (envPath) { + envPath = expandEnvironmentVariables(envPath); + return envPath; + } + } + } + + // Pattern 5 (Unix): export CONDA_PREFIX= or CONDA_PREFIX= + if (!isWindowsScript && trimmedLine.includes('CONDA_PREFIX=')) { + const match = trimmedLine.match(/(?:export\s+)?CONDA_PREFIX=(.+)/); + if (match) { + let envPath = match[1].trim().replace(/['"]/g, '').replace(/\s+$/, ''); + if (envPath) { + envPath = expandEnvironmentVariables(envPath); + return envPath; + } + } + } + + // Pattern 6 (PowerShell): $env:CONDA_PREFIX = "" + if (isPowerShellScript && trimmedLine.includes('$env:CONDA_PREFIX')) { + const match = trimmedLine.match(/\$env:CONDA_PREFIX\s*=\s*(.+)/i); + if (match) { + let envPath = match[1].trim().replace(/['"]/g, '').replace(/\s+$/, ''); + if (envPath) { + envPath = expandEnvironmentVariables(envPath); + return envPath; + } + } + } + + // Pattern 7 (PowerShell): & conda.exe activate "" or & "\conda.exe" activate "" + if (isPowerShellScript && trimmedLine.includes('conda') && trimmedLine.includes('activate')) { + // Match: & "path\conda.exe" activate "envpath" + const match = trimmedLine.match(/&\s*["']?[^"']*conda(?:\.exe)?["']?\s+activate\s+["']?([^"']+)["']?/i); + if (match) { + let envPath = match[1].trim().replace(/['"]/g, '').replace(/\s+$/, ''); + if (envPath) { + envPath = expandEnvironmentVariables(envPath); + return envPath; + } + } + } + + // Pattern 8: Extract path from script location (if script is in envs\\Scripts\) + // This handles cases where the script itself indicates the environment location + if (isPowerShellScript || isWindowsScript) { + // Check if we can derive env path from the activation script path itself + // e.g., C:\Users\Jason\miniconda3\envs\auto-claude\Scripts\auto-claude-init.ps1 + // -> C:\Users\Jason\miniconda3\envs\auto-claude + const scriptDir = path.dirname(activationScript); + if (scriptDir.toLowerCase().endsWith('scripts')) { + const potentialEnvPath = path.dirname(scriptDir); + // Verify it looks like a conda env (has conda-meta folder or python.exe) + const condaMetaPath = path.join(potentialEnvPath, 'conda-meta'); + const pythonPath = path.join(potentialEnvPath, 'python.exe'); + if (existsSync(condaMetaPath) || existsSync(pythonPath)) { + return potentialEnvPath; + } + } + } + } + + // Fallback: Try to derive from script path for Windows conda environments + if (isWindowsScript) { + const scriptDir = path.dirname(activationScript); + if (scriptDir.toLowerCase().endsWith('scripts')) { + const potentialEnvPath = path.dirname(scriptDir); + const condaMetaPath = path.join(potentialEnvPath, 'conda-meta'); + const pythonPath = path.join(potentialEnvPath, 'python.exe'); + if (existsSync(condaMetaPath) || existsSync(pythonPath)) { + return potentialEnvPath; + } + } + } + + return null; + } catch (error) { + return null; + } +} diff --git a/apps/frontend/src/main/python-path-utils.ts b/apps/frontend/src/main/python-path-utils.ts new file mode 100644 index 0000000000..b44fe884fd --- /dev/null +++ b/apps/frontend/src/main/python-path-utils.ts @@ -0,0 +1,85 @@ +/** + * Python Path Utilities + * + * Centralized utilities for constructing Python executable paths + * across different environment types (conda, venv) and platforms. + */ + +import path from 'path'; + +/** + * Platform abstraction: check if running on Windows. + * Use this instead of checking process.platform directly. + */ +export function isWindows(): boolean { + return process.platform === 'win32'; +} + +/** + * Platform abstraction: check if running on macOS. + * Use this instead of checking process.platform directly. + */ +export function isMac(): boolean { + return process.platform === 'darwin'; +} + +/** + * Platform abstraction: check if running on Linux. + * Use this instead of checking process.platform directly. + */ +export function isLinux(): boolean { + return process.platform === 'linux'; +} + +/** + * Get the path delimiter for the current platform. + * Use this for joining paths in environment variables like PYTHONPATH. + * Windows uses ';', Unix-like systems use ':'. + */ +export function getPathDelimiter(): string { + return isWindows() ? ';' : ':'; +} + +/** + * Get the Python executable path within a conda environment. + * Conda environments have python.exe at the root level on Windows. + */ +export function getCondaPythonPath(envPath: string): string { + if (process.platform === 'win32') { + return path.join(envPath, 'python.exe'); + } + return path.join(envPath, 'bin', 'python'); +} + +/** + * Get the Python executable path within a venv. + * Venvs have python.exe in the Scripts folder on Windows. + */ +export function getVenvPythonPath(venvPath: string): string { + if (process.platform === 'win32') { + return path.join(venvPath, 'Scripts', 'python.exe'); + } + return path.join(venvPath, 'bin', 'python'); +} + +/** + * Get the pip executable path within a conda environment. + * Conda environments have pip.exe at the root level on Windows. + */ +export function getCondaPipPath(envPath: string): string { + if (process.platform === 'win32') { + return path.join(envPath, 'pip.exe'); + } + return path.join(envPath, 'bin', 'pip'); +} + +/** + * Get the pip executable path within a venv. + * Venvs have pip.exe in the Scripts folder on Windows. + */ +export function getVenvPipPath(venvPath: string): string { + if (process.platform === 'win32') { + return path.join(venvPath, 'Scripts', 'pip.exe'); + } + return path.join(venvPath, 'bin', 'pip'); +} diff --git a/apps/frontend/src/main/release-service.ts b/apps/frontend/src/main/release-service.ts index b05152256d..0c560be8de 100644 --- a/apps/frontend/src/main/release-service.ts +++ b/apps/frontend/src/main/release-service.ts @@ -15,6 +15,7 @@ import type { } from '../shared/types'; import { DEFAULT_CHANGELOG_PATH } from '../shared/constants'; import { getToolPath } from './cli-tool-manager'; +import { getSpawnOptions, getSpawnCommand } from './env-utils'; /** * Service for creating GitHub releases with worktree-aware pre-flight checks. @@ -702,9 +703,10 @@ export class ReleaseService extends EventEmitter { } // Use spawn for better handling of the notes content + const ghPath = getToolPath('gh'); const result = await new Promise((resolve, reject) => { - const child = spawn('gh', args, { - cwd: projectPath, + const child = spawn(getSpawnCommand(ghPath), args, { + ...getSpawnOptions(ghPath, { cwd: projectPath }), stdio: ['pipe', 'pipe', 'pipe'] }); diff --git a/apps/frontend/src/main/terminal-name-generator.ts b/apps/frontend/src/main/terminal-name-generator.ts index ca12cda8e3..a8f91826bb 100644 --- a/apps/frontend/src/main/terminal-name-generator.ts +++ b/apps/frontend/src/main/terminal-name-generator.ts @@ -178,7 +178,8 @@ export class TerminalNameGenerator extends EventEmitter { PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' - } + }, + ...(process.platform === 'win32' && { windowsHide: true }) }); let output = ''; diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index cdc81fd1ae..c1fd30a423 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -13,7 +13,7 @@ import { getClaudeProfileManager, initializeClaudeProfileManager } from '../clau import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; -import { escapeShellArg, escapeForWindowsDoubleQuote, buildCdCommand } from '../../shared/utils/shell-escape'; +import { escapeShellArg, escapeForWindowsDoubleQuote, buildCdCommand, type WindowsShellType } from '../../shared/utils/shell-escape'; import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; import { isWindows } from '../platform'; import type { @@ -61,23 +61,34 @@ function getTempFileExtension(): string { /** * Build PATH environment variable prefix for Claude CLI invocation. * - * On Windows, uses semicolon separators and cmd.exe escaping. + * On Windows cmd.exe, uses `set "PATH=value" && ` syntax. + * On Windows PowerShell, uses `$env:PATH = "value"; ` syntax (PowerShell 5.1 doesn't support &&). * On Unix/macOS, uses colon separators and bash escaping. * * @param pathEnv - PATH environment variable value + * @param shellType - On Windows, specify 'powershell' or 'cmd' for correct syntax * @returns Empty string if no PATH, otherwise platform-specific PATH prefix */ -function buildPathPrefix(pathEnv: string): string { +function buildPathPrefix(pathEnv: string, shellType?: WindowsShellType): string { if (!pathEnv) { return ''; } if (isWindows()) { - // Windows: Use semicolon-separated PATH with double-quote escaping - // Format: set "PATH=value" where value uses semicolons + const escapedPath = escapeForWindowsDoubleQuote(pathEnv); + + if (shellType === 'powershell') { + // PowerShell: Use $env:PATH syntax with semicolon separator + // PowerShell 5.1 doesn't support '&&', so use ';' instead + // Note: In PowerShell, backticks escape special chars, but inside double quotes + // we mainly need to escape $ and " characters + const psEscapedPath = pathEnv.replace(/`/g, '``').replace(/\$/g, '`$'); + return `$env:PATH = "${psEscapedPath}"; `; + } + + // cmd.exe: Use set "PATH=value" && syntax // For values inside double quotes, use escapeForWindowsDoubleQuote() because // caret is literal inside double quotes in cmd.exe (only " needs escaping). - const escapedPath = escapeForWindowsDoubleQuote(pathEnv); return `set "PATH=${escapedPath}" && `; } @@ -90,19 +101,28 @@ function buildPathPrefix(pathEnv: string): string { /** * Escape a command for safe use in shell commands. * - * On Windows, wraps in double quotes for cmd.exe. Since the value is inside - * double quotes, we use escapeForWindowsDoubleQuote() (only escapes embedded - * double quotes as ""). Caret escaping is NOT used inside double quotes. + * On Windows cmd.exe, wraps in double quotes. + * On Windows PowerShell, uses the call operator & before double quotes, + * because PowerShell interprets `--` as the decrement operator otherwise. * On Unix/macOS, wraps in single quotes for bash. * * @param cmd - The command to escape + * @param shellType - On Windows, specify 'powershell' or 'cmd' for correct syntax * @returns The escaped command safe for use in shell commands */ -function escapeShellCommand(cmd: string): string { +function escapeShellCommand(cmd: string, shellType?: WindowsShellType): string { if (isWindows()) { // Windows: Wrap in double quotes and escape only embedded double quotes // Inside double quotes, caret is literal, so use escapeForWindowsDoubleQuote() const escapedCmd = escapeForWindowsDoubleQuote(cmd); + + if (shellType === 'powershell') { + // PowerShell: Use call operator & to execute the command + // Without &, PowerShell interprets "--flag" as using the decrement operator + return `& "${escapedCmd}"`; + } + + // cmd.exe: Just quote the path return `"${escapedCmd}"`; } // Unix/macOS: Wrap in single quotes for bash @@ -115,6 +135,7 @@ function escapeShellCommand(cmd: string): string { */ const YOLO_MODE_FLAG = ' --dangerously-skip-permissions'; + // ============================================================================ // SHARED HELPERS - Used by both sync and async invokeClaude // ============================================================================ @@ -570,8 +591,8 @@ export function invokeClaude( const cwdCommand = buildCdCommand(cwd, terminal.shellType); const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); - const escapedClaudeCmd = escapeShellCommand(claudeCmd); - const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const escapedClaudeCmd = escapeShellCommand(claudeCmd, terminal.shellType); + const pathPrefix = buildPathPrefix(claudeEnv.PATH || '', terminal.shellType); const needsEnvOverride = profileId && profileId !== previousProfileId; debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', { @@ -661,7 +682,7 @@ export function invokeClaude( */ export function resumeClaude( terminal: TerminalProcess, - _sessionId: string | undefined, + sessionId: string | undefined, getWindow: WindowGetter ): void { // Track terminal state for cleanup on error @@ -672,8 +693,8 @@ export function resumeClaude( SessionHandler.releaseSessionId(terminal.id); const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); - const escapedClaudeCmd = escapeShellCommand(claudeCmd); - const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const escapedClaudeCmd = escapeShellCommand(claudeCmd, terminal.shellType); + const pathPrefix = buildPathPrefix(claudeEnv.PATH || '', terminal.shellType); // Always use --continue which resumes the most recent session in the current directory. // This is more reliable than --resume with session IDs since Auto Claude already restores @@ -684,7 +705,7 @@ export function resumeClaude( terminal.claudeSessionId = undefined; // Deprecation warning for callers still passing sessionId - if (_sessionId) { + if (sessionId) { console.warn('[ClaudeIntegration:resumeClaude] sessionId parameter is deprecated and ignored; using claude --continue instead'); } @@ -788,8 +809,8 @@ export async function invokeClaudeAsync( if (timeoutId) clearTimeout(timeoutId); }); - const escapedClaudeCmd = escapeShellCommand(claudeCmd); - const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const escapedClaudeCmd = escapeShellCommand(claudeCmd, terminal.shellType); + const pathPrefix = buildPathPrefix(claudeEnv.PATH || '', terminal.shellType); const needsEnvOverride = profileId && profileId !== previousProfileId; debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', { @@ -899,8 +920,8 @@ export async function resumeClaudeAsync( if (timeoutId) clearTimeout(timeoutId); }); - const escapedClaudeCmd = escapeShellCommand(claudeCmd); - const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const escapedClaudeCmd = escapeShellCommand(claudeCmd, terminal.shellType); + const pathPrefix = buildPathPrefix(claudeEnv.PATH || '', terminal.shellType); // Always use --continue which resumes the most recent session in the current directory. // This is more reliable than --resume with session IDs since Auto Claude already restores diff --git a/apps/frontend/src/main/terminal/pty-manager.ts b/apps/frontend/src/main/terminal/pty-manager.ts index f1d659f2b3..c559b8c18e 100644 --- a/apps/frontend/src/main/terminal/pty-manager.ts +++ b/apps/frontend/src/main/terminal/pty-manager.ts @@ -5,7 +5,8 @@ import * as pty from '@lydell/node-pty'; import * as os from 'os'; -import { existsSync } from 'fs'; +import * as path from 'path'; +import { existsSync, readFileSync } from 'fs'; import type { TerminalProcess, WindowGetter, WindowsShellType } from './types'; import { isWindows, getWindowsShellPaths } from '../platform'; import { IPC_CHANNELS } from '../../shared/constants'; @@ -13,6 +14,8 @@ import { getClaudeProfileManager } from '../claude-profile-manager'; import { readSettingsFile } from '../settings-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import type { SupportedTerminal } from '../../shared/types/settings'; +import type { CondaActivationResult, CondaActivationError } from '../../shared/types/conda'; +import { getCondaPythonPath } from '../python-path-utils'; // Windows shell paths are now imported from the platform module via getWindowsShellPaths() @@ -358,3 +361,223 @@ export function getActiveProfileEnv(): Record { const profileManager = getClaudeProfileManager(); return profileManager.getActiveProfileEnv(); } + +// ============================================================================ +// Conda Environment Activation +// ============================================================================ + +/** + * Read the conda base path from the .conda_base file in an environment + * This file is created during environment setup and stores the path to the + * Conda installation used to create the environment. + * The file is located at {envPath}/activate/.conda_base + */ +export function readCondaBase(envPath: string): string | null { + // The .conda_base file is in the activate subdirectory + const condaBasePath = path.join(envPath, 'activate', '.conda_base'); + if (existsSync(condaBasePath)) { + try { + return readFileSync(condaBasePath, 'utf-8').trim(); + } catch (error) { + console.warn('[PtyManager] Failed to read .conda_base:', error); + return null; + } + } + return null; +} + +/** + * Options for getting activation command + */ +export interface ActivationOptions { + /** Path to the conda environment */ + envPath: string; + /** Path to the python root (where scripts/ directory is located) */ + pythonRoot?: string; + /** Project name (used in init script filename) */ + projectName?: string; +} + +/** + * Get the shell command to activate a conda environment + * Uses the generated init scripts in {pythonRoot}/scripts/ + */ +export function getActivationCommand(options: ActivationOptions, platform: NodeJS.Platform): string | null { + const { envPath, pythonRoot, projectName } = options; + + const condaBase = readCondaBase(envPath); + if (!condaBase) { + return null; + } + + if (platform === 'win32') { + // Windows: Use the generated PowerShell init script + // This script is at {pythonRoot}/scripts/init-{projectName}.ps1 + if (pythonRoot && projectName) { + const initScript = path.join(pythonRoot, 'scripts', `init-${projectName}.ps1`); + if (existsSync(initScript)) { + // Use & to invoke the script in PowerShell + return `& "${initScript}"`; + } + } + + // Fallback: Try the activate script in the environment + const ps1Script = path.join(envPath, 'activate', 'activate.ps1'); + if (existsSync(ps1Script)) { + return `& "${ps1Script}"`; + } + + // Last resort: Use conda hook for PowerShell activation + return `& "${condaBase}\\shell\\condabin\\conda-hook.ps1" ; conda activate "${envPath}"`; + } else { + // Unix: Use the generated shell init script + if (pythonRoot && projectName) { + const initScript = path.join(pythonRoot, 'scripts', `init-${projectName}.sh`); + if (existsSync(initScript)) { + return `source "${initScript}"`; + } + } + + // Fallback: Try the activate script in the environment + const shScript = path.join(envPath, 'activate', 'activate.sh'); + if (existsSync(shScript)) { + return `source "${shScript}"`; + } + + // Last resort: Source conda.sh and then activate the environment + return `source "${condaBase}/etc/profile.d/conda.sh" && conda activate "${envPath}"`; + } +} + +/** + * Validate that a conda environment exists and is usable + */ +export async function validateCondaEnv(envPath: string): Promise { + // Check if environment directory exists + if (!existsSync(envPath)) { + return { + success: false, + error: 'env_not_found', + message: `Environment directory not found: ${envPath}` + }; + } + + // Check if .conda_base file exists (required for activation) + const condaBase = readCondaBase(envPath); + if (!condaBase) { + return { + success: false, + error: 'env_broken', + message: 'Missing .conda_base file - environment may need to be recreated' + }; + } + + // Check if conda base installation still exists + if (!existsSync(condaBase)) { + return { + success: false, + error: 'conda_not_found', + message: `Conda installation not found: ${condaBase}` + }; + } + + // Check for Python executable in the environment + const pythonPath = getCondaPythonPath(envPath); + + if (!existsSync(pythonPath)) { + return { + success: false, + error: 'env_broken', + message: 'Python executable not found in environment' + }; + } + + return { + success: true, + message: 'Environment is valid and ready for activation' + }; +} + +/** + * Write conda activation warning to PTY + * Uses shell-specific echo commands to safely output warnings without + * the text being interpreted as commands (e.g., PowerShell [!] syntax) + */ +export function writeCondaWarning( + ptyProcess: pty.IPty, + error: CondaActivationError, + envPath: string +): void { + const errorMessages: Record = { + 'env_not_found': 'Environment directory not found', + 'env_broken': 'Environment is broken or corrupted', + 'conda_not_found': 'Conda installation not found', + 'activation_failed': 'Failed to activate environment', + 'script_not_found': 'Activation script not found' + }; + + const message = errorMessages[error] || 'Unknown error'; + + // Use shell-specific echo to output warning without interpretation + // PowerShell interprets [!] as an invocation expression causing parse errors + if (process.platform === 'win32') { + // On Windows, use Write-Host to output warnings safely + // Escape single quotes in paths for PowerShell + const escapedPath = envPath.replace(/'/g, "''"); + ptyProcess.write(`Write-Host -ForegroundColor Yellow '(!) Conda environment not available: ${message}'\r`); + ptyProcess.write(`Write-Host -ForegroundColor Yellow ' Path: ${escapedPath}'\r`); + ptyProcess.write(`Write-Host -ForegroundColor Yellow ' Run setup in Project Settings > Python Env to fix.'\r`); + } else { + // On Unix, use ANSI escape codes with echo + ptyProcess.write(`echo -e '\\033[33m[!] Conda environment not available: ${message}\\033[0m'\r`); + ptyProcess.write(`echo -e '\\033[33m Path: ${envPath}\\033[0m'\r`); + ptyProcess.write(`echo -e '\\033[33m Run setup in Project Settings > Python Env to fix.\\033[0m'\r`); + } +} + +/** + * Options for conda activation injection + */ +export interface CondaActivationOptions { + /** Path to the conda environment */ + envPath: string; + /** Path to the python root (where scripts/ directory is located) */ + pythonRoot?: string; + /** Project name (used in init script filename) */ + projectName?: string; +} + +/** + * Inject conda activation command into PTY after shell initialization + * Returns true if activation was injected, false if validation failed + */ +export async function injectCondaActivation( + ptyProcess: pty.IPty, + options: CondaActivationOptions +): Promise { + const { envPath, pythonRoot, projectName } = options; + + // Validate the environment first + const validation = await validateCondaEnv(envPath); + + if (!validation.success) { + console.warn('[PtyManager] Conda environment validation failed:', validation.message); + writeCondaWarning(ptyProcess, validation.error!, envPath); + return false; + } + + // Get the activation command + const command = getActivationCommand({ envPath, pythonRoot, projectName }, process.platform); + if (!command) { + writeCondaWarning(ptyProcess, 'activation_failed', envPath); + return false; + } + + // Small delay to let shell initialize before injecting activation + setTimeout(() => { + console.warn('[PtyManager] Injecting conda activation command:', command); + ptyProcess.write(command + '\r'); + }, 500); + + return true; +} diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index 142e2005ce..d83f75505a 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -8,6 +8,8 @@ import { existsSync } from 'fs'; import type { TerminalCreateOptions } from '../../shared/types'; import { IPC_CHANNELS } from '../../shared/constants'; import type { TerminalSession } from '../terminal-session-store'; +import { projectStore } from '../project-store'; +import { getPythonEnvPath, detectProjectStructure } from '../conda-project-structure'; import * as PtyManager from './pty-manager'; import * as SessionHandler from './session-handler'; import type { @@ -18,6 +20,33 @@ import type { import { isWindows } from '../platform'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; +/** + * Compute conda environment path from project settings if available. + * Returns undefined if conda auto-activation is not enabled for the project. + */ +function computeCondaEnvPath(projectPath: string | undefined): string | undefined { + if (!projectPath) { + return undefined; + } + + // Find project by path to get settings + const projects = projectStore.getProjects(); + const project = projects.find((p) => p.path === projectPath); + + if (project?.settings) { + const { useCondaEnv, condaAutoActivate } = project.settings; + + // Only activate if both useCondaEnv is true AND condaAutoActivate is not explicitly false + if (useCondaEnv && condaAutoActivate !== false) { + // Use getPythonEnvPath to get the correct path based on project structure + // (handles pure-python vs mixed projects like dotnet+python) + return getPythonEnvPath(projectPath, project.name); + } + } + + return undefined; +} + /** * Options for terminal restoration */ @@ -43,9 +72,9 @@ export async function createTerminal( getWindow: WindowGetter, dataHandler: DataHandlerFn ): Promise { - const { id, cwd, cols = 80, rows = 24, projectPath } = options; + const { id, cwd, cols = 80, rows = 24, projectPath, condaEnvPath } = options; - debugLog('[TerminalLifecycle] Creating terminal:', { id, cwd, cols, rows, projectPath }); + debugLog('[TerminalLifecycle] Creating terminal:', { id, cwd, cols, rows, projectPath, condaEnvPath }); if (terminals.has(id)) { debugLog('[TerminalLifecycle] Terminal already exists, returning success:', id); @@ -98,6 +127,34 @@ export async function createTerminal( (term) => handleTerminalExit(term, terminals) ); + // Inject conda activation if condaEnvPath is provided + if (condaEnvPath) { + debugLog('[TerminalLifecycle] Injecting conda activation for:', condaEnvPath); + + // Get project info to find the correct activation script + let pythonRoot: string | undefined; + let projectName: string | undefined; + if (projectPath) { + const projects = projectStore.getProjects(); + const project = projects.find((p) => p.path === projectPath); + if (project) { + projectName = project.name; + const structure = detectProjectStructure(projectPath); + pythonRoot = structure.pythonRoot; + debugLog('[TerminalLifecycle] Using project init script:', { projectName, pythonRoot }); + } + } + + // Fire and forget - activation happens asynchronously after shell init + PtyManager.injectCondaActivation(ptyProcess, { + envPath: condaEnvPath, + pythonRoot, + projectName + }).catch((error) => { + debugError('[TerminalLifecycle] Conda activation failed:', error); + }); + } + if (projectPath) { SessionHandler.persistSessionAsync(terminal); } @@ -148,13 +205,17 @@ export async function restoreTerminal( effectiveCwd = session.projectPath || os.homedir(); } + // Compute conda env path for restored sessions + const condaEnvPath = computeCondaEnvPath(session.projectPath); + const result = await createTerminal( { id: session.id, cwd: effectiveCwd, cols, rows, - projectPath: session.projectPath + projectPath: session.projectPath, + condaEnvPath }, terminals, getWindow, diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index 5181f3f412..023c73e597 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -5,6 +5,8 @@ import type { TerminalCreateOptions } from '../../shared/types'; import type { TerminalSession } from '../terminal-session-store'; +import { projectStore } from '../project-store'; +import { getPythonEnvPath } from '../conda-project-structure'; // Internal modules import type { @@ -48,12 +50,35 @@ export class TerminalManager { /** * Create a new terminal process + * + * If projectPath is provided and the project has Conda auto-activation enabled, + * the terminal will automatically activate the project's Conda environment. */ async create( options: TerminalCreateOptions & { projectPath?: string } ): Promise { + // Compute condaEnvPath from project settings if not explicitly provided + let condaEnvPath = options.condaEnvPath; + + if (!condaEnvPath && options.projectPath) { + // Find project by path to get settings + const projects = projectStore.getProjects(); + const project = projects.find((p) => p.path === options.projectPath); + + if (project?.settings) { + const { useCondaEnv, condaAutoActivate } = project.settings; + + // Only activate if both useCondaEnv is true AND condaAutoActivate is not explicitly false + if (useCondaEnv && condaAutoActivate !== false) { + // Use getPythonEnvPath to get the correct path based on project structure + // (handles pure-python vs mixed projects like dotnet+python) + condaEnvPath = getPythonEnvPath(options.projectPath, project.name); + } + } + } + return TerminalLifecycle.createTerminal( - options, + { ...options, condaEnvPath }, this.terminals, this.getWindow, (terminal, data) => this.handleTerminalData(terminal, data) diff --git a/apps/frontend/src/main/title-generator.ts b/apps/frontend/src/main/title-generator.ts index ae809ba351..41bd65a3ba 100644 --- a/apps/frontend/src/main/title-generator.ts +++ b/apps/frontend/src/main/title-generator.ts @@ -11,6 +11,7 @@ import { EventEmitter } from 'events'; import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector'; import { parsePythonCommand, getValidatedPythonPath } from './python-detector'; import { getConfiguredPythonPath } from './python-env-manager'; +import { isWindows } from './python-path-utils'; /** * Debug logging - only logs when DEBUG=true or in development mode @@ -157,7 +158,8 @@ export class TitleGenerator extends EventEmitter { PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' - } + }, + ...(isWindows() && { windowsHide: true }) }); let output = ''; diff --git a/apps/frontend/src/preload/api/index.ts b/apps/frontend/src/preload/api/index.ts index 5e01084ace..39908311ff 100644 --- a/apps/frontend/src/preload/api/index.ts +++ b/apps/frontend/src/preload/api/index.ts @@ -13,6 +13,8 @@ import { DebugAPI, createDebugAPI } from './modules/debug-api'; import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api'; import { McpAPI, createMcpAPI } from './modules/mcp-api'; import { ProfileAPI, createProfileAPI } from './profile-api'; +import { CondaAPI, createCondaAPI } from './modules/conda-api'; +import { ShellAPI, createShellAPI } from './modules/shell-api'; export interface ElectronAPI extends ProjectAPI, @@ -28,8 +30,10 @@ export interface ElectronAPI extends DebugAPI, ClaudeCodeAPI, McpAPI, - ProfileAPI { + ProfileAPI, + ShellAPI { github: GitHubAPI; + conda: CondaAPI; } export const createElectronAPI = (): ElectronAPI => ({ @@ -47,7 +51,9 @@ export const createElectronAPI = (): ElectronAPI => ({ ...createClaudeCodeAPI(), ...createMcpAPI(), ...createProfileAPI(), - github: createGitHubAPI() + ...createShellAPI(), + github: createGitHubAPI(), + conda: createCondaAPI() }); // Export individual API creators for potential use in tests or specialized contexts @@ -66,7 +72,9 @@ export { createGitLabAPI, createDebugAPI, createClaudeCodeAPI, - createMcpAPI + createMcpAPI, + createCondaAPI, + createShellAPI }; export type { @@ -84,5 +92,7 @@ export type { GitLabAPI, DebugAPI, ClaudeCodeAPI, - McpAPI + McpAPI, + CondaAPI, + ShellAPI }; diff --git a/apps/frontend/src/preload/api/modules/conda-api.ts b/apps/frontend/src/preload/api/modules/conda-api.ts new file mode 100644 index 0000000000..5918523b80 --- /dev/null +++ b/apps/frontend/src/preload/api/modules/conda-api.ts @@ -0,0 +1,101 @@ +/** + * Conda API for renderer process + * + * Provides access to Conda environment management: + * - Detect Conda installations on the system + * - Create and manage app-level Auto Claude environment + * - Create and manage project-level Python environments + * - Install dependencies and check Python versions + */ + +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { + IPCResult, + CondaDetectionResult, + CondaEnvValidation, + PythonVersionResult, + SetupProgress, + CondaProjectPaths, +} from '../../../shared/types'; +import { createIpcListener, invokeIpc, IpcListenerCleanup } from './ipc-utils'; + +/** + * Conda API interface exposed to renderer + */ +export interface CondaAPI { + // Detection + detectConda: () => Promise>; + refreshConda: () => Promise>; + + // App-level environment + setupAutoClaudeEnv: () => Promise>; + checkAutoClaudeEnv: () => Promise>; + + // Project-level environment + setupProjectEnv: (projectPath: string, projectName: string, pythonVersion?: string) => Promise>; + checkProjectEnv: (envPath: string) => Promise>; + deleteProjectEnv: (envPath: string) => Promise>; + deleteActivationScripts: (projectPath: string) => Promise>; + regenerateScripts: (envPath: string, projectPath: string) => Promise>; + + // General + getPythonVersion: (projectPath: string) => Promise>; + installDeps: (envPath: string, requirementsPath: string) => Promise>; + getProjectPaths: (projectPath: string, projectName: string) => Promise>; + listPythonVersions: (projectPath?: string) => Promise>; + + // Progress event listener + onSetupProgress: (callback: (progress: SetupProgress) => void) => IpcListenerCleanup; +} + +/** + * Creates the Conda API implementation + */ +export const createCondaAPI = (): CondaAPI => ({ + // Detection + detectConda: (): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_DETECT), + + refreshConda: (): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_REFRESH), + + // App-level environment + setupAutoClaudeEnv: (): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_SETUP_AUTO_CLAUDE), + + checkAutoClaudeEnv: (): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_CHECK_AUTO_CLAUDE), + + // Project-level environment + setupProjectEnv: (projectPath: string, projectName: string, pythonVersion?: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_SETUP_PROJECT_ENV, projectPath, projectName, pythonVersion), + + checkProjectEnv: (envPath: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_CHECK_PROJECT_ENV, envPath), + + deleteProjectEnv: (envPath: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_DELETE_PROJECT_ENV, envPath), + + deleteActivationScripts: (projectPath: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_DELETE_ACTIVATION_SCRIPTS, projectPath), + + regenerateScripts: (envPath: string, projectPath: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_REGENERATE_SCRIPTS, envPath, projectPath), + + // General + getPythonVersion: (projectPath: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_GET_PYTHON_VERSION, projectPath), + + installDeps: (envPath: string, requirementsPath: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_INSTALL_DEPS, envPath, requirementsPath), + + getProjectPaths: (projectPath: string, projectName: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_GET_PROJECT_PATHS, projectPath, projectName), + + listPythonVersions: (projectPath?: string): Promise> => + invokeIpc(IPC_CHANNELS.CONDA_LIST_PYTHON_VERSIONS, projectPath), + + // Progress event listener + onSetupProgress: (callback: (progress: SetupProgress) => void): IpcListenerCleanup => + createIpcListener(IPC_CHANNELS.CONDA_SETUP_PROGRESS, callback) +}); diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index 6408479f58..0fa958d2e6 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -8,7 +8,8 @@ import type { GitHubInvestigationResult, IPCResult, VersionSuggestion, - PaginatedIssuesResult + PaginatedIssuesResult, + MergeReadiness } from '../../../shared/types'; import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc-utils'; @@ -386,22 +387,8 @@ export interface NewCommitsCheck { isMergeFromBase?: boolean; } -/** - * Lightweight merge readiness check result - * Used for real-time validation of AI verdict freshness - */ -export interface MergeReadiness { - /** PR is in draft mode */ - isDraft: boolean; - /** GitHub's mergeable status */ - mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; - /** Branch is behind base branch (out of date) */ - isBehind: boolean; - /** Simplified CI status */ - ciStatus: 'passing' | 'failing' | 'pending' | 'none'; - /** List of blockers that contradict a "ready to merge" verdict */ - blockers: string[]; -} +// MergeReadiness is imported from shared/types/integrations.ts +export type { MergeReadiness } from '../../../shared/types'; /** * Review progress status diff --git a/apps/frontend/src/preload/api/modules/index.ts b/apps/frontend/src/preload/api/modules/index.ts index e2cc553781..ed8a58bcbd 100644 --- a/apps/frontend/src/preload/api/modules/index.ts +++ b/apps/frontend/src/preload/api/modules/index.ts @@ -13,3 +13,4 @@ export * from './linear-api'; export * from './github-api'; export * from './shell-api'; export * from './debug-api'; +export * from './conda-api'; diff --git a/apps/frontend/src/preload/api/modules/shell-api.ts b/apps/frontend/src/preload/api/modules/shell-api.ts index 1a395ffdb6..a38a14578e 100644 --- a/apps/frontend/src/preload/api/modules/shell-api.ts +++ b/apps/frontend/src/preload/api/modules/shell-api.ts @@ -8,6 +8,7 @@ import type { IPCResult } from '../../../shared/types'; export interface ShellAPI { openExternal: (url: string) => Promise; openTerminal: (dirPath: string) => Promise>; + showItemInFolder: (filePath: string) => Promise; } /** @@ -17,5 +18,7 @@ export const createShellAPI = (): ShellAPI => ({ openExternal: (url: string): Promise => invokeIpc(IPC_CHANNELS.SHELL_OPEN_EXTERNAL, url), openTerminal: (dirPath: string): Promise> => - invokeIpc(IPC_CHANNELS.SHELL_OPEN_TERMINAL, dirPath) + invokeIpc(IPC_CHANNELS.SHELL_OPEN_TERMINAL, dirPath), + showItemInFolder: (filePath: string): Promise => + invokeIpc(IPC_CHANNELS.SHELL_SHOW_ITEM_IN_FOLDER, filePath) }); diff --git a/apps/frontend/src/preload/api/settings-api.ts b/apps/frontend/src/preload/api/settings-api.ts index 1c1f8752f9..86382f321c 100644 --- a/apps/frontend/src/preload/api/settings-api.ts +++ b/apps/frontend/src/preload/api/settings-api.ts @@ -33,6 +33,31 @@ export interface SettingsAPI { notifySentryStateChanged: (enabled: boolean) => void; getSentryDsn: () => Promise; getSentryConfig: () => Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }>; + + // Python package validation + validatePythonPackages: (params: { pythonPath: string; activationScript?: string }) => Promise>; + onPythonValidationProgress: (callback: (progress: { current: number; total: number; packageName: string }) => void) => () => void; + installPythonRequirements: (params: { pythonPath: string; activationScript?: string }) => Promise; + onPythonInstallProgress: (callback: (progress: string) => void) => () => void; + validatePythonEnvironment: (params: { activationScript: string }) => Promise>; + reinstallPythonEnvironment: (params: { activationScript: string; pythonVersion?: string }) => Promise>; + onPythonReinstallProgress: (callback: (progress: { step: string; completed: number; total: number }) => void) => () => void; } export const createSettingsAPI = (): SettingsAPI => ({ @@ -76,5 +101,52 @@ export const createSettingsAPI = (): SettingsAPI => ({ // Get full Sentry config from main process (DSN + sample rates) getSentryConfig: (): Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }> => - ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG) + ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG), + + // Python package validation + validatePythonPackages: (params: { pythonPath: string; activationScript?: string }): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PYTHON_VALIDATE_PACKAGES, params), + + onPythonValidationProgress: (callback: (progress: { current: number; total: number; packageName: string }) => void): () => void => { + const handler = (_event: Electron.IpcRendererEvent, progress: { current: number; total: number; packageName: string }) => callback(progress); + ipcRenderer.on(IPC_CHANNELS.PYTHON_VALIDATION_PROGRESS, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.PYTHON_VALIDATION_PROGRESS, handler); + }, + + installPythonRequirements: (params: { pythonPath: string; activationScript?: string }): Promise => + ipcRenderer.invoke(IPC_CHANNELS.PYTHON_INSTALL_REQUIREMENTS, params), + + onPythonInstallProgress: (callback: (progress: string) => void): () => void => { + const handler = (_event: Electron.IpcRendererEvent, progress: string) => callback(progress); + ipcRenderer.on(IPC_CHANNELS.PYTHON_INSTALL_PROGRESS, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.PYTHON_INSTALL_PROGRESS, handler); + }, + + validatePythonEnvironment: (params: { activationScript: string }): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PYTHON_VALIDATE_ENVIRONMENT, params), + + reinstallPythonEnvironment: (params: { activationScript: string; pythonVersion?: string }): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PYTHON_REINSTALL_ENVIRONMENT, params), + + onPythonReinstallProgress: (callback: (progress: { step: string; completed: number; total: number }) => void): () => void => { + const handler = (_event: Electron.IpcRendererEvent, progress: { step: string; completed: number; total: number }) => callback(progress); + ipcRenderer.on(IPC_CHANNELS.PYTHON_REINSTALL_PROGRESS, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.PYTHON_REINSTALL_PROGRESS, handler); + } }); diff --git a/apps/frontend/src/renderer/components/Insights.tsx b/apps/frontend/src/renderer/components/Insights.tsx index 3f3a9b5fe6..146fbc6695 100644 --- a/apps/frontend/src/renderer/components/Insights.tsx +++ b/apps/frontend/src/renderer/components/Insights.tsx @@ -14,7 +14,8 @@ import { FileText, FolderSearch, PanelLeftClose, - PanelLeft + PanelLeft, + X } from 'lucide-react'; import ReactMarkdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; diff --git a/apps/frontend/src/renderer/components/project-settings/GeneralSettings.tsx b/apps/frontend/src/renderer/components/project-settings/GeneralSettings.tsx index 7899f6dbb8..925fdb2ed4 100644 --- a/apps/frontend/src/renderer/components/project-settings/GeneralSettings.tsx +++ b/apps/frontend/src/renderer/components/project-settings/GeneralSettings.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { RefreshCw, Download, @@ -7,6 +8,7 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '../ui/button'; +import { Checkbox } from '../ui/checkbox'; import { Label } from '../ui/label'; import { Switch } from '../ui/switch'; import { @@ -17,6 +19,16 @@ import { SelectValue } from '../ui/select'; import { Separator } from '../ui/separator'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../ui/alert-dialog'; import { AVAILABLE_MODELS } from '../../../shared/constants'; import type { Project, @@ -32,6 +44,8 @@ interface GeneralSettingsProps { isCheckingVersion: boolean; isUpdating: boolean; handleInitialize: () => Promise; + /** Callback when useCondaEnv setting changes (for sidebar reactivity) */ + onUseCondaEnvChange?: (enabled: boolean) => void; } export function GeneralSettings({ @@ -41,23 +55,109 @@ export function GeneralSettings({ versionInfo, isCheckingVersion, isUpdating, - handleInitialize + handleInitialize, + onUseCondaEnvChange }: GeneralSettingsProps) { const { t } = useTranslation(['settings']); + const [showDeleteEnvDialog, setShowDeleteEnvDialog] = useState(false); + const [isCheckingEnv, setIsCheckingEnv] = useState(false); + const [envPathToDelete, setEnvPathToDelete] = useState(''); + const [deleteActivationScripts, setDeleteActivationScripts] = useState(true); + + // Derived values for the Python environment + const projectName = project.name; + + // Check if environment exists (for determining whether to show delete dialog) + // Uses backend to get correct path based on project structure + async function checkEnvExists(): Promise<{ exists: boolean; envPath: string }> { + try { + const condaApi = window.electronAPI?.conda; + + if (!condaApi) return { exists: false, envPath: '' }; + + // First get the correct env path from backend + const pathsResult = await condaApi.getProjectPaths(project.path, projectName); + const envPath = pathsResult.data?.envPath || `${project.path}/.envs/${projectName}`; + + // Then check if env exists at that path + const result = await condaApi.checkProjectEnv(envPath); + return { + exists: result.success && result.data?.valid === true, + envPath + }; + } catch { + return { exists: false, envPath: '' }; + } + } + + // Handle the conda toggle - show confirmation when turning off only if env exists + async function handleCondaToggle(enabled: boolean) { + if (!enabled && settings.useCondaEnv) { + // Turning off - check if env actually exists before prompting to delete + setIsCheckingEnv(true); + const { exists, envPath: checkedEnvPath } = await checkEnvExists(); + setIsCheckingEnv(false); + + if (exists) { + // Environment exists - prompt to delete or keep + // Store the path for deletion + setEnvPathToDelete(checkedEnvPath); + setShowDeleteEnvDialog(true); + } else { + // No environment exists - just toggle off without dialog + setSettings(prev => ({ ...prev, useCondaEnv: false })); + onUseCondaEnvChange?.(false); + } + } else { + // Turning on or just updating + setSettings(prev => ({ ...prev, useCondaEnv: enabled })); + onUseCondaEnvChange?.(enabled); + } + } + + // Handle keeping files but disabling the setting + function handleKeepFiles() { + setSettings(prev => ({ ...prev, useCondaEnv: false })); + onUseCondaEnvChange?.(false); + setShowDeleteEnvDialog(false); + } + + // Handle deleting the environment + async function handleDeleteEnv() { + try { + const condaApi = window.electronAPI.conda; + + if (condaApi && envPathToDelete) { + await condaApi.deleteProjectEnv(envPathToDelete); + } + + // Also delete activation scripts if checkbox is checked + if (condaApi && deleteActivationScripts) { + await condaApi.deleteActivationScripts(project.path); + } + } catch (error) { + console.error('Failed to delete environment:', error); + } + setSettings(prev => ({ ...prev, useCondaEnv: false })); + onUseCondaEnvChange?.(false); + setShowDeleteEnvDialog(false); + setEnvPathToDelete(''); + setDeleteActivationScripts(true); // Reset for next time + } return ( <> {/* Auto-Build Integration */}
-

Auto-Build Integration

+

{t('projectSections.autoBuild.title')}

{!project.autoBuildPath ? (
-

Not Initialized

+

{t('projectSections.autoBuild.notInitialized')}

- Initialize Auto-Build to enable task creation and agent workflows. + {t('projectSections.autoBuild.notInitializedDescription')}

@@ -85,7 +185,7 @@ export function GeneralSettings({
- Initialized + {t('projectSections.autoBuild.initialized')}
{project.autoBuildPath} @@ -94,29 +194,95 @@ export function GeneralSettings({ {isCheckingVersion ? (
- Checking status... + {t('projectSections.autoBuild.checkingStatus')}
) : versionInfo && (
- {versionInfo.isInitialized ? 'Initialized' : 'Not initialized'} + {versionInfo.isInitialized ? t('projectSections.autoBuild.initialized') : t('projectSections.autoBuild.notInitialized')}
)}
)}
+ + + {/* Python Environment Toggle */} +
+

+ {t('python.title')} +

+
+
+ +

+ {t('python.useCondaEnvHint')} +

+
+
+ {isCheckingEnv && } + handleCondaToggle(checked)} + disabled={isCheckingEnv} + /> +
+
+
+ + {/* Delete Environment Confirmation Dialog */} + + + + {t('python.deleteEnvTitle')} + +
+ {t('python.deleteEnvMessage')} + + {envPathToDelete} + + {t('python.deleteEnvPrompt')} +
+
+
+
+ setDeleteActivationScripts(checked === true)} + /> + +
+ + + {t('python.keepFiles')} + + + {t('python.deleteEnv')} + + +
+
+ {project.autoBuildPath && ( <> {/* Agent Settings */}
-

Agent Configuration

+

{t('projectSections.agentConfig.title')}

- + + setSettings(prev => ({ ...prev, gitProvider: value })) + } + > + + + + + +
+ + {t('settings:gitProvider.options.auto')} +
+
+ +
+ + GitHub +
+
+ +
+ + GitLab +
+
+
+ + + {/* Detection result preview */} + {settings.gitProvider === 'auto' && ( +
+

+ {t('settings:gitProvider.autoDetectHint')} +

+
+ )} +
+ + )} +
); } diff --git a/apps/frontend/src/renderer/components/project-settings/PythonEnvSettings.tsx b/apps/frontend/src/renderer/components/project-settings/PythonEnvSettings.tsx new file mode 100644 index 0000000000..af107ac616 --- /dev/null +++ b/apps/frontend/src/renderer/components/project-settings/PythonEnvSettings.tsx @@ -0,0 +1,408 @@ +/** + * PythonEnvSettings - Python Environment settings page for projects + * + * This component is shown when the user navigates to "Python Env" in project settings + * (only visible when the useCondaEnv toggle is enabled). + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + CheckCircle2, + XCircle, + Circle, + RefreshCw, + ExternalLink, + FileCode2, + FolderOpen, + Terminal, + Loader2 +} from 'lucide-react'; +import { Button } from '../ui/button'; +import { Label } from '../ui/label'; +import { Switch } from '../ui/switch'; +import { Separator } from '../ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '../ui/select'; +import { CondaSetupWizard } from '../settings/CondaSetupWizard'; +import type { Project, ProjectSettings, CondaEnvValidation, PythonVersionResult, CondaProjectPaths } from '../../../shared/types'; + +export interface PythonEnvSettingsProps { + project: Project; + settings: ProjectSettings; + onSettingsChange: (settings: ProjectSettings) => void; +} + +interface EnvStatus { + status: 'none' | 'ready' | 'broken' | 'creating'; + pythonVersion?: string; + packageCount?: number; +} + +export function PythonEnvSettings({ + project, + settings, + onSettingsChange +}: PythonEnvSettingsProps) { + const { t } = useTranslation(['settings']); + + // State + const [envStatus, setEnvStatus] = useState({ status: 'none' }); + const [detectedPython, setDetectedPython] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [showSetupWizard, setShowSetupWizard] = useState(false); + const [projectPaths, setProjectPaths] = useState(null); + const [pythonVersions, setPythonVersions] = useState([]); + const [selectedPythonVersion, setSelectedPythonVersion] = useState(''); + const [recommendedVersion, setRecommendedVersion] = useState('3.12'); + // Use ref to track initial version selection without causing callback recreation + const hasInitializedVersionRef = useRef(false); + + // Derived values - use dynamic paths from backend if available, otherwise fallback + const projectName = project.name; + const pythonRootPrefix = projectPaths?.pythonRootRelative ? `${projectPaths.pythonRootRelative}/` : ''; + const envPathDisplay = projectPaths?.envPathRelative || `.envs/${projectName}/`; + const envPath = `${pythonRootPrefix}${envPathDisplay}`; + const workspaceFile = projectPaths?.workspaceFile || `${projectName}.code-workspace`; + + // Load environment status on mount + const loadEnvStatus = useCallback(async () => { + if (!project.path) return; + + setIsLoading(true); + try { + const condaApi = (window.electronAPI as { conda?: { + checkProjectEnv: (envPath: string) => Promise<{ success: boolean; data?: CondaEnvValidation }>; + getPythonVersion: (projectPath: string) => Promise<{ success: boolean; data?: PythonVersionResult }>; + getProjectPaths: (projectPath: string, projectName: string) => Promise<{ success: boolean; data?: CondaProjectPaths }>; + listPythonVersions: (projectPath?: string) => Promise<{ success: boolean; data?: { versions: string[]; recommended: string; detectedVersion?: string } }>; + } }).conda; + + if (!condaApi) { + console.warn('Conda API not available'); + setEnvStatus({ status: 'none' }); + setIsLoading(false); + return; + } + + // Get computed project paths first + const pathsResult = await condaApi.getProjectPaths(project.path, project.name); + if (pathsResult.success && pathsResult.data) { + setProjectPaths(pathsResult.data); + } + + // Check environment status using the actual env path from backend + const actualEnvPath = pathsResult.data?.envPath || `${project.path}/.envs/${project.name}`; + const envResult = await condaApi.checkProjectEnv(actualEnvPath); + + if (envResult.success && envResult.data) { + const validation = envResult.data; + if (validation.valid) { + setEnvStatus({ + status: 'ready', + pythonVersion: validation.pythonVersion, + packageCount: validation.packageCount + }); + } else if (validation.error === 'env_not_found') { + setEnvStatus({ status: 'none' }); + } else { + setEnvStatus({ status: 'broken' }); + } + } else { + setEnvStatus({ status: 'none' }); + } + + // Get detected Python version from project files + const pythonResult = await condaApi.getPythonVersion(project.path); + if (pythonResult.success && pythonResult.data) { + setDetectedPython(pythonResult.data); + } + + // Load available Python versions + const versionsResult = await condaApi.listPythonVersions(project.path); + if (versionsResult.success && versionsResult.data) { + setPythonVersions(versionsResult.data.versions); + setRecommendedVersion(versionsResult.data.recommended); + // Only set default selection on initial load, preserve user's selection after that + if (!hasInitializedVersionRef.current) { + setSelectedPythonVersion(versionsResult.data.recommended); + hasInitializedVersionRef.current = true; + } + } + } catch (error) { + console.error('Failed to load environment status:', error); + setEnvStatus({ status: 'none' }); + } finally { + setIsLoading(false); + } + }, [project.path, project.name]); + + useEffect(() => { + loadEnvStatus(); + }, [loadEnvStatus]); + + // Handle setup/reinstall environment + const handleSetupEnv = () => { + setShowSetupWizard(true); + }; + + // Handle setup completion - refresh environment status + const handleSetupComplete = () => { + loadEnvStatus(); + }; + + // Handle Open in VS Code + const handleOpenInVsCode = async () => { + // Use actual workspace path from backend if available + const actualWorkspacePath = projectPaths?.workspacePath || `${project.path}/${workspaceFile}`; + // Convert backslashes to forward slashes for URI + const normalizedPath = actualWorkspacePath.replace(/\\/g, '/'); + // Encode path to handle spaces and special characters in the deep link + const encodedPath = encodeURIComponent(normalizedPath); + try { + await window.electronAPI.openExternal(`vscode://file/${encodedPath}`); + } catch (error) { + console.error('Failed to open VS Code:', error); + } + }; + + // Handle Show in Folder + const handleShowInFolder = async () => { + // Use actual workspace path from backend if available + const actualWorkspacePath = projectPaths?.workspacePath || `${project.path}/${workspaceFile}`; + try { + await window.electronAPI.showItemInFolder(actualWorkspacePath); + } catch (error) { + console.error('Failed to show in folder:', error); + } + }; + + // Handle auto-activate toggle + const handleAutoActivateChange = (checked: boolean) => { + onSettingsChange({ + ...settings, + condaAutoActivate: checked + }); + }; + + // Render status indicator + const renderStatusIndicator = () => { + if (isLoading) { + return ( +
+ + {t('python.statusCreating')} +
+ ); + } + + switch (envStatus.status) { + case 'ready': + return ( +
+
+ + {t('python.statusReady')} +
+ {(envStatus.pythonVersion || envStatus.packageCount !== undefined) && ( +

+ {envStatus.pythonVersion && `Python ${envStatus.pythonVersion}`} + {envStatus.pythonVersion && envStatus.packageCount !== undefined && ' | '} + {envStatus.packageCount !== undefined && t('python.packagesInstalled', { count: envStatus.packageCount })} +

+ )} +
+ ); + case 'broken': + return ( +
+ + {t('python.statusBroken')} +
+ ); + case 'none': + default: + return ( +
+ + {t('python.statusNotConfigured')} +
+ ); + } + }; + + return ( +
+ {/* Environment Location Section */} +
+

{t('python.envLocation')}

+
+ {envPath} +

+ {t('python.envLocationHint')} +

+
+
+ + + + {/* Python Version Section */} +
+

{t('python.pythonVersion')}

+
+
+ + +
+ {envStatus.status === 'ready' && envStatus.pythonVersion ? ( + <> +

+ {t('python.installedVersion')}: Python {envStatus.pythonVersion} +

+

+ {t('python.versionChangeHint')} +

+ + ) : detectedPython ? ( +

+ {t('python.detectedFromProject')}: Python {detectedPython.version} ({detectedPython.source}) +

+ ) : null} +
+
+ + + + {/* Status Section */} +
+

{t('python.status')}

+
+ {renderStatusIndicator()} +
+ {envStatus.status === 'none' ? ( + + ) : ( + + )} +
+
+
+ + + + {/* VS Code Integration Section */} +
+

{t('python.vscodeIntegration')}

+
+
+
+ + {t('python.workspaceFile')}: + {workspaceFile} +
+

+ {t('python.workspaceHint')} +

+
+
+ + +
+
+
+ + + + {/* Terminal Integration Section */} +
+

{t('python.terminalIntegration')}

+
+
+
+
+ + +
+

+ {t('python.autoActivateHint')} +

+
+ +
+
+
+ + {/* Conda Setup Wizard */} + +
+ ); +} diff --git a/apps/frontend/src/renderer/components/project-settings/hooks/useProjectSettings.ts b/apps/frontend/src/renderer/components/project-settings/hooks/useProjectSettings.ts index a0307119e6..2882ffc45c 100644 --- a/apps/frontend/src/renderer/components/project-settings/hooks/useProjectSettings.ts +++ b/apps/frontend/src/renderer/components/project-settings/hooks/useProjectSettings.ts @@ -368,14 +368,22 @@ export function useProjectSettings( try { const result = await window.electronAPI.updateProjectEnv(project.id, newConfig); if (!result.success) { - console.error('[useProjectSettings] Failed to auto-save env config:', result.error); + const errorMessage = result.error || 'Failed to save environment config'; + console.error('[useProjectSettings] Failed to auto-save env config:', errorMessage); + setEnvError(errorMessage); + return; // Don't update local state on failure } + console.log('[useProjectSettings.updateEnvConfig] Successfully saved to backend'); + // Notify other components (like Sidebar) that env config changed + window.dispatchEvent(new CustomEvent('env-config-updated')); + // Only update local state after successful save + setEnvConfig(newConfig); } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error saving environment config'; console.error('[useProjectSettings] Error auto-saving env config:', err); + setEnvError(errorMessage); + // Don't update local state on exception } - - // Then update local state (triggers effects that read from disk) - setEnvConfig(newConfig); } }; diff --git a/apps/frontend/src/renderer/components/settings/AppSettings.tsx b/apps/frontend/src/renderer/components/settings/AppSettings.tsx index a68f33eba1..edd63e52e3 100644 --- a/apps/frontend/src/renderer/components/settings/AppSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/AppSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Settings, @@ -19,7 +19,8 @@ import { Globe, Code, Bug, - Server + Server, + Terminal } from 'lucide-react'; // GitLab icon component (lucide-react doesn't have one) @@ -88,7 +89,8 @@ const appNavItemsConfig: NavItemConfig[] = [ { id: 'debug', icon: Bug } ]; -const projectNavItemsConfig: NavItemConfig[] = [ +// Base project nav items (always shown) +const baseProjectNavItemsConfig: NavItemConfig[] = [ { id: 'general', icon: Settings2 }, { id: 'linear', icon: Zap }, { id: 'github', icon: Github }, @@ -96,6 +98,9 @@ const projectNavItemsConfig: NavItemConfig[] = [ { id: 'memory', icon: Database } ]; +// Python Env nav item (conditionally shown when useCondaEnv is enabled) +const pythonEnvNavItem: NavItemConfig = { id: 'python-env', icon: Terminal }; + /** * Main application settings dialog container * Coordinates app and project settings sections @@ -132,6 +137,30 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP // Project settings hook state (lifted from child) const [projectSettingsHook, setProjectSettingsHook] = useState(null); const [projectError, setProjectError] = useState(null); + // Track useCondaEnv state separately to ensure sidebar updates reactively + // (the proxy pattern doesn't trigger React updates when nested values change) + const [useCondaEnvState, setUseCondaEnvState] = useState(undefined); + + // Sync useCondaEnvState when project changes or hook initializes + // Note: Depend on specific nested value to avoid re-running when unrelated hook properties change + const hookUseCondaEnv = projectSettingsHook?.settings?.useCondaEnv; + useEffect(() => { + const newValue = hookUseCondaEnv ?? selectedProject?.settings?.useCondaEnv; + // Only update if we have a definite value (not undefined) + if (newValue !== undefined) { + setUseCondaEnvState(newValue); + } + }, [selectedProject?.id, selectedProject?.settings?.useCondaEnv, hookUseCondaEnv]); + + // Compute project nav items dynamically (conditionally include Python Env when useCondaEnv is enabled) + // Python Env appears 2nd (right after General) when enabled + const projectNavItemsConfig = useMemo(() => { + if (useCondaEnvState) { + const [general, ...rest] = baseProjectNavItemsConfig; + return [general, pythonEnvNavItem, ...rest]; + } + return baseProjectNavItemsConfig; + }, [useCondaEnvState]); // Load app version on mount useEffect(() => { @@ -217,6 +246,7 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP activeSection={projectSection} isOpen={open} onHookReady={handleProjectHookReady} + onUseCondaEnvChange={setUseCondaEnvState} /> ); }; @@ -237,10 +267,10 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP - {t('title')} + {t('settings:title')} - {t('tabs.app')} & {t('tabs.project')} + {t('settings:tabs.app')} & {t('settings:tabs.project')} @@ -253,7 +283,7 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP {/* APPLICATION Section */}

- {t('tabs.app')} + {t('settings:tabs.app')}

{appNavItemsConfig.map((item) => { @@ -275,8 +305,8 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP >
-
{t(`sections.${item.id}.title`)}
-
{t(`sections.${item.id}.description`)}
+
{t(`settings:sections.${item.id}.title`)}
+
{t(`settings:sections.${item.id}.description`)}
); @@ -297,8 +327,8 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP >
-
{t('actions.rerunWizard')}
-
{t('actions.rerunWizardDescription')}
+
{t('settings:actions.rerunWizard')}
+
{t('settings:actions.rerunWizardDescription')}
)} @@ -308,7 +338,7 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP {/* PROJECT Section */}

- {t('tabs.project')} + {t('settings:tabs.project')}

{/* Project Selector */} @@ -343,8 +373,8 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP >
-
{t(`projectSections.${item.id}.title`)}
-
{t(`projectSections.${item.id}.description`)}
+
{t(`settings:projectSections.${item.id}.title`)}
+
{t(`settings:projectSections.${item.id}.description`)}
); @@ -357,7 +387,7 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP {version && (

- {t('updates.version')} {version} + {t('settings:updates.version')} {version}

)} @@ -396,7 +426,7 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP ) : ( <> - {t('actions.save')} + {t('settings:actions.save')} )} diff --git a/apps/frontend/src/renderer/components/settings/CondaDetectionDisplay.tsx b/apps/frontend/src/renderer/components/settings/CondaDetectionDisplay.tsx new file mode 100644 index 0000000000..1873fe38ab --- /dev/null +++ b/apps/frontend/src/renderer/components/settings/CondaDetectionDisplay.tsx @@ -0,0 +1,139 @@ +import { useTranslation } from 'react-i18next'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { + RefreshCw, + ExternalLink, + FolderOpen, + CheckCircle2, + AlertTriangle +} from 'lucide-react'; +import type { CondaDetectionResult } from '../../../shared/types'; + +interface CondaDetectionDisplayProps { + detection: CondaDetectionResult | null; + isLoading: boolean; + onRefresh: () => void; + onBrowse?: () => void; + manualPath?: string; + onManualPathChange?: (path: string) => void; +} + +/** + * Opens the Miniconda installation page in the default browser + */ +function openMinicondaInstallPage(): void { + window.open('https://docs.conda.io/en/latest/miniconda.html', '_blank'); +} + +/** + * Component that displays the detected Conda installation status. + * Shows loading, not-found, or found states with appropriate UI. + */ +export function CondaDetectionDisplay({ + detection, + isLoading, + onRefresh, + onBrowse, + manualPath, + onManualPathChange +}: CondaDetectionDisplayProps) { + const { t } = useTranslation('settings'); + + // Loading state + if (isLoading) { + return ( +
+ + {t('python.detecting')} +
+ ); + } + + // Not found state + if (!detection || !detection.found || !detection.preferred) { + return ( +
+
+ + {t('python.notDetected')} +
+ +
+ + +
+ + {onManualPathChange && ( +
+ + {t('python.specifyPath')}: + + onManualPathChange(e.target.value)} + placeholder={t('python.pathPlaceholder')} + className="flex-1 h-8 text-sm" + /> + {onBrowse && ( + + )} +
+ )} +
+ ); + } + + // Found state + const { preferred } = detection; + const condaType = t(`python.condaTypes.${preferred.type}`, { defaultValue: t('python.condaTypes.unknown') }); + const versionDisplay = preferred.version ? ` (v${preferred.version})` : ''; + + return ( +
+ + + {t('python.detected')}: + + + {preferred.path} + + + {condaType}{versionDisplay} + + +
+ ); +} diff --git a/apps/frontend/src/renderer/components/settings/CondaSetupWizard.tsx b/apps/frontend/src/renderer/components/settings/CondaSetupWizard.tsx new file mode 100644 index 0000000000..9827289339 --- /dev/null +++ b/apps/frontend/src/renderer/components/settings/CondaSetupWizard.tsx @@ -0,0 +1,402 @@ +/** + * CondaSetupWizard - Stepper wizard dialog for Conda environment setup + * + * Displays a multi-step wizard with progress tracking for setting up + * an isolated Python conda environment (either app-level or project-level). + * + * Features: + * - Vertical stepper with step states (pending, in_progress, completed, error) + * - Expandable log panels for each step + * - Continue in background / Cancel / Retry actions + * - i18n support via settings:condaSetup namespace + */ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, Loader2, AlertCircle, ChevronDown, Circle, XCircle } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '../ui/dialog'; +import { Button } from '../ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'; +import { cn } from '../../lib/utils'; +import { useCondaSetup, type CondaSetupLogEntry } from '../../hooks/useCondaSetup'; + +export interface CondaSetupWizardProps { + /** Whether the dialog is open */ + open: boolean; + /** Callback when the dialog open state changes */ + onOpenChange: (open: boolean) => void; + /** Type of setup: 'app' for app-level or 'project' for project-level */ + type: 'app' | 'project'; + /** Path to the project (required when type is 'project') */ + projectPath?: string; + /** Display name for the project */ + projectName?: string; + /** Python version to install (e.g., '3.12'). If not provided, auto-detects from project files */ + pythonVersion?: string; + /** Callback when setup completes successfully */ + onComplete?: () => void; +} + +type StepStatus = 'pending' | 'in_progress' | 'completed' | 'error'; + +interface StepInfo { + id: string; + labelKey: string; + labelParams?: Record; +} + +// Base steps without dynamic parameters +const BASE_SETUP_STEPS: StepInfo[] = [ + { id: 'detecting', labelKey: 'settings:condaSetup.steps.detecting' }, + { id: 'analyzing', labelKey: 'settings:condaSetup.steps.analyzing' }, + { id: 'creating', labelKey: 'settings:condaSetup.steps.creating' }, + { id: 'installing-python', labelKey: 'settings:condaSetup.steps.installingPython' }, + { id: 'verifying-python', labelKey: 'settings:condaSetup.steps.verifyingPython' }, + { id: 'installing-deps', labelKey: 'settings:condaSetup.steps.installingDeps' }, + { id: 'generating-scripts', labelKey: 'settings:condaSetup.steps.generatingScripts' }, + { id: 'finalizing', labelKey: 'settings:condaSetup.steps.finalizing' }, + { id: 'complete', labelKey: 'settings:condaSetup.steps.complete' } +]; + +// Helper to build steps with dynamic Python version +function buildSetupSteps(version: string): StepInfo[] { + return BASE_SETUP_STEPS.map(step => + step.id === 'installing-python' + ? { ...step, labelParams: { version } } + : step + ); +} + +function StepIcon({ status }: { status: StepStatus }) { + switch (status) { + case 'completed': + return ; + case 'in_progress': + return ; + case 'error': + return ; + default: + return ; + } +} + +function getStepStatus(stepIndex: number, currentStep: number, hasError: boolean, isComplete: boolean): StepStatus { + if (hasError && stepIndex === currentStep) { + return 'error'; + } + // When setup is complete, all steps including the last one should show as completed + if (isComplete) { + return 'completed'; + } + if (stepIndex < currentStep) { + return 'completed'; + } + if (stepIndex === currentStep) { + return 'in_progress'; + } + return 'pending'; +} + +export function CondaSetupWizard({ + open, + onOpenChange, + type, + projectPath, + projectName, + pythonVersion, + onComplete +}: CondaSetupWizardProps) { + const { t } = useTranslation(['settings', 'common']); + const [expandedSteps, setExpandedSteps] = useState>(new Set()); + + // Build steps with the selected Python version + const setupSteps = useMemo( + () => buildSetupSteps(pythonVersion || '3.12'), + [pythonVersion] + ); + + const { + step, + progress, + logs, + isRunning, + error, + startSetup, + cancelSetup + } = useCondaSetup({ type, projectPath, projectName, pythonVersion }); + + // Convert step string to index + const stepIndex = setupSteps.findIndex(s => s.id === step); + const currentStepIndex = stepIndex >= 0 ? stepIndex : 0; + + // Auto-start setup when dialog opens + useEffect(() => { + if (open && !isRunning && step === 'detecting' && !error) { + startSetup(); + } + }, [open, isRunning, step, error, startSetup]); + + // Call onComplete when setup finishes successfully + useEffect(() => { + if (step === 'complete' && !error && !isRunning) { + onComplete?.(); + } + }, [step, error, isRunning, onComplete]); + + // Auto-expand current step + useEffect(() => { + if (isRunning && step) { + setExpandedSteps(prev => new Set(prev).add(step)); + } + }, [step, isRunning]); + + const toggleStepExpanded = useCallback((stepId: string) => { + setExpandedSteps(prev => { + const next = new Set(prev); + if (next.has(stepId)) { + next.delete(stepId); + } else { + next.add(stepId); + } + return next; + }); + }, []); + + const handleContinueInBackground = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + const handleCancel = useCallback(() => { + cancelSetup(); + onOpenChange(false); + }, [cancelSetup, onOpenChange]); + + const handleRetry = useCallback(() => { + startSetup(); + }, [startSetup]); + + const handleClose = useCallback((openState: boolean) => { + // Only allow closing if not running or if explicitly requested + if (!isRunning || !openState) { + onOpenChange(openState); + } + }, [isRunning, onOpenChange]); + + // Determine which steps to show (hide deps step if no requirements file) + const visibleSteps = setupSteps.filter(_stepInfo => { + // Always show all steps for now; the hook will skip steps internally if needed + return true; + }); + + const isComplete = step === 'complete' && !error; + const hasError = !!error; + + return ( + + + + + {t('settings:condaSetup.title')} + {projectName && ( + + - {projectName} + + )} + + + + {/* Stepper Container */} +
+
+ {visibleSteps.map((stepInfo, index) => { + const status = getStepStatus(index, currentStepIndex, hasError, isComplete); + // Filter logs for this step + const stepLogs = logs.filter(log => log.step === stepInfo.id); + const hasLogs = stepLogs.length > 0; + const isExpanded = expandedSteps.has(stepInfo.id); + const isCurrentStep = stepInfo.id === step; + + // Get label with interpolation + const label = stepInfo.labelParams + ? t(stepInfo.labelKey, stepInfo.labelParams) + : t(stepInfo.labelKey); + + return ( +
+ hasLogs && toggleStepExpanded(stepInfo.id)} + > + {/* Step Header */} +
hasLogs && toggleStepExpanded(stepInfo.id)} + > + {/* Step indicator line connector */} +
+ + {index < visibleSteps.length - 1 && ( +
+ )} +
+ + {/* Step label */} +
+ + {label} + + {status === 'error' && error && ( +

+ {error} +

+ )} +
+ + {/* Expand/collapse indicator */} + {hasLogs && ( + + + + )} +
+ + {/* Collapsible Log Panel */} + +
+
+ {stepLogs.map((log: CondaSetupLogEntry, logIndex: number) => ( +
+ {log.message} + {log.detail && ( + {log.detail} + )} +
+ ))} +
+
+
+ +
+ ); + })} +
+
+ + {/* Progress bar */} + {isRunning && ( +
+
+
+ )} + + {/* Action buttons */} +
+ {hasError ? ( + <> + + + + ) : isComplete ? ( + + ) : ( + <> + + + + )} +
+ +
+ ); +} diff --git a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx index b03e1b8f2f..b6e19efb85 100644 --- a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx @@ -1,11 +1,16 @@ import { useTranslation } from 'react-i18next'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; +import { CheckCircle2, AlertTriangle, Settings2, FolderOpen } from 'lucide-react'; import { Label } from '../ui/label'; import { Input } from '../ui/input'; +import { Button } from '../ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Switch } from '../ui/switch'; import { SettingsSection } from './SettingsSection'; import { AgentProfileSettings } from './AgentProfileSettings'; +import { CondaDetectionDisplay } from './CondaDetectionDisplay'; +import { CondaSetupWizard } from './CondaSetupWizard'; +import { PythonPackageValidator } from './PythonPackageValidator'; import { AVAILABLE_MODELS, THINKING_LEVELS, @@ -16,10 +21,11 @@ import { import type { AppSettings, FeatureModelConfig, - FeatureThinkingConfig, ModelTypeShort, ThinkingLevel, - ToolDetectionResult + ToolDetectionResult, + CondaDetectionResult, + CondaEnvValidation } from '../../../shared/types'; interface GeneralSettingsProps { @@ -41,7 +47,7 @@ function ToolDetectionDisplay({ info, isLoading, t }: ToolDetectionDisplayProps) if (isLoading) { return (
- Detecting... + {t('python.detecting')}
); } @@ -100,6 +106,13 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General } | null>(null); const [isLoadingTools, setIsLoadingTools] = useState(false); + // Conda/Python environment state + const [condaDetection, setCondaDetection] = useState(null); + const [isLoadingConda, setIsLoadingConda] = useState(false); + const [autoClaudeEnvStatus, setAutoClaudeEnvStatus] = useState(null); + const [showSetupWizard, setShowSetupWizard] = useState(false); + const [isPythonValidating, setIsPythonValidating] = useState(false); + // Fetch CLI tools detection info when component mounts (paths section only) useEffect(() => { if (section === 'paths') { @@ -120,6 +133,59 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General } }, [section]); + // Check Auto Claude environment status + const checkAutoClaudeEnv = useCallback(async () => { + try { + const result = await window.electronAPI.conda.checkAutoClaudeEnv(); + if (result.success && result.data) { + setAutoClaudeEnvStatus(result.data); + } + } catch (error) { + console.error('Failed to check Auto Claude env:', error); + } + }, []); + + // Load Conda detection and Auto Claude env status + const loadCondaDetection = useCallback(async () => { + setIsLoadingConda(true); + try { + const result = await window.electronAPI.conda.detectConda(); + if (result.success && result.data) { + setCondaDetection(result.data); + // If conda is detected, also check the Auto Claude environment status + if (result.data.found) { + checkAutoClaudeEnv(); + } + } + } catch (error) { + console.error('Failed to detect Conda:', error); + } finally { + setIsLoadingConda(false); + } + }, [checkAutoClaudeEnv]); + + // Load Conda detection when paths section is active + useEffect(() => { + if (section === 'paths') { + loadCondaDetection(); + } + }, [section, loadCondaDetection]); + + const handleRefreshConda = useCallback(() => { + loadCondaDetection(); + }, [loadCondaDetection]); + + async function handleBrowseConda() { + try { + const path = await window.electronAPI.selectDirectory(); + if (path) { + onSettingsChange({ ...settings, condaPath: path }); + } + } catch (error) { + console.error('Failed to open directory picker:', error); + } + } + if (section === 'agent') { return (
@@ -250,17 +316,41 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General title={t('general.paths')} description={t('general.pathsDescription')} > + {/* Loading Overlay - Show during Python validation */} + {isPythonValidating && ( +
+
+

{t('python.validatingEnvironment')}

+

{t('python.validatingHint')}

+
+ )}

{t('general.pythonPathDescription')}

- onSettingsChange({ ...settings, pythonPath: e.target.value })} - /> +
+ onSettingsChange({ ...settings, pythonPath: e.target.value })} + /> + +
{!settings.pythonPath && ( )}
+ + {/* Python Activation Script */} +
+ +

+ {t('general.pythonActivationScriptDescription')} +

+
+ onSettingsChange({ ...settings, pythonActivationScript: e.target.value })} + /> + +
+

+ {t('general.pythonActivationScriptHint')} +

+
+ + {/* Package Validation (only show if pythonPath is set) */} + {settings.pythonPath && ( +
+ +

+ {t('general.pythonPackageValidationDescription')} +

+ +
+ )} +

{t('general.gitPathDescription')}

@@ -334,7 +478,110 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General onChange={(e) => onSettingsChange({ ...settings, autoBuildPath: e.target.value })} />
+ + {/* Conda / Python Environment Section */} +
+
+

{t('python.title')}

+

{t('sections.pythonEnv.description')}

+
+ + {/* Conda Installation Detection */} +
+ + onSettingsChange({ ...settings, condaPath: path })} + /> +
+ + {/* Auto Claude Environment Status (only shown if conda is detected) */} + {condaDetection?.found && ( +
+ +
+ {/* Status display */} +
+
+ {autoClaudeEnvStatus?.valid ? ( + <> + +
+
+ {t('python.statusReady')} +
+
+ Python {autoClaudeEnvStatus.pythonVersion} + {autoClaudeEnvStatus.packageCount !== undefined && ( + + {t('python.packagesInstalled', { count: autoClaudeEnvStatus.packageCount })} + + )} +
+
+ + ) : autoClaudeEnvStatus?.error ? ( + <> + +
+
+ {t('python.statusBroken')} +
+
+ {autoClaudeEnvStatus.message || autoClaudeEnvStatus.error} +
+
+ + ) : ( + <> + +
+
+ {t('python.statusNotConfigured')} +
+
+ {t('sections.pythonEnv.description')} +
+
+ + )} +
+ + {/* Setup / Reinstall button */} + +
+
+
+ )} +
+ + {/* Conda Setup Wizard Dialog */} + { + loadCondaDetection(); + checkAutoClaudeEnv(); + }} + /> ); } diff --git a/apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx b/apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx index 47f4e101bd..585c38e8fb 100644 --- a/apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx +++ b/apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx @@ -10,13 +10,15 @@ import { SectionRouter } from './sections/SectionRouter'; import { createHookProxy } from './utils/hookProxyFactory'; import type { Project } from '../../../shared/types'; -export type ProjectSettingsSection = 'general' | 'linear' | 'github' | 'gitlab' | 'memory'; +export type ProjectSettingsSection = 'general' | 'linear' | 'github' | 'gitlab' | 'memory' | 'python-env'; interface ProjectSettingsContentProps { project: Project | undefined; activeSection: ProjectSettingsSection; isOpen: boolean; onHookReady: (hook: UseProjectSettingsReturn | null) => void; + /** Callback when useCondaEnv setting changes (for sidebar reactivity) */ + onUseCondaEnvChange?: (enabled: boolean) => void; } /** @@ -27,7 +29,8 @@ export function ProjectSettingsContent({ project, activeSection, isOpen, - onHookReady + onHookReady, + onUseCondaEnvChange }: ProjectSettingsContentProps) { const { t } = useTranslation('settings'); @@ -49,6 +52,7 @@ export function ProjectSettingsContent({ activeSection={activeSection} isOpen={isOpen} onHookReady={onHookReady} + onUseCondaEnvChange={onUseCondaEnvChange} /> ); } @@ -61,12 +65,14 @@ function ProjectSettingsContentInner({ project, activeSection, isOpen, - onHookReady + onHookReady, + onUseCondaEnvChange }: { project: Project; activeSection: ProjectSettingsSection; isOpen: boolean; onHookReady: (hook: UseProjectSettingsReturn | null) => void; + onUseCondaEnvChange?: (enabled: boolean) => void; }) { const hook = useProjectSettings(project, isOpen); @@ -148,6 +154,7 @@ function ProjectSettingsContentInner({ isCheckingLinear={isCheckingLinear} handleInitialize={handleInitialize} onOpenLinearImport={() => setShowLinearImportModal(true)} + onUseCondaEnvChange={onUseCondaEnvChange} /> diff --git a/apps/frontend/src/renderer/components/settings/PythonPackageValidator.tsx b/apps/frontend/src/renderer/components/settings/PythonPackageValidator.tsx new file mode 100644 index 0000000000..15570b4d93 --- /dev/null +++ b/apps/frontend/src/renderer/components/settings/PythonPackageValidator.tsx @@ -0,0 +1,359 @@ +import { useEffect, useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '../ui/button'; +import { Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'; + +interface Props { + pythonPath: string; + activationScript?: string; + onValidationStateChange?: (isValidating: boolean) => void; +} + +export function PythonPackageValidator({ pythonPath, activationScript, onValidationStateChange }: Props) { + const { t } = useTranslation('settings'); + + // Package validation state + const [status, setStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle'); + const [missingPackages, setMissingPackages] = useState([]); + const [installLocation, setInstallLocation] = useState(''); + const [validationProgress, setValidationProgress] = useState<{ current: number; total: number; packageName: string } | null>(null); + const [installing, setInstalling] = useState(false); + const [installProgress, setInstallProgress] = useState(''); + const [error, setError] = useState(''); + + // Environment validation state + const [envStatus, setEnvStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle'); + const [envValidation, setEnvValidation] = useState<{ + valid: boolean; + pythonPath: string | null; + version: string | null; + error: string | null; + status: 'valid' | 'missing' | 'wrong_version' | 'error'; + } | null>(null); + const [reinstalling, setReinstalling] = useState(false); + const [reinstallProgress, setReinstallProgress] = useState<{ step: string; completed: number; total: number } | null>(null); + + // Track last validated paths to avoid re-validating unnecessarily + const lastValidatedRef = useRef<{ pythonPath: string; activationScript: string | undefined }>({ pythonPath: '', activationScript: undefined }); + + useEffect(() => { + // Skip validation if paths haven't changed + if (lastValidatedRef.current.pythonPath === pythonPath && + lastValidatedRef.current.activationScript === activationScript) { + return; + } + + // Defer validation to allow UI to render first (prevents blocking) + const timeoutId = setTimeout(() => { + lastValidatedRef.current = { pythonPath, activationScript }; + + if (activationScript) { + validateEnvironment(); + } else { + checkPackages(); + } + }, 100); // 100ms delay to let UI render + + return () => clearTimeout(timeoutId); + // eslint-disable-next-line react-hooks/exhaustive-deps -- checkPackages and validateEnvironment are stable functions, only re-run on path changes + }, [pythonPath, activationScript]); + + const checkPackages = async () => { + setStatus('checking'); + setValidationProgress(null); + setError(''); + + // Listen for validation progress + const unsubscribe = window.electronAPI.onPythonValidationProgress((progress) => { + setValidationProgress({ + current: progress.current, + total: progress.total, + packageName: progress.packageName + }); + }); + + const result = await window.electronAPI.validatePythonPackages({ + pythonPath, + activationScript + }); + + unsubscribe(); + + if (result.success && result.data) { + setStatus(result.data.allInstalled ? 'valid' : 'invalid'); + setMissingPackages(result.data.missingPackages || []); + setInstallLocation(result.data.installLocation || ''); + setError(''); + } else { + setStatus('invalid'); + setMissingPackages([]); + setInstallLocation(''); + setError(result.error || 'Failed to validate packages. Check that your Python path points to a Python executable (python.exe), not a directory.'); + } + + setValidationProgress(null); + }; + + const installRequirements = async () => { + setInstalling(true); + setInstallProgress(t('python.installing')); + setError(''); + + const unsubscribe = window.electronAPI.onPythonInstallProgress((progress) => { + setInstallProgress(progress); + }); + + const result = await window.electronAPI.installPythonRequirements({ + pythonPath, + activationScript + }); + + unsubscribe(); + setInstalling(false); + + if (result.success) { + setError(''); + await checkPackages(); + } else { + setError(result.error || 'Installation failed. Check the progress output above for details.'); + } + }; + + const validateEnvironment = async () => { + if (!activationScript) { + checkPackages(); + return; + } + + setEnvStatus('checking'); + setError(''); + + try { + const result = await window.electronAPI.validatePythonEnvironment({ + activationScript + }); + + if (result.success && result.data) { + setEnvValidation(result.data); + setEnvStatus(result.data.valid ? 'valid' : 'invalid'); + + // If environment is valid, proceed to check packages + if (result.data.valid) { + await checkPackages(); + } + } else { + setEnvStatus('invalid'); + setError(result.error || 'Failed to validate Python environment'); + } + } catch (error) { + console.error('Error validating Python environment:', error); + setEnvStatus('invalid'); + setError('Failed to validate Python environment: ' + String(error)); + } + }; + + const handleReinstallEnvironment = async () => { + if (!activationScript) { + setError('No activation script configured'); + return; + } + + if (!confirm('⚠️ WARNING: This will DELETE the existing Python environment and reinstall Python 3.12.\n\nAll installed packages will be removed.\n\nContinue?')) { + return; + } + + setReinstalling(true); + setError(''); + setReinstallProgress({ step: 'Starting...', completed: 0, total: 3 }); + + const unsubscribe = window.electronAPI.onPythonReinstallProgress((progress) => { + setReinstallProgress(progress); + }); + + const result = await window.electronAPI.reinstallPythonEnvironment({ + activationScript, + pythonVersion: '3.12' + }); + + unsubscribe(); + setReinstalling(false); + setReinstallProgress(null); + + if (result.success && result.data?.success) { + setError(''); + setEnvStatus('valid'); + setEnvValidation({ + valid: true, + pythonPath: result.data.environmentPath, + version: result.data.pythonVersion, + error: null, + status: 'valid' + }); + // Re-validate environment and packages after reinstall + await validateEnvironment(); + } else { + setError(result.data?.error || result.error || 'Environment reinstall failed'); + setEnvStatus('invalid'); + } + }; + + // Check if initial validation is in progress + const isInitialValidation = (envStatus === 'checking' && !envValidation) || + (status === 'checking' && !missingPackages.length && !installLocation); + + // Notify parent component of validation state changes + useEffect(() => { + onValidationStateChange?.(isInitialValidation); + }, [isInitialValidation, onValidationStateChange]); + + return ( +
+ {/* Loading Overlay - Show during initial validation */} + {isInitialValidation && ( +
+ +

Validating Python environment...

+

This may take a few seconds

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

Error

+

{error}

+
+ )} + + {/* Python Environment Section (only show if checking or invalid) */} + {activationScript && envStatus !== 'idle' && envStatus !== 'valid' && ( +
+ {/* Environment Status Display */} + {envStatus === 'checking' && ( +
+ + Validating Python environment... +
+ )} + + {envStatus === 'invalid' && envValidation && ( +
+
+ {envValidation.status === 'missing' && } + {envValidation.status === 'wrong_version' && } + {envValidation.status === 'error' && } + + {envValidation.status === 'missing' && 'Python not found'} + {envValidation.status === 'wrong_version' && `Wrong version: ${envValidation.version}`} + {envValidation.status === 'error' && 'Validation error'} + +
+ +

+ {envValidation.status === 'missing' && + 'Python 3.12+ is required but not found. Click below to install it.'} + {envValidation.status === 'wrong_version' && + 'Python 3.12+ is required. Click below to reinstall with the correct version.'} + {envValidation.status === 'error' && + envValidation.error} +

+ + + + {/* Reinstall Progress */} + {reinstallProgress && ( +
+
+ + {reinstallProgress.step} +
+
+ Step {reinstallProgress.completed} of {reinstallProgress.total} +
+
+ )} +
+ )} +
+ )} + + {/* Installation Location */} + {installLocation && ( +
+ Install location:{' '} + {installLocation} +
+ )} + + {status === 'checking' && ( +
+
+ + + {validationProgress + ? `${validationProgress.packageName} (${validationProgress.current}/${validationProgress.total})` + : t('python.validatingPackages')} + +
+
+ )} + + {status === 'valid' && ( +
+ + {t('python.allPackagesInstalled')} +
+ )} + + {status === 'invalid' && ( +
+
+ + {t('python.packagesMissing')} +
+ + {missingPackages.length > 0 && ( +
+

+ Missing packages ({missingPackages.length}): +

+
+ {missingPackages.map((pkg) => ( +
+ • {pkg} +
+ ))} +
+
+ )} + + + + {(installing || installProgress) && ( +
+

Installation Output:

+
+ {installProgress || 'Starting installation...'} +
+
+ )} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/renderer/components/settings/integrations/GitLabIntegration.tsx b/apps/frontend/src/renderer/components/settings/integrations/GitLabIntegration.tsx index 3d4618b0f9..947d81d825 100644 --- a/apps/frontend/src/renderer/components/settings/integrations/GitLabIntegration.tsx +++ b/apps/frontend/src/renderer/components/settings/integrations/GitLabIntegration.tsx @@ -232,10 +232,14 @@ export function GitLabIntegration({ }; const handleStartOAuth = async () => { + debugLog('handleStartOAuth called'); const hostname = envConfig?.gitlabInstanceUrl?.replace(/^https?:\/\//, '').replace(/\/$/, ''); + debugLog('Calling startGitLabAuth'); const result = await window.electronAPI.startGitLabAuth(hostname); + debugLog('startGitLabAuth result:', { success: result.success, hasError: !!result.error }); if (result.success) { + debugLog('Auth started successfully, polling for completion...'); // Poll for auth completion const checkAuth = async () => { const authResult = await window.electronAPI.checkGitLabAuth(hostname); @@ -247,6 +251,8 @@ export function GitLabIntegration({ } }; setTimeout(checkAuth, 3000); + } else { + debugLog('startGitLabAuth failed:', result.error); } }; diff --git a/apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx b/apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx index 27dbdd8a0d..2c6db95bc6 100644 --- a/apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx +++ b/apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx @@ -3,6 +3,7 @@ import type { Project, ProjectSettings as ProjectSettingsType, AutoBuildVersionI import { SettingsSection } from '../SettingsSection'; import { GeneralSettings } from '../../project-settings/GeneralSettings'; import { SecuritySettings } from '../../project-settings/SecuritySettings'; +import { PythonEnvSettings } from '../../project-settings/PythonEnvSettings'; import { LinearIntegration } from '../integrations/LinearIntegration'; import { GitHubIntegration } from '../integrations/GitHubIntegration'; import { GitLabIntegration } from '../integrations/GitLabIntegration'; @@ -37,6 +38,8 @@ interface SectionRouterProps { isCheckingLinear: boolean; handleInitialize: () => Promise; onOpenLinearImport: () => void; + /** Callback when useCondaEnv setting changes (for sidebar reactivity) */ + onUseCondaEnvChange?: (enabled: boolean) => void; } /** @@ -70,7 +73,8 @@ export function SectionRouter({ linearConnectionStatus, isCheckingLinear, handleInitialize, - onOpenLinearImport + onOpenLinearImport, + onUseCondaEnvChange }: SectionRouterProps) { const { t } = useTranslation('settings'); @@ -78,8 +82,8 @@ export function SectionRouter({ case 'general': return ( ); @@ -194,6 +199,30 @@ export function SectionRouter({ ); + case 'python-env': + // Only render if useCondaEnv is enabled (nav item should only appear when enabled) + if (!settings.useCondaEnv) { + return null; + } + return ( + + + + + + ); + default: return null; } diff --git a/apps/frontend/src/renderer/components/ui/slider.tsx b/apps/frontend/src/renderer/components/ui/slider.tsx new file mode 100644 index 0000000000..75d06313c4 --- /dev/null +++ b/apps/frontend/src/renderer/components/ui/slider.tsx @@ -0,0 +1,29 @@ +/** + * Slider component based on Radix UI Slider + * Used for numeric value selection with visual feedback + */ +import * as React from 'react'; +import * as SliderPrimitive from '@radix-ui/react-slider'; +import { cn } from '../../lib/utils'; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/apps/frontend/src/renderer/hooks/index.ts b/apps/frontend/src/renderer/hooks/index.ts index 2afff22a76..f547b37528 100644 --- a/apps/frontend/src/renderer/hooks/index.ts +++ b/apps/frontend/src/renderer/hooks/index.ts @@ -8,3 +8,5 @@ export { type AgentSettingsSource, } from './useResolvedAgentSettings'; export { useVirtualizedTree } from './useVirtualizedTree'; +export { useCondaSetup } from './useCondaSetup'; +export type { UseCondaSetupOptions, UseCondaSetupReturn, CondaSetupLogEntry } from './useCondaSetup'; diff --git a/apps/frontend/src/renderer/hooks/useCondaSetup.ts b/apps/frontend/src/renderer/hooks/useCondaSetup.ts new file mode 100644 index 0000000000..3c6346fcb2 --- /dev/null +++ b/apps/frontend/src/renderer/hooks/useCondaSetup.ts @@ -0,0 +1,224 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { CondaSetupStep, SetupProgress } from '../../shared/types'; + +/** + * Log entry accumulated during setup + */ +export interface CondaSetupLogEntry { + step: CondaSetupStep; + message: string; + detail?: string; +} + +/** + * Options for the useCondaSetup hook + */ +export interface UseCondaSetupOptions { + /** Type of environment to set up */ + type: 'app' | 'project'; + /** Project path (required for project type) */ + projectPath?: string; + /** Project name (required for project type) */ + projectName?: string; + /** Python version to install (e.g., '3.12'). If not provided, auto-detects from project files */ + pythonVersion?: string; +} + +/** + * Return type for the useCondaSetup hook + */ +export interface UseCondaSetupReturn { + /** Current step in the setup process */ + step: CondaSetupStep; + /** Current progress message */ + message: string; + /** Detailed log output for current step */ + detail: string; + /** Progress percentage (0-100) if available */ + progress: number | undefined; + /** All logs collected during setup */ + logs: CondaSetupLogEntry[]; + /** Whether setup is currently running */ + isRunning: boolean; + /** Error message if setup failed */ + error: string | null; + /** Start the setup process */ + startSetup: () => Promise; + /** Cancel the setup process */ + cancelSetup: () => void; + /** Reset state to allow retry */ + reset: () => void; +} + +/** + * React hook for managing Conda environment setup. + * + * Handles both app-level (Auto Claude) and project-level environment setup, + * tracking progress and collecting logs throughout the process. + * + * @param options - Configuration for the setup process + * @returns Setup state and control functions + * + * @example + * ```tsx + * // App-level setup + * const { step, message, isRunning, startSetup } = useCondaSetup({ type: 'app' }); + * + * // Project-level setup + * const { step, message, isRunning, startSetup } = useCondaSetup({ + * type: 'project', + * projectPath: '/path/to/project', + * projectName: 'my-project' + * }); + * ``` + */ +export function useCondaSetup(options: UseCondaSetupOptions): UseCondaSetupReturn { + const { type, projectPath, projectName, pythonVersion } = options; + + // State for tracking setup progress + const [step, setStep] = useState('detecting'); + const [message, setMessage] = useState(''); + const [detail, setDetail] = useState(''); + const [progress, setProgress] = useState(undefined); + const [logs, setLogs] = useState([]); + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + + // Ref to track if component is mounted (for async cleanup) + const isMountedRef = useRef(true); + // Ref to store cleanup function for progress listener + const cleanupRef = useRef<(() => void) | null>(null); + + // Subscribe to progress events + useEffect(() => { + // Reset mounted ref on each effect run (important for React 18 StrictMode) + isMountedRef.current = true; + + const handleProgress = (progressData: SetupProgress) => { + if (!isMountedRef.current) return; + + // Update current state + setStep(progressData.step); + setMessage(progressData.message); + setDetail(progressData.detail || ''); + setProgress(progressData.progress); + + // Accumulate log entry (limit to last 50 entries to prevent memory issues) + setLogs((prevLogs) => { + const newLogs = [ + ...prevLogs, + { + step: progressData.step, + message: progressData.message, + detail: progressData.detail, + }, + ]; + // Keep only the last 50 entries + return newLogs.length > 50 ? newLogs.slice(-50) : newLogs; + }); + + // Check for completion or error states + if (progressData.step === 'complete' || progressData.step === 'error') { + setIsRunning(false); + if (progressData.step === 'error') { + setError(progressData.message); + } + } + }; + + // Subscribe to progress events + cleanupRef.current = window.electronAPI.conda.onSetupProgress(handleProgress); + + // Cleanup on unmount + return () => { + isMountedRef.current = false; + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + }; + }, []); + + /** + * Start the setup process + */ + const startSetup = useCallback(async () => { + // Validate project options for project type + if (type === 'project' && (!projectPath || !projectName)) { + setError('Project path and name are required for project environment setup'); + setStep('error'); + return; + } + + // Reset state for new setup + setIsRunning(true); + setError(null); + setLogs([]); + setStep('detecting'); + setMessage('Starting setup...'); + setDetail(''); + setProgress(undefined); + + try { + if (type === 'app') { + const result = await window.electronAPI.conda.setupAutoClaudeEnv(); + if (!result.success && result.error) { + throw new Error(result.error); + } + } else { + const result = await window.electronAPI.conda.setupProjectEnv(projectPath!, projectName!, pythonVersion); + if (!result.success && result.error) { + throw new Error(result.error); + } + } + // For successful completion, the progress event handler sets + // isRunning to false when step becomes 'complete'. + } catch (err) { + if (isMountedRef.current) { + const errorMessage = err instanceof Error ? err.message : 'Setup failed'; + setError(errorMessage); + setStep('error'); + setMessage(errorMessage); + setIsRunning(false); + } + } + }, [type, projectPath, projectName, pythonVersion]); + + /** + * Cancel the setup process + * Note: Currently just resets state as actual cancellation would require + * backend support for interrupting conda commands + */ + const cancelSetup = useCallback(() => { + setIsRunning(false); + setMessage('Setup cancelled'); + setStep('error'); + setError('Setup was cancelled'); + }, []); + + /** + * Reset state to allow retry + */ + const reset = useCallback(() => { + setStep('detecting'); + setMessage(''); + setDetail(''); + setProgress(undefined); + setLogs([]); + setIsRunning(false); + setError(null); + }, []); + + return { + step, + message, + detail, + progress, + logs, + isRunning, + error, + startSetup, + cancelSetup, + reset, + }; +} diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index bd9d8d4d23..eb604d4043 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -229,6 +229,44 @@ const browserMockAPI: ElectronAPI = { onAnalyzePreviewError: () => () => {} }, + // Conda API + conda: { + detectConda: async () => ({ success: true, data: { found: false, installations: [], preferred: null } }), + refreshConda: async () => ({ success: true, data: { found: false, installations: [], preferred: null } }), + setupAutoClaudeEnv: async () => ({ success: true }), + checkAutoClaudeEnv: async () => ({ success: true, data: { valid: false } }), + setupProjectEnv: async (_projectPath: string, _projectName: string, _pythonVersion?: string) => ({ success: true }), + checkProjectEnv: async () => ({ success: true, data: { valid: false } }), + deleteProjectEnv: async () => ({ success: true }), + deleteActivationScripts: async () => ({ success: true }), + regenerateScripts: async () => ({ success: true, data: { workspacePath: '', initScriptPath: '' } }), + getPythonVersion: async () => ({ success: true, data: { version: '3.12', source: 'default' as const, constraint: 'minimum' as const, raw: '>=3.12' } }), + installDeps: async () => ({ success: true }), + getProjectPaths: async (_projectPath: string, projectName: string) => ({ + success: true, + data: { + projectType: 'pure-python' as const, + pythonRoot: '', + pythonRootRelative: '', + envPath: `.envs/${projectName}/`, + envPathRelative: `.envs/${projectName}/`, + workspacePath: `${projectName}.code-workspace`, + workspaceFile: `${projectName}.code-workspace`, + scriptsPath: 'scripts/', + scriptsPathRelative: 'scripts/' + } + }), + listPythonVersions: async () => ({ + success: true, + data: { + versions: ['3.13', '3.12', '3.11', '3.10', '3.9', '3.8'], + recommended: '3.12', + detectedVersion: '3.12' + } + }), + onSetupProgress: () => () => {} + }, + // Claude Code Operations checkClaudeCodeVersion: async () => ({ success: true, @@ -330,7 +368,19 @@ const browserMockAPI: ElectronAPI = { openLogsFolder: async () => ({ success: false, error: 'Not available in browser mode' }), copyDebugInfo: async () => ({ success: false, error: 'Not available in browser mode' }), getRecentErrors: async () => [], - listLogFiles: async () => [] + listLogFiles: async () => [], + + // Shell Operations + openExternal: async (_url: string) => { + console.warn('[Browser Mock] openExternal called'); + }, + openTerminal: async (_dirPath: string) => ({ + success: false, + error: 'Not available in browser mode' + }), + showItemInFolder: async (_filePath: string) => { + console.warn('[Browser Mock] showItemInFolder called'); + } }; /** diff --git a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts index 88c78c357a..6fad218f29 100644 --- a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts @@ -20,6 +20,24 @@ export const settingsMock = { getSentryDsn: async () => '', // No DSN in browser mode getSentryConfig: async () => ({ dsn: '', tracesSampleRate: 0, profilesSampleRate: 0 }), + // Python package validation (mock - not available in browser mode) + validatePythonPackages: async () => ({ + success: true, + data: { allInstalled: true, missingPackages: [], installLocation: '' } + }), + onPythonValidationProgress: () => () => {}, + installPythonRequirements: async () => ({ success: true }), + onPythonInstallProgress: () => () => {}, + validatePythonEnvironment: async () => ({ + success: true, + data: { valid: true, pythonPath: null, version: null, error: null, status: 'valid' as const } + }), + reinstallPythonEnvironment: async () => ({ + success: true, + data: { success: true, environmentPath: null, pythonVersion: null, error: null, stepsCompleted: [] } + }), + onPythonReinstallProgress: () => () => {}, + getCliToolsInfo: async () => ({ success: true, data: { diff --git a/apps/frontend/src/shared/constants/config.ts b/apps/frontend/src/shared/constants/config.ts index daa0954e48..20835e9282 100644 --- a/apps/frontend/src/shared/constants/config.ts +++ b/apps/frontend/src/shared/constants/config.ts @@ -50,7 +50,11 @@ export const DEFAULT_APP_SETTINGS = { // Language preference (default to English) language: 'en' as const, // Anonymous error reporting (Sentry) - enabled by default to help improve the app - sentryEnabled: true + sentryEnabled: true, + // Conda configuration (detected during setup/onboarding) + condaPath: undefined as string | undefined, + autoClaudeEnvPath: undefined as string | undefined, + autoClaudeEnvStatus: 'none' as const }; // ============================================ diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 9e4f5b3649..86191ef9a3 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -149,6 +149,7 @@ export const IPC_CHANNELS = { // Shell operations SHELL_OPEN_EXTERNAL: 'shell:openExternal', SHELL_OPEN_TERMINAL: 'shell:openTerminal', + SHELL_SHOW_ITEM_IN_FOLDER: 'shell:showItemInFolder', // Roadmap operations ROADMAP_GET: 'roadmap:get', @@ -426,6 +427,24 @@ export const IPC_CHANNELS = { AUTOBUILD_SOURCE_ENV_UPDATE: 'autobuild:source:env:update', AUTOBUILD_SOURCE_ENV_CHECK_TOKEN: 'autobuild:source:env:checkToken', + // Conda environment management + CONDA_DETECT: 'conda:detect', + CONDA_REFRESH: 'conda:refresh', + CONDA_SETUP_AUTO_CLAUDE: 'conda:setupAutoClaude', + CONDA_CHECK_AUTO_CLAUDE: 'conda:checkAutoClaude', + CONDA_SETUP_PROJECT_ENV: 'conda:setupProjectEnv', + CONDA_CHECK_PROJECT_ENV: 'conda:checkProjectEnv', + CONDA_DELETE_PROJECT_ENV: 'conda:deleteProjectEnv', + CONDA_DELETE_ACTIVATION_SCRIPTS: 'conda:deleteActivationScripts', + CONDA_REGENERATE_SCRIPTS: 'conda:regenerateScripts', + CONDA_GET_PYTHON_VERSION: 'conda:getPythonVersion', + CONDA_INSTALL_DEPS: 'conda:installDeps', + CONDA_GET_PROJECT_PATHS: 'conda:getProjectPaths', + CONDA_LIST_PYTHON_VERSIONS: 'conda:listPythonVersions', + + // Conda events (main -> renderer) + CONDA_SETUP_PROGRESS: 'conda:setupProgress', + // Changelog operations CHANGELOG_GET_DONE_TASKS: 'changelog:getDoneTasks', CHANGELOG_LOAD_TASK_SPECS: 'changelog:loadTaskSpecs', @@ -521,5 +540,14 @@ export const IPC_CHANNELS = { // Sentry error reporting SENTRY_STATE_CHANGED: 'sentry:state-changed', // Notify main process when setting changes GET_SENTRY_DSN: 'sentry:get-dsn', // Get DSN from main process (env var) - GET_SENTRY_CONFIG: 'sentry:get-config' // Get full Sentry config (DSN + sample rates) + GET_SENTRY_CONFIG: 'sentry:get-config', // Get full Sentry config (DSN + sample rates) + + // Python validation + PYTHON_VALIDATE_PACKAGES: 'python:validatePackages', + PYTHON_VALIDATION_PROGRESS: 'python:validationProgress', + PYTHON_INSTALL_REQUIREMENTS: 'python:installRequirements', + PYTHON_INSTALL_PROGRESS: 'python:installProgress', + PYTHON_VALIDATE_ENVIRONMENT: 'python:validateEnvironment', + PYTHON_REINSTALL_ENVIRONMENT: 'python:reinstallEnvironment', + PYTHON_REINSTALL_PROGRESS: 'python:reinstallProgress' } as const; diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index bb2d0a2cfc..2b50990e47 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -52,7 +52,8 @@ "hideTokenEntryAriaLabel": "Hide token entry", "enterTokenManuallyAriaLabel": "Enter token manually", "renameProfileAriaLabel": "Rename profile", - "deleteProfileAriaLabel": "Delete profile" + "deleteProfileAriaLabel": "Delete profile", + "stopGenerationAriaLabel": "Stop generation" }, "buttons": { "save": "Save", @@ -93,6 +94,7 @@ "required": "Required", "dismiss": "Dismiss" }, + "recommended": "Recommended", "time": { "justNow": "Just now", "minutesAgo": "{{count}}m ago", @@ -105,7 +107,11 @@ "operationFailed": "Operation failed", "networkError": "Network error", "notFound": "Not found", - "unauthorized": "Unauthorized" + "unauthorized": "Unauthorized", + "unknownProvider": "Unknown provider: {{provider}}", + "providerNotImplemented": "{{provider}} not yet implemented", + "failedToGetGitHubToken": "Failed to get GitHub token", + "failedToGetGitLabToken": "Failed to get GitLab token" }, "notification": { "accountSwitched": "Account Switched", diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index ca243e07d5..d7308e7690 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -48,6 +48,10 @@ "debug": { "title": "Debug & Logs", "description": "Troubleshooting tools" + }, + "pythonEnv": { + "title": "Python Environment", + "description": "Configure isolated Python conda environment" } }, "apiProfiles": { @@ -202,6 +206,10 @@ "pythonPath": "Python Path", "pythonPathDescription": "Path to Python executable (leave empty for auto-detection)", "pythonPathPlaceholder": "python3 (default)", + "pythonActivationScript": "Python Activation Script", + "pythonActivationScriptDescription": "Path to conda activation script (e.g., activate.bat for Windows, activate for Unix). Leave empty to use Python directly.", + "pythonActivationScriptPlaceholder": "e.g., C:\\Users\\User\\.conda\\envs\\myenv\\Scripts\\activate.bat", + "pythonActivationScriptHint": "If set, this script will be sourced/executed before running Python commands to activate a virtual or conda environment.", "gitPath": "Git Path", "gitPathDescription": "Path to Git executable (leave empty for auto-detection)", "gitPathPlaceholder": "git (default)", @@ -226,7 +234,17 @@ "autoClaudePathDescription": "Relative path to auto-claude directory in projects", "autoClaudePathPlaceholder": "auto-claude (default)", "autoNameTerminals": "Automatically name terminals", - "autoNameTerminalsDescription": "Use AI to generate descriptive names for terminal tabs based on their activity" + "autoNameTerminalsDescription": "Use AI to generate descriptive names for terminal tabs based on their activity", + "browse": "Browse", + "selectPythonExecutable": "Select Python Executable", + "selectActivationScript": "Select Activation Script", + "fileFilter": { + "pythonExecutable": "Python Executable", + "activationScripts": "Activation Scripts", + "allFiles": "All Files" + }, + "pythonPackageValidation": "Package Validation", + "pythonPackageValidationDescription": "Verify required packages are installed in the configured Python environment." }, "theme": { "title": "Appearance", @@ -316,9 +334,22 @@ "general": { "title": "General", "description": "Auto-Build and agent config", + "descriptionWithProject": "Configure Auto-Build, agent model, and notifications for {{projectName}}", "useClaudeMd": "Use CLAUDE.md", "useClaudeMdDescription": "Include CLAUDE.md instructions in agent context" }, + "autoBuild": { + "title": "Auto-Build Integration", + "notInitialized": "Not Initialized", + "notInitializedDescription": "Initialize Auto-Build to enable task creation and agent workflows.", + "initializing": "Initializing...", + "initialize": "Initialize Auto-Build", + "initialized": "Initialized", + "checkingStatus": "Checking status..." + }, + "agentConfig": { + "title": "Agent Configuration" + }, "claude": { "title": "Claude Auth", "description": "Claude authentication" @@ -350,8 +381,117 @@ "integrationTitle": "Memory", "integrationDescription": "Configure persistent cross-session memory for agents", "syncDescription": "Configure persistent memory" + }, + "pythonEnv": { + "title": "Python Env", + "description": "Isolated Python environment" + }, + "python-env": { + "title": "Python Env", + "description": "Isolated Python environment" } }, + "python": { + "title": "Python", + "pathPlaceholder": "C:\\path\\to\\conda", + "condaInstallation": "Conda Installation", + "condaTypes": { + "miniconda": "Miniconda", + "anaconda": "Anaconda", + "mambaforge": "Mambaforge", + "miniforge": "Miniforge", + "unknown": "Conda" + }, + "detected": "Detected", + "detecting": "Detecting...", + "notDetected": "No conda installation found", + "installMiniconda": "Install Miniconda", + "refresh": "Refresh", + "specifyPath": "Or specify path", + "browse": "Browse", + "autoClaudeEnv": "Auto Claude Environment", + "projectEnv": "Project Environment", + "envLocation": "Environment Location", + "envLocationHint": "Self-contained within project (no external paths allowed)", + "detectedPython": "Detected Python", + "pythonVersion": "Python Version", + "selectVersion": "Select version", + "selectVersionPlaceholder": "Select Python version", + "detectedFromProject": "Detected from project", + "versionLockedHint": "Version cannot be changed after environment is created. Delete and recreate to use a different version.", + "versionChangeHint": "Select a different version and click Reinstall to change.", + "installedVersion": "Installed", + "status": "Status", + "statusReady": "Ready", + "statusNotConfigured": "Not configured", + "statusCreating": "Setting up...", + "statusBroken": "Broken / Missing", + "packagesInstalled": "{{count}} packages installed", + "setupEnv": "Setup Environment", + "reinstallEnv": "Reinstall Environment", + "vscodeIntegration": "VS Code Integration", + "workspaceFile": "Workspace file", + "workspaceHint": "Opens with PowerShell + auto-activated conda env", + "openInVscode": "Open in VS Code", + "showInFolder": "Show in Folder", + "activationScripts": "Activation Scripts", + "initScript": "VS Code init script", + "standaloneScripts": "Standalone scripts", + "regenerateAll": "Regenerate All", + "openScriptsFolder": "Open Scripts Folder", + "terminalIntegration": "Terminal Integration", + "autoActivate": "Auto-activate in terminals", + "autoActivateHint": "Automatically activate when opening terminals for this project", + "useCondaEnv": "Use Self-Contained Conda Environment", + "useCondaEnvHint": "Enable isolated Python environment for this project", + "deleteEnvTitle": "Delete Environment?", + "deleteEnvMessage": "An environment exists at:", + "deleteEnvPrompt": "Do you want to delete it?", + "keepFiles": "Keep Files", + "deleteEnv": "Delete Environment", + "deleteActivationScripts": "Also remove activation scripts", + "pathPlaceholder": "C:\\path\\to\\conda", + "validatingEnvironment": "Validating Python Environment", + "validatingHint": "This may take a moment...", + "checkingEnvironment": "Checking Python environment...", + "environmentValid": "Environment valid", + "environmentMissing": "Python environment not found", + "wrongVersion": "Python version {{version}} found, requires 3.12+", + "reinstalling": "Reinstalling...", + "reinstallEnvironment": "Reinstall Environment", + "reinstallTitle": "Reinstall Python Environment?", + "reinstallWarning": "This will remove the existing environment and create a new one with Python 3.12. All installed packages will be lost.", + "reinstallConfirm": "Yes, Reinstall", + "checking": "Checking", + "validatingPackages": "Validating packages...", + "allPackagesInstalled": "All required packages installed", + "packagesMissing": "Required packages missing", + "missingPackages": "{{count}} missing package(s)", + "installing": "Installing...", + "installMissing": "Install Missing Packages", + "installLocation": "Install location" + }, + "condaSetup": { + "title": "Setup Conda Environment", + "steps": { + "detecting": "Detecting conda installation", + "analyzing": "Analyzing project requirements", + "creating": "Creating environment", + "installingPython": "Installing Python {{version}}", + "verifyingPython": "Verifying Python installation", + "installingDeps": "Installing dependencies", + "generatingScripts": "Generating activation scripts", + "finalizing": "Finalizing environment", + "complete": "Setup complete", + "error": "Setup failed" + }, + "finalizingNote": "This may take up to a minute while Windows indexes the new files", + "showLogs": "Show logs", + "hideLogs": "Hide logs", + "continueInBackground": "Continue in Background", + "cancel": "Cancel", + "retry": "Retry" + }, "agentProfile": { "label": "Agent Profile", "title": "Default Agent Profile", @@ -479,6 +619,50 @@ "description": "Select a project from the sidebar to configure its settings." } }, + "gitProvider": { + "title": "Git Provider Settings", + "providerLabel": "Pull Request / Merge Request Provider", + "providerDescription": "Choose which git platform to use for PR/MR creation", + "autoDetectPlaceholder": "Auto-detect from remote", + "autoDetectHint": "Provider will be auto-detected from your git remote URL when creating PRs/MRs", + "options": { + "auto": "Auto-detect from git remote" + } + }, + "providerIntegration": { + "loadingConfiguration": "Loading configuration...", + "enableIssues": "Enable {{provider}} Issues", + "syncIssuesDescription": "Sync issues from {{provider}} and create tasks automatically", + "instanceLabel": "{{provider}} Instance", + "instanceDescription": "Use {{defaultUrl}} or your self-hosted instance URL", + "connectedViaCli": "Connected via {{provider}} CLI", + "authenticatedAs": "Authenticated as {{username}}", + "useManualToken": "Use Manual Token", + "selectRepo": "Select {{repoType}}", + "loadingRepos": "Loading {{repoType}}s...", + "selectRepoPlaceholder": "Select {{repoType}}", + "authentication": "{{provider}} Authentication", + "completeAuthInBrowser": "Complete the authentication in your browser", + "cliRequired": "{{provider}} CLI Required", + "cliRequiredDescription": "The {{provider}} CLI ({{cliName}}) is required for OAuth authentication.", + "installationCommandExecuted": "Installation command executed", + "installing": "Installing...", + "installCli": "Install {{cliName}}", + "learnMore": "Learn more", + "cliInstalled": "{{cliName}} CLI installed", + "authenticated": "Authenticated", + "notAuthenticatedHint": "Not authenticated - will authenticate when you save your token", + "personalAccessToken": "Personal Access Token", + "useOAuth": "Use OAuth", + "createTokenHint": "Create a token with api scope from", + "providerSettings": "{{provider}} Settings", + "repoFormatHint": "Format: {{format}} (e.g. {{example}})", + "defaultBranch": "Default Branch", + "refresh": "Refresh", + "baseBranchDescription": "Base branch for creating task worktrees", + "loadingBranches": "Loading branches...", + "noBranchesFound": "No branches found" + }, "mcp": { "title": "MCP Server Overview", "titleWithProject": "MCP Server Overview for {{projectName}}", diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index b68cf2d5e1..e021496ce1 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -237,5 +237,49 @@ }, "subtasks": { "untitled": "Untitled subtask" + }, + "tabs": { + "configuration": "Configuration", + "feedback": "Feedback" + }, + "configuration": { + "title": "Task Configuration", + "descriptionRunning": "Configure model and thinking levels per phase. Changes will apply to upcoming phases.", + "descriptionNotRunning": "Configure model and thinking levels per phase. Changes are saved automatically.", + "taskRunningWarning": "Task is Running ({{phase}} phase)", + "changesApplyFrom": "Changes will apply starting from the {{phase}} phase", + "currentPhaseComplete": "Current phase will complete with original settings", + "pendingChanges": "Pending Changes", + "pendingChangesHint": "Click \"Apply Changes\" to save. Changes will take effect on the next phase.", + "profile": "Profile: {{name}}", + "resetToDefaults": "Reset to Defaults", + "customProfile": "Custom", + "customProfileDescription": "Per-phase configuration", + "defaultProfile": "Default", + "defaultProfileDescription": "Using system defaults", + "phaseConfiguration": "Phase Configuration", + "phaseConfigurationHint": "Adjust model and thinking effort for each phase. Higher thinking levels use more tokens but provide deeper analysis.", + "phases": { + "spec": "Spec", + "planning": "Planning", + "coding": "Coding", + "qa": "QA" + }, + "phaseLabel": "{{phase}} Phase", + "currentPhase": "Current", + "willApplyChanges": "Will apply changes", + "model": "Model", + "thinkingLevel": "Thinking Level", + "thinkingLabels": { + "none": "None", + "low": "Low", + "medium": "Medium", + "high": "High", + "ultra": "Ultra" + }, + "tokens": "{{count}} tokens", + "tokensK": "{{count}}K tokens", + "applyChanges": "Apply Changes", + "cancel": "Cancel" } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index b704f75ec8..ae4daa87c1 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -52,7 +52,8 @@ "hideTokenEntryAriaLabel": "Masquer la saisie du jeton", "enterTokenManuallyAriaLabel": "Saisir le jeton manuellement", "renameProfileAriaLabel": "Renommer le profil", - "deleteProfileAriaLabel": "Supprimer le profil" + "deleteProfileAriaLabel": "Supprimer le profil", + "stopGenerationAriaLabel": "Arrêter la génération" }, "buttons": { "save": "Enregistrer", @@ -93,6 +94,7 @@ "required": "Requis", "dismiss": "Ignorer" }, + "recommended": "Recommandé", "time": { "justNow": "À l'instant", "minutesAgo": "Il y a {{count}} min", @@ -105,7 +107,11 @@ "operationFailed": "Opération échouée", "networkError": "Erreur réseau", "notFound": "Non trouvé", - "unauthorized": "Non autorisé" + "unauthorized": "Non autorisé", + "unknownProvider": "Fournisseur inconnu : {{provider}}", + "providerNotImplemented": "{{provider}} pas encore implémenté", + "failedToGetGitHubToken": "Échec de récupération du jeton GitHub", + "failedToGetGitLabToken": "Échec de récupération du jeton GitLab" }, "notification": { "accountSwitched": "Compte changé", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index ea959dd178..ee26533d8c 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -48,6 +48,10 @@ "debug": { "title": "Debug & Logs", "description": "Outils de dépannage" + }, + "pythonEnv": { + "title": "Environnement Python", + "description": "Configurer un environnement conda Python isolé" } }, "apiProfiles": { @@ -202,6 +206,10 @@ "pythonPath": "Chemin Python", "pythonPathDescription": "Chemin vers l'exécutable Python (laisser vide pour détection automatique)", "pythonPathPlaceholder": "python3 (par défaut)", + "pythonActivationScript": "Script d'activation Python", + "pythonActivationScriptDescription": "Chemin vers le script d'activation conda (ex: activate.bat pour Windows, activate pour Unix). Laisser vide pour utiliser Python directement.", + "pythonActivationScriptPlaceholder": "ex: C:\\Users\\User\\.conda\\envs\\myenv\\Scripts\\activate.bat", + "pythonActivationScriptHint": "Si défini, ce script sera exécuté avant les commandes Python pour activer un environnement virtuel ou conda.", "gitPath": "Chemin Git", "gitPathDescription": "Chemin vers l'exécutable Git (laisser vide pour détection automatique)", "gitPathPlaceholder": "git (par défaut)", @@ -226,7 +234,17 @@ "autoClaudePathDescription": "Chemin relatif vers le répertoire auto-claude dans les projets", "autoClaudePathPlaceholder": "auto-claude (par défaut)", "autoNameTerminals": "Nommer automatiquement les terminaux", - "autoNameTerminalsDescription": "Utiliser l'IA pour générer des noms descriptifs pour les onglets de terminal en fonction de leur activité" + "autoNameTerminalsDescription": "Utiliser l'IA pour générer des noms descriptifs pour les onglets de terminal en fonction de leur activité", + "browse": "Parcourir", + "selectPythonExecutable": "Sélectionner l'exécutable Python", + "selectActivationScript": "Sélectionner le script d'activation", + "fileFilter": { + "pythonExecutable": "Exécutable Python", + "activationScripts": "Scripts d'activation", + "allFiles": "Tous les fichiers" + }, + "pythonPackageValidation": "Validation des packages", + "pythonPackageValidationDescription": "Vérifier que les packages requis sont installés dans l'environnement Python configuré." }, "theme": { "title": "Apparence", @@ -316,9 +334,22 @@ "general": { "title": "Général", "description": "Auto-Build et configuration de l'agent", + "descriptionWithProject": "Configurer Auto-Build, le modèle de l'agent et les notifications pour {{projectName}}", "useClaudeMd": "Utiliser CLAUDE.md", "useClaudeMdDescription": "Inclure les instructions CLAUDE.md dans le contexte de l'agent" }, + "autoBuild": { + "title": "Intégration Auto-Build", + "notInitialized": "Non initialisé", + "notInitializedDescription": "Initialisez Auto-Build pour activer la création de tâches et les workflows d'agents.", + "initializing": "Initialisation...", + "initialize": "Initialiser Auto-Build", + "initialized": "Initialisé", + "checkingStatus": "Vérification du statut..." + }, + "agentConfig": { + "title": "Configuration de l'agent" + }, "claude": { "title": "Auth Claude", "description": "Authentification Claude" @@ -350,8 +381,116 @@ "integrationTitle": "Mémoire", "integrationDescription": "Configurer la mémoire persistante inter-sessions pour les agents", "syncDescription": "Configurer la mémoire persistante" + }, + "pythonEnv": { + "title": "Env Python", + "description": "Environnement Python isolé" + }, + "python-env": { + "title": "Env Python", + "description": "Environnement Python isolé" } }, + "python": { + "title": "Python", + "condaInstallation": "Installation Conda", + "condaTypes": { + "miniconda": "Miniconda", + "anaconda": "Anaconda", + "mambaforge": "Mambaforge", + "miniforge": "Miniforge", + "unknown": "Conda" + }, + "detected": "Détecté", + "detecting": "Détection...", + "notDetected": "Aucune installation conda trouvée", + "installMiniconda": "Installer Miniconda", + "refresh": "Actualiser", + "specifyPath": "Ou spécifier le chemin", + "browse": "Parcourir", + "autoClaudeEnv": "Environnement Auto Claude", + "projectEnv": "Environnement du projet", + "envLocation": "Emplacement de l'environnement", + "envLocationHint": "Autonome dans le projet (aucun chemin externe autorisé)", + "detectedPython": "Python détecté", + "pythonVersion": "Version Python", + "selectVersion": "Sélectionner la version", + "selectVersionPlaceholder": "Sélectionner la version Python", + "detectedFromProject": "Détecté depuis le projet", + "versionLockedHint": "La version ne peut pas être modifiée après la création de l'environnement. Supprimez et recréez pour utiliser une autre version.", + "versionChangeHint": "Sélectionnez une version différente et cliquez sur Réinstaller pour changer.", + "installedVersion": "Installé", + "status": "Statut", + "statusReady": "Prêt", + "statusNotConfigured": "Non configuré", + "statusCreating": "Configuration en cours...", + "statusBroken": "Cassé / Manquant", + "packagesInstalled": "{{count}} packages installés", + "setupEnv": "Configurer l'environnement", + "reinstallEnv": "Réinstaller l'environnement", + "vscodeIntegration": "Intégration VS Code", + "workspaceFile": "Fichier workspace", + "workspaceHint": "S'ouvre avec PowerShell + environnement conda auto-activé", + "openInVscode": "Ouvrir dans VS Code", + "showInFolder": "Afficher dans le dossier", + "activationScripts": "Scripts d'activation", + "initScript": "Script init VS Code", + "standaloneScripts": "Scripts autonomes", + "regenerateAll": "Tout régénérer", + "openScriptsFolder": "Ouvrir le dossier des scripts", + "terminalIntegration": "Intégration terminal", + "autoActivate": "Auto-activation dans les terminaux", + "autoActivateHint": "Activer automatiquement lors de l'ouverture des terminaux pour ce projet", + "useCondaEnv": "Utiliser un environnement Conda autonome", + "useCondaEnvHint": "Activer un environnement Python isolé pour ce projet", + "deleteEnvTitle": "Supprimer l'environnement ?", + "deleteEnvMessage": "Un environnement existe à :", + "deleteEnvPrompt": "Voulez-vous le supprimer ?", + "keepFiles": "Conserver les fichiers", + "deleteEnv": "Supprimer l'environnement", + "deleteActivationScripts": "Supprimer également les scripts d'activation", + "pathPlaceholder": "C:\\chemin\\vers\\conda", + "validatingEnvironment": "Validation de l'environnement Python", + "validatingHint": "Cela peut prendre un moment...", + "checkingEnvironment": "Vérification de l'environnement Python...", + "environmentValid": "Environnement valide", + "environmentMissing": "Environnement Python non trouvé", + "wrongVersion": "Python version {{version}} trouvé, nécessite 3.12+", + "reinstalling": "Réinstallation...", + "reinstallEnvironment": "Réinstaller l'environnement", + "reinstallTitle": "Réinstaller l'environnement Python ?", + "reinstallWarning": "Ceci supprimera l'environnement existant et en créera un nouveau avec Python 3.12. Tous les packages installés seront perdus.", + "reinstallConfirm": "Oui, réinstaller", + "checking": "Vérification", + "validatingPackages": "Validation des packages...", + "allPackagesInstalled": "Tous les packages requis sont installés", + "packagesMissing": "Packages requis manquants", + "missingPackages": "{{count}} package(s) manquant(s)", + "installing": "Installation...", + "installMissing": "Installer les packages manquants", + "installLocation": "Emplacement d'installation" + }, + "condaSetup": { + "title": "Configurer l'environnement Conda", + "steps": { + "detecting": "Détection de l'installation conda", + "analyzing": "Analyse des exigences du projet", + "creating": "Création de l'environnement", + "installingPython": "Installation de Python {{version}}", + "verifyingPython": "Vérification de l'installation Python", + "installingDeps": "Installation des dépendances", + "generatingScripts": "Génération des scripts d'activation", + "finalizing": "Finalisation de l'environnement", + "complete": "Configuration terminée", + "error": "La configuration a échoué" + }, + "finalizingNote": "Cela peut prendre jusqu'à une minute pendant que Windows indexe les nouveaux fichiers", + "showLogs": "Afficher les logs", + "hideLogs": "Masquer les logs", + "continueInBackground": "Continuer en arrière-plan", + "cancel": "Annuler", + "retry": "Réessayer" + }, "agentProfile": { "label": "Profil d'agent", "title": "Profil d'agent par défaut", @@ -479,6 +618,50 @@ "description": "Sélectionnez un projet dans la barre latérale pour configurer ses paramètres." } }, + "gitProvider": { + "title": "Paramètres du fournisseur Git", + "providerLabel": "Fournisseur de Pull Request / Merge Request", + "providerDescription": "Choisissez quelle plateforme git utiliser pour la création de PR/MR", + "autoDetectPlaceholder": "Détection automatique depuis le remote", + "autoDetectHint": "Le fournisseur sera détecté automatiquement depuis l'URL de votre remote git lors de la création de PR/MR", + "options": { + "auto": "Détection automatique depuis le remote git" + } + }, + "providerIntegration": { + "loadingConfiguration": "Chargement de la configuration...", + "enableIssues": "Activer les issues {{provider}}", + "syncIssuesDescription": "Synchroniser les issues depuis {{provider}} et créer des tâches automatiquement", + "instanceLabel": "Instance {{provider}}", + "instanceDescription": "Utilisez {{defaultUrl}} ou l'URL de votre instance auto-hébergée", + "connectedViaCli": "Connecté via {{provider}} CLI", + "authenticatedAs": "Authentifié en tant que {{username}}", + "useManualToken": "Utiliser un token manuel", + "selectRepo": "Sélectionner {{repoType}}", + "loadingRepos": "Chargement des {{repoType}}s...", + "selectRepoPlaceholder": "Sélectionner {{repoType}}", + "authentication": "Authentification {{provider}}", + "completeAuthInBrowser": "Complétez l'authentification dans votre navigateur", + "cliRequired": "{{provider}} CLI requis", + "cliRequiredDescription": "Le CLI {{provider}} ({{cliName}}) est requis pour l'authentification OAuth.", + "installationCommandExecuted": "Commande d'installation exécutée", + "installing": "Installation...", + "installCli": "Installer {{cliName}}", + "learnMore": "En savoir plus", + "cliInstalled": "{{cliName}} CLI installé", + "authenticated": "Authentifié", + "notAuthenticatedHint": "Non authentifié - l'authentification se fera lors de l'enregistrement de votre token", + "personalAccessToken": "Token d'accès personnel", + "useOAuth": "Utiliser OAuth", + "createTokenHint": "Créez un token avec le scope api depuis", + "providerSettings": "Paramètres {{provider}}", + "repoFormatHint": "Format : {{format}} (ex. {{example}})", + "defaultBranch": "Branche par défaut", + "refresh": "Actualiser", + "baseBranchDescription": "Branche de base pour créer les worktrees de tâches", + "loadingBranches": "Chargement des branches...", + "noBranchesFound": "Aucune branche trouvée" + }, "mcp": { "title": "Aperçu des serveurs MCP", "titleWithProject": "Aperçu des serveurs MCP pour {{projectName}}", diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 9609f5fc9a..3e52b2a333 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -237,5 +237,49 @@ }, "subtasks": { "untitled": "Sous-tâche sans titre" + }, + "tabs": { + "configuration": "Configuration", + "feedback": "Commentaires" + }, + "configuration": { + "title": "Configuration de la tache", + "descriptionRunning": "Configurez le modele et les niveaux de reflexion par phase. Les modifications s'appliqueront aux phases suivantes.", + "descriptionNotRunning": "Configurez le modele et les niveaux de reflexion par phase. Les modifications sont enregistrees automatiquement.", + "taskRunningWarning": "Tache en cours (phase {{phase}})", + "changesApplyFrom": "Les modifications s'appliqueront a partir de la phase {{phase}}", + "currentPhaseComplete": "La phase actuelle se terminera avec les parametres d'origine", + "pendingChanges": "Modifications en attente", + "pendingChangesHint": "Cliquez sur \"Appliquer les modifications\" pour enregistrer. Les modifications prendront effet a la prochaine phase.", + "profile": "Profil : {{name}}", + "resetToDefaults": "Reinitialiser aux valeurs par defaut", + "customProfile": "Personnalise", + "customProfileDescription": "Configuration par phase", + "defaultProfile": "Par defaut", + "defaultProfileDescription": "Utilisation des valeurs par defaut du systeme", + "phaseConfiguration": "Configuration des phases", + "phaseConfigurationHint": "Ajustez le modele et l'effort de reflexion pour chaque phase. Des niveaux de reflexion plus eleves utilisent plus de jetons mais fournissent une analyse plus approfondie.", + "phases": { + "spec": "Spec", + "planning": "Planification", + "coding": "Codage", + "qa": "QA" + }, + "phaseLabel": "Phase {{phase}}", + "currentPhase": "Actuelle", + "willApplyChanges": "Appliquera les modifications", + "model": "Modele", + "thinkingLevel": "Niveau de reflexion", + "thinkingLabels": { + "none": "Aucun", + "low": "Faible", + "medium": "Moyen", + "high": "Eleve", + "ultra": "Ultra" + }, + "tokens": "{{count}} jetons", + "tokensK": "{{count}}K jetons", + "applyChanges": "Appliquer les modifications", + "cancel": "Annuler" } } diff --git a/apps/frontend/src/shared/types/conda.ts b/apps/frontend/src/shared/types/conda.ts new file mode 100644 index 0000000000..51e9c32939 --- /dev/null +++ b/apps/frontend/src/shared/types/conda.ts @@ -0,0 +1,440 @@ +/** + * Conda Environment Management Types + * + * Shared types for Conda environment detection, creation, and management. + * Used by both main process (conda services) and renderer process (Settings UI). + */ + +// ============================================================================ +// Conda Installation Detection +// ============================================================================ + +/** + * Type of Conda distribution detected + */ +export type CondaDistributionType = 'miniconda' | 'anaconda' | 'mambaforge' | 'miniforge' | 'unknown'; + +/** + * Legacy alias for backward compatibility + */ +export type CondaType = CondaDistributionType; + +/** + * Represents a detected Conda installation on the system + */ +export interface CondaInstallation { + /** Full path to the Conda installation directory (e.g., C:\Users\Jason\miniconda3) */ + path: string; + /** Full path to the conda executable (e.g., C:\Users\Jason\miniconda3\Scripts\conda.exe) */ + condaExe: string; + /** Legacy alias for condaExe - path to the Conda executable */ + executablePath: string; + /** Conda version string (e.g., "24.1.2") */ + version: string; + /** Type of Conda distribution */ + type: CondaDistributionType; +} + +/** + * Result of scanning the system for Conda installations + */ +export interface CondaDetectionResult { + /** Whether any valid Conda installation was found */ + found: boolean; + /** All detected Conda installations */ + installations: CondaInstallation[]; + /** The preferred/recommended installation (first valid one found) */ + preferred: CondaInstallation | null; + /** Timestamp when the detection was performed */ + timestamp?: number; + /** Error message if detection failed */ + error?: string; +} + +// ============================================================================ +// Python Version Detection +// ============================================================================ + +/** + * Source of Python version detection + */ +export type PythonVersionSource = + | 'comment' // # python 3.12 in requirements.txt + | 'marker' // python_requires marker + | 'pyproject.toml' // requires-python in pyproject.toml + | 'environment.yml' // conda environment.yml + | 'runtime.txt' // Heroku runtime.txt + | '.python-version' // pyenv version file + | 'default'; // Fallback default + +/** + * Type of Python version constraint + */ +export type PythonVersionConstraint = 'exact' | 'minimum' | 'range'; + +/** + * Result of parsing Python version requirements from project files + */ +export interface PythonVersionResult { + /** Normalized Python version (e.g., "3.12") */ + version: string; + /** Where the version requirement was found */ + source: PythonVersionSource; + /** Type of version constraint */ + constraint: PythonVersionConstraint; + /** Original text that was matched/parsed */ + raw: string; +} + +// ============================================================================ +// Environment Configuration +// ============================================================================ + +/** + * Configuration for creating a new Conda environment + */ +export interface CondaEnvConfig { + /** Where to create the environment (e.g., project/.envs/myproject/) */ + envPath: string; + /** Python version to install (e.g., "3.12") */ + pythonVersion: string; + /** Path to requirements.txt file (optional) */ + requirementsPath?: string; + /** Display name for the environment */ + envName?: string; + /** Conda installation to use for creating the environment */ + condaInstallation?: CondaInstallation; +} + +/** + * Result of environment creation + */ +export interface CondaEnvCreateResult { + /** Whether creation was successful */ + success: boolean; + /** The created environment configuration */ + config?: CondaEnvConfig; + /** Path to generated activation scripts */ + scripts?: ActivationScripts; + /** Error message if creation failed */ + error?: string; + /** Detailed log output from conda commands */ + log?: string; +} + +// ============================================================================ +// Setup Progress Tracking +// ============================================================================ + +/** + * Steps in the Conda environment setup process + */ +export type CondaSetupStep = + | 'detecting' // Detecting Conda installations + | 'analyzing' // Analyzing project requirements and Python version + | 'creating' // Creating conda environment + | 'installing-python' // Installing Python version + | 'verifying-python' // Verifying Python installation + | 'installing-deps' // Installing dependencies from requirements.txt + | 'generating-scripts' // Generating activation scripts + | 'finalizing' // Finalizing environment (Windows indexing delay) + | 'complete' // Setup completed successfully + | 'error'; // Setup failed + +/** + * Progress update during environment setup + */ +export interface SetupProgress { + /** Current step in the setup process */ + step: CondaSetupStep; + /** Human-readable message describing current action */ + message: string; + /** Detailed output (e.g., conda command output) */ + detail?: string; + /** Progress percentage (0-100), if determinable */ + progress?: number; + /** Timestamp of this progress update */ + timestamp?: string; +} + +/** + * Complete setup result after all steps + */ +export interface CondaSetupResult { + /** Whether setup completed successfully */ + success: boolean; + /** Final environment configuration */ + config?: CondaEnvConfig; + /** Generated activation scripts */ + scripts?: ActivationScripts; + /** VS Code workspace file path (if generated) */ + vsCodeWorkspace?: string; + /** Error message if setup failed */ + error?: string; + /** Step where failure occurred */ + failedStep?: CondaSetupStep; + /** All progress updates from the setup process */ + progressHistory?: SetupProgress[]; +} + +// ============================================================================ +// Environment Status & Validation +// ============================================================================ + +/** + * Status of a Conda environment + */ +export type CondaEnvStatus = 'none' | 'creating' | 'ready' | 'error' | 'broken' | 'outdated'; + +/** + * Types of environment validation errors + */ +export type CondaEnvError = + | 'env_not_found' // Environment directory doesn't exist + | 'env_broken' // Environment exists but is corrupted + | 'conda_not_found' // No Conda installation available + | 'python_missing' // Python not found in environment + | 'python_wrong_version'// Python version doesn't match requirements + | 'deps_missing'; // Dependencies not installed + +/** + * Detailed validation result for an existing environment + */ +export interface CondaEnvValidation { + /** Whether the environment is valid and usable */ + valid: boolean; + /** Python version installed in the environment */ + pythonVersion?: string; + /** Number of packages installed */ + packageCount?: number; + /** Specific error type if invalid */ + error?: CondaEnvError; + /** Human-readable error/status message */ + message?: string; + /** Path to the environment */ + envPath?: string; + /** Whether pip dependencies are installed */ + depsInstalled?: boolean; +} + +// ============================================================================ +// Project Structure Detection +// ============================================================================ + +/** + * Type of project based on language composition + */ +export type ProjectType = 'pure-python' | 'mixed' | 'monorepo'; + +/** + * Detected project structure for determining Python root + */ +export interface ProjectStructure { + /** Project type based on detected languages */ + type: ProjectType; + /** Root directory for Python environment setup (.envs, workspace, etc.) */ + pythonRoot: string; + /** Whether .NET projects were detected */ + hasDotnet: boolean; + /** Other languages detected in the project */ + hasOtherLanguages: string[]; + /** Detected requirements files */ + requirementsFiles?: string[]; + /** Detected pyproject.toml path */ + pyprojectPath?: string; +} + +// ============================================================================ +// Activation Scripts +// ============================================================================ + +/** + * Generated activation scripts for different shells + */ +export interface ActivationScripts { + /** Windows CMD batch script path */ + bat: string; + /** PowerShell script path */ + ps1: string; + /** Bash/sh script path */ + sh: string; + /** Path to the Conda installation used */ + condaBase: string; + /** Path to the environment */ + envPath: string; +} + +/** + * Content of activation scripts (before writing to files) + */ +export interface ActivationScriptContent { + /** Windows CMD batch script content */ + bat: string; + /** PowerShell script content */ + ps1: string; + /** Bash/sh script content */ + sh: string; +} + +// ============================================================================ +// VS Code Workspace Configuration +// ============================================================================ + +/** + * Configuration for generating VS Code workspace file + */ +export interface VsCodeWorkspaceConfig { + /** Project display name */ + projectName: string; + /** Python root directory */ + pythonRoot: string; + /** Path to the Conda environment */ + envPath: string; + /** Path to Conda installation */ + condaBase: string; + /** Additional workspace folders to include */ + additionalFolders?: string[]; +} + +/** + * Generated VS Code workspace file data + */ +export interface VsCodeWorkspaceFile { + /** Path where the workspace file was written */ + path: string; + /** Workspace file content (JSON) */ + content: string; +} + +// ============================================================================ +// Terminal Activation +// ============================================================================ + +/** + * Types of activation errors + */ +export type CondaActivationError = + | 'env_not_found' + | 'env_broken' + | 'conda_not_found' + | 'activation_failed' + | 'script_not_found'; + +/** + * Result of activating a Conda environment in a terminal + */ +export interface CondaActivationResult { + /** Whether activation was successful */ + success: boolean; + /** Error type if activation failed */ + error?: CondaActivationError; + /** Human-readable message */ + message?: string; + /** The activation command that was executed */ + command?: string; +} + +// ============================================================================ +// IPC Request/Response Types +// ============================================================================ + +/** + * Request to detect Conda installations + */ +export interface CondaDetectRequest { + /** Force re-detection even if cached */ + forceRefresh?: boolean; +} + +/** + * Request to create a Conda environment + */ +export interface CondaCreateEnvRequest { + /** Project path to create environment for */ + projectPath: string; + /** Optional specific Python version (auto-detected if not provided) */ + pythonVersion?: string; + /** Optional specific Conda installation to use */ + condaInstallation?: CondaInstallation; + /** Whether to install dependencies from requirements.txt */ + installDeps?: boolean; + /** Whether to generate VS Code workspace file */ + generateVsCodeWorkspace?: boolean; +} + +/** + * Request to validate an existing environment + */ +export interface CondaValidateEnvRequest { + /** Path to the environment to validate */ + envPath: string; + /** Project path (for version requirement checking) */ + projectPath?: string; +} + +/** + * Request to activate environment in a terminal + */ +export interface CondaActivateRequest { + /** Terminal ID to activate in */ + terminalId: string; + /** Path to the environment */ + envPath: string; + /** Type of shell (auto-detected if not provided) */ + shellType?: 'cmd' | 'powershell' | 'bash' | 'zsh'; +} + +// ============================================================================ +// Event Types +// ============================================================================ + +/** + * Event emitted during environment setup progress + */ +export interface CondaSetupProgressEvent { + /** Project path being set up */ + projectPath: string; + /** Current progress state */ + progress: SetupProgress; +} + +/** + * Event emitted when environment status changes + */ +export interface CondaEnvStatusChangeEvent { + /** Project path */ + projectPath: string; + /** Environment path */ + envPath: string; + /** Previous status */ + previousStatus: CondaEnvStatus; + /** New status */ + newStatus: CondaEnvStatus; + /** Validation result if available */ + validation?: CondaEnvValidation; +} + +/** + * Computed paths for a project's Conda environment setup + * Used by UI to display accurate paths based on project structure + */ +export interface CondaProjectPaths { + /** Project structure type (pure-python, mixed, monorepo) */ + projectType: ProjectType; + /** The Python root directory (where .envs and workspace file go) */ + pythonRoot: string; + /** Relative path from project root to Python root (empty for pure-python) */ + pythonRootRelative: string; + /** Full path to the .envs/projectName directory */ + envPath: string; + /** Relative path for display (from pythonRoot) */ + envPathRelative: string; + /** Full path to the workspace file */ + workspacePath: string; + /** Workspace filename for display */ + workspaceFile: string; + /** Full path to the scripts directory */ + scriptsPath: string; + /** Relative path to scripts for display */ + scriptsPathRelative: string; +} diff --git a/apps/frontend/src/shared/types/index.ts b/apps/frontend/src/shared/types/index.ts index 38fbe58d78..1ecc92302b 100644 --- a/apps/frontend/src/shared/types/index.ts +++ b/apps/frontend/src/shared/types/index.ts @@ -17,6 +17,7 @@ export * from './roadmap'; export * from './integrations'; export * from './app-update'; export * from './cli'; +export * from './conda'; // IPC types (must be last to use types from other modules) export * from './ipc'; diff --git a/apps/frontend/src/shared/types/integrations.ts b/apps/frontend/src/shared/types/integrations.ts index 741e388f33..9effd7d254 100644 --- a/apps/frontend/src/shared/types/integrations.ts +++ b/apps/frontend/src/shared/types/integrations.ts @@ -152,6 +152,23 @@ export interface GitHubInvestigationStatus { error?: string; } +/** + * Lightweight merge readiness check result + * Used for real-time validation of AI verdict freshness + */ +export interface MergeReadiness { + /** PR is in draft mode */ + isDraft: boolean; + /** GitHub's mergeable status */ + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + /** Branch is behind base branch (out of date) */ + isBehind: boolean; + /** Simplified CI status */ + ciStatus: 'passing' | 'failing' | 'pending' | 'none'; + /** List of blockers that contradict a "ready to merge" verdict */ + blockers: string[]; +} + // ============================================ // GitLab Integration Types // ============================================ diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 3ed5c3398c..674b844cd1 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -131,6 +131,13 @@ import type { GitLabNewCommitsCheck } from './integrations'; import type { APIProfile, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from './profile'; +import type { + CondaDetectionResult, + CondaEnvValidation, + PythonVersionResult, + SetupProgress, + CondaProjectPaths, +} from './conda'; // Electron API exposed via contextBridge // Tab state interface (persisted in main process) @@ -290,6 +297,31 @@ export interface ElectronAPI { getSettings: () => Promise>; saveSettings: (settings: Partial) => Promise; + // Python package validation + validatePythonPackages: (params: { pythonPath: string; activationScript?: string }) => Promise>; + onPythonValidationProgress: (callback: (progress: { current: number; total: number; packageName: string }) => void) => () => void; + installPythonRequirements: (params: { pythonPath: string; activationScript?: string }) => Promise; + onPythonInstallProgress: (callback: (progress: string) => void) => () => void; + validatePythonEnvironment: (params: { activationScript: string }) => Promise>; + reinstallPythonEnvironment: (params: { activationScript: string; pythonVersion?: string }) => Promise>; + onPythonReinstallProgress: (callback: (progress: { step: string; completed: number; total: number }) => void) => () => void; + // Sentry error reporting notifySentryStateChanged: (enabled: boolean) => void; getSentryDsn: () => Promise; @@ -620,6 +652,7 @@ export interface ElectronAPI { // Shell operations openExternal: (url: string) => Promise; openTerminal: (dirPath: string) => Promise>; + showItemInFolder: (filePath: string) => Promise; // Auto Claude source environment operations getSourceEnv: () => Promise>; @@ -777,6 +810,24 @@ export interface ElectronAPI { // GitHub API (nested for organized access) github: import('../../preload/api/modules/github-api').GitHubAPI; + // Conda API (nested for organized access) + conda: { + detectConda: () => Promise>; + refreshConda: () => Promise>; + setupAutoClaudeEnv: () => Promise>; + checkAutoClaudeEnv: () => Promise>; + setupProjectEnv: (projectPath: string, projectName: string, pythonVersion?: string) => Promise>; + checkProjectEnv: (envPath: string) => Promise>; + deleteProjectEnv: (envPath: string) => Promise>; + deleteActivationScripts: (projectPath: string) => Promise>; + regenerateScripts: (envPath: string, projectPath: string) => Promise>; + getPythonVersion: (projectPath: string) => Promise>; + installDeps: (envPath: string, requirementsPath: string) => Promise>; + getProjectPaths: (projectPath: string, projectName: string) => Promise>; + listPythonVersions: (projectPath?: string) => Promise>; + onSetupProgress: (callback: (progress: SetupProgress) => void) => () => void; + }; + // Claude Code CLI operations checkClaudeCodeVersion: () => Promise>; installClaudeCode: () => Promise>; diff --git a/apps/frontend/src/shared/types/project.ts b/apps/frontend/src/shared/types/project.ts index a0bd234b4c..43d8475e73 100644 --- a/apps/frontend/src/shared/types/project.ts +++ b/apps/frontend/src/shared/types/project.ts @@ -26,6 +26,21 @@ export interface ProjectSettings { mainBranch?: string; /** Include CLAUDE.md instructions in agent system prompt (default: true) */ useClaudeMd?: boolean; + /** Git provider for PR/MR creation: 'auto' (detect from remote), 'github', or 'gitlab' (default: 'auto') */ + gitProvider?: 'auto' | 'github' | 'gitlab'; + + // ============================================ + // Python Environment (Project-level) + // ============================================ + + /** Toggle for using a self-contained Conda environment for this project */ + useCondaEnv?: boolean; + /** Path to the project-specific Conda environment (e.g., .envs/) */ + condaEnvPath?: string; + /** Status of the project's Conda environment */ + condaEnvStatus?: 'none' | 'creating' | 'ready' | 'error' | 'broken'; + /** Auto-activate Conda environment in terminals (default: true) */ + condaAutoActivate?: boolean; } export interface NotificationSettings { diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index 3ca8617563..4295dd92fa 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -283,6 +283,20 @@ export interface AppSettings { dangerouslySkipPermissions?: boolean; // Anonymous error reporting (Sentry) - enabled by default to help improve the app sentryEnabled?: boolean; + + /** Optional Python activation script (e.g., conda activate script or venv activate) */ + pythonActivationScript?: string; + + // ============================================ + // Conda Configuration (App-level) + // ============================================ + + /** Detected/configured Conda installation path (e.g., ~/miniconda3) */ + condaPath?: string; + /** Path to the shared auto-claude Conda environment (e.g., ~/miniconda3/envs/auto-claude) */ + autoClaudeEnvPath?: string; + /** Status of the shared auto-claude Conda environment */ + autoClaudeEnvStatus?: 'none' | 'creating' | 'ready' | 'error'; } // Auto-Claude Source Environment Configuration (for auto-claude repo .env) diff --git a/apps/frontend/src/shared/types/terminal.ts b/apps/frontend/src/shared/types/terminal.ts index 2d91b71ee2..2711577cf7 100644 --- a/apps/frontend/src/shared/types/terminal.ts +++ b/apps/frontend/src/shared/types/terminal.ts @@ -16,6 +16,8 @@ export interface TerminalCreateOptions { cols?: number; rows?: number; projectPath?: string; + /** If set, activate this conda environment after shell initializes */ + condaEnvPath?: string; } export interface TerminalResizeOptions { diff --git a/apps/frontend/src/shared/utils/shell-escape.ts b/apps/frontend/src/shared/utils/shell-escape.ts index 2a17d16bb4..90d8efccc0 100644 --- a/apps/frontend/src/shared/utils/shell-escape.ts +++ b/apps/frontend/src/shared/utils/shell-escape.ts @@ -14,21 +14,31 @@ export type { WindowsShellType }; /** * Escape a string for safe use as a shell argument. * - * Uses single quotes which prevent all shell expansion (variables, command substitution, etc.) - * except for single quotes themselves, which are escaped as '\'' + * Platform-aware escaping: + * - Windows PowerShell: Uses single quotes with '' to escape internal quotes + * - Unix shells: Uses single quotes with '\'' to escape internal quotes * - * Examples: + * Examples (Unix): * - "hello" → 'hello' * - "hello world" → 'hello world' * - "it's" → 'it'\''s' * - "$(rm -rf /)" → '$(rm -rf /)' - * - 'test"; rm -rf / #' → 'test"; rm -rf / #' + * + * Examples (Windows): + * - "hello" → 'hello' + * - "it's" → 'it''s' * * @param arg - The argument to escape * @returns The escaped argument wrapped in single quotes */ export function escapeShellArg(arg: string): string { - // Replace single quotes with: end quote, escaped quote, start quote + if (process.platform === 'win32') { + // PowerShell: escape single quotes by doubling them + const escaped = arg.replace(/'/g, "''"); + return `'${escaped}'`; + } + + // Unix: Replace single quotes with: end quote, escaped quote, start quote // This is the standard POSIX-safe way to handle single quotes const escaped = arg.replace(/'/g, "'\\''"); return `'${escaped}'`; @@ -46,7 +56,9 @@ export function escapeShellPath(path: string): string { /** * Build a safe cd command from a path. - * Uses platform-appropriate quoting (double quotes on Windows, single quotes on Unix). + * Uses platform-appropriate quoting and command chaining: + * - Windows PowerShell: double quotes with `;` separator (&&` not valid in PS 5.1) + * - Unix shells: single quotes with `&&` separator * * On Windows, uses the /d flag to allow changing drives (e.g., from C: to D:) * and uses escapeForWindowsDoubleQuote for proper escaping inside double quotes. @@ -63,28 +75,32 @@ export function buildCdCommand(path: string | undefined, shellType?: WindowsShel // Windows cmd.exe uses double quotes, Unix shells use single quotes if (isWindows()) { - // On Windows, use cd /d to change drives and directories simultaneously. + if (shellType === 'powershell') { + // PowerShell: Use Set-Location (cd alias) without /d flag + // Use single quotes to avoid variable expansion with $ + // Escape embedded single quotes by doubling them + const escaped = path.replace(/'/g, "''"); + return `cd '${escaped}'; `; + } + + // cmd.exe: Use cd /d to change drives and directories simultaneously. // For values inside double quotes, use escapeForWindowsDoubleQuote() because // caret is literal inside double quotes in cmd.exe (only double quotes need escaping). const escaped = escapeForWindowsDoubleQuote(path); - // PowerShell 5.1 doesn't support '&&' - use ';' instead - // cmd.exe uses '&&' for conditional execution - const separator = shellType === 'powershell' ? '; ' : ' && '; - return `cd /d "${escaped}"${separator}`; + return `cd /d "${escaped}" && `; } return `cd ${escapeShellPath(path)} && `; } /** - * Escape a string for safe use as a Windows cmd.exe argument. + * Escape a string for safe use as a PowerShell argument. * - * Windows cmd.exe uses different escaping rules than POSIX shells. - * This function escapes special characters that could break out of strings - * or execute additional commands. + * PowerShell uses different escaping rules than cmd.exe. + * Inside double quotes, only backtick, $, and " need escaping. * * @param arg - The argument to escape - * @returns The escaped argument safe for use in cmd.exe + * @returns The escaped argument safe for use in PowerShell double-quoted strings */ export function escapeShellArgWindows(arg: string): string { // Escape characters that have special meaning in cmd.exe: @@ -106,6 +122,28 @@ export function escapeShellArgWindows(arg: string): string { return escaped; } +/** + * Escape a string for safe use as a PowerShell argument. + * + * PowerShell uses different escaping rules than cmd.exe. + * Inside double quotes, only backtick, $, and " need escaping. + * + * @param arg - The argument to escape + * @returns The escaped argument safe for use in PowerShell double-quoted strings + */ +export function escapeShellArgPowerShell(arg: string): string { + // Inside PowerShell double-quoted strings: + // ` is the escape character + // $ triggers variable expansion + // " needs escaping + const escaped = arg + .replace(/`/g, '``') // Escape backticks first (escape char itself) + .replace(/\$/g, '`$') // Escape dollar signs (variable expansion) + .replace(/"/g, '`"'); // Escape double quotes + + return escaped; +} + /** * Escape a string for safe use inside Windows cmd.exe double-quoted strings. *