pdd-cli 0.0.55__py3-none-any.whl → 0.0.57__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.55"
3
+ __version__ = "0.0.57"
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/auto_deps_main.py CHANGED
@@ -49,7 +49,8 @@ def auto_deps_main( # pylint: disable=too-many-arguments, too-many-locals
49
49
  force=ctx.obj.get('force', False),
50
50
  quiet=ctx.obj.get('quiet', False),
51
51
  command="auto-deps",
52
- command_options=command_options
52
+ command_options=command_options,
53
+ context_override=ctx.obj.get('context')
53
54
  )
54
55
 
55
56
  # Get the CSV file path
@@ -101,4 +102,4 @@ def auto_deps_main( # pylint: disable=too-many-arguments, too-many-locals
101
102
  except Exception as exc:
102
103
  if not ctx.obj.get('quiet', False):
103
104
  rprint(f"[bold red]Error:[/bold red] {str(exc)}")
104
- sys.exit(1)
105
+ sys.exit(1)
pdd/bug_main.py CHANGED
@@ -50,7 +50,8 @@ def bug_main(
50
50
  force=ctx.obj.get('force', False),
51
51
  quiet=ctx.obj.get('quiet', False),
52
52
  command="bug",
53
- command_options=command_options
53
+ command_options=command_options,
54
+ context_override=ctx.obj.get('context')
54
55
  )
55
56
 
56
57
  # Use the language detected by construct_paths if none was explicitly provided
@@ -117,4 +118,4 @@ def bug_main(
117
118
  except Exception as e:
118
119
  if not ctx.obj.get('quiet', False):
119
120
  rprint(f"[bold red]Error:[/bold red] {str(e)}")
120
- sys.exit(1)
121
+ sys.exit(1)
pdd/change_main.py CHANGED
@@ -203,6 +203,7 @@ def change_main(
203
203
  quiet=quiet,
204
204
  command="change",
205
205
  command_options=command_options,
206
+ context_override=ctx.obj.get('context')
206
207
  )
207
208
  logger.debug("construct_paths returned:")
208
209
  logger.debug(" input_strings keys: %s", list(input_strings.keys()))
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
 
@@ -26,13 +28,18 @@ from .cmd_test_main import cmd_test_main
26
28
  from .code_generator_main import code_generator_main
27
29
  from .conflicts_main import conflicts_main
28
30
  # Need to import construct_paths for tests patching pdd.cli.construct_paths
29
- from .construct_paths import construct_paths
31
+ from .construct_paths import construct_paths, list_available_contexts
30
32
  from .context_generator_main import context_generator_main
31
33
  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, 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,
@@ -134,6 +223,20 @@ def handle_error(exception: Exception, command_name: str, quiet: bool):
134
223
  default=False,
135
224
  help="Run commands locally instead of in the cloud.",
136
225
  )
226
+ @click.option(
227
+ "--context",
228
+ "context_override",
229
+ type=str,
230
+ default=None,
231
+ help="Override automatic context detection and use the specified .pddrc context.",
232
+ )
233
+ @click.option(
234
+ "--list-contexts",
235
+ "list_contexts",
236
+ is_flag=True,
237
+ default=False,
238
+ help="List available contexts from .pddrc and exit.",
239
+ )
137
240
  @click.version_option(version=__version__, package_name="pdd-cli")
138
241
  @click.pass_context
139
242
  def cli(
@@ -147,10 +250,11 @@ def cli(
147
250
  review_examples: bool,
148
251
  local: bool,
149
252
  time: Optional[float], # Type hint is Optional[float]
253
+ context_override: Optional[str],
254
+ list_contexts: bool,
150
255
  ):
151
256
  """
152
257
  Main entry point for the PDD CLI. Handles global options and initializes context.
153
- Supports multi-command chaining.
154
258
  """
155
259
  # Ensure PDD_PATH is set before any commands run
156
260
  get_local_pdd_path()
@@ -166,11 +270,44 @@ def cli(
166
270
  ctx.obj["local"] = local
167
271
  # Use DEFAULT_TIME if time is not provided
168
272
  ctx.obj["time"] = time if time is not None else DEFAULT_TIME
273
+ # Persist context override for downstream calls
274
+ ctx.obj["context"] = context_override
169
275
 
170
276
  # Suppress verbose if quiet is enabled
171
277
  if quiet:
172
278
  ctx.obj["verbose"] = False
173
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
+
287
+ # If --list-contexts is provided, print and exit before any other actions
288
+ if list_contexts:
289
+ try:
290
+ names = list_available_contexts()
291
+ except Exception as exc:
292
+ # Surface config errors as usage errors
293
+ raise click.UsageError(f"Failed to load .pddrc: {exc}")
294
+ # Print one per line; avoid Rich formatting for portability
295
+ for name in names:
296
+ click.echo(name)
297
+ ctx.exit(0)
298
+
299
+ # Optional early validation for --context
300
+ if context_override:
301
+ try:
302
+ names = list_available_contexts()
303
+ except Exception as exc:
304
+ # If .pddrc is malformed, propagate as usage error
305
+ raise click.UsageError(f"Failed to load .pddrc: {exc}")
306
+ if context_override not in names:
307
+ raise click.UsageError(
308
+ f"Unknown context '{context_override}'. Available contexts: {', '.join(names)}"
309
+ )
310
+
174
311
  # Perform auto-update check unless disabled
175
312
  if os.getenv("PDD_AUTO_UPDATE", "true").lower() != "false":
176
313
  try:
@@ -185,17 +322,23 @@ def cli(
185
322
  style="warning"
186
323
  )
187
324
 
188
- # --- 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 ---
189
333
  @cli.result_callback()
190
334
  @click.pass_context
191
335
  def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float, str]]], **kwargs):
192
336
  """
193
- Processes the results from chained commands.
194
-
337
+ Processes results returned by executed commands and prints a summary.
195
338
  Receives a list of tuples, typically (result, cost, model_name),
196
339
  or None from each command function.
197
340
  """
198
- total_chain_cost = 0.0
341
+ total_cost = 0.0
199
342
  # Get Click's invoked subcommands attribute first
200
343
  invoked_subcommands = getattr(ctx, 'invoked_subcommands', [])
201
344
  # If Click didn't provide it (common in real runs), fall back to the list
@@ -209,11 +352,12 @@ def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float
209
352
  invoked_subcommands = ctx.obj.get('invoked_subcommands', []) or []
210
353
  except Exception:
211
354
  invoked_subcommands = []
355
+ results = results or []
212
356
  num_commands = len(invoked_subcommands)
213
357
  num_results = len(results) # Number of results actually received
214
358
 
215
359
  if not ctx.obj.get("quiet"):
216
- console.print("\n[info]--- Command Chain Execution Summary ---[/info]")
360
+ console.print("\n[info]--- Command Execution Summary ---[/info]")
217
361
 
218
362
  for i, result_tuple in enumerate(results):
219
363
  # Use the retrieved subcommand name (might be "Unknown Command X" in tests)
@@ -237,7 +381,7 @@ def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float
237
381
  # Check if the result is the expected tuple structure from @track_cost or preprocess success
238
382
  elif isinstance(result_tuple, tuple) and len(result_tuple) == 3:
239
383
  _result_data, cost, model_name = result_tuple
240
- total_chain_cost += cost
384
+ total_cost += cost
241
385
  if not ctx.obj.get("quiet"):
242
386
  # Special handling for preprocess success message (check actual command name)
243
387
  actual_command_name = invoked_subcommands[i] if i < num_commands else None # Get actual name if possible
@@ -256,17 +400,107 @@ def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float
256
400
  if not ctx.obj.get("quiet"):
257
401
  # Only print total cost if at least one command potentially contributed cost
258
402
  if any(res is not None and isinstance(res, tuple) and len(res) == 3 for res in results):
259
- 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}")
260
404
  # Indicate if the chain might have been incomplete due to errors
261
405
  if num_results < num_commands and not all(res is None for res in results): # Avoid printing if all failed
262
406
  console.print("[warning]Note: Chain may have terminated early due to errors.[/warning]")
263
407
  console.print("[info]-------------------------------------[/info]")
264
408
 
265
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
+
266
500
  # --- Command Definitions ---
267
501
 
268
- @cli.command("generate")
269
- @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))
270
504
  @click.option(
271
505
  "--output",
272
506
  type=click.Path(writable=True),
@@ -294,18 +528,40 @@ def process_commands(ctx: click.Context, results: List[Optional[Tuple[Any, float
294
528
  multiple=True,
295
529
  help="Set template variable (KEY=VALUE) or read KEY from env",
296
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
+ )
297
538
  @click.pass_context
298
539
  @track_cost
299
540
  def generate(
300
541
  ctx: click.Context,
301
- prompt_file: str,
542
+ prompt_file: Optional[str],
302
543
  output: Optional[str],
303
544
  original_prompt_file_path: Optional[str],
304
545
  incremental_flag: bool,
305
546
  env_kv: Tuple[str, ...],
547
+ template_name: Optional[str],
306
548
  ) -> Optional[Tuple[str, float, str]]:
307
549
  """Generate code from a prompt file."""
308
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.")
309
565
  # Parse -e/--env arguments into a dict
310
566
  env_vars: Dict[str, str] = {}
311
567
  import os as _os
@@ -326,7 +582,7 @@ def generate(
326
582
  console.print(f"[warning]-e {key} not found in environment; skipping[/warning]")
327
583
  generated_code, incremental, total_cost, model_name = code_generator_main(
328
584
  ctx=ctx,
329
- prompt_file=prompt_file,
585
+ prompt_file=prompt_file, # resolved template path or user path
330
586
  output=output,
331
587
  original_prompt_file_path=original_prompt_file_path,
332
588
  force_incremental_flag=incremental_flag,
@@ -1288,6 +1544,22 @@ def install_completion_cmd(ctx: click.Context) -> None: # Return type remains No
1288
1544
  # Do not return anything, as the callback expects None or a tuple
1289
1545
 
1290
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
+
1291
1563
  # --- Entry Point ---
1292
1564
  if __name__ == "__main__":
1293
1565
  cli()
pdd/cmd_test_main.py CHANGED
@@ -88,6 +88,7 @@ def cmd_test_main(
88
88
  quiet=ctx.obj["quiet"],
89
89
  command="test",
90
90
  command_options=command_options,
91
+ context_override=ctx.obj.get('context')
91
92
  )
92
93
  except Exception as exception:
93
94
  # Catching a general exception is necessary here to handle a wide range of