pdd-cli 0.0.42__py3-none-any.whl → 0.0.90__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 (119) hide show
  1. pdd/__init__.py +4 -4
  2. pdd/agentic_common.py +863 -0
  3. pdd/agentic_crash.py +534 -0
  4. pdd/agentic_fix.py +1179 -0
  5. pdd/agentic_langtest.py +162 -0
  6. pdd/agentic_update.py +370 -0
  7. pdd/agentic_verify.py +183 -0
  8. pdd/auto_deps_main.py +15 -5
  9. pdd/auto_include.py +63 -5
  10. pdd/bug_main.py +3 -2
  11. pdd/bug_to_unit_test.py +2 -0
  12. pdd/change_main.py +11 -4
  13. pdd/cli.py +22 -1181
  14. pdd/cmd_test_main.py +80 -19
  15. pdd/code_generator.py +58 -18
  16. pdd/code_generator_main.py +672 -25
  17. pdd/commands/__init__.py +42 -0
  18. pdd/commands/analysis.py +248 -0
  19. pdd/commands/fix.py +140 -0
  20. pdd/commands/generate.py +257 -0
  21. pdd/commands/maintenance.py +174 -0
  22. pdd/commands/misc.py +79 -0
  23. pdd/commands/modify.py +230 -0
  24. pdd/commands/report.py +144 -0
  25. pdd/commands/templates.py +215 -0
  26. pdd/commands/utility.py +110 -0
  27. pdd/config_resolution.py +58 -0
  28. pdd/conflicts_main.py +8 -3
  29. pdd/construct_paths.py +281 -81
  30. pdd/context_generator.py +10 -2
  31. pdd/context_generator_main.py +113 -11
  32. pdd/continue_generation.py +47 -7
  33. pdd/core/__init__.py +0 -0
  34. pdd/core/cli.py +503 -0
  35. pdd/core/dump.py +554 -0
  36. pdd/core/errors.py +63 -0
  37. pdd/core/utils.py +90 -0
  38. pdd/crash_main.py +44 -11
  39. pdd/data/language_format.csv +71 -62
  40. pdd/data/llm_model.csv +20 -18
  41. pdd/detect_change_main.py +5 -4
  42. pdd/fix_code_loop.py +331 -77
  43. pdd/fix_error_loop.py +209 -60
  44. pdd/fix_errors_from_unit_tests.py +4 -3
  45. pdd/fix_main.py +75 -18
  46. pdd/fix_verification_errors.py +12 -100
  47. pdd/fix_verification_errors_loop.py +319 -272
  48. pdd/fix_verification_main.py +57 -17
  49. pdd/generate_output_paths.py +93 -10
  50. pdd/generate_test.py +16 -5
  51. pdd/get_jwt_token.py +48 -9
  52. pdd/get_run_command.py +73 -0
  53. pdd/get_test_command.py +68 -0
  54. pdd/git_update.py +70 -19
  55. pdd/increase_tests.py +7 -0
  56. pdd/incremental_code_generator.py +2 -2
  57. pdd/insert_includes.py +11 -3
  58. pdd/llm_invoke.py +1278 -110
  59. pdd/load_prompt_template.py +36 -10
  60. pdd/pdd_completion.fish +25 -2
  61. pdd/pdd_completion.sh +30 -4
  62. pdd/pdd_completion.zsh +79 -4
  63. pdd/postprocess.py +10 -3
  64. pdd/preprocess.py +228 -15
  65. pdd/preprocess_main.py +8 -5
  66. pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
  67. pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
  68. pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
  69. pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
  70. pdd/prompts/agentic_update_LLM.prompt +1071 -0
  71. pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
  72. pdd/prompts/auto_include_LLM.prompt +98 -101
  73. pdd/prompts/change_LLM.prompt +1 -3
  74. pdd/prompts/detect_change_LLM.prompt +562 -3
  75. pdd/prompts/example_generator_LLM.prompt +22 -1
  76. pdd/prompts/extract_code_LLM.prompt +5 -1
  77. pdd/prompts/extract_program_code_fix_LLM.prompt +14 -2
  78. pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
  79. pdd/prompts/extract_promptline_LLM.prompt +17 -11
  80. pdd/prompts/find_verification_errors_LLM.prompt +6 -0
  81. pdd/prompts/fix_code_module_errors_LLM.prompt +16 -4
  82. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +6 -41
  83. pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
  84. pdd/prompts/generate_test_LLM.prompt +21 -6
  85. pdd/prompts/increase_tests_LLM.prompt +1 -2
  86. pdd/prompts/insert_includes_LLM.prompt +1181 -6
  87. pdd/prompts/split_LLM.prompt +1 -62
  88. pdd/prompts/trace_LLM.prompt +25 -22
  89. pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
  90. pdd/prompts/update_prompt_LLM.prompt +22 -1
  91. pdd/prompts/xml_convertor_LLM.prompt +3246 -7
  92. pdd/pytest_output.py +188 -21
  93. pdd/python_env_detector.py +151 -0
  94. pdd/render_mermaid.py +236 -0
  95. pdd/setup_tool.py +648 -0
  96. pdd/simple_math.py +2 -0
  97. pdd/split_main.py +3 -2
  98. pdd/summarize_directory.py +56 -7
  99. pdd/sync_determine_operation.py +918 -186
  100. pdd/sync_main.py +82 -32
  101. pdd/sync_orchestration.py +1456 -453
  102. pdd/sync_tui.py +848 -0
  103. pdd/template_registry.py +264 -0
  104. pdd/templates/architecture/architecture_json.prompt +242 -0
  105. pdd/templates/generic/generate_prompt.prompt +174 -0
  106. pdd/trace.py +168 -12
  107. pdd/trace_main.py +4 -3
  108. pdd/track_cost.py +151 -61
  109. pdd/unfinished_prompt.py +49 -3
  110. pdd/update_main.py +549 -67
  111. pdd/update_model_costs.py +2 -2
  112. pdd/update_prompt.py +19 -4
  113. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/METADATA +20 -7
  114. pdd_cli-0.0.90.dist-info/RECORD +153 -0
  115. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/licenses/LICENSE +1 -1
  116. pdd_cli-0.0.42.dist-info/RECORD +0 -115
  117. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/WHEEL +0 -0
  118. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/entry_points.txt +0 -0
  119. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/top_level.txt +0 -0
@@ -1,10 +1,13 @@
1
1
  import os
2
+ import re
2
3
  import asyncio
3
4
  import json
4
5
  import pathlib
5
6
  import shlex
6
7
  import subprocess
7
8
  import requests
9
+ import tempfile
10
+ import sys
8
11
  from typing import Optional, Tuple, Dict, Any, List
9
12
 
10
13
  import click
@@ -19,6 +22,7 @@ from .preprocess import preprocess as pdd_preprocess
19
22
  from .code_generator import code_generator as local_code_generator_func
20
23
  from .incremental_code_generator import incremental_code_generator as incremental_code_generator_func
21
24
  from .get_jwt_token import get_jwt_token, AuthError, NetworkError, TokenError, UserCancelledError, RateLimitError
25
+ from .python_env_detector import detect_host_python_executable
22
26
 
23
27
  # Environment variable names for Firebase/GitHub auth
24
28
  FIREBASE_API_KEY_ENV_VAR = "NEXT_PUBLIC_FIREBASE_API_KEY"
@@ -31,6 +35,17 @@ CLOUD_REQUEST_TIMEOUT = 400 # seconds
31
35
 
32
36
  console = Console()
33
37
 
38
+ # --- Helper Functions ---
39
+ def _parse_llm_bool(value: str) -> bool:
40
+ """Parse LLM boolean value from string."""
41
+ if not value:
42
+ return True
43
+ llm_str = str(value).strip().lower()
44
+ if llm_str in {"0", "false", "no", "off"}:
45
+ return False
46
+ else:
47
+ return llm_str in {"1", "true", "yes", "on"}
48
+
34
49
  # --- Git Helper Functions ---
35
50
  def _run_git_command(command: List[str], cwd: Optional[str] = None) -> Tuple[int, str, str]:
36
51
  """Runs a git command and returns (return_code, stdout, stderr)."""
@@ -59,6 +74,102 @@ def is_git_repository(path: Optional[str] = None) -> bool:
59
74
  return False
60
75
 
61
76
 
77
+ def _expand_vars(text: str, vars_map: Optional[Dict[str, str]]) -> str:
78
+ """Replace $KEY and ${KEY} in text when KEY exists in vars_map. Leave others unchanged."""
79
+ if not text or not vars_map:
80
+ return text
81
+
82
+ def repl_braced(m: re.Match) -> str:
83
+ key = m.group(1)
84
+ return vars_map.get(key, m.group(0))
85
+
86
+ def repl_simple(m: re.Match) -> str:
87
+ key = m.group(1)
88
+ return vars_map.get(key, m.group(0))
89
+
90
+ # Replace ${KEY} first, then $KEY
91
+ text = re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", repl_braced, text)
92
+ text = re.sub(r"\$([A-Za-z_][A-Za-z0-9_]*)", repl_simple, text)
93
+ return text
94
+
95
+
96
+ def _parse_front_matter(text: str) -> Tuple[Optional[Dict[str, Any]], str]:
97
+ """Parse YAML front matter at the start of a prompt and return (meta, body)."""
98
+ try:
99
+ if not text.startswith("---\n"):
100
+ return None, text
101
+ end_idx = text.find("\n---", 4)
102
+ if end_idx == -1:
103
+ return None, text
104
+ fm_body = text[4:end_idx]
105
+ rest = text[end_idx + len("\n---"):]
106
+ if rest.startswith("\n"):
107
+ rest = rest[1:]
108
+ import yaml as _yaml
109
+ meta = _yaml.safe_load(fm_body) or {}
110
+ if not isinstance(meta, dict):
111
+ meta = {}
112
+ return meta, rest
113
+ except Exception:
114
+ return None, text
115
+
116
+
117
+ def _is_architecture_template(meta: Optional[Dict[str, Any]]) -> bool:
118
+ """Detect the packaged architecture JSON template via its front matter name."""
119
+ return isinstance(meta, dict) and meta.get("name") == "architecture/architecture_json"
120
+
121
+
122
+ def _repair_architecture_interface_types(payload: Any) -> Tuple[Any, bool]:
123
+ """
124
+ Patch common LLM slip-ups for the architecture template where interface.type
125
+ occasionally returns an unsupported value like "object". Only normalizes the
126
+ interface.type field and leaves other schema issues untouched so validation
127
+ still fails for genuinely malformed outputs.
128
+ """
129
+ allowed_types = {
130
+ "component",
131
+ "page",
132
+ "module",
133
+ "api",
134
+ "graphql",
135
+ "cli",
136
+ "job",
137
+ "message",
138
+ "config",
139
+ }
140
+ changed = False
141
+ if not isinstance(payload, list):
142
+ return payload, changed
143
+
144
+ for entry in payload:
145
+ if not isinstance(entry, dict):
146
+ continue
147
+ interface = entry.get("interface")
148
+ if not isinstance(interface, dict):
149
+ continue
150
+ raw_type = interface.get("type")
151
+ normalized = raw_type.lower() if isinstance(raw_type, str) else None
152
+ if normalized in allowed_types:
153
+ if normalized != raw_type:
154
+ interface["type"] = normalized
155
+ changed = True
156
+ continue
157
+
158
+ inferred_type = None
159
+ for key in ("page", "component", "module", "api", "graphql", "cli", "job", "message", "config"):
160
+ if isinstance(interface.get(key), dict):
161
+ inferred_type = key
162
+ break
163
+ if inferred_type is None:
164
+ inferred_type = "module"
165
+
166
+ if raw_type != inferred_type:
167
+ interface["type"] = inferred_type
168
+ changed = True
169
+
170
+ return payload, changed
171
+
172
+
62
173
  def get_git_content_at_ref(file_path: str, git_ref: str = "HEAD") -> Optional[str]:
63
174
  """Gets the content of the file as it was at the specified git_ref."""
64
175
  abs_file_path = pathlib.Path(file_path).resolve()
@@ -124,6 +235,28 @@ def git_add_files(file_paths: List[str], verbose: bool = False) -> bool:
124
235
  return False
125
236
  # --- End Git Helper Functions ---
126
237
 
238
+ def _find_default_test_files(tests_dir: Optional[str], code_file_path: Optional[str]) -> List[str]:
239
+ """Find default test files for a given code file in the tests directory."""
240
+ if not tests_dir or not code_file_path:
241
+ return []
242
+
243
+ tests_path = pathlib.Path(tests_dir)
244
+ code_path = pathlib.Path(code_file_path)
245
+
246
+ if not tests_path.exists() or not tests_path.is_dir():
247
+ return []
248
+
249
+ code_stem = code_path.stem
250
+ code_suffix = code_path.suffix
251
+
252
+ # Look for files starting with test_{code_stem}
253
+ # We look for test_{code_stem}*.{code_suffix}
254
+ # e.g., hello.py -> test_hello.py, test_hello_1.py
255
+ pattern = f"test_{code_stem}*{code_suffix}"
256
+ found_files = list(tests_path.glob(pattern))
257
+
258
+ return [str(p) for p in sorted(found_files)]
259
+
127
260
 
128
261
  def code_generator_main(
129
262
  ctx: click.Context,
@@ -131,6 +264,9 @@ def code_generator_main(
131
264
  output: Optional[str],
132
265
  original_prompt_file_path: Optional[str],
133
266
  force_incremental_flag: bool,
267
+ env_vars: Optional[Dict[str, str]] = None,
268
+ unit_test_file: Optional[str] = None,
269
+ exclude_tests: bool = False,
134
270
  ) -> Tuple[str, bool, float, str]:
135
271
  """
136
272
  CLI wrapper for generating code from prompts. Handles full and incremental generation,
@@ -157,20 +293,97 @@ def code_generator_main(
157
293
  command_options: Dict[str, Any] = {"output": output}
158
294
 
159
295
  try:
296
+ # Read prompt content once to determine LLM state and for construct_paths
297
+ with open(prompt_file, 'r', encoding='utf-8') as f:
298
+ raw_prompt_content = f.read()
299
+
300
+ # Phase-2 templates: parse front matter metadata
301
+ fm_meta, body = _parse_front_matter(raw_prompt_content)
302
+ if fm_meta:
303
+ prompt_content = body
304
+ else:
305
+ prompt_content = raw_prompt_content
306
+
307
+ # Determine LLM state early to avoid unnecessary overwrite prompts
308
+ llm_enabled: bool = True
309
+ env_llm_raw = None
310
+ try:
311
+ if env_vars and 'llm' in env_vars:
312
+ env_llm_raw = str(env_vars.get('llm'))
313
+ elif os.environ.get('llm') is not None:
314
+ env_llm_raw = os.environ.get('llm')
315
+ elif os.environ.get('LLM') is not None:
316
+ env_llm_raw = os.environ.get('LLM')
317
+ except Exception:
318
+ env_llm_raw = None
319
+
320
+ # Environment variables should override front matter
321
+ if env_llm_raw is not None:
322
+ llm_enabled = _parse_llm_bool(env_llm_raw)
323
+ elif fm_meta and isinstance(fm_meta, dict) and 'llm' in fm_meta:
324
+ llm_enabled = bool(fm_meta.get('llm', True))
325
+ # else: keep default True
326
+
327
+ # If LLM is disabled, we're only doing post-processing, so skip overwrite confirmation
328
+ effective_force = force_overwrite or not llm_enabled
329
+
160
330
  resolved_config, input_strings, output_file_paths, language = construct_paths(
161
331
  input_file_paths=input_file_paths_dict,
162
- force=force_overwrite,
332
+ force=effective_force,
163
333
  quiet=quiet,
164
334
  command="generate",
165
335
  command_options=command_options,
336
+ context_override=ctx.obj.get('context'),
337
+ confirm_callback=cli_params.get('confirm_callback')
166
338
  )
167
- prompt_content = input_strings["prompt_file"]
168
- # Prioritize orchestration output path over construct_paths result
169
- output_path = output or output_file_paths.get("output")
339
+ # Determine final output path: if user passed a directory, use resolved file path
340
+ resolved_output = output_file_paths.get("output")
341
+ if output is None:
342
+ output_path = resolved_output
343
+ else:
344
+ try:
345
+ is_dir_hint = output.endswith(os.path.sep) or output.endswith("/")
346
+ except Exception:
347
+ is_dir_hint = False
348
+ if is_dir_hint or os.path.isdir(output):
349
+ output_path = resolved_output
350
+ else:
351
+ output_path = output
352
+
353
+ # --- Unit Test Inclusion Logic ---
354
+ test_files_to_include: List[str] = []
355
+ if unit_test_file:
356
+ test_files_to_include.append(unit_test_file)
357
+ elif not exclude_tests:
358
+ # Try to find default test files
359
+ tests_dir = resolved_config.get("tests_dir")
360
+ found_tests = _find_default_test_files(tests_dir, output_path)
361
+ if found_tests:
362
+ if verbose:
363
+ console.print(f"[info]Found default test files: {', '.join(found_tests)}[/info]")
364
+ test_files_to_include.extend(found_tests)
365
+
366
+ if test_files_to_include:
367
+ prompt_content += "\n\n<unit_test_content>\n"
368
+ prompt_content += "The following is the unit test content that the generated code must pass:\n"
369
+ for tf in test_files_to_include:
370
+ try:
371
+ with open(tf, 'r', encoding='utf-8') as f:
372
+ content = f.read()
373
+ # If multiple files, label them? Or just concat?
374
+ # Using code block with file path comment is safer for context.
375
+ prompt_content += f"\nFile: {pathlib.Path(tf).name}\n```python\n{content}\n```\n"
376
+ except Exception as e:
377
+ console.print(f"[yellow]Warning: Could not read unit test file {tf}: {e}[/yellow]")
378
+ prompt_content += "</unit_test_content>\n"
379
+ # ---------------------------------
170
380
 
171
381
  except FileNotFoundError as e:
172
382
  console.print(f"[red]Error: Input file not found: {e.filename}[/red]")
173
383
  return "", False, 0.0, "error"
384
+ except click.Abort:
385
+ # User cancelled - re-raise to stop the sync loop
386
+ raise
174
387
  except Exception as e:
175
388
  console.print(f"[red]Error during path construction: {e}[/red]")
176
389
  return "", False, 0.0, "error"
@@ -179,6 +392,108 @@ def code_generator_main(
179
392
  existing_code_content: Optional[str] = None
180
393
  original_prompt_content_for_incremental: Optional[str] = None
181
394
 
395
+ # Merge -e vars with front-matter defaults; validate required
396
+ if env_vars is None:
397
+ env_vars = {}
398
+ if fm_meta and isinstance(fm_meta.get("variables"), dict):
399
+ for k, spec in (fm_meta["variables"].items()):
400
+ if isinstance(spec, dict):
401
+ if k not in env_vars and "default" in spec:
402
+ env_vars[k] = str(spec["default"])
403
+ # if scalar default allowed, ignore for now
404
+ missing = [k for k, spec in fm_meta["variables"].items() if isinstance(spec, dict) and spec.get("required") and k not in env_vars]
405
+ if missing:
406
+ console.print(f"[error]Missing required variables: {', '.join(missing)}")
407
+ return "", False, 0.0, "error"
408
+
409
+ # Execute optional discovery from front matter to populate env_vars without overriding explicit -e values
410
+ def _run_discovery(discover_cfg: Dict[str, Any]) -> Dict[str, str]:
411
+ results: Dict[str, str] = {}
412
+ try:
413
+ if not discover_cfg:
414
+ return results
415
+ enabled = discover_cfg.get("enabled", False)
416
+ if not enabled:
417
+ return results
418
+ root = discover_cfg.get("root", ".")
419
+ patterns = discover_cfg.get("patterns", []) or []
420
+ exclude = discover_cfg.get("exclude", []) or []
421
+ max_per = int(discover_cfg.get("max_per_pattern", 0) or 0)
422
+ max_total = int(discover_cfg.get("max_total", 0) or 0)
423
+ root_path = pathlib.Path(root).resolve()
424
+ seen: List[str] = []
425
+ def _match_one(patterns_list: List[str]) -> List[str]:
426
+ matches: List[str] = []
427
+ for pat in patterns_list:
428
+ globbed = list(root_path.rglob(pat))
429
+ for p in globbed:
430
+ if any(p.match(ex) for ex in exclude):
431
+ continue
432
+ sp = str(p.resolve())
433
+ if sp not in matches:
434
+ matches.append(sp)
435
+ if max_per and len(matches) >= max_per:
436
+ matches = matches[:max_per]
437
+ break
438
+ return matches
439
+ # If a mapping 'set' is provided, compute per-variable results
440
+ set_map = discover_cfg.get("set") or {}
441
+ if isinstance(set_map, dict) and set_map:
442
+ for var_name, spec in set_map.items():
443
+ if var_name in env_vars:
444
+ continue # don't override explicit -e
445
+ v_patterns = spec.get("patterns", []) if isinstance(spec, dict) else []
446
+ v_exclude = spec.get("exclude", []) if isinstance(spec, dict) else []
447
+ save_exclude = exclude
448
+ try:
449
+ if v_exclude:
450
+ exclude = v_exclude
451
+ matches = _match_one(v_patterns or patterns)
452
+ finally:
453
+ exclude = save_exclude
454
+ if matches:
455
+ results[var_name] = ",".join(matches)
456
+ seen.extend(matches)
457
+ # Fallback: populate SCAN_FILES and SCAN metadata
458
+ if not results:
459
+ files = _match_one(patterns)
460
+ if max_total and len(files) > max_total:
461
+ files = files[:max_total]
462
+ if files:
463
+ results["SCAN_FILES"] = ",".join(files)
464
+ # Always set root/patterns helpers
465
+ if root:
466
+ results.setdefault("SCAN_ROOT", str(root_path))
467
+ if patterns:
468
+ results.setdefault("SCAN_PATTERNS", ",".join(patterns))
469
+ except Exception as e:
470
+ if verbose and not quiet:
471
+ console.print(f"[yellow]Discovery skipped due to error: {e}[/yellow]")
472
+ return results
473
+
474
+ if fm_meta and isinstance(fm_meta.get("discover"), dict):
475
+ discovered = _run_discovery(fm_meta.get("discover") or {})
476
+ for k, v in discovered.items():
477
+ if k not in env_vars:
478
+ env_vars[k] = v
479
+
480
+ # Expand variables in output path if provided
481
+ if output_path:
482
+ output_path = _expand_vars(output_path, env_vars)
483
+
484
+ # Honor front-matter output when CLI did not pass --output
485
+ if output is None and fm_meta and isinstance(fm_meta.get("output"), str):
486
+ try:
487
+ meta_out = _expand_vars(fm_meta["output"], env_vars)
488
+ if meta_out:
489
+ output_path = str(pathlib.Path(meta_out).resolve())
490
+ except Exception:
491
+ pass
492
+
493
+ # Honor front-matter language if provided (overrides detection for both local and cloud)
494
+ if fm_meta and isinstance(fm_meta.get("language"), str) and fm_meta.get("language"):
495
+ language = fm_meta.get("language")
496
+
182
497
  if output_path and pathlib.Path(output_path).exists():
183
498
  try:
184
499
  existing_code_content = pathlib.Path(output_path).read_text(encoding="utf-8")
@@ -303,7 +618,96 @@ def code_generator_main(
303
618
  can_attempt_incremental = False
304
619
 
305
620
  try:
306
- if can_attempt_incremental and existing_code_content is not None and original_prompt_content_for_incremental is not None:
621
+ # Resolve post-process script from env/CLI override, then front matter, then sensible default per template
622
+ post_process_script: Optional[str] = None
623
+ prompt_body_for_script: str = prompt_content
624
+
625
+ if verbose:
626
+ console.print(f"[blue]LLM enabled:[/blue] {llm_enabled}")
627
+ try:
628
+ post_process_script = None
629
+ script_override = None
630
+ if env_vars:
631
+ script_override = env_vars.get('POST_PROCESS_PYTHON') or env_vars.get('post_process_python')
632
+ if not script_override:
633
+ script_override = os.environ.get('POST_PROCESS_PYTHON') or os.environ.get('post_process_python')
634
+ if script_override and str(script_override).strip():
635
+ expanded = _expand_vars(str(script_override), env_vars)
636
+ pkg_dir = pathlib.Path(__file__).parent.resolve()
637
+ repo_root = pathlib.Path.cwd().resolve()
638
+ repo_pdd_dir = (repo_root / 'pdd').resolve()
639
+ candidate = pathlib.Path(expanded)
640
+ if not candidate.is_absolute():
641
+ # 1) As provided, relative to CWD
642
+ as_is = (repo_root / candidate)
643
+ # 2) Under repo pdd/
644
+ under_repo_pdd = (repo_pdd_dir / candidate.name) if not as_is.exists() else as_is
645
+ # 3) Under installed package dir
646
+ under_pkg = (pkg_dir / candidate.name) if not as_is.exists() and not under_repo_pdd.exists() else as_is
647
+ if as_is.exists():
648
+ candidate = as_is
649
+ elif under_repo_pdd.exists():
650
+ candidate = under_repo_pdd
651
+ elif under_pkg.exists():
652
+ candidate = under_pkg
653
+ else:
654
+ candidate = as_is # will fail later with not found
655
+ post_process_script = str(candidate.resolve())
656
+ elif fm_meta and isinstance(fm_meta, dict):
657
+ raw_script = fm_meta.get('post_process_python')
658
+ if isinstance(raw_script, str) and raw_script.strip():
659
+ # Expand variables like $VAR and ${VAR}
660
+ expanded = _expand_vars(raw_script, env_vars)
661
+ pkg_dir = pathlib.Path(__file__).parent.resolve()
662
+ repo_root = pathlib.Path.cwd().resolve()
663
+ repo_pdd_dir = (repo_root / 'pdd').resolve()
664
+ candidate = pathlib.Path(expanded)
665
+ if not candidate.is_absolute():
666
+ as_is = (repo_root / candidate)
667
+ under_repo_pdd = (repo_pdd_dir / candidate.name) if not as_is.exists() else as_is
668
+ under_pkg = (pkg_dir / candidate.name) if not as_is.exists() and not under_repo_pdd.exists() else as_is
669
+ if as_is.exists():
670
+ candidate = as_is
671
+ elif under_repo_pdd.exists():
672
+ candidate = under_repo_pdd
673
+ elif under_pkg.exists():
674
+ candidate = under_pkg
675
+ else:
676
+ candidate = as_is
677
+ post_process_script = str(candidate.resolve())
678
+ # Fallback default: for architecture template, use built-in render_mermaid.py
679
+ if not post_process_script:
680
+ try:
681
+ prompt_str = str(prompt_file)
682
+ looks_like_arch_template = (
683
+ (isinstance(prompt_file, str) and (
684
+ prompt_str.endswith("architecture/architecture_json.prompt") or
685
+ prompt_str.endswith("architecture/architecture_json") or
686
+ "architecture_json.prompt" in prompt_str or
687
+ "architecture/architecture_json" in prompt_str
688
+ ))
689
+ )
690
+ looks_like_arch_output = (
691
+ bool(output_path) and pathlib.Path(str(output_path)).name == 'architecture.json'
692
+ )
693
+ if looks_like_arch_template or looks_like_arch_output:
694
+ pkg_dir = pathlib.Path(__file__).parent
695
+ repo_pdd_dir = pathlib.Path.cwd() / 'pdd'
696
+ if (pkg_dir / 'render_mermaid.py').exists():
697
+ post_process_script = str((pkg_dir / 'render_mermaid.py').resolve())
698
+ elif (repo_pdd_dir / 'render_mermaid.py').exists():
699
+ post_process_script = str((repo_pdd_dir / 'render_mermaid.py').resolve())
700
+ except Exception:
701
+ post_process_script = None
702
+ if verbose:
703
+ console.print(f"[blue]Post-process script resolved to:[/blue] {post_process_script if post_process_script else 'None'}")
704
+ except Exception:
705
+ post_process_script = None
706
+ # If LLM is disabled but no post-process script is provided, surface a helpful error
707
+ if not llm_enabled and not post_process_script:
708
+ console.print("[red]Error: llm: false requires 'post_process_python' to be specified in front matter.[/red]")
709
+ return "", was_incremental_operation, total_cost, "error"
710
+ if llm_enabled and can_attempt_incremental and existing_code_content is not None and original_prompt_content_for_incremental is not None:
307
711
  if verbose:
308
712
  console.print(Panel("Attempting incremental code generation...", title="[blue]Mode[/blue]", expand=False))
309
713
 
@@ -326,9 +730,18 @@ def code_generator_main(
326
730
  if files_to_stage_for_rollback:
327
731
  git_add_files(files_to_stage_for_rollback, verbose=verbose)
328
732
 
733
+ # Preprocess both prompts: expand includes, substitute vars, then double
734
+ orig_proc = pdd_preprocess(original_prompt_content_for_incremental, recursive=True, double_curly_brackets=False)
735
+ orig_proc = _expand_vars(orig_proc, env_vars)
736
+ orig_proc = pdd_preprocess(orig_proc, recursive=False, double_curly_brackets=True)
737
+
738
+ new_proc = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False)
739
+ new_proc = _expand_vars(new_proc, env_vars)
740
+ new_proc = pdd_preprocess(new_proc, recursive=False, double_curly_brackets=True)
741
+
329
742
  generated_code_content, was_incremental_operation, total_cost, model_name = incremental_code_generator_func(
330
- original_prompt=original_prompt_content_for_incremental,
331
- new_prompt=prompt_content,
743
+ original_prompt=orig_proc,
744
+ new_prompt=new_proc,
332
745
  existing_code=existing_code_content,
333
746
  language=language,
334
747
  strength=strength,
@@ -336,7 +749,7 @@ def code_generator_main(
336
749
  time=time_budget,
337
750
  force_incremental=force_incremental_flag,
338
751
  verbose=verbose,
339
- preprocess_prompt=True
752
+ preprocess_prompt=False
340
753
  )
341
754
 
342
755
  if not was_incremental_operation:
@@ -345,7 +758,7 @@ def code_generator_main(
345
758
  elif verbose:
346
759
  console.print(Panel(f"Incremental update successful. Model: {model_name}, Cost: ${total_cost:.6f}", title="[green]Incremental Success[/green]", expand=False))
347
760
 
348
- if not was_incremental_operation: # Full generation path
761
+ if llm_enabled and not was_incremental_operation: # Full generation path
349
762
  if verbose:
350
763
  console.print(Panel("Performing full code generation...", title="[blue]Mode[/blue]", expand=False))
351
764
 
@@ -353,8 +766,10 @@ def code_generator_main(
353
766
 
354
767
  if not current_execution_is_local:
355
768
  if verbose: console.print("Attempting cloud code generation...")
356
-
357
- processed_prompt_for_cloud = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=True, exclude_keys=[])
769
+ # Expand includes, substitute vars, then double
770
+ processed_prompt_for_cloud = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[])
771
+ processed_prompt_for_cloud = _expand_vars(processed_prompt_for_cloud, env_vars)
772
+ processed_prompt_for_cloud = pdd_preprocess(processed_prompt_for_cloud, recursive=False, double_curly_brackets=True, exclude_keys=[])
358
773
  if verbose: console.print(Panel(Text(processed_prompt_for_cloud, overflow="fold"), title="[cyan]Preprocessed Prompt for Cloud[/cyan]", expand=False))
359
774
 
360
775
  jwt_token: Optional[str] = None
@@ -389,7 +804,7 @@ def code_generator_main(
389
804
  total_cost = float(response_data.get("totalCost", 0.0))
390
805
  model_name = response_data.get("modelName", "cloud_model")
391
806
 
392
- if generated_code_content is None:
807
+ if not generated_code_content:
393
808
  console.print("[yellow]Cloud execution returned no code. Falling back to local.[/yellow]")
394
809
  current_execution_is_local = True
395
810
  elif verbose:
@@ -410,36 +825,268 @@ def code_generator_main(
410
825
 
411
826
  if current_execution_is_local:
412
827
  if verbose: console.print("Executing code generator locally...")
828
+ # Expand includes, substitute vars, then double; pass to local generator with preprocess_prompt=False
829
+ local_prompt = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[])
830
+ local_prompt = _expand_vars(local_prompt, env_vars)
831
+ local_prompt = pdd_preprocess(local_prompt, recursive=False, double_curly_brackets=True, exclude_keys=[])
832
+ # Language already resolved (front matter overrides detection if present)
833
+ gen_language = language
834
+
835
+ # Extract output schema from front matter if available
836
+ output_schema = fm_meta.get("output_schema") if fm_meta else None
837
+
413
838
  generated_code_content, total_cost, model_name = local_code_generator_func(
414
- prompt=prompt_content,
415
- language=language,
839
+ prompt=local_prompt,
840
+ language=gen_language,
416
841
  strength=strength,
417
842
  temperature=temperature,
418
843
  time=time_budget,
419
844
  verbose=verbose,
420
- preprocess_prompt=True
845
+ preprocess_prompt=False,
846
+ output_schema=output_schema,
421
847
  )
422
848
  was_incremental_operation = False
423
849
  if verbose:
424
850
  console.print(Panel(f"Full generation successful. Model: {model_name}, Cost: ${total_cost:.6f}", title="[green]Local Success[/green]", expand=False))
425
-
851
+
852
+ # Optional post-process Python hook (runs after LLM when enabled, or standalone when LLM is disabled)
853
+ if post_process_script:
854
+ try:
855
+ python_executable = detect_host_python_executable()
856
+ # Choose stdin for the script: LLM output if available and enabled, else prompt body
857
+ stdin_payload = generated_code_content if (llm_enabled and generated_code_content is not None) else prompt_body_for_script
858
+ env = os.environ.copy()
859
+ env['PDD_LANGUAGE'] = str(language or '')
860
+ env['PDD_OUTPUT_PATH'] = str(output_path or '')
861
+ env['PDD_PROMPT_FILE'] = str(pathlib.Path(prompt_file).resolve())
862
+ env['PDD_LLM'] = '1' if llm_enabled else '0'
863
+ try:
864
+ env['PDD_ENV_VARS'] = json.dumps(env_vars or {})
865
+ except Exception:
866
+ env['PDD_ENV_VARS'] = '{}'
867
+ # If front matter provides args, run in argv mode with a temp input file
868
+ fm_args = None
869
+ try:
870
+ # Env/CLI override for args (comma-separated or JSON list)
871
+ raw_args_env = None
872
+ if env_vars:
873
+ raw_args_env = env_vars.get('POST_PROCESS_ARGS') or env_vars.get('post_process_args')
874
+ if not raw_args_env:
875
+ raw_args_env = os.environ.get('POST_PROCESS_ARGS') or os.environ.get('post_process_args')
876
+ if raw_args_env:
877
+ s = str(raw_args_env).strip()
878
+ parsed_list = None
879
+ if s.startswith('[') and s.endswith(']'):
880
+ try:
881
+ parsed = json.loads(s)
882
+ if isinstance(parsed, list):
883
+ parsed_list = [str(a) for a in parsed]
884
+ except Exception:
885
+ parsed_list = None
886
+ if parsed_list is None:
887
+ if ',' in s:
888
+ parsed_list = [part.strip() for part in s.split(',') if part.strip()]
889
+ else:
890
+ parsed_list = [part for part in s.split() if part]
891
+ fm_args = parsed_list or None
892
+ if fm_args is None:
893
+ raw_args = fm_meta.get('post_process_args') if isinstance(fm_meta, dict) else None
894
+ if isinstance(raw_args, list):
895
+ fm_args = [str(a) for a in raw_args]
896
+ except Exception:
897
+ fm_args = None
898
+ proc = None
899
+ temp_input_path = None
900
+ try:
901
+ if fm_args is None:
902
+ # Provide sensible default args for architecture template with render_mermaid.py
903
+ try:
904
+ if post_process_script and pathlib.Path(post_process_script).name == 'render_mermaid.py':
905
+ if isinstance(prompt_file, str) and prompt_file.endswith('architecture/architecture_json.prompt'):
906
+ fm_args = ["{INPUT_FILE}", "{APP_NAME}", "{OUTPUT_HTML}"]
907
+ except Exception:
908
+ pass
909
+ if fm_args:
910
+ # When LLM is disabled, use the existing output file instead of creating a temp file
911
+ if not llm_enabled and output_path and pathlib.Path(output_path).exists():
912
+ temp_input_path = str(pathlib.Path(output_path).resolve())
913
+ env['PDD_POSTPROCESS_INPUT_FILE'] = temp_input_path
914
+ else:
915
+ # Write payload to a temp file for scripts expecting a file path input
916
+ suffix = '.json' if (isinstance(language, str) and str(language).lower().strip() == 'json') or (output_path and str(output_path).lower().endswith('.json')) else '.txt'
917
+ if output_path and llm_enabled:
918
+ temp_input_path = str(pathlib.Path(output_path).resolve())
919
+ pathlib.Path(temp_input_path).parent.mkdir(parents=True, exist_ok=True)
920
+ with open(temp_input_path, 'w', encoding='utf-8') as f:
921
+ f.write(stdin_payload or '')
922
+ else:
923
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=suffix, encoding='utf-8') as tf:
924
+ tf.write(stdin_payload or '')
925
+ temp_input_path = tf.name
926
+ env['PDD_POSTPROCESS_INPUT_FILE'] = temp_input_path
927
+ # Compute placeholder values
928
+ app_name_val = (env_vars or {}).get('APP_NAME') if env_vars else None
929
+ if not app_name_val:
930
+ app_name_val = 'System Architecture'
931
+ output_html_default = None
932
+ if output_path and str(output_path).lower().endswith('.json'):
933
+ output_html_default = str(pathlib.Path(output_path).with_name(f"{pathlib.Path(output_path).stem}_diagram.html").resolve())
934
+ placeholder_map = {
935
+ 'INPUT_FILE': temp_input_path or '',
936
+ 'OUTPUT': str(output_path or ''),
937
+ 'PROMPT_FILE': str(pathlib.Path(prompt_file).resolve()),
938
+ 'APP_NAME': str(app_name_val),
939
+ 'OUTPUT_HTML': str(output_html_default or ''),
940
+ }
941
+ def _subst_arg(arg: str) -> str:
942
+ # First expand $VARS using existing helper, then {TOKENS}
943
+ expanded = _expand_vars(arg, env_vars)
944
+ for key, val in placeholder_map.items():
945
+ expanded = expanded.replace('{' + key + '}', val)
946
+ return expanded
947
+ args_list = [_subst_arg(a) for a in fm_args]
948
+ if verbose:
949
+ console.print(Panel(f"Post-process hook (argv)\nScript: {post_process_script}\nArgs: {args_list}", title="[blue]Post-process[/blue]", expand=False))
950
+ proc = subprocess.run(
951
+ [python_executable, post_process_script] + args_list,
952
+ text=True,
953
+ capture_output=True,
954
+ timeout=300,
955
+ cwd=str(pathlib.Path(post_process_script).parent),
956
+ env=env
957
+ )
958
+ else:
959
+ # Run the script with stdin payload, capture stdout as final content
960
+ if verbose:
961
+ console.print(Panel(f"Post-process hook (stdin)\nScript: {post_process_script}", title="[blue]Post-process[/blue]", expand=False))
962
+ proc = subprocess.run(
963
+ [python_executable, post_process_script],
964
+ input=stdin_payload or '',
965
+ text=True,
966
+ capture_output=True,
967
+ timeout=300,
968
+ cwd=str(pathlib.Path(post_process_script).parent),
969
+ env=env
970
+ )
971
+ finally:
972
+ if temp_input_path:
973
+ try:
974
+ # Only delete temp files, not the actual output file when llm=false
975
+ if llm_enabled or not (output_path and pathlib.Path(output_path).exists() and temp_input_path == str(pathlib.Path(output_path).resolve())):
976
+ os.unlink(temp_input_path)
977
+ except Exception:
978
+ pass
979
+ if proc and proc.returncode == 0:
980
+ if verbose:
981
+ console.print(Panel(f"Post-process success (rc=0)\nstdout: {proc.stdout[:150]}\nstderr: {proc.stderr[:150]}", title="[green]Post-process[/green]", expand=False))
982
+ # Do not modify generated_code_content to preserve architecture.json
983
+ else:
984
+ rc = getattr(proc, 'returncode', 'N/A')
985
+ err = getattr(proc, 'stderr', '')
986
+ console.print(f"[yellow]Post-process failed (rc={rc}). Stderr:\n{err[:500]}[/yellow]")
987
+ except FileNotFoundError:
988
+ console.print(f"[yellow]Post-process script not found: {post_process_script}. Skipping.[/yellow]")
989
+ except subprocess.TimeoutExpired:
990
+ console.print("[yellow]Post-process script timed out. Skipping.[/yellow]")
991
+ except Exception as e:
992
+ console.print(f"[yellow]Post-process script error: {e}. Skipping.[/yellow]")
426
993
  if generated_code_content is not None:
994
+ # Optional output_schema JSON validation before writing (only when LLM ran)
995
+ if llm_enabled:
996
+ try:
997
+ if fm_meta and isinstance(fm_meta.get("output_schema"), dict):
998
+ is_json_output = False
999
+ if isinstance(language, str) and str(language).lower().strip() == "json":
1000
+ is_json_output = True
1001
+ elif output_path and str(output_path).lower().endswith(".json"):
1002
+ is_json_output = True
1003
+ if is_json_output:
1004
+ # Check if the generated content is an error message from llm_invoke
1005
+ if generated_code_content.strip().startswith("ERROR:"):
1006
+ raise click.UsageError(f"LLM generation failed: {generated_code_content}")
1007
+
1008
+ parsed = json.loads(generated_code_content)
1009
+ if _is_architecture_template(fm_meta):
1010
+ parsed, repaired = _repair_architecture_interface_types(parsed)
1011
+ if repaired:
1012
+ generated_code_content = json.dumps(parsed, indent=2)
1013
+ try:
1014
+ import jsonschema
1015
+ jsonschema.validate(instance=parsed, schema=fm_meta.get("output_schema"))
1016
+ except ModuleNotFoundError:
1017
+ if verbose and not quiet:
1018
+ console.print("[yellow]jsonschema not installed; skipping schema validation.[/yellow]")
1019
+ except Exception as ve:
1020
+ raise click.UsageError(f"Generated JSON does not match output_schema: {ve}")
1021
+ except json.JSONDecodeError as jde:
1022
+ raise click.UsageError(f"Generated output is not valid JSON: {jde}")
1023
+
427
1024
  if output_path:
428
1025
  p_output = pathlib.Path(output_path)
429
1026
  p_output.parent.mkdir(parents=True, exist_ok=True)
430
1027
  p_output.write_text(generated_code_content, encoding="utf-8")
431
1028
  if verbose or not quiet:
432
1029
  console.print(f"Generated code saved to: [green]{p_output.resolve()}[/green]")
433
- elif not quiet: # No output path, print to console if not quiet
434
- console.print(Panel(Text(generated_code_content, overflow="fold"), title="[cyan]Generated Code[/cyan]", expand=True))
1030
+ # Safety net: ensure architecture HTML is generated post-write if applicable
1031
+ try:
1032
+ # Prefer resolved script if available; else default for architecture outputs
1033
+ script_path2 = post_process_script
1034
+ if not script_path2:
1035
+ looks_like_arch_output2 = pathlib.Path(str(p_output)).name == 'architecture.json'
1036
+ if looks_like_arch_output2:
1037
+ pkg_dir2 = pathlib.Path(__file__).parent
1038
+ repo_pdd_dir2 = pathlib.Path.cwd() / 'pdd'
1039
+ if (pkg_dir2 / 'render_mermaid.py').exists():
1040
+ script_path2 = str((pkg_dir2 / 'render_mermaid.py').resolve())
1041
+ elif (repo_pdd_dir2 / 'render_mermaid.py').exists():
1042
+ script_path2 = str((repo_pdd_dir2 / 'render_mermaid.py').resolve())
1043
+ if script_path2 and pathlib.Path(script_path2).exists():
1044
+ app_name2 = os.environ.get('APP_NAME') or (env_vars or {}).get('APP_NAME') or 'System Architecture'
1045
+ out_html2 = os.environ.get('POST_PROCESS_OUTPUT') or str(p_output.with_name(f"{p_output.stem}_diagram.html").resolve())
1046
+ html_missing = not pathlib.Path(out_html2).exists()
1047
+ always_run_for_arch = pathlib.Path(str(p_output)).name == 'architecture.json'
1048
+ if always_run_for_arch or html_missing:
1049
+ try:
1050
+ py_exec2 = detect_host_python_executable()
1051
+ except Exception:
1052
+ py_exec2 = sys.executable
1053
+ if verbose:
1054
+ console.print(Panel(f"Safety net post-process\nScript: {script_path2}\nArgs: {[str(p_output.resolve()), app_name2, out_html2]}", title="[blue]Post-process[/blue]", expand=False))
1055
+ sp2 = subprocess.run([py_exec2, script_path2, str(p_output.resolve()), app_name2, out_html2],
1056
+ capture_output=True, text=True, cwd=str(pathlib.Path(script_path2).parent))
1057
+ if sp2.returncode == 0 and not quiet:
1058
+ print(f"✅ Generated: {out_html2}")
1059
+ elif verbose:
1060
+ console.print(f"[yellow]Safety net failed (rc={sp2.returncode}). stderr:\n{sp2.stderr[:300]}[/yellow]")
1061
+ except Exception:
1062
+ pass
1063
+ # Post-step now runs regardless of LLM value via the general post-process hook above.
1064
+ elif not quiet:
1065
+ # No destination resolved; surface the generated code directly to the console.
1066
+ console.print(Panel(Text(generated_code_content, overflow="fold"), title="[cyan]Generated Code[/cyan]", expand=False))
1067
+ console.print("[yellow]No output path resolved; skipping file write and stdout print.[/yellow]")
435
1068
  else:
436
- console.print("[red]Error: Code generation failed. No code was produced.[/red]")
437
- return "", was_incremental_operation, total_cost, model_name or "error"
1069
+ # If LLM was disabled and post-process ran, that's a success (no error)
1070
+ if not llm_enabled and post_process_script:
1071
+ if verbose or not quiet:
1072
+ console.print("[green]Post-process completed successfully (LLM was disabled).[/green]")
1073
+ else:
1074
+ console.print("[red]Error: Code generation failed. No code was produced.[/red]")
1075
+ return "", was_incremental_operation, total_cost, model_name or "error"
438
1076
 
1077
+ except click.Abort:
1078
+ # User cancelled - re-raise to stop the sync loop
1079
+ raise
439
1080
  except Exception as e:
440
- console.print(f"[red]An unexpected error occurred: {e}[/red]")
441
- import traceback
442
- if verbose: console.print(traceback.format_exc())
443
- return "", was_incremental_operation, total_cost, "error"
1081
+ if isinstance(e, click.UsageError):
1082
+ raise
1083
+
1084
+ # For any other unexpected error, we should fail hard so the CLI exits non-zero
1085
+ # Log the detailed traceback first if verbose
1086
+ if verbose:
1087
+ import traceback
1088
+ console.print(traceback.format_exc())
1089
+
1090
+ raise click.UsageError(f"An unexpected error occurred: {e}")
444
1091
 
445
- return generated_code_content or "", was_incremental_operation, total_cost, model_name
1092
+ return generated_code_content or "", was_incremental_operation, total_cost, model_name