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

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

View File

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

View File

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

View File

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

View File

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

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

129
start.py
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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