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
pdd/server/security.py ADDED
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Callable, List, Optional, Union
7
+
8
+ from fastapi import FastAPI, HTTPException, Request, status, Depends
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+ from rich.console import Console
13
+
14
+ # Initialize Rich console for logging
15
+ console = Console()
16
+
17
+ # Default blacklist patterns
18
+ DEFAULT_BLACKLIST = [
19
+ ".git", ".git/**",
20
+ ".env", ".env.*",
21
+ "node_modules", "node_modules/**",
22
+ "__pycache__", "*.pyc",
23
+ ".DS_Store",
24
+ "credentials*", "*secret*", "*.key", "*.pem",
25
+ "id_rsa*",
26
+ ]
27
+
28
+ class SecurityError(Exception):
29
+ """
30
+ Custom exception for security violations.
31
+ """
32
+ def __init__(self, code: str, message: str):
33
+ self.code = code
34
+ self.message = message
35
+ super().__init__(message)
36
+
37
+
38
+ class PathValidator:
39
+ """
40
+ Validates filesystem paths to prevent directory traversal and access to sensitive files.
41
+ """
42
+
43
+ def __init__(self, project_root: Path, blacklist_patterns: Optional[List[str]] = None):
44
+ """
45
+ Initialize the validator.
46
+
47
+ Args:
48
+ project_root: The absolute root directory of the project.
49
+ blacklist_patterns: Optional list of glob patterns to block.
50
+ If None, uses DEFAULT_BLACKLIST.
51
+ """
52
+ self.project_root = project_root.resolve()
53
+ self.blacklist = blacklist_patterns if blacklist_patterns is not None else DEFAULT_BLACKLIST
54
+
55
+ def validate(self, path: Union[str, Path]) -> Path:
56
+ """
57
+ Resolve and validate that a path is safe to access.
58
+
59
+ Args:
60
+ path: The relative or absolute path to validate.
61
+
62
+ Returns:
63
+ Path: The resolved, absolute path.
64
+
65
+ Raises:
66
+ SecurityError: If the path is invalid, blocked, or traverses outside root.
67
+ """
68
+ try:
69
+ # 1. Construct the full candidate path
70
+ path_obj = Path(path)
71
+
72
+ if path_obj.is_absolute():
73
+ # If absolute, we must ensure it starts with project_root before resolving
74
+ # to catch simple string mismatches, but the real check is relative_to below.
75
+ candidate_path = path_obj
76
+ else:
77
+ # If relative, join with project root
78
+ candidate_path = self.project_root / path_obj
79
+
80
+ # 2. Resolve symlinks and '..' components
81
+ # strict=False allows validating paths for files that don't exist yet (e.g. for writing)
82
+ resolved_path = candidate_path.resolve()
83
+
84
+ # 3. Check for Directory Traversal
85
+ # This raises ValueError if resolved_path is not inside project_root
86
+ try:
87
+ relative_path = resolved_path.relative_to(self.project_root)
88
+ except ValueError:
89
+ console.print(f"[bold red]Security Alert:[/bold red] Path traversal attempt: {path}")
90
+ raise SecurityError(
91
+ code="PATH_TRAVERSAL",
92
+ message="Access denied: Path is outside the project root."
93
+ )
94
+
95
+ # 4. Check Blacklist
96
+ # We check the relative path parts against patterns
97
+ path_str = str(relative_path)
98
+ parts = relative_path.parts
99
+
100
+ for pattern in self.blacklist:
101
+ # Check the full relative path string
102
+ if fnmatch.fnmatch(path_str, pattern):
103
+ self._raise_blacklist_error(path_str, pattern)
104
+
105
+ # Check individual path components (e.g., blocking 'node_modules' anywhere in tree)
106
+ # This handles cases like 'src/node_modules/foo' matching 'node_modules'
107
+ for part in parts:
108
+ if fnmatch.fnmatch(part, pattern):
109
+ self._raise_blacklist_error(path_str, pattern)
110
+
111
+ return resolved_path
112
+
113
+ except SecurityError:
114
+ raise
115
+ except Exception as e:
116
+ console.print(f"[bold red]Error:[/bold red] Invalid path processing: {e}")
117
+ raise SecurityError(code="INVALID_PATH", message=f"Invalid path: {str(e)}")
118
+
119
+ def _raise_blacklist_error(self, path: str, pattern: str):
120
+ console.print(f"[bold red]Security Alert:[/bold red] Blocked access to blacklisted resource: {path} (matched {pattern})")
121
+ raise SecurityError(
122
+ code="BLACKLISTED_PATH",
123
+ message="Access denied: Resource is blacklisted."
124
+ )
125
+
126
+
127
+ def configure_cors(app: FastAPI, allowed_origins: Optional[List[str]] = None) -> None:
128
+ """
129
+ Configure CORS middleware for the FastAPI application.
130
+
131
+ Args:
132
+ app: The FastAPI application instance.
133
+ allowed_origins: List of allowed origins. Defaults to standard local dev ports.
134
+ """
135
+ origins = allowed_origins or [
136
+ "http://localhost:3000",
137
+ "http://127.0.0.1:3000",
138
+ "http://localhost:5173",
139
+ "http://127.0.0.1:5173",
140
+ ]
141
+
142
+ app.add_middleware(
143
+ CORSMiddleware,
144
+ allow_origins=origins,
145
+ allow_credentials=True,
146
+ allow_methods=["*"],
147
+ allow_headers=["*"],
148
+ expose_headers=["X-Job-Id", "X-Total-Chunks"],
149
+ )
150
+ console.print(f"[green]CORS configured for origins:[/green] {origins}")
151
+
152
+
153
+ def create_token_dependency(token: Optional[str]) -> Callable:
154
+ """
155
+ Creates a FastAPI dependency for Bearer token authentication.
156
+
157
+ Args:
158
+ token: The expected secret token. If None, authentication is disabled.
159
+
160
+ Returns:
161
+ A dependency function to be used with Depends().
162
+ """
163
+ security = HTTPBearer(auto_error=False)
164
+
165
+ async def verify_token(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
166
+ if token is None:
167
+ return None
168
+
169
+ if not credentials:
170
+ raise HTTPException(
171
+ status_code=status.HTTP_401_UNAUTHORIZED,
172
+ detail="Missing authentication credentials",
173
+ headers={"WWW-Authenticate": "Bearer"},
174
+ )
175
+
176
+ if credentials.credentials != token:
177
+ console.print("[bold red]Auth Failed:[/bold red] Invalid token provided.")
178
+ raise HTTPException(
179
+ status_code=status.HTTP_401_UNAUTHORIZED,
180
+ detail="Invalid authentication token",
181
+ headers={"WWW-Authenticate": "Bearer"},
182
+ )
183
+
184
+ return credentials
185
+
186
+ return verify_token
187
+
188
+
189
+ class SecurityLoggingMiddleware(BaseHTTPMiddleware):
190
+ """
191
+ Middleware to log requests and provide basic request validation/monitoring.
192
+ """
193
+
194
+ # Endpoints that are polled frequently and should be quiet
195
+ QUIET_ENDPOINTS = [
196
+ "/api/v1/commands/jobs/", # Job status polling
197
+ "/api/v1/commands/spawned-jobs/", # Spawned job status polling
198
+ "/api/v1/prompts", # Prompt listing
199
+ "/api/v1/status", # Health checks
200
+ "/api/v1/auth/jwt-token", # JWT token polling
201
+ "/api/v1/auth/status", # Auth status
202
+ "/api/v1/files/", # File operations
203
+ "/api/v1/config/", # Config endpoints
204
+ "/assets/", # Static assets
205
+ ]
206
+
207
+ # Also skip root and static files
208
+ QUIET_EXACT = ["/", "/index.html", "/favicon.ico"]
209
+
210
+ def _should_log(self, path: str) -> bool:
211
+ """Check if we should log this request (skip noisy polling endpoints)."""
212
+ # Check exact matches first
213
+ if path in self.QUIET_EXACT:
214
+ return False
215
+ # Check prefix/substring matches
216
+ for quiet_path in self.QUIET_ENDPOINTS:
217
+ if quiet_path in path:
218
+ return False
219
+ return True
220
+
221
+ async def dispatch(self, request: Request, call_next):
222
+ start_time = time.time()
223
+ client_host = request.client.host if request.client else "unknown"
224
+ path = request.url.path
225
+ should_log = self._should_log(path)
226
+
227
+ # Log incoming request (skip noisy polling endpoints)
228
+ if should_log:
229
+ console.print(f"[dim]Request:[/dim] {request.method} {path} [dim]from {client_host}[/dim]")
230
+
231
+ response = await call_next(request)
232
+
233
+ process_time = (time.time() - start_time) * 1000
234
+
235
+ # Log response (skip noisy endpoints, but always log errors)
236
+ if should_log or response.status_code >= 400:
237
+ status_color = "green" if response.status_code < 400 else "red"
238
+ console.print(
239
+ f"[dim]Response:[/dim] [{status_color}]{response.status_code}[/{status_color}] "
240
+ f"took {process_time:.2f}ms"
241
+ )
242
+
243
+ return response
@@ -0,0 +1,209 @@
1
+ """
2
+ Cross-platform terminal spawning utilities.
3
+
4
+ Allows spawning new terminal windows to run commands in isolation,
5
+ rather than running them in the same process as the server.
6
+
7
+ Each spawned terminal calls back to the server when the command completes,
8
+ enabling automatic progress tracking in the frontend dashboard.
9
+ """
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+
19
+ # Default server port for callback (must match PDD server port)
20
+ DEFAULT_SERVER_PORT = 9876
21
+
22
+
23
+ class TerminalSpawner:
24
+ """Spawn terminal windows on macOS, Linux, and Windows."""
25
+
26
+ @staticmethod
27
+ def spawn(
28
+ command: str,
29
+ working_dir: Optional[str] = None,
30
+ job_id: Optional[str] = None,
31
+ server_port: int = DEFAULT_SERVER_PORT,
32
+ ) -> bool:
33
+ """
34
+ Spawn a new terminal window and execute command.
35
+
36
+ Args:
37
+ command: Shell command to execute
38
+ working_dir: Optional working directory for the command
39
+ job_id: Optional job ID for tracking - enables completion callback
40
+ server_port: Server port for completion callback (default: 5000)
41
+
42
+ Returns:
43
+ True if terminal was spawned successfully
44
+ """
45
+ if working_dir:
46
+ # Quote the path to handle spaces and special characters
47
+ command = f'cd "{working_dir}" && {command}'
48
+
49
+ platform = sys.platform
50
+
51
+ if platform == "darwin":
52
+ return TerminalSpawner._darwin(command, job_id, server_port)
53
+ elif platform == "linux":
54
+ return TerminalSpawner._linux(command, job_id, server_port)
55
+ elif platform == "win32":
56
+ return TerminalSpawner._windows(command, job_id, server_port)
57
+ return False
58
+
59
+ @staticmethod
60
+ def _darwin(
61
+ command: str,
62
+ job_id: Optional[str] = None,
63
+ server_port: int = DEFAULT_SERVER_PORT,
64
+ ) -> bool:
65
+ """
66
+ macOS: Open Terminal.app with command.
67
+
68
+ Creates a temporary shell script and opens it with Terminal.app.
69
+ The script keeps the terminal open after command completes.
70
+ If job_id is provided, calls back to server with completion status.
71
+ """
72
+ try:
73
+ # Create unique script path to avoid conflicts
74
+ script_path = Path(f"/tmp/pdd_terminal_{os.getpid()}_{id(command)}.sh")
75
+
76
+ # Build callback section if job_id provided
77
+ if job_id:
78
+ callback_section = f'''
79
+ # Report completion to server (must complete before exec bash)
80
+ echo "[DEBUG] Sending callback to http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete"
81
+ echo "[DEBUG] Payload: {{\\"success\\": '$((EXIT_CODE == 0))', \\"exit_code\\": '$EXIT_CODE'}}"
82
+ CURL_RESPONSE=$(curl -s -w "\\n[HTTP_STATUS:%{{http_code}}]" -X POST "http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete" \\
83
+ -H "Content-Type: application/json" \\
84
+ -d '{{"success": '$((EXIT_CODE == 0))', "exit_code": '$EXIT_CODE'}}' 2>&1)
85
+ echo "[DEBUG] Curl response: $CURL_RESPONSE"
86
+
87
+ # Show result to user
88
+ echo ""
89
+ if [ $EXIT_CODE -eq 0 ]; then
90
+ echo -e "\\033[32m✓ Command completed successfully\\033[0m"
91
+ else
92
+ echo -e "\\033[31m✗ Command failed (exit code: $EXIT_CODE)\\033[0m"
93
+ fi
94
+ echo ""
95
+ '''
96
+ else:
97
+ callback_section = ""
98
+
99
+ # Script that runs command and optionally reports status
100
+ script_content = f"""#!/bin/bash
101
+ {command}
102
+ EXIT_CODE=$?
103
+ {callback_section}
104
+ exec bash
105
+ """
106
+ script_path.write_text(script_content)
107
+ script_path.chmod(0o755)
108
+
109
+ # Open with Terminal.app
110
+ subprocess.Popen(["open", "-a", "Terminal", str(script_path)])
111
+ return True
112
+
113
+ except Exception as e:
114
+ print(f"Failed to spawn terminal on macOS: {e}")
115
+ return False
116
+
117
+ @staticmethod
118
+ def _linux(
119
+ command: str,
120
+ job_id: Optional[str] = None,
121
+ server_port: int = DEFAULT_SERVER_PORT,
122
+ ) -> bool:
123
+ """
124
+ Linux: Use gnome-terminal, xfce4-terminal, or konsole.
125
+
126
+ Tries each terminal emulator in order until one works.
127
+ If job_id is provided, calls back to server with completion status.
128
+ """
129
+ try:
130
+ # Build callback section if job_id provided
131
+ if job_id:
132
+ callback_cmd = f'''
133
+ EXIT_CODE=$?
134
+ echo "[DEBUG] Sending callback to http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete"
135
+ CURL_RESPONSE=$(curl -s -w "\\n[HTTP_STATUS:%{{http_code}}]" -X POST "http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete" \
136
+ -H "Content-Type: application/json" \
137
+ -d '{{"success": '$((EXIT_CODE == 0))', "exit_code": '$EXIT_CODE'}}' 2>&1)
138
+ echo "[DEBUG] Curl response: $CURL_RESPONSE"
139
+ echo ""
140
+ if [ $EXIT_CODE -eq 0 ]; then echo -e "\\033[32m✓ Command completed successfully\\033[0m"; else echo -e "\\033[31m✗ Command failed (exit code: $EXIT_CODE)\\033[0m"; fi
141
+ '''
142
+ full_cmd = f"{command}; {callback_cmd}; exec bash"
143
+ else:
144
+ full_cmd = f"{command}; exec bash"
145
+
146
+ terminals = [
147
+ ("gnome-terminal", ["gnome-terminal", "--", "bash", "-c", full_cmd]),
148
+ ("xfce4-terminal", ["xfce4-terminal", "-e", f"bash -c '{full_cmd}'"]),
149
+ ("konsole", ["konsole", "-e", "bash", "-c", full_cmd]),
150
+ ("xterm", ["xterm", "-e", "bash", "-c", full_cmd]),
151
+ ]
152
+
153
+ for term_name, args in terminals:
154
+ if shutil.which(term_name):
155
+ subprocess.Popen(args)
156
+ return True
157
+
158
+ print("No supported terminal emulator found on Linux")
159
+ return False
160
+
161
+ except Exception as e:
162
+ print(f"Failed to spawn terminal on Linux: {e}")
163
+ return False
164
+
165
+ @staticmethod
166
+ def _windows(
167
+ command: str,
168
+ job_id: Optional[str] = None,
169
+ server_port: int = DEFAULT_SERVER_PORT,
170
+ ) -> bool:
171
+ """
172
+ Windows: Use Windows Terminal or PowerShell.
173
+
174
+ Tries Windows Terminal first, falls back to PowerShell.
175
+ If job_id is provided, calls back to server with completion status.
176
+ """
177
+ try:
178
+ # Build callback section if job_id provided
179
+ if job_id:
180
+ callback_cmd = f'''
181
+ $exitCode = $LASTEXITCODE
182
+ $success = if ($exitCode -eq 0) {{ "true" }} else {{ "false" }}
183
+ Invoke-RestMethod -Uri "http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete" -Method Post -ContentType "application/json" -Body ('{{"success": ' + $success + ', "exit_code": ' + $exitCode + '}}') -ErrorAction SilentlyContinue
184
+ Write-Host ""
185
+ if ($exitCode -eq 0) {{ Write-Host "✓ Command completed successfully" -ForegroundColor Green }} else {{ Write-Host "✗ Command failed (exit code: $exitCode)" -ForegroundColor Red }}
186
+ '''
187
+ full_cmd = f"{command}; {callback_cmd}"
188
+ else:
189
+ full_cmd = command
190
+
191
+ # Try Windows Terminal first (modern)
192
+ try:
193
+ subprocess.Popen([
194
+ "wt.exe", "new-tab",
195
+ "powershell", "-NoExit", "-Command", full_cmd
196
+ ])
197
+ return True
198
+ except FileNotFoundError:
199
+ pass
200
+
201
+ # Fallback to PowerShell directly
202
+ subprocess.Popen([
203
+ "powershell.exe", "-NoExit", "-Command", full_cmd
204
+ ])
205
+ return True
206
+
207
+ except Exception as e:
208
+ print(f"Failed to spawn terminal on Windows: {e}")
209
+ return False
@@ -0,0 +1,222 @@
1
+ """
2
+ Token counting and cost estimation utilities.
3
+
4
+ Uses tiktoken for local token estimation without API calls.
5
+ Loads model pricing from .pdd/llm_model.csv.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ from dataclasses import dataclass
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+ from typing import Optional, Dict
15
+
16
+ import tiktoken
17
+
18
+ # Default context limits by model family (tokens)
19
+ MODEL_CONTEXT_LIMITS = {
20
+ "gpt-4": 128000,
21
+ "gpt-5": 200000,
22
+ "claude-3": 200000,
23
+ "claude-sonnet-4": 200000,
24
+ "claude-opus-4": 200000,
25
+ "claude-haiku-4": 200000,
26
+ "gemini-2": 1000000,
27
+ "gemini-3": 1000000,
28
+ "default": 128000,
29
+ }
30
+
31
+ # Tiktoken encodings - use cl100k_base for most modern models
32
+ ENCODING_NAME = "cl100k_base"
33
+
34
+
35
+ @dataclass
36
+ class CostEstimate:
37
+ """Cost estimation result."""
38
+ input_cost: float
39
+ model: str
40
+ tokens: int
41
+ cost_per_million: float
42
+ currency: str = "USD"
43
+
44
+ def to_dict(self) -> Dict:
45
+ return {
46
+ "input_cost": round(self.input_cost, 6),
47
+ "model": self.model,
48
+ "tokens": self.tokens,
49
+ "cost_per_million": self.cost_per_million,
50
+ "currency": self.currency,
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class TokenMetrics:
56
+ """Combined token metrics result."""
57
+ token_count: int
58
+ context_limit: int
59
+ context_usage_percent: float
60
+ cost_estimate: Optional[CostEstimate]
61
+
62
+ def to_dict(self) -> Dict:
63
+ return {
64
+ "token_count": self.token_count,
65
+ "context_limit": self.context_limit,
66
+ "context_usage_percent": round(self.context_usage_percent, 2),
67
+ "cost_estimate": self.cost_estimate.to_dict() if self.cost_estimate else None,
68
+ }
69
+
70
+
71
+ @lru_cache(maxsize=1)
72
+ def _get_encoding() -> tiktoken.Encoding:
73
+ """Get tiktoken encoding (cached)."""
74
+ return tiktoken.get_encoding(ENCODING_NAME)
75
+
76
+
77
+ def count_tokens(text: str) -> int:
78
+ """
79
+ Count tokens in text using tiktoken.
80
+
81
+ Args:
82
+ text: The text to count tokens for
83
+
84
+ Returns:
85
+ Token count
86
+ """
87
+ if not text:
88
+ return 0
89
+
90
+ encoding = _get_encoding()
91
+ return len(encoding.encode(text))
92
+
93
+
94
+ def get_context_limit(model: str) -> int:
95
+ """
96
+ Get the context limit for a model.
97
+
98
+ Args:
99
+ model: Model name
100
+
101
+ Returns:
102
+ Context limit in tokens
103
+ """
104
+ model_lower = model.lower()
105
+
106
+ for prefix, limit in MODEL_CONTEXT_LIMITS.items():
107
+ if prefix in model_lower:
108
+ return limit
109
+
110
+ return MODEL_CONTEXT_LIMITS["default"]
111
+
112
+
113
+ @lru_cache(maxsize=1)
114
+ def _load_model_pricing(csv_path: str) -> Dict[str, float]:
115
+ """Load model pricing from CSV (cached)."""
116
+ pricing = {}
117
+
118
+ try:
119
+ with open(csv_path, 'r') as f:
120
+ reader = csv.DictReader(f)
121
+ for row in reader:
122
+ model = row.get('model', '')
123
+ input_cost = row.get('input', '0')
124
+ try:
125
+ pricing[model] = float(input_cost)
126
+ except ValueError:
127
+ continue
128
+ except (FileNotFoundError, PermissionError):
129
+ pass
130
+
131
+ return pricing
132
+
133
+
134
+ def estimate_cost(
135
+ token_count: int,
136
+ model: str,
137
+ pricing_csv: Optional[Path] = None
138
+ ) -> Optional[CostEstimate]:
139
+ """
140
+ Estimate the input cost for a given token count.
141
+
142
+ Args:
143
+ token_count: Number of input tokens
144
+ model: Model name
145
+ pricing_csv: Path to llm_model.csv (optional)
146
+
147
+ Returns:
148
+ CostEstimate or None if pricing not found
149
+ """
150
+ if pricing_csv is None or not pricing_csv.exists():
151
+ return None
152
+
153
+ pricing = _load_model_pricing(str(pricing_csv))
154
+
155
+ if not pricing:
156
+ return None
157
+
158
+ # Find matching model
159
+ cost_per_million = None
160
+ matched_model = model
161
+
162
+ # Try exact match first
163
+ if model in pricing:
164
+ cost_per_million = pricing[model]
165
+ else:
166
+ # Try partial match
167
+ model_lower = model.lower()
168
+ for csv_model, cost in pricing.items():
169
+ if model_lower in csv_model.lower() or csv_model.lower() in model_lower:
170
+ cost_per_million = cost
171
+ matched_model = csv_model
172
+ break
173
+
174
+ if cost_per_million is None:
175
+ # Use a default model for estimation
176
+ for default_model in ['claude-sonnet-4-20250514', 'gpt-4o', 'claude-3-5-sonnet-latest']:
177
+ if default_model in pricing:
178
+ cost_per_million = pricing[default_model]
179
+ matched_model = default_model
180
+ break
181
+
182
+ if cost_per_million is None:
183
+ return None
184
+
185
+ # Calculate cost (pricing is per million tokens)
186
+ input_cost = (token_count / 1_000_000) * cost_per_million
187
+
188
+ return CostEstimate(
189
+ input_cost=input_cost,
190
+ model=matched_model,
191
+ tokens=token_count,
192
+ cost_per_million=cost_per_million,
193
+ )
194
+
195
+
196
+ def get_token_metrics(
197
+ text: str,
198
+ model: str = "claude-sonnet-4-20250514",
199
+ pricing_csv: Optional[Path] = None
200
+ ) -> TokenMetrics:
201
+ """
202
+ Get comprehensive token metrics for text.
203
+
204
+ Args:
205
+ text: The text to analyze
206
+ model: Model name
207
+ pricing_csv: Path to pricing CSV
208
+
209
+ Returns:
210
+ TokenMetrics with count, context usage, and cost
211
+ """
212
+ token_count = count_tokens(text)
213
+ context_limit = get_context_limit(model)
214
+ context_usage = (token_count / context_limit) * 100 if context_limit > 0 else 0
215
+ cost = estimate_cost(token_count, model, pricing_csv)
216
+
217
+ return TokenMetrics(
218
+ token_count=token_count,
219
+ context_limit=context_limit,
220
+ context_usage_percent=context_usage,
221
+ cost_estimate=cost,
222
+ )