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

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