Skip to content

Commit dd60653

Browse files
committed
feat: 优化了提示管理器的核心功能、多文件和单文件模式支持、相应的测试以及VS Code扩展。
1 parent 2c87cd4 commit dd60653

11 files changed

Lines changed: 704 additions & 139 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "prompt-vcs"
7-
version = "0.5.0"
7+
version = "0.5.1"
88
description = "Git-native prompt management library for LLM applications"
99
readme = "README.md"
1010
license = "MIT"

src/prompt_vcs/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
ABTestResult,
1717
)
1818

19-
__version__ = "0.5.0"
19+
__version__ = "0.5.1"
2020
__all__ = [
2121
"p",
2222
"prompt",

src/prompt_vcs/cli.py

Lines changed: 176 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,45 @@
2727
console = 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()
3170
def 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

Comments
 (0)