pyrtan / sourcev0.14.0 · main

Serve pyrtan.com from this repository's GitHub Pages

Claude · 2026-10-25 · 9b5a8f9cd5abcb167db9c0f5655b223f41506de5 · parents 5367bc2

One Pages deployment from this repository replaces the planned site and catalog repositories. The `pages` workflow runs the tests, then site/build.sh assembles a static directory: the landing page (site/index.html), the console (`pyrtan console --out`), the catalog (`pyrtan index examples` and every tree file at the path the index names, with a small viewer at /catalog/), the seed list (site/rendezvous/seeds.json) and the CNAME, and publishes it. The catalog is examples/: a tree merged here with a licence and a test is pullable from https://pyrtan.com/catalog/ on the next push to main.

Links on the landing page are relative, so the site works at b-i-t-8.github.io/pyrtan/ before the domain points at it and at pyrtan.com after. deploy/pyrtan.com (the two-repository workflows and guide) is gone; site/README.md says how to turn Pages on and what DNS to set. Verified by serving the built directory under a subpath with a seed hub beside it: pull and search against the served catalog, the live directory on the landing page, the catalog viewer, the console prefilled from ?hub=.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YChr78vDHtQJ15HNbsaRLZ

Changes

 .github/workflows/image.yml                                     |   2 +-
 .github/workflows/pages.yml                                     |  44 ++++++
 CONTRIBUTING.md                                                 |   9 +-
 README.md                                                       |   6 +-
 _meta/platform.md                                               |   4 +-
 deploy/pyrtan.com/README.md                                     | 200 ----------------------------
 deploy/pyrtan.com/catalog.yml                                   |  65 ---------
 deploy/pyrtan.com/site.yml                                      |  68 ----------
 deploy/pyrtan.com/trees-README.md                               |  46 -------
 site/CNAME                                                      |   1 +
 site/README.md                                                  |  67 ++++++++++
 site/build.sh                                                   |  20 +++
 site/catalog/index.html                                         |  51 +++++++
 {deploy/pyrtan.com => site}/index.html                          |  10 +-
 {deploy/pyrtan.com => site}/rendezvous/seeds.json               |   0
 .../scratchpad/out/catalog/github/github-release.toml           |  63 +++++++++
 .../scratchpad/out/catalog/hacker-news/hacker-news-item.toml    |  66 +++++++++
 .../scratchpad/out/catalog/hacker-news/hacker-news.toml         |  79 +++++++++++
 .../scratchpad/out/catalog/site-status/site-status.toml         |  62 +++++++++
 .../scratchpad/out/catalog/weather/weather.toml                 | 133 ++++++++++++++++++
 20 files changed, 603 insertions(+), 393 deletions(-)
diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml
index 6a5ff67..7ead3a1 100644
--- a/.github/workflows/image.yml
+++ b/.github/workflows/image.yml
@@ -1,5 +1,5 @@
# Build the image and publish it to GitHub's registry on every tag, so a
-# deployment (deploy/pyrtan.com/rendezvous, the compose stack, hosting)
+# deployment (the compose stack, hosting)
# can pull ghcr.io/b-i-t-8/pyrtan:<version> instead of building from a # checkout. Tests run first; a tag whose tests fail publishes nothing. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..0b228bd --- /dev/null +++ b/.github/workflows/pages.yml
@@ -0,0 +1,44 @@
+# pyrtan.com: this repository's GitHub Pages site. On every push to main,
+# build the landing page, the console (from this checkout's pyrtan), the
+# catalog (from examples/) and the seed list, and publish them. Set the
+# repository's Pages source to "GitHub Actions" and its custom domain to
+# pyrtan.com; site/CNAME carries the domain.
+
+name: pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v5
+ - run: uv sync
+ - run: uv run pytest -q
+ - run: uv run site/build.sh out
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: out
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39ca8d4..e41fba5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md
@@ -25,10 +25,11 @@ A tree that reads a page should prefer an API or a feed when the site offers
one, and should decide on the status code before it decides on the body. A tree meant for the catalog at pyrtan.com carries a `license` line (an
-SPDX identifier; `MIT`, `Apache-2.0` or `CC0-1.0` are the usual choices)
-and is contributed to the catalog repository, not this one; see
-[`deploy/pyrtan.com/README.md`](deploy/pyrtan.com/README.md). The examples
-here are MIT so they can be pulled from there too.
+SPDX identifier; `MIT`, `Apache-2.0` or `CC0-1.0` are the usual choices).
+The catalog *is* `examples/`: the `pages` workflow indexes it and
+publishes it at <https://pyrtan.com/catalog/> on every push to `main`,
+so a tree merged here with a licence and a test is pullable the same
+day. See [`site/README.md`](site/README.md).
## Changing the runtime diff --git a/README.md b/README.md index afe4cab..1526317 100644 --- a/README.md +++ b/README.md
@@ -331,8 +331,10 @@ examples/ trees that run against real public APIs, and offline against
tests/ pytest; a swarm of two services behind a hub runs on localhost _meta/ the design record, the tree format reference, and the design for pyrtan.com
-deploy/ Dockerfile at the root; a compose stack for OpenMediaVault,
- a systemd unit, and what to put in the pyrtan.com repositories
+deploy/ Dockerfile at the root; a compose stack for OpenMediaVault
+ and a systemd unit
+site/ pyrtan.com: the landing page, the seed list, the catalog
+ viewer, and the build the pages workflow publishes
``` ## Status diff --git a/_meta/platform.md b/_meta/platform.md index 2435761..9f58ac2 100644 --- a/_meta/platform.md +++ b/_meta/platform.md
@@ -2,10 +2,10 @@
| | | |---|---|
-| **Status** | **Phase 1 built, v0.2.** Tokens, CORS, persistent gaps, the egress policy, the catalog client and the console are in this repository and tested. The site and the catalog repository are described in [`../deploy/pyrtan.com/README.md`](../deploy/pyrtan.com/README.md). Hosting is designed, not built. |
+| **Status** | **Phase 1 built, v0.2.** Tokens, CORS, persistent gaps, the egress policy, the catalog client and the console are in this repository and tested. The site is this repository's GitHub Pages deployment, described in [`../site/README.md`](../site/README.md). Hosting is designed, not built. |
| **Date** | 2026-10-25 | | **Decision** | pyrtan.com is three separate things on one invariant: a static catalog of trees anyone can pull, a static console that talks only to the public hub API, and (later) paid hosting that runs the unmodified open-source hub per tenant. A caller cannot tell a hosted hub from a self-hosted one, so every piece works for both. |
-| **Where** | This repository for the runtime, client and console. `pyrtan-trees` for the catalog. `pyrtan-site` for the pages. A private repository for the control plane, when there is one. |
+| **Where** | This repository for everything open: the runtime, the client, the console, the catalog (`examples/`) and the site (`site/`, published by the `pages` workflow). A private repository for the control plane, when there is one. |
## 0. The one invariant diff --git a/deploy/pyrtan.com/README.md b/deploy/pyrtan.com/README.md deleted file mode 100644 index 487344c..0000000 --- a/deploy/pyrtan.com/README.md +++ /dev/null
@@ -1,200 +0,0 @@
-# Putting up pyrtan.com
-
-pyrtan.com is static files: a landing page, the console, and the catalog.
-Nothing runs server-side until there is paid hosting, so the whole site is
-two GitHub repositories deployed to GitHub Pages (or Cloudflare Pages; the
-files are the same). The design is in
-[`_meta/platform.md`](../../_meta/platform.md).
-
-What to create, in order:
-
-| | Repository | Serves | From |
-| :-- | :-- | :-- | :-- |
-| 1 | `b-i-t-8/pyrtan-trees` | `pyrtan.com/catalog/` | its own `trees/` folder, checked and indexed by CI |
-| 2 | `b-i-t-8/pyrtan-site` | `pyrtan.com/` and `pyrtan.com/console/` | `index.html` here, and `pyrtan console --out` |
-| 3 | nothing | the rendezvous | `rendezvous/seeds.json` here, served by the site; your NAS is seed #1 |
-
-Both can be one repository if you prefer; two keeps "I wrote a tree" pull
-requests apart from "I changed the site" ones, and lets the catalog's
-contributors never touch the site.
-
-Files in this folder, ready to copy:
-
-- [`catalog.yml`](catalog.yml): the workflow for the trees repository.
-- [`site.yml`](site.yml): the workflow for the site repository.
-- [`index.html`](index.html): the landing page.
-- [`trees-README.md`](trees-README.md): the README for the trees repository.
-
-## 1. The catalog repository (`pyrtan-trees`)
-
-```
-pyrtan-trees/
- README.md from trees-README.md here
- LICENSE what the *repository's* own files are under; each tree declares its own
- .github/workflows/catalog.yml
- trees/
- weather/
- weather.toml license = "MIT" in the file
- fixture.json recorded responses, from pyrtan run --record
- hacker-news/
- hacker-news.toml
- hacker-news-item.toml
- fixture.json
- tests/
- test_trees.py replays each tree against its fixture
-```
-
-Steps:
-
-1. Create the repository, empty.
-2. Copy `trees-README.md` to `README.md` and `catalog.yml` to
- `.github/workflows/catalog.yml`.
-3. Seed `trees/` from this repository's `examples/` (each already carries
- `license = "MIT"`), and `tests/fixtures/examples.json` as the fixtures,
- split per folder or kept as one file; the workflow only needs `pyrtan
- check` and `pytest` to pass.
-4. In the repository's *Settings › Pages*, set *Source* to **GitHub
- Actions**.
-5. Push. The workflow runs `pyrtan check trees`, the tests, then
- `pyrtan index trees --out site/index.json`, copies the tree files next
- to it, and publishes `site/` to Pages. The result is at
- `https://b-i-t-8.github.io/pyrtan-trees/`.
-
-What the workflow enforces on every pull request: every tree loads and
-checks clean, every tree has a `license`, no two trees share a name, and
-the tests pass. `pyrtan index` exits 1 on any of those, which fails the
-check. A reviewer reads the tree; nothing else is needed to accept one.
-
-Bump `version` in the tree when a change should be noticed by people who
-pulled it; `pyrtan pull NAME@VERSION` pins one.
-
-## 2. The site repository (`pyrtan-site`)
-
-```
-pyrtan-site/
- index.html from index.html here
- CNAME "pyrtan.com"
- .github/workflows/site.yml
-```
-
-Steps:
-
-1. Create the repository; copy `index.html` and `site.yml` (to
- `.github/workflows/site.yml`); add a `CNAME` file containing
- `pyrtan.com`.
-2. *Settings › Pages*: *Source* **GitHub Actions**; *Custom domain*
- `pyrtan.com`; tick *Enforce HTTPS* once DNS resolves.
-3. Push. The workflow installs `pyrtan` from this repository (pinned to a
- tag; update the tag to ship a new console), writes
- `console/index.html` with `pyrtan console --out`, and publishes.
-
-### DNS
-
-At your DNS provider, for `pyrtan.com`:
-
-| Record | Name | Value |
-| :-- | :-- | :-- |
-| `A` | `@` | `185.199.108.153`, `185.199.109.153`, `185.199.110.153`, `185.199.111.153` |
-| `AAAA` | `@` | `2606:50c0:8000::153`, `2606:50c0:8001::153`, `2606:50c0:8002::153`, `2606:50c0:8003::153` |
-| `CNAME` | `www` | `b-i-t-8.github.io` |
-
-(GitHub's current addresses; check *Pages › Custom domain* in the
-repository settings if they have changed.)
-
-### `/catalog/` on the same domain
-
-The catalog is published from the other repository, at
-`b-i-t-8.github.io/pyrtan-trees/`. The command's default is
-`https://pyrtan.com/catalog/`, so the catalog must answer there. Two ways:
-
-- **Proxy it (Cloudflare).** If pyrtan.com's DNS is on Cloudflare, add a
- *Worker route* or an *Origin rule* that serves
- `pyrtan.com/catalog/*` from `b-i-t-8.github.io/pyrtan-trees/*`. A
- ten-line Worker does it:
-
- ```js
- export default {
- async fetch(req) {
- const u = new URL(req.url);
- if (!u.pathname.startsWith("/catalog/")) return fetch(req);
- const up = "https://b-i-t-8.github.io/pyrtan-trees/" + u.pathname.slice("/catalog/".length);
- const r = await fetch(up, { headers: { "User-Agent": "pyrtan.com" } });
- return new Response(r.body, { status: r.status, headers: { "Content-Type": r.headers.get("Content-Type") || "application/octet-stream", "Cache-Control": "public, max-age=300", "Access-Control-Allow-Origin": "*" } });
- },
- };
- ```
-
-- **Or publish the catalog into the site.** Have `site.yml` check out
- `pyrtan-trees` too, run `pyrtan index` there, and copy the output to
- `catalog/` in the site's artifact; then one Pages deployment holds
- everything, at the cost of the site rebuilding when a tree merges. The
- commented block in `site.yml` shows this.
-
-Either way, `curl https://pyrtan.com/catalog/index.json` should return the
-index, and `pyrtan search weather` from anywhere should find it.
-
-### `/console/`
-
-Works as soon as the site is up. A visitor enters a hub URL and a token;
-the hub must have been started with `--cors https://pyrtan.com`. A hub on
-a LAN over plain `http://` cannot be reached from an `https://` page (the
-browser blocks it); the console says so and points at the hub's own `/`,
-which is the same console served locally.
-
-## 3. The rendezvous: seeds, and your NAS
-
-Nothing runs for this. Hubs keep and gossip the directory among
-themselves (`_meta/federation.md` §7); the site only publishes a list
-of hubs to follow first.
-
-1. **Seeds.** Copy `rendezvous/seeds.json` into the site repository at
- `rendezvous/seeds.json` and put your hub's public URL in `hubs`.
- `site.yml` serves it at `https://pyrtan.com/rendezvous/seeds.json`,
- which is what `pyrtan follow` reads when given no URL. Others add
- theirs by pull request.
-2. **Your NAS as seed #1.** Its hub needs a public address. Two ways
- that open no port and cost nothing:
-
- - *Cloudflare Tunnel:* `cloudflared tunnel --url http://127.0.0.1:8700`
- gives a `https://….trycloudflare.com` address at once; a named
- tunnel on a domain you own (`hub.example.net`) is a few more
- commands in Cloudflare's dashboard and survives restarts.
- - *Tailscale Funnel:* `tailscale funnel 8700` publishes the hub at
- `https://<machine>.<tailnet>.ts.net`.
-
- Then, in the compose file's environment: `PYRTAN_TOKEN` set,
- `PYRTAN_ADVERTISE` the public URL, `PYRTAN_FOLLOW: seed` (or another
- hub), `PYRTAN_LISTED: "true"`. The hub keeps its follows and the
- directory in the `hub-state` volume. `pyrtan directory --at
- https://<your hub>` shows what it knows.
-3. **The page.** `index.html` fetches `rendezvous/seeds.json` and asks
- each seed's `/directory` from the visitor's browser (seeds run with
- `--cors https://pyrtan.com`, which the compose file sets), so
- pyrtan.com shows the live directory with no backend. The console link
- on each row opens `/console/?hub=<url>`.
-
-## 4. Later: hosting
-
-Nothing here needs to change. A tenant's hub is this repository's image
-started with `--token`, `--cors https://pyrtan.com`, `--gaps` on a volume,
-`--egress public` and `--federate --advertise https://<tenant>.pyrtan.com
---listed --state <volume>/federation.json`, behind TLS; the console at
-pyrtan.com connects to it like to any other, a self-hosted hub can follow
-it (or be followed by it) like any other hub, and it is one more seed. The control plane that provisions it is a separate,
-private repository, per `_meta/platform.md` §2.6.
-
-## Checklist
-
-- [ ] `pyrtan-trees` created; Pages source = Actions; first run green;
- `…github.io/pyrtan-trees/index.json` answers.
-- [ ] `pyrtan-site` created with `CNAME`; Pages custom domain set; DNS
- records in; HTTPS enforced.
-- [ ] `https://pyrtan.com/` shows the landing page; `/console/` loads.
-- [ ] `/catalog/index.json` answers on pyrtan.com (proxy or merged build).
-- [ ] From a laptop: `pyrtan search weather` finds it; `pyrtan pull weather`
- writes `trees/weather.toml`.
-- [ ] A hub started with `--token … --cors https://pyrtan.com` connects
- from `https://pyrtan.com/console/`.
-- [ ] `rendezvous/seeds.json` in the site repo names your hub; `pyrtan
- follow --at <another hub>` with no URL follows it; `pyrtan
- directory` lists it; the page shows it.
diff --git a/deploy/pyrtan.com/catalog.yml b/deploy/pyrtan.com/catalog.yml deleted file mode 100644 index 4ca7228..0000000 --- a/deploy/pyrtan.com/catalog.yml +++ /dev/null
@@ -1,65 +0,0 @@
-# The catalog repository's workflow (pyrtan-trees/.github/workflows/catalog.yml).
-#
-# On every push and pull request: every tree must load, check clean, carry
-# a license and have a unique name, and the tests must pass. On a push to
-# main: build index.json, put the tree files next to it, publish to Pages.
-#
-# Pin PYRTAN_REF to a tag of b-i-t-8/pyrtan; bump it to adopt a newer
-# checker or index format.
-
-name: catalog
-
-on:
- push:
- branches: [main]
- pull_request:
-
-env:
- PYRTAN_REF: v0.4.0
-
-permissions:
- contents: read
-
-jobs:
- check:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: astral-sh/setup-uv@v5
- - name: Install pyrtan
- run: uv tool install --python 3.12 "git+https://github.com/b-i-t-8/pyrtan@${PYRTAN_REF}"
- - name: Check every tree
- run: pyrtan check trees
- - name: Build the index (fails on a tree with no license or a duplicate name)
- run: |
- mkdir -p site
- pyrtan index trees --out site/index.json
- - name: Replay the tests
- if: hashFiles('tests/**') != ''
- run: |
- uv venv --python 3.12 .venv
- uv pip install --python .venv/bin/python pytest "git+https://github.com/b-i-t-8/pyrtan@${PYRTAN_REF}"
- .venv/bin/python -m pytest -q
- - name: Lay out the catalog
- run: |
- # index.json plus every tree file at the path the index names.
- (cd trees && find . -name '*.toml' -not -name 'pyproject.toml' | cpio -pdm ../site)
- cp -n README.md site/ 2>/dev/null || true
- ls -R site | head -50
- - uses: actions/upload-pages-artifact@v3
- with:
- path: site
-
- publish:
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- needs: check
- runs-on: ubuntu-latest
- permissions:
- pages: write
- id-token: write
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - id: deployment
- uses: actions/deploy-pages@v4
diff --git a/deploy/pyrtan.com/site.yml b/deploy/pyrtan.com/site.yml deleted file mode 100644 index 2b034bb..0000000 --- a/deploy/pyrtan.com/site.yml +++ /dev/null
@@ -1,68 +0,0 @@
-# The site repository's workflow (pyrtan-site/.github/workflows/site.yml).
-#
-# Publishes index.html, CNAME and the console (written by `pyrtan console
-# --out` from a pinned release of b-i-t-8/pyrtan) to GitHub Pages at
-# pyrtan.com. Bump PYRTAN_REF to ship a newer console.
-
-name: site
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-env:
- PYRTAN_REF: v0.4.0
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: astral-sh/setup-uv@v5
- - name: Install pyrtan
- run: uv tool install --python 3.12 "git+https://github.com/b-i-t-8/pyrtan@${PYRTAN_REF}"
- - name: Assemble the site
- run: |
- mkdir -p site/console
- cp index.html CNAME site/
- pyrtan console --out site/console/index.html
- mkdir -p site/rendezvous
- cp rendezvous/seeds.json site/rendezvous/seeds.json
- touch site/.nojekyll
-
- # To serve the catalog from this same deployment instead of proxying
- # b-i-t-8.github.io/pyrtan-trees, uncomment this block (and give the
- # job `contents: read` on that repository if it is private).
- # - uses: actions/checkout@v4
- # with:
- # repository: b-i-t-8/pyrtan-trees
- # path: trees-repo
- # - name: Build the catalog into /catalog
- # run: |
- # mkdir -p site/catalog
- # pyrtan index trees-repo/trees --out site/catalog/index.json
- # (cd trees-repo/trees && find . -name '*.toml' | cpio -pdm ../../site/catalog)
-
- - uses: actions/upload-pages-artifact@v3
- with:
- path: site
-
- deploy:
- needs: build
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - id: deployment
- uses: actions/deploy-pages@v4
diff --git a/deploy/pyrtan.com/trees-README.md b/deploy/pyrtan.com/trees-README.md deleted file mode 100644 index 29ef6a4..0000000 --- a/deploy/pyrtan.com/trees-README.md +++ /dev/null
@@ -1,46 +0,0 @@
-# Pyrtan trees
-
-The catalog behind `pyrtan search` and `pyrtan pull`: trees people wrote
-for [Pyrtan](https://github.com/b-i-t-8/pyrtan), checked by CI and
-published as static files at <https://pyrtan.com/catalog/>.
-
-A tree is one TOML file that says which sentences it answers to, which
-hosts it may touch, what it observes, and exactly how it decides. No model
-is involved; see the Pyrtan README for the format.
-
-## Using a tree
-
-```
-pyrtan search weather
-pyrtan pull weather --dir trees
-pyrtan serve trees
-```
-
-A pull verifies the file against this catalog's index before writing it.
-
-## Adding a tree
-
-1. Fork, then `pyrtan new my-tree --dir trees/my-tree` and write it. Keep
- `hosts` to exactly what it needs, and set `license` (an SPDX
- identifier; `MIT`, `Apache-2.0` or `CC0-1.0`). CI refuses a tree
- without one.
-2. `pyrtan check trees/my-tree` until it is clean.
-3. Record its responses for the tests:
- `pyrtan run "the sentence" --trees trees/my-tree --record trees/my-tree/fixture.json`.
- Trim the recording to what the test needs; never commit a credential.
-4. Add a test under `tests/` that replays the sentence against the fixture
- and asserts the `say`, the `output` and the branches taken.
-5. Open a pull request. CI runs the checker, the index build and the
- tests; a maintainer reads the tree.
-
-To change a tree others may have pulled, bump its `version`.
-
-## Mirroring
-
-The catalog is `index.json` and the files it names. Copy the published
-site anywhere and point `pyrtan --catalog` (or `$PYRTAN_CATALOG`) at it.
-
-## Licences
-
-Each tree carries its own licence in the file. The repository's own files
-(this README, the workflow, the tests) are under the licence in `LICENSE`.
diff --git a/site/CNAME b/site/CNAME new file mode 100644 index 0000000..afefb1f --- /dev/null +++ b/site/CNAME
@@ -0,0 +1 @@
+pyrtan.com
diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..53139fe --- /dev/null +++ b/site/README.md
@@ -0,0 +1,67 @@
+# pyrtan.com
+
+pyrtan.com is this repository's GitHub Pages site. Nothing runs: on every
+push to `main`, the `pages` workflow builds a static directory and
+publishes it.
+
+| Path | What | Built from |
+| :-- | :-- | :-- |
+| `/` | the landing page, with the live directory read from the seeds in the visitor's browser | `site/index.html` |
+| `/console/` | the console; point it at any hub started with `--cors https://pyrtan.com` | `pyrtan console --out` |
+| `/catalog/` | the trees people can `pyrtan pull`, and `index.json` the command reads | `pyrtan index examples`, and the tree files |
+| `/rendezvous/seeds.json` | hubs to follow first | `site/rendezvous/seeds.json` |
+
+`site/build.sh out` builds the same thing locally; open `out/index.html`.
+
+## Turning it on
+
+1. In the repository's *Settings › Pages*, set *Source* to **GitHub
+ Actions**. Push to `main` (or run the `pages` workflow by hand); the
+ site is up at `https://b-i-t-8.github.io/pyrtan/`.
+2. *Settings › Pages › Custom domain*: `pyrtan.com` (`site/CNAME` carries
+ it so a deploy never drops it). Tick *Enforce HTTPS* once DNS resolves.
+3. At your DNS provider, for `pyrtan.com`:
+
+ | Record | Name | Value |
+ | :-- | :-- | :-- |
+ | `A` | `@` | `185.199.108.153`, `185.199.109.153`, `185.199.110.153`, `185.199.111.153` |
+ | `AAAA` | `@` | `2606:50c0:8000::153`, `2606:50c0:8001::153`, `2606:50c0:8002::153`, `2606:50c0:8003::153` |
+ | `CNAME` | `www` | `b-i-t-8.github.io` |
+
+ (GitHub's current addresses; *Pages › Custom domain* in the settings
+ says if they change.)
+
+Then `curl https://pyrtan.com/catalog/index.json` returns the index,
+`pyrtan search weather` from anywhere finds it, and `pyrtan pull weather`
+writes `trees/weather.toml`.
+
+## The catalog
+
+The catalog is `examples/`. A tree goes in by pull request to this
+repository, under `examples/<name>/`, with a `license` line and a test
+in `tests/test_examples.py` against a recording (see
+[`CONTRIBUTING.md`](../CONTRIBUTING.md)). `pyrtan index` refuses a tree
+with no licence or a duplicate name, and the workflow runs the tests
+before it publishes, so what is at `/catalog/` always passes.
+
+## The seeds
+
+`site/rendezvous/seeds.json` lists hubs to follow first. A seed is any
+federated hub its operator keeps up, started with `--listed` and `--cors
+https://pyrtan.com`, reachable from outside (a tunnel or a port forward
+behind TLS; [`deploy/README.md`](../deploy/README.md) says how). Put
+yours in by pull request. The landing page asks each seed's `/directory`
+from the visitor's browser, so the directory on pyrtan.com is live and
+needs no backend; the console link on each row opens
+`/console/?hub=<url>`.
+
+## Later: hosting
+
+Nothing here changes. A hosted hub is this repository's image started
+with `--token`, `--cors https://pyrtan.com`, `--gaps` on a volume,
+`--egress public` and `--federate --advertise https://<tenant>.pyrtan.com
+--listed --state <volume>/federation.json`, behind TLS; the console
+connects to it like to any other, a self-hosted hub can follow it or be
+followed by it, and it is one more seed. The control plane that
+provisions it is a separate, private repository, per
+`_meta/platform.md`.
diff --git a/site/build.sh b/site/build.sh new file mode 100755 index 0000000..5e33dc7 --- /dev/null +++ b/site/build.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+# Assemble pyrtan.com into one static directory: the landing page, the
+# console, the catalog (built from examples/), the seed list. Run from the
+# repository root with pyrtan installed (`uv run site/build.sh`); the Pages
+# workflow runs the same thing.
+set -eu
+out="${1:-out}"
+rm -rf "$out"
+mkdir -p "$out/console" "$out/catalog" "$out/rendezvous"
+out="$(cd "$out" && pwd)"
+cp site/index.html site/CNAME "$out/"
+cp site/rendezvous/seeds.json "$out/rendezvous/"
+cp site/catalog/index.html "$out/catalog/"
+pyrtan console --out "$out/console/index.html"
+pyrtan check examples --quiet
+pyrtan index examples --out "$out/catalog/index.json"
+# Every tree file at the path the index names, next to it.
+(cd examples && find . -name '*.toml' -not -name 'pyproject.toml' -exec sh -c 'mkdir -p "$0/$(dirname "$1")" && cp "$1" "$0/$1"' "$out/catalog" {} \;)
+touch "$out/.nojekyll"
+echo "built $out: $(find "$out" -type f | wc -l) files"
diff --git a/site/catalog/index.html b/site/catalog/index.html new file mode 100644 index 0000000..9ab8a95 --- /dev/null +++ b/site/catalog/index.html
@@ -0,0 +1,51 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Pyrtan catalog</title>
+<meta name="description" content="Trees you can pull into your own Pyrtan swarm, checked and indexed from the repository.">
+<style>
+ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --accent:#2b5db8; --soft:#f1efe9 }
+ @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --accent:#7fa6f0; --soft:#222420 } }
+ * { box-sizing:border-box }
+ body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif }
+ main { max-width:920px; margin:0 auto; padding:32px 16px 64px }
+ h1 { font-size:24px; margin:0 0 4px } p.lede { color:var(--mute); margin:0 0 20px }
+ input { width:100%; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg); margin:0 0 14px }
+ .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:0 0 12px }
+ .card h2 { font-size:17px; margin:0 0 4px } .card h2 small { color:var(--mute); font-weight:normal; font-size:13px; margin-left:8px }
+ .mute { color:var(--mute); font-size:14px }
+ code { font:13px ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--soft); padding:1px 5px; border-radius:4px }
+ pre { margin:8px 0 0; padding:8px 12px; background:var(--soft); border-radius:8px; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; overflow:auto }
+ a { color:var(--accent) }
+ footer { color:var(--mute); font-size:13px; margin-top:32px }
+</style>
+</head>
+<body>
+<main>
+ <h1>Pyrtan catalog</h1>
+ <p class="lede">Trees you can pull into your own swarm. Each is one TOML file, checked by the repository's tests, with the licence its author chose. <code>pyrtan pull NAME</code> verifies the file against <a href="index.json">index.json</a> before writing it.</p>
+ <input type="search" id="q" placeholder="filter: weather, github, read-only…" autocomplete="off">
+ <div id="list"><p class="mute">Loading index.json…</p></div>
+ <footer>To add a tree, open a pull request that puts it under <code>examples/</code> with a <code>license</code> line. <a href="../">pyrtan.com</a> · <a href="https://github.com/b-i-t-8/pyrtan">source</a></footer>
+</main>
+<script>
+const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
+let trees = [];
+function render() {
+ const q = document.getElementById("q").value.toLowerCase().split(/\s+/).filter(Boolean);
+ const list = document.getElementById("list"); list.innerHTML = "";
+ const shown = trees.filter(t => { const hay = [t.name, t.description, ...(t.tags||[]), ...(t.hosts||[]), ...(t.triggers||[])].join(" ").toLowerCase(); return q.every(w => hay.includes(w)); });
+ for (const t of shown) {
+ const d = document.createElement("div"); d.className = "card";
+ d.innerHTML = `<h2><code>${esc(t.name)}</code><small>v${esc(t.version)} · ${esc(t.license||"no licence")}${(t.hosts||[]).length ? " · " + esc(t.hosts.join(", ")) : ""}</small></h2><div class="mute">${esc(t.description||"")}</div>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}<pre>pyrtan pull ${esc(t.name)}</pre><div class="mute"><a href="${esc(t.path)}">${esc(t.path)}</a> · sha256 ${esc((t.sha256||"").slice(0, 12))}…</div>`;
+ list.appendChild(d);
+ }
+ if (!shown.length) list.innerHTML = `<p class="mute">Nothing matches.</p>`;
+}
+fetch("index.json").then(r => r.json()).then(i => { trees = i.trees || []; document.title = `Pyrtan catalog · ${trees.length} trees`; render(); }).catch(e => { document.getElementById("list").innerHTML = `<p class="mute">Could not load index.json: ${esc(e)}</p>`; });
+document.getElementById("q").addEventListener("input", render);
+</script>
+</body>
+</html>
diff --git a/deploy/pyrtan.com/index.html b/site/index.html similarity index 92% rename from deploy/pyrtan.com/index.html rename to site/index.html index 5062d6b..dc17dfe 100644 --- a/deploy/pyrtan.com/index.html +++ b/site/index.html
@@ -37,8 +37,8 @@
<p class="lede">Open-source total automation: a core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees. It answers like an assistant. Inside, there is no model.</p> <div class="actions">
- <a class="primary" href="/console/">Open the console</a>
- <a href="/catalog/">Browse the catalog</a>
+ <a class="primary" href="console/">Open the console</a>
+ <a href="catalog/">Browse the catalog</a>
<a href="#directory">The directory</a> <a href="https://github.com/b-i-t-8/pyrtan">Source on GitHub</a> </div>
@@ -69,10 +69,10 @@ pyrtan serve trees --port 8701 # then open http://127
<p>Python 3.12 or later, no runtime dependencies. Runs on a laptop, a NAS or a container; <a href="https://github.com/b-i-t-8/pyrtan/blob/main/deploy/README.md">deployment notes</a>.</p> <h2>The console</h2>
- <p>Every hub and service serves the console at <code>/</code>. The same console is at <a href="/console/">pyrtan.com/console</a>: point it at your own hub, started with <code>--token</code> and <code>--cors https://pyrtan.com</code>, and ask it from anywhere. Nothing about your swarm passes through this site; the page talks to your hub directly.</p>
+ <p>Every hub and service serves the console at <code>/</code>. The same console is at <a href="console/">pyrtan.com/console</a>: point it at your own hub, started with <code>--token</code> and <code>--cors https://pyrtan.com</code>, and ask it from anywhere. Nothing about your swarm passes through this site; the page talks to your hub directly.</p>
<h2 id="directory">The directory</h2>
- <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This page asks the <a href="/rendezvous/seeds.json">seed hubs</a> from your browser and shows what they know. An entry is a hint, not a route: to use a hub, follow it from your own.</p>
+ <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This page asks the <a href="rendezvous/seeds.json">seed hubs</a> from your browser and shows what they know. An entry is a hint, not a route: to use a hub, follow it from your own.</p>
<div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the seeds…</td></tr></tbody></table></div> <pre>pyrtan hub --federate --advertise https://your-hub --listed --token … # your hub, behind a tunnel or TLS pyrtan follow --at https://your-hub --token … # follow the first seed; route to it
@@ -107,7 +107,7 @@ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">
const hubs = [...seen.values()].sort((a, b) => ({up:0, stale:1, down:2}[a.link] - {up:0, stale:1, down:2}[b.link]) || a.id.localeCompare(b.id)); for (const h of hubs) { const tr = document.createElement("tr");
- tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td><a href="/console/?hub=${encodeURIComponent(h.id)}">open in console</a></td></tr>`;
+ tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td><a href="console/?hub=${encodeURIComponent(h.id)}">open in console</a></td></tr>`;
body.appendChild(tr); } if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">The seeds know of nobody yet.</td></tr>`; diff --git a/deploy/pyrtan.com/rendezvous/seeds.json b/site/rendezvous/seeds.json similarity index 100% rename from deploy/pyrtan.com/rendezvous/seeds.json rename to site/rendezvous/seeds.json diff --git a/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/github/github-release.toml b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/github/github-release.toml new file mode 100644 index 0000000..d32f374 --- /dev/null +++ b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/github/github-release.toml
@@ -0,0 +1,63 @@
+# Latest release of a GitHub repository, from the public API. Shows an
+# optional token from the service's env and a decision on the status code.
+
+name = "github-release"
+version = 1
+description = "The latest release of a GitHub repository."
+tags = ["github", "read-only"]
+license = "MIT"
+hosts = ["api.github.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "[what's the] latest release of {repo:word}"
+[[triggers]]
+pattern = "latest {repo:word} release"
+
+[inputs.repo]
+type = "str"
+pattern = "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$"
+description = "owner/name"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://api.github.com/repos/{{ inputs.repo }}/releases/latest", headers = { Accept = "application/vnd.github+json" } }
+into = "resp"
+extract = [
+ { into = "tag", json = "json.tag_name" },
+ { into = "name", json = "json.name" },
+ { into = "published", json = "json.published_at" },
+ { into = "url", json = "json.html_url" },
+ { into = "prerelease", json = "json.prerelease" },
+]
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { eq = 200 }, then = "reply" },
+ { when = { eq = 404 }, then = "none" },
+ { when = { in = [403, 429] }, then = "rate-limited" },
+ { else = "unexpected" },
+]
+
+[nodes.reply]
+kind = "done"
+say = "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"
+output = { repo = "{{ inputs.repo }}", tag = "{{ state.tag }}", name = "{{ state.name }}", published = "{{ state.published }}", url = "{{ state.url }}", prerelease = "{{ state.prerelease }}" }
+
+[nodes.none]
+kind = "fail"
+reason = "no-release"
+say = "{{ inputs.repo }} has no releases, or does not exist."
+
+[nodes.rate-limited]
+kind = "fail"
+reason = "rate-limited"
+say = "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-response"
+say = "GitHub answered {{ state.resp.status }}."
diff --git a/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/hacker-news/hacker-news-item.toml b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/hacker-news/hacker-news-item.toml new file mode 100644 index 0000000..eadbb09 --- /dev/null +++ b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/hacker-news/hacker-news-item.toml
@@ -0,0 +1,66 @@
+name = "hacker-news-item"
+version = 1
+description = "One Hacker News item by id: title, url, score, author."
+tags = ["news", "read-only"]
+license = "MIT"
+hosts = ["hacker-news.firebaseio.com"]
+start = "fetch"
+
+[[triggers]]
+pattern = "hacker news item {id:int}"
+
+[inputs.id]
+type = "int"
+min = 1
+description = "The item id"
+
+[nodes.fetch]
+kind = "observe"
+http = { method = "GET", url = "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json" }
+into = "resp"
+extract = [
+ { into = "title", json = "json.title" },
+ { into = "url", json = "json.url" },
+ { into = "score", json = "json.score" },
+ { into = "by", json = "json.by" },
+ { into = "type", json = "json.type" },
+]
+next = "kind"
+
+[nodes.kind]
+kind = "decide"
+on = "type"
+branches = [
+ { when = { eq = "story" }, then = "has-url" },
+ { when = { eq = "job" }, then = "has-url" },
+ { when = { exists = false }, then = "missing" },
+ { else = "not-a-story" },
+]
+
+[nodes.has-url]
+kind = "decide"
+on = "url"
+branches = [
+ { when = { exists = true }, then = "reply" },
+ { else = "self-post" },
+]
+
+[nodes.self-post]
+kind = "set"
+set = { url = "https://news.ycombinator.com/item?id={{ inputs.id }}" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.title }} ({{ state.score }} points, by {{ state.by }}) {{ state.url }}"
+output = { id = "{{ inputs.id }}", title = "{{ state.title }}", url = "{{ state.url }}", score = "{{ state.score }}", by = "{{ state.by }}" }
+
+[nodes.missing]
+kind = "fail"
+reason = "no-such-item"
+say = "There is no Hacker News item {{ inputs.id }}."
+
+[nodes.not-a-story]
+kind = "fail"
+reason = "not-a-story"
+say = "Item {{ inputs.id }} is a {{ state.type }}, not a story."
diff --git a/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/hacker-news/hacker-news.toml b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/hacker-news/hacker-news.toml new file mode 100644 index 0000000..826c223 --- /dev/null +++ b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/hacker-news/hacker-news.toml
@@ -0,0 +1,79 @@
+# Hacker News, through its public Firebase API. Two trees: this one answers
+# the question, and `hacker-news-item` fetches one story, so that `each`
+# can reuse it.
+
+name = "hacker-news"
+version = 1
+description = "Top stories on Hacker News, from its public API."
+tags = ["news", "read-only"]
+license = "MIT"
+hosts = ["hacker-news.firebaseio.com"]
+start = "fetch-ids"
+
+[[triggers]]
+pattern = "[what's the] top story on hacker news"
+inputs = { count = 1 }
+
+[[triggers]]
+pattern = "top {count:int} stories on hacker news"
+
+[inputs.count]
+type = "int"
+default = 1
+min = 1
+max = 30
+description = "How many stories to return"
+
+[nodes.fetch-ids]
+kind = "observe"
+http = { method = "GET", url = "https://hacker-news.firebaseio.com/v0/topstories.json" }
+into = "ids_resp"
+extract = [{ into = "ids", json = "json" }]
+next = "check"
+
+[nodes.check]
+kind = "decide"
+on = "ids_resp.status"
+branches = [
+ { when = { eq = 200 }, then = "any" },
+ { when = { gte = 500 }, then = "hn-down" },
+ { else = "unexpected" },
+]
+
+[nodes.any]
+kind = "decide"
+on = "ids"
+branches = [
+ { when = { empty = true }, then = "nothing" },
+ { when = { type = "list" }, then = "stories" },
+ { else = "unexpected" },
+]
+
+[nodes.stories]
+kind = "each"
+over = "ids"
+limit = "{{ inputs.count }}"
+tree = "hacker-news-item"
+with = { id = "{{ item }}" }
+into = "stories"
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "Top of Hacker News: {{ state.stories[0].title }} — {{ state.stories[0].url }}"
+output = { stories = "{{ state.stories }}" }
+
+[nodes.nothing]
+kind = "fail"
+reason = "no-stories"
+say = "Hacker News returned an empty front page."
+
+[nodes.hn-down]
+kind = "fail"
+reason = "upstream-down"
+say = "Hacker News answered {{ state.ids_resp.status }}; try again later."
+
+[nodes.unexpected]
+kind = "fail"
+reason = "unexpected-response"
+say = "Hacker News answered {{ state.ids_resp.status }} with something this tree does not recognise."
diff --git a/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/site-status/site-status.toml b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/site-status/site-status.toml new file mode 100644 index 0000000..3d016af --- /dev/null +++ b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/site-status/site-status.toml
@@ -0,0 +1,62 @@
+# Is a site up? The host comes from the sentence, so it cannot be checked
+# at load time; the runtime refuses anything outside `hosts`, and this tree
+# allows any host on purpose, with the wildcard spelled out.
+
+name = "site-status"
+version = 1
+description = "Whether a web site answers, and how quickly."
+tags = ["ops", "read-only"]
+license = "MIT"
+hosts = ["*"]
+start = "probe"
+
+[[triggers]]
+pattern = "is {host:host} up"
+[[triggers]]
+pattern = "is {host:host} down"
+[[triggers]]
+pattern = "[check] status of {host:host}"
+
+[inputs.host]
+type = "str"
+pattern = "(?i)^[a-z0-9.\\-]+(:[0-9]+)?$"
+description = "A host name, with no scheme"
+
+[nodes.probe]
+kind = "observe"
+http = { method = "GET", url = "https://{{ inputs.host | lower }}/" }
+into = "resp"
+timeout = 8
+retries = 1
+next = "status"
+
+[nodes.status]
+kind = "decide"
+on = "resp.status"
+branches = [
+ { when = { between = [200, 399] }, then = "up" },
+ { when = { between = [400, 499] }, then = "refusing" },
+ { when = { gte = 500 }, then = "erroring" },
+ { else = "odd" },
+]
+
+[nodes.up]
+kind = "done"
+say = "{{ inputs.host }} is up: {{ state.resp.status }} in {{ state.resp.elapsed_ms }} ms."
+output = { host = "{{ inputs.host }}", up = true, status = "{{ state.resp.status }}", elapsed_ms = "{{ state.resp.elapsed_ms }}" }
+
+[nodes.refusing]
+kind = "done"
+say = "{{ inputs.host }} answers but refuses the front page ({{ state.resp.status }}); the server is up."
+output = { host = "{{ inputs.host }}", up = true, status = "{{ state.resp.status }}", note = "client error on /" }
+
+[nodes.erroring]
+kind = "fail"
+reason = "server-error"
+say = "{{ inputs.host }} is answering {{ state.resp.status }}: up, but broken."
+output = { host = "{{ inputs.host }}", up = false, status = "{{ state.resp.status }}" }
+
+[nodes.odd]
+kind = "fail"
+reason = "unexpected-status"
+say = "{{ inputs.host }} answered {{ state.resp.status }}, which this tree has no branch for."
diff --git a/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/weather/weather.toml b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/weather/weather.toml new file mode 100644 index 0000000..3e65419 --- /dev/null +++ b/tmp/claude-0/-home-user-pyrtan/c01cbcf1-7852-56a6-833f-ba19e8dea139/scratchpad/out/catalog/weather/weather.toml
@@ -0,0 +1,133 @@
+# Weather for a place, from Open-Meteo (no key needed). Two hops: geocode
+# the name, then fetch the forecast for the coordinates. Each hop has its
+# own decision so a failure says which hop failed.
+
+name = "weather"
+version = 1
+description = "Current weather for a place, from Open-Meteo."
+tags = ["weather", "read-only"]
+license = "MIT"
+hosts = ["geocoding-api.open-meteo.com", "api.open-meteo.com"]
+start = "geocode"
+
+# "forecast in Oslo" and "temperature in Oslo" read as "weather in Oslo".
+[language]
+synonyms = { weather = ["forecast", "temperature"] }
+
+[[triggers]]
+pattern = "[what's the] weather in {place}"
+[[triggers]]
+pattern = "[what's the] weather for {place}"
+[[triggers]]
+pattern = "how [hot|cold|warm] is it in {place}"
+
+[inputs.place]
+type = "str"
+description = "A town or city name"
+
+[inputs.units]
+type = "str"
+default = "celsius"
+one_of = ["celsius", "fahrenheit"]
+
+[nodes.geocode]
+kind = "observe"
+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }
+into = "geo"
+extract = [
+ { into = "hit", json = "json.results[0]" },
+ { into = "lat", json = "json.results[0].latitude" },
+ { into = "lon", json = "json.results[0].longitude" },
+ { into = "name", json = "json.results[0].name" },
+ { into = "country", json = "json.results[0].country" },
+]
+next = "found"
+
+[nodes.found]
+kind = "decide"
+on = "hit"
+branches = [
+ { when = { exists = true }, then = "forecast" },
+ { else = "unknown-place" },
+]
+
+[nodes.forecast]
+kind = "observe"
+http = { method = "GET", url = "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code&temperature_unit={{ inputs.units }}" }
+into = "fc"
+extract = [
+ { into = "temp", json = "json.current.temperature_2m" },
+ { into = "humidity", json = "json.current.relative_humidity_2m" },
+ { into = "wind", json = "json.current.wind_speed_10m" },
+ { into = "code", json = "json.current.weather_code" },
+ { into = "unit", json = "json.current_units.temperature_2m" },
+]
+next = "have-temp"
+
+[nodes.have-temp]
+kind = "decide"
+on = "temp"
+branches = [
+ { when = { type = "number" }, then = "sky" },
+ { else = "no-forecast" },
+]
+
+# WMO weather codes, folded into words a person would use.
+[nodes.sky]
+kind = "decide"
+on = "code"
+branches = [
+ { when = { eq = 0 }, then = "clear" },
+ { when = { between = [1, 3] }, then = "cloudy" },
+ { when = { between = [45, 48] }, then = "fog" },
+ { when = { between = [51, 67] }, then = "rain" },
+ { when = { between = [71, 77] }, then = "snow" },
+ { when = { between = [80, 82] }, then = "rain" },
+ { when = { between = [85, 86] }, then = "snow" },
+ { when = { between = [95, 99] }, then = "storm" },
+ { else = "unknown-sky" },
+]
+
+[nodes.clear]
+kind = "set"
+set = { sky = "clear" }
+next = "reply"
+[nodes.cloudy]
+kind = "set"
+set = { sky = "cloudy" }
+next = "reply"
+[nodes.fog]
+kind = "set"
+set = { sky = "foggy" }
+next = "reply"
+[nodes.rain]
+kind = "set"
+set = { sky = "raining" }
+next = "reply"
+[nodes.snow]
+kind = "set"
+set = { sky = "snowing" }
+next = "reply"
+[nodes.storm]
+kind = "set"
+set = { sky = "stormy" }
+next = "reply"
+[nodes.unknown-sky]
+kind = "set"
+set = { sky = "weather code {{ state.code }}" }
+next = "reply"
+
+[nodes.reply]
+kind = "done"
+say = "{{ state.name }}, {{ state.country }}: {{ state.temp }}{{ state.unit }}, {{ state.sky }}, humidity {{ state.humidity }}%, wind {{ state.wind }} km/h."
+output = { place = "{{ state.name }}", country = "{{ state.country }}", temperature = "{{ state.temp }}", unit = "{{ state.unit }}", sky = "{{ state.sky }}", humidity = "{{ state.humidity }}", wind_kmh = "{{ state.wind }}" }
+
+[nodes.unknown-place]
+kind = "fail"
+reason = "unknown-place"
+say = "I could not find a place called {{ inputs.place }}."
+
+[nodes.no-forecast]
+kind = "fail"
+reason = "no-forecast"
+say = "Open-Meteo found {{ state.name }} but returned no current temperature (status {{ state.fc.status }})."