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/commands/auth.py ADDED
@@ -0,0 +1,309 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Optional, Dict, Any
11
+
12
+ import click
13
+ from rich.console import Console
14
+
15
+ # Internal imports
16
+ try:
17
+ from ..auth_service import (
18
+ get_auth_status,
19
+ logout as service_logout,
20
+ JWT_CACHE_FILE,
21
+ )
22
+ from ..get_jwt_token import (
23
+ get_jwt_token,
24
+ AuthError,
25
+ NetworkError,
26
+ TokenError,
27
+ UserCancelledError,
28
+ RateLimitError,
29
+ )
30
+ except ImportError:
31
+ pass
32
+
33
+ console = Console()
34
+
35
+ # Constants
36
+ PDD_ENV = os.environ.get("PDD_ENV", "local")
37
+
38
+
39
+ def _load_firebase_api_key() -> str:
40
+ """Load the Firebase API key from environment or .env files."""
41
+ # 1. Check direct env var
42
+ env_key = os.environ.get("NEXT_PUBLIC_FIREBASE_API_KEY")
43
+ if env_key:
44
+ return env_key
45
+
46
+ # 2. Check .env files in current directory
47
+ candidates = [Path(".env"), Path(".env.local")]
48
+
49
+ for candidate in candidates:
50
+ if candidate.exists():
51
+ try:
52
+ content = candidate.read_text(encoding="utf-8")
53
+ for line in content.splitlines():
54
+ if line.strip().startswith("NEXT_PUBLIC_FIREBASE_API_KEY="):
55
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
56
+ except Exception:
57
+ continue
58
+
59
+ return ""
60
+
61
+
62
+ def _get_client_id() -> Optional[str]:
63
+ """Get the GitHub Client ID for the current environment."""
64
+ return os.environ.get(f"GITHUB_CLIENT_ID_{PDD_ENV.upper()}") or os.environ.get("GITHUB_CLIENT_ID")
65
+
66
+
67
+ def _decode_jwt_payload(token: str) -> Dict[str, Any]:
68
+ """Decode JWT payload without verification to extract claims."""
69
+ try:
70
+ # JWT is header.payload.signature
71
+ parts = token.split(".")
72
+ if len(parts) != 3:
73
+ return {}
74
+
75
+ payload = parts[1]
76
+ # Add padding if needed
77
+ padding = len(payload) % 4
78
+ if padding:
79
+ payload += "=" * (4 - padding)
80
+
81
+ decoded = base64.urlsafe_b64decode(payload)
82
+ return json.loads(decoded)
83
+ except Exception:
84
+ return {}
85
+
86
+
87
+ @click.group("auth")
88
+ def auth_group():
89
+ """Manage PDD Cloud authentication."""
90
+ pass
91
+
92
+
93
+ @auth_group.command("login")
94
+ @click.option(
95
+ "--browser/--no-browser",
96
+ default=None,
97
+ help="Control browser opening (auto-detect if not specified)"
98
+ )
99
+ def login(browser: Optional[bool]):
100
+ """Authenticate with PDD Cloud via GitHub."""
101
+
102
+ api_key = _load_firebase_api_key()
103
+ if not api_key:
104
+ console.print("[red]Error: NEXT_PUBLIC_FIREBASE_API_KEY not found.[/red]")
105
+ console.print("Please set it in your environment or .env file.")
106
+ sys.exit(1)
107
+
108
+ client_id = _get_client_id()
109
+ app_name = "PDD CLI"
110
+
111
+ async def run_login():
112
+ try:
113
+ # Import remote session detection
114
+ from ..core.remote_session import should_skip_browser
115
+
116
+ # Determine if browser should be skipped
117
+ skip_browser, reason = should_skip_browser(explicit_flag=browser)
118
+
119
+ if skip_browser:
120
+ console.print(f"[yellow]Note: {reason}[/yellow]")
121
+ console.print("[yellow]Please open the authentication URL manually in a browser.[/yellow]")
122
+
123
+ # Pass no_browser parameter to get_jwt_token
124
+ token = await get_jwt_token(
125
+ firebase_api_key=api_key,
126
+ github_client_id=client_id,
127
+ app_name=app_name,
128
+ no_browser=skip_browser
129
+ )
130
+
131
+ if not token:
132
+ console.print("[red]Authentication failed: No token received.[/red]")
133
+ sys.exit(1)
134
+
135
+ # Decode token to get expiration
136
+ payload = _decode_jwt_payload(token)
137
+ expires_at = payload.get("exp")
138
+
139
+ # Ensure cache directory exists
140
+ if not JWT_CACHE_FILE.parent.exists():
141
+ JWT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
142
+
143
+ # Save token and expiration to cache
144
+ # We store id_token for retrieval and expires_at for auth_service checks
145
+ cache_data = {
146
+ "id_token": token,
147
+ "expires_at": expires_at
148
+ }
149
+
150
+ JWT_CACHE_FILE.write_text(json.dumps(cache_data))
151
+
152
+ console.print("[green]Successfully authenticated to PDD Cloud.[/green]")
153
+
154
+ except AuthError as e:
155
+ console.print(f"[red]Authentication failed: {e}[/red]")
156
+ sys.exit(1)
157
+ except NetworkError as e:
158
+ console.print(f"[red]Network error: {e}[/red]")
159
+ sys.exit(1)
160
+ except TokenError as e:
161
+ console.print(f"[red]Token error: {e}[/red]")
162
+ sys.exit(1)
163
+ except UserCancelledError:
164
+ console.print("[yellow]Authentication cancelled by user.[/yellow]")
165
+ sys.exit(1)
166
+ except RateLimitError as e:
167
+ console.print(f"[red]Rate limit exceeded: {e}[/red]")
168
+ sys.exit(1)
169
+ except Exception as e:
170
+ console.print(f"[red]An unexpected error occurred: {e}[/red]")
171
+ sys.exit(1)
172
+
173
+ asyncio.run(run_login())
174
+
175
+
176
+ @auth_group.command("status")
177
+ def status():
178
+ """Check current authentication status."""
179
+ auth_status = get_auth_status()
180
+
181
+ if not auth_status.get("authenticated"):
182
+ console.print("Not authenticated.")
183
+ return
184
+
185
+ username = "Unknown"
186
+
187
+ # If we have a cached token, try to extract user info
188
+ if auth_status.get("cached") and JWT_CACHE_FILE.exists():
189
+ try:
190
+ data = json.loads(JWT_CACHE_FILE.read_text())
191
+ token = data.get("id_token")
192
+ if token:
193
+ payload = _decode_jwt_payload(token)
194
+ # Try to find a meaningful identifier
195
+ username = payload.get("email") or payload.get("sub")
196
+
197
+ # Check for GitHub specific claims if available in Firebase token
198
+ firebase_claims = payload.get("firebase", {})
199
+ identities = firebase_claims.get("identities", {})
200
+ if "github.com" in identities:
201
+ # identities['github.com'] is a list of IDs, not usernames usually
202
+ pass
203
+ except Exception:
204
+ pass
205
+
206
+ console.print(f"Authenticated as: [bold green]{username}[/bold green]")
207
+ sys.exit(0)
208
+
209
+
210
+ @auth_group.command("logout")
211
+ def logout_cmd():
212
+ """Log out of PDD Cloud."""
213
+ success, error = service_logout()
214
+ if success:
215
+ console.print("Logged out of PDD Cloud.")
216
+ else:
217
+ console.print(f"[red]Failed to logout: {error}[/red]")
218
+ # We don't exit with 1 here as partial logout might have occurred
219
+ # and the user is effectively logged out locally anyway.
220
+
221
+
222
+ @auth_group.command("token")
223
+ @click.option("--format", "output_format", type=click.Choice(["raw", "json"]), default="raw", help="Output format.")
224
+ def token_cmd(output_format: str):
225
+ """Print the current authentication token."""
226
+
227
+ token_str = None
228
+ expires_at = None
229
+
230
+ # Attempt to read valid token from cache
231
+ if JWT_CACHE_FILE.exists():
232
+ try:
233
+ data = json.loads(JWT_CACHE_FILE.read_text())
234
+ cached_token = data.get("id_token")
235
+ cached_exp = data.get("expires_at")
236
+
237
+ # Simple expiry check
238
+ if cached_token and cached_exp and cached_exp > time.time():
239
+ token_str = cached_token
240
+ expires_at = cached_exp
241
+ except Exception:
242
+ pass
243
+
244
+ if not token_str:
245
+ # Removed err=True because rich.console.Console.print does not support it
246
+ console.print("[red]No valid token available. Please login.[/red]")
247
+ sys.exit(1)
248
+
249
+ if output_format == "json":
250
+ output = {
251
+ "token": token_str,
252
+ "expires_at": expires_at
253
+ }
254
+ console.print_json(data=output)
255
+ else:
256
+ console.print(token_str)
257
+
258
+
259
+ @auth_group.command("clear-cache")
260
+ def clear_cache():
261
+ """Clear the JWT token cache.
262
+
263
+ This is useful when:
264
+ - Switching between environments (staging vs production)
265
+ - Experiencing authentication issues
266
+ - JWT token audience mismatch errors
267
+
268
+ After clearing the cache, you'll need to re-authenticate
269
+ with 'pdd auth login' or source the appropriate environment
270
+ setup script (e.g., setup_staging_env.sh).
271
+ """
272
+ if not JWT_CACHE_FILE.exists():
273
+ console.print("[yellow]No JWT cache found at ~/.pdd/jwt_cache[/yellow]")
274
+ console.print("Nothing to clear.")
275
+ return
276
+
277
+ try:
278
+ # Try to read cache before deleting to show what was cached
279
+ cache_data = json.loads(JWT_CACHE_FILE.read_text())
280
+ token = cache_data.get("id_token") or cache_data.get("jwt")
281
+ if token:
282
+ payload = _decode_jwt_payload(token)
283
+ aud = payload.get("aud") or payload.get("firebase", {}).get("aud")
284
+ exp = payload.get("exp")
285
+
286
+ console.print("[dim]Cached token info:[/dim]")
287
+ if aud:
288
+ console.print(f" Audience: {aud}")
289
+ if exp:
290
+ if exp > time.time():
291
+ time_remaining = int((exp - time.time()) / 60)
292
+ console.print(f" Expires in: {time_remaining} minutes")
293
+ else:
294
+ console.print(" Status: [red]Expired[/red]")
295
+ except Exception:
296
+ # If we can't read the cache, that's fine - just proceed with deletion
297
+ pass
298
+
299
+ # Delete the cache file
300
+ try:
301
+ JWT_CACHE_FILE.unlink()
302
+ console.print("[green]✓[/green] JWT cache cleared successfully")
303
+ console.print()
304
+ console.print("[dim]To re-authenticate:[/dim]")
305
+ console.print(" - For production: [bold]pdd auth login[/bold]")
306
+ console.print(" - For staging: [bold]source setup_staging_env.sh[/bold]")
307
+ except OSError as e:
308
+ console.print(f"[red]Failed to clear cache: {e}[/red]")
309
+ sys.exit(1)
@@ -0,0 +1,358 @@
1
+ """
2
+ PDD Connect Command.
3
+
4
+ This module provides the `pdd connect` CLI command which launches a local
5
+ REST server to enable the web frontend to interact with PDD.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import errno
12
+ import os
13
+ import socket
14
+ import webbrowser
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import click
19
+
20
+
21
+ # Default port and range for auto-assignment
22
+ DEFAULT_PORT = 9876
23
+ PORT_RANGE_START = 9876
24
+ PORT_RANGE_END = 9899 # Try up to 24 ports
25
+
26
+
27
+ def is_port_available(port: int, host: str = "127.0.0.1") -> bool:
28
+ """Check if a port is available for binding."""
29
+ try:
30
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
31
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
32
+ s.bind((host, port))
33
+ return True
34
+ except OSError as exc:
35
+ # If we lack permission to bind (common in sandboxed environments),
36
+ # treat availability as unknown and allow the caller to proceed.
37
+ if exc.errno in (errno.EACCES, errno.EPERM):
38
+ return True
39
+ return False
40
+
41
+
42
+ def find_available_port(start_port: int, end_port: int, host: str = "127.0.0.1") -> Optional[int]:
43
+ """Find an available port in the given range."""
44
+ for port in range(start_port, end_port + 1):
45
+ if is_port_available(port, host):
46
+ return port
47
+ return None
48
+
49
+ # Handle optional dependencies - uvicorn may not be installed
50
+ try:
51
+ import uvicorn
52
+ except ImportError:
53
+ uvicorn = None
54
+
55
+ # Internal imports
56
+ # We wrap this in a try/except block to allow the module to be imported
57
+ # even if the server dependencies are not present (e.g. in partial environments)
58
+ try:
59
+ from ..server.app import create_app
60
+ except (ImportError, ValueError):
61
+ def create_app(*args, **kwargs):
62
+ raise ImportError("Could not import pdd.server.app.create_app. Ensure server dependencies are installed.")
63
+
64
+
65
+ @click.command("connect")
66
+ @click.option(
67
+ "--port",
68
+ default=9876,
69
+ help="Port to listen on",
70
+ show_default=True,
71
+ type=int,
72
+ )
73
+ @click.option(
74
+ "--host",
75
+ default="127.0.0.1",
76
+ help="Host to bind to",
77
+ show_default=True,
78
+ )
79
+ @click.option(
80
+ "--allow-remote",
81
+ is_flag=True,
82
+ help="Allow non-localhost connections",
83
+ )
84
+ @click.option(
85
+ "--token",
86
+ help="Bearer token for authentication",
87
+ default=None,
88
+ )
89
+ @click.option(
90
+ "--no-browser",
91
+ is_flag=True,
92
+ help="Don't open browser automatically",
93
+ )
94
+ @click.option(
95
+ "--frontend-url",
96
+ help="Custom frontend URL",
97
+ default=None,
98
+ )
99
+ @click.option(
100
+ "--local-only",
101
+ is_flag=True,
102
+ help="Skip cloud registration (local access only)",
103
+ )
104
+ @click.option(
105
+ "--session-name",
106
+ help="Custom session name for identification",
107
+ default=None,
108
+ )
109
+ @click.pass_context
110
+ def connect(
111
+ ctx: click.Context,
112
+ port: int,
113
+ host: str,
114
+ allow_remote: bool,
115
+ token: Optional[str],
116
+ no_browser: bool,
117
+ frontend_url: Optional[str],
118
+ local_only: bool,
119
+ session_name: Optional[str],
120
+ ) -> None:
121
+ """
122
+ Launch the local REST server for the PDD web frontend.
123
+
124
+ This command starts a FastAPI server that exposes the PDD functionality
125
+ via a REST API. It automatically opens the web interface in your default
126
+ browser unless --no-browser is specified.
127
+
128
+ For authenticated users, the session is automatically registered with
129
+ PDD Cloud for remote access. Use --local-only to skip cloud registration.
130
+ """
131
+ # Check uvicorn is available
132
+ if uvicorn is None:
133
+ click.echo(click.style("Error: 'uvicorn' is not installed. Please install it to use the connect command.", fg="red"))
134
+ ctx.exit(1)
135
+
136
+ # 1. Determine Project Root
137
+ # We assume the current working directory is the project root
138
+ project_root = Path.cwd()
139
+
140
+ # 2. Security Checks & Configuration
141
+ if allow_remote:
142
+ if not token:
143
+ click.echo(click.style(
144
+ "SECURITY WARNING: You are allowing remote connections without an authentication token.",
145
+ fg="red", bold=True
146
+ ))
147
+ click.echo("Anyone with access to your network could execute code on your machine.")
148
+ if not click.confirm("Do you want to proceed?"):
149
+ ctx.exit(1)
150
+
151
+ # If user explicitly asked for remote but left host as localhost,
152
+ # bind to all interfaces to actually allow remote connections.
153
+ if host == "127.0.0.1":
154
+ host = "0.0.0.0"
155
+ click.echo(click.style("Binding to 0.0.0.0 to allow remote connections.", fg="yellow"))
156
+ else:
157
+ # Warn if binding to non-localhost without explicit allow-remote
158
+ if host not in ("127.0.0.1", "localhost"):
159
+ click.echo(click.style(
160
+ f"Warning: Binding to {host} without --allow-remote flag. "
161
+ "External connections may be blocked or insecure.",
162
+ fg="yellow"
163
+ ))
164
+
165
+ # 2.5 Smart Port Detection
166
+ # Check if user explicitly specified a port
167
+ port_source = ctx.get_parameter_source("port")
168
+ user_specified_port = port_source == click.core.ParameterSource.COMMANDLINE
169
+
170
+ # For port checking, use the effective bind host
171
+ check_host = "0.0.0.0" if host == "0.0.0.0" else "127.0.0.1"
172
+
173
+ if not is_port_available(port, check_host):
174
+ if user_specified_port:
175
+ # User explicitly requested this port, show error
176
+ click.echo(click.style(
177
+ f"Error: Port {port} is already in use.",
178
+ fg="red", bold=True
179
+ ))
180
+ click.echo("Please specify a different port with --port or stop the process using this port.")
181
+ ctx.exit(1)
182
+ else:
183
+ # Auto-detect an available port
184
+ click.echo(click.style(
185
+ f"Port {port} is in use, looking for an available port...",
186
+ fg="yellow"
187
+ ))
188
+ available_port = find_available_port(PORT_RANGE_START, PORT_RANGE_END, check_host)
189
+ if available_port is None:
190
+ click.echo(click.style(
191
+ f"Error: No available ports found in range {PORT_RANGE_START}-{PORT_RANGE_END}.",
192
+ fg="red", bold=True
193
+ ))
194
+ click.echo("Please specify a port manually with --port or free up a port in this range.")
195
+ ctx.exit(1)
196
+ port = available_port
197
+ click.echo(click.style(
198
+ f"Using port {port} instead.",
199
+ fg="green"
200
+ ))
201
+
202
+ # 3. Determine URLs
203
+ # The server URL is where the API lives
204
+ server_url = f"http://{host}:{port}"
205
+
206
+ # The frontend URL is what we open in the browser
207
+ # If binding to 0.0.0.0, we still use localhost for the local browser
208
+ browser_host = "localhost" if host == "0.0.0.0" else host
209
+ target_url = frontend_url if frontend_url else f"http://{browser_host}:{port}"
210
+
211
+ # 4. Configure CORS
212
+ # We need to allow the frontend to talk to the backend
213
+ allowed_origins = [
214
+ "http://localhost:3000",
215
+ "http://127.0.0.1:3000",
216
+ "http://localhost:5173",
217
+ "http://127.0.0.1:5173",
218
+ f"http://localhost:{port}",
219
+ f"http://127.0.0.1:{port}",
220
+ # PDD Cloud frontend
221
+ "https://pdd.dev",
222
+ "https://www.pdd.dev",
223
+ ]
224
+ if frontend_url:
225
+ allowed_origins.append(frontend_url)
226
+
227
+ # 4.5 Cloud Session Registration (automatic for authenticated users)
228
+ session_manager = None
229
+ cloud_url = None
230
+ if not local_only:
231
+ try:
232
+ from ..core.cloud import CloudConfig
233
+ from ..remote_session import (
234
+ RemoteSessionManager,
235
+ RemoteSessionError,
236
+ set_active_session_manager,
237
+ )
238
+
239
+ # Check if user is authenticated
240
+ jwt_token = CloudConfig.get_jwt_token(verbose=False)
241
+ if not jwt_token:
242
+ click.echo(click.style(
243
+ "Not authenticated. Running in local-only mode.",
244
+ dim=True
245
+ ))
246
+ click.echo(click.style(
247
+ "Run 'pdd login' to enable remote access via cloud.",
248
+ dim=True
249
+ ))
250
+ else:
251
+ click.echo("Registering session with PDD Cloud...")
252
+ session_manager = RemoteSessionManager(jwt_token, project_root)
253
+ try:
254
+ # Register with cloud - no public URL needed, cloud hosts everything
255
+ cloud_url = asyncio.run(session_manager.register(
256
+ session_name=session_name,
257
+ ))
258
+ # Heartbeat will be started by the app's lifespan manager
259
+ set_active_session_manager(session_manager)
260
+
261
+ click.echo(click.style(
262
+ "Session registered with PDD Cloud!", fg="green", bold=True
263
+ ))
264
+ # TODO: Re-enable when production /connect page is deployed
265
+ # click.echo(f" Access URL: {click.style(cloud_url, fg='cyan', underline=True)}")
266
+ # click.echo(click.style(
267
+ # " Share this URL to access your PDD session from any browser.",
268
+ # dim=True
269
+ # ))
270
+ except RemoteSessionError as e:
271
+ click.echo(click.style(
272
+ f"Warning: Failed to register with cloud: {e.message}",
273
+ fg="yellow"
274
+ ))
275
+ click.echo(click.style(
276
+ "Running in local-only mode.",
277
+ dim=True
278
+ ))
279
+ session_manager = None
280
+ except ImportError as e:
281
+ click.echo(click.style(
282
+ f"Running in local-only mode (cloud dependencies not available).",
283
+ dim=True
284
+ ))
285
+ else:
286
+ click.echo(click.style(
287
+ "Running in local-only mode (--local-only flag set).",
288
+ dim=True
289
+ ))
290
+
291
+ # 5. Initialize Server App
292
+ try:
293
+ # Pass token via environment variable if provided, as create_app might not take it directly
294
+ if token:
295
+ os.environ["PDD_ACCESS_TOKEN"] = token
296
+
297
+ app = create_app(project_root, allowed_origins=allowed_origins)
298
+ except Exception as e:
299
+ click.echo(click.style(f"Failed to initialize server: {e}", fg="red", bold=True))
300
+ ctx.exit(1)
301
+
302
+ # 6. Print Status Messages
303
+ click.echo(click.style(f"Starting PDD server on {server_url}", fg="green", bold=True))
304
+ click.echo(f"Project Root: {click.style(str(project_root), fg='blue')}")
305
+ click.echo(f"API Documentation: {click.style(f'{server_url}/docs', underline=True)}")
306
+ click.echo(f"Local Frontend: {click.style(target_url, underline=True)}")
307
+ # TODO: Re-enable when production /connect page is deployed
308
+ # if cloud_url:
309
+ # click.echo(f"Remote Access: {click.style(cloud_url, fg='cyan', underline=True)}")
310
+ click.echo(click.style("Press Ctrl+C to stop the server", dim=True))
311
+
312
+ # 7. Open Browser
313
+ if not no_browser:
314
+ # Import remote session detection
315
+ from ..core.remote_session import is_remote_session
316
+
317
+ is_remote, reason = is_remote_session()
318
+ if is_remote:
319
+ click.echo(click.style(f"Note: {reason}", fg="yellow"))
320
+ click.echo("Opening browser may not work in remote sessions. Use the URL above to connect manually.")
321
+
322
+ click.echo("Opening browser...")
323
+ try:
324
+ webbrowser.open(target_url)
325
+ except Exception as e:
326
+ click.echo(click.style(f"Could not open browser: {e}", fg="yellow"))
327
+ click.echo(f"Please open {target_url} manually in your browser.")
328
+
329
+ # 8. Run Server
330
+ try:
331
+ # Run uvicorn
332
+ # Disable access_log to avoid noisy polling logs - custom middleware handles important logging
333
+ uvicorn.run(
334
+ app,
335
+ host=host,
336
+ port=port,
337
+ log_level="warning", # Only show warnings and errors from uvicorn
338
+ access_log=False # Custom middleware handles request logging
339
+ )
340
+ except KeyboardInterrupt:
341
+ click.echo(click.style("\nServer stopping...", fg="yellow", bold=True))
342
+ except Exception as e:
343
+ click.echo(click.style(f"\nServer error: {e}", fg="red", bold=True))
344
+ ctx.exit(1)
345
+ finally:
346
+ # Clean up cloud session if registered
347
+ if session_manager is not None:
348
+ click.echo("Deregistering from PDD Cloud...")
349
+ try:
350
+ from ..remote_session import set_active_session_manager
351
+ asyncio.run(session_manager.stop_heartbeat())
352
+ asyncio.run(session_manager.deregister())
353
+ set_active_session_manager(None)
354
+ click.echo(click.style("Session deregistered.", fg="green"))
355
+ except Exception as e:
356
+ click.echo(click.style(f"Warning: Error during session cleanup: {e}", fg="yellow"))
357
+
358
+ click.echo(click.style("Goodbye!", fg="blue"))