2727console = Console ()
2828
2929
30+ def _single_file_version_exists (prompts_cache : dict [str , dict ], prompt_id : str , version : str ) -> bool :
31+ """Return True when a version exists in single-file mode."""
32+ version_key = f"{ prompt_id } @{ version } "
33+ if version_key in prompts_cache :
34+ return True
35+
36+ prompt_data = prompts_cache .get (prompt_id )
37+ if not isinstance (prompt_data , dict ):
38+ return False
39+
40+ versions = prompt_data .get ("versions" )
41+ return isinstance (versions , dict ) and version in versions
42+
43+
44+ def _single_file_version_template (
45+ prompts_cache : dict [str , dict ], prompt_id : str , version : str
46+ ) -> Optional [str ]:
47+ """Resolve a version template from prompts.yaml."""
48+ version_key = f"{ prompt_id } @{ version } "
49+ version_entry = prompts_cache .get (version_key )
50+ if isinstance (version_entry , dict ) and "template" in version_entry :
51+ return version_entry ["template" ]
52+
53+ prompt_data = prompts_cache .get (prompt_id )
54+ if not isinstance (prompt_data , dict ):
55+ return None
56+
57+ versions = prompt_data .get ("versions" )
58+ if not isinstance (versions , dict ):
59+ return None
60+
61+ version_data = versions .get (version )
62+ if isinstance (version_data , str ):
63+ return version_data
64+ if isinstance (version_data , dict ) and "template" in version_data :
65+ return version_data ["template" ]
66+ return None
67+
68+
3069@app .command ()
3170def init (
3271 path : Optional [Path ] = typer .Argument (
@@ -285,21 +324,48 @@ def switch(
285324 raise typer .Exit (1 )
286325
287326 lockfile_path = project_root / LOCKFILE_NAME
288- yaml_path = project_root / PROMPTS_DIR / prompt_id / f"{ version } .yaml"
289-
290- # Check if the version file exists
291- if not yaml_path .exists ():
292- console .print (f"[red]Error:[/red] Version file not found: { yaml_path } " )
293- console .print (f"[dim]Available versions in prompts/{ prompt_id } /:[/dim]" )
294-
295- prompt_dir = project_root / PROMPTS_DIR / prompt_id
296- if prompt_dir .exists ():
297- for f in prompt_dir .glob ("*.yaml" ):
298- console .print (f" - { f .stem } " )
299- else :
300- console .print (" (none)" )
301-
302- raise typer .Exit (1 )
327+ prompts_file = project_root / PROMPTS_FILE
328+
329+ # Single-file mode: validate version exists in prompts.yaml
330+ if prompts_file .exists ():
331+ try :
332+ prompts_cache = load_prompts_file (prompts_file )
333+ except Exception as e :
334+ console .print (f"[red]Error:[/red] Failed to load { prompts_file .name } : { e } " )
335+ raise typer .Exit (1 )
336+
337+ if not _single_file_version_exists (prompts_cache , prompt_id , version ):
338+ console .print (f"[red]Error:[/red] Version '{ version } ' not found in prompts.yaml for '{ prompt_id } '" )
339+ available = []
340+ for key in prompts_cache .keys ():
341+ if key .startswith (f"{ prompt_id } @" ):
342+ available .append (key .split ("@" , 1 )[1 ])
343+ prompt_data = prompts_cache .get (prompt_id , {})
344+ versions = prompt_data .get ("versions" ) if isinstance (prompt_data , dict ) else None
345+ if isinstance (versions , dict ):
346+ available .extend (list (versions .keys ()))
347+ available = sorted (set (available ))
348+ if available :
349+ console .print (f"[dim]Available versions:[/dim] { ', ' .join (available )} " )
350+ else :
351+ console .print ("[dim]No versions found in prompts.yaml[/dim]" )
352+ raise typer .Exit (1 )
353+ else :
354+ yaml_path = project_root / PROMPTS_DIR / prompt_id / f"{ version } .yaml"
355+
356+ # Check if the version file exists
357+ if not yaml_path .exists ():
358+ console .print (f"[red]Error:[/red] Version file not found: { yaml_path } " )
359+ console .print (f"[dim]Available versions in prompts/{ prompt_id } /:[/dim]" )
360+
361+ prompt_dir = project_root / PROMPTS_DIR / prompt_id
362+ if prompt_dir .exists ():
363+ for f in prompt_dir .glob ("*.yaml" ):
364+ console .print (f" - { f .stem } " )
365+ else :
366+ console .print (" (none)" )
367+
368+ raise typer .Exit (1 )
303369
304370 # Load and update lockfile
305371 with open (lockfile_path , "r" , encoding = "utf-8" ) as f :
@@ -357,10 +423,23 @@ def status(
357423 table .add_column ("Prompt ID" , style = "cyan" )
358424 table .add_column ("Version" , style = "green" )
359425 table .add_column ("File Status" , style = "yellow" )
426+
427+ prompts_file = project_root / PROMPTS_FILE
428+ prompts_cache : Optional [dict ] = None
429+ if prompts_file .exists ():
430+ try :
431+ prompts_cache = load_prompts_file (prompts_file )
432+ except Exception as e :
433+ console .print (f"[red]Error:[/red] Failed to load { prompts_file .name } : { e } " )
434+ raise typer .Exit (1 )
360435
361436 for prompt_id , version in sorted (lockfile .items ()):
362- yaml_path = project_root / PROMPTS_DIR / prompt_id / f"{ version } .yaml"
363- file_status = "✓" if yaml_path .exists () else "✗ missing"
437+ if prompts_cache is not None :
438+ exists = _single_file_version_exists (prompts_cache , prompt_id , version )
439+ file_status = "✓" if exists else "✗ missing"
440+ else :
441+ yaml_path = project_root / PROMPTS_DIR / prompt_id / f"{ version } .yaml"
442+ file_status = "✓" if yaml_path .exists () else "✗ missing"
364443 table .add_row (prompt_id , version , file_status )
365444
366445 console .print (table )
@@ -462,11 +541,10 @@ def migrate(
462541 console .print (f"[blue]Scanning:[/blue] { len (py_files )} Python file(s)\n " )
463542
464543 total_candidates = 0
465- applied_count = 0
466- skipped_count = 0
544+ applied_total = 0
545+ skipped_total = 0
467546 yaml_written_count = 0
468547
469-
470548 for py_file in py_files :
471549 try :
472550 content = py_file .read_text (encoding = "utf-8" )
@@ -490,6 +568,18 @@ def migrate(
490568 console .print (f"\n [bold cyan]File:[/bold cyan] { py_file .relative_to (target_path .parent if target_path .is_file () else target_path )} " )
491569 console .print (f"[dim]Found { len (candidates )} migration candidate(s)[/dim]\n " )
492570
571+ approved_ids : set [str ] = set ()
572+ existing_yaml_ids : set [str ] = set ()
573+ existing_prompt_ids : set [str ] = set ()
574+
575+ if clean and project_root and use_single_file :
576+ try :
577+ existing_prompt_ids = set (
578+ load_prompts_file (project_root / PROMPTS_FILE ).keys ()
579+ )
580+ except Exception :
581+ existing_prompt_ids = set ()
582+
493583 for candidate in candidates :
494584 total_candidates += 1
495585
@@ -503,6 +593,7 @@ def migrate(
503593 else :
504594 yaml_path = project_root / PROMPTS_DIR / candidate .prompt_id / "v1.yaml"
505595 if yaml_path .exists ():
596+ existing_yaml_ids .add (candidate .prompt_id )
506597 console .print (f"[yellow] ⚠ YAML file exists, will skip:[/yellow] { yaml_path .relative_to (project_root )} " )
507598 else :
508599 console .print (f"[green] → Will create:[/green] { yaml_path .relative_to (project_root )} " )
@@ -523,37 +614,52 @@ def migrate(
523614
524615 if dry_run :
525616 console .print ("[yellow]Dry run - no changes applied[/yellow]\n " )
526- skipped_count += 1
617+ skipped_total += 1
527618 continue
528619
529620 # Ask for confirmation
530621 if yes or Confirm .ask ("Apply this change?" , default = True ):
531- applied_count += 1
622+ approved_ids . add ( candidate . prompt_id )
532623 else :
533- skipped_count += 1
624+ skipped_total += 1
534625 console .print ("[dim]Skipped[/dim]\n " )
535626
536627 # If any changes were approved, apply them all at once
537- if not dry_run and applied_count > 0 :
628+ if not dry_run and approved_ids :
538629 modified_content , applied_candidates = migrate_file_content (
539630 content ,
540631 py_file .name ,
541632 apply_changes = True ,
542633 clean_mode = clean ,
543634 project_root = project_root ,
544635 extra_patterns = pattern ,
636+ approved_prompt_ids = approved_ids ,
545637 )
546- py_file .write_text (modified_content , encoding = "utf-8" )
547- console .print (f"[green]✓[/green] Applied changes to { py_file .name } " )
638+ if applied_candidates :
639+ py_file .write_text (modified_content , encoding = "utf-8" )
640+ console .print (f"[green]✓[/green] Applied changes to { py_file .name } " )
641+ applied_total += len (applied_candidates )
548642
549643 # In clean mode, report YAML file status
550644 if clean and project_root :
551645 if use_single_file :
552- yaml_written_count += len (applied_candidates )
553- console .print (f"[green] ✓[/green] Added { len (applied_candidates )} prompt(s) to prompts.yaml" )
646+ added = 0
647+ try :
648+ after_ids = set (
649+ load_prompts_file (project_root / PROMPTS_FILE ).keys ()
650+ )
651+ new_ids = after_ids - existing_prompt_ids
652+ applied_ids = {c .prompt_id for c in applied_candidates }
653+ added = len (new_ids & applied_ids )
654+ except Exception :
655+ added = len (applied_candidates )
656+ yaml_written_count += added
657+ console .print (f"[green] ✓[/green] Added { added } prompt(s) to prompts.yaml" )
554658 else :
555659 for cand in applied_candidates :
556660 yaml_path = project_root / PROMPTS_DIR / cand .prompt_id / "v1.yaml"
661+ if cand .prompt_id in existing_yaml_ids :
662+ continue
557663 if yaml_path .exists ():
558664 # Check if we just created it (file mtime is recent)
559665 yaml_written_count += 1
@@ -564,8 +670,8 @@ def migrate(
564670 console .print ("[bold]Migration Summary[/bold]" )
565671 console .print (f" Total candidates: { total_candidates } " )
566672 if not dry_run :
567- console .print (f" [green]Applied:[/green] { applied_count } " )
568- console .print (f" [yellow]Skipped:[/yellow] { skipped_count } " )
673+ console .print (f" [green]Applied:[/green] { applied_total } " )
674+ console .print (f" [yellow]Skipped:[/yellow] { skipped_total } " )
569675 if clean :
570676 console .print (f" [green]YAML files created:[/green] { yaml_written_count } " )
571677 else :
@@ -595,7 +701,7 @@ def diff(
595701 """
596702 Compare two versions of a prompt.
597703
598- Shows a unified diff between the two version files .
704+ Shows a unified diff between two prompt versions .
599705 """
600706 import difflib
601707 from rich .syntax import Syntax
@@ -617,36 +723,52 @@ def diff(
617723 console .print ("[red]Error:[/red] No project root found. Run 'pvcs init' first." )
618724 raise typer .Exit (1 )
619725
620- # Check for single-file vs multi-file mode
621726 prompts_file = project_root / PROMPTS_FILE
622727 if prompts_file .exists ():
623- console .print ("[yellow]Note:[/yellow] Single-file mode (prompts.yaml) does not support versioning." )
624- console .print ("[dim]Use multi-file mode with 'pvcs init --split' for version comparison.[/dim]" )
625- raise typer .Exit (1 )
626-
627- # Build paths
628- yaml_path1 = project_root / PROMPTS_DIR / prompt_id / f"{ version1 } .yaml"
629- yaml_path2 = project_root / PROMPTS_DIR / prompt_id / f"{ version2 } .yaml"
630-
631- # Check files exist
632- if not yaml_path1 .exists ():
633- console .print (f"[red]Error:[/red] Version file not found: { yaml_path1 } " )
634- raise typer .Exit (1 )
635-
636- if not yaml_path2 .exists ():
637- console .print (f"[red]Error:[/red] Version file not found: { yaml_path2 } " )
638- raise typer .Exit (1 )
639-
640- # Read contents
641- content1 = yaml_path1 .read_text (encoding = "utf-8" ).splitlines (keepends = True )
642- content2 = yaml_path2 .read_text (encoding = "utf-8" ).splitlines (keepends = True )
728+ try :
729+ prompts_cache = load_prompts_file (prompts_file )
730+ except Exception as e :
731+ console .print (f"[red]Error:[/red] Failed to load { prompts_file .name } : { e } " )
732+ raise typer .Exit (1 )
733+
734+ template1 = _single_file_version_template (prompts_cache , prompt_id , version1 )
735+ template2 = _single_file_version_template (prompts_cache , prompt_id , version2 )
736+
737+ if template1 is None :
738+ console .print (f"[red]Error:[/red] Version '{ version1 } ' not found in prompts.yaml for '{ prompt_id } '" )
739+ raise typer .Exit (1 )
740+
741+ if template2 is None :
742+ console .print (f"[red]Error:[/red] Version '{ version2 } ' not found in prompts.yaml for '{ prompt_id } '" )
743+ raise typer .Exit (1 )
744+
745+ content1 = template1 .splitlines (keepends = True )
746+ content2 = template2 .splitlines (keepends = True )
747+ fromfile = f"{ PROMPTS_FILE } :{ prompt_id } @{ version1 } "
748+ tofile = f"{ PROMPTS_FILE } :{ prompt_id } @{ version2 } "
749+ else :
750+ yaml_path1 = project_root / PROMPTS_DIR / prompt_id / f"{ version1 } .yaml"
751+ yaml_path2 = project_root / PROMPTS_DIR / prompt_id / f"{ version2 } .yaml"
752+
753+ if not yaml_path1 .exists ():
754+ console .print (f"[red]Error:[/red] Version file not found: { yaml_path1 } " )
755+ raise typer .Exit (1 )
756+
757+ if not yaml_path2 .exists ():
758+ console .print (f"[red]Error:[/red] Version file not found: { yaml_path2 } " )
759+ raise typer .Exit (1 )
760+
761+ content1 = yaml_path1 .read_text (encoding = "utf-8" ).splitlines (keepends = True )
762+ content2 = yaml_path2 .read_text (encoding = "utf-8" ).splitlines (keepends = True )
763+ fromfile = f"prompts/{ prompt_id } /{ version1 } .yaml"
764+ tofile = f"prompts/{ prompt_id } /{ version2 } .yaml"
643765
644766 # Generate diff
645767 diff_lines = list (difflib .unified_diff (
646768 content1 ,
647769 content2 ,
648- fromfile = f"prompts/ { prompt_id } / { version1 } .yaml" ,
649- tofile = f"prompts/ { prompt_id } / { version2 } .yaml" ,
770+ fromfile = fromfile ,
771+ tofile = tofile ,
650772 ))
651773
652774 if not diff_lines :
0 commit comments