--registry now accepts an http(s):// URL as well as a path, implemented with urllib so reference/ keeps PyYAML as its only dependency. This was the first real test of INTENT principle 10's claim that a hosted registry layers on without changing package semantics. Verified against the running service, almost everything survived the transport unaltered: identity and its ambiguity rules (a bare id in two registries returns 409 over HTTP just as it does locally), immutability of a published id@version (identical content accepted, changed content refused, version bump accepted), strict packaging, validation, and the index. A package published and then installed over HTTP was byte-identical to its source — diff -r clean — and its canonical-fidelity eval still passed after the round trip. One thing did not survive: a URL is not a registry. A filesystem registry IS one registry and section 20.1 names it from its directory; an HTTP service HOSTS SEVERAL behind one base URL. The address therefore cannot name the registry, so it must be named separately — --as when publishing, a qualified reference when installing. Recorded as section 20.4 rather than worked around silently in the client, because the gap is in the specification's list of registry kinds, not in the CLI. Publishing to an HTTP registry without --as fails with that explanation rather than guessing a registry name. Registry responses are treated as untrusted input (section 19): decode_files refuses path traversal, with a test. The wire shape round-trips binary content through base64, also tested. Reference tests 99 -> 105. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM Assistant: claude-code Assistant-Model: opus Assistant-Process: 388925@bnt-lap001 Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
1606 lines
62 KiB
Python
Executable file
1606 lines
62 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Tiny reference CLI for Canned Prompt Format v0.1.
|
|
|
|
This implementation intentionally favors readability over features. It uses a
|
|
filesystem-backed local catalog and registry and never calls a model.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import base64
|
|
import datetime as dt
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
import yaml
|
|
|
|
FORMAT = "canned-prompt/v0.2"
|
|
# v0.2 is additive, so a v0.1 package means exactly what it always meant (§ 3.2).
|
|
ACCEPTED_FORMATS = ("canned-prompt/v0.2", "canned-prompt/v0.1")
|
|
REGISTRY_FORMAT = "canned-prompt-registry/v0.1"
|
|
LOCAL_REGISTRY = "local"
|
|
REGISTRY_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
EVAL_RUBRIC_SCHEMA = "canned-prompts/eval-rubric/v0.1"
|
|
RENDER_CHECKS = ("contains", "not_contains", "resolves_all")
|
|
CAPABILITY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
RESERVED_FILES = ("prompt.yaml", "README.md", "LICENSE")
|
|
RESERVED_DIRS = ("examples", "evals", "assets")
|
|
INDEX_FILE = "index.yaml"
|
|
INDEX_FORMAT = "canned-prompt-index/v0.1"
|
|
PLACEHOLDER_RE = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
|
|
SEMVER_RE = re.compile(
|
|
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
|
|
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
|
)
|
|
REQUIRED_FIELDS = ("format", "id", "name", "version", "summary", "template")
|
|
|
|
|
|
class CannedPromptError(Exception):
|
|
pass
|
|
|
|
|
|
def home_dir() -> Path:
|
|
return Path(os.environ.get("CANNED_PROMPTS_HOME", Path.home() / ".canned-prompts"))
|
|
|
|
|
|
def default_catalog() -> Path:
|
|
return home_dir() / "catalog"
|
|
|
|
|
|
def default_registry() -> Path:
|
|
# Named "default" rather than "registry" because § 20.1 takes a registry's
|
|
# name from its directory basename, and that name reaches qualified
|
|
# references, catalog paths and index rows.
|
|
return home_dir() / "default"
|
|
|
|
|
|
def legacy_registry() -> Path:
|
|
return home_dir() / "registry"
|
|
|
|
|
|
def check_legacy_registry(registry: Path) -> None:
|
|
"""Refuse to silently start a fresh default store beside a populated one."""
|
|
if registry.resolve() != default_registry().resolve():
|
|
return
|
|
if registry.exists() or not legacy_registry().is_dir():
|
|
return
|
|
raise CannedPromptError(
|
|
f"the default registry moved from {legacy_registry()} to {registry}, so "
|
|
f"a registry's basename names it (§ 20.1) rather than reading as "
|
|
f"'registry:'. Move it:\n mv {legacy_registry()} {registry}\n"
|
|
f"or keep using the old one explicitly with --registry {legacy_registry()}"
|
|
)
|
|
|
|
|
|
def read_manifest(package_dir: Path) -> dict[str, Any]:
|
|
manifest_path = package_dir / "prompt.yaml"
|
|
if not manifest_path.is_file():
|
|
raise CannedPromptError(f"missing manifest: {manifest_path}")
|
|
try:
|
|
data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as exc:
|
|
raise CannedPromptError(f"invalid YAML in {manifest_path}: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise CannedPromptError("prompt.yaml must contain a mapping")
|
|
return data
|
|
|
|
|
|
def safe_relative_file(package_dir: Path, relative: str, field: str) -> Path:
|
|
if not isinstance(relative, str) or not relative.strip():
|
|
raise CannedPromptError(f"{field} must be a non-empty relative path")
|
|
candidate = (package_dir / relative).resolve()
|
|
root = package_dir.resolve()
|
|
try:
|
|
candidate.relative_to(root)
|
|
except ValueError as exc:
|
|
raise CannedPromptError(f"{field} escapes the package: {relative}") from exc
|
|
if not candidate.is_file():
|
|
raise CannedPromptError(f"{field} does not reference a file: {relative}")
|
|
return candidate
|
|
|
|
|
|
def declared_names(manifest: dict[str, Any]) -> set[str]:
|
|
names: set[str] = set()
|
|
inputs = manifest.get("inputs") or []
|
|
if not isinstance(inputs, list):
|
|
raise CannedPromptError("inputs must be a list")
|
|
for item in inputs:
|
|
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
|
|
raise CannedPromptError("each input must be a mapping with a string name")
|
|
name = item["name"]
|
|
if name in names:
|
|
raise CannedPromptError(f"duplicate input/parameter name: {name}")
|
|
names.add(name)
|
|
|
|
parameters = manifest.get("parameters") or {}
|
|
if not isinstance(parameters, dict):
|
|
raise CannedPromptError("parameters must be a mapping")
|
|
for name, spec in parameters.items():
|
|
if not isinstance(name, str) or not isinstance(spec, dict):
|
|
raise CannedPromptError("parameters must map names to mappings")
|
|
if name in names:
|
|
raise CannedPromptError(f"duplicate input/parameter name: {name}")
|
|
names.add(name)
|
|
return names
|
|
|
|
|
|
def input_default_kind(item: dict[str, Any]) -> str:
|
|
"""Classify a default as 'none', 'static', 'derived', or 'included' (§ 6.1)."""
|
|
if "default" not in item:
|
|
return "none"
|
|
default = item["default"]
|
|
if isinstance(default, dict):
|
|
if "derive" in default and "include" in default:
|
|
return "conflict"
|
|
if "derive" in default:
|
|
return "derived"
|
|
if "include" in default:
|
|
return "included"
|
|
return "static"
|
|
|
|
|
|
def prompt_dependencies(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
dependencies = manifest.get("dependencies") or {}
|
|
if not isinstance(dependencies, dict):
|
|
raise CannedPromptError("dependencies must be a mapping")
|
|
prompts = dependencies.get("prompts") or []
|
|
if not isinstance(prompts, list):
|
|
raise CannedPromptError("dependencies.prompts must be a list")
|
|
declared: dict[str, dict[str, Any]] = {}
|
|
for entry in prompts:
|
|
if isinstance(entry, str):
|
|
declared[entry] = {"id": entry}
|
|
elif isinstance(entry, dict) and isinstance(entry.get("id"), str):
|
|
if "version" in entry:
|
|
validate_version_selector(
|
|
entry["version"], f"dependency {entry['id']!r}"
|
|
)
|
|
declared[entry["id"]] = entry
|
|
else:
|
|
raise CannedPromptError(
|
|
"each prompt dependency must be an id string or a mapping with a string id"
|
|
)
|
|
return declared
|
|
|
|
|
|
def validate_capabilities(names: Any, where: str) -> list[str]:
|
|
"""Validation rule 20 of § 18: shape is checked, membership is not (§ 10.2)."""
|
|
if names is None:
|
|
return []
|
|
if not isinstance(names, list):
|
|
raise CannedPromptError(f"{where} must be a list")
|
|
for name in names:
|
|
if not isinstance(name, str) or not CAPABILITY_RE.match(name):
|
|
raise CannedPromptError(f"{where}: {name!r} must be lowercase kebab-case")
|
|
return list(names)
|
|
|
|
|
|
def validate_context_dependencies(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Validation rule 19 of § 18.
|
|
|
|
A context dependency names something CPF does not package and cannot
|
|
resolve, so `description` is the only thing a consumer has to go on.
|
|
"""
|
|
dependencies = manifest.get("dependencies") or {}
|
|
entries = dependencies.get("context") or []
|
|
if not isinstance(entries, list):
|
|
raise CannedPromptError("dependencies.context must be a list")
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
raise CannedPromptError("each context dependency must be a mapping")
|
|
name = entry.get("name")
|
|
if not isinstance(name, str) or not CAPABILITY_RE.match(name):
|
|
raise CannedPromptError(
|
|
f"context dependency name {name!r} must be lowercase kebab-case; "
|
|
"context entries use 'name', not 'id', because nothing can look them up"
|
|
)
|
|
if not isinstance(entry.get("description"), str) or not entry["description"].strip():
|
|
raise CannedPromptError(
|
|
f"context dependency {name!r} must declare a description; nothing "
|
|
"else can explain a dependency the format does not package"
|
|
)
|
|
requirement = entry.get("requirement", "required")
|
|
if requirement not in ("required", "optional"):
|
|
raise CannedPromptError(
|
|
f"context dependency {name!r}: requirement must be 'required' or 'optional'"
|
|
)
|
|
return list(entries)
|
|
|
|
|
|
def read_eval(package_dir: Path, relative: str) -> dict[str, Any]:
|
|
path = safe_relative_file(package_dir, relative, "evals")
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as exc:
|
|
raise CannedPromptError(f"invalid YAML in {relative}: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise CannedPromptError(f"{relative}: an eval file must contain a mapping")
|
|
if not isinstance(data.get("schema"), str) or not data["schema"].strip():
|
|
raise CannedPromptError(f"{relative}: an eval file must declare a schema")
|
|
return data
|
|
|
|
|
|
def validate_eval(relative: str, spec: dict[str, Any], declared_examples: list[str]) -> None:
|
|
"""Validation rules 17-18 of § 18. Unknown schemas are ignored, not rejected."""
|
|
if spec["schema"] != EVAL_RUBRIC_SCHEMA:
|
|
return
|
|
if not isinstance(spec.get("name"), str) or not spec["name"].strip():
|
|
raise CannedPromptError(f"{relative}: eval must declare a name")
|
|
|
|
render = spec.get("render") or []
|
|
output = spec.get("output") or {}
|
|
if not render and not output:
|
|
raise CannedPromptError(
|
|
f"{relative}: eval asserts nothing; declare render checks or output criteria"
|
|
)
|
|
|
|
if not isinstance(render, list):
|
|
raise CannedPromptError(f"{relative}: render must be a list of checks")
|
|
for check in render:
|
|
if not isinstance(check, dict) or len(check) != 1:
|
|
raise CannedPromptError(
|
|
f"{relative}: each render check must be a single-key mapping"
|
|
)
|
|
(kind,) = check
|
|
if kind not in RENDER_CHECKS:
|
|
raise CannedPromptError(
|
|
f"{relative}: unknown render check {kind!r}; expected one of "
|
|
+ ", ".join(RENDER_CHECKS)
|
|
)
|
|
|
|
if output:
|
|
if not isinstance(output, dict):
|
|
raise CannedPromptError(f"{relative}: output must be a mapping")
|
|
criteria = output.get("criteria") or []
|
|
if not isinstance(criteria, list) or not all(isinstance(c, str) for c in criteria):
|
|
raise CannedPromptError(f"{relative}: output.criteria must be a list of strings")
|
|
|
|
example = spec.get("example")
|
|
if example is not None:
|
|
if not isinstance(example, str):
|
|
raise CannedPromptError(f"{relative}: example must be a path string")
|
|
if example not in declared_examples:
|
|
raise CannedPromptError(
|
|
f"{relative}: example {example!r} is not declared in the manifest's examples"
|
|
)
|
|
|
|
|
|
def check_composition_reference(
|
|
name: str, reference: str, field: str, declared: dict[str, dict[str, Any]]
|
|
) -> None:
|
|
if not reference.strip():
|
|
raise CannedPromptError(f"input {name!r}: {field} reference is empty")
|
|
if reference not in declared:
|
|
raise CannedPromptError(
|
|
f"input {name!r} {field}s {reference!r}, which is not declared "
|
|
"in dependencies.prompts"
|
|
)
|
|
if "version" not in declared[reference]:
|
|
raise CannedPromptError(
|
|
f"input {name!r} {field}s {reference!r}, so that dependency must "
|
|
"declare a version (§ 10.3)"
|
|
)
|
|
|
|
|
|
def validate_input_default(
|
|
item: dict[str, Any], declared: dict[str, dict[str, Any]]
|
|
) -> None:
|
|
"""Validation rules 11-16 of § 18."""
|
|
kind = input_default_kind(item)
|
|
if kind == "none":
|
|
return
|
|
name = item["name"]
|
|
if item.get("required", False):
|
|
raise CannedPromptError(
|
|
f"input {name!r} declares a default and required: true; a required "
|
|
"input is always supplied, so the default could never apply"
|
|
)
|
|
if kind == "static":
|
|
return
|
|
if kind == "conflict":
|
|
raise CannedPromptError(
|
|
f"input {name!r}: a default declares at most one of 'include' or 'derive'"
|
|
)
|
|
|
|
if kind == "included":
|
|
include = item["default"]["include"]
|
|
if not isinstance(include, str):
|
|
raise CannedPromptError(
|
|
f"input {name!r}: include must name a declared prompt dependency"
|
|
)
|
|
check_composition_reference(name, include, "include", declared)
|
|
return
|
|
|
|
derive = item["default"]["derive"]
|
|
if isinstance(derive, str):
|
|
check_composition_reference(name, derive, "derive", declared)
|
|
return
|
|
if isinstance(derive, dict):
|
|
prompt = derive.get("prompt")
|
|
if not isinstance(prompt, str) or not prompt.strip():
|
|
raise CannedPromptError(
|
|
f"input {name!r}: inline derive requires a non-empty prompt"
|
|
)
|
|
extra = sorted(set(derive) - {"prompt"})
|
|
if extra:
|
|
raise CannedPromptError(
|
|
f"input {name!r}: an inline derive cannot also reference a "
|
|
"dependency; unexpected keys: " + ", ".join(extra)
|
|
)
|
|
return
|
|
raise CannedPromptError(
|
|
f"input {name!r}: derive must be a dependency id or a mapping with a prompt"
|
|
)
|
|
|
|
|
|
def validate_package(package_dir: Path) -> dict[str, Any]:
|
|
package_dir = package_dir.resolve()
|
|
if not package_dir.is_dir():
|
|
raise CannedPromptError(f"package directory not found: {package_dir}")
|
|
|
|
manifest = read_manifest(package_dir)
|
|
missing = [field for field in REQUIRED_FIELDS if field not in manifest]
|
|
if missing:
|
|
raise CannedPromptError("missing required fields: " + ", ".join(missing))
|
|
|
|
if manifest["format"] not in ACCEPTED_FORMATS:
|
|
raise CannedPromptError(
|
|
f"unsupported format: {manifest['format']!r}; expected one of "
|
|
+ ", ".join(ACCEPTED_FORMATS)
|
|
)
|
|
|
|
package_id = manifest["id"]
|
|
if not isinstance(package_id, str) or not package_id.strip():
|
|
raise CannedPromptError("id must be a non-empty string")
|
|
if any(part in ("", ".", "..") for part in package_id.split("/")):
|
|
raise CannedPromptError("id contains an invalid path segment")
|
|
if re.search(r"[^A-Za-z0-9._/-]", package_id):
|
|
raise CannedPromptError("id contains unsupported characters")
|
|
|
|
version = manifest["version"]
|
|
if not isinstance(version, str) or not SEMVER_RE.match(version):
|
|
raise CannedPromptError("version must be semantic-version-like, e.g. 1.2.0")
|
|
|
|
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
|
|
|
for field in ("examples", "evals"):
|
|
refs = manifest.get(field) or []
|
|
if not isinstance(refs, list):
|
|
raise CannedPromptError(f"{field} must be a list")
|
|
for relative in refs:
|
|
safe_relative_file(package_dir, relative, field)
|
|
|
|
names = declared_names(manifest)
|
|
|
|
declared = prompt_dependencies(manifest)
|
|
for item in manifest.get("inputs") or []:
|
|
validate_input_default(item, declared)
|
|
|
|
validate_context_dependencies(manifest)
|
|
validate_capabilities(
|
|
(manifest.get("dependencies") or {}).get("capabilities"),
|
|
"dependencies.capabilities",
|
|
)
|
|
validate_capabilities(
|
|
(manifest.get("compatibility") or {}).get("capabilities"),
|
|
"compatibility.capabilities",
|
|
)
|
|
|
|
declared_examples = [str(path) for path in (manifest.get("examples") or [])]
|
|
for relative in manifest.get("evals") or []:
|
|
validate_eval(relative, read_eval(package_dir, relative), declared_examples)
|
|
|
|
template = template_path.read_text(encoding="utf-8")
|
|
placeholders = set(PLACEHOLDER_RE.findall(template))
|
|
undeclared = sorted(placeholders - names)
|
|
if undeclared:
|
|
raise CannedPromptError(
|
|
"template contains undeclared placeholders: " + ", ".join(undeclared)
|
|
)
|
|
|
|
return manifest
|
|
|
|
|
|
def check_registry_name(name: str) -> str:
|
|
if not isinstance(name, str) or not REGISTRY_NAME_RE.match(name):
|
|
raise CannedPromptError(
|
|
f"invalid registry name: {name!r}; expected letters, digits, '.', '-' or '_'"
|
|
)
|
|
return name
|
|
|
|
|
|
def parse_reference(reference: str) -> tuple[str | None, str]:
|
|
"""Split a qualified `<registry>:<id>` reference (§ 3.2)."""
|
|
if ":" not in reference:
|
|
return None, reference
|
|
registry, _, package_id = reference.partition(":")
|
|
if not registry or not package_id:
|
|
raise CannedPromptError(f"malformed reference: {reference!r}")
|
|
return check_registry_name(registry), package_id
|
|
|
|
|
|
def read_registry_manifest(registry: Path) -> dict[str, Any] | None:
|
|
"""Read an optional registry.yaml (§ 20.1). A bare directory is still valid."""
|
|
manifest_path = registry / "registry.yaml"
|
|
if not manifest_path.is_file():
|
|
return None
|
|
try:
|
|
data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as exc:
|
|
raise CannedPromptError(f"invalid YAML in {manifest_path}: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise CannedPromptError("registry.yaml must contain a mapping")
|
|
if data.get("format") != REGISTRY_FORMAT:
|
|
raise CannedPromptError(f"unsupported registry format: {data.get('format')!r}")
|
|
check_registry_name(data.get("name"))
|
|
|
|
namespaces = data.get("namespaces") or {}
|
|
if not isinstance(namespaces, dict):
|
|
raise CannedPromptError("registry namespaces must be a mapping")
|
|
for namespace, claim in namespaces.items():
|
|
if not isinstance(claim, dict):
|
|
raise CannedPromptError(f"namespace {namespace!r} must map to a mapping")
|
|
policy = claim.get("policy", "open")
|
|
if policy not in ("open", "closed"):
|
|
raise CannedPromptError(
|
|
f"namespace {namespace!r}: policy must be 'open' or 'closed'"
|
|
)
|
|
return data
|
|
|
|
|
|
def registry_name(registry: Path) -> str:
|
|
"""A registry's name: its manifest's, else its directory basename (§ 20.1)."""
|
|
manifest = read_registry_manifest(registry)
|
|
if manifest:
|
|
return manifest["name"]
|
|
return check_registry_name(registry.expanduser().resolve().name)
|
|
|
|
|
|
def namespace_policy(registry: Path, package_id: str) -> tuple[str, dict[str, Any] | None]:
|
|
manifest = read_registry_manifest(registry)
|
|
if not manifest:
|
|
return "open", None
|
|
namespace = package_id.split("/")[0] if "/" in package_id else package_id
|
|
claim = (manifest.get("namespaces") or {}).get(namespace)
|
|
if not claim:
|
|
return "open", None
|
|
return claim.get("policy", "open"), claim
|
|
|
|
|
|
def id_path(store: Path, package_id: str) -> Path:
|
|
parts = package_id.split("/")
|
|
if any(part in ("", ".", "..") for part in parts):
|
|
raise CannedPromptError("unsafe package id")
|
|
return store.joinpath(*parts)
|
|
|
|
|
|
def registry_package_path(registry: Path, package_id: str, version: str) -> Path:
|
|
"""A registry's own layout is flat: an id is unambiguous within a registry."""
|
|
return id_path(registry, package_id) / version
|
|
|
|
|
|
def catalog_package_path(catalog: Path, registry: str, package_id: str, version: str) -> Path:
|
|
"""The catalog is namespaced by registry (§ 20.2)."""
|
|
return id_path(catalog / check_registry_name(registry), package_id) / version
|
|
|
|
|
|
def validate_version_selector(value: Any, where: str) -> str:
|
|
"""A semver literal, `any`, `newest`, or a `>=` lower bound (§ 10.3)."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise CannedPromptError(f"{where}: version must be a non-empty string")
|
|
selector = value.strip()
|
|
if selector in ("any", "newest"):
|
|
return selector
|
|
compact = selector.replace(" ", "")
|
|
if compact.startswith(">="):
|
|
if not SEMVER_RE.match(compact[2:]):
|
|
raise CannedPromptError(f"{where}: {value!r} is not a valid lower bound")
|
|
return compact
|
|
if not SEMVER_RE.match(selector):
|
|
raise CannedPromptError(
|
|
f"{where}: unsupported version selector {value!r}; expected a semver "
|
|
"literal, 'any', 'newest', or '>= X.Y.Z'"
|
|
)
|
|
return selector
|
|
|
|
|
|
def select_version(available: list[str], selector: str | None) -> str | None:
|
|
"""Pick a version from `available` (newest first) per a § 10.3 selector.
|
|
|
|
Each dependency is selected independently against what is present. There is
|
|
no constraint solving across a dependency graph; that stays a non-goal.
|
|
|
|
Prereleases are excluded from `any`, `newest` and `>=` (§ 17.1); only an
|
|
exact pin selects one, so publishing a release candidate never changes what
|
|
existing consumers resolve to.
|
|
"""
|
|
if not available:
|
|
return None
|
|
if selector is not None and selector not in ("any", "newest") and not selector.startswith(">="):
|
|
return selector if selector in available else None
|
|
|
|
stable = [version for version in available if not is_prerelease(version)]
|
|
if not stable:
|
|
return None
|
|
if selector is None or selector in ("any", "newest"):
|
|
return stable[0]
|
|
floor = parse_semver(selector[2:])
|
|
for version in stable:
|
|
if parse_semver(version) >= floor:
|
|
return version
|
|
return None
|
|
|
|
|
|
def parse_semver(value: str) -> tuple[Any, ...]:
|
|
"""A precedence key following SemVer § 11 (spec § 17.1).
|
|
|
|
A prerelease ranks below its own release, so `1.0.0-rc1` < `1.0.0`. Build
|
|
metadata is ignored. Unparseable versions sort below everything.
|
|
"""
|
|
match = SEMVER_RE.match(value)
|
|
if not match:
|
|
return (-1, -1, -1, 0, (), value)
|
|
major, minor, patch = (int(match.group(i)) for i in (1, 2, 3))
|
|
prerelease = match.group(4)
|
|
if prerelease is None:
|
|
# 1 outranks the 0 given to a prerelease of the same numeric version.
|
|
return (major, minor, patch, 1, (), "")
|
|
identifiers: list[tuple[int, int, str]] = []
|
|
for part in prerelease.split("."):
|
|
# Numeric identifiers compare numerically and rank below alphanumeric ones.
|
|
identifiers.append((0, int(part), "") if part.isdigit() else (1, 0, part))
|
|
return (major, minor, patch, 0, tuple(identifiers), "")
|
|
|
|
|
|
def is_prerelease(value: str) -> bool:
|
|
match = SEMVER_RE.match(value)
|
|
return bool(match and match.group(4) is not None)
|
|
|
|
|
|
def versions_at(base: Path) -> list[str]:
|
|
if not base.is_dir():
|
|
return []
|
|
versions = [p.name for p in base.iterdir() if p.is_dir() and (p / "prompt.yaml").is_file()]
|
|
return sorted(versions, key=parse_semver, reverse=True)
|
|
|
|
|
|
def pick_version(base: Path, label: str, version: str | None) -> Path:
|
|
available = versions_at(base)
|
|
if version:
|
|
path = base / version
|
|
if not (path / "prompt.yaml").is_file():
|
|
raise CannedPromptError(f"package not found: {label}@{version}")
|
|
return path
|
|
if not available:
|
|
raise CannedPromptError(f"package not found: {label}")
|
|
chosen = select_version(available, None)
|
|
if chosen is None:
|
|
raise CannedPromptError(
|
|
f"{label}: only prerelease versions are available "
|
|
f"({', '.join(available)}); name one exactly to use it"
|
|
)
|
|
return base / chosen
|
|
|
|
|
|
def resolve_in_registry(registry: Path, package_id: str, version: str | None) -> Path:
|
|
return pick_version(id_path(registry, package_id), package_id, version)
|
|
|
|
|
|
def catalog_registries(catalog: Path) -> list[str]:
|
|
if not catalog.is_dir():
|
|
return []
|
|
return sorted(p.name for p in catalog.iterdir() if p.is_dir())
|
|
|
|
|
|
def legacy_catalog_entry(catalog: Path, package_id: str) -> bool:
|
|
"""True if the pre-registry-scoped catalog layout holds this id."""
|
|
try:
|
|
base = id_path(catalog, package_id)
|
|
except CannedPromptError:
|
|
return False
|
|
return bool(versions_at(base))
|
|
|
|
|
|
def resolve_installed(
|
|
catalog: Path, reference: str, version: str | None
|
|
) -> tuple[Path, str]:
|
|
"""Resolve a bare id or a qualified `<registry>:<id>` against the catalog.
|
|
|
|
Returns the package directory and the registry it was installed from. A
|
|
bare id present in more than one registry is ambiguous and is reported, not
|
|
guessed (§ 3.2).
|
|
"""
|
|
registry, package_id = parse_reference(reference)
|
|
if registry is not None:
|
|
matches = [registry] if versions_at(id_path(catalog / registry, package_id)) else []
|
|
else:
|
|
matches = [
|
|
name
|
|
for name in catalog_registries(catalog)
|
|
if versions_at(id_path(catalog / name, package_id))
|
|
]
|
|
|
|
if not matches:
|
|
if registry is None and legacy_catalog_entry(catalog, package_id):
|
|
raise CannedPromptError(
|
|
f"{package_id} is stored in the pre-registry-scoped catalog layout; "
|
|
"catalogs are now namespaced by registry (§ 20.2). Re-add or "
|
|
"re-install the package, or move it under a registry directory"
|
|
)
|
|
raise CannedPromptError(f"package not found: {reference}")
|
|
if len(matches) > 1:
|
|
raise CannedPromptError(
|
|
f"{package_id} is installed from more than one registry: "
|
|
+ ", ".join(f"{name}:{package_id}" for name in matches)
|
|
+ " — qualify the reference"
|
|
)
|
|
|
|
name = matches[0]
|
|
return pick_version(id_path(catalog / name, package_id), f"{name}:{package_id}", version), name
|
|
|
|
|
|
def package_members(package_dir: Path, manifest: dict[str, Any]) -> set[str]:
|
|
"""Relative paths that belong to the package (§ 2).
|
|
|
|
Reserved paths plus whatever a manifest field references. Everything else in
|
|
the working directory — `.git`, a virtualenv, scratch files — is not part of
|
|
the package.
|
|
"""
|
|
members: set[str] = set()
|
|
for name in RESERVED_FILES:
|
|
if (package_dir / name).is_file():
|
|
members.add(name)
|
|
for name in RESERVED_DIRS:
|
|
directory = package_dir / name
|
|
if directory.is_dir():
|
|
for path in directory.rglob("*"):
|
|
if path.is_file():
|
|
members.add(path.relative_to(package_dir).as_posix())
|
|
|
|
referenced = [("template", manifest["template"])]
|
|
for key in ("examples", "evals"):
|
|
referenced.extend((key, relative) for relative in (manifest.get(key) or []))
|
|
for field, relative in referenced:
|
|
resolved = safe_relative_file(package_dir, relative, field)
|
|
members.add(resolved.relative_to(package_dir.resolve()).as_posix())
|
|
return members
|
|
|
|
|
|
def skipped_entries(package_dir: Path, members: set[str]) -> list[str]:
|
|
"""Top-level entries that will not be packaged.
|
|
|
|
Reported by name only; nothing walks into an ignored directory, so a stray
|
|
virtualenv costs nothing to skip.
|
|
"""
|
|
covered = {member.split("/", 1)[0] for member in members}
|
|
skipped = []
|
|
for entry in sorted(package_dir.iterdir()):
|
|
if entry.name in covered:
|
|
continue
|
|
skipped.append(entry.name + ("/" if entry.is_dir() else ""))
|
|
return skipped
|
|
|
|
|
|
def copy_package(src: Path, dst: Path, manifest: dict[str, Any], what: str) -> list[str]:
|
|
"""Copy only what belongs to the package (§ 2). Returns what was skipped."""
|
|
if dst.exists():
|
|
raise CannedPromptError(f"{what} already exists: {dst}")
|
|
members = package_members(src, manifest)
|
|
skipped = skipped_entries(src, members)
|
|
dst.mkdir(parents=True)
|
|
for relative in sorted(members):
|
|
target = dst / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src / relative, target)
|
|
return skipped
|
|
|
|
|
|
def read_index(store: Path) -> dict[str, Any]:
|
|
"""The catalog index: which packages entered this store, from where, when.
|
|
|
|
Registry-side metadata, deliberately outside the immutable package (§ 20).
|
|
A package records who wrote it; the index records how it got here.
|
|
"""
|
|
path = store / INDEX_FILE
|
|
if not path.is_file():
|
|
return {"format": INDEX_FORMAT, "entries": []}
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as exc:
|
|
raise CannedPromptError(f"invalid YAML in {path}: {exc}") from exc
|
|
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
|
|
raise CannedPromptError(f"{path}: index must be a mapping with an entries list")
|
|
if data.get("format") != INDEX_FORMAT:
|
|
raise CannedPromptError(f"unsupported index format: {data.get('format')!r}")
|
|
return data
|
|
|
|
|
|
def index_entry_key(entry: dict[str, Any]) -> tuple[str, str, str]:
|
|
return (entry.get("registry", ""), entry.get("id", ""), entry.get("version", ""))
|
|
|
|
|
|
def record_inclusion(
|
|
store: Path,
|
|
registry: str,
|
|
manifest: dict[str, Any],
|
|
source: str,
|
|
method: str,
|
|
) -> dict[str, Any]:
|
|
"""Record that a package version entered this store, and from where.
|
|
|
|
Re-recording the same registry/id/version keeps the original `included_at`:
|
|
the date a package first entered is a fact about history, not about the last
|
|
time someone re-ran a command.
|
|
"""
|
|
index = read_index(store)
|
|
entry = {
|
|
"registry": registry,
|
|
"id": manifest["id"],
|
|
"version": manifest["version"],
|
|
"name": manifest.get("name"),
|
|
"source": source,
|
|
"method": method,
|
|
"included_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
}
|
|
provenance = manifest.get("provenance") or {}
|
|
if isinstance(provenance, dict):
|
|
for field in ("author", "source"):
|
|
if provenance.get(field):
|
|
entry[f"declared_{field}"] = str(provenance[field])
|
|
if manifest.get("license"):
|
|
entry["license"] = manifest["license"]
|
|
|
|
existing = {index_entry_key(e): e for e in index["entries"]}
|
|
key = index_entry_key(entry)
|
|
if key in existing:
|
|
entry["included_at"] = existing[key].get("included_at", entry["included_at"])
|
|
entry["last_seen_at"] = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
existing[key] = entry
|
|
|
|
index["entries"] = [existing[k] for k in sorted(existing)]
|
|
store.mkdir(parents=True, exist_ok=True)
|
|
(store / INDEX_FILE).write_text(
|
|
yaml.safe_dump(index, sort_keys=False, allow_unicode=True), encoding="utf-8"
|
|
)
|
|
return entry
|
|
|
|
|
|
# --- HTTP registries (§ 20) ----------------------------------------------
|
|
#
|
|
# § 20 lists "an HTTP service" as a valid registry. Implementing one surfaced a
|
|
# distinction the specification does not draw: a filesystem registry *is* one
|
|
# registry, but an HTTP service *hosts several* — the reference service keeps
|
|
# `local`, `helix` and others behind a single base URL. So a URL does not name a
|
|
# registry the way a directory does, and the registry must be named separately:
|
|
# by `--as` when publishing, and by a qualified reference when installing.
|
|
# Recorded as a finding rather than worked around silently.
|
|
|
|
|
|
def is_http_registry(value: str) -> bool:
|
|
return value.startswith("http://") or value.startswith("https://")
|
|
|
|
|
|
def http_request(
|
|
url: str, method: str = "GET", payload: dict[str, Any] | None = None
|
|
) -> dict[str, Any]:
|
|
"""A registry call. stdlib only, so `reference/` stays dependency-light."""
|
|
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
request = urllib.request.Request(url, data=data, method=method)
|
|
request.add_header("Accept", "application/json")
|
|
if data is not None:
|
|
request.add_header("Content-Type", "application/json")
|
|
token = os.environ.get("CANNED_PROMPTS_PUBLISH_TOKEN")
|
|
if token:
|
|
request.add_header("Authorization", f"Bearer {token}")
|
|
try:
|
|
with urllib.request.urlopen(request) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode("utf-8", "replace")
|
|
try:
|
|
detail = json.loads(body).get("detail", body)
|
|
except json.JSONDecodeError:
|
|
detail = body
|
|
raise CannedPromptError(f"registry refused ({exc.code}): {detail}") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise CannedPromptError(f"registry unreachable: {exc.reason}") from exc
|
|
|
|
|
|
def encode_files(package_dir: Path, manifest: dict[str, Any]) -> dict[str, Any]:
|
|
"""The package's files, in the shape the service's archive returns."""
|
|
files: dict[str, Any] = {}
|
|
for relative in sorted(package_members(package_dir, manifest)):
|
|
raw = (package_dir / relative).read_bytes()
|
|
try:
|
|
files[relative] = {"text": raw.decode("utf-8")}
|
|
except UnicodeDecodeError:
|
|
files[relative] = {"base64": base64.b64encode(raw).decode()}
|
|
return files
|
|
|
|
|
|
def decode_files(files: dict[str, Any], destination: Path) -> None:
|
|
for relative, item in files.items():
|
|
if relative.startswith("/") or ".." in Path(relative).parts:
|
|
raise CannedPromptError(f"unsafe path from registry: {relative}")
|
|
target = destination / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if "text" in item:
|
|
target.write_text(item["text"], encoding="utf-8")
|
|
else:
|
|
target.write_bytes(base64.b64decode(item["base64"]))
|
|
|
|
|
|
def missing_dependencies(catalog: Path, manifest: dict[str, Any]) -> list[str]:
|
|
"""Declared prompt dependencies that this catalog cannot satisfy.
|
|
|
|
CPF leaves dependency *resolution* to the consumer (§ 10), so this tool does
|
|
not fetch anything. It does refuse to hand over a package that looks
|
|
installed but cannot render.
|
|
"""
|
|
missing: list[str] = []
|
|
for reference, entry in prompt_dependencies(manifest).items():
|
|
registry, package_id = parse_reference(reference)
|
|
names = [registry] if registry is not None else catalog_registries(catalog)
|
|
selector = entry.get("version")
|
|
if any(
|
|
select_version(versions_at(id_path(catalog / name, package_id)), selector)
|
|
for name in names
|
|
):
|
|
continue
|
|
missing.append(f"{reference}@{selector}" if selector else reference)
|
|
return missing
|
|
|
|
|
|
def report_missing_dependencies(catalog: Path, manifest: dict[str, Any]) -> None:
|
|
missing = missing_dependencies(catalog, manifest)
|
|
if missing:
|
|
print(
|
|
"declared dependencies not in this catalog: "
|
|
+ ", ".join(missing)
|
|
+ " — install them, or composition referencing them will not resolve",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def duplicate_inclusions(resolution: Resolution) -> list[str]:
|
|
"""Included values that will render more than once (§ 10.4).
|
|
|
|
Two ways this happens, and both are the same defect to an author:
|
|
|
|
- two inputs include the same dependency, so the values are equal;
|
|
- an included package inherits an outer input of the same name and renders
|
|
it again, so one included value *contains* another.
|
|
|
|
The second is the common one and is easy to miss: nothing was included
|
|
twice, but the text appears twice all the same.
|
|
"""
|
|
included = {
|
|
name: str(resolution.values[name])
|
|
for name, origin in resolution.origins.items()
|
|
if origin.startswith("included from") and name in resolution.values
|
|
}
|
|
duplicated: set[str] = set()
|
|
for name, value in included.items():
|
|
if not value.strip():
|
|
continue
|
|
for other, other_value in included.items():
|
|
if other != name and (value == other_value or value in other_value):
|
|
duplicated.add(name)
|
|
return sorted(duplicated)
|
|
|
|
|
|
def report_repeated_inclusions(resolution: Resolution) -> None:
|
|
repeated = duplicate_inclusions(resolution)
|
|
if repeated:
|
|
print(
|
|
"rendered more than once: "
|
|
+ ", ".join(repeated)
|
|
+ " — inclusion is textual and is not deduplicated (§ 10.4); "
|
|
"extract the shared part into its own fragment",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def report_skipped(skipped: list[str]) -> None:
|
|
if skipped:
|
|
print(
|
|
"not packaged (not a reserved path, not referenced by the manifest): "
|
|
+ ", ".join(skipped),
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def iter_catalog(catalog: Path) -> Iterable[tuple[str, Path, dict[str, Any]]]:
|
|
for name in catalog_registries(catalog):
|
|
for manifest_path in (catalog / name).rglob("prompt.yaml"):
|
|
package_dir = manifest_path.parent
|
|
try:
|
|
manifest = validate_package(package_dir)
|
|
except CannedPromptError:
|
|
continue
|
|
yield name, package_dir, manifest
|
|
|
|
|
|
def cmd_add(args: argparse.Namespace) -> None:
|
|
src = Path(args.path)
|
|
manifest = validate_package(src)
|
|
catalog = Path(args.catalog).expanduser()
|
|
registry = check_registry_name(args.as_registry)
|
|
dst = catalog_package_path(catalog, registry, manifest["id"], manifest["version"])
|
|
report_skipped(copy_package(src.resolve(), dst, manifest, "catalog package"))
|
|
record_inclusion(catalog, registry, manifest, str(src.resolve()), "add")
|
|
report_missing_dependencies(catalog, manifest)
|
|
print(f"added {registry}:{manifest['id']}@{manifest['version']} -> {dst}")
|
|
|
|
|
|
def cmd_publish(args: argparse.Namespace) -> None:
|
|
src = Path(args.path)
|
|
manifest = validate_package(src)
|
|
|
|
if is_http_registry(args.registry):
|
|
# A URL hosts several registries, so it cannot name one on its own.
|
|
if not args.as_registry:
|
|
raise CannedPromptError(
|
|
"publishing to an HTTP registry needs --as NAME: a URL addresses a "
|
|
"service that hosts several registries, so unlike a directory it "
|
|
"does not name one (§ 20)"
|
|
)
|
|
result = http_request(
|
|
args.registry.rstrip("/") + "/packages",
|
|
method="POST",
|
|
payload={
|
|
"registry": check_registry_name(args.as_registry),
|
|
"source": str(src.resolve()),
|
|
"files": encode_files(src.resolve(), manifest),
|
|
},
|
|
)
|
|
print(f"published {result['reference']} -> {args.registry}")
|
|
return
|
|
|
|
registry = Path(args.registry).expanduser()
|
|
check_legacy_registry(registry)
|
|
name = registry_name(registry) if registry.is_dir() else None
|
|
|
|
policy, claim = namespace_policy(registry, manifest["id"]) if registry.is_dir() else ("open", None)
|
|
if policy == "closed":
|
|
owner = (claim or {}).get("owner")
|
|
suffix = f" (owner: {owner})" if owner else ""
|
|
print(
|
|
f"warning: namespace {manifest['id'].split('/')[0]!r} is declared closed"
|
|
f"{suffix}; this tool cannot authenticate a publisher",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
dst = registry_package_path(registry, manifest["id"], manifest["version"])
|
|
report_skipped(copy_package(src.resolve(), dst, manifest, "published package"))
|
|
record_inclusion(registry, name or "", manifest, str(src.resolve()), "publish")
|
|
label = f"{name}:{manifest['id']}" if name else manifest["id"]
|
|
print(f"published {label}@{manifest['version']} -> {dst}")
|
|
|
|
|
|
def cmd_install(args: argparse.Namespace) -> None:
|
|
catalog = Path(args.catalog).expanduser()
|
|
|
|
if is_http_registry(args.registry):
|
|
reference = args.id + (f"@{args.version}" if args.version else "")
|
|
archive = http_request(
|
|
args.registry.rstrip("/") + "/archives/" + reference
|
|
)
|
|
source_registry, _, rest = archive["reference"].partition(":")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
staged = Path(tmp) / "package"
|
|
staged.mkdir()
|
|
decode_files(archive["files"], staged)
|
|
manifest = validate_package(staged)
|
|
dst = catalog_package_path(
|
|
catalog, source_registry, manifest["id"], manifest["version"]
|
|
)
|
|
copy_package(staged, dst, manifest, "catalog package")
|
|
record_inclusion(catalog, source_registry, manifest, args.registry, "install")
|
|
report_missing_dependencies(catalog, manifest)
|
|
print(f"installed {archive['reference']} -> {dst}")
|
|
return
|
|
|
|
registry = Path(args.registry).expanduser()
|
|
check_legacy_registry(registry)
|
|
name = registry_name(registry)
|
|
|
|
qualifier, package_id = parse_reference(args.id)
|
|
if qualifier is not None and qualifier != name:
|
|
raise CannedPromptError(
|
|
f"reference names registry {qualifier!r} but --registry is {name!r}"
|
|
)
|
|
|
|
src = resolve_in_registry(registry, package_id, args.version)
|
|
manifest = validate_package(src)
|
|
dst = catalog_package_path(catalog, name, manifest["id"], manifest["version"])
|
|
copy_package(src, dst, manifest, "catalog package")
|
|
record_inclusion(catalog, name, manifest, str(registry.resolve()), "install")
|
|
report_missing_dependencies(catalog, manifest)
|
|
print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}")
|
|
|
|
|
|
def cmd_search(args: argparse.Namespace) -> None:
|
|
catalog = Path(args.catalog).expanduser()
|
|
query = args.query.lower()
|
|
matches: list[dict[str, str]] = []
|
|
for registry, _, manifest in iter_catalog(catalog):
|
|
haystack = " ".join(
|
|
[
|
|
str(manifest.get("id", "")),
|
|
str(manifest.get("name", "")),
|
|
str(manifest.get("summary", "")),
|
|
" ".join(str(tag) for tag in (manifest.get("tags") or [])),
|
|
]
|
|
).lower()
|
|
if query in haystack:
|
|
matches.append(
|
|
{
|
|
"registry": registry,
|
|
"id": manifest["id"],
|
|
"version": manifest["version"],
|
|
"name": manifest["name"],
|
|
"summary": manifest["summary"],
|
|
}
|
|
)
|
|
matches.sort(key=lambda m: (m["registry"], m["id"], parse_semver(m["version"])))
|
|
if args.json:
|
|
print(json.dumps(matches, indent=2, ensure_ascii=False))
|
|
return
|
|
if not matches:
|
|
print("no matches")
|
|
return
|
|
for item in matches:
|
|
print(f"{item['registry']}:{item['id']}@{item['version']} {item['name']}")
|
|
print(f" {item['summary']}")
|
|
|
|
|
|
def cmd_show(args: argparse.Namespace) -> None:
|
|
catalog = Path(args.catalog).expanduser()
|
|
package_dir, _ = resolve_installed(catalog, args.id, args.version)
|
|
manifest = validate_package(package_dir)
|
|
if args.json:
|
|
print(json.dumps(manifest, indent=2, ensure_ascii=False))
|
|
else:
|
|
print(yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True).rstrip())
|
|
|
|
|
|
def coerce_value(raw: Any, spec: dict[str, Any]) -> Any:
|
|
kind = spec.get("type", "string")
|
|
if not isinstance(raw, str):
|
|
# Already typed — from a YAML fixture rather than the command line.
|
|
# Validate membership, but do not try to parse it.
|
|
if kind == "enum" and raw not in (spec.get("values") or []):
|
|
raise CannedPromptError(
|
|
f"invalid enum value {raw!r}; expected one of {spec.get('values') or []}"
|
|
)
|
|
return raw
|
|
if kind == "boolean":
|
|
lowered = raw.lower()
|
|
if lowered in {"true", "1", "yes", "on"}:
|
|
return True
|
|
if lowered in {"false", "0", "no", "off"}:
|
|
return False
|
|
raise CannedPromptError(f"cannot parse boolean value: {raw}")
|
|
if kind == "integer":
|
|
try:
|
|
return int(raw)
|
|
except ValueError as exc:
|
|
raise CannedPromptError(f"cannot parse integer value: {raw}") from exc
|
|
if kind == "number":
|
|
try:
|
|
return float(raw)
|
|
except ValueError as exc:
|
|
raise CannedPromptError(f"cannot parse numeric value: {raw}") from exc
|
|
if kind == "enum":
|
|
values = spec.get("values") or []
|
|
if raw not in values:
|
|
raise CannedPromptError(f"invalid enum value {raw!r}; expected one of {values}")
|
|
return raw
|
|
|
|
|
|
def supplied_values(pairs: list[str]) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
for pair in pairs:
|
|
if "=" not in pair:
|
|
raise CannedPromptError(f"--set expects name=value, got: {pair}")
|
|
name, value = pair.split("=", 1)
|
|
if not name:
|
|
raise CannedPromptError("--set name cannot be empty")
|
|
values[name] = value
|
|
return values
|
|
|
|
|
|
@dataclass
|
|
class Resolution:
|
|
"""The outcome of § 5.1 resolution, separate from rendering."""
|
|
|
|
values: dict[str, Any] = field(default_factory=dict)
|
|
origins: dict[str, str] = field(default_factory=dict)
|
|
underivable: list[str] = field(default_factory=list)
|
|
|
|
|
|
def resolve_inputs(
|
|
manifest: dict[str, Any],
|
|
raw_values: dict[str, str],
|
|
composer: "Composer | None" = None,
|
|
inherited: dict[str, Any] | None = None,
|
|
) -> Resolution:
|
|
"""Resolve inputs and parameters per § 5.1.
|
|
|
|
`composer`, when supplied, satisfies `include` defaults by rendering the
|
|
included package (§ 10.4). Inclusion is deterministic, so a tool that can
|
|
render can satisfy it; derivation is not, and this tool never calls a model,
|
|
so a derived default is satisfied only by its static fallback `value`.
|
|
|
|
`inherited` carries the including package's already-resolved values into an
|
|
included package (§ 10.4). Those values are used as-is rather than coerced,
|
|
and do not count as caller-supplied.
|
|
"""
|
|
result = Resolution()
|
|
inherited = inherited or {}
|
|
known: set[str] = set()
|
|
declared = prompt_dependencies(manifest)
|
|
|
|
def compose(name: str, item: dict[str, Any]) -> None:
|
|
default = item["default"]
|
|
reference = default["include"]
|
|
if composer is not None:
|
|
entry = declared.get(reference) or {}
|
|
result.values[name] = composer(reference, entry.get("version"), result.values)
|
|
result.origins[name] = f"included from {reference}"
|
|
return
|
|
if "value" in default:
|
|
result.values[name] = default["value"]
|
|
result.origins[name] = "fallback (not included)"
|
|
else:
|
|
result.origins[name] = "unresolved (included, no fallback)"
|
|
result.underivable.append(name)
|
|
|
|
# Parameters resolve first: a composed input inherits the including
|
|
# package's resolved values (§ 10.4), so those must already be settled.
|
|
parameters = manifest.get("parameters") or {}
|
|
for name, spec in parameters.items():
|
|
known.add(name)
|
|
if name in raw_values:
|
|
result.values[name] = coerce_value(raw_values[name], spec)
|
|
result.origins[name] = "supplied"
|
|
elif name in inherited:
|
|
result.values[name] = inherited[name]
|
|
result.origins[name] = "inherited"
|
|
elif "default" in spec:
|
|
result.values[name] = spec["default"]
|
|
result.origins[name] = "default"
|
|
else:
|
|
result.origins[name] = "unresolved (no default)"
|
|
|
|
inputs = manifest.get("inputs") or []
|
|
for item in inputs:
|
|
name = item["name"]
|
|
known.add(name)
|
|
if name in raw_values:
|
|
result.values[name] = raw_values[name]
|
|
result.origins[name] = "supplied"
|
|
continue
|
|
if name in inherited:
|
|
result.values[name] = inherited[name]
|
|
result.origins[name] = "inherited"
|
|
continue
|
|
if item.get("required", False):
|
|
raise CannedPromptError(f"missing required input: {name}")
|
|
|
|
kind = input_default_kind(item)
|
|
if kind == "static":
|
|
result.values[name] = item["default"]
|
|
result.origins[name] = "default"
|
|
elif kind == "included":
|
|
compose(name, item)
|
|
elif kind == "derived":
|
|
default = item["default"]
|
|
if "value" in default:
|
|
result.values[name] = default["value"]
|
|
result.origins[name] = "fallback (not derived)"
|
|
else:
|
|
result.origins[name] = "unresolved (derived, no fallback)"
|
|
result.underivable.append(name)
|
|
else:
|
|
result.origins[name] = "unresolved (optional, no default)"
|
|
|
|
# Report inputs before parameters regardless of resolution order.
|
|
ordered = {item["name"]: result.origins[item["name"]] for item in inputs}
|
|
ordered.update({name: result.origins[name] for name in parameters})
|
|
result.origins = ordered
|
|
|
|
unknown = sorted(set(raw_values) - known)
|
|
if unknown:
|
|
raise CannedPromptError("unknown values: " + ", ".join(unknown))
|
|
|
|
return result
|
|
|
|
|
|
def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict[str, Any]:
|
|
return resolve_inputs(manifest, raw_values).values
|
|
|
|
|
|
class CatalogComposer:
|
|
"""Satisfies `include` defaults from the catalog (§ 10.4).
|
|
|
|
Inclusion is text composition: the included package is rendered and its text
|
|
is inlined. No model is involved, so this is deterministic.
|
|
"""
|
|
|
|
def __init__(self, catalog: Path) -> None:
|
|
self.catalog = catalog
|
|
self._stack: list[str] = []
|
|
|
|
def __call__(
|
|
self, reference: str, selector: str | None, outer_values: dict[str, Any]
|
|
) -> str:
|
|
if reference in self._stack:
|
|
raise CannedPromptError(
|
|
"inclusion cycle: " + " -> ".join([*self._stack, reference])
|
|
)
|
|
|
|
registry, package_id = parse_reference(reference)
|
|
names = (
|
|
[registry]
|
|
if registry is not None
|
|
else [
|
|
name
|
|
for name in catalog_registries(self.catalog)
|
|
if versions_at(id_path(self.catalog / name, package_id))
|
|
]
|
|
)
|
|
if len(names) > 1:
|
|
raise CannedPromptError(
|
|
f"included package {package_id} is present in more than one registry: "
|
|
+ ", ".join(f"{name}:{package_id}" for name in names)
|
|
+ " — qualify the dependency id"
|
|
)
|
|
if not names:
|
|
raise CannedPromptError(f"included package not found: {reference}")
|
|
|
|
base = id_path(self.catalog / names[0], package_id)
|
|
version = select_version(versions_at(base), selector)
|
|
if version is None:
|
|
raise CannedPromptError(
|
|
f"included package {reference}: no version satisfies {selector!r}"
|
|
)
|
|
|
|
package_dir = base / version
|
|
manifest = validate_package(package_dir)
|
|
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
|
|
|
self._stack.append(reference)
|
|
try:
|
|
inner = resolve_inputs(manifest, {}, composer=self, inherited=outer_values)
|
|
except CannedPromptError as exc:
|
|
if str(exc).startswith("inclusion cycle:"):
|
|
raise
|
|
raise CannedPromptError(f"while including {reference}: {exc}") from exc
|
|
finally:
|
|
self._stack.pop()
|
|
|
|
text = template_path.read_text(encoding="utf-8")
|
|
used = set(PLACEHOLDER_RE.findall(text))
|
|
missing = sorted(used - set(inner.values))
|
|
if missing:
|
|
raise CannedPromptError(
|
|
f"included package {reference} has unresolved inputs the including "
|
|
"package does not supply: " + ", ".join(missing)
|
|
)
|
|
return render_template(text, inner.values)
|
|
|
|
|
|
Composer = CatalogComposer
|
|
|
|
|
|
def render_template(template: str, values: dict[str, Any]) -> str:
|
|
def replace(match: re.Match[str]) -> str:
|
|
name = match.group(1)
|
|
if name not in values:
|
|
raise CannedPromptError(f"unresolved placeholder: {name}")
|
|
value = values[name]
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, (dict, list)):
|
|
return json.dumps(value, ensure_ascii=False)
|
|
return str(value)
|
|
|
|
return PLACEHOLDER_RE.sub(replace, template)
|
|
|
|
|
|
def cmd_render(args: argparse.Namespace) -> None:
|
|
catalog = Path(args.catalog).expanduser()
|
|
package_dir, _ = resolve_installed(catalog, args.id, args.version)
|
|
manifest = validate_package(package_dir)
|
|
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
|
raw = supplied_values(args.set_values)
|
|
composer = CatalogComposer(catalog)
|
|
resolution = resolve_inputs(manifest, raw, composer=composer)
|
|
report_repeated_inclusions(resolution)
|
|
|
|
template = template_path.read_text(encoding="utf-8")
|
|
used = set(PLACEHOLDER_RE.findall(template))
|
|
blocked = sorted(used.intersection(resolution.underivable))
|
|
if blocked:
|
|
raise CannedPromptError(
|
|
"cannot render: this tool does not derive values, and these inputs "
|
|
"declare a derived default with no static fallback: "
|
|
+ ", ".join(blocked)
|
|
+ " — supply them with --set, or add a fallback `value` to the default"
|
|
)
|
|
|
|
rendered = render_template(template, resolution.values)
|
|
print(rendered, end="" if rendered.endswith("\n") else "\n")
|
|
|
|
|
|
def cmd_resolve(args: argparse.Namespace) -> None:
|
|
catalog = Path(args.catalog).expanduser()
|
|
package_dir, _ = resolve_installed(catalog, args.id, args.version)
|
|
manifest = validate_package(package_dir)
|
|
raw = supplied_values(args.set_values)
|
|
composer = CatalogComposer(catalog)
|
|
resolution = resolve_inputs(manifest, raw, composer=composer)
|
|
report_repeated_inclusions(resolution)
|
|
|
|
if args.json:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"values": resolution.values,
|
|
"origins": resolution.origins,
|
|
"underivable": resolution.underivable,
|
|
},
|
|
indent=2,
|
|
ensure_ascii=False,
|
|
)
|
|
)
|
|
return
|
|
|
|
width = max((len(name) for name in resolution.origins), default=0)
|
|
origin_width = max((len(o) for o in resolution.origins.values()), default=0)
|
|
for name, origin in resolution.origins.items():
|
|
if name in resolution.values:
|
|
preview = str(resolution.values[name]).replace("\n", " ")
|
|
if len(preview) > 48:
|
|
preview = preview[:45] + "..."
|
|
print(f"{name:<{width}} {origin:<{origin_width}} {preview}")
|
|
else:
|
|
print(f"{name:<{width}} {origin}")
|
|
dependencies = manifest.get("dependencies") or {}
|
|
capabilities = dependencies.get("capabilities") or []
|
|
context = [
|
|
entry
|
|
for entry in (dependencies.get("context") or [])
|
|
if entry.get("requirement", "required") == "required"
|
|
]
|
|
if capabilities or context:
|
|
print()
|
|
print("requires (this tool cannot verify these):")
|
|
for name in capabilities:
|
|
print(f" capability {name}")
|
|
for entry in context:
|
|
print(f" context {entry['name']} — {entry['description'].strip()}")
|
|
|
|
if resolution.underivable:
|
|
print()
|
|
plural = len(resolution.underivable) > 1
|
|
print(
|
|
"note: "
|
|
+ ", ".join(resolution.underivable)
|
|
+ (" declare" if plural else " declares")
|
|
+ " a derived default this tool cannot satisfy; rendering will fail "
|
|
+ ("if the template uses them" if plural else "if the template uses it")
|
|
)
|
|
|
|
|
|
def load_example_values(package_dir: Path, relative: str) -> dict[str, Any]:
|
|
path = safe_relative_file(package_dir, relative, "examples")
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as exc:
|
|
raise CannedPromptError(f"invalid YAML in {relative}: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise CannedPromptError(f"{relative}: an example must contain a mapping")
|
|
values = data.get("values") or {}
|
|
if not isinstance(values, dict):
|
|
raise CannedPromptError(f"{relative}: example values must be a mapping")
|
|
return values
|
|
|
|
|
|
def run_render_checks(
|
|
rendered: str, resolution: Resolution, checks: list[dict[str, Any]]
|
|
) -> list[tuple[bool, str]]:
|
|
"""Evaluate § 12.1 render checks. Deterministic: no model is involved."""
|
|
results: list[tuple[bool, str]] = []
|
|
for check in checks:
|
|
(kind,) = check
|
|
expected = check[kind]
|
|
if kind == "contains":
|
|
results.append((str(expected) in rendered, f'contains "{expected}"'))
|
|
elif kind == "not_contains":
|
|
results.append((str(expected) not in rendered, f'not_contains "{expected}"'))
|
|
elif kind == "resolves_all":
|
|
unresolved = sorted(set(resolution.origins) - set(resolution.values))
|
|
ok = (not unresolved) if expected else bool(unresolved)
|
|
detail = f" ({', '.join(unresolved)})" if unresolved else ""
|
|
results.append((ok, f"resolves_all{detail}"))
|
|
return results
|
|
|
|
|
|
def cmd_eval(args: argparse.Namespace) -> None:
|
|
catalog = Path(args.catalog).expanduser()
|
|
package_dir, registry = resolve_installed(catalog, args.id, args.version)
|
|
manifest = validate_package(package_dir)
|
|
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
|
template = template_path.read_text(encoding="utf-8")
|
|
|
|
relatives = manifest.get("evals") or []
|
|
if not relatives:
|
|
print(f"{registry}:{manifest['id']}@{manifest['version']} no evals declared")
|
|
return
|
|
|
|
print(f"{registry}:{manifest['id']}@{manifest['version']}")
|
|
failures = 0
|
|
for relative in relatives:
|
|
spec = read_eval(package_dir, relative)
|
|
if spec["schema"] != EVAL_RUBRIC_SCHEMA:
|
|
print(f" {relative} schema {spec['schema']} not recognized — skipped")
|
|
continue
|
|
|
|
print(f" {relative} ({spec['name']})")
|
|
checks = spec.get("render") or []
|
|
if checks:
|
|
values = (
|
|
load_example_values(package_dir, spec["example"])
|
|
if spec.get("example")
|
|
else {}
|
|
)
|
|
composer = CatalogComposer(catalog)
|
|
resolution = resolve_inputs(manifest, values, composer=composer)
|
|
report_repeated_inclusions(resolution)
|
|
missing = sorted(set(PLACEHOLDER_RE.findall(template)) - set(resolution.values))
|
|
rendered = "" if missing else render_template(template, resolution.values)
|
|
if missing:
|
|
print(f" render FAIL cannot render: unresolved {', '.join(missing)}")
|
|
failures += 1
|
|
for ok, label in run_render_checks(rendered, resolution, checks):
|
|
print(f" render {'PASS' if ok else 'FAIL'} {label}")
|
|
failures += 0 if ok else 1
|
|
|
|
criteria = (spec.get("output") or {}).get("criteria") or []
|
|
if criteria:
|
|
print(
|
|
f" output -- {len(criteria)} criteria declared "
|
|
"(not run: judging output needs a model)"
|
|
)
|
|
|
|
if failures:
|
|
raise CannedPromptError(f"{failures} render check(s) failed")
|
|
|
|
|
|
def cmd_index(args: argparse.Namespace) -> None:
|
|
store = Path(args.catalog).expanduser()
|
|
entries = read_index(store)["entries"]
|
|
if args.id:
|
|
_, package_id = parse_reference(args.id)
|
|
entries = [e for e in entries if e.get("id") == package_id]
|
|
if args.json:
|
|
print(json.dumps(entries, indent=2, ensure_ascii=False))
|
|
return
|
|
if not entries:
|
|
print("no packages recorded in this catalog")
|
|
return
|
|
width = max(len(f"{e.get('registry')}:{e.get('id')}@{e.get('version')}") for e in entries)
|
|
for entry in entries:
|
|
label = f"{entry.get('registry')}:{entry.get('id')}@{entry.get('version')}"
|
|
print(f"{label:<{width}} {entry.get('included_at')} {entry.get('method')}")
|
|
print(f"{'':<{width}} from {entry.get('source')}")
|
|
declared = entry.get("declared_source")
|
|
if declared:
|
|
print(f"{'':<{width}} declares source {declared}")
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(prog="canned-prompts", description=__doc__)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
add = sub.add_parser("add", help="add a package directory to the local catalog")
|
|
add.add_argument("path")
|
|
add.add_argument("--catalog", default=str(default_catalog()))
|
|
add.add_argument(
|
|
"--as",
|
|
dest="as_registry",
|
|
default=LOCAL_REGISTRY,
|
|
metavar="REGISTRY",
|
|
help=f"catalog registry name to file the package under (default: {LOCAL_REGISTRY})",
|
|
)
|
|
add.set_defaults(func=cmd_add)
|
|
|
|
search = sub.add_parser("search", help="search the local catalog")
|
|
search.add_argument("query")
|
|
search.add_argument("--catalog", default=str(default_catalog()))
|
|
search.add_argument("--json", action="store_true")
|
|
search.set_defaults(func=cmd_search)
|
|
|
|
show = sub.add_parser("show", help="show an installed package manifest")
|
|
show.add_argument("id")
|
|
show.add_argument("--version")
|
|
show.add_argument("--catalog", default=str(default_catalog()))
|
|
show.add_argument("--json", action="store_true")
|
|
show.set_defaults(func=cmd_show)
|
|
|
|
render = sub.add_parser("render", help="render an installed prompt template")
|
|
render.add_argument("id")
|
|
render.add_argument("--version")
|
|
render.add_argument("--catalog", default=str(default_catalog()))
|
|
render.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
|
|
render.set_defaults(func=cmd_render)
|
|
|
|
index = sub.add_parser("index", help="show what entered this catalog, from where and when")
|
|
index.add_argument("id", nargs="?")
|
|
index.add_argument("--catalog", default=str(default_catalog()))
|
|
index.add_argument("--json", action="store_true")
|
|
index.set_defaults(func=cmd_index)
|
|
|
|
evaluate = sub.add_parser("eval", help="run an installed package's render checks")
|
|
evaluate.add_argument("id")
|
|
evaluate.add_argument("--version")
|
|
evaluate.add_argument("--catalog", default=str(default_catalog()))
|
|
evaluate.set_defaults(func=cmd_eval)
|
|
|
|
resolve = sub.add_parser("resolve", help="report how each input and parameter resolves")
|
|
resolve.add_argument("id")
|
|
resolve.add_argument("--version")
|
|
resolve.add_argument("--catalog", default=str(default_catalog()))
|
|
resolve.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
|
|
resolve.add_argument("--json", action="store_true")
|
|
resolve.set_defaults(func=cmd_resolve)
|
|
|
|
install = sub.add_parser("install", help="install a package from a filesystem registry")
|
|
install.add_argument("id")
|
|
install.add_argument("--version")
|
|
install.add_argument("--catalog", default=str(default_catalog()))
|
|
install.add_argument("--registry", default=str(default_registry()))
|
|
install.set_defaults(func=cmd_install)
|
|
|
|
publish = sub.add_parser("publish", help="publish a package to a registry")
|
|
publish.add_argument("path")
|
|
publish.add_argument("--registry", default=str(default_registry()))
|
|
publish.add_argument(
|
|
"--as",
|
|
dest="as_registry",
|
|
default=None,
|
|
metavar="REGISTRY",
|
|
help="registry name to publish into; required for an HTTP registry",
|
|
)
|
|
publish.set_defaults(func=cmd_publish)
|
|
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
args.func(args)
|
|
return 0
|
|
except CannedPromptError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|