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

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