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
@@ -1,6 +1,5 @@
1
1
  import os
2
2
  import re
3
- import asyncio
4
3
  import json
5
4
  import pathlib
6
5
  import shlex
@@ -21,16 +20,15 @@ from .construct_paths import construct_paths
21
20
  from .preprocess import preprocess as pdd_preprocess
22
21
  from .code_generator import code_generator as local_code_generator_func
23
22
  from .incremental_code_generator import incremental_code_generator as incremental_code_generator_func
24
- from .get_jwt_token import get_jwt_token, AuthError, NetworkError, TokenError, UserCancelledError, RateLimitError
23
+ from .core.cloud import CloudConfig
25
24
  from .python_env_detector import detect_host_python_executable
25
+ from .architecture_sync import (
26
+ get_architecture_entry_for_prompt,
27
+ has_pdd_tags,
28
+ generate_tags_from_architecture,
29
+ )
26
30
 
27
- # Environment variable names for Firebase/GitHub auth
28
- FIREBASE_API_KEY_ENV_VAR = "NEXT_PUBLIC_FIREBASE_API_KEY"
29
- GITHUB_CLIENT_ID_ENV_VAR = "GITHUB_CLIENT_ID"
30
- PDD_APP_NAME = "PDD Code Generator"
31
-
32
- # Cloud function URL
33
- CLOUD_GENERATE_URL = "https://us-central1-prompt-driven-development.cloudfunctions.net/generateCode"
31
+ # Cloud request timeout
34
32
  CLOUD_REQUEST_TIMEOUT = 400 # seconds
35
33
 
36
34
  console = Console()
@@ -46,6 +44,13 @@ def _parse_llm_bool(value: str) -> bool:
46
44
  else:
47
45
  return llm_str in {"1", "true", "yes", "on"}
48
46
 
47
+ def _env_flag_enabled(name: str) -> bool:
48
+ """Return True when an env var is set to a truthy value."""
49
+ value = os.environ.get(name)
50
+ if value is None:
51
+ return False
52
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
53
+
49
54
  # --- Git Helper Functions ---
50
55
  def _run_git_command(command: List[str], cwd: Optional[str] = None) -> Tuple[int, str, str]:
51
56
  """Runs a git command and returns (return_code, stdout, stderr)."""
@@ -762,7 +767,8 @@ def code_generator_main(
762
767
  if verbose:
763
768
  console.print(Panel("Performing full code generation...", title="[blue]Mode[/blue]", expand=False))
764
769
 
765
- current_execution_is_local = is_local_execution_preferred
770
+ cloud_only = _env_flag_enabled("PDD_CLOUD_ONLY") or _env_flag_enabled("PDD_NO_LOCAL_FALLBACK")
771
+ current_execution_is_local = is_local_execution_preferred and not cloud_only
766
772
 
767
773
  if not current_execution_is_local:
768
774
  if verbose: console.print("Attempting cloud code generation...")
@@ -772,31 +778,22 @@ def code_generator_main(
772
778
  processed_prompt_for_cloud = pdd_preprocess(processed_prompt_for_cloud, recursive=False, double_curly_brackets=True, exclude_keys=[])
773
779
  if verbose: console.print(Panel(Text(processed_prompt_for_cloud, overflow="fold"), title="[cyan]Preprocessed Prompt for Cloud[/cyan]", expand=False))
774
780
 
775
- jwt_token: Optional[str] = None
776
- try:
777
- firebase_api_key_val = os.environ.get(FIREBASE_API_KEY_ENV_VAR)
778
- github_client_id_val = os.environ.get(GITHUB_CLIENT_ID_ENV_VAR)
779
-
780
- if not firebase_api_key_val: raise AuthError(f"{FIREBASE_API_KEY_ENV_VAR} not set.")
781
- if not github_client_id_val: raise AuthError(f"{GITHUB_CLIENT_ID_ENV_VAR} not set.")
782
-
783
- jwt_token = asyncio.run(get_jwt_token(
784
- firebase_api_key=firebase_api_key_val,
785
- github_client_id=github_client_id_val,
786
- app_name=PDD_APP_NAME
787
- ))
788
- except (AuthError, NetworkError, TokenError, UserCancelledError, RateLimitError) as e:
789
- console.print(f"[yellow]Cloud authentication/token error: {e}. Falling back to local execution.[/yellow]")
790
- current_execution_is_local = True
791
- except Exception as e:
792
- console.print(f"[yellow]Unexpected error during cloud authentication: {e}. Falling back to local execution.[/yellow]")
781
+ # Get JWT token via CloudConfig (handles both injected tokens and device flow)
782
+ jwt_token = CloudConfig.get_jwt_token(verbose=verbose)
783
+
784
+ if not jwt_token:
785
+ if cloud_only:
786
+ console.print("[red]Cloud authentication failed.[/red]")
787
+ raise click.UsageError("Cloud authentication failed")
788
+ console.print("[yellow]Cloud authentication failed. Falling back to local execution.[/yellow]")
793
789
  current_execution_is_local = True
794
790
 
795
791
  if jwt_token and not current_execution_is_local:
796
792
  payload = {"promptContent": processed_prompt_for_cloud, "language": language, "strength": strength, "temperature": temperature, "verbose": verbose}
797
793
  headers = {"Authorization": f"Bearer {jwt_token}", "Content-Type": "application/json"}
794
+ cloud_url = CloudConfig.get_endpoint_url("generateCode")
798
795
  try:
799
- response = requests.post(CLOUD_GENERATE_URL, json=payload, headers=headers, timeout=CLOUD_REQUEST_TIMEOUT)
796
+ response = requests.post(cloud_url, json=payload, headers=headers, timeout=CLOUD_REQUEST_TIMEOUT)
800
797
  response.raise_for_status()
801
798
 
802
799
  response_data = response.json()
@@ -804,22 +801,71 @@ def code_generator_main(
804
801
  total_cost = float(response_data.get("totalCost", 0.0))
805
802
  model_name = response_data.get("modelName", "cloud_model")
806
803
 
804
+ # Strip markdown code fences if present (cloud API returns fenced JSON)
805
+ if generated_code_content and isinstance(language, str) and language.strip().lower() == "json":
806
+ cleaned = generated_code_content.strip()
807
+ if cleaned.startswith("```json"):
808
+ cleaned = cleaned[7:]
809
+ elif cleaned.startswith("```"):
810
+ cleaned = cleaned[3:]
811
+ if cleaned.endswith("```"):
812
+ cleaned = cleaned[:-3]
813
+ generated_code_content = cleaned.strip()
814
+
807
815
  if not generated_code_content:
816
+ if cloud_only:
817
+ console.print("[red]Cloud execution returned no code.[/red]")
818
+ raise click.UsageError("Cloud execution returned no code")
808
819
  console.print("[yellow]Cloud execution returned no code. Falling back to local.[/yellow]")
809
820
  current_execution_is_local = True
810
821
  elif verbose:
811
822
  console.print(Panel(f"Cloud generation successful. Model: {model_name}, Cost: ${total_cost:.6f}", title="[green]Cloud Success[/green]", expand=False))
812
823
  except requests.exceptions.Timeout:
824
+ if cloud_only:
825
+ console.print(f"[red]Cloud execution timed out ({CLOUD_REQUEST_TIMEOUT}s).[/red]")
826
+ raise click.UsageError("Cloud execution timed out")
813
827
  console.print(f"[yellow]Cloud execution timed out ({CLOUD_REQUEST_TIMEOUT}s). Falling back to local.[/yellow]")
814
828
  current_execution_is_local = True
815
829
  except requests.exceptions.HTTPError as e:
830
+ status_code = e.response.status_code if e.response else 0
816
831
  err_content = e.response.text[:200] if e.response else "No response content"
817
- console.print(f"[yellow]Cloud HTTP error ({e.response.status_code}): {err_content}. Falling back to local.[/yellow]")
818
- current_execution_is_local = True
832
+
833
+ # Non-recoverable errors: do NOT fall back to local
834
+ if status_code == 402: # Insufficient credits
835
+ try:
836
+ error_data = e.response.json()
837
+ current_balance = error_data.get("currentBalance", "unknown")
838
+ estimated_cost = error_data.get("estimatedCost", "unknown")
839
+ console.print(f"[red]Insufficient credits. Current balance: {current_balance}, estimated cost: {estimated_cost}[/red]")
840
+ except Exception:
841
+ console.print(f"[red]Insufficient credits: {err_content}[/red]")
842
+ raise click.UsageError("Insufficient credits for cloud code generation")
843
+ elif status_code == 401: # Authentication error
844
+ console.print(f"[red]Authentication failed: {err_content}[/red]")
845
+ raise click.UsageError("Cloud authentication failed")
846
+ elif status_code == 403: # Authorization error (not approved)
847
+ console.print(f"[red]Access denied: {err_content}[/red]")
848
+ raise click.UsageError("Access denied - user not approved")
849
+ elif status_code == 400: # Validation error (e.g., empty prompt)
850
+ console.print(f"[red]Invalid request: {err_content}[/red]")
851
+ raise click.UsageError(f"Invalid request: {err_content}")
852
+ else:
853
+ # Recoverable errors (5xx, unexpected errors): fall back to local
854
+ if cloud_only:
855
+ console.print(f"[red]Cloud HTTP error ({status_code}): {err_content}[/red]")
856
+ raise click.UsageError(f"Cloud HTTP error ({status_code}): {err_content}")
857
+ console.print(f"[yellow]Cloud HTTP error ({status_code}): {err_content}. Falling back to local.[/yellow]")
858
+ current_execution_is_local = True
819
859
  except requests.exceptions.RequestException as e:
860
+ if cloud_only:
861
+ console.print(f"[red]Cloud network error: {e}[/red]")
862
+ raise click.UsageError(f"Cloud network error: {e}")
820
863
  console.print(f"[yellow]Cloud network error: {e}. Falling back to local.[/yellow]")
821
864
  current_execution_is_local = True
822
865
  except json.JSONDecodeError:
866
+ if cloud_only:
867
+ console.print("[red]Cloud returned invalid JSON.[/red]")
868
+ raise click.UsageError("Cloud returned invalid JSON")
823
869
  console.print("[yellow]Cloud returned invalid JSON. Falling back to local.[/yellow]")
824
870
  current_execution_is_local = True
825
871
 
@@ -1006,6 +1052,23 @@ def code_generator_main(
1006
1052
  raise click.UsageError(f"LLM generation failed: {generated_code_content}")
1007
1053
 
1008
1054
  parsed = json.loads(generated_code_content)
1055
+
1056
+ # Fix common LLM mistake: unwrap arrays wrapped in objects
1057
+ # LLMs often return {"items": [...]} or {"type": "array", "items": [...]}
1058
+ # when the schema expects a plain array [...]
1059
+ output_schema = fm_meta.get("output_schema", {})
1060
+ if output_schema.get("type") == "array" and isinstance(parsed, dict):
1061
+ # Check for common wrapper patterns
1062
+ if "items" in parsed and isinstance(parsed["items"], list):
1063
+ parsed = parsed["items"]
1064
+ generated_code_content = json.dumps(parsed, indent=2)
1065
+ elif "data" in parsed and isinstance(parsed["data"], list):
1066
+ parsed = parsed["data"]
1067
+ generated_code_content = json.dumps(parsed, indent=2)
1068
+ elif "results" in parsed and isinstance(parsed["results"], list):
1069
+ parsed = parsed["results"]
1070
+ generated_code_content = json.dumps(parsed, indent=2)
1071
+
1009
1072
  if _is_architecture_template(fm_meta):
1010
1073
  parsed, repaired = _repair_architecture_interface_types(parsed)
1011
1074
  if repaired:
@@ -1024,7 +1087,30 @@ def code_generator_main(
1024
1087
  if output_path:
1025
1088
  p_output = pathlib.Path(output_path)
1026
1089
  p_output.parent.mkdir(parents=True, exist_ok=True)
1027
- p_output.write_text(generated_code_content, encoding="utf-8")
1090
+
1091
+ # Inject architecture metadata tags for .prompt files (reverse sync)
1092
+ final_content = generated_code_content
1093
+ if p_output.suffix == '.prompt':
1094
+ try:
1095
+ # Check if this prompt has an architecture entry
1096
+ arch_entry = get_architecture_entry_for_prompt(p_output.name)
1097
+
1098
+ # Only inject tags if:
1099
+ # 1. Architecture entry exists
1100
+ # 2. Content doesn't already have PDD tags (preserve manual edits)
1101
+ if arch_entry and not has_pdd_tags(generated_code_content):
1102
+ tags = generate_tags_from_architecture(arch_entry)
1103
+ if tags:
1104
+ # Prepend tags to the generated content
1105
+ final_content = tags + '\n\n' + generated_code_content
1106
+ if verbose:
1107
+ console.print("[info]Injected architecture metadata tags from architecture.json[/info]")
1108
+ except Exception as e:
1109
+ # Don't fail generation if tag injection fails
1110
+ if verbose:
1111
+ console.print(f"[yellow]Warning: Could not inject architecture tags: {e}[/yellow]")
1112
+
1113
+ p_output.write_text(final_content, encoding="utf-8")
1028
1114
  if verbose or not quiet:
1029
1115
  console.print(f"Generated code saved to: [green]{p_output.resolve()}[/green]")
1030
1116
  # Safety net: ensure architecture HTML is generated post-write if applicable
pdd/commands/__init__.py CHANGED
@@ -8,7 +8,10 @@ from .fix import fix
8
8
  from .modify import split, change, update
9
9
  from .maintenance import sync, auto_deps, setup
10
10
  from .analysis import detect_change, conflicts, bug, crash, trace
11
+ from .connect import connect
12
+ from .auth import auth_group
11
13
  from .misc import preprocess
14
+ from .sessions import sessions
12
15
  from .report import report_core
13
16
  from .templates import templates_group
14
17
  from .utility import install_completion_cmd, verify
@@ -40,3 +43,6 @@ def register_commands(cli: click.Group) -> None:
40
43
  # The original code did: cli.commands["templates"] = templates_group
41
44
  # Using add_command is cleaner if it works for the structure.
42
45
  cli.add_command(templates_group)
46
+ cli.add_command(connect)
47
+ cli.add_command(auth_group)
48
+ cli.add_command(sessions)
pdd/commands/analysis.py CHANGED
@@ -1,16 +1,25 @@
1
+ from __future__ import annotations
2
+
1
3
  """
2
4
  Analysis commands (detect-change, conflicts, bug, crash, trace).
3
5
  """
6
+ import os
4
7
  import click
5
- from typing import Optional, Tuple, List
8
+ from typing import Optional, Tuple, List, Dict, Any
6
9
 
7
10
  from ..detect_change_main import detect_change_main
8
11
  from ..conflicts_main import conflicts_main
9
12
  from ..bug_main import bug_main
13
+ from ..agentic_bug import run_agentic_bug
10
14
  from ..crash_main import crash_main
11
15
  from ..trace_main import trace_main
12
16
  from ..track_cost import track_cost
13
17
  from ..core.errors import handle_error
18
+ from ..operation_log import log_operation
19
+
20
+ def get_context_obj(ctx: click.Context) -> Dict[str, Any]:
21
+ """Safely retrieve the context object, defaulting to empty dict if None."""
22
+ return ctx.obj or {}
14
23
 
15
24
  @click.command("detect")
16
25
  @click.argument("files", nargs=-1, type=click.Path(exists=True, dir_okay=False))
@@ -24,8 +33,8 @@ from ..core.errors import handle_error
24
33
  @track_cost
25
34
  def detect_change(
26
35
  ctx: click.Context,
27
- files: Tuple[str, ...],
28
- output: Optional[str],
36
+ files: Tuple[str, ...] = (),
37
+ output: Optional[str] = None,
29
38
  ) -> Optional[Tuple[List, float, str]]:
30
39
  """Detect if prompts need to be changed based on a description.
31
40
 
@@ -46,10 +55,10 @@ def detect_change(
46
55
  output=output,
47
56
  )
48
57
  return result, total_cost, model_name
49
- except click.Abort:
58
+ except (click.Abort, click.ClickException):
50
59
  raise
51
60
  except Exception as exception:
52
- handle_error(exception, "detect", ctx.obj.get("quiet", False))
61
+ handle_error(exception, "detect", get_context_obj(ctx).get("quiet", False))
53
62
  return None
54
63
 
55
64
 
@@ -68,7 +77,7 @@ def conflicts(
68
77
  ctx: click.Context,
69
78
  prompt1: str,
70
79
  prompt2: str,
71
- output: Optional[str],
80
+ output: Optional[str] = None,
72
81
  ) -> Optional[Tuple[List, float, str]]:
73
82
  """Check for conflicts between two prompt files."""
74
83
  try:
@@ -77,22 +86,24 @@ def conflicts(
77
86
  prompt1=prompt1,
78
87
  prompt2=prompt2,
79
88
  output=output,
80
- verbose=ctx.obj.get("verbose", False),
89
+ verbose=get_context_obj(ctx).get("verbose", False),
81
90
  )
82
91
  return result, total_cost, model_name
83
- except click.Abort:
92
+ except (click.Abort, click.ClickException):
84
93
  raise
85
94
  except Exception as exception:
86
- handle_error(exception, "conflicts", ctx.obj.get("quiet", False))
95
+ handle_error(exception, "conflicts", get_context_obj(ctx).get("quiet", False))
87
96
  return None
88
97
 
89
98
 
90
99
  @click.command("bug")
91
- @click.argument("prompt_file", type=click.Path(exists=True, dir_okay=False))
92
- @click.argument("code_file", type=click.Path(exists=True, dir_okay=False))
93
- @click.argument("program_file", type=click.Path(exists=True, dir_okay=False))
94
- @click.argument("current_output", type=click.Path(exists=True, dir_okay=False))
95
- @click.argument("desired_output", type=click.Path(exists=True, dir_okay=False))
100
+ @click.option(
101
+ "--manual",
102
+ is_flag=True,
103
+ default=False,
104
+ help="Run in manual mode requiring 5 positional file arguments.",
105
+ )
106
+ @click.argument("args", nargs=-1)
96
107
  @click.option(
97
108
  "--output",
98
109
  type=click.Path(writable=True),
@@ -103,37 +114,90 @@ def conflicts(
103
114
  "--language",
104
115
  type=str,
105
116
  default="Python",
106
- help="Programming language for the unit test.",
117
+ help="Programming language for the unit test (Manual mode only).",
118
+ )
119
+ @click.option(
120
+ "--timeout-adder",
121
+ type=float,
122
+ default=0.0,
123
+ help="Additional seconds to add to each step's timeout (agentic mode only).",
124
+ )
125
+ @click.option(
126
+ "--no-github-state",
127
+ is_flag=True,
128
+ default=False,
129
+ help="Disable GitHub state persistence (agentic mode only).",
107
130
  )
108
131
  @click.pass_context
109
132
  @track_cost
110
133
  def bug(
111
134
  ctx: click.Context,
112
- prompt_file: str,
113
- code_file: str,
114
- program_file: str,
115
- current_output: str,
116
- desired_output: str,
117
- output: Optional[str],
118
- language: str,
135
+ manual: bool = False,
136
+ args: Tuple[str, ...] = (),
137
+ output: Optional[str] = None,
138
+ language: str = "Python",
139
+ timeout_adder: float = 0.0,
140
+ no_github_state: bool = False,
119
141
  ) -> Optional[Tuple[str, float, str]]:
120
- """Generate a unit test reproducing a bug from inputs and outputs."""
142
+ """Generate a unit test (manual) or investigate a bug (agentic).
143
+
144
+ Agentic Mode (default):
145
+ pdd bug ISSUE_URL
146
+
147
+ Manual Mode:
148
+ pdd bug --manual PROMPT_FILE CODE_FILE PROGRAM_FILE CURRENT_OUTPUT DESIRED_OUTPUT
149
+ """
121
150
  try:
122
- result, total_cost, model_name = bug_main(
123
- ctx=ctx,
124
- prompt_file=prompt_file,
125
- code_file=code_file,
126
- program_file=program_file,
127
- current_output=current_output,
128
- desired_output=desired_output,
129
- output=output,
130
- language=language,
131
- )
132
- return result, total_cost, model_name
133
- except click.Abort:
151
+ obj = get_context_obj(ctx)
152
+ if manual:
153
+ if len(args) != 5:
154
+ raise click.UsageError(
155
+ "Manual mode requires 5 arguments: PROMPT_FILE CODE_FILE PROGRAM_FILE CURRENT_OUTPUT DESIRED_OUTPUT"
156
+ )
157
+
158
+ # Validate files exist (replicating click.Path(exists=True))
159
+ for f in args:
160
+ if not os.path.exists(f):
161
+ raise click.UsageError(f"File does not exist: {f}")
162
+ if os.path.isdir(f):
163
+ raise click.UsageError(f"Path is a directory, not a file: {f}")
164
+
165
+ prompt_file, code_file, program_file, current_output, desired_output = args
166
+
167
+ result, total_cost, model_name = bug_main(
168
+ ctx=ctx,
169
+ prompt_file=prompt_file,
170
+ code_file=code_file,
171
+ program_file=program_file,
172
+ current_output=current_output,
173
+ desired_output=desired_output,
174
+ output=output,
175
+ language=language,
176
+ )
177
+ return result, total_cost, model_name
178
+
179
+ else:
180
+ # Agentic mode
181
+ if len(args) != 1:
182
+ raise click.UsageError("Agentic mode requires exactly one argument: the GitHub Issue URL.")
183
+
184
+ issue_url = args[0]
185
+
186
+ success, message, cost, model, changed_files = run_agentic_bug(
187
+ issue_url=issue_url,
188
+ verbose=obj.get("verbose", False),
189
+ quiet=obj.get("quiet", False),
190
+ timeout_adder=timeout_adder,
191
+ use_github_state=not no_github_state,
192
+ )
193
+
194
+ result_str = f"Success: {success}\nMessage: {message}\nChanged Files: {changed_files}"
195
+ return result_str, cost, model
196
+
197
+ except (click.Abort, click.ClickException):
134
198
  raise
135
199
  except Exception as exception:
136
- handle_error(exception, "bug", ctx.obj.get("quiet", False))
200
+ handle_error(exception, "bug", get_context_obj(ctx).get("quiet", False))
137
201
  return None
138
202
 
139
203
 
@@ -173,6 +237,7 @@ def bug(
173
237
  help="Maximum cost allowed for the fixing process (default: 5.0).",
174
238
  )
175
239
  @click.pass_context
240
+ @log_operation("crash", clears_run_report=True)
176
241
  @track_cost
177
242
  def crash(
178
243
  ctx: click.Context,
@@ -180,15 +245,15 @@ def crash(
180
245
  code_file: str,
181
246
  program_file: str,
182
247
  error_file: str,
183
- output: Optional[str],
184
- output_program: Optional[str],
185
- loop: bool,
186
- max_attempts: Optional[int],
187
- budget: Optional[float],
248
+ output: Optional[str] = None,
249
+ output_program: Optional[str] = None,
250
+ loop: bool = False,
251
+ max_attempts: Optional[int] = None,
252
+ budget: Optional[float] = None,
188
253
  ) -> Optional[Tuple[str, float, str]]:
189
254
  """Analyze a crash and fix the code and program."""
190
255
  try:
191
- # crash_main returns: success, final_code, final_program, attempts, cost, model
256
+ # crash_main returns: success, final_code, final_program, attempts, total_cost, model_name
192
257
  success, final_code, final_program, attempts, total_cost, model_name = crash_main(
193
258
  ctx=ctx,
194
259
  prompt_file=prompt_file,
@@ -204,10 +269,10 @@ def crash(
204
269
  # Return a summary string as the result for track_cost/CLI output
205
270
  result = f"Success: {success}, Attempts: {attempts}"
206
271
  return result, total_cost, model_name
207
- except click.Abort:
272
+ except (click.Abort, click.ClickException):
208
273
  raise
209
274
  except Exception as exception:
210
- handle_error(exception, "crash", ctx.obj.get("quiet", False))
275
+ handle_error(exception, "crash", get_context_obj(ctx).get("quiet", False))
211
276
  return None
212
277
 
213
278
 
@@ -228,7 +293,7 @@ def trace(
228
293
  prompt_file: str,
229
294
  code_file: str,
230
295
  code_line: int,
231
- output: Optional[str],
296
+ output: Optional[str] = None,
232
297
  ) -> Optional[Tuple[str, float, str]]:
233
298
  """Trace execution flow back to the prompt."""
234
299
  try:
@@ -241,8 +306,8 @@ def trace(
241
306
  output=output,
242
307
  )
243
308
  return str(result), total_cost, model_name
244
- except click.Abort:
309
+ except (click.Abort, click.ClickException):
245
310
  raise
246
311
  except Exception as exception:
247
- handle_error(exception, "trace", ctx.obj.get("quiet", False))
248
- return None
312
+ handle_error(exception, "trace", get_context_obj(ctx).get("quiet", False))
313
+ return None