pdd-cli 0.0.45__py3-none-any.whl → 0.0.118__py3-none-any.whl

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 (195) hide show
  1. pdd/__init__.py +40 -8
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +497 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +526 -0
  6. pdd/agentic_common.py +598 -0
  7. pdd/agentic_crash.py +534 -0
  8. pdd/agentic_e2e_fix.py +319 -0
  9. pdd/agentic_e2e_fix_orchestrator.py +426 -0
  10. pdd/agentic_fix.py +1294 -0
  11. pdd/agentic_langtest.py +162 -0
  12. pdd/agentic_update.py +387 -0
  13. pdd/agentic_verify.py +183 -0
  14. pdd/architecture_sync.py +565 -0
  15. pdd/auth_service.py +210 -0
  16. pdd/auto_deps_main.py +71 -51
  17. pdd/auto_include.py +245 -5
  18. pdd/auto_update.py +125 -47
  19. pdd/bug_main.py +196 -23
  20. pdd/bug_to_unit_test.py +2 -0
  21. pdd/change_main.py +11 -4
  22. pdd/cli.py +22 -1181
  23. pdd/cmd_test_main.py +350 -150
  24. pdd/code_generator.py +60 -18
  25. pdd/code_generator_main.py +790 -57
  26. pdd/commands/__init__.py +48 -0
  27. pdd/commands/analysis.py +306 -0
  28. pdd/commands/auth.py +309 -0
  29. pdd/commands/connect.py +290 -0
  30. pdd/commands/fix.py +163 -0
  31. pdd/commands/generate.py +257 -0
  32. pdd/commands/maintenance.py +175 -0
  33. pdd/commands/misc.py +87 -0
  34. pdd/commands/modify.py +256 -0
  35. pdd/commands/report.py +144 -0
  36. pdd/commands/sessions.py +284 -0
  37. pdd/commands/templates.py +215 -0
  38. pdd/commands/utility.py +110 -0
  39. pdd/config_resolution.py +58 -0
  40. pdd/conflicts_main.py +8 -3
  41. pdd/construct_paths.py +589 -111
  42. pdd/context_generator.py +10 -2
  43. pdd/context_generator_main.py +175 -76
  44. pdd/continue_generation.py +53 -10
  45. pdd/core/__init__.py +33 -0
  46. pdd/core/cli.py +527 -0
  47. pdd/core/cloud.py +237 -0
  48. pdd/core/dump.py +554 -0
  49. pdd/core/errors.py +67 -0
  50. pdd/core/remote_session.py +61 -0
  51. pdd/core/utils.py +90 -0
  52. pdd/crash_main.py +262 -33
  53. pdd/data/language_format.csv +71 -63
  54. pdd/data/llm_model.csv +20 -18
  55. pdd/detect_change_main.py +5 -4
  56. pdd/docs/prompting_guide.md +864 -0
  57. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  58. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  59. pdd/fix_code_loop.py +523 -95
  60. pdd/fix_code_module_errors.py +6 -2
  61. pdd/fix_error_loop.py +491 -92
  62. pdd/fix_errors_from_unit_tests.py +4 -3
  63. pdd/fix_main.py +278 -21
  64. pdd/fix_verification_errors.py +12 -100
  65. pdd/fix_verification_errors_loop.py +529 -286
  66. pdd/fix_verification_main.py +294 -89
  67. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  68. pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
  69. pdd/frontend/dist/index.html +376 -0
  70. pdd/frontend/dist/logo.svg +33 -0
  71. pdd/generate_output_paths.py +139 -15
  72. pdd/generate_test.py +218 -146
  73. pdd/get_comment.py +19 -44
  74. pdd/get_extension.py +8 -9
  75. pdd/get_jwt_token.py +318 -22
  76. pdd/get_language.py +8 -7
  77. pdd/get_run_command.py +75 -0
  78. pdd/get_test_command.py +68 -0
  79. pdd/git_update.py +70 -19
  80. pdd/incremental_code_generator.py +2 -2
  81. pdd/insert_includes.py +13 -4
  82. pdd/llm_invoke.py +1711 -181
  83. pdd/load_prompt_template.py +19 -12
  84. pdd/path_resolution.py +140 -0
  85. pdd/pdd_completion.fish +25 -2
  86. pdd/pdd_completion.sh +30 -4
  87. pdd/pdd_completion.zsh +79 -4
  88. pdd/postprocess.py +14 -4
  89. pdd/preprocess.py +293 -24
  90. pdd/preprocess_main.py +41 -6
  91. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  92. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  93. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  94. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  95. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  96. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  97. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  98. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  99. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  100. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  101. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  102. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  103. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
  104. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  105. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  106. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  107. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  108. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  109. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  110. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  111. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  112. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  113. pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
  114. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  115. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  116. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  117. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  118. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  119. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  120. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  121. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  122. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  123. pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
  124. pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
  125. pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
  126. pdd/prompts/agentic_update_LLM.prompt +925 -0
  127. pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
  128. pdd/prompts/auto_include_LLM.prompt +122 -905
  129. pdd/prompts/change_LLM.prompt +3093 -1
  130. pdd/prompts/detect_change_LLM.prompt +686 -27
  131. pdd/prompts/example_generator_LLM.prompt +22 -1
  132. pdd/prompts/extract_code_LLM.prompt +5 -1
  133. pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
  134. pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
  135. pdd/prompts/extract_promptline_LLM.prompt +17 -11
  136. pdd/prompts/find_verification_errors_LLM.prompt +6 -0
  137. pdd/prompts/fix_code_module_errors_LLM.prompt +12 -2
  138. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +9 -0
  139. pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
  140. pdd/prompts/generate_test_LLM.prompt +41 -7
  141. pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
  142. pdd/prompts/increase_tests_LLM.prompt +1 -5
  143. pdd/prompts/insert_includes_LLM.prompt +316 -186
  144. pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
  145. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  146. pdd/prompts/trace_LLM.prompt +25 -22
  147. pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
  148. pdd/prompts/update_prompt_LLM.prompt +22 -1
  149. pdd/pytest_output.py +127 -12
  150. pdd/remote_session.py +876 -0
  151. pdd/render_mermaid.py +236 -0
  152. pdd/server/__init__.py +52 -0
  153. pdd/server/app.py +335 -0
  154. pdd/server/click_executor.py +587 -0
  155. pdd/server/executor.py +338 -0
  156. pdd/server/jobs.py +661 -0
  157. pdd/server/models.py +241 -0
  158. pdd/server/routes/__init__.py +31 -0
  159. pdd/server/routes/architecture.py +451 -0
  160. pdd/server/routes/auth.py +364 -0
  161. pdd/server/routes/commands.py +929 -0
  162. pdd/server/routes/config.py +42 -0
  163. pdd/server/routes/files.py +603 -0
  164. pdd/server/routes/prompts.py +1322 -0
  165. pdd/server/routes/websocket.py +473 -0
  166. pdd/server/security.py +243 -0
  167. pdd/server/terminal_spawner.py +209 -0
  168. pdd/server/token_counter.py +222 -0
  169. pdd/setup_tool.py +648 -0
  170. pdd/simple_math.py +2 -0
  171. pdd/split_main.py +3 -2
  172. pdd/summarize_directory.py +237 -195
  173. pdd/sync_animation.py +8 -4
  174. pdd/sync_determine_operation.py +839 -112
  175. pdd/sync_main.py +351 -57
  176. pdd/sync_orchestration.py +1400 -756
  177. pdd/sync_tui.py +848 -0
  178. pdd/template_expander.py +161 -0
  179. pdd/template_registry.py +264 -0
  180. pdd/templates/architecture/architecture_json.prompt +237 -0
  181. pdd/templates/generic/generate_prompt.prompt +174 -0
  182. pdd/trace.py +168 -12
  183. pdd/trace_main.py +4 -3
  184. pdd/track_cost.py +140 -63
  185. pdd/unfinished_prompt.py +51 -4
  186. pdd/update_main.py +567 -67
  187. pdd/update_model_costs.py +2 -2
  188. pdd/update_prompt.py +19 -4
  189. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +29 -11
  190. pdd_cli-0.0.118.dist-info/RECORD +227 -0
  191. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +1 -1
  192. pdd_cli-0.0.45.dist-info/RECORD +0 -116
  193. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
  194. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
  195. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,929 @@
1
+ """
2
+ REST API endpoints for PDD command execution.
3
+
4
+ Provides endpoints for submitting, monitoring, and canceling CLI commands
5
+ through asynchronous job management.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional, Tuple
21
+
22
+ from fastapi import APIRouter, Depends, HTTPException, Query
23
+
24
+ try:
25
+ from rich.console import Console
26
+ console = Console()
27
+ except ImportError:
28
+ class Console:
29
+ def print(self, *args, **kwargs):
30
+ import builtins
31
+ builtins.print(*args)
32
+ console = Console()
33
+
34
+ from pydantic import BaseModel
35
+
36
+ from ..models import CommandRequest, JobHandle, JobResult, JobStatus
37
+ from ..jobs import JobManager
38
+ from ..click_executor import ClickCommandExecutor, get_pdd_command
39
+
40
+ # Import construct_paths functions for smart output path detection
41
+ from ...construct_paths import (
42
+ detect_context_for_file,
43
+ )
44
+
45
+
46
+ class RunResult(BaseModel):
47
+ """Result of running a command in terminal mode."""
48
+ success: bool
49
+ message: str
50
+ exit_code: int = 0
51
+ stdout: Optional[str] = None
52
+ stderr: Optional[str] = None
53
+ error_details: Optional[str] = None
54
+
55
+
56
+ class CancelResult(BaseModel):
57
+ """Result of cancelling a command."""
58
+ cancelled: bool
59
+ message: str
60
+
61
+
62
+ # Track current running process for cancellation
63
+ class ProcessTracker:
64
+ """Thread-safe tracker for the currently running command process."""
65
+
66
+ def __init__(self):
67
+ self._process: Optional[subprocess.Popen] = None
68
+ self._lock = threading.Lock()
69
+ self._command_info: Optional[str] = None
70
+
71
+ def set_process(self, process: subprocess.Popen, command_info: str):
72
+ with self._lock:
73
+ self._process = process
74
+ self._command_info = command_info
75
+
76
+ def clear_process(self):
77
+ with self._lock:
78
+ self._process = None
79
+ self._command_info = None
80
+
81
+ def cancel(self) -> Tuple[bool, str]:
82
+ """Cancel the current process if running. Returns (success, message)."""
83
+ with self._lock:
84
+ if self._process is None:
85
+ return False, "No command is currently running"
86
+
87
+ try:
88
+ # Try graceful termination first
89
+ self._process.terminate()
90
+
91
+ # Give it a moment to terminate gracefully
92
+ try:
93
+ self._process.wait(timeout=2)
94
+ except subprocess.TimeoutExpired:
95
+ # Force kill if it doesn't respond
96
+ self._process.kill()
97
+
98
+ cmd_info = self._command_info or "unknown command"
99
+ self._process = None
100
+ self._command_info = None
101
+ return True, f"Cancelled: {cmd_info}"
102
+
103
+ except Exception as e:
104
+ return False, f"Failed to cancel: {str(e)}"
105
+
106
+ def is_running(self) -> bool:
107
+ with self._lock:
108
+ return self._process is not None and self._process.poll() is None
109
+
110
+ def get_command_info(self) -> Optional[str]:
111
+ with self._lock:
112
+ return self._command_info
113
+
114
+
115
+ # Global process tracker instance
116
+ _process_tracker = ProcessTracker()
117
+
118
+ # Allowed commands whitelist
119
+ ALLOWED_COMMANDS = {
120
+ "sync": "Synchronize prompts with code and tests",
121
+ "update": "Update prompts based on code changes",
122
+ "bug": "Generate unit test from bug (agentic mode)",
123
+ "generate": "Generate code from prompt",
124
+ "test": "Generate unit tests",
125
+ "example": "Generate example code",
126
+ "fix": "Fix code based on test errors",
127
+ "crash": "Fix code based on crash errors",
128
+ "verify": "Verify code against prompt requirements",
129
+ # Advanced operations
130
+ "split": "Split large prompt files into smaller ones",
131
+ "change": "Modify prompts based on change instructions",
132
+ "detect": "Detect which prompts need changes",
133
+ "auto-deps": "Analyze project dependencies and update prompt",
134
+ "conflicts": "Check for conflicts between prompt files",
135
+ "preprocess": "Preprocess prompt file for LLM use",
136
+ }
137
+
138
+
139
+ def _compute_smart_output_path(
140
+ command: str,
141
+ prompt_file: str,
142
+ project_root: Path,
143
+ ) -> Optional[str]:
144
+ """
145
+ Compute the correct output path based on .pddrc context detection.
146
+
147
+ For commands like 'example' and 'test', this:
148
+ 1. Detects which context the prompt file belongs to
149
+ 2. Gets the appropriate output path from context config (e.g., example_output_path)
150
+ 3. Preserves subdirectory structure from the prompt file
151
+
152
+ Example:
153
+ prompt_file: "prompts/server/terminal_spawner_python.prompt"
154
+ context: pdd_cli (example_output_path: "context")
155
+ -> output: "context/server/terminal_spawner_example.py"
156
+
157
+ Args:
158
+ command: The pdd command (e.g., "example", "test")
159
+ prompt_file: Path to the prompt file
160
+ project_root: Project root directory
161
+
162
+ Returns:
163
+ Computed output path, or None if detection fails
164
+ """
165
+ if command not in ("example", "test"):
166
+ return None
167
+
168
+ try:
169
+ # Detect context for the prompt file
170
+ context_name, context_defaults = detect_context_for_file(
171
+ prompt_file,
172
+ repo_root=str(project_root)
173
+ )
174
+
175
+ if not context_name or not context_defaults:
176
+ return None
177
+
178
+ # Get the appropriate output path based on command
179
+ if command == "example":
180
+ base_output = context_defaults.get("example_output_path")
181
+ elif command == "test":
182
+ base_output = context_defaults.get("test_output_path")
183
+ else:
184
+ return None
185
+
186
+ if not base_output:
187
+ return None
188
+
189
+ # Compute subdirectory structure from prompt file path
190
+ # e.g., prompts/server/foo.prompt -> server/
191
+ prompt_path = Path(prompt_file)
192
+
193
+ # Try to find "prompts" directory in the path and get relative subdirectory
194
+ path_parts = prompt_path.parts
195
+ subdirs = []
196
+
197
+ for i, part in enumerate(path_parts):
198
+ if part == "prompts":
199
+ # Get subdirectories between "prompts" and the filename
200
+ subdirs = list(path_parts[i+1:-1])
201
+ break
202
+
203
+ # If we found subdirectories, append them to the output path
204
+ if subdirs:
205
+ output_path = os.path.join(base_output, *subdirs)
206
+ # Ensure it ends with / to indicate directory
207
+ if not output_path.endswith('/'):
208
+ output_path += '/'
209
+ return output_path
210
+
211
+ # No subdirectories, just return base output
212
+ return base_output
213
+
214
+ except Exception as e:
215
+ console.print(f"[yellow]Warning: Could not compute smart output path: {e}[/yellow]")
216
+ return None
217
+
218
+
219
+ router = APIRouter(prefix="/api/v1/commands", tags=["commands"])
220
+
221
+ # Dependency injection placeholder - will be overridden by app
222
+ _job_manager: Optional[JobManager] = None
223
+
224
+
225
+ def get_job_manager() -> JobManager:
226
+ """Dependency to get the JobManager instance."""
227
+ if _job_manager is None:
228
+ raise RuntimeError("JobManager not configured")
229
+ return _job_manager
230
+
231
+
232
+ def set_job_manager(manager: JobManager) -> None:
233
+ """Configure the JobManager instance."""
234
+ global _job_manager
235
+ _job_manager = manager
236
+
237
+
238
+ # Project root dependency - will be overridden by app
239
+ _project_root: Optional[Path] = None
240
+
241
+
242
+ def get_project_root() -> Path:
243
+ """Dependency to get the project root path."""
244
+ if _project_root is None:
245
+ # Fallback to cwd if not configured (shouldn't happen in production)
246
+ return Path(os.getcwd())
247
+ return _project_root
248
+
249
+
250
+ def set_project_root(project_root: Path) -> None:
251
+ """Configure the project root path."""
252
+ global _project_root
253
+ _project_root = project_root
254
+
255
+
256
+ # Server port dependency - for terminal spawner callbacks
257
+ _server_port: int = 9876 # Default port
258
+
259
+
260
+ def get_server_port() -> int:
261
+ """Dependency to get the server port for terminal callbacks."""
262
+ return _server_port
263
+
264
+
265
+ def set_server_port(port: int) -> None:
266
+ """Configure the server port."""
267
+ global _server_port
268
+ _server_port = port
269
+
270
+
271
+ @router.post("/execute", response_model=JobHandle)
272
+ async def execute_command(
273
+ request: CommandRequest,
274
+ manager: JobManager = Depends(get_job_manager),
275
+ ):
276
+ """
277
+ Submit a command for execution.
278
+
279
+ Returns immediately with a job handle. Use /jobs/{job_id} to check status.
280
+ """
281
+ # Validate command is allowed
282
+ if request.command not in ALLOWED_COMMANDS:
283
+ raise HTTPException(
284
+ status_code=400,
285
+ detail=f"Unknown command: {request.command}. Allowed: {list(ALLOWED_COMMANDS.keys())}"
286
+ )
287
+
288
+ # Submit job
289
+ job = await manager.submit(
290
+ command=request.command,
291
+ args=request.args,
292
+ options=request.options,
293
+ )
294
+
295
+ return JobHandle(
296
+ job_id=job.id,
297
+ status=job.status,
298
+ created_at=job.created_at,
299
+ )
300
+
301
+
302
+ @router.get("/jobs/{job_id}", response_model=JobResult)
303
+ async def get_job_status(
304
+ job_id: str,
305
+ manager: JobManager = Depends(get_job_manager),
306
+ ):
307
+ """
308
+ Get the status and result of a job.
309
+ """
310
+ job = manager.get_job(job_id)
311
+ if not job:
312
+ raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
313
+
314
+ duration = 0.0
315
+ if job.started_at and job.completed_at:
316
+ duration = (job.completed_at - job.started_at).total_seconds()
317
+ elif job.started_at:
318
+ # Use timezone-aware UTC now
319
+ now = datetime.now(timezone.utc)
320
+ # Handle case where job.started_at might be naive (from legacy code or DB)
321
+ if job.started_at.tzinfo is None:
322
+ now = now.replace(tzinfo=None)
323
+ duration = (now - job.started_at).total_seconds()
324
+
325
+ # For running/queued jobs, provide live output in the result field
326
+ result = job.result
327
+ if job.status in (JobStatus.RUNNING, JobStatus.QUEUED) and result is None:
328
+ # Provide live output while job is running
329
+ result = {
330
+ "stdout": job.live_stdout,
331
+ "stderr": job.live_stderr,
332
+ "exit_code": None, # Not yet available
333
+ }
334
+
335
+ return JobResult(
336
+ job_id=job.id,
337
+ status=job.status,
338
+ result=result,
339
+ error=job.error,
340
+ cost=job.cost,
341
+ duration_seconds=duration,
342
+ completed_at=job.completed_at,
343
+ )
344
+
345
+
346
+ @router.post("/jobs/{job_id}/cancel")
347
+ async def cancel_job(
348
+ job_id: str,
349
+ manager: JobManager = Depends(get_job_manager),
350
+ ):
351
+ """
352
+ Attempt to cancel a running job.
353
+ """
354
+ job = manager.get_job(job_id)
355
+ if not job:
356
+ raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
357
+
358
+ if job.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED):
359
+ raise HTTPException(
360
+ status_code=409,
361
+ detail=f"Job already finished with status: {job.status.value}"
362
+ )
363
+
364
+ cancelled = await manager.cancel(job_id)
365
+ return {
366
+ "cancelled": cancelled,
367
+ "message": "Cancellation requested" if cancelled else "Could not cancel job"
368
+ }
369
+
370
+
371
+ @router.get("/history", response_model=List[JobResult])
372
+ async def get_job_history(
373
+ limit: int = Query(50, description="Maximum number of jobs to return", ge=1, le=200),
374
+ offset: int = Query(0, description="Offset for pagination", ge=0),
375
+ status: Optional[JobStatus] = Query(None, description="Filter by status"),
376
+ manager: JobManager = Depends(get_job_manager),
377
+ ):
378
+ """
379
+ Get job history.
380
+
381
+ Returns jobs ordered by creation time (newest first).
382
+ """
383
+ all_jobs = manager.get_all_jobs()
384
+
385
+ # Filter by status if specified
386
+ if status:
387
+ jobs = [j for j in all_jobs.values() if j.status == status]
388
+ else:
389
+ jobs = list(all_jobs.values())
390
+
391
+ # Sort by created_at descending
392
+ jobs.sort(key=lambda j: j.created_at, reverse=True)
393
+
394
+ # Apply pagination
395
+ jobs = jobs[offset:offset + limit]
396
+
397
+ results = []
398
+ for job in jobs:
399
+ duration = 0.0
400
+ if job.started_at and job.completed_at:
401
+ duration = (job.completed_at - job.started_at).total_seconds()
402
+ elif job.started_at:
403
+ # Use timezone-aware UTC now
404
+ now = datetime.now(timezone.utc)
405
+ # Handle case where job.started_at might be naive
406
+ if job.started_at.tzinfo is None:
407
+ now = now.replace(tzinfo=None)
408
+ duration = (now - job.started_at).total_seconds()
409
+
410
+ results.append(JobResult(
411
+ job_id=job.id,
412
+ status=job.status,
413
+ result=job.result,
414
+ error=job.error,
415
+ cost=job.cost,
416
+ duration_seconds=duration,
417
+ completed_at=job.completed_at,
418
+ ))
419
+
420
+ return results
421
+
422
+
423
+ @router.get("/available")
424
+ async def get_available_commands():
425
+ """
426
+ Get list of available commands with descriptions.
427
+ """
428
+ return [
429
+ {"name": name, "description": desc}
430
+ for name, desc in ALLOWED_COMMANDS.items()
431
+ ]
432
+
433
+
434
+ def _find_pdd_executable() -> Optional[str]:
435
+ """Find the pdd executable path."""
436
+ import shutil
437
+ from pathlib import Path
438
+
439
+ # First try to find 'pdd' in PATH
440
+ pdd_path = shutil.which("pdd")
441
+ if pdd_path:
442
+ return pdd_path
443
+
444
+ # Try to find 'pdd' in the same directory as the Python interpreter
445
+ # This handles virtual environments and conda environments
446
+ python_dir = Path(sys.executable).parent
447
+ pdd_in_python_dir = python_dir / "pdd"
448
+ if pdd_in_python_dir.exists():
449
+ return str(pdd_in_python_dir)
450
+
451
+ # Fallback: None means we need to use the module approach
452
+ return None
453
+
454
+
455
+ # Commands where specific args should be positional (not --options)
456
+ # Order matters! Arguments are added in this order.
457
+ POSITIONAL_ARGS = {
458
+ "sync": ["basename"],
459
+ "generate": ["prompt_file"],
460
+ "test": ["prompt_file", "code_file"],
461
+ "example": ["prompt_file", "code_file"],
462
+ "fix": ["prompt_file", "code_file", "unit_test_files", "error_file"], # pdd fix PROMPT CODE TEST... ERROR
463
+ "bug": ["args"], # Special: 'args' contains the positional arguments
464
+ "update": ["args"], # Special: 'args' contains the positional arguments
465
+ "crash": ["prompt_file", "code_file", "program_file", "error_file"], # pdd crash PROMPT CODE PROGRAM ERROR
466
+ "verify": ["prompt_file", "code_file", "verification_program"], # pdd verify PROMPT CODE VERIFICATION_PROGRAM
467
+ # Advanced operations
468
+ "split": ["input_prompt", "input_code", "example_code"], # pdd split INPUT_PROMPT INPUT_CODE EXAMPLE_CODE
469
+ "change": ["change_prompt_file", "input_code", "input_prompt_file"], # pdd change CHANGE_PROMPT INPUT_CODE [INPUT_PROMPT]
470
+ "detect": ["args"], # Special: 'args' contains [PROMPT_FILES..., CHANGE_FILE]
471
+ "auto-deps": ["prompt_file", "directory_path"], # pdd auto-deps PROMPT DIRECTORY
472
+ "conflicts": ["prompt_file", "prompt2"], # pdd conflicts PROMPT1 PROMPT2
473
+ "preprocess": ["prompt_file"], # pdd preprocess PROMPT
474
+ }
475
+
476
+
477
+ # Global options that must be placed BEFORE the subcommand (defined on cli group)
478
+ GLOBAL_OPTIONS = {
479
+ "force", "strength", "temperature", "time", "verbose", "quiet",
480
+ "output_cost", "review_examples", "local", "context", "list_contexts", "core_dump"
481
+ }
482
+
483
+
484
+ def _build_pdd_command_args(command: str, args: Optional[Dict], options: Optional[Dict]) -> List[str]:
485
+ """Build command line arguments for pdd subprocess.
486
+
487
+ Global options (--force, --strength, etc.) are placed BEFORE the subcommand.
488
+ Command-specific options are placed AFTER the subcommand and positional args.
489
+ """
490
+ pdd_exe = _find_pdd_executable()
491
+
492
+ # Start with just the executable - we'll add global options, then command
493
+ if pdd_exe:
494
+ cmd_args = [pdd_exe]
495
+ else:
496
+ # Fallback: use runpy to run the CLI module with proper argument handling
497
+ cmd_args = [
498
+ sys.executable, "-c",
499
+ "import sys; from pdd.cli import cli; sys.exit(cli())"
500
+ ]
501
+
502
+ # Separate global options from command-specific options
503
+ global_opts: Dict[str, Any] = {}
504
+ cmd_opts: Dict[str, Any] = {}
505
+
506
+ if options:
507
+ for key, value in options.items():
508
+ # Normalize key for comparison (replace hyphens with underscores)
509
+ normalized_key = key.replace("-", "_")
510
+ if normalized_key in GLOBAL_OPTIONS:
511
+ global_opts[key] = value
512
+ else:
513
+ cmd_opts[key] = value
514
+
515
+ # Add global options BEFORE the command
516
+ for key, value in global_opts.items():
517
+ if isinstance(value, bool):
518
+ if value:
519
+ cmd_args.append(f"--{key.replace('_', '-')}")
520
+ elif isinstance(value, (list, tuple)):
521
+ for v in value:
522
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
523
+ elif value is not None:
524
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
525
+
526
+ # Now add the command
527
+ cmd_args.append(command)
528
+
529
+ # Get positional arg names for this command
530
+ positional_names = POSITIONAL_ARGS.get(command, [])
531
+
532
+ if args:
533
+ # First, add positional arguments in order
534
+ for pos_name in positional_names:
535
+ if pos_name in args:
536
+ value = args[pos_name]
537
+ if pos_name == "args" and isinstance(value, (list, tuple)):
538
+ # Special case: 'args' is a list of positional args
539
+ cmd_args.extend(str(v) for v in value)
540
+ elif value is not None:
541
+ cmd_args.append(str(value))
542
+
543
+ # Then, add remaining args as options (--key value)
544
+ for key, value in args.items():
545
+ if key in positional_names:
546
+ continue # Already handled as positional
547
+ if isinstance(value, bool):
548
+ if value:
549
+ cmd_args.append(f"--{key.replace('_', '-')}")
550
+ elif isinstance(value, (list, tuple)):
551
+ for v in value:
552
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
553
+ elif value is not None:
554
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
555
+
556
+ # Add command-specific options (global options were already added before the command)
557
+ if cmd_opts:
558
+ for key, value in cmd_opts.items():
559
+ if isinstance(value, bool):
560
+ if value:
561
+ cmd_args.append(f"--{key.replace('_', '-')}")
562
+ elif isinstance(value, (list, tuple)):
563
+ # Handle array options (e.g., multiple -e KEY=VALUE flags)
564
+ for v in value:
565
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(v)])
566
+ elif value is not None:
567
+ cmd_args.extend([f"--{key.replace('_', '-')}", str(value)])
568
+
569
+ return cmd_args
570
+
571
+
572
+ def _parse_error_details(exit_code: int) -> str:
573
+ """Parse exit code and return user-friendly error details."""
574
+ error_messages = {
575
+ 1: "Command failed - check the terminal output above for details",
576
+ 2: "Command line usage error - invalid arguments or options",
577
+ 126: "Command not executable - permission denied",
578
+ 127: "Command not found - pdd may not be properly installed",
579
+ 128: "Invalid exit argument",
580
+ 130: "Command terminated by Ctrl+C",
581
+ 137: "Command killed (SIGKILL) - possibly out of memory",
582
+ 139: "Segmentation fault",
583
+ 143: "Command terminated (SIGTERM)",
584
+ }
585
+
586
+ # Check for specific PDD exit codes
587
+ if exit_code == 1:
588
+ return "Command failed - this may indicate missing files, authentication issues, or other errors. Check the terminal for details."
589
+
590
+ return error_messages.get(exit_code, f"Command exited with code {exit_code}")
591
+
592
+
593
+ @router.post("/run", response_model=RunResult)
594
+ async def run_command_in_terminal(
595
+ request: CommandRequest,
596
+ project_root: Path = Depends(get_project_root),
597
+ ):
598
+ """
599
+ Execute a command in terminal mode as a subprocess.
600
+
601
+ Output goes directly to the terminal where the server is running.
602
+ This endpoint blocks until the command completes or is cancelled.
603
+
604
+ Use POST /commands/cancel to stop a running command.
605
+ """
606
+ # Validate command is allowed
607
+ if request.command not in ALLOWED_COMMANDS:
608
+ raise HTTPException(
609
+ status_code=400,
610
+ detail=f"Unknown command: {request.command}. Allowed: {list(ALLOWED_COMMANDS.keys())}"
611
+ )
612
+
613
+ # Check if a command is already running
614
+ if _process_tracker.is_running():
615
+ raise HTTPException(
616
+ status_code=409,
617
+ detail=f"A command is already running: {_process_tracker.get_command_info()}"
618
+ )
619
+
620
+ # Build command arguments
621
+ cmd_args = _build_pdd_command_args(request.command, request.args, request.options)
622
+ command_str = f"pdd {request.command}"
623
+
624
+ # Build display command (just 'pdd <cmd> <args>' without full path)
625
+ display_args = cmd_args[1:] if cmd_args[0].endswith('pdd') else cmd_args[3:] # Skip python -c wrapper
626
+ display_cmd = f"pdd {' '.join(display_args)}"
627
+
628
+ # Print separator for visibility in terminal
629
+ console.print(f"\n[bold cyan]{'='*60}[/bold cyan]")
630
+ console.print(f"[bold cyan]Running: {display_cmd}[/bold cyan]")
631
+ console.print(f"[cyan]{'='*60}[/cyan]\n")
632
+
633
+ try:
634
+ # Set environment to disable TUI and enable headless mode
635
+ env = os.environ.copy()
636
+ env['CI'] = '1' # Triggers headless mode in sync
637
+ env['PDD_FORCE'] = '1' # Skip confirmation prompts
638
+ env['TERM'] = 'dumb' # Disable fancy terminal features
639
+
640
+ # Start subprocess - output goes directly to terminal (inherit stdio)
641
+ # Use project_root as cwd to ensure correct .pddrc and context detection
642
+ process = subprocess.Popen(
643
+ cmd_args,
644
+ stdout=None, # Inherit from parent (terminal)
645
+ stderr=None, # Inherit from parent (terminal)
646
+ stdin=None,
647
+ cwd=str(project_root),
648
+ env=env,
649
+ )
650
+
651
+ # Track the process for cancellation
652
+ _process_tracker.set_process(process, command_str)
653
+
654
+ # Wait for completion in a way that allows async cancellation
655
+ def wait_for_process():
656
+ return process.wait()
657
+
658
+ loop = asyncio.get_event_loop()
659
+ exit_code = await loop.run_in_executor(None, wait_for_process)
660
+
661
+ except Exception as e:
662
+ console.print(f"\n[bold red]{'='*60}[/bold red]")
663
+ console.print(f"[bold red]Failed to start command: {str(e)}[/bold red]")
664
+ console.print(f"[red]{'='*60}[/red]\n")
665
+ return RunResult(
666
+ success=False,
667
+ message=f"Failed to start command: {str(e)}",
668
+ exit_code=1,
669
+ )
670
+ finally:
671
+ _process_tracker.clear_process()
672
+
673
+ # Print completion message
674
+ if exit_code == 0:
675
+ console.print(f"\n[bold green]{'='*60}[/bold green]")
676
+ console.print(f"[bold green]Command completed successfully[/bold green]")
677
+ console.print(f"[green]{'='*60}[/green]\n")
678
+ elif exit_code == -signal.SIGTERM or exit_code == -signal.SIGKILL:
679
+ console.print(f"\n[bold yellow]{'='*60}[/bold yellow]")
680
+ console.print(f"[bold yellow]Command was cancelled[/bold yellow]")
681
+ console.print(f"[yellow]{'='*60}[/yellow]\n")
682
+ return RunResult(
683
+ success=False,
684
+ message="Command was cancelled",
685
+ exit_code=exit_code,
686
+ )
687
+ else:
688
+ console.print(f"\n[bold red]{'='*60}[/bold red]")
689
+ console.print(f"[bold red]Command failed (exit code: {exit_code})[/bold red]")
690
+ console.print(f"[red]{'='*60}[/red]\n")
691
+
692
+ # Parse common error patterns based on exit code
693
+ error_details = _parse_error_details(exit_code)
694
+
695
+ return RunResult(
696
+ success=False,
697
+ message=f"Command failed with exit code {exit_code}",
698
+ exit_code=exit_code,
699
+ error_details=error_details,
700
+ )
701
+
702
+ return RunResult(
703
+ success=True,
704
+ message="Command completed successfully",
705
+ exit_code=0,
706
+ )
707
+
708
+
709
+ @router.post("/cancel", response_model=CancelResult)
710
+ async def cancel_current_command():
711
+ """
712
+ Cancel the currently running command.
713
+
714
+ Returns success if a command was cancelled, failure if no command was running.
715
+ """
716
+ cancelled, message = _process_tracker.cancel()
717
+
718
+ if cancelled:
719
+ console.print(f"\n[bold yellow]{'='*60}[/bold yellow]")
720
+ console.print(f"[bold yellow]{message}[/bold yellow]")
721
+ console.print(f"[yellow]{'='*60}[/yellow]\n")
722
+
723
+ return CancelResult(cancelled=cancelled, message=message)
724
+
725
+
726
+ @router.get("/status")
727
+ async def get_command_status():
728
+ """
729
+ Get the status of the current running command.
730
+
731
+ Returns whether a command is running and its info.
732
+ """
733
+ is_running = _process_tracker.is_running()
734
+ command_info = _process_tracker.get_command_info()
735
+
736
+ return {
737
+ "running": is_running,
738
+ "command": command_info,
739
+ }
740
+
741
+
742
+ # ============================================================================
743
+ # Terminal Spawning - Run commands in separate terminal windows
744
+ # ============================================================================
745
+
746
+ from ..terminal_spawner import TerminalSpawner
747
+
748
+
749
+ class SpawnTerminalResponse(BaseModel):
750
+ """Response from spawning a terminal."""
751
+ success: bool
752
+ message: str
753
+ command: str
754
+ platform: str
755
+ job_id: Optional[str] = None
756
+
757
+
758
+ class SpawnedJobCompleteRequest(BaseModel):
759
+ """Request body for completing a spawned job."""
760
+ success: bool
761
+ exit_code: int = 0
762
+
763
+
764
+ class SpawnedJobCompleteResponse(BaseModel):
765
+ """Response from completing a spawned job."""
766
+ updated: bool
767
+ job_id: str
768
+
769
+
770
+ class SpawnedJobStatus(BaseModel):
771
+ """Status of a spawned job."""
772
+ job_id: str
773
+ command: str
774
+ status: str
775
+ started_at: str
776
+ completed_at: Optional[str] = None
777
+ exit_code: Optional[int] = None
778
+
779
+
780
+ # Track spawned jobs in memory
781
+ # Key: job_id, Value: dict with job info
782
+ _spawned_jobs: Dict[str, dict] = {}
783
+
784
+
785
+ @router.post("/spawned-jobs/{job_id}/complete", response_model=SpawnedJobCompleteResponse)
786
+ async def complete_spawned_job(job_id: str, request: SpawnedJobCompleteRequest):
787
+ """
788
+ Called by spawned terminal script when command completes.
789
+
790
+ The shell script in the terminal calls this endpoint via curl
791
+ when the command finishes to report success/failure.
792
+ """
793
+ if job_id in _spawned_jobs:
794
+ _spawned_jobs[job_id]["status"] = "completed" if request.success else "failed"
795
+ _spawned_jobs[job_id]["exit_code"] = request.exit_code
796
+ _spawned_jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
797
+
798
+ status_str = "completed" if request.success else "failed"
799
+ command = _spawned_jobs[job_id].get("command", "unknown")
800
+ console.print(f"[cyan]Spawned job {job_id[:16]}... {status_str} (exit code: {request.exit_code})[/cyan]")
801
+
802
+ # Broadcast to ALL connected WebSocket clients
803
+ # (spawned jobs don't have specific subscribers, so we broadcast to all)
804
+ try:
805
+ from .websocket import emit_spawned_job_complete
806
+ await emit_spawned_job_complete(
807
+ job_id=job_id,
808
+ command=command,
809
+ success=request.success,
810
+ exit_code=request.exit_code
811
+ )
812
+ except ImportError:
813
+ # WebSocket module may not be available in test environment
814
+ pass
815
+
816
+ return SpawnedJobCompleteResponse(updated=True, job_id=job_id)
817
+
818
+ return SpawnedJobCompleteResponse(updated=False, job_id=job_id)
819
+
820
+
821
+ @router.get("/spawned-jobs/{job_id}/status", response_model=SpawnedJobStatus)
822
+ async def get_spawned_job_status(job_id: str):
823
+ """
824
+ Get the status of a spawned job.
825
+
826
+ Frontend polls this endpoint to check if spawned jobs have completed.
827
+ """
828
+ if job_id in _spawned_jobs:
829
+ job = _spawned_jobs[job_id]
830
+ return SpawnedJobStatus(
831
+ job_id=job_id,
832
+ command=job.get("command", "unknown"),
833
+ status=job.get("status", "unknown"),
834
+ started_at=job.get("started_at", ""),
835
+ completed_at=job.get("completed_at"),
836
+ exit_code=job.get("exit_code"),
837
+ )
838
+
839
+ return SpawnedJobStatus(
840
+ job_id=job_id,
841
+ command="unknown",
842
+ status="unknown",
843
+ started_at="",
844
+ )
845
+
846
+
847
+ @router.post("/spawn-terminal", response_model=SpawnTerminalResponse)
848
+ async def spawn_terminal_command(
849
+ request: CommandRequest,
850
+ project_root: Path = Depends(get_project_root),
851
+ server_port: int = Depends(get_server_port),
852
+ ):
853
+ """
854
+ Spawn a PDD command in a new terminal window.
855
+
856
+ The command runs in complete isolation from the server.
857
+ User sees output directly in the new terminal window.
858
+
859
+ This is safer than running commands in the server process because:
860
+ - Each command runs in its own terminal process
861
+ - No risk of WebSocket/subprocess conflicts
862
+ - User can manage terminal windows directly
863
+
864
+ The spawned terminal script will call back to report completion status.
865
+ """
866
+ # Validate command is allowed
867
+ if request.command not in ALLOWED_COMMANDS:
868
+ raise HTTPException(
869
+ status_code=400,
870
+ detail=f"Unknown command: {request.command}. Allowed: {list(ALLOWED_COMMANDS.keys())}"
871
+ )
872
+
873
+ # Generate unique job ID
874
+ job_id = f"spawned-{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}"
875
+
876
+ # Smart output path detection for example/test commands
877
+ # If --output is not already provided, compute it from .pddrc context
878
+ options = dict(request.options) if request.options else {}
879
+ if request.command in ("example", "test") and not options.get("output"):
880
+ prompt_file = request.args.get("prompt_file") if request.args else None
881
+ if prompt_file:
882
+ smart_output = _compute_smart_output_path(
883
+ request.command,
884
+ prompt_file,
885
+ project_root,
886
+ )
887
+ if smart_output:
888
+ options["output"] = smart_output
889
+ console.print(f"[green]Auto-detected output path: {smart_output}[/green]")
890
+
891
+ # Build full command with potentially updated options
892
+ cmd_args = _build_pdd_command_args(request.command, request.args, options)
893
+ cmd_str = " ".join(cmd_args)
894
+
895
+ # Use project root as working directory (not os.getcwd())
896
+ cwd = str(project_root)
897
+
898
+ # Track the job before spawning
899
+ _spawned_jobs[job_id] = {
900
+ "job_id": job_id,
901
+ "command": request.command,
902
+ "status": "running",
903
+ "started_at": datetime.now(timezone.utc).isoformat(),
904
+ "completed_at": None,
905
+ "exit_code": None,
906
+ }
907
+
908
+ # Spawn terminal with job_id and server_port for callback
909
+ success = TerminalSpawner.spawn(
910
+ cmd_str, working_dir=cwd, job_id=job_id, server_port=server_port
911
+ )
912
+
913
+ if success:
914
+ console.print(f"\n[bold cyan]{'='*60}[/bold cyan]")
915
+ console.print(f"[bold cyan]Spawned terminal: pdd {request.command}[/bold cyan]")
916
+ console.print(f"[cyan]Job ID: {job_id}[/cyan]")
917
+ console.print(f"[cyan]{'='*60}[/cyan]\n")
918
+ else:
919
+ console.print(f"\n[bold red]Failed to spawn terminal for: pdd {request.command}[/bold red]")
920
+ # Remove from tracking if spawn failed
921
+ del _spawned_jobs[job_id]
922
+
923
+ return SpawnTerminalResponse(
924
+ success=success,
925
+ message="Terminal window opened" if success else "Failed to open terminal",
926
+ command=request.command,
927
+ platform=sys.platform,
928
+ job_id=job_id if success else None,
929
+ )