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,475 @@
1
+ """
2
+ Project Configuration Service
3
+ =============================
4
+
5
+ Handles project type detection and dev command configuration.
6
+ Detects project types by scanning for configuration files and provides
7
+ default or custom dev commands for each project.
8
+
9
+ Configuration is stored in {project_dir}/.autoforge/config.json.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import TypedDict
16
+
17
+ # Python 3.11+ has tomllib in the standard library
18
+ try:
19
+ import tomllib
20
+ except ImportError:
21
+ tomllib = None # type: ignore[assignment]
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # =============================================================================
27
+ # Path Validation
28
+ # =============================================================================
29
+
30
+
31
+ def _validate_project_dir(project_dir: Path) -> Path:
32
+ """
33
+ Validate and resolve the project directory.
34
+
35
+ Args:
36
+ project_dir: Path to the project directory.
37
+
38
+ Returns:
39
+ Resolved Path object.
40
+
41
+ Raises:
42
+ ValueError: If project_dir is not a valid directory.
43
+ """
44
+ resolved = Path(project_dir).resolve()
45
+
46
+ if not resolved.exists():
47
+ raise ValueError(f"Project directory does not exist: {resolved}")
48
+ if not resolved.is_dir():
49
+ raise ValueError(f"Path is not a directory: {resolved}")
50
+
51
+ return resolved
52
+
53
+ # =============================================================================
54
+ # Type Definitions
55
+ # =============================================================================
56
+
57
+
58
+ class ProjectConfig(TypedDict):
59
+ """Full project configuration response."""
60
+ detected_type: str | None
61
+ detected_command: str | None
62
+ custom_command: str | None
63
+ effective_command: str | None
64
+
65
+
66
+ # =============================================================================
67
+ # Project Type Definitions
68
+ # =============================================================================
69
+
70
+ # Mapping of project types to their default dev commands
71
+ PROJECT_TYPE_COMMANDS: dict[str, str] = {
72
+ "nodejs-vite": "npm run dev",
73
+ "nodejs-cra": "npm start",
74
+ "python-poetry": "poetry run python -m uvicorn main:app --reload",
75
+ "python-django": "python manage.py runserver",
76
+ "python-fastapi": "python -m uvicorn main:app --reload",
77
+ "rust": "cargo run",
78
+ "go": "go run .",
79
+ }
80
+
81
+
82
+ # =============================================================================
83
+ # Configuration File Handling
84
+ # =============================================================================
85
+
86
+
87
+ def _get_config_path(project_dir: Path) -> Path:
88
+ """
89
+ Get the path to the project config file.
90
+
91
+ Checks the new .autoforge/ location first, falls back to .autocoder/
92
+ for backward compatibility.
93
+
94
+ Args:
95
+ project_dir: Path to the project directory.
96
+
97
+ Returns:
98
+ Path to the config.json file in the appropriate directory.
99
+ """
100
+ new_path = project_dir / ".autoforge" / "config.json"
101
+ if new_path.exists():
102
+ return new_path
103
+ old_path = project_dir / ".autocoder" / "config.json"
104
+ if old_path.exists():
105
+ return old_path
106
+ return new_path
107
+
108
+
109
+ def _load_config(project_dir: Path) -> dict:
110
+ """
111
+ Load the project configuration from disk.
112
+
113
+ Args:
114
+ project_dir: Path to the project directory.
115
+
116
+ Returns:
117
+ Configuration dictionary, or empty dict if file doesn't exist or is invalid.
118
+ """
119
+ config_path = _get_config_path(project_dir)
120
+
121
+ if not config_path.exists():
122
+ return {}
123
+
124
+ try:
125
+ with open(config_path, "r", encoding="utf-8") as f:
126
+ config = json.load(f)
127
+
128
+ if not isinstance(config, dict):
129
+ logger.warning(
130
+ "Invalid config format in %s: expected dict, got %s",
131
+ config_path, type(config).__name__
132
+ )
133
+ return {}
134
+
135
+ return config
136
+
137
+ except json.JSONDecodeError as e:
138
+ logger.warning("Failed to parse config at %s: %s", config_path, e)
139
+ return {}
140
+ except OSError as e:
141
+ logger.warning("Failed to read config at %s: %s", config_path, e)
142
+ return {}
143
+
144
+
145
+ def _save_config(project_dir: Path, config: dict) -> None:
146
+ """
147
+ Save the project configuration to disk.
148
+
149
+ Creates the .autoforge directory if it doesn't exist.
150
+
151
+ Args:
152
+ project_dir: Path to the project directory.
153
+ config: Configuration dictionary to save.
154
+
155
+ Raises:
156
+ OSError: If the file cannot be written.
157
+ """
158
+ config_path = _get_config_path(project_dir)
159
+
160
+ # Ensure the .autoforge directory exists
161
+ config_path.parent.mkdir(parents=True, exist_ok=True)
162
+
163
+ try:
164
+ with open(config_path, "w", encoding="utf-8") as f:
165
+ json.dump(config, f, indent=2)
166
+ logger.debug("Saved config to %s", config_path)
167
+ except OSError as e:
168
+ logger.error("Failed to save config to %s: %s", config_path, e)
169
+ raise
170
+
171
+
172
+ # =============================================================================
173
+ # Project Type Detection
174
+ # =============================================================================
175
+
176
+
177
+ def _parse_package_json(project_dir: Path) -> dict | None:
178
+ """
179
+ Parse package.json if it exists.
180
+
181
+ Args:
182
+ project_dir: Path to the project directory.
183
+
184
+ Returns:
185
+ Parsed package.json as dict, or None if not found or invalid.
186
+ """
187
+ package_json_path = project_dir / "package.json"
188
+
189
+ if not package_json_path.exists():
190
+ return None
191
+
192
+ try:
193
+ with open(package_json_path, "r", encoding="utf-8") as f:
194
+ data = json.load(f)
195
+ if isinstance(data, dict):
196
+ return data
197
+ return None
198
+ except (json.JSONDecodeError, OSError) as e:
199
+ logger.debug("Failed to parse package.json in %s: %s", project_dir, e)
200
+ return None
201
+
202
+
203
+ def _is_poetry_project(project_dir: Path) -> bool:
204
+ """
205
+ Check if pyproject.toml indicates a Poetry project.
206
+
207
+ Parses pyproject.toml to look for [tool.poetry] section.
208
+ Falls back to simple file existence check if tomllib is not available.
209
+
210
+ Args:
211
+ project_dir: Path to the project directory.
212
+
213
+ Returns:
214
+ True if pyproject.toml exists and contains Poetry configuration.
215
+ """
216
+ pyproject_path = project_dir / "pyproject.toml"
217
+ if not pyproject_path.exists():
218
+ return False
219
+
220
+ # If tomllib is available (Python 3.11+), parse and check for [tool.poetry]
221
+ if tomllib is not None:
222
+ try:
223
+ with open(pyproject_path, "rb") as f:
224
+ data = tomllib.load(f)
225
+ return "poetry" in data.get("tool", {})
226
+ except Exception:
227
+ # If parsing fails, fall back to False
228
+ return False
229
+
230
+ # Fallback for older Python: simple file existence check
231
+ # This is less accurate but provides backward compatibility
232
+ return True
233
+
234
+
235
+ def detect_project_type(project_dir: Path) -> str | None:
236
+ """
237
+ Detect the project type by scanning for configuration files.
238
+
239
+ Detection priority (first match wins):
240
+ 1. package.json with scripts.dev -> nodejs-vite
241
+ 2. package.json with scripts.start -> nodejs-cra
242
+ 3. pyproject.toml with [tool.poetry] -> python-poetry
243
+ 4. manage.py -> python-django
244
+ 5. requirements.txt + (main.py or app.py) -> python-fastapi
245
+ 6. Cargo.toml -> rust
246
+ 7. go.mod -> go
247
+
248
+ Args:
249
+ project_dir: Path to the project directory.
250
+
251
+ Returns:
252
+ Project type string (e.g., "nodejs-vite", "python-django"),
253
+ or None if no known project type is detected.
254
+ """
255
+ project_dir = Path(project_dir).resolve()
256
+
257
+ if not project_dir.exists() or not project_dir.is_dir():
258
+ logger.debug("Project directory does not exist: %s", project_dir)
259
+ return None
260
+
261
+ # Check for Node.js projects (package.json)
262
+ package_json = _parse_package_json(project_dir)
263
+ if package_json is not None:
264
+ scripts = package_json.get("scripts", {})
265
+ if isinstance(scripts, dict):
266
+ # Check for 'dev' script first (typical for Vite, Next.js, etc.)
267
+ if "dev" in scripts:
268
+ logger.debug("Detected nodejs-vite project in %s", project_dir)
269
+ return "nodejs-vite"
270
+ # Fall back to 'start' script (typical for CRA)
271
+ if "start" in scripts:
272
+ logger.debug("Detected nodejs-cra project in %s", project_dir)
273
+ return "nodejs-cra"
274
+
275
+ # Check for Python Poetry project (must have [tool.poetry] in pyproject.toml)
276
+ if _is_poetry_project(project_dir):
277
+ logger.debug("Detected python-poetry project in %s", project_dir)
278
+ return "python-poetry"
279
+
280
+ # Check for Django project
281
+ if (project_dir / "manage.py").exists():
282
+ logger.debug("Detected python-django project in %s", project_dir)
283
+ return "python-django"
284
+
285
+ # Check for Python FastAPI project (requirements.txt + main.py or app.py)
286
+ if (project_dir / "requirements.txt").exists():
287
+ has_main = (project_dir / "main.py").exists()
288
+ has_app = (project_dir / "app.py").exists()
289
+ if has_main or has_app:
290
+ logger.debug("Detected python-fastapi project in %s", project_dir)
291
+ return "python-fastapi"
292
+
293
+ # Check for Rust project
294
+ if (project_dir / "Cargo.toml").exists():
295
+ logger.debug("Detected rust project in %s", project_dir)
296
+ return "rust"
297
+
298
+ # Check for Go project
299
+ if (project_dir / "go.mod").exists():
300
+ logger.debug("Detected go project in %s", project_dir)
301
+ return "go"
302
+
303
+ logger.debug("No known project type detected in %s", project_dir)
304
+ return None
305
+
306
+
307
+ # =============================================================================
308
+ # Dev Command Functions
309
+ # =============================================================================
310
+
311
+
312
+ def get_default_dev_command(project_dir: Path) -> str | None:
313
+ """
314
+ Get the auto-detected dev command for a project.
315
+
316
+ This returns the default command based on detected project type,
317
+ ignoring any custom command that may be configured.
318
+
319
+ Args:
320
+ project_dir: Path to the project directory.
321
+
322
+ Returns:
323
+ Default dev command string for the detected project type,
324
+ or None if no project type is detected.
325
+ """
326
+ project_type = detect_project_type(project_dir)
327
+
328
+ if project_type is None:
329
+ return None
330
+
331
+ return PROJECT_TYPE_COMMANDS.get(project_type)
332
+
333
+
334
+ def get_dev_command(project_dir: Path) -> str | None:
335
+ """
336
+ Get the effective dev command for a project.
337
+
338
+ Returns the custom command if one is configured,
339
+ otherwise returns the auto-detected default command.
340
+
341
+ Args:
342
+ project_dir: Path to the project directory.
343
+
344
+ Returns:
345
+ The effective dev command (custom if set, else detected),
346
+ or None if neither is available.
347
+ """
348
+ project_dir = Path(project_dir).resolve()
349
+
350
+ # Check for custom command first
351
+ config = _load_config(project_dir)
352
+ custom_command = config.get("dev_command")
353
+
354
+ if custom_command and isinstance(custom_command, str):
355
+ # Type is narrowed to str by isinstance check
356
+ result: str = custom_command
357
+ return result
358
+
359
+ # Fall back to auto-detected command
360
+ return get_default_dev_command(project_dir)
361
+
362
+
363
+ def set_dev_command(project_dir: Path, command: str) -> None:
364
+ """
365
+ Save a custom dev command for a project.
366
+
367
+ Args:
368
+ project_dir: Path to the project directory.
369
+ command: The custom dev command to save.
370
+
371
+ Raises:
372
+ ValueError: If command is empty or not a string, or if project_dir is invalid.
373
+ OSError: If the config file cannot be written.
374
+ """
375
+ if not command or not isinstance(command, str):
376
+ raise ValueError("Command must be a non-empty string")
377
+
378
+ project_dir = _validate_project_dir(project_dir)
379
+
380
+ # Load existing config and update
381
+ config = _load_config(project_dir)
382
+ config["dev_command"] = command
383
+
384
+ _save_config(project_dir, config)
385
+ logger.info("Set custom dev command for %s: %s", project_dir.name, command)
386
+
387
+
388
+ def clear_dev_command(project_dir: Path) -> None:
389
+ """
390
+ Remove the custom dev command, reverting to auto-detection.
391
+
392
+ If no config file exists or no custom command is set,
393
+ this function does nothing (no error is raised).
394
+
395
+ Args:
396
+ project_dir: Path to the project directory.
397
+
398
+ Raises:
399
+ ValueError: If project_dir is not a valid directory.
400
+ """
401
+ project_dir = _validate_project_dir(project_dir)
402
+ config_path = _get_config_path(project_dir)
403
+
404
+ if not config_path.exists():
405
+ return
406
+
407
+ config = _load_config(project_dir)
408
+
409
+ if "dev_command" not in config:
410
+ return
411
+
412
+ del config["dev_command"]
413
+
414
+ # If config is now empty, delete the file
415
+ if not config:
416
+ try:
417
+ config_path.unlink(missing_ok=True)
418
+ logger.info("Removed empty config file for %s", project_dir.name)
419
+
420
+ # Also remove .autoforge directory if empty
421
+ autoforge_dir = config_path.parent
422
+ if autoforge_dir.exists() and not any(autoforge_dir.iterdir()):
423
+ autoforge_dir.rmdir()
424
+ logger.debug("Removed empty .autoforge directory for %s", project_dir.name)
425
+ except OSError as e:
426
+ logger.warning("Failed to clean up config for %s: %s", project_dir.name, e)
427
+ else:
428
+ _save_config(project_dir, config)
429
+
430
+ logger.info("Cleared custom dev command for %s", project_dir.name)
431
+
432
+
433
+ def get_project_config(project_dir: Path) -> ProjectConfig:
434
+ """
435
+ Get the full project configuration including detection results.
436
+
437
+ This provides all relevant configuration information in a single call,
438
+ useful for displaying in a UI or debugging.
439
+
440
+ Args:
441
+ project_dir: Path to the project directory.
442
+
443
+ Returns:
444
+ ProjectConfig dict with:
445
+ - detected_type: The auto-detected project type (or None)
446
+ - detected_command: The default command for detected type (or None)
447
+ - custom_command: The user-configured custom command (or None)
448
+ - effective_command: The command that would actually be used (or None)
449
+
450
+ Raises:
451
+ ValueError: If project_dir is not a valid directory.
452
+ """
453
+ project_dir = _validate_project_dir(project_dir)
454
+
455
+ # Detect project type and get default command
456
+ detected_type = detect_project_type(project_dir)
457
+ detected_command = PROJECT_TYPE_COMMANDS.get(detected_type) if detected_type else None
458
+
459
+ # Load custom command from config
460
+ config = _load_config(project_dir)
461
+ custom_command = config.get("dev_command")
462
+
463
+ # Validate custom_command is a string
464
+ if not isinstance(custom_command, str):
465
+ custom_command = None
466
+
467
+ # Determine effective command
468
+ effective_command = custom_command if custom_command else detected_command
469
+
470
+ return ProjectConfig(
471
+ detected_type=detected_type,
472
+ detected_command=detected_command,
473
+ custom_command=custom_command,
474
+ effective_command=effective_command,
475
+ )