Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
# Pyro

Pyro is an open-source renderer for ***plain specifications. It's packaged as an agentic skill
that renders a `.plain` spec into working and tested code.
Pyro is an open-source renderer for ***plain specifications. It's packaged as an
agentic skill that renders a `.plain` spec into working and tested code.

Invoke it from your agent with:

```
/pyro:render-spec <plain-spec-filename> # Claude Code plugin install
/render-spec <plain-spec-filename> # npx skills install or manual copy
/pyro:render-spec <plain-spec-filename> # if installed as Claude Code plugin
/render-spec <plain-spec-filename> # if installed via npx skills or manual copy
```

The skill resolves the modules the target spec requires or imports, renders them
in dependency order, and writes:
* intermediate implementation code to `plain_modules/`
* conformance tests to `conf_tests/`
* intermediate implementation code to `plain_module/code/`
* conformance tests to `plain_module/tests/`
* the target module's output to `dist/`

## Installation

### Claude Code plugin

```
/plugin marketplace add plainlang/pyro
/plugin install pyro@pyro
Run these two commands in your terminal:

```bash
claude plugin marketplace add plainlang/pyro
claude plugin install pyro@pyro
```

Plugin skills are namespaced by their plugin, so invoke this one as
Expand Down Expand Up @@ -50,16 +52,15 @@ claude plugin marketplace update pyro
claude plugin update pyro
```

Both also live in the in-session `/plugin` menu. A restart is needed either way
before the new version loads.
A restart is needed either way before the new version loads.

### Installation with npx skills

If you have Node available, install the skill with the `npx skills` tool:

```bash
cd project
npx skills add https://github.com/plainlang/pyro --agent claude-code
npx skills add https://github.com/plainlang/pyro --agent codex
```

This copies the skill straight into your agent's skills folder, so it is not
Expand Down
14 changes: 7 additions & 7 deletions skills/render-spec/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ Do every step yourself. Never hand any step to a subagent, Agent/Task tool or wo
* On Claude Code it is `${CLAUDE_SKILL_DIR}`; on other agents derive it from the location this skill was loaded from.
* Never reach a file inside this skill by a relative path.

`<python>` is required Python 3.8 or newer. Resolve it now and reuse it everywhere in this skill where `<python>` is used. Try these in order, keep the first that prints `ok`:
* `py -3 "<skill_folder>/scripts/check_version.py" --check`
* `python3 "<skill_folder>/scripts/check_version.py" --check`
* `python "<skill_folder>/scripts/check_version.py" --check`
`<python>` is required Python 3.8 or newer. Resolve it now and reuse it everywhere in this skill where `<python>` is used. Run all three of these as independent tool calls in one single turn, in parallel - do not run them one after another:
* `python3 "<skill_folder>/scripts/check_version.py" check`
* `py -3 "<skill_folder>/scripts/check_version.py" check`
* `python "<skill_folder>/scripts/check_version.py" check`

Report the Python tool you settled on. If none prints `ok`, Python is a missing dependency: abort the rendering and report it.
Among the commands whose output contains a `current:` line, keep the first one in the order listed above: its interpreter is `<python>`. Report the Python tool you settled on. If no command prints a `current:` line, Python is a missing dependency: abort the rendering and report it.

Run `<python> "<skill_folder>/scripts/check_version.py" check` and report with a message "Running pyro: <current>", where <current> and the other values are read from the command's output.
From that same command's output report with a message "Running pyro: <current>".
* If the output shows `status: update-available`, warn with a message: "A newer pyro release <latest> is available (this is <current>)".
* On any other status, or if the command fails, say nothing about updates and continue.
* On any other status, say nothing about updates and continue.

# Step 1: Introduction, inputs and outputs

Expand Down
14 changes: 2 additions & 12 deletions skills/render-spec/scripts/check_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# Usage: check_version.py current [--skill-md <path>]
# check_version.py latest [--url <url>]
# check_version.py check [--skill-md <path>] [--url <url>]
# check_version.py --check
#
# current prints the version this copy of the skill ships with, read from
# the `metadata: version:` field of the SKILL.md next to this script.
Expand All @@ -32,10 +31,6 @@
# current and latest versions are read from; the tests use them with fixture
# files and file:// URLs.
#
# --check takes no other arguments: it verifies the interpreter is a usable
# Python 3, prints "ok" and exits 0, so a caller can test-drive this script
# before relying on it.
#
# Versions are `X.Y.Z` with an optional `-rc.N` prerelease, and a release
# outranks its own release candidates: 0.1.0 < 0.2.0-rc.1 < 0.2.0.
#
Expand All @@ -62,7 +57,7 @@
PROG = os.path.basename(sys.argv[0])

LATEST_URL = "https://raw.githubusercontent.com/plainlang/pyro/main/pyproject.toml"
FETCH_TIMEOUT = 10 # seconds
FETCH_TIMEOUT = 3 # seconds
SKILL_MD = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "SKILL.md")
)
Expand All @@ -72,7 +67,6 @@
USAGE = """usage: %(prog)s current [--skill-md <path>]
%(prog)s latest [--url <url>]
%(prog)s check [--skill-md <path>] [--url <url>]
%(prog)s --check

current print this skill's own version (from SKILL.md metadata)
latest print the latest published stable version (pyproject.toml on main)
Expand Down Expand Up @@ -175,7 +169,7 @@ def parse_args(argv):
usage_error("%s needs a value" % name)
value = argv[index]
options[name] = value
elif name in ("current", "latest", "check", "--check") and not equals:
elif name in ("current", "latest", "check") and not equals:
if command is not None:
usage_error("only one command allowed, got %r after %r" % (name, command))
command = name
Expand All @@ -195,10 +189,6 @@ def usage_error(message):
def main(argv):
command, skill_md, url = parse_args(argv)

if command == "--check":
emit("ok\n")
return 0

if command == "latest":
latest = fetch_latest(url)
if latest is None:
Expand Down
19 changes: 2 additions & 17 deletions skills/render-spec/scripts/plain_sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# Extract one section from .plain specs and print the bodies to stdout.
#
# Usage: plain_sections.py [--include-filename] [--output <path>] <section> <spec.plain> [spec.plain ...]
# plain_sections.py --check
#
# Sections (case-insensitive, `-` and `_` interchangeable):
# definitions defs :plainDefinitions:
Expand All @@ -21,10 +20,6 @@
# `>>`, which corrupts files that later steps read back, so redirecting here rather than in
# the calling shell keeps the output UTF-8/LF on every platform. Warnings stay on stderr.
#
# --check takes no other arguments: it verifies the interpreter is a usable Python 3, prints
# "ok" and exits 0, so a caller can test-drive this script before relying on it. It returns
# before the --output redirect, so --output is ignored with --check.
#
# Portability: the standard library only, Python 3.8+, no shell and no awk - so the same
# command line works from sh, PowerShell and cmd alike. Everything is written as bytes to
# keep the output UTF-8 with LF endings even on a Windows console, whose text streams would
Expand All @@ -50,7 +45,6 @@
PROG = os.path.basename(sys.argv[0])

USAGE = """usage: %(prog)s [--include-filename] [--output <path>] <section> <spec.plain> [spec.plain ...]
%(prog)s --check

sections (case-insensitive, - and _ interchangeable):
definitions defs :plainDefinitions:
Expand Down Expand Up @@ -265,16 +259,13 @@ def parse_args(argv):
"""Options are recognized wherever they appear, including after the positionals."""
show_path = False
out = None
check_only = False
positional = []

args = list(argv)
while args:
arg = args.pop(0)
if arg == "--include-filename":
show_path = True
elif arg == "--check":
check_only = True
elif arg == "--output":
if not args:
error("--output requires a path")
Expand All @@ -290,17 +281,11 @@ def parse_args(argv):
else:
positional.append(arg)

return show_path, out, check_only, positional
return show_path, out, positional


def main(argv):
show_path, out, check_only, positional = parse_args(argv)

# --check proves the interpreter is a usable Python 3; anything older already exited at
# the version guard above, so reaching here is the answer.
if check_only:
write_text(byte_sink(sys.stdout), "ok\n")
return 0
show_path, out, positional = parse_args(argv)

if len(positional) < 2:
usage()
Expand Down
17 changes: 7 additions & 10 deletions tests/run_check_version_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,7 @@ def run_suite(tmp):
url_garbage = write_pyproject(tmp, "py-garbage.toml", "not.a.version")
url_missing = pathlib.Path(os.path.join(tmp, "no-such-file.toml")).as_uri()

# 1. Interpreter probe.
assert_stdout("--check prints ok", b"ok\n", "--check")

# 2. current: fixture and the real SKILL.md this repo ships.
# 1. current: fixture and the real SKILL.md this repo ships.
assert_stdout("current from fixture", b"0.1.0\n", "current", "--skill-md", skill_010)
assert_stdout("current, = spelling", b"0.1.0\n", "current", "--skill-md=" + skill_010)
rc, out, err = run("current")
Expand All @@ -97,13 +94,13 @@ def run_suite(tmp):
"exit %d, stdout %r, stderr %r" % (rc, out, err),
)

# 3. latest: scoped to [project], loud when unavailable.
# 2. latest: scoped to [project], loud when unavailable.
assert_stdout("latest from fixture", b"0.2.0\n", "latest", "--url", url_020)
assert_rc("latest with unreachable url exits 1", 1, "latest", "--url", url_missing)
assert_rc("latest without [project] exits 1", 1, "latest", "--url", url_no_project)
assert_rc("latest with unparseable version exits 1", 1, "latest", "--url", url_garbage)

# 4. check: every status.
# 3. check: every status.
assert_stdout(
"check update-available",
check_wants("0.1.0", "0.2.0", "update-available"),
Expand All @@ -125,7 +122,7 @@ def run_suite(tmp):
"check", "--skill-md", skill_rc, "--url", url_020,
)

# 5. check degrades softly: still exit 0, status unknown.
# 4. check degrades softly: still exit 0, status unknown.
assert_stdout(
"check with unreachable url degrades to unknown",
check_wants("0.1.0", "unknown", "unknown"),
Expand All @@ -142,7 +139,7 @@ def run_suite(tmp):
"check", "--skill-md", skill_010, "--url", url_garbage,
)

# 6. A broken own version is a hard error.
# 5. A broken own version is a hard error.
assert_rc(
"missing SKILL.md exits 1",
1,
Expand All @@ -155,12 +152,12 @@ def run_suite(tmp):
"check", "--skill-md", no_version, "--url", url_010,
)

# 7. Usage errors.
# 6. Usage errors.
assert_rc("no command exits 2", 2)
assert_rc("unknown command exits 2", 2, "frobnicate")
assert_rc("two commands exit 2", 2, "check", "latest")
assert_rc("--skill-md without value exits 2", 2, "current", "--skill-md")
assert_rc("--check with a command exits 2", 2, "check", "--check")
assert_rc("unknown flag exits 2", 2, "check", "--frobnicate")


if __name__ == "__main__":
Expand Down
21 changes: 4 additions & 17 deletions tests/run_plain_sections_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,7 @@ def run_suite(tmp):
want_two = expect("defs") + b"\n" + b"- :Gadget: is another thing.\n"
assert_stdout("two specs, one blank line between", want_two, "defs", SAMPLE, SECOND)

# 7. --check probes the interpreter and says so.
assert_rc("--check exits 0", 0, "--check")
if run("--check")[1] == b"ok\n":
ok("--check prints ok")
else:
bad("--check prints ok")
unused = os.path.join(tmp, "unused")
assert_rc("--check ignores a trailing --output", 0, "--check", "--output", unused)
if os.path.exists(unused):
bad("--check leaves --output path untouched")
else:
ok("--check leaves --output path untouched")

# 8. Unreadable inputs exit 1.
# 7. Unreadable inputs exit 1.
assert_rc("missing spec exits 1", 1, "defs", os.path.join(FIXTURES, "does-not-exist.plain"))
assert_rc("directory argument exits 1", 1, "defs", FIXTURES)
# ... but a readable spec later in the list is still printed.
Expand All @@ -129,22 +116,22 @@ def run_suite(tmp):
else:
bad("unreadable spec does not suppress the readable one")

# 9. Usage errors exit 2.
# 8. Usage errors exit 2.
assert_rc("unknown section exits 2", 2, "nonsense", SAMPLE)
assert_rc("no arguments exits 2", 2)
assert_rc("section without a spec exits 2", 2, "defs")
assert_rc("--output without a path exits 2", 2, "defs", SAMPLE, "--output")
assert_rc("--help exits 2", 2, "--help")

# 10. A missing section is a warning, not an error.
# 9. A missing section is a warning, not an error.
assert_rc("missing section exits 0", 0, "defs", EMPTY)
rc, out, err = run("defs", EMPTY)
if out == b"" and err != b"":
ok("missing section warns on stderr, stdout empty")
else:
bad("missing section warns on stderr, stdout empty")

# 11. Flags are accepted after the positional arguments too.
# 10. Flags are accepted after the positional arguments too.
assert_stdout("flag after positionals", want_filename, "defs", SAMPLE, "--include-filename")


Expand Down