#!/usr/bin/env python3 """Version and diff prompts with a local JSONL history store. Commands: - add - list - diff - changelog Input modes: - prompt text via --prompt, --prompt-file, --input JSON, or stdin JSON """ import argparse import difflib import json import sys from dataclasses import dataclass, asdict from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional class CLIError(Exception): """Raised for expected CLI failures.""" @dataclass class PromptVersion: name: str version: int author: str timestamp: str change_note: str prompt: str def add_common_subparser_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--store", default=".prompt_versions.jsonl", help="JSONL history file path.") parser.add_argument("--input", help="Optional JSON input file with prompt payload.") parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Version and diff prompts.") sub = parser.add_subparsers(dest="command", required=True) add = sub.add_parser("add", help="Add a new prompt version.") add_common_subparser_args(add) add.add_argument("--name", required=True, help="Prompt identifier.") add.add_argument("--prompt", help="Prompt text.") add.add_argument("--prompt-file", help="Prompt file path.") add.add_argument("--author", default="unknown", help="Author name.") add.add_argument("--change-note", default="", help="Reason for this revision.") ls = sub.add_parser("list", help="List versions for a prompt.") add_common_subparser_args(ls) ls.add_argument("--name", required=True, help="Prompt identifier.") diff = sub.add_parser("diff", help="Diff two prompt versions.") add_common_subparser_args(diff) diff.add_argument("--name", required=True, help="Prompt identifier.") diff.add_argument("--from-version", type=int, required=True) diff.add_argument("--to-version", type=int, required=True) changelog = sub.add_parser("changelog", help="Show changelog for a prompt.") add_common_subparser_args(changelog) changelog.add_argument("--name", required=True, help="Prompt identifier.") return parser def read_optional_json(input_path: Optional[str]) -> Dict[str, Any]: if input_path: try: return json.loads(Path(input_path).read_text(encoding="utf-8")) except Exception as exc: raise CLIError(f"Failed reading --input: {exc}") from exc if not sys.stdin.isatty(): raw = sys.stdin.read().strip() if raw: try: return json.loads(raw) except json.JSONDecodeError as exc: raise CLIError(f"Invalid JSON from stdin: {exc}") from exc return {} def read_store(path: Path) -> List[PromptVersion]: if not path.exists(): return [] versions: List[PromptVersion] = [] for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue obj = json.loads(line) versions.append(PromptVersion(**obj)) return versions def write_store(path: Path, versions: List[PromptVersion]) -> None: payload = "\n".join(json.dumps(asdict(v), ensure_ascii=True) for v in versions) path.write_text(payload + ("\n" if payload else ""), encoding="utf-8") def get_prompt_text(args: argparse.Namespace, payload: Dict[str, Any]) -> str: if args.prompt: return args.prompt if args.prompt_file: try: return Path(args.prompt_file).read_text(encoding="utf-8") except Exception as exc: raise CLIError(f"Failed reading prompt file: {exc}") from exc if payload.get("prompt"): return str(payload["prompt"]) raise CLIError("Prompt content required via --prompt, --prompt-file, --input JSON, or stdin JSON.") def next_version(versions: List[PromptVersion], name: str) -> int: existing = [v.version for v in versions if v.name == name] return (max(existing) + 1) if existing else 1 def main() -> int: parser = build_parser() args = parser.parse_args() payload = read_optional_json(args.input) store_path = Path(args.store) versions = read_store(store_path) if args.command == "add": prompt_name = str(payload.get("name", args.name)) prompt_text = get_prompt_text(args, payload) author = str(payload.get("author", args.author)) change_note = str(payload.get("change_note", args.change_note)) item = PromptVersion( name=prompt_name, version=next_version(versions, prompt_name), author=author, timestamp=datetime.now(timezone.utc).isoformat(), change_note=change_note, prompt=prompt_text, ) versions.append(item) write_store(store_path, versions) output: Dict[str, Any] = {"added": asdict(item), "store": str(store_path.resolve())} elif args.command == "list": prompt_name = str(payload.get("name", args.name)) matches = [asdict(v) for v in versions if v.name == prompt_name] output = {"name": prompt_name, "versions": matches} elif args.command == "changelog": prompt_name = str(payload.get("name", args.name)) matches = [v for v in versions if v.name == prompt_name] entries = [ { "version": v.version, "author": v.author, "timestamp": v.timestamp, "change_note": v.change_note, } for v in matches ] output = {"name": prompt_name, "changelog": entries} elif args.command == "diff": prompt_name = str(payload.get("name", args.name)) from_v = int(payload.get("from_version", args.from_version)) to_v = int(payload.get("to_version", args.to_version)) by_name = [v for v in versions if v.name == prompt_name] old = next((v for v in by_name if v.version == from_v), None) new = next((v for v in by_name if v.version == to_v), None) if not old or not new: raise CLIError("Requested versions not found for prompt name.") diff_lines = list( difflib.unified_diff( old.prompt.splitlines(), new.prompt.splitlines(), fromfile=f"{prompt_name}@v{from_v}", tofile=f"{prompt_name}@v{to_v}", lineterm="", ) ) output = { "name": prompt_name, "from_version": from_v, "to_version": to_v, "diff": diff_lines, } else: raise CLIError("Unknown command.") if args.format == "json": print(json.dumps(output, indent=2)) else: if args.command == "add": added = output["added"] print("Prompt version added") print(f"- name: {added['name']}") print(f"- version: {added['version']}") print(f"- author: {added['author']}") print(f"- store: {output['store']}") elif args.command in ("list", "changelog"): print(f"Prompt: {output['name']}") key = "versions" if args.command == "list" else "changelog" items = output[key] if not items: print("- no entries") else: for item in items: line = f"- v{item.get('version')} by {item.get('author')} at {item.get('timestamp')}" note = item.get("change_note") if note: line += f" | {note}" print(line) else: print("\n".join(output["diff"]) if output["diff"] else "No differences.") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except CLIError as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(2)