Created
April 28, 2026 10:17
-
-
Save rickychilcott/7ad7a8aef890321b71d336279af55838 to your computer and use it in GitHub Desktop.
Cloudflare Transform Rules for agent-ready static sites (markdown negotiation + Link header)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # Apply the 4 Cloudflare Transform Rules that complete the agent-readiness setup | |
| # (Options D and E from the score-maximizer bundle): | |
| # | |
| # 1. URL rewrite — Accept: text/markdown → /<path>/index.md | |
| # 2. Response header — *.md responses → Content-Type: text/markdown | |
| # 3. Response header — homepage / → Link: <api-catalog>, <agent-skills> | |
| # 4. Response header — /.well-known/api-catalog → Content-Type: application/linkset+json | |
| # | |
| # Idempotent: on each phase, GETs the existing entrypoint ruleset, removes any | |
| # rules whose descriptions match ours, then PUTs the full set. Preserves any | |
| # unrelated rules the user already had in those phases. | |
| # | |
| # Usage: | |
| # export CLOUDFLARE_API_TOKEN=... # token with Zone:Rulesets:Edit on the zone | |
| # export CLOUDFLARE_ZONE_ID=... # zone overview page in CF dashboard | |
| # | |
| # ./scripts/setup-cf-transform-rules.sh <host> [--dry-run] | |
| # HOST=<host> ./scripts/setup-cf-transform-rules.sh [--dry-run] | |
| # | |
| # Examples: | |
| # ./scripts/setup-cf-transform-rules.sh get.stokedhq.com | |
| # ./scripts/setup-cf-transform-rules.sh www.causey.app --dry-run | |
| # HOST=www.rickychilcott.com ./scripts/setup-cf-transform-rules.sh | |
| # | |
| # After applying, verify with: | |
| # curl -sI -H 'Accept: text/markdown' https://<host>/ | grep -i content-type | |
| # curl -sI https://<host>/ | grep -i ^link | |
| # curl -sI https://<host>/.well-known/api-catalog | grep -i content-type | |
| # | |
| set -euo pipefail | |
| # ---- arg parsing ---- | |
| DRY_RUN=0 | |
| HOST="${HOST:-}" | |
| usage() { | |
| echo "usage: $(basename "$0") <host> [--dry-run]" >&2 | |
| echo " HOST=<host> $(basename "$0") [--dry-run]" >&2 | |
| exit 2 | |
| } | |
| for arg in "$@"; do | |
| case "$arg" in | |
| --dry-run) DRY_RUN=1 ;; | |
| -h|--help) usage ;; | |
| --*) echo "unknown flag: $arg" >&2; usage ;; | |
| *) HOST="$arg" ;; | |
| esac | |
| done | |
| if [[ -z "$HOST" ]]; then | |
| echo "✗ host is required (e.g. get.stokedhq.com)" >&2 | |
| usage | |
| fi | |
| : "${CLOUDFLARE_API_TOKEN:?Need CLOUDFLARE_API_TOKEN in env}" | |
| : "${CLOUDFLARE_ZONE_ID:?Need CLOUDFLARE_ZONE_ID in env}" | |
| API="https://api.cloudflare.com/client/v4" | |
| AUTH="Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | |
| # ---- preflight ---- | |
| echo "→ verifying zone access for host: ${HOST}" | |
| zone_resp=$(curl -sS -H "$AUTH" "${API}/zones/${CLOUDFLARE_ZONE_ID}") | |
| if [[ "$(jq -r .success <<<"$zone_resp")" != "true" ]]; then | |
| echo "✗ zone lookup failed:" >&2 | |
| jq . <<<"$zone_resp" >&2 | |
| exit 1 | |
| fi | |
| zone_name=$(jq -r .result.name <<<"$zone_resp") | |
| echo " zone: ${zone_name}" | |
| if [[ "$HOST" != "$zone_name" && "$HOST" != *".$zone_name" ]]; then | |
| echo " ⚠️ warning: host '${HOST}' is not under zone '${zone_name}'. continuing anyway." | |
| fi | |
| # ---- helpers ---- | |
| # fetch_existing_rules <phase> — prints a JSON array of existing rules (empty if none). | |
| # Cloudflare returns 10003 when no entrypoint ruleset exists yet; treat that as empty. | |
| fetch_existing_rules() { | |
| local phase="$1" | |
| local resp | |
| resp=$(curl -sS -H "$AUTH" "${API}/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/${phase}/entrypoint") | |
| if [[ "$(jq -r .success <<<"$resp")" == "true" ]]; then | |
| jq '.result.rules // []' <<<"$resp" | |
| return 0 | |
| fi | |
| if jq -e '.errors[]? | select(.code == 10003)' <<<"$resp" >/dev/null; then | |
| echo '[]' | |
| return 0 | |
| fi | |
| echo "✗ unexpected response from GET entrypoint for phase ${phase}:" >&2 | |
| jq . <<<"$resp" >&2 | |
| exit 1 | |
| } | |
| # put_phase_rules <phase> <rules_json_array> — replaces the entire entrypoint. | |
| put_phase_rules() { | |
| local phase="$1" | |
| local rules_json="$2" | |
| local body | |
| body=$(jq -n --argjson rules "$rules_json" '{rules: $rules}') | |
| if (( DRY_RUN )); then | |
| echo " (dry-run) PUT ${API}/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/${phase}/entrypoint" | |
| echo " body:" | |
| jq . <<<"$body" | sed 's/^/ /' | |
| return 0 | |
| fi | |
| local resp | |
| resp=$(curl -sS -X PUT \ | |
| -H "$AUTH" \ | |
| -H "Content-Type: application/json" \ | |
| --data "$body" \ | |
| "${API}/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/${phase}/entrypoint") | |
| if [[ "$(jq -r .success <<<"$resp")" != "true" ]]; then | |
| echo "✗ PUT failed for phase ${phase}:" >&2 | |
| jq .errors <<<"$resp" >&2 | |
| exit 1 | |
| fi | |
| echo " ✓ phase ${phase} updated, $(jq '.result.rules | length' <<<"$resp") rule(s) total" | |
| } | |
| # apply_phase <phase> <our_rules_json> — merge our rules with any existing | |
| # (replacing by matching description), then PUT. | |
| apply_phase() { | |
| local phase="$1" | |
| local our_rules="$2" | |
| echo "" | |
| echo "── phase: ${phase}" | |
| local our_descriptions | |
| our_descriptions=$(jq '[.[].description]' <<<"$our_rules") | |
| local existing | |
| existing=$(fetch_existing_rules "$phase") | |
| local kept | |
| kept=$(jq --argjson ours "$our_descriptions" '[.[] | select(.description as $d | ($ours | index($d)) == null)]' <<<"$existing") | |
| local combined | |
| combined=$(jq -n --argjson kept "$kept" --argjson ours "$our_rules" '$kept + $ours') | |
| local existing_count kept_count ours_count | |
| existing_count=$(jq 'length' <<<"$existing") | |
| kept_count=$(jq 'length' <<<"$kept") | |
| ours_count=$(jq 'length' <<<"$our_rules") | |
| echo " existing rules: ${existing_count}, kept (unrelated): ${kept_count}, applying: ${ours_count}" | |
| put_phase_rules "$phase" "$combined" | |
| } | |
| # ---- request-phase rules: the markdown URL rewrite ---- | |
| REQUEST_RULES=$(jq -n --arg host "$HOST" '[ | |
| { | |
| description: "Markdown for Agents — URL rewrite", | |
| expression: ("(http.host eq \"" + $host + "\" and any(http.request.headers[\"accept\"][*] contains \"text/markdown\") and not ends_with(http.request.uri.path, \".md\"))"), | |
| action: "rewrite", | |
| action_parameters: { | |
| uri: { | |
| path: { | |
| expression: "concat(http.request.uri.path, \"index.md\")" | |
| } | |
| } | |
| }, | |
| enabled: true | |
| } | |
| ]') | |
| # ---- response-headers-phase rules: 3 headers ---- | |
| LINK_VALUE='</.well-known/api-catalog>; rel="api-catalog", </.well-known/agent-skills/index.json>; rel="https://schemas.agentskills.io/discovery/0.2.0/"' | |
| RESPONSE_RULES=$(jq -n --arg host "$HOST" --arg link "$LINK_VALUE" '[ | |
| { | |
| description: "Markdown Content-Type", | |
| expression: ("(http.host eq \"" + $host + "\" and ends_with(http.request.uri.path, \".md\"))"), | |
| action: "rewrite", | |
| action_parameters: { | |
| headers: { | |
| "content-type": { | |
| operation: "set", | |
| value: "text/markdown; charset=utf-8" | |
| } | |
| } | |
| }, | |
| enabled: true | |
| }, | |
| { | |
| description: "Agent discovery Link header", | |
| expression: ("(http.host eq \"" + $host + "\" and http.request.uri.path eq \"/\")"), | |
| action: "rewrite", | |
| action_parameters: { | |
| headers: { | |
| "link": { | |
| operation: "set", | |
| value: $link | |
| } | |
| } | |
| }, | |
| enabled: true | |
| }, | |
| { | |
| description: "API catalog Content-Type", | |
| expression: ("(http.host eq \"" + $host + "\" and http.request.uri.path eq \"/.well-known/api-catalog\")"), | |
| action: "rewrite", | |
| action_parameters: { | |
| headers: { | |
| "content-type": { | |
| operation: "set", | |
| value: "application/linkset+json" | |
| } | |
| } | |
| }, | |
| enabled: true | |
| } | |
| ]') | |
| apply_phase "http_request_transform" "$REQUEST_RULES" | |
| apply_phase "http_response_headers_transform" "$RESPONSE_RULES" | |
| echo "" | |
| echo "✅ done." | |
| echo "" | |
| echo "verify with:" | |
| echo " curl -sI -H 'Accept: text/markdown' https://${HOST}/ | grep -i content-type" | |
| echo " curl -sI https://${HOST}/ | grep -i ^link" | |
| echo " curl -sI https://${HOST}/.well-known/api-catalog | grep -i content-type" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment