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:
Auto
2025-12-31 10:20:07 +02:00
parent 21f737e767
commit 6c99e40408
24 changed files with 2018 additions and 195 deletions

View File

@@ -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)
# ============================================================================

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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()

View 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),
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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]

View File

@@ -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

View File

@@ -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