From d9aac8a275e68ad733cd154140f496d8ff2309ae Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 15 Jun 2026 23:04:22 +0300 Subject: [PATCH] Make sourcehut-custom-service a global skill installed via Justfile Move the skill from project-local .claude/skills to the skills/ backup dir (matching sourcehut-ci) and generalize the Justfile install target to a skill list, installing both into ~/.claude/skills. Replace absolute mirror paths with repo:::: references resolved against a single __SRHT_MIRROR__ placeholder that the install target substitutes with the repo's absolute path; switch citations from drift-prone line numbers to stable symbol names and fix a stale core-go/auth/internal.go reference to core-go/auth/middleware.go. --- justfile | 27 +++-- .../sourcehut-custom-service/SKILL.md | 105 +++++++++--------- 2 files changed, 75 insertions(+), 57 deletions(-) rename {.claude/skills => skills}/sourcehut-custom-service/SKILL.md (61%) diff --git a/justfile b/justfile index 1bdd787e03a969cfb1eea36e64a629050d88d7e1..09e8f533d18a39e4c4066a5fe198df2cbb7642a3 100644 --- a/justfile +++ b/justfile @@ -1,12 +1,25 @@ _default: @just --list -skill := "sourcehut-ci" -src := "skills/" + skill -dst := "~/.claude/skills/" + skill +# Skills backed up in this repo, installed into ~/.claude/skills (repo -> ~/.claude) +skills := "sourcehut-ci sourcehut-custom-service" -# Install the backed-up skill into ~/.claude/skills (repo -> ~/.claude) +# Absolute path to this repo (the SourceHut documentation mirror), substituted +# into skills for the __SRHT_MIRROR__ placeholder at install time. +mirror := justfile_directory() + +# Install the backed-up skills into ~/.claude/skills install: - mkdir -p {{dst}} - rsync -a --delete --exclude='.*' {{src}}/ {{dst}}/ - @echo "Installed {{skill}} -> {{dst}} ($(find {{dst}} -type f | wc -l | tr -d ' ') files)" + #!/usr/bin/env bash + set -euo pipefail + for skill in {{skills}}; do + src="skills/$skill" + dst="$HOME/.claude/skills/$skill" + mkdir -p "$dst" + rsync -a --delete --exclude='.*' "$src/" "$dst/" + # Substitute the mirror-root placeholder with this repo's absolute path + find "$dst" -type f -name '*.md' -print0 | while IFS= read -r -d '' f; do + sed "s|__SRHT_MIRROR__|{{mirror}}|g" "$f" > "$f.tmp" && mv "$f.tmp" "$f" + done + echo "Installed $skill -> $dst ($(find "$dst" -type f | wc -l | tr -d ' ') files)" + done diff --git a/.claude/skills/sourcehut-custom-service/SKILL.md b/skills/sourcehut-custom-service/SKILL.md similarity index 61% rename from .claude/skills/sourcehut-custom-service/SKILL.md rename to skills/sourcehut-custom-service/SKILL.md index dae2c55d8f793e6da7182043c95d23b72fc9eaf5..be9ffafa2597c0a8dab20365d34f86ad50f60257 100644 --- a/.claude/skills/sourcehut-custom-service/SKILL.md +++ b/skills/sourcehut-custom-service/SKILL.md @@ -11,7 +11,13 @@ SourceHut has **no plugin system** — nothing loads third-party code into a run **This only works on a self-hosted instance you control** (config + nginx + DNS), e.g. the user's `*.srht.bigb.es`. You cannot add a service to hosted sr.ht. SourceHut services are tightly-coupled siblings sharing `core.sr.ht`/`core-go`, **not** a versioned plugin API — when you bump those on a refresh, your service can break and it's on you to track it. -All file:line references below point into the read-only mirror at `~/data/home/sourcehut`. +### Reference notation + +Citations are written **`repo::::`**: open `` **relative to the mirror root** and search for `` (a function, class, template anchor, or config section). Symbol names survive refreshes; line numbers don't, so they are deliberately omitted. The mirror root is the **single absolute path** in this skill, substituted at install time: + +> **`__SRHT_MIRROR__`** + +(In this workspace that is the repo holding the SourceHut documentation mirror. The `just install` target rewrites the token to the real path.) --- @@ -21,14 +27,14 @@ All file:line references below point into the read-only mirror at `~/data/home/s The list of services in the top nav is computed from config — **every section name ending in `.sr.ht`**: ```python -# core.sr.ht/srht/app/flask.py:32 +# repo::core.sr.ht/srht/app/flask.py::_network _network = [ s for s in config if s.endswith(".sr.ht") and s not in ["paste.sr.ht", "pages.sr.ht"] ] ``` -The nav template loops over `network` and links each via `get_origin()`, which just reads `[service] origin=` from config (`core.sr.ht/srht/templates/nav.html:17-32`, `core.sr.ht/srht/config.py:67`). +The nav template loops over `network` and links each via `get_origin()`, which just reads `[service] origin=` from config (`repo::core.sr.ht/srht/templates/nav.html::for _site in network`, `repo::core.sr.ht/srht/config.py::get_origin`). → **Add a `[myservice.sr.ht]` section with `origin=` to each service's `config.ini` and your service appears in the nav of every SourceHut web UI.** (paste/pages are hardcoded out of the switcher; your service won't be.) @@ -36,36 +42,36 @@ The nav template loops over `network` and links each via `get_origin()`, which j Services do **not** each run a separate web-login OAuth dance. There is a single shared cookie, `sr.ht.unified-login.v1`, set on the **global domain** (e.g. `.srht.bigb.es`), httponly, containing the user's profile as Fernet-encrypted JSON: ```python -# core.sr.ht/srht/app/flask.py:201-218 (read on every request) +# repo::core.sr.ht/srht/app/flask.py::get_session_cookie (read on every request) cookie = request.cookies.get("sr.ht.unified-login.v1") user_info = json.loads(fernet.decrypt(cookie.encode()).decode()) user = self.oauth_service.lookup_user(user_info["name"]) # ...g.current_user = user -# core.sr.ht/srht/app/flask.py:267-285 (set after login) +# repo::core.sr.ht/srht/app/flask.py::make_response (set after login) response.set_cookie("sr.ht.unified-login.v1", fernet.encrypt(user_info.encode()).decode(), domain=global_domain, httponly=True, max_age=...) ``` -The Fernet key is the shared **`[sr.ht] network-key`** from config (`core.sr.ht/srht/crypto.py:21`). meta.sr.ht sets this cookie when the user logs in; **every service — in any language — can read it, decrypt it with the same `network-key`, and know who the user is.** No per-service OAuth callback is needed just to render pages as the logged-in user. +The Fernet key is the shared **`[sr.ht] network-key`** from config (`repo::core.sr.ht/srht/crypto.py::fernet`). meta.sr.ht sets this cookie when the user logs in; **every service — in any language — can read it, decrypt it with the same `network-key`, and know who the user is.** No per-service OAuth callback is needed just to render pages as the logged-in user. -Login/logout are plain redirects to meta (`core.sr.ht/srht/app/flask.py:290-302`): +Login/logout are plain redirects to meta (`repo::core.sr.ht/srht/app/flask.py::login_url`, `::logout_url`): ``` {meta-origin}/login?return_to={your_url} {meta-origin}/logout?return_to={your_url} ``` -> Fernet = AES-128-CBC + HMAC-SHA256, base64url, with a version byte + timestamp + IV (the `cryptography` library's spec). Reimplementable in any language; Go has `github.com/fernet/fernet-go`. +> Fernet = AES-128-CBC + HMAC-SHA256, base64url, with a version byte + timestamp + IV (the `cryptography` library's spec). Reimplementable in any language; Go has `github.com/fernet/fernet-go` (the same library `core-go` uses). ### 3. The shared theme -Just a compiled CSS asset. `core.sr.ht/scss/` is the Bootstrap-derived theme; each service's `scss/main.scss` imports it and the Makefile compiles to a hashed `main.min..css`. To match the look, **link/serve that compiled CSS** — you do not reimplement SCSS. +Just a compiled CSS asset. `repo::core.sr.ht/scss/` is the Bootstrap-derived theme; each service's `scss/main.scss` imports it and the Makefile compiles to a hashed `main.min..css`. To match the look, **link/serve that compiled CSS** — you do not reimplement SCSS. ### 4. The GraphQL federation gateway (`api.sr.ht`) The aggregator merges per-service GraphQL schemas into one endpoint, also config-driven: ```go -// api.sr.ht/main.go:30-35 +// repo::api.sr.ht/main.go::main for name := range conf { if strings.HasSuffix(name, ".sr.ht") { services = append(services, thistle.NewService(name, getOrigin(conf, name))) @@ -73,10 +79,10 @@ for name := range conf { } ``` -It fetches each service's `/query` schema and runs `thistle.BuildSchema(...)` (`api.sr.ht/main.go:140-168`). Add a config section pointing at your service's GraphQL `/query` endpoint and your types join the unified API — **no edit to api.sr.ht**, just config + a SIGHUP to reload (`main.go:74-87`). Internal service-to-service calls use HMAC auth (`core-go/auth/internal.go`, `api.sr.ht/auth.go` `InternalAuthTransport`). +It fetches each service's `/query` schema and runs `thistle.BuildSchema(...)` (`repo::api.sr.ht/main.go::updateSchema`). Add a config section pointing at your service's GraphQL `/query` endpoint and your types join the unified API — **no edit to api.sr.ht**, just config + a SIGHUP to reload (handled in `repo::api.sr.ht/main.go::main`). Internal service-to-service calls use HMAC auth (`repo::api.sr.ht/auth.go::InternalAuthTransport` on the caller side, `repo::core-go/auth/middleware.go::internalAuth` on the receiver side). ### 5. Webhooks -React to events from other services: GraphQL-native (`core-go/webhooks/`, `/api/webhooks/`) or legacy HTTP (`core.sr.ht/srht/webhook/`). Smaller services (`paste`, `man`, `pages`) may not emit them. +React to events from other services: GraphQL-native (`repo::core-go/webhooks/queue.go::NewQueue`, plus per-service `api/webhooks/`) or legacy HTTP (`repo::core.sr.ht/srht/webhook/`). Smaller services (`paste`, `man`, `pages`) may not emit them. --- @@ -87,13 +93,13 @@ React to events from other services: GraphQL-native (`core-go/webhooks/`, `` | Python — `repo::core.sr.ht/srht/config.py::` | | --- | --- | --- | -| Load file | `LoadConfig()` (`config.go:31`) | `load_config()` (`config.py:23`) | -| String / int / bool | `GetString` / `GetInt` / `GetBool` (`:156`/`:167`/`:181`) | `cfg` / `cfgi` / `cfgb` (`:35`/`:44`/`:50`) | -| A service's web URL | `GetOrigin(conf, svc, external)` (`:107`) | `get_origin(svc, external=)` (`:67`) | -| A service's API URL | `GetAPI(conf, svc, external)` (`:125`) | `get_api(svc, external=)` (`:80`) | -| Owner name/email | `GetOwner` (`:89`) | — | -| Internal-caller check | `IsInternalIP(ip)` (`:198`) | — | -| Global domain | — | `get_global_domain(site)` (`:107`) | -| S3 upstream | — | `get_s3_upstream()` (`:119`) | +| Load file | `LoadConfig` | `load_config` | +| String / int / bool | `GetString` / `GetInt` / `GetBool` | `cfg` / `cfgi` / `cfgb` | +| A service's web URL | `GetOrigin` | `get_origin` | +| A service's API URL | `GetAPI` | `get_api` | +| Owner name/email | `GetOwner` | — | +| Internal-caller check | `IsInternalIP` | — | +| Global domain | — | `get_global_domain` | +| S3 upstream | — | `get_s3_upstream` | **Use the canonical key names for your `[myservice.sr.ht]` section** — every other service uses these exact spellings, so reuse them rather than coining synonyms: @@ -228,8 +234,8 @@ Two reasons to mirror upstream's config vocabulary instead of designing fresh ke | `origin` | `url`, `base-url`, `web-url` | `GetOrigin`/`get_origin` look up exactly `origin` (+ `internal-origin`). | | `internal-origin` | `lan-url`, `private-origin` | The internal-preferred fallback partner of `origin`. | | `api-origin` / `api-internal-origin` | `graphql-url`, `api-url` | `GetAPI`/`get_api` probe these names in a fixed order. | -| `connection-string` | `db`, `dsn`, `database-url`, `pg` | `core-go/server/server.go:288` and `DbSession` read `connection-string`. | -| `redis-host` | `redis`, `redis-url` | Read from `[sr.ht]`, not your section (`server.go:302`). | +| `connection-string` | `db`, `dsn`, `database-url`, `pg` | `repo::core-go/server/server.go::WithDefaultMiddleware` and `DbSession` read `connection-string`. | +| `redis-host` | `redis`, `redis-url` | Read from `[sr.ht]`, not your section (`repo::core-go/server/server.go::WithDefaultMiddleware`). | | `migrate-on-upgrade` | `auto-migrate` | Packaging convention. | | `webhooks` | `webhook-redis`, `hooks-db` | Redis URL for the webhook queue. | | `debug-host` / `debug-port` | `host` / `port`, `bind` | Dev-server bind convention. | @@ -252,11 +258,10 @@ Two reasons to mirror upstream's config vocabulary instead of designing fresh ke ## Key files to re-read when working on this -- `core.sr.ht/srht/app/flask.py` — `_network` (nav list, :32), unified-login cookie read (:201-218) + write (:267-285), `login_url`/`logout_url` (:290-302) -- `core.sr.ht/srht/templates/nav.html`, `layout.html` — the shared chrome -- `core.sr.ht/srht/config.py:67` `get_origin` / `:80` `get_api`; `core.sr.ht/srht/crypto.py:21` Fernet key -- `core-go/config/config.go` — `:71` `internal-ipnet`, `:89` `GetOwner` (panics), `:107` `GetOrigin`, `:125` `GetAPI`; `core-go/crypto/crypto.go:27` webhook key / `:38` `network-key`; `core-go/server/server.go:288` `connection-string` / `:302` `redis-host` -- `*/config.example.ini` — real field names per service (`paste.sr.ht` minimal, `git.sr.ht` rich: S3, dispatch, optional `[builds.sr.ht]` integration block at `:168-171`) -- `paste.sr.ht/pastesrht/app.py` — minimal Python service bootstrap -- `api.sr.ht/main.go:26-168` — federation gateway (config-driven, thistle) -- `core-go/auth/` (`bearer.go`, `middleware.go`, `internal.go`) — Go token validation + internal HMAC +- `repo::core.sr.ht/srht/app/flask.py` — `::_network` (nav list), `::get_session_cookie` (unified-login read) + `::make_response` (write), `::login_url` / `::logout_url`, `::Flask` (the base class) +- `repo::core.sr.ht/srht/templates/nav.html`, `repo::core.sr.ht/srht/templates/layout.html` — the shared chrome +- `repo::core.sr.ht/srht/config.py::get_origin` / `::get_api`; `repo::core.sr.ht/srht/crypto.py::fernet` — Fernet key +- `repo::core-go/config/config.go` — `::LoadConfig` (incl. `internal-ipnet`), `::GetOwner` (panics), `::GetOrigin`, `::GetAPI`; `repo::core-go/crypto/crypto.go::InitCrypto` (webhook key + `network-key`); `repo::core-go/server/server.go::WithDefaultMiddleware` (`connection-string` + `redis-host`); `repo::core-go/auth/middleware.go::Middleware` / `::cookieAuth` / `::internalAuth` +- `*/config.example.ini` — real field names per service (`paste.sr.ht` minimal, `git.sr.ht` rich: S3, dispatch, optional `repo::git.sr.ht/config.example.ini::[builds.sr.ht]` integration block) +- `repo::paste.sr.ht/pastesrht/app.py::PasteApp` — minimal Python service bootstrap +- `repo::api.sr.ht/main.go::main` + `::updateSchema` — federation gateway (config-driven, thistle)