pdd-cli 0.0.56__py3-none-any.whl → 0.0.58__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.

Potentially problematic release.


This version of pdd-cli might be problematic. Click here for more details.

pdd/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """PDD - Prompt Driven Development"""
2
2
 
3
- __version__ = "0.0.56"
3
+ __version__ = "0.0.58"
4
4
 
5
5
  # Strength parameter used for LLM extraction across the codebase
6
6
  # Used in postprocessing, XML tagging, code generation, and other extraction
pdd/cli.py CHANGED
@@ -8,6 +8,8 @@ generating code, tests, fixing issues, and managing prompts.
8
8
  from __future__ import annotations
9
9
 
10
10
  import os
11
+ import subprocess
12
+ import sys
11
13
  from typing import Any, Dict, List, Optional, Tuple
12
14
  from pathlib import Path # Import Path
13
15
 
@@ -32,7 +34,12 @@ from .crash_main import crash_main
32
34
  from .detect_change_main import detect_change_main
33
35
  from .fix_main import fix_main
34
36
  from .fix_verification_main import fix_verification_main
35
- from .install_completion import install_completion, get_local_pdd_path
37
+ from .install_completion import (
38
+ install_completion,
39
+ get_local_pdd_path,
40
+ get_current_shell,
41
+ get_shell_rc_path,
42
+ )
36
43
  from .preprocess_main import preprocess_main
37
44
  from .pytest_output import run_pytest_and_capture_output
38
45
  from .split_main import split_main
@@ -40,6 +47,7 @@ from .sync_main import sync_main
40
47
  from .trace_main import trace_main
41
48
  from .track_cost import track_cost
42
49
  from .update_main import update_main
50
+ from . import template_registry
43
51
 
44
52
 
45
53
  # --- Initialize Rich Console ---
@@ -75,8 +83,89 @@ def handle_error(exception: Exception, command_name: str, quiet: bool):
75
83
  # Do NOT re-raise e here. Let the command function return None.
76
84
 
77
85
 
86
+ def _first_pending_command(ctx: click.Context) -> Optional[str]:
87
+ """Return the first subcommand scheduled for this invocation."""
88
+ for arg in ctx.protected_args:
89
+ if not arg.startswith("-"):
90
+ return arg
91
+ return None
92
+
93
+
94
+ def _api_env_exists() -> bool:
95
+ """Check whether the ~/.pdd/api-env file exists."""
96
+ return (Path.home() / ".pdd" / "api-env").exists()
97
+
98
+
99
+ def _completion_installed() -> bool:
100
+ """Check if the shell RC file already sources the PDD completion script."""
101
+ shell = get_current_shell()
102
+ rc_path = get_shell_rc_path(shell) if shell else None
103
+ if not rc_path:
104
+ return False
105
+
106
+ try:
107
+ content = Path(rc_path).read_text(encoding="utf-8")
108
+ except (OSError, UnicodeDecodeError):
109
+ return False
110
+
111
+ return "PDD CLI completion" in content or "pdd_completion" in content
112
+
113
+
114
+ def _project_has_local_configuration() -> bool:
115
+ """Detect project-level env configuration that should suppress reminders."""
116
+ cwd = Path.cwd()
117
+
118
+ env_file = cwd / ".env"
119
+ if env_file.exists():
120
+ try:
121
+ env_content = env_file.read_text(encoding="utf-8")
122
+ except (OSError, UnicodeDecodeError):
123
+ env_content = ""
124
+ if any(token in env_content for token in ("OPENAI_API_KEY=", "GOOGLE_API_KEY=", "ANTHROPIC_API_KEY=")):
125
+ return True
126
+
127
+ project_pdd_dir = cwd / ".pdd"
128
+ if project_pdd_dir.exists():
129
+ return True
130
+
131
+ return False
132
+
133
+
134
+ def _should_show_onboarding_reminder(ctx: click.Context) -> bool:
135
+ """Determine whether to display the onboarding reminder banner."""
136
+ suppress = os.getenv("PDD_SUPPRESS_SETUP_REMINDER", "").lower()
137
+ if suppress in {"1", "true", "yes"}:
138
+ return False
139
+
140
+ first_command = _first_pending_command(ctx)
141
+ if first_command == "setup":
142
+ return False
143
+
144
+ if _api_env_exists():
145
+ return False
146
+
147
+ if _project_has_local_configuration():
148
+ return False
149
+
150
+ if _completion_installed():
151
+ return False
152
+
153
+ return True
154
+
155
+
156
+ def _run_setup_utility() -> None:
157
+ """Execute the interactive setup utility script."""
158
+ setup_script = Path(__file__).resolve().parent.parent / "utils" / "pdd-setup.py"
159
+ if not setup_script.exists():
160
+ raise FileNotFoundError(f"Setup utility not found at {setup_script}")
161
+
162
+ result = subprocess.run([sys.executable, str(setup_script)])
163
+ if result.returncode not in (0, None):
164
+ raise RuntimeError(f"Setup utility exited with status {result.returncode}")
165
+
166
+
78
167
  # --- Main CLI Group ---
79
- @click.group(chain=True, invoke_without_command=True, help="PDD (Prompt-Driven Development) Command Line Interface.")
168
+ @click.group(invoke_without_command=True, help="PDD (Prompt-Driven Development) Command Line Interface.")
80
169
  @click.option(
81
170
  "--force",
82
171
  is_flag=True,
@@ -166,7 +255,6 @@ def cli(
166
255
  ):
167
256
  """
168
257
  Main entry point for the PDD CLI. Handles global options and initializes context.
169
- Supports multi-command chaining.
170
258
  """
171
259
  # Ensure PDD_PATH is set before any commands run
172
260
  get_local_pdd_path()
@@ -189,6 +277,13 @@ def cli(
189
277
  if quiet:
190
278
  ctx.obj["verbose"] = False
191
279
 
280
+ # Warn users who have not completed interactive setup unless they are running it now
281
+ if _should_show_onboarding_reminder(ctx):
282
+ console.print(
283
+ "[warning]Complete onboarding with `pdd setup` to install tab completion and configure API keys.[/warning]"
284
+ )
285
+ ctx.obj["reminder_shown"] = True
286
+
192
287
  # If --list-contexts is provided, print and exit before any other actions
193
288
  if list_contexts:
194
289
  try:
@@ -227,17 +322,23 @@ def cli(
227
322
  style="warning"
228
323
  )
229
324
 
230
- # --- Result Callback for Chained Commands ---
325
+ # If no subcommands were provided, show help and exit gracefully
326
+ if ctx.invoked_subcommand is None and not ctx.protected_args:
327
+ if not quiet:
328
+ console.print("[info]Run `pdd --help` for usage or `pdd setup` to finish onboarding.[/info]")
329
+ click.echo(ctx.get_help())
330
+ ctx.exit(0)
331
+
332
+ # --- Result Callback for Command Execution Summary ---
231
333
  @cli.result_callback()
232
334
  @click.pass_context
233
335
  def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float, str]]], **kwargs):
234
336
  """
235
- Processes the results from chained commands.
236
-
337
+ Processes results returned by executed commands and prints a summary.
237
338
  Receives a list of tuples, typically (result, cost, model_name),
238
339
  or None from each command function.
239
340
  """
240
- total_chain_cost = 0.0
341
+ total_cost = 0.0
241
342
  # Get Click's invoked subcommands attribute first
242
343
  invoked_subcommands = getattr(ctx, 'invoked_subcommands', [])
243
344
  # If Click didn't provide it (common in real runs), fall back to the list
@@ -251,11 +352,12 @@ def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float
251
352
  invoked_subcommands = ctx.obj.get('invoked_subcommands', []) or []
252
353
  except Exception:
253
354
  invoked_subcommands = []
355
+ results = results or []
254
356
  num_commands = len(invoked_subcommands)
255
357
  num_results = len(results) # Number of results actually received
256
358
 
257
359
  if not ctx.obj.get("quiet"):
258
- console.print("\n[info]--- Command Chain Execution Summary ---[/info]")
360
+ console.print("\n[info]--- Command Execution Summary ---[/info]")
259
361
 
260
362
  for i, result_tuple in enumerate(results):
261
363
  # Use the retrieved subcommand name (might be "Unknown Command X" in tests)
@@ -279,7 +381,7 @@ def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float
279
381
  # Check if the result is the expected tuple structure from @track_cost or preprocess success
280
382
  elif isinstance(result_tuple, tuple) and len(result_tuple) == 3:
281
383
  _result_data, cost, model_name = result_tuple
282
- total_chain_cost += cost
384
+ total_cost += cost
283
385
  if not ctx.obj.get("quiet"):
284
386
  # Special handling for preprocess success message (check actual command name)
285
387
  actual_command_name = invoked_subcommands[i] if i < num_commands else None # Get actual name if possible
@@ -298,17 +400,107 @@ def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float
298
400
  if not ctx.obj.get("quiet"):
299
401
  # Only print total cost if at least one command potentially contributed cost
300
402
  if any(res is not None and isinstance(res, tuple) and len(res) == 3 for res in results):
301
- console.print(f"[info]Total Estimated Cost for Chain:[/info] ${total_chain_cost:.6f}")
403
+ console.print(f"[info]Total Estimated Cost:[/info] ${total_cost:.6f}")
302
404
  # Indicate if the chain might have been incomplete due to errors
303
405
  if num_results < num_commands and not all(res is None for res in results): # Avoid printing if all failed
304
406
  console.print("[warning]Note: Chain may have terminated early due to errors.[/warning]")
305
407
  console.print("[info]-------------------------------------[/info]")
306
408
 
307
409
 
410
+ # --- Templates Command Group ---
411
+ @click.group(name="templates")
412
+ def templates_group():
413
+ """Manage packaged and project templates."""
414
+ pass
415
+
416
+
417
+ @templates_group.command("list")
418
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
419
+ @click.option("--filter", "filter_tag", type=str, default=None, help="Filter by tag")
420
+ def templates_list(as_json: bool, filter_tag: Optional[str]):
421
+ try:
422
+ items = template_registry.list_templates(filter_tag)
423
+ if as_json:
424
+ import json as _json
425
+ click.echo(_json.dumps(items, indent=2))
426
+ else:
427
+ if not items:
428
+ console.print("[info]No templates found.[/info]")
429
+ return
430
+ console.print("[info]Available Templates:[/info]")
431
+ for it in items:
432
+ tags = ", ".join(it.get("tags", []))
433
+ console.print(f"- [bold]{it['name']}[/bold] ({it.get('version','')}) — {it.get('description','')} [dim]{tags}[/dim]")
434
+ except Exception as e:
435
+ handle_error(e, "templates list", False)
436
+
437
+
438
+ @templates_group.command("show")
439
+ @click.argument("name", type=str)
440
+ def templates_show(name: str):
441
+ try:
442
+ data = template_registry.show_template(name)
443
+ summary = data.get("summary", {})
444
+ console.print(f"[bold]{summary.get('name','')}[/bold] — {summary.get('description','')}")
445
+ console.print(f"Version: {summary.get('version','')} Tags: {', '.join(summary.get('tags',[]))}")
446
+ console.print(f"Language: {summary.get('language','')} Output: {summary.get('output','')}")
447
+ console.print(f"Path: {summary.get('path','')}")
448
+ if data.get("variables"):
449
+ console.print("\n[info]Variables:[/info]")
450
+ for k, v in data["variables"].items():
451
+ console.print(f"- {k}: {v}")
452
+ if data.get("usage"):
453
+ console.print("\n[info]Usage:[/info]")
454
+ console.print(data["usage"]) # raw; CLI may format later
455
+ if data.get("discover"):
456
+ console.print("\n[info]Discover:[/info]")
457
+ console.print(data["discover"]) # raw dict
458
+ if data.get("output_schema"):
459
+ console.print("\n[info]Output Schema:[/info]")
460
+ try:
461
+ import json as _json
462
+ console.print(_json.dumps(data["output_schema"], indent=2))
463
+ except Exception:
464
+ console.print(str(data["output_schema"]))
465
+ if data.get("notes"):
466
+ console.print("\n[info]Notes:[/info]")
467
+ console.print(data["notes"]) # plain text
468
+ except Exception as e:
469
+ handle_error(e, "templates show", False)
470
+
471
+
472
+ @templates_group.command("copy")
473
+ @click.argument("name", type=str)
474
+ @click.option("--to", "dest_dir", type=click.Path(file_okay=False), required=True)
475
+ def templates_copy(name: str, dest_dir: str):
476
+ try:
477
+ dest = template_registry.copy_template(name, dest_dir)
478
+ console.print(f"[success]Copied to:[/success] {dest}")
479
+ except Exception as e:
480
+ handle_error(e, "templates copy", False)
481
+
482
+
483
+ # Register the templates group with the main CLI.
484
+ # Click disallows attaching a MultiCommand to a chained group via add_command,
485
+ # so insert directly into the commands mapping after cli is defined.
486
+ cli.commands["templates"] = templates_group
487
+
488
+
489
+ # --- Custom Click Command Classes ---
490
+
491
+
492
+ class GenerateCommand(click.Command):
493
+ """Ensure help shows PROMPT_FILE as required even when validated at runtime."""
494
+
495
+ def collect_usage_pieces(self, ctx: click.Context) -> List[str]:
496
+ pieces = super().collect_usage_pieces(ctx)
497
+ return ["PROMPT_FILE" if piece == "[PROMPT_FILE]" else piece for piece in pieces]
498
+
499
+
308
500
  # --- Command Definitions ---
309
501
 
310
- @cli.command("generate")
311
- @click.argument("prompt_file", type=click.Path(exists=True, dir_okay=False))
502
+ @cli.command("generate", cls=GenerateCommand)
503
+ @click.argument("prompt_file", required=False, type=click.Path(exists=True, dir_okay=False))
312
504
  @click.option(
313
505
  "--output",
314
506
  type=click.Path(writable=True),
@@ -336,18 +528,40 @@ def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float
336
528
  multiple=True,
337
529
  help="Set template variable (KEY=VALUE) or read KEY from env",
338
530
  )
531
+ @click.option(
532
+ "--template",
533
+ "template_name",
534
+ type=str,
535
+ default=None,
536
+ help="Use a packaged/project template by name (e.g., architecture/architecture_json)",
537
+ )
339
538
  @click.pass_context
340
539
  @track_cost
341
540
  def generate(
342
541
  ctx: click.Context,
343
- prompt_file: str,
542
+ prompt_file: Optional[str],
344
543
  output: Optional[str],
345
544
  original_prompt_file_path: Optional[str],
346
545
  incremental_flag: bool,
347
546
  env_kv: Tuple[str, ...],
547
+ template_name: Optional[str],
348
548
  ) -> Optional[Tuple[str, float, str]]:
349
549
  """Generate code from a prompt file."""
350
550
  try:
551
+ # Resolve template to a prompt path when requested
552
+ if template_name and prompt_file:
553
+ raise click.UsageError("Provide either --template or a PROMPT_FILE path, not both.")
554
+ if template_name:
555
+ try:
556
+ from . import template_registry as _tpl
557
+ meta = _tpl.load_template(template_name)
558
+ prompt_file = meta.get("path")
559
+ if not prompt_file:
560
+ raise click.UsageError(f"Template '{template_name}' did not return a valid path")
561
+ except Exception as e:
562
+ raise click.UsageError(f"Failed to load template '{template_name}': {e}")
563
+ if not template_name and not prompt_file:
564
+ raise click.UsageError("Missing PROMPT_FILE. To use a template, pass --template NAME instead.")
351
565
  # Parse -e/--env arguments into a dict
352
566
  env_vars: Dict[str, str] = {}
353
567
  import os as _os
@@ -368,7 +582,7 @@ def generate(
368
582
  console.print(f"[warning]-e {key} not found in environment; skipping[/warning]")
369
583
  generated_code, incremental, total_cost, model_name = code_generator_main(
370
584
  ctx=ctx,
371
- prompt_file=prompt_file,
585
+ prompt_file=prompt_file, # resolved template path or user path
372
586
  output=output,
373
587
  original_prompt_file_path=original_prompt_file_path,
374
588
  force_incremental_flag=incremental_flag,
@@ -1330,6 +1544,22 @@ def install_completion_cmd(ctx: click.Context) -> None: # Return type remains No
1330
1544
  # Do not return anything, as the callback expects None or a tuple
1331
1545
 
1332
1546
 
1547
+ @cli.command("setup")
1548
+ @click.pass_context
1549
+ def setup_cmd(ctx: click.Context) -> None:
1550
+ """Run the interactive setup utility and install shell completion."""
1551
+ command_name = "setup"
1552
+ quiet_mode = ctx.obj.get("quiet", False)
1553
+
1554
+ try:
1555
+ install_completion(quiet=quiet_mode)
1556
+ _run_setup_utility()
1557
+ if not quiet_mode:
1558
+ console.print("[success]Setup completed. Restart your shell or source your RC file to apply changes.[/success]")
1559
+ except Exception as exc:
1560
+ handle_error(exc, command_name, quiet_mode)
1561
+
1562
+
1333
1563
  # --- Entry Point ---
1334
1564
  if __name__ == "__main__":
1335
1565
  cli()
@@ -79,6 +79,27 @@ def _expand_vars(text: str, vars_map: Optional[Dict[str, str]]) -> str:
79
79
  return text
80
80
 
81
81
 
82
+ def _parse_front_matter(text: str) -> Tuple[Optional[Dict[str, Any]], str]:
83
+ """Parse YAML front matter at the start of a prompt and return (meta, body)."""
84
+ try:
85
+ if not text.startswith("---\n"):
86
+ return None, text
87
+ end_idx = text.find("\n---", 4)
88
+ if end_idx == -1:
89
+ return None, text
90
+ fm_body = text[4:end_idx]
91
+ rest = text[end_idx + len("\n---"):]
92
+ if rest.startswith("\n"):
93
+ rest = rest[1:]
94
+ import yaml as _yaml
95
+ meta = _yaml.safe_load(fm_body) or {}
96
+ if not isinstance(meta, dict):
97
+ meta = {}
98
+ return meta, rest
99
+ except Exception:
100
+ return None, text
101
+
102
+
82
103
  def get_git_content_at_ref(file_path: str, git_ref: str = "HEAD") -> Optional[str]:
83
104
  """Gets the content of the file as it was at the specified git_ref."""
84
105
  abs_file_path = pathlib.Path(file_path).resolve()
@@ -187,6 +208,10 @@ def code_generator_main(
187
208
  context_override=ctx.obj.get('context')
188
209
  )
189
210
  prompt_content = input_strings["prompt_file"]
211
+ # Phase-2 templates: parse front matter metadata
212
+ fm_meta, body = _parse_front_matter(prompt_content)
213
+ if fm_meta:
214
+ prompt_content = body
190
215
  # Determine final output path: if user passed a directory, use resolved file path
191
216
  resolved_output = output_file_paths.get("output")
192
217
  if output is None:
@@ -212,10 +237,108 @@ def code_generator_main(
212
237
  existing_code_content: Optional[str] = None
213
238
  original_prompt_content_for_incremental: Optional[str] = None
214
239
 
240
+ # Merge -e vars with front-matter defaults; validate required
241
+ if env_vars is None:
242
+ env_vars = {}
243
+ if fm_meta and isinstance(fm_meta.get("variables"), dict):
244
+ for k, spec in (fm_meta["variables"].items()):
245
+ if isinstance(spec, dict):
246
+ if k not in env_vars and "default" in spec:
247
+ env_vars[k] = str(spec["default"])
248
+ # if scalar default allowed, ignore for now
249
+ missing = [k for k, spec in fm_meta["variables"].items() if isinstance(spec, dict) and spec.get("required") and k not in env_vars]
250
+ if missing:
251
+ console.print(f"[error]Missing required variables: {', '.join(missing)}")
252
+ return "", False, 0.0, "error"
253
+
254
+ # Execute optional discovery from front matter to populate env_vars without overriding explicit -e values
255
+ def _run_discovery(discover_cfg: Dict[str, Any]) -> Dict[str, str]:
256
+ results: Dict[str, str] = {}
257
+ try:
258
+ if not discover_cfg:
259
+ return results
260
+ enabled = discover_cfg.get("enabled", False)
261
+ if not enabled:
262
+ return results
263
+ root = discover_cfg.get("root", ".")
264
+ patterns = discover_cfg.get("patterns", []) or []
265
+ exclude = discover_cfg.get("exclude", []) or []
266
+ max_per = int(discover_cfg.get("max_per_pattern", 0) or 0)
267
+ max_total = int(discover_cfg.get("max_total", 0) or 0)
268
+ root_path = pathlib.Path(root).resolve()
269
+ seen: List[str] = []
270
+ def _match_one(patterns_list: List[str]) -> List[str]:
271
+ matches: List[str] = []
272
+ for pat in patterns_list:
273
+ globbed = list(root_path.rglob(pat))
274
+ for p in globbed:
275
+ if any(p.match(ex) for ex in exclude):
276
+ continue
277
+ sp = str(p.resolve())
278
+ if sp not in matches:
279
+ matches.append(sp)
280
+ if max_per and len(matches) >= max_per:
281
+ matches = matches[:max_per]
282
+ break
283
+ return matches
284
+ # If a mapping 'set' is provided, compute per-variable results
285
+ set_map = discover_cfg.get("set") or {}
286
+ if isinstance(set_map, dict) and set_map:
287
+ for var_name, spec in set_map.items():
288
+ if var_name in env_vars:
289
+ continue # don't override explicit -e
290
+ v_patterns = spec.get("patterns", []) if isinstance(spec, dict) else []
291
+ v_exclude = spec.get("exclude", []) if isinstance(spec, dict) else []
292
+ save_exclude = exclude
293
+ try:
294
+ if v_exclude:
295
+ exclude = v_exclude
296
+ matches = _match_one(v_patterns or patterns)
297
+ finally:
298
+ exclude = save_exclude
299
+ if matches:
300
+ results[var_name] = ",".join(matches)
301
+ seen.extend(matches)
302
+ # Fallback: populate SCAN_FILES and SCAN metadata
303
+ if not results:
304
+ files = _match_one(patterns)
305
+ if max_total and len(files) > max_total:
306
+ files = files[:max_total]
307
+ if files:
308
+ results["SCAN_FILES"] = ",".join(files)
309
+ # Always set root/patterns helpers
310
+ if root:
311
+ results.setdefault("SCAN_ROOT", str(root_path))
312
+ if patterns:
313
+ results.setdefault("SCAN_PATTERNS", ",".join(patterns))
314
+ except Exception as e:
315
+ if verbose and not quiet:
316
+ console.print(f"[yellow]Discovery skipped due to error: {e}[/yellow]")
317
+ return results
318
+
319
+ if fm_meta and isinstance(fm_meta.get("discover"), dict):
320
+ discovered = _run_discovery(fm_meta.get("discover") or {})
321
+ for k, v in discovered.items():
322
+ if k not in env_vars:
323
+ env_vars[k] = v
324
+
215
325
  # Expand variables in output path if provided
216
326
  if output_path:
217
327
  output_path = _expand_vars(output_path, env_vars)
218
328
 
329
+ # Honor front-matter output when CLI did not pass --output
330
+ if output is None and fm_meta and isinstance(fm_meta.get("output"), str):
331
+ try:
332
+ meta_out = _expand_vars(fm_meta["output"], env_vars)
333
+ if meta_out:
334
+ output_path = str(pathlib.Path(meta_out).resolve())
335
+ except Exception:
336
+ pass
337
+
338
+ # Honor front-matter language if provided (overrides detection for both local and cloud)
339
+ if fm_meta and isinstance(fm_meta.get("language"), str) and fm_meta.get("language"):
340
+ language = fm_meta.get("language")
341
+
219
342
  if output_path and pathlib.Path(output_path).exists():
220
343
  try:
221
344
  existing_code_content = pathlib.Path(output_path).read_text(encoding="utf-8")
@@ -462,9 +585,11 @@ def code_generator_main(
462
585
  local_prompt = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[])
463
586
  local_prompt = _expand_vars(local_prompt, env_vars)
464
587
  local_prompt = pdd_preprocess(local_prompt, recursive=False, double_curly_brackets=True, exclude_keys=[])
588
+ # Language already resolved (front matter overrides detection if present)
589
+ gen_language = language
465
590
  generated_code_content, total_cost, model_name = local_code_generator_func(
466
591
  prompt=local_prompt,
467
- language=language,
592
+ language=gen_language,
468
593
  strength=strength,
469
594
  temperature=temperature,
470
595
  time=time_budget,
@@ -476,14 +601,37 @@ def code_generator_main(
476
601
  console.print(Panel(f"Full generation successful. Model: {model_name}, Cost: ${total_cost:.6f}", title="[green]Local Success[/green]", expand=False))
477
602
 
478
603
  if generated_code_content is not None:
604
+ # Optional output_schema JSON validation before writing
605
+ try:
606
+ if fm_meta and isinstance(fm_meta.get("output_schema"), dict):
607
+ is_json_output = False
608
+ if isinstance(language, str) and str(language).lower().strip() == "json":
609
+ is_json_output = True
610
+ elif output_path and str(output_path).lower().endswith(".json"):
611
+ is_json_output = True
612
+ if is_json_output:
613
+ parsed = json.loads(generated_code_content)
614
+ try:
615
+ import jsonschema # type: ignore
616
+ jsonschema.validate(instance=parsed, schema=fm_meta.get("output_schema"))
617
+ except ModuleNotFoundError:
618
+ if verbose and not quiet:
619
+ console.print("[yellow]jsonschema not installed; skipping schema validation.[/yellow]")
620
+ except Exception as ve:
621
+ raise click.UsageError(f"Generated JSON does not match output_schema: {ve}")
622
+ except json.JSONDecodeError as jde:
623
+ raise click.UsageError(f"Generated output is not valid JSON: {jde}")
624
+
479
625
  if output_path:
480
626
  p_output = pathlib.Path(output_path)
481
627
  p_output.parent.mkdir(parents=True, exist_ok=True)
482
628
  p_output.write_text(generated_code_content, encoding="utf-8")
483
629
  if verbose or not quiet:
484
630
  console.print(f"Generated code saved to: [green]{p_output.resolve()}[/green]")
485
- elif not quiet: # No output path, print to console if not quiet
486
- console.print(Panel(Text(generated_code_content, overflow="fold"), title="[cyan]Generated Code[/cyan]", expand=True))
631
+ elif not quiet:
632
+ # No destination resolved; surface the generated code directly to the console.
633
+ console.print(Panel(Text(generated_code_content, overflow="fold"), title="[cyan]Generated Code[/cyan]", expand=False))
634
+ console.print("[yellow]No output path resolved; skipping file write and stdout print.[/yellow]")
487
635
  else:
488
636
  console.print("[red]Error: Code generation failed. No code was produced.[/red]")
489
637
  return "", was_incremental_operation, total_cost, model_name or "error"
pdd/construct_paths.py CHANGED
@@ -210,7 +210,9 @@ def _is_known_language(language_name: str) -> bool:
210
210
  builtin_languages = {
211
211
  'python', 'javascript', 'typescript', 'java', 'cpp', 'c', 'go', 'ruby', 'rust',
212
212
  'kotlin', 'swift', 'csharp', 'php', 'scala', 'r', 'lua', 'perl', 'bash', 'shell',
213
- 'powershell', 'sql', 'prompt', 'html', 'css', 'makefile'
213
+ 'powershell', 'sql', 'prompt', 'html', 'css', 'makefile',
214
+ # Common data and config formats for architecture prompts and configs
215
+ 'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini'
214
216
  }
215
217
 
216
218
  pdd_path_str = os.getenv('PDD_PATH')
@@ -636,7 +638,9 @@ def construct_paths(
636
638
  'kotlin': '.kt', 'swift': '.swift', 'csharp': '.cs', 'php': '.php',
637
639
  'scala': '.scala', 'r': '.r', 'lua': '.lua', 'perl': '.pl', 'bash': '.sh',
638
640
  'shell': '.sh', 'powershell': '.ps1', 'sql': '.sql', 'html': '.html', 'css': '.css',
639
- 'prompt': '.prompt', 'makefile': ''
641
+ 'prompt': '.prompt', 'makefile': '',
642
+ # Common data/config formats
643
+ 'json': '.json', 'jsonl': '.jsonl', 'yaml': '.yaml', 'yml': '.yml', 'toml': '.toml', 'ini': '.ini'
640
644
  }
641
645
  file_extension = builtin_ext_map.get(language.lower(), f".{language.lower()}" if language else '')
642
646
 
@@ -30,6 +30,7 @@ Groovy,//,.groovy
30
30
  Dart,//,.dart
31
31
  F#,//,.fs
32
32
  YAML,#,.yml
33
+ YAML,#,.yaml
33
34
  JSON,del,.json
34
35
  JSONL,del,.jsonl
35
36
  XML,"<!-- -->",.xml
@@ -62,3 +63,4 @@ TOML,#,.toml
62
63
  Log,del,.log
63
64
  reStructuredText,del,.rst
64
65
  Text,del,.txt
66
+ INI,;,.ini