pdd-cli 0.0.90__py3-none-any.whl → 0.0.121__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 (151) hide show
  1. pdd/__init__.py +38 -6
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +506 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +537 -0
  6. pdd/agentic_common.py +533 -770
  7. pdd/agentic_crash.py +2 -1
  8. pdd/agentic_e2e_fix.py +319 -0
  9. pdd/agentic_e2e_fix_orchestrator.py +582 -0
  10. pdd/agentic_fix.py +118 -3
  11. pdd/agentic_update.py +27 -9
  12. pdd/agentic_verify.py +3 -2
  13. pdd/architecture_sync.py +565 -0
  14. pdd/auth_service.py +210 -0
  15. pdd/auto_deps_main.py +63 -53
  16. pdd/auto_include.py +236 -3
  17. pdd/auto_update.py +125 -47
  18. pdd/bug_main.py +195 -23
  19. pdd/cmd_test_main.py +345 -197
  20. pdd/code_generator.py +4 -2
  21. pdd/code_generator_main.py +118 -32
  22. pdd/commands/__init__.py +6 -0
  23. pdd/commands/analysis.py +113 -48
  24. pdd/commands/auth.py +309 -0
  25. pdd/commands/connect.py +358 -0
  26. pdd/commands/fix.py +155 -114
  27. pdd/commands/generate.py +5 -0
  28. pdd/commands/maintenance.py +3 -2
  29. pdd/commands/misc.py +8 -0
  30. pdd/commands/modify.py +225 -163
  31. pdd/commands/sessions.py +284 -0
  32. pdd/commands/utility.py +12 -7
  33. pdd/construct_paths.py +334 -32
  34. pdd/context_generator_main.py +167 -170
  35. pdd/continue_generation.py +6 -3
  36. pdd/core/__init__.py +33 -0
  37. pdd/core/cli.py +44 -7
  38. pdd/core/cloud.py +237 -0
  39. pdd/core/dump.py +68 -20
  40. pdd/core/errors.py +4 -0
  41. pdd/core/remote_session.py +61 -0
  42. pdd/crash_main.py +219 -23
  43. pdd/data/llm_model.csv +4 -4
  44. pdd/docs/prompting_guide.md +864 -0
  45. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  46. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  47. pdd/fix_code_loop.py +208 -34
  48. pdd/fix_code_module_errors.py +6 -2
  49. pdd/fix_error_loop.py +291 -38
  50. pdd/fix_main.py +208 -6
  51. pdd/fix_verification_errors_loop.py +235 -26
  52. pdd/fix_verification_main.py +269 -83
  53. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  54. pdd/frontend/dist/assets/index-CUWd8al1.js +450 -0
  55. pdd/frontend/dist/index.html +376 -0
  56. pdd/frontend/dist/logo.svg +33 -0
  57. pdd/generate_output_paths.py +46 -5
  58. pdd/generate_test.py +212 -151
  59. pdd/get_comment.py +19 -44
  60. pdd/get_extension.py +8 -9
  61. pdd/get_jwt_token.py +309 -20
  62. pdd/get_language.py +8 -7
  63. pdd/get_run_command.py +7 -5
  64. pdd/insert_includes.py +2 -1
  65. pdd/llm_invoke.py +531 -97
  66. pdd/load_prompt_template.py +15 -34
  67. pdd/operation_log.py +342 -0
  68. pdd/path_resolution.py +140 -0
  69. pdd/postprocess.py +122 -97
  70. pdd/preprocess.py +68 -12
  71. pdd/preprocess_main.py +33 -1
  72. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  73. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  74. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  75. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  76. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  77. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  78. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  79. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  80. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  81. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  82. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  83. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  84. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +140 -0
  85. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  86. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  87. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  88. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  89. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  90. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  91. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  92. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  93. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  94. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  95. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  96. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  97. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  98. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  99. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  100. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  101. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  102. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  103. pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
  104. pdd/prompts/agentic_update_LLM.prompt +192 -338
  105. pdd/prompts/auto_include_LLM.prompt +22 -0
  106. pdd/prompts/change_LLM.prompt +3093 -1
  107. pdd/prompts/detect_change_LLM.prompt +571 -14
  108. pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
  109. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
  110. pdd/prompts/generate_test_LLM.prompt +19 -1
  111. pdd/prompts/generate_test_from_example_LLM.prompt +366 -0
  112. pdd/prompts/insert_includes_LLM.prompt +262 -252
  113. pdd/prompts/prompt_code_diff_LLM.prompt +123 -0
  114. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  115. pdd/remote_session.py +876 -0
  116. pdd/server/__init__.py +52 -0
  117. pdd/server/app.py +335 -0
  118. pdd/server/click_executor.py +587 -0
  119. pdd/server/executor.py +338 -0
  120. pdd/server/jobs.py +661 -0
  121. pdd/server/models.py +241 -0
  122. pdd/server/routes/__init__.py +31 -0
  123. pdd/server/routes/architecture.py +451 -0
  124. pdd/server/routes/auth.py +364 -0
  125. pdd/server/routes/commands.py +929 -0
  126. pdd/server/routes/config.py +42 -0
  127. pdd/server/routes/files.py +603 -0
  128. pdd/server/routes/prompts.py +1347 -0
  129. pdd/server/routes/websocket.py +473 -0
  130. pdd/server/security.py +243 -0
  131. pdd/server/terminal_spawner.py +217 -0
  132. pdd/server/token_counter.py +222 -0
  133. pdd/summarize_directory.py +236 -237
  134. pdd/sync_animation.py +8 -4
  135. pdd/sync_determine_operation.py +329 -47
  136. pdd/sync_main.py +272 -28
  137. pdd/sync_orchestration.py +289 -211
  138. pdd/sync_order.py +304 -0
  139. pdd/template_expander.py +161 -0
  140. pdd/templates/architecture/architecture_json.prompt +41 -46
  141. pdd/trace.py +1 -1
  142. pdd/track_cost.py +0 -13
  143. pdd/unfinished_prompt.py +2 -1
  144. pdd/update_main.py +68 -26
  145. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/METADATA +15 -10
  146. pdd_cli-0.0.121.dist-info/RECORD +229 -0
  147. pdd_cli-0.0.90.dist-info/RECORD +0 -153
  148. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/WHEEL +0 -0
  149. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/entry_points.txt +0 -0
  150. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/licenses/LICENSE +0 -0
  151. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/top_level.txt +0 -0
pdd/core/cloud.py ADDED
@@ -0,0 +1,237 @@
1
+ """
2
+ Centralized cloud configuration for PDD CLI commands.
3
+
4
+ Provides consistent cloud URL configuration and JWT token handling
5
+ across all cloud-enabled commands (generate, fix, test, sync, etc.).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ from typing import Optional
13
+
14
+ from rich.console import Console
15
+
16
+ from ..get_jwt_token import (
17
+ AuthError,
18
+ NetworkError,
19
+ RateLimitError,
20
+ TokenError,
21
+ UserCancelledError,
22
+ get_jwt_token as device_flow_get_token,
23
+ )
24
+
25
+ console = Console()
26
+
27
+ # Environment variable names
28
+ FIREBASE_API_KEY_ENV = "NEXT_PUBLIC_FIREBASE_API_KEY"
29
+ GITHUB_CLIENT_ID_ENV = "GITHUB_CLIENT_ID"
30
+ PDD_CLOUD_URL_ENV = "PDD_CLOUD_URL"
31
+ PDD_JWT_TOKEN_ENV = "PDD_JWT_TOKEN"
32
+
33
+ # Default cloud endpoints
34
+ DEFAULT_BASE_URL = "https://us-central1-prompt-driven-development.cloudfunctions.net"
35
+
36
+ # Endpoint paths (can be extended as more endpoints are added)
37
+ CLOUD_ENDPOINTS = {
38
+ "generateCode": "/generateCode",
39
+ "generateExample": "/generateExample",
40
+ "generateTest": "/generateTest",
41
+ "generateBugTest": "/generateBugTest",
42
+ "fixCode": "/fixCode",
43
+ "crashCode": "/crashCode",
44
+ "verifyCode": "/verifyCode",
45
+ "syncState": "/syncState",
46
+ "trackUsage": "/trackUsage",
47
+ "getCreditBalance": "/getCreditBalance",
48
+ "llmInvoke": "/llmInvoke",
49
+ # Remote session endpoints
50
+ "registerSession": "/registerSession",
51
+ "listSessions": "/listSessions",
52
+ "heartbeatSession": "/heartbeatSession",
53
+ "deregisterSession": "/deregisterSession",
54
+ # Command relay endpoints (Firestore message bus)
55
+ "getCommands": "/getCommands",
56
+ "getCommandStatus": "/getCommandStatus",
57
+ "updateCommand": "/updateCommand",
58
+ "cancelCommand": "/cancelCommand",
59
+ }
60
+
61
+
62
+ class CloudConfig:
63
+ """Centralized cloud configuration for all PDD commands."""
64
+
65
+ @staticmethod
66
+ def _ensure_default_env() -> None:
67
+ """Default PDD_ENV for CLI usage when unset."""
68
+ if os.environ.get("PDD_ENV"):
69
+ return
70
+
71
+ # Local/emulator signals should keep PDD_ENV local.
72
+ if (os.environ.get("FUNCTIONS_EMULATOR") or
73
+ os.environ.get("FIREBASE_AUTH_EMULATOR_HOST") or
74
+ os.environ.get("FIREBASE_EMULATOR_HUB")):
75
+ os.environ["PDD_ENV"] = "local"
76
+ return
77
+
78
+ cloud_url = (os.environ.get(PDD_CLOUD_URL_ENV) or "").lower()
79
+ if cloud_url:
80
+ if any(host in cloud_url for host in ("localhost", "127.0.0.1", "0.0.0.0")):
81
+ os.environ["PDD_ENV"] = "local"
82
+ return
83
+ if "prompt-driven-development-stg" in cloud_url or "staging" in cloud_url:
84
+ os.environ["PDD_ENV"] = "staging"
85
+ return
86
+
87
+ # Default to production for typical CLI usage.
88
+ os.environ["PDD_ENV"] = "prod"
89
+
90
+ @staticmethod
91
+ def get_base_url() -> str:
92
+ """Get cloud base URL, allowing override via PDD_CLOUD_URL.
93
+
94
+ For testing against different environments:
95
+ - Local emulator: http://127.0.0.1:5555/prompt-driven-development/us-central1
96
+ - Staging: https://us-central1-prompt-driven-development-stg.cloudfunctions.net
97
+ - Production: (default) https://us-central1-prompt-driven-development.cloudfunctions.net
98
+ """
99
+ custom_url = os.environ.get(PDD_CLOUD_URL_ENV)
100
+ if custom_url:
101
+ # If full URL provided (with endpoint), extract base
102
+ # If base URL provided, use as-is
103
+ return custom_url.rstrip("/")
104
+ return DEFAULT_BASE_URL
105
+
106
+ @staticmethod
107
+ def get_endpoint_url(endpoint_name: str) -> str:
108
+ """Get full URL for a specific cloud endpoint.
109
+
110
+ Args:
111
+ endpoint_name: Name of endpoint (e.g., 'generateCode', 'syncState')
112
+
113
+ Returns:
114
+ Full URL for the endpoint
115
+ """
116
+ base = CloudConfig.get_base_url()
117
+
118
+ # Check if PDD_CLOUD_URL already includes the endpoint
119
+ custom_url = os.environ.get(PDD_CLOUD_URL_ENV, "")
120
+ if endpoint_name in custom_url:
121
+ return custom_url
122
+
123
+ path = CLOUD_ENDPOINTS.get(endpoint_name, f"/{endpoint_name}")
124
+ return f"{base}{path}"
125
+
126
+ @staticmethod
127
+ def get_jwt_token(
128
+ verbose: bool = False,
129
+ app_name: str = "PDD Code Generator"
130
+ ) -> Optional[str]:
131
+ """Get JWT token for cloud authentication.
132
+
133
+ Checks PDD_JWT_TOKEN environment variable first (for testing/CI),
134
+ then falls back to interactive device flow authentication.
135
+
136
+ Args:
137
+ verbose: Whether to print status messages
138
+ app_name: Application name for device flow
139
+
140
+ Returns:
141
+ JWT token string, or None if authentication failed
142
+
143
+ Note:
144
+ Callers should handle None return by falling back to local execution.
145
+ """
146
+ # Default env to prod for typical CLI usage (unless emulator/custom URL says otherwise).
147
+ CloudConfig._ensure_default_env()
148
+
149
+ # Check for pre-injected token (testing/CI)
150
+ injected_token = os.environ.get(PDD_JWT_TOKEN_ENV)
151
+ if injected_token:
152
+ if verbose:
153
+ console.print(f"[info]Using injected JWT token from {PDD_JWT_TOKEN_ENV}[/info]")
154
+ return injected_token
155
+
156
+ # Standard device flow authentication
157
+ try:
158
+ firebase_api_key = os.environ.get(FIREBASE_API_KEY_ENV)
159
+ github_client_id = os.environ.get(GITHUB_CLIENT_ID_ENV)
160
+
161
+ if not firebase_api_key:
162
+ raise AuthError(f"{FIREBASE_API_KEY_ENV} not set.")
163
+ if not github_client_id:
164
+ raise AuthError(f"{GITHUB_CLIENT_ID_ENV} not set.")
165
+
166
+ return asyncio.run(device_flow_get_token(
167
+ firebase_api_key=firebase_api_key,
168
+ github_client_id=github_client_id,
169
+ app_name=app_name
170
+ ))
171
+ except (AuthError, NetworkError, TokenError, UserCancelledError, RateLimitError) as e:
172
+ # Always display auth errors (both these expected ones and the unexpected ones handled below) - critical for debugging auth issues
173
+ console.print(f"[yellow]Cloud authentication error: {e}[/yellow]")
174
+ return None
175
+ except Exception as e:
176
+ # Always display unexpected errors too
177
+ console.print(f"[yellow]Unexpected auth error: {e}[/yellow]")
178
+ return None
179
+
180
+ @staticmethod
181
+ def is_running_in_cloud() -> bool:
182
+ """Check if we're running inside a cloud environment.
183
+
184
+ Detects Google Cloud Functions/Cloud Run via K_SERVICE env var,
185
+ or local emulator via FUNCTIONS_EMULATOR. This prevents infinite
186
+ loops when cloud endpoints call the CLI internally.
187
+ """
188
+ return bool(
189
+ os.environ.get("K_SERVICE") or
190
+ os.environ.get("FUNCTIONS_EMULATOR")
191
+ )
192
+
193
+ @staticmethod
194
+ def is_cloud_enabled() -> bool:
195
+ """Check if cloud features are available.
196
+
197
+ Cloud is enabled if:
198
+ 1. PDD_FORCE_LOCAL is NOT set (respects --local flag), AND
199
+ 2. NOT already running inside a cloud environment (prevents infinite loops), AND
200
+ 3. Either:
201
+ a. PDD_JWT_TOKEN is set (injected token for testing/CI), OR
202
+ b. Both FIREBASE_API_KEY and GITHUB_CLIENT_ID are set (for device flow auth)
203
+ """
204
+ # Respect --local flag (sets PDD_FORCE_LOCAL=1)
205
+ if os.environ.get("PDD_FORCE_LOCAL"):
206
+ return False
207
+
208
+ # CRITICAL: Never enable cloud mode when already running in cloud
209
+ # This prevents infinite loops when cloud endpoints call CLI internally
210
+ if CloudConfig.is_running_in_cloud():
211
+ return False
212
+
213
+ # Check for injected token first (testing/CI scenario)
214
+ if os.environ.get(PDD_JWT_TOKEN_ENV):
215
+ return True
216
+ # Check for device flow auth credentials
217
+ return bool(
218
+ os.environ.get(FIREBASE_API_KEY_ENV) and
219
+ os.environ.get(GITHUB_CLIENT_ID_ENV)
220
+ )
221
+
222
+
223
+ # Re-export exception classes for convenience
224
+ __all__ = [
225
+ 'CloudConfig',
226
+ 'AuthError',
227
+ 'NetworkError',
228
+ 'TokenError',
229
+ 'UserCancelledError',
230
+ 'RateLimitError',
231
+ 'FIREBASE_API_KEY_ENV',
232
+ 'GITHUB_CLIENT_ID_ENV',
233
+ 'PDD_CLOUD_URL_ENV',
234
+ 'PDD_JWT_TOKEN_ENV',
235
+ 'DEFAULT_BASE_URL',
236
+ 'CLOUD_ENDPOINTS',
237
+ ]
pdd/core/dump.py CHANGED
@@ -16,6 +16,43 @@ import requests
16
16
  from .. import __version__
17
17
  from .errors import console, get_core_dump_errors
18
18
 
19
+
20
+ def garbage_collect_core_dumps(keep: int = 10) -> int:
21
+ """Delete old core dumps, keeping only the most recent `keep` files.
22
+
23
+ Core dumps are sorted by modification time (mtime), and the oldest
24
+ files beyond the `keep` limit are deleted.
25
+
26
+ Args:
27
+ keep: Number of core dump files to keep. Default is 10.
28
+
29
+ Returns:
30
+ The number of deleted files.
31
+ """
32
+ core_dump_dir = Path.cwd() / ".pdd" / "core_dumps"
33
+ if not core_dump_dir.exists():
34
+ return 0
35
+
36
+ # Find all core dump files and sort by mtime (newest first)
37
+ dumps = sorted(
38
+ core_dump_dir.glob("pdd-core-*.json"),
39
+ key=lambda p: p.stat().st_mtime,
40
+ reverse=True
41
+ )
42
+
43
+ # Delete files beyond the keep limit
44
+ deleted = 0
45
+ for dump_file in dumps[keep:]:
46
+ try:
47
+ dump_file.unlink()
48
+ deleted += 1
49
+ except OSError:
50
+ # If we can't delete a file, just skip it
51
+ pass
52
+
53
+ return deleted
54
+
55
+
19
56
  def _write_core_dump(
20
57
  ctx: click.Context,
21
58
  normalized_results: List[Any],
@@ -70,8 +107,8 @@ def _write_core_dump(
70
107
  file_contents = {}
71
108
  core_dump_files = ctx.obj.get("core_dump_files", set())
72
109
 
73
- if not ctx.obj.get("quiet"):
74
- console.print(f"[info]Core dump: Found {len(core_dump_files)} tracked files[/info]")
110
+ if ctx.obj.get("verbose") and not ctx.obj.get("quiet"):
111
+ console.print(f"[info]Debug snapshot: Found {len(core_dump_files)} tracked files[/info]")
75
112
 
76
113
  # Auto-include relevant meta files for the invoked commands
77
114
  meta_dir = Path.cwd() / ".pdd" / "meta"
@@ -100,8 +137,8 @@ def _write_core_dump(
100
137
  for file_path in core_dump_files:
101
138
  try:
102
139
  path = Path(file_path)
103
- if not ctx.obj.get("quiet"):
104
- console.print(f"[info]Core dump: Checking file {file_path}[/info]")
140
+ if ctx.obj.get("verbose") and not ctx.obj.get("quiet"):
141
+ console.print(f"[info]Debug snapshot: Checking file {file_path}[/info]")
105
142
 
106
143
  if path.exists() and path.is_file():
107
144
  if path.stat().st_size < 50000: # 50KB limit
@@ -113,23 +150,23 @@ def _write_core_dump(
113
150
  key = str(path)
114
151
 
115
152
  file_contents[key] = path.read_text(encoding='utf-8')
116
- if not ctx.obj.get("quiet"):
117
- console.print(f"[info]Core dump: Added content for {key}[/info]")
153
+ if ctx.obj.get("verbose") and not ctx.obj.get("quiet"):
154
+ console.print(f"[info]Debug snapshot: Added content for {key}[/info]")
118
155
  except UnicodeDecodeError:
119
156
  file_contents[str(path)] = "<binary>"
120
- if not ctx.obj.get("quiet"):
121
- console.print(f"[warning]Core dump: Binary file {path}[/warning]")
157
+ if ctx.obj.get("verbose") and not ctx.obj.get("quiet"):
158
+ console.print(f"[warning]Debug snapshot: Binary file {path}[/warning]")
122
159
  else:
123
160
  file_contents[str(path)] = "<too large>"
124
- if not ctx.obj.get("quiet"):
125
- console.print(f"[warning]Core dump: File too large {path}[/warning]")
161
+ if ctx.obj.get("verbose") and not ctx.obj.get("quiet"):
162
+ console.print(f"[warning]Debug snapshot: File too large {path}[/warning]")
126
163
  else:
127
- if not ctx.obj.get("quiet"):
128
- console.print(f"[warning]Core dump: File not found or not a file: {file_path}[/warning]")
164
+ if ctx.obj.get("verbose") and not ctx.obj.get("quiet"):
165
+ console.print(f"[warning]Debug snapshot: File not found or not a file: {file_path}[/warning]")
129
166
  except Exception as e:
130
167
  file_contents[str(file_path)] = f"<error reading file: {e}>"
131
- if not ctx.obj.get("quiet"):
132
- console.print(f"[warning]Core dump: Error reading {file_path}: {e}[/warning]")
168
+ if ctx.obj.get("verbose") and not ctx.obj.get("quiet"):
169
+ console.print(f"[warning]Debug snapshot: Error reading {file_path}: {e}[/warning]")
133
170
 
134
171
  payload: Dict[str, Any] = {
135
172
  "schema_version": 1,
@@ -166,15 +203,26 @@ def _write_core_dump(
166
203
 
167
204
  dump_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
168
205
 
206
+ # Garbage collect old core dumps after writing (Issue #231)
207
+ # This ensures we keep at most N dumps, not N+1
208
+ keep_core_dumps = ctx.obj.get("keep_core_dumps", 10)
209
+ garbage_collect_core_dumps(keep=keep_core_dumps)
210
+
169
211
  if not ctx.obj.get("quiet"):
170
- console.print(
171
- f"[info]Core dump written to [path]{dump_path}[/path]. "
172
- "You can attach this file when reporting a bug.[/info]"
173
- )
212
+ # Check if the dump still exists after GC (may be deleted if keep=0)
213
+ if dump_path.exists():
214
+ console.print(
215
+ f"[info]📦 Debug snapshot saved to [path]{dump_path}[/path] "
216
+ "(attach when reporting bugs)[/info]"
217
+ )
218
+ else:
219
+ console.print(
220
+ "[info]📦 Debug snapshot saved and immediately cleaned up (--keep-core-dumps=0)[/info]"
221
+ )
174
222
  except Exception as exc:
175
- # Never let core dumping itself crash the CLI
223
+ # Never let debug snapshot creation crash the CLI
176
224
  if not ctx.obj.get("quiet"):
177
- console.print(f"[warning]Failed to write core dump: {exc}[/warning]", style="warning")
225
+ console.print(f"[warning]Failed to write debug snapshot: {exc}[/warning]", style="warning")
178
226
 
179
227
 
180
228
  def _get_github_token() -> Optional[str]:
pdd/core/errors.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Error handling logic for PDD CLI.
3
3
  """
4
+ import os
4
5
  import traceback
5
6
  from typing import Any, Dict, List
6
7
  import click
@@ -60,4 +61,7 @@ def handle_error(exception: Exception, command_name: str, quiet: bool):
60
61
  console.print(escape(str(exception)))
61
62
  else:
62
63
  console.print(f" [error]An unexpected error occurred:[/error] {exception}", style="error")
64
+ strict_exit = os.environ.get("PDD_STRICT_EXIT", "").strip().lower() in {"1", "true", "yes", "on"}
65
+ if strict_exit:
66
+ raise SystemExit(1)
63
67
  # Do NOT re-raise e here. Let the command function return None.
@@ -0,0 +1,61 @@
1
+ """
2
+ Utility to detect if running in a remote/SSH session or headless environment.
3
+ """
4
+ import os
5
+ import sys
6
+ from typing import Optional, Tuple
7
+
8
+
9
+ def is_remote_session() -> Tuple[bool, str]:
10
+ """
11
+ Detect if the current session is remote (SSH) or headless.
12
+
13
+ Returns:
14
+ Tuple[bool, str]: (is_remote, reason) where reason explains why
15
+
16
+ Detection criteria:
17
+ 1. SSH environment variables (SSH_CONNECTION, SSH_CLIENT, SSH_TTY)
18
+ 2. DISPLAY not set (headless Linux/Unix)
19
+ 3. WSL without WSLg (Windows Subsystem for Linux)
20
+ """
21
+ # Check SSH environment variables
22
+ if os.environ.get("SSH_CONNECTION"):
23
+ return True, "SSH_CONNECTION environment variable detected"
24
+ if os.environ.get("SSH_CLIENT"):
25
+ return True, "SSH_CLIENT environment variable detected"
26
+ if os.environ.get("SSH_TTY"):
27
+ return True, "SSH_TTY environment variable detected"
28
+
29
+ # Check for headless environment (no DISPLAY on Unix/Linux)
30
+ if sys.platform in ("linux", "linux2") or sys.platform == "darwin":
31
+ if not os.environ.get("DISPLAY"):
32
+ return True, "No DISPLAY environment variable (headless)"
33
+
34
+ # Check for WSL without WSLg (older WSL versions)
35
+ if os.environ.get("WSL_DISTRO_NAME") and not os.environ.get("WAYLAND_DISPLAY"):
36
+ return True, "WSL environment without display server"
37
+
38
+ return False, "Local session with display capability"
39
+
40
+
41
+ def should_skip_browser(explicit_flag: Optional[bool] = None) -> Tuple[bool, str]:
42
+ """
43
+ Determine if browser opening should be skipped.
44
+
45
+ Args:
46
+ explicit_flag: True to force browser, False to force no-browser, None to auto-detect
47
+
48
+ Returns:
49
+ Tuple[bool, str]: (skip_browser, reason)
50
+ """
51
+ if explicit_flag is True:
52
+ return False, "User explicitly requested browser opening (--browser flag)"
53
+ if explicit_flag is False:
54
+ return True, "User explicitly requested no browser (--no-browser flag)"
55
+
56
+ # Auto-detect
57
+ is_remote, reason = is_remote_session()
58
+ if is_remote:
59
+ return True, f"Auto-detected remote session: {reason}"
60
+
61
+ return False, "Local session detected, will attempt browser opening"