mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-29 22:02:05 +00:00
feat: Add arbitrary directory project storage with registry system
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 <noreply@anthropic.com>
This commit is contained in:
35
CLAUDE.md
35
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
492
registry.py
Normal file
492
registry.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
513
server/routers/filesystem.py
Normal file
513
server/routers/filesystem.py
Normal file
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
129
start.py
129
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.")
|
||||
|
||||
336
ui/src/components/FolderBrowser.tsx
Normal file
336
ui/src/components/FolderBrowser.tsx
Normal file
@@ -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<string | undefined>(initialPath)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
const [createError, setCreateError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col h-full max-h-[70vh]">
|
||||
{/* Header with breadcrumb navigation */}
|
||||
<div className="flex-shrink-0 p-4 border-b-3 border-[var(--color-neo-border)] bg-white">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Folder size={20} className="text-[var(--color-neo-progress)]" />
|
||||
<span className="font-bold text-[#1a1a1a]">Select Project Folder</span>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb navigation */}
|
||||
<div className="flex items-center gap-1 flex-wrap text-sm">
|
||||
{directoryData?.parent_path && (
|
||||
<button
|
||||
onClick={handleNavigateUp}
|
||||
className="neo-btn neo-btn-ghost p-1"
|
||||
title="Go up"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={crumb.path} className="flex items-center">
|
||||
{index > 0 && <ChevronRight size={14} className="text-gray-400 mx-1" />}
|
||||
<button
|
||||
onClick={() => handleNavigate(crumb.path)}
|
||||
className={`
|
||||
px-2 py-1 rounded text-[#1a1a1a]
|
||||
hover:bg-[var(--color-neo-bg)]
|
||||
${index === breadcrumbs.length - 1 ? 'font-bold' : ''}
|
||||
`}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drive selector (Windows only) */}
|
||||
{directoryData?.drives && directoryData.drives.length > 0 && (
|
||||
<div className="flex-shrink-0 p-3 border-b-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--color-neo-text-secondary)]">Drives:</span>
|
||||
{directoryData.drives.map((drive) => (
|
||||
<button
|
||||
key={drive.letter}
|
||||
onClick={() => handleDriveSelect(drive)}
|
||||
className={`
|
||||
neo-btn neo-btn-ghost py-1 px-2 text-sm
|
||||
flex items-center gap-1
|
||||
${currentPath?.startsWith(drive.letter) ? 'bg-[var(--color-neo-progress)] text-white' : ''}
|
||||
`}
|
||||
>
|
||||
<HardDrive size={14} />
|
||||
{drive.letter}: {drive.label && `(${drive.label})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory listing */}
|
||||
<div className="flex-1 overflow-y-auto p-2 bg-white">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 size={24} className="animate-spin text-[var(--color-neo-progress)]" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center">
|
||||
<AlertCircle size={32} className="mx-auto mb-2 text-[var(--color-neo-danger)]" />
|
||||
<p className="text-[var(--color-neo-danger)]">
|
||||
{error instanceof Error ? error.message : 'Failed to load directory'}
|
||||
</p>
|
||||
<button onClick={() => refetch()} className="neo-btn neo-btn-ghost mt-2">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{/* Directory entries - only show directories */}
|
||||
{directoryData?.entries
|
||||
.filter((entry) => entry.is_directory)
|
||||
.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
onDoubleClick={() => handleNavigate(entry.path)}
|
||||
className={`
|
||||
w-full text-left p-2 rounded
|
||||
flex items-center gap-2
|
||||
hover:bg-[var(--color-neo-bg)]
|
||||
border-2 border-transparent
|
||||
text-[var(--color-neo-text)]
|
||||
${selectedPath === entry.path ? 'bg-[var(--color-neo-progress)] bg-opacity-10 border-[var(--color-neo-progress)]' : ''}
|
||||
`}
|
||||
>
|
||||
{selectedPath === entry.path ? (
|
||||
<FolderOpen size={18} className="text-[var(--color-neo-progress)] flex-shrink-0" />
|
||||
) : (
|
||||
<Folder size={18} className="text-[var(--color-neo-pending)] flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1 text-[#1a1a1a]">{entry.name}</span>
|
||||
{entry.has_children && (
|
||||
<ChevronRight size={14} className="ml-auto text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{directoryData?.entries.filter((e) => e.is_directory).length === 0 && (
|
||||
<div className="p-4 text-center text-[var(--color-neo-text-secondary)]">
|
||||
<Folder size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>No subfolders</p>
|
||||
<p className="text-sm">You can create a new folder or select this directory.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New folder creation */}
|
||||
{isCreatingFolder && (
|
||||
<div className="mt-2 p-3 bg-[var(--color-neo-bg)] border-2 border-[var(--color-neo-border)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderPlus size={18} className="text-[var(--color-neo-progress)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button onClick={handleCreateFolder} className="neo-btn neo-btn-primary py-1 px-3">
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreatingFolder(false)
|
||||
setNewFolderName('')
|
||||
setCreateError(null)
|
||||
}}
|
||||
className="neo-btn neo-btn-ghost py-1 px-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{createError && (
|
||||
<p className="text-sm text-[var(--color-neo-danger)] mt-1">{createError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with selected path and actions */}
|
||||
<div className="flex-shrink-0 p-4 border-t-3 border-[var(--color-neo-border)] bg-white">
|
||||
{/* Selected path display */}
|
||||
<div className="mb-3 p-2 bg-[var(--color-neo-bg)] rounded border-2 border-[var(--color-neo-border)]">
|
||||
<div className="text-xs text-[#4a4a4a] mb-1">Selected path:</div>
|
||||
<div className="font-mono text-sm truncate text-[#1a1a1a]">{selectedPath || 'No folder selected'}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsCreatingFolder(true)}
|
||||
className="neo-btn neo-btn-ghost"
|
||||
disabled={isCreatingFolder}
|
||||
>
|
||||
<FolderPlus size={16} />
|
||||
New Folder
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onCancel} className="neo-btn neo-btn-ghost">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelect}
|
||||
className="neo-btn neo-btn-primary"
|
||||
disabled={!selectedPath}
|
||||
>
|
||||
Select This Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Step>('name')
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [projectPath, setProjectPath] = useState<string | null>(null)
|
||||
const [_specMethod, setSpecMethod] = useState<SpecMethod | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('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 (
|
||||
<div className="neo-modal-backdrop" onClick={handleClose}>
|
||||
<div
|
||||
className="neo-modal w-full max-w-3xl max-h-[85vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder size={24} className="text-[var(--color-neo-progress)]" />
|
||||
<div>
|
||||
<h2 className="font-display font-bold text-xl text-[#1a1a1a]">
|
||||
Select Project Location
|
||||
</h2>
|
||||
<p className="text-sm text-[#4a4a4a]">
|
||||
A folder named <span className="font-bold font-mono">{projectName}</span> will be created inside the selected directory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folder Browser */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FolderBrowser
|
||||
onSelect={handleFolderSelect}
|
||||
onCancel={handleFolderCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={handleClose}>
|
||||
<div
|
||||
|
||||
@@ -29,8 +29,8 @@ export function useCreateProject() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, specMethod }: { name: string; specMethod?: 'claude' | 'manual' }) =>
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<ProjectSummary[]> {
|
||||
return fetchJSON('/projects')
|
||||
}
|
||||
|
||||
export async function createProject(name: string, specMethod: 'claude' | 'manual' = 'manual'): Promise<ProjectSummary> {
|
||||
export async function createProject(
|
||||
name: string,
|
||||
path: string,
|
||||
specMethod: 'claude' | 'manual' = 'manual'
|
||||
): Promise<ProjectSummary> {
|
||||
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<SetupStatus> {
|
||||
export async function healthCheck(): Promise<{ status: string }> {
|
||||
return fetchJSON('/health')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filesystem API
|
||||
// ============================================================================
|
||||
|
||||
export async function listDirectory(path?: string): Promise<DirectoryListResponse> {
|
||||
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<PathValidationResponse> {
|
||||
return fetchJSON('/filesystem/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user