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,327 @@
1
+ """
2
+ Assistant Chat Router
3
+ =====================
4
+
5
+ WebSocket and REST endpoints for the read-only project assistant.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from typing import Optional
11
+
12
+ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
13
+ from pydantic import BaseModel
14
+
15
+ from ..services.assistant_chat_session import (
16
+ AssistantChatSession,
17
+ create_session,
18
+ get_session,
19
+ list_sessions,
20
+ remove_session,
21
+ )
22
+ from ..services.assistant_database import (
23
+ create_conversation,
24
+ delete_conversation,
25
+ get_conversation,
26
+ get_conversations,
27
+ )
28
+ from ..utils.project_helpers import get_project_path as _get_project_path
29
+ from ..utils.validation import is_valid_project_name as validate_project_name
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ router = APIRouter(prefix="/api/assistant", tags=["assistant-chat"])
34
+
35
+
36
+ # ============================================================================
37
+ # Pydantic Models
38
+ # ============================================================================
39
+
40
+ class ConversationSummary(BaseModel):
41
+ """Summary of a conversation."""
42
+ id: int
43
+ project_name: str
44
+ title: Optional[str]
45
+ created_at: Optional[str]
46
+ updated_at: Optional[str]
47
+ message_count: int
48
+
49
+
50
+ class ConversationMessageModel(BaseModel):
51
+ """A message within a conversation."""
52
+ id: int
53
+ role: str
54
+ content: str
55
+ timestamp: Optional[str]
56
+
57
+
58
+ class ConversationDetail(BaseModel):
59
+ """Full conversation with messages."""
60
+ id: int
61
+ project_name: str
62
+ title: Optional[str]
63
+ created_at: Optional[str]
64
+ updated_at: Optional[str]
65
+ messages: list[ConversationMessageModel]
66
+
67
+
68
+ class SessionInfo(BaseModel):
69
+ """Active session information."""
70
+ project_name: str
71
+ conversation_id: Optional[int]
72
+ is_active: bool
73
+
74
+
75
+ # ============================================================================
76
+ # REST Endpoints - Conversation Management
77
+ # ============================================================================
78
+
79
+ @router.get("/conversations/{project_name}", response_model=list[ConversationSummary])
80
+ async def list_project_conversations(project_name: str):
81
+ """List all conversations for a project."""
82
+ if not validate_project_name(project_name):
83
+ raise HTTPException(status_code=400, detail="Invalid project name")
84
+
85
+ project_dir = _get_project_path(project_name)
86
+ if not project_dir or not project_dir.exists():
87
+ raise HTTPException(status_code=404, detail="Project not found")
88
+
89
+ conversations = get_conversations(project_dir, project_name)
90
+ return [ConversationSummary(**c) for c in conversations]
91
+
92
+
93
+ @router.get("/conversations/{project_name}/{conversation_id}", response_model=ConversationDetail)
94
+ async def get_project_conversation(project_name: str, conversation_id: int):
95
+ """Get a specific conversation with all messages."""
96
+ if not validate_project_name(project_name):
97
+ raise HTTPException(status_code=400, detail="Invalid project name")
98
+
99
+ project_dir = _get_project_path(project_name)
100
+ if not project_dir or not project_dir.exists():
101
+ raise HTTPException(status_code=404, detail="Project not found")
102
+
103
+ conversation = get_conversation(project_dir, conversation_id)
104
+ if not conversation:
105
+ raise HTTPException(status_code=404, detail="Conversation not found")
106
+
107
+ return ConversationDetail(
108
+ id=conversation["id"],
109
+ project_name=conversation["project_name"],
110
+ title=conversation["title"],
111
+ created_at=conversation["created_at"],
112
+ updated_at=conversation["updated_at"],
113
+ messages=[ConversationMessageModel(**m) for m in conversation["messages"]],
114
+ )
115
+
116
+
117
+ @router.post("/conversations/{project_name}", response_model=ConversationSummary)
118
+ async def create_project_conversation(project_name: str):
119
+ """Create a new conversation for a project."""
120
+ if not validate_project_name(project_name):
121
+ raise HTTPException(status_code=400, detail="Invalid project name")
122
+
123
+ project_dir = _get_project_path(project_name)
124
+ if not project_dir or not project_dir.exists():
125
+ raise HTTPException(status_code=404, detail="Project not found")
126
+
127
+ conversation = create_conversation(project_dir, project_name)
128
+ return ConversationSummary(
129
+ id=int(conversation.id),
130
+ project_name=str(conversation.project_name),
131
+ title=str(conversation.title) if conversation.title else None,
132
+ created_at=conversation.created_at.isoformat() if conversation.created_at else None,
133
+ updated_at=conversation.updated_at.isoformat() if conversation.updated_at else None,
134
+ message_count=0,
135
+ )
136
+
137
+
138
+ @router.delete("/conversations/{project_name}/{conversation_id}")
139
+ async def delete_project_conversation(project_name: str, conversation_id: int):
140
+ """Delete a conversation."""
141
+ if not validate_project_name(project_name):
142
+ raise HTTPException(status_code=400, detail="Invalid project name")
143
+
144
+ project_dir = _get_project_path(project_name)
145
+ if not project_dir or not project_dir.exists():
146
+ raise HTTPException(status_code=404, detail="Project not found")
147
+
148
+ success = delete_conversation(project_dir, conversation_id)
149
+ if not success:
150
+ raise HTTPException(status_code=404, detail="Conversation not found")
151
+
152
+ return {"success": True, "message": "Conversation deleted"}
153
+
154
+
155
+ # ============================================================================
156
+ # REST Endpoints - Session Management
157
+ # ============================================================================
158
+
159
+ @router.get("/sessions", response_model=list[str])
160
+ async def list_active_sessions():
161
+ """List all active assistant sessions."""
162
+ return list_sessions()
163
+
164
+
165
+ @router.get("/sessions/{project_name}", response_model=SessionInfo)
166
+ async def get_session_info(project_name: str):
167
+ """Get information about an active session."""
168
+ if not validate_project_name(project_name):
169
+ raise HTTPException(status_code=400, detail="Invalid project name")
170
+
171
+ session = get_session(project_name)
172
+ if not session:
173
+ raise HTTPException(status_code=404, detail="No active session for this project")
174
+
175
+ return SessionInfo(
176
+ project_name=project_name,
177
+ conversation_id=session.get_conversation_id(),
178
+ is_active=True,
179
+ )
180
+
181
+
182
+ @router.delete("/sessions/{project_name}")
183
+ async def close_session(project_name: str):
184
+ """Close an active session."""
185
+ if not validate_project_name(project_name):
186
+ raise HTTPException(status_code=400, detail="Invalid project name")
187
+
188
+ session = get_session(project_name)
189
+ if not session:
190
+ raise HTTPException(status_code=404, detail="No active session for this project")
191
+
192
+ await remove_session(project_name)
193
+ return {"success": True, "message": "Session closed"}
194
+
195
+
196
+ # ============================================================================
197
+ # WebSocket Endpoint
198
+ # ============================================================================
199
+
200
+ @router.websocket("/ws/{project_name}")
201
+ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
202
+ """
203
+ WebSocket endpoint for assistant chat.
204
+
205
+ Message protocol:
206
+
207
+ Client -> Server:
208
+ - {"type": "start", "conversation_id": int | null} - Start/resume session
209
+ - {"type": "message", "content": "..."} - Send user message
210
+ - {"type": "ping"} - Keep-alive ping
211
+
212
+ Server -> Client:
213
+ - {"type": "conversation_created", "conversation_id": int} - New conversation created
214
+ - {"type": "text", "content": "..."} - Text chunk from Claude
215
+ - {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
216
+ - {"type": "response_done"} - Response complete
217
+ - {"type": "error", "content": "..."} - Error message
218
+ - {"type": "pong"} - Keep-alive pong
219
+ """
220
+ if not validate_project_name(project_name):
221
+ await websocket.close(code=4000, reason="Invalid project name")
222
+ return
223
+
224
+ project_dir = _get_project_path(project_name)
225
+ if not project_dir:
226
+ await websocket.close(code=4004, reason="Project not found in registry")
227
+ return
228
+
229
+ if not project_dir.exists():
230
+ await websocket.close(code=4004, reason="Project directory not found")
231
+ return
232
+
233
+ await websocket.accept()
234
+ logger.info(f"Assistant WebSocket connected for project: {project_name}")
235
+
236
+ session: Optional[AssistantChatSession] = None
237
+
238
+ try:
239
+ while True:
240
+ try:
241
+ data = await websocket.receive_text()
242
+ message = json.loads(data)
243
+ msg_type = message.get("type")
244
+ logger.debug(f"Assistant received message type: {msg_type}")
245
+
246
+ if msg_type == "ping":
247
+ await websocket.send_json({"type": "pong"})
248
+ continue
249
+
250
+ elif msg_type == "start":
251
+ # Get optional conversation_id to resume
252
+ conversation_id = message.get("conversation_id")
253
+ logger.debug(f"Processing start message with conversation_id={conversation_id}")
254
+
255
+ try:
256
+ # Create a new session
257
+ logger.debug(f"Creating session for {project_name}")
258
+ session = await create_session(
259
+ project_name,
260
+ project_dir,
261
+ conversation_id=conversation_id,
262
+ )
263
+ logger.debug("Session created, starting...")
264
+
265
+ # Stream the initial greeting
266
+ async for chunk in session.start():
267
+ if logger.isEnabledFor(logging.DEBUG):
268
+ logger.debug(f"Sending chunk: {chunk.get('type')}")
269
+ await websocket.send_json(chunk)
270
+ logger.debug("Session start complete")
271
+ except Exception as e:
272
+ logger.exception(f"Error starting assistant session for {project_name}")
273
+ await websocket.send_json({
274
+ "type": "error",
275
+ "content": f"Failed to start session: {str(e)}"
276
+ })
277
+
278
+ elif msg_type == "message":
279
+ if not session:
280
+ session = get_session(project_name)
281
+ if not session:
282
+ await websocket.send_json({
283
+ "type": "error",
284
+ "content": "No active session. Send 'start' first."
285
+ })
286
+ continue
287
+
288
+ user_content = message.get("content", "").strip()
289
+ if not user_content:
290
+ await websocket.send_json({
291
+ "type": "error",
292
+ "content": "Empty message"
293
+ })
294
+ continue
295
+
296
+ # Stream Claude's response
297
+ async for chunk in session.send_message(user_content):
298
+ await websocket.send_json(chunk)
299
+
300
+ else:
301
+ await websocket.send_json({
302
+ "type": "error",
303
+ "content": f"Unknown message type: {msg_type}"
304
+ })
305
+
306
+ except json.JSONDecodeError:
307
+ await websocket.send_json({
308
+ "type": "error",
309
+ "content": "Invalid JSON"
310
+ })
311
+
312
+ except WebSocketDisconnect:
313
+ logger.info(f"Assistant chat WebSocket disconnected for {project_name}")
314
+
315
+ except Exception as e:
316
+ logger.exception(f"Assistant chat WebSocket error for {project_name}")
317
+ try:
318
+ await websocket.send_json({
319
+ "type": "error",
320
+ "content": f"Server error: {str(e)}"
321
+ })
322
+ except Exception:
323
+ pass
324
+
325
+ finally:
326
+ # Don't remove session on disconnect - allow resume
327
+ pass
@@ -0,0 +1,309 @@
1
+ """
2
+ Dev Server Router
3
+ =================
4
+
5
+ API endpoints for dev server control (start/stop) and configuration.
6
+ Uses project registry for path lookups and project_config for command detection.
7
+ """
8
+
9
+ import logging
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from fastapi import APIRouter, HTTPException
14
+
15
+ from ..schemas import (
16
+ DevServerActionResponse,
17
+ DevServerConfigResponse,
18
+ DevServerConfigUpdate,
19
+ DevServerStartRequest,
20
+ DevServerStatus,
21
+ )
22
+ from ..services.dev_server_manager import get_devserver_manager
23
+ from ..services.project_config import (
24
+ clear_dev_command,
25
+ get_dev_command,
26
+ get_project_config,
27
+ set_dev_command,
28
+ )
29
+ from ..utils.project_helpers import get_project_path as _get_project_path
30
+ from ..utils.validation import validate_project_name
31
+
32
+ # Add root to path for security module import
33
+ _root = Path(__file__).parent.parent.parent
34
+ if str(_root) not in sys.path:
35
+ sys.path.insert(0, str(_root))
36
+
37
+ from security import extract_commands, get_effective_commands, is_command_allowed
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ router = APIRouter(prefix="/api/projects/{project_name}/devserver", tags=["devserver"])
43
+
44
+
45
+ def get_project_dir(project_name: str) -> Path:
46
+ """
47
+ Get the validated project directory for a project name.
48
+
49
+ Args:
50
+ project_name: Name of the project
51
+
52
+ Returns:
53
+ Path to the project directory
54
+
55
+ Raises:
56
+ HTTPException: If project is not found or directory does not exist
57
+ """
58
+ project_name = validate_project_name(project_name)
59
+ project_dir = _get_project_path(project_name)
60
+
61
+ if not project_dir:
62
+ raise HTTPException(
63
+ status_code=404,
64
+ detail=f"Project '{project_name}' not found in registry"
65
+ )
66
+
67
+ if not project_dir.exists():
68
+ raise HTTPException(
69
+ status_code=404,
70
+ detail=f"Project directory not found: {project_dir}"
71
+ )
72
+
73
+ return project_dir
74
+
75
+
76
+ def get_project_devserver_manager(project_name: str):
77
+ """
78
+ Get the dev server process manager for a project.
79
+
80
+ Args:
81
+ project_name: Name of the project
82
+
83
+ Returns:
84
+ DevServerProcessManager instance for the project
85
+
86
+ Raises:
87
+ HTTPException: If project is not found or directory does not exist
88
+ """
89
+ project_dir = get_project_dir(project_name)
90
+ return get_devserver_manager(project_name, project_dir)
91
+
92
+
93
+ def validate_dev_command(command: str, project_dir: Path) -> None:
94
+ """
95
+ Validate a dev server command against the security allowlist.
96
+
97
+ Extracts all commands from the shell string and checks each against
98
+ the effective allowlist (global + org + project). Raises HTTPException
99
+ if any command is blocked or not allowed.
100
+
101
+ Args:
102
+ command: The shell command string to validate
103
+ project_dir: Project directory for loading project-level allowlists
104
+
105
+ Raises:
106
+ HTTPException 400: If the command fails validation
107
+ """
108
+ commands = extract_commands(command)
109
+ if not commands:
110
+ raise HTTPException(
111
+ status_code=400,
112
+ detail="Could not parse command for security validation"
113
+ )
114
+
115
+ allowed_commands, blocked_commands = get_effective_commands(project_dir)
116
+
117
+ for cmd in commands:
118
+ if cmd in blocked_commands:
119
+ logger.warning("Blocked dev server command '%s' (in blocklist) for project dir %s", cmd, project_dir)
120
+ raise HTTPException(
121
+ status_code=400,
122
+ detail=f"Command '{cmd}' is blocked and cannot be used as a dev server command"
123
+ )
124
+ if not is_command_allowed(cmd, allowed_commands):
125
+ logger.warning("Rejected dev server command '%s' (not in allowlist) for project dir %s", cmd, project_dir)
126
+ raise HTTPException(
127
+ status_code=400,
128
+ detail=f"Command '{cmd}' is not in the allowed commands list"
129
+ )
130
+
131
+
132
+ # ============================================================================
133
+ # Endpoints
134
+ # ============================================================================
135
+
136
+
137
+ @router.get("/status", response_model=DevServerStatus)
138
+ async def get_devserver_status(project_name: str) -> DevServerStatus:
139
+ """
140
+ Get the current status of the dev server for a project.
141
+
142
+ Returns information about whether the dev server is running,
143
+ its process ID, detected URL, and the command used to start it.
144
+ """
145
+ manager = get_project_devserver_manager(project_name)
146
+
147
+ # Run healthcheck to detect crashed processes
148
+ await manager.healthcheck()
149
+
150
+ return DevServerStatus(
151
+ status=manager.status,
152
+ pid=manager.pid,
153
+ url=manager.detected_url,
154
+ command=manager._command,
155
+ started_at=manager.started_at.isoformat() if manager.started_at else None,
156
+ )
157
+
158
+
159
+ @router.post("/start", response_model=DevServerActionResponse)
160
+ async def start_devserver(
161
+ project_name: str,
162
+ request: DevServerStartRequest = DevServerStartRequest(),
163
+ ) -> DevServerActionResponse:
164
+ """
165
+ Start the dev server for a project.
166
+
167
+ If a custom command is provided in the request, it will be used.
168
+ Otherwise, the effective command from the project configuration is used.
169
+
170
+ Args:
171
+ project_name: Name of the project
172
+ request: Optional start request with custom command
173
+
174
+ Returns:
175
+ Response indicating success/failure and current status
176
+ """
177
+ manager = get_project_devserver_manager(project_name)
178
+ project_dir = get_project_dir(project_name)
179
+
180
+ # Determine which command to use
181
+ command: str | None
182
+ if request.command:
183
+ command = request.command
184
+ else:
185
+ command = get_dev_command(project_dir)
186
+
187
+ if not command:
188
+ raise HTTPException(
189
+ status_code=400,
190
+ detail="No dev command available. Configure a custom command or ensure project type can be detected."
191
+ )
192
+
193
+ # Validate command against security allowlist before execution
194
+ validate_dev_command(command, project_dir)
195
+
196
+ # Now command is definitely str and validated
197
+ success, message = await manager.start(command)
198
+
199
+ return DevServerActionResponse(
200
+ success=success,
201
+ status=manager.status,
202
+ message=message,
203
+ )
204
+
205
+
206
+ @router.post("/stop", response_model=DevServerActionResponse)
207
+ async def stop_devserver(project_name: str) -> DevServerActionResponse:
208
+ """
209
+ Stop the dev server for a project.
210
+
211
+ Gracefully terminates the dev server process and all its child processes.
212
+
213
+ Args:
214
+ project_name: Name of the project
215
+
216
+ Returns:
217
+ Response indicating success/failure and current status
218
+ """
219
+ manager = get_project_devserver_manager(project_name)
220
+
221
+ success, message = await manager.stop()
222
+
223
+ return DevServerActionResponse(
224
+ success=success,
225
+ status=manager.status,
226
+ message=message,
227
+ )
228
+
229
+
230
+ @router.get("/config", response_model=DevServerConfigResponse)
231
+ async def get_devserver_config(project_name: str) -> DevServerConfigResponse:
232
+ """
233
+ Get the dev server configuration for a project.
234
+
235
+ Returns information about:
236
+ - detected_type: The auto-detected project type (nodejs-vite, python-django, etc.)
237
+ - detected_command: The default command for the detected type
238
+ - custom_command: Any user-configured custom command
239
+ - effective_command: The command that will actually be used (custom or detected)
240
+
241
+ Args:
242
+ project_name: Name of the project
243
+
244
+ Returns:
245
+ Configuration details for the project's dev server
246
+ """
247
+ project_dir = get_project_dir(project_name)
248
+ config = get_project_config(project_dir)
249
+
250
+ return DevServerConfigResponse(
251
+ detected_type=config["detected_type"],
252
+ detected_command=config["detected_command"],
253
+ custom_command=config["custom_command"],
254
+ effective_command=config["effective_command"],
255
+ )
256
+
257
+
258
+ @router.patch("/config", response_model=DevServerConfigResponse)
259
+ async def update_devserver_config(
260
+ project_name: str,
261
+ update: DevServerConfigUpdate,
262
+ ) -> DevServerConfigResponse:
263
+ """
264
+ Update the dev server configuration for a project.
265
+
266
+ Set custom_command to a string to override the auto-detected command.
267
+ Set custom_command to null/None to clear the custom command and revert
268
+ to using the auto-detected command.
269
+
270
+ Args:
271
+ project_name: Name of the project
272
+ update: Configuration update containing the new custom_command
273
+
274
+ Returns:
275
+ Updated configuration details for the project's dev server
276
+ """
277
+ project_dir = get_project_dir(project_name)
278
+
279
+ # Update the custom command
280
+ if update.custom_command is None:
281
+ # Clear the custom command
282
+ try:
283
+ clear_dev_command(project_dir)
284
+ except ValueError as e:
285
+ raise HTTPException(status_code=400, detail=str(e))
286
+ else:
287
+ # Validate command against security allowlist before persisting
288
+ validate_dev_command(update.custom_command, project_dir)
289
+
290
+ # Set the custom command
291
+ try:
292
+ set_dev_command(project_dir, update.custom_command)
293
+ except ValueError as e:
294
+ raise HTTPException(status_code=400, detail=str(e))
295
+ except OSError as e:
296
+ raise HTTPException(
297
+ status_code=500,
298
+ detail=f"Failed to save configuration: {e}"
299
+ )
300
+
301
+ # Return updated config
302
+ config = get_project_config(project_dir)
303
+
304
+ return DevServerConfigResponse(
305
+ detected_type=config["detected_type"],
306
+ detected_command=config["detected_command"],
307
+ custom_command=config["custom_command"],
308
+ effective_command=config["effective_command"],
309
+ )