From 6c99e404085ed4c987822e56a89aa12d20f4ce24 Mon Sep 17 00:00:00 2001 From: Auto Date: Wed, 31 Dec 2025 10:20:07 +0200 Subject: [PATCH] feat: Add arbitrary directory project storage with registry system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This major update replaces the fixed `generations/` directory with support for storing projects in any directory on the filesystem. Projects are now tracked via a cross-platform registry system. ## New Features ### Project Registry (`registry.py`) - Cross-platform registry storing project name-to-path mappings - Platform-specific config locations: - Windows: %APPDATA%\autonomous-coder\projects.json - macOS: ~/Library/Application Support/autonomous-coder/projects.json - Linux: ~/.config/autonomous-coder/projects.json - POSIX path format for cross-platform compatibility - File locking for concurrent access safety (fcntl/msvcrt) - Atomic writes via temp file + rename to prevent corruption - Fixed Windows file locking issue with tempfile.mkstemp() ### Filesystem Browser API (`server/routers/filesystem.py`) - REST endpoints for browsing directories server-side - Cross-platform support with blocked system paths: - Windows: C:\Windows, Program Files, ProgramData, etc. - macOS: /System, /Library, /private, etc. - Linux: /etc, /var, /usr, /bin, etc. - Universal blocked paths: .ssh, .aws, .gnupg, .docker, etc. - Hidden file detection (Unix dot-prefix + Windows attributes) - UNC path blocking for security - Windows drive enumeration via ctypes - Directory creation with validation - Added `has_children` field to DirectoryEntry schema ### UI Folder Browser (`ui/src/components/FolderBrowser.tsx`) - React component for selecting project directories - Breadcrumb navigation with clickable segments - Windows drive selector - New folder creation inline - Fixed text visibility with explicit color values ## Updated Components ### Server Routers - `projects.py`: Uses registry instead of fixed generations/ directory - `agent.py`: Uses registry for project path lookups - `features.py`: Uses registry for database path resolution - `spec_creation.py`: Uses registry for WebSocket project resolution ### Process Manager (`server/services/process_manager.py`) - Fixed sandbox issue: subprocess now uses project_dir as cwd - This allows the Claude SDK sandbox to access external project directories ### Schemas (`server/schemas.py`) - Added `has_children` to DirectoryEntry - Added `in_progress` to ProjectStats - Added path field to ProjectSummary and ProjectDetail ### UI Components - `NewProjectModal.tsx`: Multi-step wizard with folder selection - Added clarifying text about subfolder creation - Fixed text color visibility issues ### API Client (`ui/src/lib/api.ts`) - Added filesystem API functions (listDirectory, createDirectory) - Fixed Windows path splitting for directory creation ### Documentation - Updated CLAUDE.md with registry system details - Updated command examples for absolute paths ## Security Improvements - Blocked `.` and `..` in directory names to prevent traversal - Added path blocking check in project creation - UNC path blocking throughout filesystem API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 35 +- SAMPLE_PROMPT.md | 2 + autonomous_agent_demo.py | 59 +-- client.py | 3 + prompts.py | 4 +- registry.py | 492 ++++++++++++++++++++++++ server/main.py | 3 +- server/routers/__init__.py | 3 +- server/routers/agent.py | 31 +- server/routers/features.py | 57 +-- server/routers/filesystem.py | 513 ++++++++++++++++++++++++++ server/routers/projects.py | 185 +++++++--- server/routers/spec_creation.py | 23 +- server/schemas.py | 49 +++ server/services/process_manager.py | 26 +- server/services/spec_chat_session.py | 16 +- server/websocket.py | 36 +- start.py | 129 ++++--- ui/src/components/FolderBrowser.tsx | 336 +++++++++++++++++ ui/src/components/NewProjectModal.tsx | 79 +++- ui/src/hooks/useProjects.ts | 34 +- ui/src/lib/api.ts | 66 +++- ui/src/lib/types.ts | 30 ++ ui/tsconfig.tsbuildinfo | 2 +- 24 files changed, 2018 insertions(+), 195 deletions(-) create mode 100644 registry.py create mode 100644 server/routers/filesystem.py create mode 100644 ui/src/components/FolderBrowser.tsx diff --git a/CLAUDE.md b/CLAUDE.md index ed95d4c..13feb97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,8 +39,9 @@ pip install -r requirements.txt # Run the main CLI launcher python start.py -# Run agent directly for a specific project -python autonomous_agent_demo.py --project-dir PROJECT_NAME +# Run agent directly for a project (use absolute path or registered name) +python autonomous_agent_demo.py --project-dir C:/Projects/my-app +python autonomous_agent_demo.py --project-dir my-app # if registered ``` ### React UI (in ui/ directory) @@ -66,6 +67,29 @@ npm run lint # Run ESLint - `security.py` - Bash command allowlist validation (ALLOWED_COMMANDS whitelist) - `prompts.py` - Prompt template loading with project-specific fallback - `progress.py` - Progress tracking, database queries, webhook notifications +- `registry.py` - Project registry for mapping names to paths (cross-platform) + +### Project Registry + +Projects can be stored in any directory. The registry (`projects.json`) maps project names to paths: +- **Windows**: `%APPDATA%\autonomous-coder\projects.json` +- **macOS**: `~/Library/Application Support/autonomous-coder/projects.json` +- **Linux**: `~/.config/autonomous-coder/projects.json` + +The registry uses: +- POSIX path format (forward slashes) for cross-platform compatibility +- File locking for concurrent access safety +- Atomic writes (temp file + rename) to prevent corruption + +### Server API (server/) + +The FastAPI server provides REST endpoints for the UI: + +- `server/routers/projects.py` - Project CRUD with registry integration +- `server/routers/features.py` - Feature management +- `server/routers/agent.py` - Agent control (start/stop/pause/resume) +- `server/routers/filesystem.py` - Filesystem browser API with security controls +- `server/routers/spec_creation.py` - WebSocket for interactive spec creation ### Feature Management @@ -90,14 +114,17 @@ MCP tools available to the agent: - `src/hooks/useProjects.ts` - React Query hooks for API calls - `src/lib/api.ts` - REST API client - `src/lib/types.ts` - TypeScript type definitions +- `src/components/FolderBrowser.tsx` - Server-side filesystem browser for project folder selection +- `src/components/NewProjectModal.tsx` - Multi-step project creation wizard ### Project Structure for Generated Apps -Generated projects are stored in `generations/PROJECT_NAME/` with: +Projects can be stored in any directory (registered in `projects.json`). Each project contains: - `prompts/app_spec.txt` - Application specification (XML format) - `prompts/initializer_prompt.md` - First session prompt - `prompts/coding_prompt.md` - Continuation session prompt - `features.db` - SQLite database with feature test cases +- `.agent.lock` - Lock file to prevent multiple agent instances ### Security Model @@ -116,7 +143,7 @@ Defense-in-depth approach configured in `client.py`: ### Prompt Loading Fallback Chain -1. Project-specific: `generations/{project}/prompts/{name}.md` +1. Project-specific: `{project_dir}/prompts/{name}.md` 2. Base template: `.claude/templates/{name}.template.md` ### Agent Session Flow diff --git a/SAMPLE_PROMPT.md b/SAMPLE_PROMPT.md index 8a23117..284a4bf 100644 --- a/SAMPLE_PROMPT.md +++ b/SAMPLE_PROMPT.md @@ -18,3 +18,5 @@ to-dos, we should store: - Optionally, we can also set tags, due dates, and priorities which should be represented as beautiful little badges on the to-do card Users should have the ability to easily clear out all the completed To-Dos. They should also be able to filter and search for To-Dos as well. + +You choose the rest. Keep it simple. Should be 25 features. diff --git a/autonomous_agent_demo.py b/autonomous_agent_demo.py index 4856cde..0e9b2ee 100644 --- a/autonomous_agent_demo.py +++ b/autonomous_agent_demo.py @@ -8,8 +8,14 @@ This script implements the two-agent pattern (initializer + coding agent) and incorporates all the strategies from the long-running agents guide. Example Usage: - python autonomous_agent_demo.py --project-dir ./claude_clone_demo - python autonomous_agent_demo.py --project-dir ./claude_clone_demo --max-iterations 5 + # Using absolute path directly + python autonomous_agent_demo.py --project-dir C:/Projects/my-app + + # Using registered project name (looked up from registry) + python autonomous_agent_demo.py --project-dir my-app + + # Limit iterations for testing + python autonomous_agent_demo.py --project-dir my-app --max-iterations 5 """ import argparse @@ -24,6 +30,7 @@ from dotenv import load_dotenv load_dotenv() from agent import run_autonomous_agent +from registry import get_project_path # Configuration @@ -38,17 +45,17 @@ def parse_args() -> argparse.Namespace: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - # Start fresh project - python autonomous_agent_demo.py --project-dir ./claude_clone + # Use absolute path directly + python autonomous_agent_demo.py --project-dir C:/Projects/my-app + + # Use registered project name (looked up from registry) + python autonomous_agent_demo.py --project-dir my-app # Use a specific model - python autonomous_agent_demo.py --project-dir ./claude_clone --model claude-sonnet-4-5-20250929 + python autonomous_agent_demo.py --project-dir my-app --model claude-sonnet-4-5-20250929 # Limit iterations for testing - python autonomous_agent_demo.py --project-dir ./claude_clone --max-iterations 5 - - # Continue existing project - python autonomous_agent_demo.py --project-dir ./claude_clone + python autonomous_agent_demo.py --project-dir my-app --max-iterations 5 Authentication: Uses Claude CLI credentials from ~/.claude/.credentials.json @@ -58,9 +65,9 @@ Authentication: parser.add_argument( "--project-dir", - type=Path, - default=Path("./autonomous_demo_project"), - help="Directory for the project (default: generations/autonomous_demo_project). Relative paths automatically placed in generations/ directory.", + type=str, + required=True, + help="Project directory path (absolute) or registered project name", ) parser.add_argument( @@ -87,16 +94,26 @@ def main() -> None: # Note: Authentication is handled by start.bat/start.sh before this script runs. # The Claude SDK auto-detects credentials from ~/.claude/.credentials.json - # Automatically place projects in generations/ directory unless already specified - project_dir = args.project_dir - if not str(project_dir).startswith("generations/"): - # Convert relative paths to be under generations/ - if project_dir.is_absolute(): - # If absolute path, use as-is - pass + # Resolve project directory: + # 1. If absolute path, use as-is + # 2. Otherwise, look up from registry by name + project_dir_input = args.project_dir + project_dir = Path(project_dir_input) + + if project_dir.is_absolute(): + # Absolute path provided - use directly + if not project_dir.exists(): + print(f"Error: Project directory does not exist: {project_dir}") + return + else: + # Treat as a project name - look up from registry + registered_path = get_project_path(project_dir_input) + if registered_path: + project_dir = registered_path else: - # Prepend generations/ to relative paths - project_dir = Path("generations") / project_dir + print(f"Error: Project '{project_dir_input}' not found in registry") + print("Use an absolute path or register the project first.") + return try: # Run the agent (MCP server handles feature database) diff --git a/client.py b/client.py index 4694f56..47d92f3 100644 --- a/client.py +++ b/client.py @@ -158,6 +158,9 @@ def create_client(project_dir: Path, model: str): "command": sys.executable, # Use the same Python that's running this script "args": ["-m", "mcp_server.feature_mcp"], "env": { + # Inherit parent environment (PATH, ANTHROPIC_API_KEY, etc.) + **os.environ, + # Add custom variables "PROJECT_DIR": str(project_dir.resolve()), "PYTHONPATH": str(Path(__file__).parent.resolve()), }, diff --git a/prompts.py b/prompts.py index 48c0cba..7e666d0 100644 --- a/prompts.py +++ b/prompts.py @@ -5,7 +5,7 @@ Prompt Loading Utilities Functions for loading prompt templates with project-specific support. Fallback chain: -1. Project-specific: generations/{project}/prompts/{name}.md +1. Project-specific: {project_dir}/prompts/{name}.md 2. Base template: .claude/templates/{name}.template.md """ @@ -119,7 +119,7 @@ def scaffold_project_prompts(project_dir: Path) -> Path: This sets up a new project with template files that can be customized. Args: - project_dir: The project directory (e.g., generations/my-app) + project_dir: The absolute path to the project directory Returns: The path to the project prompts directory diff --git a/registry.py b/registry.py new file mode 100644 index 0000000..0cb641e --- /dev/null +++ b/registry.py @@ -0,0 +1,492 @@ +""" +Project Registry Module +======================= + +Cross-platform project registry for storing project name to path mappings. +Supports Windows, macOS, and Linux with platform-specific config directories. +""" + +import json +import logging +import os +import stat +import sys +import tempfile +import shutil +import time +from datetime import datetime +from pathlib import Path +from typing import Any + +# Module logger +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Exceptions +# ============================================================================= + +class RegistryError(Exception): + """Base registry exception.""" + pass + + +class RegistryNotFound(RegistryError): + """Registry file doesn't exist.""" + pass + + +class RegistryCorrupted(RegistryError): + """Registry JSON is malformed.""" + pass + + +class RegistryPermissionDenied(RegistryError): + """Can't read/write registry file.""" + pass + + +# ============================================================================= +# Registry Lock (Cross-Platform) +# ============================================================================= + +class RegistryLock: + """ + Context manager for registry file locking. + Uses fcntl on Unix and msvcrt on Windows. + """ + + def __init__(self, registry_path: Path): + self.registry_path = registry_path + self.lock_path = registry_path.with_suffix('.lock') + self._file = None + + def __enter__(self): + self.lock_path.parent.mkdir(parents=True, exist_ok=True) + self._file = open(self.lock_path, 'w') + + try: + if sys.platform == "win32": + import msvcrt + # Windows: msvcrt.LK_NBLCK is non-blocking, so we retry with backoff + max_attempts = 10 + for attempt in range(max_attempts): + try: + msvcrt.locking(self._file.fileno(), msvcrt.LK_NBLCK, 1) + break # Lock acquired + except OSError: + if attempt == max_attempts - 1: + raise # Give up after max attempts + time.sleep(0.1 * (attempt + 1)) # Exponential backoff + else: + import fcntl + fcntl.flock(self._file.fileno(), fcntl.LOCK_EX) + except Exception as e: + self._file.close() + raise RegistryError(f"Could not acquire registry lock: {e}") from e + + return self + + def __exit__(self, *args): + if self._file: + try: + if sys.platform != "win32": + import fcntl + fcntl.flock(self._file.fileno(), fcntl.LOCK_UN) + finally: + self._file.close() + try: + self.lock_path.unlink(missing_ok=True) + except Exception: + pass + + +# ============================================================================= +# Registry Path Functions +# ============================================================================= + +def get_config_dir() -> Path: + """ + Get the platform-specific config directory for the application. + + Returns: + - Windows: %APPDATA%/autonomous-coder/ + - macOS: ~/Library/Application Support/autonomous-coder/ + - Linux: ~/.config/autonomous-coder/ (or $XDG_CONFIG_HOME) + """ + if sys.platform == "win32": + base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Roaming")) + elif sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + else: # Linux and other Unix-like + base = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) + + config_dir = base / "autonomous-coder" + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir + + +def get_registry_path() -> Path: + """Get the path to the projects registry file.""" + return get_config_dir() / "projects.json" + + +# ============================================================================= +# Registry I/O Functions +# ============================================================================= + +def _create_empty_registry() -> dict[str, Any]: + """Create a new empty registry structure.""" + return { + "version": 1, + "created_at": datetime.now().isoformat(), + "projects": {} + } + + +def load_registry(create_if_missing: bool = True) -> dict[str, Any]: + """ + Load the registry from disk. + + Args: + create_if_missing: If True, create a new registry if none exists. + + Returns: + The registry dictionary. + + Raises: + RegistryNotFound: If registry doesn't exist and create_if_missing is False. + RegistryCorrupted: If registry JSON is malformed. + RegistryPermissionDenied: If can't read the registry file. + """ + registry_path = get_registry_path() + + # Case 1: File doesn't exist + if not registry_path.exists(): + if create_if_missing: + registry = _create_empty_registry() + save_registry(registry) + return registry + else: + raise RegistryNotFound(f"Registry not found: {registry_path}") + + # Case 2: Read the file + try: + content = registry_path.read_text(encoding='utf-8') + except PermissionError as e: + raise RegistryPermissionDenied(f"Cannot read registry: {e}") from e + except OSError as e: + raise RegistryError(f"Error reading registry: {e}") from e + + # Case 3: Parse JSON + try: + data = json.loads(content) + except json.JSONDecodeError as e: + # Try to recover from backup + backup_path = registry_path.with_suffix('.json.backup') + logger.warning("Registry corrupted, attempting recovery from backup: %s", backup_path) + if backup_path.exists(): + try: + backup_content = backup_path.read_text(encoding='utf-8') + data = json.loads(backup_content) + # Restore from backup + shutil.copy2(backup_path, registry_path) + logger.info("Successfully recovered registry from backup") + return data + except Exception as recovery_error: + logger.error("Failed to recover from backup: %s", recovery_error) + raise RegistryCorrupted( + f"Registry corrupted: {e}\nBackup location: {backup_path}" + ) from e + + # Ensure required structure + if "projects" not in data: + data["projects"] = {} + if "version" not in data: + data["version"] = 1 + + return data + + +def save_registry(registry: dict[str, Any]) -> None: + """ + Save the registry to disk atomically. + + Uses temp file + rename for atomic writes to prevent corruption. + + Args: + registry: The registry dictionary to save. + + Raises: + RegistryPermissionDenied: If can't write to the registry. + RegistryError: If write fails for other reasons. + """ + registry_path = get_registry_path() + registry_path.parent.mkdir(parents=True, exist_ok=True) + + # Create backup before modification (if file exists) + if registry_path.exists(): + backup_path = registry_path.with_suffix('.json.backup') + try: + shutil.copy2(registry_path, backup_path) + except Exception as e: + logger.warning("Failed to create registry backup: %s", e) + + # Write to temp file in same directory (ensures same filesystem for atomic rename) + # On Windows, we must close the file before renaming it + tmp_path = None + try: + # Create temp file + fd, tmp_name = tempfile.mkstemp(suffix='.json', dir=registry_path.parent) + tmp_path = Path(tmp_name) + + try: + # Write content + with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file: + json.dump(registry, tmp_file, indent=2) + tmp_file.flush() + os.fsync(tmp_file.fileno()) + # File is now closed, safe to rename on Windows + + # Atomic rename + tmp_path.replace(registry_path) + + # Set restrictive permissions (owner read/write only) + # On Windows, this is a best-effort operation + try: + if sys.platform != "win32": + registry_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 + except Exception: + pass # Best effort - don't fail if permissions can't be set + except Exception: + if tmp_path and tmp_path.exists(): + tmp_path.unlink(missing_ok=True) + raise + except PermissionError as e: + raise RegistryPermissionDenied(f"Cannot write registry: {e}") from e + except OSError as e: + raise RegistryError(f"Failed to write registry: {e}") from e + + +# ============================================================================= +# Project CRUD Functions +# ============================================================================= + +def register_project(name: str, path: Path) -> None: + """ + Register a new project in the registry. + + Args: + name: The project name (unique identifier). + path: The absolute path to the project directory. + + Raises: + ValueError: If project name is invalid or path is not absolute. + RegistryError: If a project with that name already exists. + """ + # Validate name + import re + if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name): + raise ValueError( + "Invalid project name. Use only letters, numbers, hyphens, " + "and underscores (1-50 chars)." + ) + + # Ensure path is absolute + path = Path(path).resolve() + + with RegistryLock(get_registry_path()): + registry = load_registry() + + if name in registry["projects"]: + logger.warning("Attempted to register duplicate project: %s", name) + raise RegistryError(f"Project '{name}' already exists in registry") + + # Store path as POSIX format (forward slashes) for cross-platform consistency + registry["projects"][name] = { + "path": path.as_posix(), + "created_at": datetime.now().isoformat() + } + + save_registry(registry) + logger.info("Registered project '%s' at path: %s", name, path) + + +def unregister_project(name: str) -> bool: + """ + Remove a project from the registry. + + Args: + name: The project name to remove. + + Returns: + True if removed, False if project wasn't found. + """ + with RegistryLock(get_registry_path()): + registry = load_registry() + + if name not in registry["projects"]: + logger.debug("Attempted to unregister non-existent project: %s", name) + return False + + del registry["projects"][name] + save_registry(registry) + logger.info("Unregistered project: %s", name) + return True + + +def get_project_path(name: str) -> Path | None: + """ + Look up a project's path by name. + + Args: + name: The project name. + + Returns: + The project Path, or None if not found. + """ + registry = load_registry() + project = registry["projects"].get(name) + + if project is None: + return None + + # Convert POSIX path string back to Path object + return Path(project["path"]) + + +def list_registered_projects() -> dict[str, dict[str, Any]]: + """ + Get all registered projects. + + Returns: + Dictionary mapping project names to their info dictionaries. + """ + registry = load_registry() + return registry.get("projects", {}) + + +def get_project_info(name: str) -> dict[str, Any] | None: + """ + Get full info about a project. + + Args: + name: The project name. + + Returns: + Project info dictionary, or None if not found. + """ + registry = load_registry() + return registry["projects"].get(name) + + +def update_project_path(name: str, new_path: Path) -> bool: + """ + Update a project's path (for relocating projects). + + Args: + name: The project name. + new_path: The new absolute path. + + Returns: + True if updated, False if project wasn't found. + """ + new_path = Path(new_path).resolve() + + with RegistryLock(get_registry_path()): + registry = load_registry() + + if name not in registry["projects"]: + return False + + registry["projects"][name]["path"] = new_path.as_posix() + save_registry(registry) + return True + + +# ============================================================================= +# Validation Functions +# ============================================================================= + +def validate_project_path(path: Path) -> tuple[bool, str]: + """ + Validate that a project path is accessible and writable. + + Args: + path: The path to validate. + + Returns: + Tuple of (is_valid, error_message). + """ + path = Path(path).resolve() + + # Check if path exists + if not path.exists(): + return False, f"Path does not exist: {path}" + + # Check if it's a directory + if not path.is_dir(): + return False, f"Path is not a directory: {path}" + + # Check read permissions + if not os.access(path, os.R_OK): + return False, f"No read permission: {path}" + + # Check write permissions + if not os.access(path, os.W_OK): + return False, f"No write permission: {path}" + + return True, "" + + +def cleanup_stale_projects() -> list[str]: + """ + Remove projects from registry whose paths no longer exist. + + Returns: + List of removed project names. + """ + removed = [] + + with RegistryLock(get_registry_path()): + registry = load_registry() + projects = registry.get("projects", {}) + + stale_names = [] + for name, info in projects.items(): + path = Path(info["path"]) + if not path.exists(): + stale_names.append(name) + + for name in stale_names: + del projects[name] + removed.append(name) + + if removed: + save_registry(registry) + + return removed + + +def list_valid_projects() -> list[dict[str, Any]]: + """ + List all projects that have valid, accessible paths. + + Returns: + List of project info dicts with additional 'name' field. + """ + registry = load_registry() + projects = registry.get("projects", {}) + + valid = [] + for name, info in projects.items(): + path = Path(info["path"]) + is_valid, _ = validate_project_path(path) + if is_valid: + valid.append({ + "name": name, + "path": info["path"], + "created_at": info.get("created_at") + }) + + return valid diff --git a/server/main.py b/server/main.py index a2f6b42..7f7e774 100644 --- a/server/main.py +++ b/server/main.py @@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from .routers import projects_router, features_router, agent_router, spec_creation_router +from .routers import projects_router, features_router, agent_router, spec_creation_router, filesystem_router from .websocket import project_websocket from .services.process_manager import cleanup_all_managers from .schemas import SetupStatus @@ -82,6 +82,7 @@ app.include_router(projects_router) app.include_router(features_router) app.include_router(agent_router) app.include_router(spec_creation_router) +app.include_router(filesystem_router) # ============================================================================ diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 381f6c3..95f72c3 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -9,5 +9,6 @@ from .projects import router as projects_router from .features import router as features_router from .agent import router as agent_router from .spec_creation import router as spec_creation_router +from .filesystem import router as filesystem_router -__all__ = ["projects_router", "features_router", "agent_router", "spec_creation_router"] +__all__ = ["projects_router", "features_router", "agent_router", "spec_creation_router", "filesystem_router"] diff --git a/server/routers/agent.py b/server/routers/agent.py index 1751404..116adcf 100644 --- a/server/routers/agent.py +++ b/server/routers/agent.py @@ -3,6 +3,7 @@ Agent Router ============ API endpoints for agent control (start/stop/pause/resume). +Uses project registry for path lookups. """ import re @@ -13,21 +14,16 @@ from fastapi import APIRouter, HTTPException from ..schemas import AgentStatus, AgentActionResponse from ..services.process_manager import get_manager -# Lazy import to avoid sys.path manipulation at module level -_GENERATIONS_DIR = None +def _get_project_path(project_name: str) -> Path: + """Get project path from registry.""" + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) -def _get_generations_dir(): - """Lazy import of GENERATIONS_DIR.""" - global _GENERATIONS_DIR - if _GENERATIONS_DIR is None: - import sys - root = Path(__file__).parent.parent.parent - if str(root) not in sys.path: - sys.path.insert(0, str(root)) - from start import GENERATIONS_DIR - _GENERATIONS_DIR = GENERATIONS_DIR - return _GENERATIONS_DIR + from registry import get_project_path + return get_project_path(project_name) router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"]) @@ -49,12 +45,15 @@ def validate_project_name(name: str) -> str: def get_project_manager(project_name: str): """Get the process manager for a project.""" project_name = validate_project_name(project_name) - project_dir = _get_generations_dir() / project_name + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory not found: {project_dir}") - return get_manager(project_name, ROOT_DIR) + return get_manager(project_name, project_dir, ROOT_DIR) @router.get("/status", response_model=AgentStatus) diff --git a/server/routers/features.py b/server/routers/features.py index 087cf38..0caa7ba 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -19,25 +19,21 @@ from ..schemas import ( ) # Lazy imports to avoid circular dependencies -_GENERATIONS_DIR = None _create_database = None _Feature = None logger = logging.getLogger(__name__) -def _get_generations_dir(): - """Lazy import of GENERATIONS_DIR.""" - global _GENERATIONS_DIR - if _GENERATIONS_DIR is None: - import sys - from pathlib import Path - root = Path(__file__).parent.parent.parent - if str(root) not in sys.path: - sys.path.insert(0, str(root)) - from start import GENERATIONS_DIR - _GENERATIONS_DIR = GENERATIONS_DIR - return _GENERATIONS_DIR +def _get_project_path(project_name: str) -> Path: + """Get project path from registry.""" + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from registry import get_project_path + return get_project_path(project_name) def _get_db_classes(): @@ -108,10 +104,13 @@ async def list_features(project_name: str): - done: passes=True """ project_name = validate_project_name(project_name) - project_dir = _get_generations_dir() / project_name + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory not found") db_file = project_dir / "features.db" if not db_file.exists(): @@ -152,10 +151,13 @@ async def list_features(project_name: str): async def create_feature(project_name: str, feature: FeatureCreate): """Create a new feature/test case manually.""" project_name = validate_project_name(project_name) - project_dir = _get_generations_dir() / project_name + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory not found") _, Feature = _get_db_classes() @@ -194,10 +196,13 @@ async def create_feature(project_name: str, feature: FeatureCreate): async def get_feature(project_name: str, feature_id: int): """Get details of a specific feature.""" project_name = validate_project_name(project_name) - project_dir = _get_generations_dir() / project_name + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory not found") db_file = project_dir / "features.db" if not db_file.exists(): @@ -224,10 +229,13 @@ async def get_feature(project_name: str, feature_id: int): async def delete_feature(project_name: str, feature_id: int): """Delete a feature.""" project_name = validate_project_name(project_name) - project_dir = _get_generations_dir() / project_name + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory not found") _, Feature = _get_db_classes() @@ -258,10 +266,13 @@ async def skip_feature(project_name: str, feature_id: int): so it will be processed last. """ project_name = validate_project_name(project_name) - project_dir = _get_generations_dir() / project_name + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory not found") _, Feature = _get_db_classes() diff --git a/server/routers/filesystem.py b/server/routers/filesystem.py new file mode 100644 index 0000000..a97972f --- /dev/null +++ b/server/routers/filesystem.py @@ -0,0 +1,513 @@ +""" +Filesystem Router +================== + +API endpoints for browsing the filesystem for project folder selection. +Provides cross-platform support for Windows, macOS, and Linux. +""" + +import logging +import os +import re +import sys +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query + +# Module logger +logger = logging.getLogger(__name__) + +from ..schemas import ( + DirectoryEntry, + DirectoryListResponse, + DriveInfo, + PathValidationResponse, + CreateDirectoryRequest, +) + + +router = APIRouter(prefix="/api/filesystem", tags=["filesystem"]) + + +# ============================================================================= +# Platform-Specific Blocked Paths +# ============================================================================= + +# Windows blocked paths +WINDOWS_BLOCKED = { + "C:\\Windows", + "C:\\Program Files", + "C:\\Program Files (x86)", + "C:\\ProgramData", + "C:\\System Volume Information", + "C:\\$Recycle.Bin", + "C:\\Recovery", +} + +# macOS blocked paths +MACOS_BLOCKED = { + "/System", + "/Library", + "/private", + "/usr", + "/bin", + "/sbin", + "/etc", + "/var", + "/Volumes", + "/cores", + "/opt", +} + +# Linux blocked paths +LINUX_BLOCKED = { + "/etc", + "/var", + "/usr", + "/bin", + "/sbin", + "/boot", + "/proc", + "/sys", + "/dev", + "/root", + "/lib", + "/lib64", + "/run", + "/tmp", + "/opt", +} + +# Universal blocked paths (relative to home directory) +UNIVERSAL_BLOCKED_RELATIVE = { + ".ssh", + ".aws", + ".gnupg", + ".config/gh", + ".netrc", + ".docker", + ".kube", + ".terraform", +} + +# Patterns for files that should not be shown +HIDDEN_PATTERNS = [ + r"^\.env", # .env files + r".*\.key$", # Key files + r".*\.pem$", # PEM files + r".*credentials.*", # Credential files + r".*secrets.*", # Secrets files +] + + +def get_blocked_paths() -> set[Path]: + """Get the set of blocked paths for the current platform.""" + home = Path.home() + blocked = set() + + # Add platform-specific blocked paths + if sys.platform == "win32": + for p in WINDOWS_BLOCKED: + blocked.add(Path(p).resolve()) + elif sys.platform == "darwin": + for p in MACOS_BLOCKED: + blocked.add(Path(p).resolve()) + else: # Linux + for p in LINUX_BLOCKED: + blocked.add(Path(p).resolve()) + + # Add universal blocked paths (relative to home) + for rel in UNIVERSAL_BLOCKED_RELATIVE: + blocked.add((home / rel).resolve()) + + return blocked + + +def is_path_blocked(path: Path) -> bool: + """Check if a path is in the blocked list.""" + try: + resolved = path.resolve() + except (OSError, ValueError): + return True # Can't resolve = blocked + + blocked_paths = get_blocked_paths() + + # Check if path is exactly a blocked path or inside one + for blocked in blocked_paths: + try: + resolved.relative_to(blocked) + return True + except ValueError: + pass + + # Also check if blocked is inside path (for parent directories) + if resolved == blocked: + return True + + return False + + +def is_hidden_file(path: Path) -> bool: + """Check if a file/directory is hidden (cross-platform).""" + name = path.name + + # Unix-style: starts with dot + if name.startswith('.'): + return True + + # Windows: check FILE_ATTRIBUTE_HIDDEN + if sys.platform == "win32": + try: + import ctypes + attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) + if attrs != -1 and (attrs & 0x02): # FILE_ATTRIBUTE_HIDDEN + return True + except Exception: + pass + + return False + + +def matches_blocked_pattern(name: str) -> bool: + """Check if filename matches a blocked pattern.""" + for pattern in HIDDEN_PATTERNS: + if re.match(pattern, name, re.IGNORECASE): + return True + return False + + +def is_unc_path(path_str: str) -> bool: + """Check if path is a Windows UNC path (network share).""" + return path_str.startswith("\\\\") or path_str.startswith("//") + + +# ============================================================================= +# Endpoints +# ============================================================================= + +@router.get("/list", response_model=DirectoryListResponse) +async def list_directory( + path: str | None = Query(None, description="Directory path to list (defaults to home)"), + show_hidden: bool = Query(False, description="Include hidden files"), +): + """ + List contents of a directory. + + Returns directories only (for folder selection). + On Windows, includes available drives. + """ + # Default to home directory + if path is None or path == "": + target = Path.home() + else: + # Security: Block UNC paths + if is_unc_path(path): + logger.warning("Blocked UNC path access attempt: %s", path) + raise HTTPException( + status_code=403, + detail="Network paths (UNC) are not allowed" + ) + target = Path(path) + + # Resolve symlinks and get absolute path + try: + target = target.resolve() + except (OSError, ValueError) as e: + raise HTTPException(status_code=400, detail=f"Invalid path: {e}") + + # Security: Check if path is blocked + if is_path_blocked(target): + logger.warning("Blocked access to restricted path: %s", target) + raise HTTPException( + status_code=403, + detail="Access to this directory is not allowed" + ) + + # Check if path exists and is a directory + if not target.exists(): + raise HTTPException(status_code=404, detail="Directory not found") + + if not target.is_dir(): + raise HTTPException(status_code=400, detail="Path is not a directory") + + # Check read permission + if not os.access(target, os.R_OK): + raise HTTPException(status_code=403, detail="No read permission") + + # List directory contents + entries = [] + try: + for item in sorted(target.iterdir(), key=lambda x: x.name.lower()): + # Skip if blocked pattern + if matches_blocked_pattern(item.name): + continue + + # Check if hidden + hidden = is_hidden_file(item) + if hidden and not show_hidden: + continue + + # Security: Skip if item path is blocked + if is_path_blocked(item): + continue + + # Only include directories for folder browsing + if item.is_dir(): + try: + # Check if directory has any subdirectories + has_children = False + try: + for child in item.iterdir(): + if child.is_dir() and not is_path_blocked(child): + has_children = True + break + except (PermissionError, OSError): + pass # Can't read = assume no children + + entries.append(DirectoryEntry( + name=item.name, + path=item.as_posix(), + is_directory=True, + is_hidden=hidden, + size=None, + has_children=has_children, + )) + except Exception: + pass # Skip items we can't process + + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied") + except OSError as e: + raise HTTPException(status_code=500, detail=f"Error reading directory: {e}") + + # Calculate parent path + parent_path = None + if target != target.parent: # Not at root + parent = target.parent + # Don't expose parent if it's blocked + if not is_path_blocked(parent): + parent_path = parent.as_posix() + + # Get drives on Windows + drives = None + if sys.platform == "win32": + drives = get_windows_drives() + + return DirectoryListResponse( + current_path=target.as_posix(), + parent_path=parent_path, + entries=entries, + drives=drives, + ) + + +@router.get("/drives", response_model=list[DriveInfo] | None) +async def list_drives(): + """ + List available drives (Windows only). + + Returns null on non-Windows platforms. + """ + if sys.platform != "win32": + return None + + return get_windows_drives() + + +def get_windows_drives() -> list[DriveInfo]: + """Get list of available drives on Windows.""" + drives = [] + + try: + import ctypes + import string + + # Get bitmask of available drives + bitmask = ctypes.windll.kernel32.GetLogicalDrives() + + for i, letter in enumerate(string.ascii_uppercase): + if bitmask & (1 << i): + drive_path = f"{letter}:\\" + try: + # Try to get volume label + volume_name = ctypes.create_unicode_buffer(1024) + ctypes.windll.kernel32.GetVolumeInformationW( + drive_path, + volume_name, + 1024, + None, None, None, None, 0 + ) + label = volume_name.value or f"Local Disk ({letter}:)" + except Exception: + label = f"Drive ({letter}:)" + + # Check if drive is accessible + available = os.path.exists(drive_path) + + drives.append(DriveInfo( + letter=letter, + label=label, + available=available, + )) + except Exception: + # Fallback: just list C: drive + drives.append(DriveInfo(letter="C", label="Local Disk (C:)", available=True)) + + return drives + + +@router.post("/validate", response_model=PathValidationResponse) +async def validate_path(path: str = Query(..., description="Path to validate")): + """ + Validate if a path is accessible and writable. + + Used to check a path before creating a project there. + """ + # Security: Block UNC paths + if is_unc_path(path): + return PathValidationResponse( + valid=False, + exists=False, + is_directory=False, + can_read=False, + can_write=False, + message="Network paths (UNC) are not allowed", + ) + + try: + target = Path(path).resolve() + except (OSError, ValueError) as e: + return PathValidationResponse( + valid=False, + exists=False, + is_directory=False, + can_read=False, + can_write=False, + message=f"Invalid path: {e}", + ) + + # Security: Check if blocked + if is_path_blocked(target): + return PathValidationResponse( + valid=False, + exists=target.exists(), + is_directory=target.is_dir() if target.exists() else False, + can_read=False, + can_write=False, + message="Access to this directory is not allowed", + ) + + exists = target.exists() + is_dir = target.is_dir() if exists else False + can_read = os.access(target, os.R_OK) if exists else False + can_write = os.access(target, os.W_OK) if exists else False + + # For non-existent paths, check if parent is writable + if not exists: + parent = target.parent + parent_exists = parent.exists() + parent_writable = os.access(parent, os.W_OK) if parent_exists else False + can_write = parent_writable + + valid = is_dir and can_read and can_write if exists else can_write + message = "" + if not exists: + message = "Directory does not exist (will be created)" + elif not is_dir: + message = "Path is not a directory" + elif not can_read: + message = "No read permission" + elif not can_write: + message = "No write permission" + + return PathValidationResponse( + valid=valid, + exists=exists, + is_directory=is_dir, + can_read=can_read, + can_write=can_write, + message=message, + ) + + +@router.post("/create-directory") +async def create_directory(request: CreateDirectoryRequest): + """ + Create a new directory inside a parent directory. + + Used for creating project folders from the folder browser. + """ + # Validate directory name + name = request.name.strip() + if not name: + raise HTTPException(status_code=400, detail="Directory name cannot be empty") + + # Security: Block special directory names that could enable traversal + if name in ('.', '..') or '..' in name: + raise HTTPException( + status_code=400, + detail="Invalid directory name" + ) + + # Security: Check for invalid characters + invalid_chars = '<>:"/\\|?*' if sys.platform == "win32" else '/' + if any(c in name for c in invalid_chars): + raise HTTPException( + status_code=400, + detail="Directory name contains invalid characters" + ) + + # Security: Block UNC paths + if is_unc_path(request.parent_path): + raise HTTPException(status_code=403, detail="Network paths are not allowed") + + try: + parent = Path(request.parent_path).resolve() + except (OSError, ValueError) as e: + raise HTTPException(status_code=400, detail=f"Invalid parent path: {e}") + + # Security: Check if parent is blocked + if is_path_blocked(parent): + raise HTTPException( + status_code=403, + detail="Cannot create directory in this location" + ) + + # Check parent exists and is writable + if not parent.exists(): + raise HTTPException(status_code=404, detail="Parent directory not found") + + if not parent.is_dir(): + raise HTTPException(status_code=400, detail="Parent path is not a directory") + + if not os.access(parent, os.W_OK): + raise HTTPException(status_code=403, detail="No write permission") + + # Create the new directory + new_dir = parent / name + + if new_dir.exists(): + raise HTTPException(status_code=409, detail="Directory already exists") + + try: + new_dir.mkdir(parents=False, exist_ok=False) + logger.info("Created directory: %s", new_dir) + except OSError as e: + logger.error("Failed to create directory %s: %s", new_dir, e) + raise HTTPException(status_code=500, detail=f"Failed to create directory: {e}") + + return { + "success": True, + "path": new_dir.as_posix(), + "message": f"Created directory: {name}", + } + + +@router.get("/home") +async def get_home_directory(): + """Get the user's home directory path.""" + home = Path.home() + return { + "path": home.as_posix(), + "display_path": str(home), + } diff --git a/server/routers/projects.py b/server/routers/projects.py index ff93aea..2e190fb 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -3,6 +3,7 @@ Projects Router =============== API endpoints for project management. +Uses project registry for path lookups instead of fixed generations/ directory. """ import re @@ -20,10 +21,8 @@ from ..schemas import ( ProjectStats, ) -# Lazy imports to avoid sys.path manipulation at module level +# Lazy imports to avoid circular dependencies _imports_initialized = False -_GENERATIONS_DIR = None -_get_existing_projects = None _check_spec_exists = None _scaffold_project_prompts = None _get_project_prompts_dir = None @@ -32,8 +31,8 @@ _count_passing_tests = None def _init_imports(): """Lazy import of project-level modules.""" - global _imports_initialized, _GENERATIONS_DIR, _get_existing_projects - global _check_spec_exists, _scaffold_project_prompts, _get_project_prompts_dir + global _imports_initialized, _check_spec_exists + global _scaffold_project_prompts, _get_project_prompts_dir global _count_passing_tests if _imports_initialized: @@ -44,12 +43,10 @@ def _init_imports(): if str(root) not in sys.path: sys.path.insert(0, str(root)) - from start import GENERATIONS_DIR, get_existing_projects, check_spec_exists from prompts import scaffold_project_prompts, get_project_prompts_dir from progress import count_passing_tests + from start import check_spec_exists - _GENERATIONS_DIR = GENERATIONS_DIR - _get_existing_projects = get_existing_projects _check_spec_exists = check_spec_exists _scaffold_project_prompts = scaffold_project_prompts _get_project_prompts_dir = get_project_prompts_dir @@ -57,6 +54,23 @@ def _init_imports(): _imports_initialized = True +def _get_registry_functions(): + """Get registry functions with lazy import.""" + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from registry import ( + register_project, + unregister_project, + get_project_path, + list_registered_projects, + validate_project_path, + ) + return register_project, unregister_project, get_project_path, list_registered_projects, validate_project_path + + router = APIRouter(prefix="/api/projects", tags=["projects"]) @@ -73,25 +87,39 @@ def validate_project_name(name: str) -> str: def get_project_stats(project_dir: Path) -> ProjectStats: """Get statistics for a project.""" _init_imports() - passing, _, total = _count_passing_tests(project_dir) + passing, in_progress, total = _count_passing_tests(project_dir) percentage = (passing / total * 100) if total > 0 else 0.0 - return ProjectStats(passing=passing, total=total, percentage=round(percentage, 1)) + return ProjectStats( + passing=passing, + in_progress=in_progress, + total=total, + percentage=round(percentage, 1) + ) @router.get("", response_model=list[ProjectSummary]) async def list_projects(): - """List all projects in the generations directory.""" + """List all registered projects.""" _init_imports() - projects = _get_existing_projects() + _, _, _, list_registered_projects, validate_project_path = _get_registry_functions() + + projects = list_registered_projects() result = [] - for name in projects: - project_dir = _GENERATIONS_DIR / name + for name, info in projects.items(): + project_dir = Path(info["path"]) + + # Skip if path no longer exists + is_valid, _ = validate_project_path(project_dir) + if not is_valid: + continue + has_spec = _check_spec_exists(project_dir) stats = get_project_stats(project_dir) result.append(ProjectSummary( name=name, + path=info["path"], has_spec=has_spec, stats=stats, )) @@ -101,26 +129,61 @@ async def list_projects(): @router.post("", response_model=ProjectSummary) async def create_project(project: ProjectCreate): - """Create a new project with scaffolded prompts.""" + """Create a new project at the specified path.""" _init_imports() + register_project, _, get_project_path, _, _ = _get_registry_functions() + name = validate_project_name(project.name) + project_path = Path(project.path).resolve() - project_dir = _GENERATIONS_DIR / name - - if project_dir.exists(): + # Check if project name already registered + existing = get_project_path(name) + if existing: raise HTTPException( status_code=409, - detail=f"Project '{name}' already exists" + detail=f"Project '{name}' already exists at {existing}" ) - # Create project directory - project_dir.mkdir(parents=True, exist_ok=True) + # Security: Check if path is in a blocked location + from .filesystem import is_path_blocked + if is_path_blocked(project_path): + raise HTTPException( + status_code=403, + detail="Cannot create project in system or sensitive directory" + ) + + # Validate the path is usable + if project_path.exists(): + if not project_path.is_dir(): + raise HTTPException( + status_code=400, + detail="Path exists but is not a directory" + ) + else: + # Create the directory + try: + project_path.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"Failed to create directory: {e}" + ) # Scaffold prompts - _scaffold_project_prompts(project_dir) + _scaffold_project_prompts(project_path) + + # Register in registry + try: + register_project(name, project_path) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to register project: {e}" + ) return ProjectSummary( name=name, + path=project_path.as_posix(), has_spec=False, # Just created, no spec yet stats=ProjectStats(passing=0, total=0, percentage=0.0), ) @@ -130,11 +193,16 @@ async def create_project(project: ProjectCreate): async def get_project(name: str): """Get detailed information about a project.""" _init_imports() + _, _, get_project_path, _, _ = _get_registry_functions() + name = validate_project_name(name) - project_dir = _GENERATIONS_DIR / name + project_dir = get_project_path(name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{name}' not found in registry") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory no longer exists: {project_dir}") has_spec = _check_spec_exists(project_dir) stats = get_project_stats(project_dir) @@ -142,6 +210,7 @@ async def get_project(name: str): return ProjectDetail( name=name, + path=project_dir.as_posix(), has_spec=has_spec, stats=stats, prompts_dir=str(prompts_dir), @@ -149,13 +218,21 @@ async def get_project(name: str): @router.delete("/{name}") -async def delete_project(name: str): - """Delete a project and all its files.""" - _init_imports() - name = validate_project_name(name) - project_dir = _GENERATIONS_DIR / name +async def delete_project(name: str, delete_files: bool = False): + """ + Delete a project from the registry. - if not project_dir.exists(): + Args: + name: Project name to delete + delete_files: If True, also delete the project directory and files + """ + _init_imports() + _, unregister_project, get_project_path, _, _ = _get_registry_functions() + + name = validate_project_name(name) + project_dir = get_project_path(name) + + if not project_dir: raise HTTPException(status_code=404, detail=f"Project '{name}' not found") # Check if agent is running @@ -166,22 +243,36 @@ async def delete_project(name: str): detail="Cannot delete project while agent is running. Stop the agent first." ) - try: - shutil.rmtree(project_dir) - return {"success": True, "message": f"Project '{name}' deleted"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to delete project: {e}") + # Optionally delete files + if delete_files and project_dir.exists(): + try: + shutil.rmtree(project_dir) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete project files: {e}") + + # Unregister from registry + unregister_project(name) + + return { + "success": True, + "message": f"Project '{name}' deleted" + (" (files removed)" if delete_files else " (files preserved)") + } @router.get("/{name}/prompts", response_model=ProjectPrompts) async def get_project_prompts(name: str): """Get the content of project prompt files.""" _init_imports() + _, _, get_project_path, _, _ = _get_registry_functions() + name = validate_project_name(name) - project_dir = _GENERATIONS_DIR / name + project_dir = get_project_path(name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{name}' not found") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory not found") prompts_dir = _get_project_prompts_dir(project_dir) @@ -205,11 +296,16 @@ async def get_project_prompts(name: str): async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate): """Update project prompt files.""" _init_imports() + _, _, get_project_path, _, _ = _get_registry_functions() + name = validate_project_name(name) - project_dir = _GENERATIONS_DIR / name + project_dir = get_project_path(name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{name}' not found") if not project_dir.exists(): - raise HTTPException(status_code=404, detail=f"Project '{name}' not found") + raise HTTPException(status_code=404, detail=f"Project directory not found") prompts_dir = _get_project_prompts_dir(project_dir) prompts_dir.mkdir(parents=True, exist_ok=True) @@ -230,10 +326,15 @@ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate): async def get_project_stats_endpoint(name: str): """Get current progress statistics for a project.""" _init_imports() - name = validate_project_name(name) - project_dir = _GENERATIONS_DIR / name + _, _, get_project_path, _, _ = _get_registry_functions() - if not project_dir.exists(): + name = validate_project_name(name) + project_dir = get_project_path(name) + + if not project_dir: raise HTTPException(status_code=404, detail=f"Project '{name}' not found") + if not project_dir.exists(): + raise HTTPException(status_code=404, detail=f"Project directory not found") + return get_project_stats(project_dir) diff --git a/server/routers/spec_creation.py b/server/routers/spec_creation.py index abf7453..5600a2f 100644 --- a/server/routers/spec_creation.py +++ b/server/routers/spec_creation.py @@ -31,6 +31,17 @@ router = APIRouter(prefix="/api/spec", tags=["spec-creation"]) ROOT_DIR = Path(__file__).parent.parent.parent +def _get_project_path(project_name: str) -> Path: + """Get project path from registry.""" + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from registry import get_project_path + return get_project_path(project_name) + + def validate_project_name(name: str) -> bool: """Validate project name to prevent path traversal.""" return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name)) @@ -116,6 +127,16 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str): await websocket.close(code=4000, reason="Invalid project name") return + # Look up project directory from registry + project_dir = _get_project_path(project_name) + if not project_dir: + await websocket.close(code=4004, reason="Project not found in registry") + return + + if not project_dir.exists(): + await websocket.close(code=4004, reason="Project directory not found") + return + await websocket.accept() session: Optional[SpecChatSession] = None @@ -134,7 +155,7 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str): elif msg_type == "start": # Create and start a new session - session = await create_session(project_name) + session = await create_session(project_name, project_dir) # Track spec completion state spec_complete_received = False diff --git a/server/schemas.py b/server/schemas.py index c14b9ac..53bbad1 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -17,12 +17,14 @@ from pydantic import BaseModel, Field class ProjectCreate(BaseModel): """Request schema for creating a new project.""" name: str = Field(..., min_length=1, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$') + path: str = Field(..., min_length=1, description="Absolute path to project directory") spec_method: Literal["claude", "manual"] = "claude" class ProjectStats(BaseModel): """Project statistics.""" passing: int = 0 + in_progress: int = 0 total: int = 0 percentage: float = 0.0 @@ -30,6 +32,7 @@ class ProjectStats(BaseModel): class ProjectSummary(BaseModel): """Summary of a project for list view.""" name: str + path: str has_spec: bool stats: ProjectStats @@ -37,6 +40,7 @@ class ProjectSummary(BaseModel): class ProjectDetail(BaseModel): """Detailed project information.""" name: str + path: str has_spec: bool stats: ProjectStats prompts_dir: str @@ -151,3 +155,48 @@ class WSAgentStatusMessage(BaseModel): """WebSocket message for agent status changes.""" type: Literal["agent_status"] = "agent_status" status: str + + +# ============================================================================ +# Filesystem Schemas +# ============================================================================ + +class DriveInfo(BaseModel): + """Information about a drive (Windows only).""" + letter: str + label: str + available: bool = True + + +class DirectoryEntry(BaseModel): + """An entry in a directory listing.""" + name: str + path: str # POSIX format + is_directory: bool + is_hidden: bool = False + size: int | None = None # Bytes, for files + has_children: bool = False # True if directory has subdirectories + + +class DirectoryListResponse(BaseModel): + """Response for directory listing.""" + current_path: str # POSIX format + parent_path: str | None + entries: list[DirectoryEntry] + drives: list[DriveInfo] | None = None # Windows only + + +class PathValidationResponse(BaseModel): + """Response for path validation.""" + valid: bool + exists: bool + is_directory: bool + can_read: bool + can_write: bool + message: str = "" + + +class CreateDirectoryRequest(BaseModel): + """Request to create a new directory.""" + parent_path: str + name: str = Field(..., min_length=1, max_length=255) diff --git a/server/services/process_manager.py b/server/services/process_manager.py index 4b13555..6f78e24 100644 --- a/server/services/process_manager.py +++ b/server/services/process_manager.py @@ -56,6 +56,7 @@ class AgentProcessManager: def __init__( self, project_name: str, + project_dir: Path, root_dir: Path, ): """ @@ -63,9 +64,11 @@ class AgentProcessManager: Args: project_name: Name of the project + project_dir: Absolute path to the project directory root_dir: Root directory of the autonomous-coding-ui project """ self.project_name = project_name + self.project_dir = project_dir self.root_dir = root_dir self.process: subprocess.Popen | None = None self._status: Literal["stopped", "running", "paused", "crashed"] = "stopped" @@ -77,8 +80,8 @@ class AgentProcessManager: self._status_callbacks: Set[Callable[[str], Awaitable[None]]] = set() self._callbacks_lock = threading.Lock() - # Lock file to prevent multiple instances - self.lock_file = self.root_dir / "generations" / project_name / ".agent.lock" + # Lock file to prevent multiple instances (stored in project directory) + self.lock_file = self.project_dir / ".agent.lock" @property def status(self) -> Literal["stopped", "running", "paused", "crashed"]: @@ -224,21 +227,22 @@ class AgentProcessManager: if not self._check_lock(): return False, "Another agent instance is already running for this project" - # Build command + # Build command - pass absolute path to project directory cmd = [ sys.executable, str(self.root_dir / "autonomous_agent_demo.py"), "--project-dir", - self.project_name, + str(self.project_dir.resolve()), ] try: # Start subprocess with piped stdout/stderr + # Use project_dir as cwd so Claude SDK sandbox allows access to project files self.process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - cwd=str(self.root_dir), + cwd=str(self.project_dir), ) self._create_lock() @@ -379,11 +383,17 @@ _managers: dict[str, AgentProcessManager] = {} _managers_lock = threading.Lock() -def get_manager(project_name: str, root_dir: Path) -> AgentProcessManager: - """Get or create a process manager for a project (thread-safe).""" +def get_manager(project_name: str, project_dir: Path, root_dir: Path) -> AgentProcessManager: + """Get or create a process manager for a project (thread-safe). + + Args: + project_name: Name of the project + project_dir: Absolute path to the project directory + root_dir: Root directory of the autonomous-coding-ui project + """ with _managers_lock: if project_name not in _managers: - _managers[project_name] = AgentProcessManager(project_name, root_dir) + _managers[project_name] = AgentProcessManager(project_name, project_dir, root_dir) return _managers[project_name] diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 2582931..5b8f952 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -35,15 +35,16 @@ class SpecChatSession: - Phase 6-7: Success Criteria & Approval """ - def __init__(self, project_name: str): + def __init__(self, project_name: str, project_dir: Path): """ Initialize the session. Args: project_name: Name of the project being created + project_dir: Absolute path to the project directory """ self.project_name = project_name - self.project_dir = ROOT_DIR / "generations" / project_name + self.project_dir = project_dir self.client: Optional[ClaudeSDKClient] = None self.messages: list[dict] = [] self.complete: bool = False @@ -342,14 +343,19 @@ def get_session(project_name: str) -> Optional[SpecChatSession]: return _sessions.get(project_name) -async def create_session(project_name: str) -> SpecChatSession: - """Create a new session for a project, closing any existing one.""" +async def create_session(project_name: str, project_dir: Path) -> SpecChatSession: + """Create a new session for a project, closing any existing one. + + Args: + project_name: Name of the project + project_dir: Absolute path to the project directory + """ old_session: Optional[SpecChatSession] = None with _sessions_lock: # Get existing session to close later (outside the lock) old_session = _sessions.pop(project_name, None) - session = SpecChatSession(project_name) + session = SpecChatSession(project_name, project_dir) _sessions[project_name] = session # Close old session outside the lock to avoid blocking diff --git a/server/websocket.py b/server/websocket.py index 56a92ae..2313900 100644 --- a/server/websocket.py +++ b/server/websocket.py @@ -18,23 +18,20 @@ from fastapi import WebSocket, WebSocketDisconnect from .services.process_manager import get_manager # Lazy imports -_GENERATIONS_DIR = None _count_passing_tests = None logger = logging.getLogger(__name__) -def _get_generations_dir(): - """Lazy import of GENERATIONS_DIR.""" - global _GENERATIONS_DIR - if _GENERATIONS_DIR is None: - import sys - root = Path(__file__).parent.parent - if str(root) not in sys.path: - sys.path.insert(0, str(root)) - from start import GENERATIONS_DIR - _GENERATIONS_DIR = GENERATIONS_DIR - return _GENERATIONS_DIR +def _get_project_path(project_name: str) -> Path: + """Get project path from registry.""" + import sys + root = Path(__file__).parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from registry import get_project_path + return get_project_path(project_name) def _get_count_passing_tests(): @@ -112,9 +109,8 @@ def validate_project_name(name: str) -> bool: return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name)) -async def poll_progress(websocket: WebSocket, project_name: str): +async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Path): """Poll database for progress changes and send updates.""" - project_dir = _get_generations_dir() / project_name count_passing_tests = _get_count_passing_tests() last_passing = -1 last_in_progress = -1 @@ -160,15 +156,19 @@ async def project_websocket(websocket: WebSocket, project_name: str): await websocket.close(code=4000, reason="Invalid project name") return - project_dir = _get_generations_dir() / project_name + project_dir = _get_project_path(project_name) + if not project_dir: + await websocket.close(code=4004, reason="Project not found in registry") + return + if not project_dir.exists(): - await websocket.close(code=4004, reason="Project not found") + await websocket.close(code=4004, reason="Project directory not found") return await manager.connect(websocket, project_name) # Get agent manager and register callbacks - agent_manager = get_manager(project_name, ROOT_DIR) + agent_manager = get_manager(project_name, project_dir, ROOT_DIR) async def on_output(line: str): """Handle agent output - broadcast to this WebSocket.""" @@ -196,7 +196,7 @@ async def project_websocket(websocket: WebSocket, project_name: str): agent_manager.add_status_callback(on_status_change) # Start progress polling task - poll_task = asyncio.create_task(poll_progress(websocket, project_name)) + poll_task = asyncio.create_task(poll_progress(websocket, project_name, project_dir)) try: # Send initial status diff --git a/start.py b/start.py index cf8fde2..9263edd 100644 --- a/start.py +++ b/start.py @@ -18,10 +18,11 @@ from prompts import ( has_project_prompts, get_project_prompts_dir, ) - - -# Directory containing generated projects -GENERATIONS_DIR = Path(__file__).parent / "generations" +from registry import ( + register_project, + get_project_path, + list_registered_projects, +) def check_spec_exists(project_dir: Path) -> bool: @@ -54,20 +55,24 @@ def check_spec_exists(project_dir: Path) -> bool: return False -def get_existing_projects() -> list[str]: - """Get list of existing projects from generations folder.""" - if not GENERATIONS_DIR.exists(): - return [] +def get_existing_projects() -> list[tuple[str, Path]]: + """Get list of existing projects from registry. + Returns: + List of (name, path) tuples for registered projects that still exist. + """ + registry = list_registered_projects() projects = [] - for item in GENERATIONS_DIR.iterdir(): - if item.is_dir() and not item.name.startswith('.'): - projects.append(item.name) - return sorted(projects) + for name, info in registry.items(): + path = Path(info["path"]) + if path.exists(): + projects.append((name, path)) + + return sorted(projects, key=lambda x: x[0]) -def display_menu(projects: list[str]) -> None: +def display_menu(projects: list[tuple[str, Path]]) -> None: """Display the main menu.""" print("\n" + "=" * 50) print(" Autonomous Coding Agent Launcher") @@ -81,21 +86,26 @@ def display_menu(projects: list[str]) -> None: print() -def display_projects(projects: list[str]) -> None: +def display_projects(projects: list[tuple[str, Path]]) -> None: """Display list of existing projects.""" print("\n" + "-" * 40) print(" Existing Projects") print("-" * 40) - for i, project in enumerate(projects, 1): - print(f" [{i}] {project}") + for i, (name, path) in enumerate(projects, 1): + print(f" [{i}] {name}") + print(f" {path}") print("\n [b] Back to main menu") print() -def get_project_choice(projects: list[str]) -> str | None: - """Get user's project selection.""" +def get_project_choice(projects: list[tuple[str, Path]]) -> tuple[str, Path] | None: + """Get user's project selection. + + Returns: + Tuple of (name, path) for the selected project, or None if cancelled. + """ while True: choice = input("Select project number: ").strip().lower() @@ -111,8 +121,12 @@ def get_project_choice(projects: list[str]) -> str | None: print("Invalid input. Enter a number or 'b' to go back.") -def get_new_project_name() -> str | None: - """Get name for new project.""" +def get_new_project_info() -> tuple[str, Path] | None: + """Get name and path for new project. + + Returns: + Tuple of (name, path) for the new project, or None if cancelled. + """ print("\n" + "-" * 40) print(" Create New Project") print("-" * 40) @@ -137,27 +151,50 @@ def get_new_project_name() -> str | None: print(f"Invalid character '{char}' in project name") return None - return name + # Check if name already registered + existing = get_project_path(name) + if existing: + print(f"Project '{name}' already exists at {existing}") + return None + + # Get project path + print("\nEnter the full path for the project directory") + print("(e.g., C:/Projects/my-app or /home/user/projects/my-app)") + print("Leave empty to cancel.\n") + + path_str = input("Project path: ").strip() + if not path_str: + return None + + project_path = Path(path_str).resolve() + + return name, project_path -def ensure_project_scaffolded(project_name: str) -> Path: +def ensure_project_scaffolded(project_name: str, project_dir: Path) -> Path: """ - Ensure project directory exists with prompt templates. + Ensure project directory exists with prompt templates and is registered. - Creates the project directory and copies template files if needed. + Creates the project directory, copies template files, and registers in registry. + + Args: + project_name: Name of the project + project_dir: Absolute path to the project directory Returns: The project directory path """ - project_dir = GENERATIONS_DIR / project_name - # Create project directory if it doesn't exist project_dir.mkdir(parents=True, exist_ok=True) # Scaffold prompts (copies templates if they don't exist) print(f"\nSetting up project: {project_name}") + print(f"Location: {project_dir}") scaffold_project_prompts(project_dir) + # Register in registry + register_project(project_name, project_dir) + return project_dir @@ -266,23 +303,25 @@ def ask_spec_creation_choice() -> str | None: print("Invalid choice. Please enter 1, 2, or b.") -def create_new_project_flow() -> str | None: +def create_new_project_flow() -> tuple[str, Path] | None: """ Complete flow for creating a new project. - 1. Get project name + 1. Get project name and path 2. Create project directory and scaffold prompts 3. Ask: Claude or Manual? 4. If Claude: Run /create-spec with project path 5. If Manual: Show paths, wait for Enter - 6. Return project name if successful + 6. Return (name, path) tuple if successful """ - project_name = get_new_project_name() - if not project_name: + project_info = get_new_project_info() + if not project_info: return None + project_name, project_path = project_info + # Create project directory and scaffold prompts FIRST - project_dir = ensure_project_scaffolded(project_name) + project_dir = ensure_project_scaffolded(project_name, project_path) # Ask user how they want to handle spec creation choice = ask_spec_creation_choice() @@ -303,13 +342,16 @@ def create_new_project_flow() -> str | None: if not success: return None - return project_name + return project_name, project_dir -def run_agent(project_name: str) -> None: - """Run the autonomous agent with the given project.""" - project_dir = GENERATIONS_DIR / project_name +def run_agent(project_name: str, project_dir: Path) -> None: + """Run the autonomous agent with the given project. + Args: + project_name: Name of the project + project_dir: Absolute path to the project directory + """ # Final validation before running if not has_project_prompts(project_dir): print(f"\nWarning: No valid spec found for project '{project_name}'") @@ -319,10 +361,11 @@ def run_agent(project_name: str) -> None: return print(f"\nStarting agent for project: {project_name}") + print(f"Location: {project_dir}") print("-" * 50) - # Build the command - cmd = [sys.executable, "autonomous_agent_demo.py", "--project-dir", project_name] + # Build the command - pass absolute path + cmd = [sys.executable, "autonomous_agent_demo.py", "--project-dir", str(project_dir.resolve())] # Run the agent try: @@ -348,15 +391,17 @@ def main() -> None: break elif choice == '1': - project_name = create_new_project_flow() - if project_name: - run_agent(project_name) + result = create_new_project_flow() + if result: + project_name, project_dir = result + run_agent(project_name, project_dir) elif choice == '2' and projects: display_projects(projects) selected = get_project_choice(projects) if selected: - run_agent(selected) + project_name, project_dir = selected + run_agent(project_name, project_dir) else: print("Invalid option. Please try again.") diff --git a/ui/src/components/FolderBrowser.tsx b/ui/src/components/FolderBrowser.tsx new file mode 100644 index 0000000..fc97d4f --- /dev/null +++ b/ui/src/components/FolderBrowser.tsx @@ -0,0 +1,336 @@ +/** + * Folder Browser Component + * + * Server-side filesystem browser for selecting project directories. + * Cross-platform support for Windows, macOS, and Linux. + */ + +import { useState, useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + Folder, + FolderOpen, + ChevronRight, + HardDrive, + Loader2, + AlertCircle, + FolderPlus, + ArrowLeft, +} from 'lucide-react' +import * as api from '../lib/api' +import type { DirectoryEntry, DriveInfo } from '../lib/types' + +interface FolderBrowserProps { + onSelect: (path: string) => void + onCancel: () => void + initialPath?: string +} + +export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowserProps) { + const [currentPath, setCurrentPath] = useState(initialPath) + const [selectedPath, setSelectedPath] = useState(null) + const [isCreatingFolder, setIsCreatingFolder] = useState(false) + const [newFolderName, setNewFolderName] = useState('') + const [createError, setCreateError] = useState(null) + + // Fetch directory listing + const { + data: directoryData, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['filesystem', 'list', currentPath], + queryFn: () => api.listDirectory(currentPath), + }) + + // Update selected path when directory changes + useEffect(() => { + if (directoryData?.current_path) { + setSelectedPath(directoryData.current_path) + } + }, [directoryData?.current_path]) + + const handleNavigate = (path: string) => { + setCurrentPath(path) + setSelectedPath(path) + setIsCreatingFolder(false) + setNewFolderName('') + setCreateError(null) + } + + const handleNavigateUp = () => { + if (directoryData?.parent_path) { + handleNavigate(directoryData.parent_path) + } + } + + const handleDriveSelect = (drive: DriveInfo) => { + handleNavigate(`${drive.letter}:/`) + } + + const handleEntryClick = (entry: DirectoryEntry) => { + if (entry.is_directory) { + handleNavigate(entry.path) + } + } + + const handleCreateFolder = async () => { + if (!newFolderName.trim()) { + setCreateError('Folder name is required') + return + } + + // Basic validation + if (!/^[a-zA-Z0-9_\-. ]+$/.test(newFolderName)) { + setCreateError('Invalid folder name') + return + } + + const newPath = `${directoryData?.current_path}/${newFolderName.trim()}` + + try { + await api.createDirectory(newPath) + // Refresh the directory listing + await refetch() + // Navigate to the new folder + handleNavigate(newPath) + } catch (err) { + setCreateError(err instanceof Error ? err.message : 'Failed to create folder') + } + } + + const handleSelect = () => { + if (selectedPath) { + onSelect(selectedPath) + } + } + + // Parse breadcrumb segments from path + const getBreadcrumbs = (path: string): { name: string; path: string }[] => { + if (!path) return [] + + const segments: { name: string; path: string }[] = [] + + // Handle Windows drive letters + if (/^[A-Za-z]:/.test(path)) { + const drive = path.slice(0, 2) + segments.push({ name: drive, path: `${drive}/` }) + path = path.slice(3) + } else if (path.startsWith('/')) { + segments.push({ name: '/', path: '/' }) + path = path.slice(1) + } + + // Split remaining path + const parts = path.split('/').filter(Boolean) + let currentPath = segments.length > 0 ? segments[0].path : '' + + for (const part of parts) { + currentPath = currentPath.endsWith('/') ? currentPath + part : currentPath + '/' + part + segments.push({ name: part, path: currentPath }) + } + + return segments + } + + const breadcrumbs = directoryData?.current_path ? getBreadcrumbs(directoryData.current_path) : [] + + return ( +
+ {/* Header with breadcrumb navigation */} +
+
+ + Select Project Folder +
+ + {/* Breadcrumb navigation */} +
+ {directoryData?.parent_path && ( + + )} + + {breadcrumbs.map((crumb, index) => ( +
+ {index > 0 && } + +
+ ))} +
+
+ + {/* Drive selector (Windows only) */} + {directoryData?.drives && directoryData.drives.length > 0 && ( +
+
+ Drives: + {directoryData.drives.map((drive) => ( + + ))} +
+
+ )} + + {/* Directory listing */} +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ +

+ {error instanceof Error ? error.message : 'Failed to load directory'} +

+ +
+ ) : ( +
+ {/* Directory entries - only show directories */} + {directoryData?.entries + .filter((entry) => entry.is_directory) + .map((entry) => ( + + ))} + + {/* Empty state */} + {directoryData?.entries.filter((e) => e.is_directory).length === 0 && ( +
+ +

No subfolders

+

You can create a new folder or select this directory.

+
+ )} +
+ )} + + {/* New folder creation */} + {isCreatingFolder && ( +
+
+ + setNewFolderName(e.target.value)} + placeholder="New folder name" + className="neo-input flex-1 py-1" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateFolder() + if (e.key === 'Escape') { + setIsCreatingFolder(false) + setNewFolderName('') + setCreateError(null) + } + }} + /> + + +
+ {createError && ( +

{createError}

+ )} +
+ )} +
+ + {/* Footer with selected path and actions */} +
+ {/* Selected path display */} +
+
Selected path:
+
{selectedPath || 'No folder selected'}
+
+ + {/* Actions */} +
+ + +
+ + +
+
+
+
+ ) +} diff --git a/ui/src/components/NewProjectModal.tsx b/ui/src/components/NewProjectModal.tsx index c47b596..b2d3889 100644 --- a/ui/src/components/NewProjectModal.tsx +++ b/ui/src/components/NewProjectModal.tsx @@ -3,20 +3,22 @@ * * Multi-step modal for creating new projects: * 1. Enter project name - * 2. Choose spec method (Claude or manual) - * 3a. If Claude: Show SpecCreationChat - * 3b. If manual: Create project and close + * 2. Select project folder + * 3. Choose spec method (Claude or manual) + * 4a. If Claude: Show SpecCreationChat + * 4b. If manual: Create project and close */ import { useState } from 'react' -import { X, Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react' +import { X, Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react' import { useCreateProject } from '../hooks/useProjects' import { SpecCreationChat } from './SpecCreationChat' +import { FolderBrowser } from './FolderBrowser' import { startAgent } from '../lib/api' type InitializerStatus = 'idle' | 'starting' | 'error' -type Step = 'name' | 'method' | 'chat' | 'complete' +type Step = 'name' | 'folder' | 'method' | 'chat' | 'complete' type SpecMethod = 'claude' | 'manual' interface NewProjectModalProps { @@ -32,6 +34,7 @@ export function NewProjectModal({ }: NewProjectModalProps) { const [step, setStep] = useState('name') const [projectName, setProjectName] = useState('') + const [projectPath, setProjectPath] = useState(null) const [_specMethod, setSpecMethod] = useState(null) const [error, setError] = useState(null) const [initializerStatus, setInitializerStatus] = useState('idle') @@ -59,17 +62,35 @@ export function NewProjectModal({ } setError(null) + setStep('folder') + } + + const handleFolderSelect = (path: string) => { + // Append project name to the selected path + const fullPath = path.endsWith('/') ? `${path}${projectName.trim()}` : `${path}/${projectName.trim()}` + setProjectPath(fullPath) setStep('method') } + const handleFolderCancel = () => { + setStep('name') + } + const handleMethodSelect = async (method: SpecMethod) => { setSpecMethod(method) + if (!projectPath) { + setError('Please select a project folder first') + setStep('folder') + return + } + if (method === 'manual') { // Create project immediately with manual method try { const project = await createProject.mutateAsync({ name: projectName.trim(), + path: projectPath, specMethod: 'manual', }) setStep('complete') @@ -85,6 +106,7 @@ export function NewProjectModal({ try { await createProject.mutateAsync({ name: projectName.trim(), + path: projectPath, specMethod: 'claude', }) setStep('chat') @@ -126,6 +148,7 @@ export function NewProjectModal({ const handleClose = () => { setStep('name') setProjectName('') + setProjectPath(null) setSpecMethod(null) setError(null) setInitializerStatus('idle') @@ -135,8 +158,11 @@ export function NewProjectModal({ const handleBack = () => { if (step === 'method') { - setStep('name') + setStep('folder') setSpecMethod(null) + } else if (step === 'folder') { + setStep('name') + setProjectPath(null) } } @@ -156,6 +182,47 @@ export function NewProjectModal({ ) } + // Folder step uses larger modal + if (step === 'folder') { + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +
+

+ Select Project Location +

+

+ A folder named {projectName} will be created inside the selected directory +

+
+
+ +
+ + {/* Folder Browser */} +
+ +
+
+
+ ) + } + return (
- api.createProject(name, specMethod), + mutationFn: ({ name, path, specMethod }: { name: string; path: string; specMethod?: 'claude' | 'manual' }) => + api.createProject(name, path, specMethod), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }) }, @@ -170,3 +170,33 @@ export function useHealthCheck() { retry: false, }) } + +// ============================================================================ +// Filesystem +// ============================================================================ + +export function useListDirectory(path?: string) { + return useQuery({ + queryKey: ['filesystem', 'list', path], + queryFn: () => api.listDirectory(path), + }) +} + +export function useCreateDirectory() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (path: string) => api.createDirectory(path), + onSuccess: (_, path) => { + // Invalidate parent directory listing + const parentPath = path.split('/').slice(0, -1).join('/') || undefined + queryClient.invalidateQueries({ queryKey: ['filesystem', 'list', parentPath] }) + }, + }) +} + +export function useValidatePath() { + return useMutation({ + mutationFn: (path: string) => api.validatePath(path), + }) +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index a420352..6ffba63 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -12,6 +12,8 @@ import type { AgentStatusResponse, AgentActionResponse, SetupStatus, + DirectoryListResponse, + PathValidationResponse, } from './types' const API_BASE = '/api' @@ -41,10 +43,14 @@ export async function listProjects(): Promise { return fetchJSON('/projects') } -export async function createProject(name: string, specMethod: 'claude' | 'manual' = 'manual'): Promise { +export async function createProject( + name: string, + path: string, + specMethod: 'claude' | 'manual' = 'manual' +): Promise { return fetchJSON('/projects', { method: 'POST', - body: JSON.stringify({ name, spec_method: specMethod }), + body: JSON.stringify({ name, path, spec_method: specMethod }), }) } @@ -146,3 +152,59 @@ export async function getSetupStatus(): Promise { export async function healthCheck(): Promise<{ status: string }> { return fetchJSON('/health') } + +// ============================================================================ +// Filesystem API +// ============================================================================ + +export async function listDirectory(path?: string): Promise { + const params = path ? `?path=${encodeURIComponent(path)}` : '' + return fetchJSON(`/filesystem/list${params}`) +} + +export async function createDirectory(fullPath: string): Promise<{ success: boolean; path: string }> { + // Backend expects { parent_path, name }, not { path } + // Split the full path into parent directory and folder name + + // Remove trailing slash if present + const normalizedPath = fullPath.endsWith('/') ? fullPath.slice(0, -1) : fullPath + + // Find the last path separator + const lastSlash = normalizedPath.lastIndexOf('/') + + let parentPath: string + let name: string + + // Handle Windows drive root (e.g., "C:/newfolder") + if (lastSlash === 2 && /^[A-Za-z]:/.test(normalizedPath)) { + // Path like "C:/newfolder" - parent is "C:/" + parentPath = normalizedPath.substring(0, 3) // "C:/" + name = normalizedPath.substring(3) + } else if (lastSlash > 0) { + parentPath = normalizedPath.substring(0, lastSlash) + name = normalizedPath.substring(lastSlash + 1) + } else if (lastSlash === 0) { + // Unix root path like "/newfolder" + parentPath = '/' + name = normalizedPath.substring(1) + } else { + // No slash - invalid path + throw new Error('Invalid path: must be an absolute path') + } + + if (!name) { + throw new Error('Invalid path: directory name is empty') + } + + return fetchJSON('/filesystem/create-directory', { + method: 'POST', + body: JSON.stringify({ parent_path: parentPath, name }), + }) +} + +export async function validatePath(path: string): Promise { + return fetchJSON('/filesystem/validate', { + method: 'POST', + body: JSON.stringify({ path }), + }) +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index e2310f2..76f6667 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -12,6 +12,7 @@ export interface ProjectStats { export interface ProjectSummary { name: string + path: string has_spec: boolean stats: ProjectStats } @@ -20,6 +21,35 @@ export interface ProjectDetail extends ProjectSummary { prompts_dir: string } +// Filesystem types +export interface DriveInfo { + letter: string + label: string + available?: boolean +} + +export interface DirectoryEntry { + name: string + path: string + is_directory: boolean + has_children: boolean +} + +export interface DirectoryListResponse { + current_path: string + parent_path: string | null + entries: DirectoryEntry[] + drives: DriveInfo[] | null +} + +export interface PathValidationResponse { + valid: boolean + exists: boolean + is_directory: boolean + can_write: boolean + message: string +} + export interface ProjectPrompts { app_spec: string initializer_prompt: string diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 4f25603..d1b60de 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file