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@v4diff --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 offersone, 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 againsttests/ 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@v4diff --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@v4diff --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.comdiff --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 => ({"&":"&","<":"<",">":">",'"':"""}[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 => ({"&":"&","<":"<",">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 }}¤t=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 }})."