mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user