Register with Custodian State Hub and seed format open-questions workplan
Register canned-prompts under agents / practice (topic
c1d199b6-55ee-4db6-b49e-257a9f0f15ac, workplan prefix CANP-WP) via
`statehub register`, then replace the generated placeholders with
repo-specific facts.
- SCOPE.md: real boundaries drawn from INTENT.md's deliberate boundary,
current state (spec v0.1 + reference CLI, 3/3 tests pass, example
round-trips), and the developer workflow.
- AGENTS.md: drop the unresolved {CREDENTIAL_ROUTING} template token left
by the generator.
- CANP-WP-0001: bootstrap tasks closed.
- CANP-WP-0002: new workplan carrying the five § 23 open questions promoted
from "experience will decide" to "decide for v0.2" — optional-input
defaults (static or derived), registry namespaces/ownership, prompt
composition, canonical eval schemas, typed context/dependency contracts —
plus two reference-implementation conformance defects found in review
(prerelease versions sort as newest; copy_immutable packages the whole
source directory).
Also lands the previously untracked seed: INTENT.md, the CPF v0.1 spec,
the reference CLI, and examples/pqrst-estimate.
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
This commit is contained in:
parent
985b41dc87
commit
dc615ef530
19 changed files with 2130 additions and 2 deletions
49
reference/README.md
Normal file
49
reference/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# canned-prompts reference CLI
|
||||
|
||||
This is intentionally a **small reference implementation**, not the intended final architecture.
|
||||
|
||||
It demonstrates six verbs:
|
||||
|
||||
```text
|
||||
add PATH
|
||||
search QUERY
|
||||
show ID
|
||||
render ID --set key=value
|
||||
install ID [--version VERSION]
|
||||
publish PATH
|
||||
```
|
||||
|
||||
The implementation uses a local catalog plus a filesystem registry and performs no model calls.
|
||||
|
||||
## Stores
|
||||
|
||||
Default locations:
|
||||
|
||||
```text
|
||||
~/.canned-prompts/catalog
|
||||
~/.canned-prompts/registry
|
||||
```
|
||||
|
||||
Catalog and registry both store packages as:
|
||||
|
||||
```text
|
||||
<store>/<id path>/<version>/...
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
~/.canned-prompts/catalog/practice/pqrst-estimate/0.1.0/
|
||||
```
|
||||
|
||||
## Design choices
|
||||
|
||||
- YAML manifest via PyYAML.
|
||||
- `{{ name }}` template substitution only.
|
||||
- No arbitrary expression/code execution.
|
||||
- Published versions are immutable by default.
|
||||
- `install` copies from registry to catalog.
|
||||
- `add` copies a package directly to catalog.
|
||||
- `search`, `show`, and `render` operate on catalog packages.
|
||||
|
||||
Use this implementation to challenge the format. Replace it once real usage reveals the right architecture.
|
||||
418
reference/canned_prompts.py
Executable file
418
reference/canned_prompts.py
Executable file
|
|
@ -0,0 +1,418 @@
|
|||
#!/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 shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import yaml
|
||||
|
||||
FORMAT = "canned-prompt/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*)(?:[-+].*)?$")
|
||||
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:
|
||||
return home_dir() / "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 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"] != FORMAT:
|
||||
raise CannedPromptError(f"unsupported format: {manifest['format']!r}")
|
||||
|
||||
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)
|
||||
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 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 package_path(store: Path, package_id: str, version: str) -> Path:
|
||||
return id_path(store, package_id) / version
|
||||
|
||||
|
||||
def parse_semver(value: str) -> tuple[int, int, int, str]:
|
||||
match = SEMVER_RE.match(value)
|
||||
if not match:
|
||||
return (-1, -1, -1, value)
|
||||
return (int(match.group(1)), int(match.group(2)), int(match.group(3)), value)
|
||||
|
||||
|
||||
def versions_for(store: Path, package_id: str) -> list[str]:
|
||||
base = id_path(store, package_id)
|
||||
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 resolve_installed(store: Path, package_id: str, version: str | None) -> Path:
|
||||
if version:
|
||||
path = package_path(store, package_id, version)
|
||||
if not path.is_dir():
|
||||
raise CannedPromptError(f"package not found: {package_id}@{version}")
|
||||
return path
|
||||
versions = versions_for(store, package_id)
|
||||
if not versions:
|
||||
raise CannedPromptError(f"package not found: {package_id}")
|
||||
return package_path(store, package_id, versions[0])
|
||||
|
||||
|
||||
def copy_immutable(src: Path, dst: Path, what: str) -> None:
|
||||
if dst.exists():
|
||||
raise CannedPromptError(f"{what} already exists: {dst}")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dst)
|
||||
|
||||
|
||||
def iter_packages(store: Path) -> Iterable[tuple[Path, dict[str, Any]]]:
|
||||
if not store.exists():
|
||||
return
|
||||
for manifest_path in store.rglob("prompt.yaml"):
|
||||
package_dir = manifest_path.parent
|
||||
try:
|
||||
manifest = validate_package(package_dir)
|
||||
except CannedPromptError:
|
||||
continue
|
||||
yield package_dir, manifest
|
||||
|
||||
|
||||
def cmd_add(args: argparse.Namespace) -> None:
|
||||
src = Path(args.path)
|
||||
manifest = validate_package(src)
|
||||
catalog = Path(args.catalog).expanduser()
|
||||
dst = package_path(catalog, manifest["id"], manifest["version"])
|
||||
copy_immutable(src.resolve(), dst, "catalog package")
|
||||
print(f"added {manifest['id']}@{manifest['version']} -> {dst}")
|
||||
|
||||
|
||||
def cmd_publish(args: argparse.Namespace) -> None:
|
||||
src = Path(args.path)
|
||||
manifest = validate_package(src)
|
||||
registry = Path(args.registry).expanduser()
|
||||
dst = package_path(registry, manifest["id"], manifest["version"])
|
||||
copy_immutable(src.resolve(), dst, "published package")
|
||||
print(f"published {manifest['id']}@{manifest['version']} -> {dst}")
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
registry = Path(args.registry).expanduser()
|
||||
catalog = Path(args.catalog).expanduser()
|
||||
src = resolve_installed(registry, args.id, args.version)
|
||||
manifest = validate_package(src)
|
||||
dst = package_path(catalog, manifest["id"], manifest["version"])
|
||||
copy_immutable(src, dst, "catalog package")
|
||||
print(f"installed {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 _, manifest in iter_packages(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(
|
||||
{
|
||||
"id": manifest["id"],
|
||||
"version": manifest["version"],
|
||||
"name": manifest["name"],
|
||||
"summary": manifest["summary"],
|
||||
}
|
||||
)
|
||||
matches.sort(key=lambda m: (m["id"], parse_semver(m["version"])), reverse=False)
|
||||
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['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: str, spec: dict[str, Any]) -> Any:
|
||||
kind = spec.get("type", "string")
|
||||
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
|
||||
|
||||
|
||||
def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict[str, Any]:
|
||||
resolved: dict[str, Any] = {}
|
||||
known: set[str] = set()
|
||||
|
||||
inputs = manifest.get("inputs") or []
|
||||
for item in inputs:
|
||||
name = item["name"]
|
||||
known.add(name)
|
||||
if name in raw_values:
|
||||
resolved[name] = raw_values[name]
|
||||
elif item.get("required", False):
|
||||
raise CannedPromptError(f"missing required input: {name}")
|
||||
|
||||
parameters = manifest.get("parameters") or {}
|
||||
for name, spec in parameters.items():
|
||||
known.add(name)
|
||||
if name in raw_values:
|
||||
resolved[name] = coerce_value(raw_values[name], spec)
|
||||
elif "default" in spec:
|
||||
resolved[name] = spec["default"]
|
||||
|
||||
unknown = sorted(set(raw_values) - known)
|
||||
if unknown:
|
||||
raise CannedPromptError("unknown values: " + ", ".join(unknown))
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
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)
|
||||
values = resolve_values(manifest, raw)
|
||||
rendered = render_template(template_path.read_text(encoding="utf-8"), values)
|
||||
print(rendered, end="" if rendered.endswith("\n") else "\n")
|
||||
|
||||
|
||||
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.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)
|
||||
|
||||
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 filesystem registry")
|
||||
publish.add_argument("path")
|
||||
publish.add_argument("--registry", default=str(default_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())
|
||||
16
reference/pyproject.toml
Normal file
16
reference/pyproject.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "canned-prompts-reference"
|
||||
version = "0.1.0"
|
||||
description = "Tiny filesystem reference CLI for Canned Prompt Format v0.1"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["PyYAML>=6.0,<7"]
|
||||
|
||||
[project.scripts]
|
||||
canned-prompts = "canned_prompts:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["canned_prompts"]
|
||||
2
reference/requirements-dev.txt
Normal file
2
reference/requirements-dev.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-r requirements.txt
|
||||
pytest>=8,<9
|
||||
1
reference/requirements.txt
Normal file
1
reference/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
PyYAML>=6.0,<7
|
||||
53
reference/tests/test_canned_prompts.py
Normal file
53
reference/tests/test_canned_prompts.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import canned_prompts as cp
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def package(tmp_path: Path) -> Path:
|
||||
pkg = tmp_path / "pkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "prompt.yaml").write_text(
|
||||
"""\
|
||||
format: canned-prompt/v0.1
|
||||
id: demo/hello
|
||||
name: Hello
|
||||
version: 1.0.0
|
||||
summary: Say hello.
|
||||
template: prompt.md
|
||||
inputs:
|
||||
- name: person
|
||||
required: true
|
||||
parameters:
|
||||
tone:
|
||||
type: enum
|
||||
values: [warm, formal]
|
||||
default: warm
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(pkg / "prompt.md").write_text(
|
||||
"Say hello to {{ person }} in a {{ tone }} tone.\n", encoding="utf-8"
|
||||
)
|
||||
return pkg
|
||||
|
||||
|
||||
def test_validate_and_render(package: Path) -> None:
|
||||
manifest = cp.validate_package(package)
|
||||
values = cp.resolve_values(manifest, {"person": "Ada"})
|
||||
rendered = cp.render_template((package / "prompt.md").read_text(), values)
|
||||
assert rendered == "Say hello to Ada in a warm tone.\n"
|
||||
|
||||
|
||||
def test_missing_required_input_fails(package: Path) -> None:
|
||||
manifest = cp.validate_package(package)
|
||||
with pytest.raises(cp.CannedPromptError, match="missing required input"):
|
||||
cp.resolve_values(manifest, {})
|
||||
|
||||
|
||||
def test_undeclared_placeholder_fails(package: Path) -> None:
|
||||
(package / "prompt.md").write_text("{{ missing }}\n", encoding="utf-8")
|
||||
with pytest.raises(cp.CannedPromptError, match="undeclared placeholders"):
|
||||
cp.validate_package(package)
|
||||
Loading…
Add table
Add a link
Reference in a new issue