autoforge-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. package/ui/package.json +57 -0
@@ -0,0 +1,514 @@
1
+ """
2
+ Filesystem Router
3
+ ==================
4
+
5
+ API endpoints for browsing the filesystem for project folder selection.
6
+ Provides cross-platform support for Windows, macOS, and Linux.
7
+ """
8
+
9
+ import functools
10
+ import logging
11
+ import os
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from fastapi import APIRouter, HTTPException, Query
17
+
18
+ from security import SENSITIVE_DIRECTORIES
19
+
20
+ # Module logger
21
+ logger = logging.getLogger(__name__)
22
+
23
+ from ..schemas import (
24
+ CreateDirectoryRequest,
25
+ DirectoryEntry,
26
+ DirectoryListResponse,
27
+ DriveInfo,
28
+ PathValidationResponse,
29
+ )
30
+
31
+ router = APIRouter(prefix="/api/filesystem", tags=["filesystem"])
32
+
33
+
34
+ # =============================================================================
35
+ # Platform-Specific Blocked Paths
36
+ # =============================================================================
37
+
38
+ # Windows blocked paths
39
+ WINDOWS_BLOCKED = {
40
+ "C:\\Windows",
41
+ "C:\\Program Files",
42
+ "C:\\Program Files (x86)",
43
+ "C:\\ProgramData",
44
+ "C:\\System Volume Information",
45
+ "C:\\$Recycle.Bin",
46
+ "C:\\Recovery",
47
+ }
48
+
49
+ # macOS blocked paths
50
+ MACOS_BLOCKED = {
51
+ "/System",
52
+ "/Library",
53
+ "/private",
54
+ "/usr",
55
+ "/bin",
56
+ "/sbin",
57
+ "/etc",
58
+ "/var",
59
+ "/Volumes",
60
+ "/cores",
61
+ "/opt",
62
+ }
63
+
64
+ # Linux blocked paths
65
+ LINUX_BLOCKED = {
66
+ "/etc",
67
+ "/var",
68
+ "/usr",
69
+ "/bin",
70
+ "/sbin",
71
+ "/boot",
72
+ "/proc",
73
+ "/sys",
74
+ "/dev",
75
+ "/root",
76
+ "/lib",
77
+ "/lib64",
78
+ "/run",
79
+ "/tmp",
80
+ "/opt",
81
+ }
82
+
83
+ # Universal blocked paths (relative to home directory).
84
+ # Delegates to the canonical SENSITIVE_DIRECTORIES set in security.py so that
85
+ # the filesystem browser and the EXTRA_READ_PATHS validator share one source of truth.
86
+ UNIVERSAL_BLOCKED_RELATIVE = SENSITIVE_DIRECTORIES
87
+
88
+ # Patterns for files that should not be shown
89
+ HIDDEN_PATTERNS = [
90
+ r"^\.env", # .env files
91
+ r".*\.key$", # Key files
92
+ r".*\.pem$", # PEM files
93
+ r".*credentials.*", # Credential files
94
+ r".*secrets.*", # Secrets files
95
+ ]
96
+
97
+
98
+ @functools.lru_cache(maxsize=1)
99
+ def get_blocked_paths() -> frozenset[Path]:
100
+ """
101
+ Get the set of blocked paths for the current platform.
102
+
103
+ Cached because the platform and home directory do not change at runtime,
104
+ and this function is called once per directory entry in list_directory().
105
+ """
106
+ home = Path.home()
107
+ blocked = set()
108
+
109
+ # Add platform-specific blocked paths
110
+ if sys.platform == "win32":
111
+ for p in WINDOWS_BLOCKED:
112
+ blocked.add(Path(p).resolve())
113
+ elif sys.platform == "darwin":
114
+ for p in MACOS_BLOCKED:
115
+ blocked.add(Path(p).resolve())
116
+ else: # Linux
117
+ for p in LINUX_BLOCKED:
118
+ blocked.add(Path(p).resolve())
119
+
120
+ # Add universal blocked paths (relative to home)
121
+ for rel in UNIVERSAL_BLOCKED_RELATIVE:
122
+ blocked.add((home / rel).resolve())
123
+
124
+ return frozenset(blocked)
125
+
126
+
127
+ def is_path_blocked(path: Path) -> bool:
128
+ """Check if a path is in the blocked list."""
129
+ try:
130
+ resolved = path.resolve()
131
+ except (OSError, ValueError):
132
+ return True # Can't resolve = blocked
133
+
134
+ blocked_paths = get_blocked_paths()
135
+
136
+ # Check if path is exactly a blocked path or inside one
137
+ for blocked in blocked_paths:
138
+ try:
139
+ resolved.relative_to(blocked)
140
+ return True
141
+ except ValueError:
142
+ pass
143
+
144
+ # Also check if blocked is inside path (for parent directories)
145
+ if resolved == blocked:
146
+ return True
147
+
148
+ return False
149
+
150
+
151
+ def is_hidden_file(path: Path) -> bool:
152
+ """Check if a file/directory is hidden (cross-platform)."""
153
+ name = path.name
154
+
155
+ # Unix-style: starts with dot
156
+ if name.startswith('.'):
157
+ return True
158
+
159
+ # Windows: check FILE_ATTRIBUTE_HIDDEN
160
+ if sys.platform == "win32":
161
+ try:
162
+ import ctypes
163
+ attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
164
+ if attrs != -1 and (attrs & 0x02): # FILE_ATTRIBUTE_HIDDEN
165
+ return True
166
+ except Exception:
167
+ pass
168
+
169
+ return False
170
+
171
+
172
+ def matches_blocked_pattern(name: str) -> bool:
173
+ """Check if filename matches a blocked pattern."""
174
+ for pattern in HIDDEN_PATTERNS:
175
+ if re.match(pattern, name, re.IGNORECASE):
176
+ return True
177
+ return False
178
+
179
+
180
+ def is_unc_path(path_str: str) -> bool:
181
+ """Check if path is a Windows UNC path (network share)."""
182
+ return path_str.startswith("\\\\") or path_str.startswith("//")
183
+
184
+
185
+ # =============================================================================
186
+ # Endpoints
187
+ # =============================================================================
188
+
189
+ @router.get("/list", response_model=DirectoryListResponse)
190
+ async def list_directory(
191
+ path: str | None = Query(None, description="Directory path to list (defaults to home)"),
192
+ show_hidden: bool = Query(False, description="Include hidden files"),
193
+ ):
194
+ """
195
+ List contents of a directory.
196
+
197
+ Returns directories only (for folder selection).
198
+ On Windows, includes available drives.
199
+ """
200
+ # Default to home directory
201
+ if path is None or path == "":
202
+ target = Path.home()
203
+ else:
204
+ # Security: Block UNC paths
205
+ if is_unc_path(path):
206
+ logger.warning("Blocked UNC path access attempt: %s", path)
207
+ raise HTTPException(
208
+ status_code=403,
209
+ detail="Network paths (UNC) are not allowed"
210
+ )
211
+ target = Path(path)
212
+
213
+ # Resolve symlinks and get absolute path
214
+ try:
215
+ target = target.resolve()
216
+ except (OSError, ValueError) as e:
217
+ raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
218
+
219
+ # Security: Check if path is blocked
220
+ if is_path_blocked(target):
221
+ logger.warning("Blocked access to restricted path: %s", target)
222
+ raise HTTPException(
223
+ status_code=403,
224
+ detail="Access to this directory is not allowed"
225
+ )
226
+
227
+ # Check if path exists and is a directory
228
+ if not target.exists():
229
+ raise HTTPException(status_code=404, detail="Directory not found")
230
+
231
+ if not target.is_dir():
232
+ raise HTTPException(status_code=400, detail="Path is not a directory")
233
+
234
+ # Check read permission
235
+ if not os.access(target, os.R_OK):
236
+ raise HTTPException(status_code=403, detail="No read permission")
237
+
238
+ # List directory contents
239
+ entries = []
240
+ try:
241
+ for item in sorted(target.iterdir(), key=lambda x: x.name.lower()):
242
+ # Skip if blocked pattern
243
+ if matches_blocked_pattern(item.name):
244
+ continue
245
+
246
+ # Check if hidden
247
+ hidden = is_hidden_file(item)
248
+ if hidden and not show_hidden:
249
+ continue
250
+
251
+ # Security: Skip if item path is blocked
252
+ if is_path_blocked(item):
253
+ continue
254
+
255
+ # Only include directories for folder browsing
256
+ if item.is_dir():
257
+ try:
258
+ # Check if directory has any subdirectories
259
+ has_children = False
260
+ try:
261
+ for child in item.iterdir():
262
+ if child.is_dir() and not is_path_blocked(child):
263
+ has_children = True
264
+ break
265
+ except (PermissionError, OSError):
266
+ pass # Can't read = assume no children
267
+
268
+ entries.append(DirectoryEntry(
269
+ name=item.name,
270
+ path=item.as_posix(),
271
+ is_directory=True,
272
+ is_hidden=hidden,
273
+ size=None,
274
+ has_children=has_children,
275
+ ))
276
+ except Exception:
277
+ pass # Skip items we can't process
278
+
279
+ except PermissionError:
280
+ raise HTTPException(status_code=403, detail="Permission denied")
281
+ except OSError as e:
282
+ raise HTTPException(status_code=500, detail=f"Error reading directory: {e}")
283
+
284
+ # Calculate parent path
285
+ parent_path = None
286
+ if target != target.parent: # Not at root
287
+ parent = target.parent
288
+ # Don't expose parent if it's blocked
289
+ if not is_path_blocked(parent):
290
+ parent_path = parent.as_posix()
291
+
292
+ # Get drives on Windows
293
+ drives = None
294
+ if sys.platform == "win32":
295
+ drives = get_windows_drives()
296
+
297
+ return DirectoryListResponse(
298
+ current_path=target.as_posix(),
299
+ parent_path=parent_path,
300
+ entries=entries,
301
+ drives=drives,
302
+ )
303
+
304
+
305
+ @router.get("/drives", response_model=list[DriveInfo] | None)
306
+ async def list_drives():
307
+ """
308
+ List available drives (Windows only).
309
+
310
+ Returns null on non-Windows platforms.
311
+ """
312
+ if sys.platform != "win32":
313
+ return None
314
+
315
+ return get_windows_drives()
316
+
317
+
318
+ def get_windows_drives() -> list[DriveInfo]:
319
+ """Get list of available drives on Windows."""
320
+ drives = []
321
+
322
+ try:
323
+ import ctypes
324
+ import string
325
+
326
+ # Get bitmask of available drives
327
+ bitmask = ctypes.windll.kernel32.GetLogicalDrives()
328
+
329
+ for i, letter in enumerate(string.ascii_uppercase):
330
+ if bitmask & (1 << i):
331
+ drive_path = f"{letter}:\\"
332
+ try:
333
+ # Try to get volume label
334
+ volume_name = ctypes.create_unicode_buffer(1024)
335
+ ctypes.windll.kernel32.GetVolumeInformationW(
336
+ drive_path,
337
+ volume_name,
338
+ 1024,
339
+ None, None, None, None, 0
340
+ )
341
+ label = volume_name.value or f"Local Disk ({letter}:)"
342
+ except Exception:
343
+ label = f"Drive ({letter}:)"
344
+
345
+ # Check if drive is accessible
346
+ available = os.path.exists(drive_path)
347
+
348
+ drives.append(DriveInfo(
349
+ letter=letter,
350
+ label=label,
351
+ available=available,
352
+ ))
353
+ except Exception:
354
+ # Fallback: just list C: drive
355
+ drives.append(DriveInfo(letter="C", label="Local Disk (C:)", available=True))
356
+
357
+ return drives
358
+
359
+
360
+ @router.post("/validate", response_model=PathValidationResponse)
361
+ async def validate_path(path: str = Query(..., description="Path to validate")):
362
+ """
363
+ Validate if a path is accessible and writable.
364
+
365
+ Used to check a path before creating a project there.
366
+ """
367
+ # Security: Block UNC paths
368
+ if is_unc_path(path):
369
+ return PathValidationResponse(
370
+ valid=False,
371
+ exists=False,
372
+ is_directory=False,
373
+ can_read=False,
374
+ can_write=False,
375
+ message="Network paths (UNC) are not allowed",
376
+ )
377
+
378
+ try:
379
+ target = Path(path).resolve()
380
+ except (OSError, ValueError) as e:
381
+ return PathValidationResponse(
382
+ valid=False,
383
+ exists=False,
384
+ is_directory=False,
385
+ can_read=False,
386
+ can_write=False,
387
+ message=f"Invalid path: {e}",
388
+ )
389
+
390
+ # Security: Check if blocked
391
+ if is_path_blocked(target):
392
+ return PathValidationResponse(
393
+ valid=False,
394
+ exists=target.exists(),
395
+ is_directory=target.is_dir() if target.exists() else False,
396
+ can_read=False,
397
+ can_write=False,
398
+ message="Access to this directory is not allowed",
399
+ )
400
+
401
+ exists = target.exists()
402
+ is_dir = target.is_dir() if exists else False
403
+ can_read = os.access(target, os.R_OK) if exists else False
404
+ can_write = os.access(target, os.W_OK) if exists else False
405
+
406
+ # For non-existent paths, check if parent is writable
407
+ if not exists:
408
+ parent = target.parent
409
+ parent_exists = parent.exists()
410
+ parent_writable = os.access(parent, os.W_OK) if parent_exists else False
411
+ can_write = parent_writable
412
+
413
+ valid = is_dir and can_read and can_write if exists else can_write
414
+ message = ""
415
+ if not exists:
416
+ message = "Directory does not exist (will be created)"
417
+ elif not is_dir:
418
+ message = "Path is not a directory"
419
+ elif not can_read:
420
+ message = "No read permission"
421
+ elif not can_write:
422
+ message = "No write permission"
423
+
424
+ return PathValidationResponse(
425
+ valid=valid,
426
+ exists=exists,
427
+ is_directory=is_dir,
428
+ can_read=can_read,
429
+ can_write=can_write,
430
+ message=message,
431
+ )
432
+
433
+
434
+ @router.post("/create-directory")
435
+ async def create_directory(request: CreateDirectoryRequest):
436
+ """
437
+ Create a new directory inside a parent directory.
438
+
439
+ Used for creating project folders from the folder browser.
440
+ """
441
+ # Validate directory name
442
+ name = request.name.strip()
443
+ if not name:
444
+ raise HTTPException(status_code=400, detail="Directory name cannot be empty")
445
+
446
+ # Security: Block special directory names that could enable traversal
447
+ if name in ('.', '..') or '..' in name:
448
+ raise HTTPException(
449
+ status_code=400,
450
+ detail="Invalid directory name"
451
+ )
452
+
453
+ # Security: Check for invalid characters
454
+ invalid_chars = '<>:"/\\|?*' if sys.platform == "win32" else '/'
455
+ if any(c in name for c in invalid_chars):
456
+ raise HTTPException(
457
+ status_code=400,
458
+ detail="Directory name contains invalid characters"
459
+ )
460
+
461
+ # Security: Block UNC paths
462
+ if is_unc_path(request.parent_path):
463
+ raise HTTPException(status_code=403, detail="Network paths are not allowed")
464
+
465
+ try:
466
+ parent = Path(request.parent_path).resolve()
467
+ except (OSError, ValueError) as e:
468
+ raise HTTPException(status_code=400, detail=f"Invalid parent path: {e}")
469
+
470
+ # Security: Check if parent is blocked
471
+ if is_path_blocked(parent):
472
+ raise HTTPException(
473
+ status_code=403,
474
+ detail="Cannot create directory in this location"
475
+ )
476
+
477
+ # Check parent exists and is writable
478
+ if not parent.exists():
479
+ raise HTTPException(status_code=404, detail="Parent directory not found")
480
+
481
+ if not parent.is_dir():
482
+ raise HTTPException(status_code=400, detail="Parent path is not a directory")
483
+
484
+ if not os.access(parent, os.W_OK):
485
+ raise HTTPException(status_code=403, detail="No write permission")
486
+
487
+ # Create the new directory
488
+ new_dir = parent / name
489
+
490
+ if new_dir.exists():
491
+ raise HTTPException(status_code=409, detail="Directory already exists")
492
+
493
+ try:
494
+ new_dir.mkdir(parents=False, exist_ok=False)
495
+ logger.info("Created directory: %s", new_dir)
496
+ except OSError as e:
497
+ logger.error("Failed to create directory %s: %s", new_dir, e)
498
+ raise HTTPException(status_code=500, detail=f"Failed to create directory: {e}")
499
+
500
+ return {
501
+ "success": True,
502
+ "path": new_dir.as_posix(),
503
+ "message": f"Created directory: {name}",
504
+ }
505
+
506
+
507
+ @router.get("/home")
508
+ async def get_home_directory():
509
+ """Get the user's home directory path."""
510
+ home = Path.home()
511
+ return {
512
+ "path": home.as_posix(),
513
+ "display_path": str(home),
514
+ }