Write / edit / delete operations against CloudFlare state for the AgentCulture organization — create redirects, modify rules, delete resources. Use when: creating a CloudFlare redirect, adding a Single Redirect rule, editing / modifying CloudFlare state, deleting a Pages project / Worker / DNS record, or the user says "create redirect", "add cloudflare redirect", "edit cloudflare", "write cloudflare", "modify dns", "delete pages project", "cf-redirect-create", "cf-redirect", "cultureflare dns create". For **read-only** inventory (list zones / DNS / Workers / Pages, verify token), use the separate `cultureflare` skill — this skill never runs GET-only queries.
Write / edit / delete operations against CloudFlare state for the AgentCulture organization — create redirects, modify rules, delete resources. Use when: creating a CloudFlare redirect, adding a Single Redirect rule, editing / modifying CloudFlare state, deleting a Pages project / Worker / DNS record, or the user says "create redirect", "add cloudflare redirect", "edit cloudflare", "write cloudflare", "modify dns", "delete pages project", "cf-redirect-create", "cf-redirect", "cultureflare dns create". For **read-only** inventory (list zones / DNS / Workers / Pages, verify token), use the separate `cultureflare` skill — this skill never runs GET-only queries.
cultureflare-write
Write-side companion to the read-only cultureflare skill. Every script
here mutates CloudFlare state (creates, updates, or deletes a
resource) and defaults to dry-run — the live API call only fires
with an explicit --apply flag.
Shared library: _lib.sh is a symlink to the read skill's copy at
../../cultureflare/scripts/_lib.sh, so env loading, cf_api,
cf_api_paginated, cf_output, cf_output_kv, and
cf_require_account_id are all available without duplicating code.
Fixes to the shared helpers apply to both skills automatically.
How to invoke
Python CLI is the preferred entry (v0.1.0: dns create). Bash scripts
under scripts/ remain the fallback for write verbs not yet ported.
The Python CLI reads CLOUDFLARE_API_TOKEN +
CLOUDFLARE_ACCOUNT_ID from the environment only — no filesystem
snooping. Bash scripts still load .env from the repo root during
coexistence.
All mutations are dry-run by default in both surfaces. Pass
--apply to actually POST. This is the one safety rail that
coexistence MUST preserve across both Python and bash.
1. Pre-flight
Write operations need a different token than the read skill uses.
The read skill's token is scoped Read only; attempting a POST /
PUT / DELETE with it will fail with code 10000 Authentication error.
Provision a second token (see docs/SETUP.md §1.5 Write-ops
token) with these additional scopes on top of the read scopes:
Zone · Single Redirect · Edit (All zones from AgentCulture) —
required by cf-redirect-create.sh. (The CloudFlare Rulesets API
still uses http_request_dynamic_redirect as the phase
identifier, but the dashboard's token-scope label is "Single
Redirect".)
Zone · DNS · Edit (All zones from AgentCulture) —
required by cultureflare dns create and cf-dns-create.sh
Account · Cloudflare Pages · Edit (this account) —
required by cultureflare pages deployments create,
cf-pages-project-create.sh, cf-pages-domain-add.sh,
cf-pages-domain-remove.sh, cf-pages-deployment-create.sh,
cf-pages-deployment-delete.sh, and
cf-pages-deployments-purge.sh. Creating a GitHub-connected
Pages project (no --direct-upload) additionally needs the
Cloudflare Pages GitHub App installed on the source GitHub
owner (org or user) with access to the target repo — a one-time
dashboard / GitHub-admin step this skill cannot automate.
--direct-upload projects have no GitHub App dependency.
Account · Workers Scripts · Edit (this account) —
required by cf-worker-create.sh. Uploads a Worker script (the
subpath-proxy template, typically) via multipart PUT.
Zone · Workers Routes · Edit (All zones from AgentCulture) —
required by cf-workers-route-create.sh. Per
memory/zone_ids.md, zone-level scopes on a subset fail with code
10000; cover every zone or the route POST will reject even though
the dry-run passed.
Swap the token into .env when you're about to run a write script,
then swap back. One token at a time.
(Reuses the read skill — the /user/tokens/verify endpoint works on
any token.)
2. Safety model
Every write operation in this skill follows the same shape:
Dry-run by default. Running without --apply resolves names,
performs read-only pre-flight checks (does the zone exist? does the
resource already exist?), prints the exact JSON body the script
would POST / PUT / DELETE, and exits 0. No mutation.
--apply makes it real. Only with --apply does the script
call the mutating endpoint.
Idempotency is enforced locally. Scripts query for existing
matching resources before creating and exit 1 with a clear error if
one already exists — they never silently overwrite.
Names, not IDs. Args are zone / project / resource names;
scripts resolve to IDs internally via cf_api_paginated.
3. Scripts
Action
Command
Create a DNS record
cultureflare dns create <zone> <type> <name> <content>(dry-run; --apply to commit)
Create a Pages project
bash .claude/skills/cultureflare-write/scripts/cf-pages-project-create.sh ...(Python port pending)
Full flag reference for each script follows below.
cf-redirect-create.sh
Creates a zone-level Single Redirect ruleset in the
http_request_dynamic_redirect phase. Path and query string are
preserved; the target URL is built as
concat("https://TO_HOST", http.request.uri.path) so the redirect
works for any sub-path.
# Dry-run (prints what would happen, does not touch the API mutating path):
bash .claude/skills/cultureflare-write/scripts/cf-redirect-create.sh \
agentculture.org culture.dev --www
# Apply for real:
bash .claude/skills/cultureflare-write/scripts/cf-redirect-create.sh \
agentculture.org culture.dev --www --apply
Flags:
--www — match both FROM_HOST and www.FROM_HOST. Use when the
zone has both apex and www. DNS records.
--status=N — HTTP status code for the redirect. Defaults to 301
(permanent, SEO-safe). --status=302 for testing. The = is
required; --status 302 (space-separated) is not accepted.
--apply — actually POST. Without this, the script is a dry-run.
--json — emit the raw CloudFlare response envelope instead of
markdown. Works in both dry-run (simulated body) and --apply
(real response) modes.
Exit codes: 0 success (dry-run or apply), 1 API error / already
exists / zone not found, 2 usage error (missing args, unknown flag).
Prerequisites for the redirect to actually fire
The redirect rule only runs if traffic reaches CloudFlare's edge.
That means FROM_HOST must have proxied DNS records (A / AAAA /
CNAME, orange-cloud in the dashboard). Check with the read skill
before applying:
If every record is "—" (DNS-only) or the zone has no apex record,
the redirect won't fire. Use cultureflare dns create / cf-dns-create.sh
(below) to add the apex and www records first, then create the
redirect.
cf-redirect-delete.sh
Deletes a single Single Redirect rule from a zone's
http_request_dynamic_redirect ruleset — the rule whose expression
matches FROM_HOST. A zone's redirect ruleset can hold many rules
(e.g. www→apex plus several subdomain 301s); this removes one and
leaves the rest untouched. It never deletes the whole ruleset.
# Dry-run (resolves the rule, prints what it would DELETE):
bash .claude/skills/cultureflare-write/scripts/cf-redirect-delete.sh \
culture.dev agex.culture.dev
# Apply for real:
bash .claude/skills/cultureflare-write/scripts/cf-redirect-delete.sh \
culture.dev agex.culture.dev --apply
ZONE is the zone whose ruleset holds the rule (e.g. culture.dev),
which is not the same as FROM_HOST when FROM_HOST is a
subdomain (agex.culture.dev). Both are explicit positionals — nothing
is inferred. The rule is matched on the anchored token
http.host eq "FROM_HOST", so a wildcard www rule or a different
subdomain's rule is never caught by accident.
Flags:
--apply — actually DELETE. Without it, the script is a dry-run.
--json — raw CloudFlare response envelope (the updated ruleset on
apply, a simulated body in dry-run).
Exit codes: 0 success (dry-run or apply), 1 zone / ruleset / rule
not found or ambiguous match or API error, 2 usage error.
Idempotent teardown: if no rule matches FROM_HOST (already removed),
the script exits 1 with "nothing to delete" rather than mutating
anything. Uses the same Zone · Single Redirect · Edit scope as
cf-redirect-create.sh — CF's "Edit" permission covers DELETE, so no
token upgrade is needed.
cf-dns-create.sh / cultureflare dns create
Creates a DNS record in a zone. Same safety model as
cf-redirect-create.sh: dry-run by default, --apply to commit,
idempotency enforced before the POST.
# Python CLI (primary):
cultureflare dns create agentculture.org A agentculture.org 192.0.2.1 --proxied
cultureflare dns create agentculture.org A agentculture.org 192.0.2.1 --proxied --apply
# Bash fallback:
# Canonical setup for a redirect-only zone — apex + www, both proxied.
# 192.0.2.1 is TEST-NET-1; CF intercepts at the edge before forwarding,
# so the origin IP is irrelevant for a pure-redirect zone.
bash .claude/skills/cultureflare-write/scripts/cf-dns-create.sh \
agentculture.org A agentculture.org 192.0.2.1 --proxied --apply
bash .claude/skills/cultureflare-write/scripts/cf-dns-create.sh \
agentculture.org A www.agentculture.org 192.0.2.1 --proxied --apply
Flags:
--proxied — orange-cloud the record so CF intercepts HTTP
traffic. Required for Single Redirects to fire on the record's
hostname.
--ttl=N — TTL in seconds. Default 1 (automatic). Manual TTLs
must be in 60..86400. Proxied records are forced to 1 by CF;
combining --proxied with --ttl=N (N≠1) is rejected up-front.
--comment=STR — free-text note attached to the record (visible
in the CF dashboard).
--apply — actually POST. Without this, the script is a dry-run.
--json — raw CloudFlare response envelope, same shape as the
read skill's --json output.
Supported record types: A, AAAA, CNAME, TXT, MX, NS, SRV, CAA.
Extend the case statement in the script if you need PTR / URI /
TLSA / etc.
Idempotency key: type + name + content. Two A records at the
same name with different IPs are allowed (CF supports round-robin);
two records with identical type+name+content are refused as
duplicates.
cf-dns-delete.sh
Deletes a DNS record from a zone, resolved by record name. Same
safety model as the create scripts: dry-run by default, --apply to
commit. Refuses ambiguous matches instead of guessing.
ZONE is the zone the record lives in (e.g. culture.dev); NAME is
the fully-qualified record name (e.g. agex.culture.dev). If a name
resolves to more than one record (e.g. an A + AAAA pair, or
round-robin A records), the script lists the candidates and exits 1 —
narrow the match with --type and/or --content.
Flags:
--type=TYPE — narrow to a record type (A, AAAA, CNAME, TXT,
MX, NS, SRV, CAA).
--content=VALUE — narrow to records with this exact content.
--apply — actually DELETE. Without it, the script is a dry-run.
--json — raw CloudFlare response envelope (or simulated body in
dry-run).
Exit codes: 0 success, 1 zone not found / no match / ambiguous
match / API error, 2 usage error. Uses the same Zone · DNS · Edit
scope as cf-dns-create.sh — DELETE needs no extra token scope.
cf-pages-project-create.sh
Creates a Cloudflare Pages project in either GitHub-connected
mode (default) or Direct Upload mode (with --direct-upload).
Mirrors the build + deployment settings of an existing project when
given --clone-from=PROJECT, so "spin up a second project with the
same style as culture-dev" is one flag, not a dozen.
Direct Upload vs GitHub-connected — with --direct-upload the
project has no source field, no GitHub App dependency, and
deployments come from wrangler pages deploy / CF's direct-upload
API rather than auto-builds. That's the mode used by the
culture.dev/NAME sub-site pattern (agex, citation-cli, afi) where
the consumer repo's own CI builds the site and uploads the finished
bundle. See references/subpath-site-pattern.md.
# Dry-run — shows the full POST body we would send, including the
# build_config / deployment_configs lifted from culture-dev:
bash .claude/skills/cultureflare-write/scripts/cf-pages-project-create.sh \
culture agentculture culture --clone-from=culture-dev
# Apply for real:
bash .claude/skills/cultureflare-write/scripts/cf-pages-project-create.sh \
culture agentculture culture --clone-from=culture-dev --apply
# Direct Upload variant (agex / citation-cli / afi style — no source):
bash .claude/skills/cultureflare-write/scripts/cf-pages-project-create.sh \
afi --direct-upload --compatibility-date=2026-04-20 --apply
Positional args:
NAME — Pages project name, becomes NAME.pages.dev. Must be
1-58 chars, lowercase alphanumeric + hyphens, no leading/trailing
hyphen. Enforced locally before the POST.
GITHUB_OWNER — GitHub org or user that owns the repo
(e.g. agentculture). Only with the default GitHub-connected
mode. Omit under --direct-upload.
REPO_NAME — repository name within that owner (e.g. culture).
Same rule — omit under --direct-upload.
Flags:
--direct-upload — create a Direct Upload project (no source
field in the POST body; deployments happen via wrangler pages deploy or direct-upload API calls). Positional args collapse to
NAME only; passing OWNER / REPO alongside is an error.
--clone-from=PROJECT — copy build_config, deployment_configs,
and production_branch from an existing Pages project in the
same account. Works with or without --direct-upload. Individual
overrides (below) still win.
--production-branch=BRANCH — git branch that produces
production deployments. Default main (or the cloned value).
--build-command=CMD — shell command CF runs to build the site.
--destination-dir=DIR — path CF uploads (relative to root-dir).
--root-dir=DIR — repo subdirectory to build from. "" (the
default) means the repo root.
--compatibility-date=YYYY-MM-DD — applied to both preview and
production deployment configs.
--build-image-version=N — 1, 2, or 3 (default 3 = latest).
--env-var=KEY=VALUE — set a deployment environment variable on
both the preview and production deployment configs. Repeatable
— pass it once per variable. KEY must match
[A-Za-z_][A-Za-z0-9_]*; a value missing the inner = is a usage
error. Used by the katvan cutover for JEKYLL_ENV=production and
RUBY_VERSION=3.3. --clone-from does not copy env vars —
they are always explicit.
--apply — actually POST. Without it, dry-run.
--json — raw CloudFlare response envelope (or simulated body in
dry-run).
Idempotency: scripts refuses to proceed if NAME already exists in
the account. Pick a different name or delete the existing project.
GitHub App prerequisite. The apply path calls
POST /accounts/:id/pages/projects with the GitHub owner and
repo_name. For this to succeed the Cloudflare Pages GitHub App
must be installed on GITHUB_OWNER with access to REPO_NAME. If
the dashboard UI couldn't list the repo when connecting manually,
the API call will fail for the same underlying reason — the script
surfaces the CF error and exits 1. The fix is a one-time
installation/authorization step in the GitHub org admin UI, not
something this skill can automate.
Custom domains (including apex mappings like culture.dev) are not
attached by this script; use cf-pages-domain-add.sh /
cf-pages-domain-remove.sh (below) for that.
cf-worker-create.sh
Uploads a Cloudflare Worker script from a local file via the
multipart PUT /accounts/:id/workers/scripts/:name endpoint.
# Dry-run (no mutation; prints metadata + a 20-line source preview):
bash .claude/skills/cultureflare-write/scripts/cf-worker-create.sh \
afi-proxy --from-file=/tmp/afi-proxy.js --compatibility-date=2026-04-20
# Apply:
bash .claude/skills/cultureflare-write/scripts/cf-worker-create.sh \
afi-proxy --from-file=/tmp/afi-proxy.js --compatibility-date=2026-04-20 --apply
Positional args:
NAME — Worker script name. 1-63 chars, lowercase alphanumeric
plus _-, no leading/trailing _ or -. Enforced locally.
Flags:
--from-file=PATH — local .js file to upload. Required.
--module — ES-module format (default). Metadata:
{"main_module":"worker.js", "compatibility_date": ...}.
Part content-type application/javascript+module.
--service-worker — legacy service-worker format. Metadata:
{"body_part":"script", "compatibility_date": ...}. Part
content-type application/javascript. Mutually exclusive with
--module.
--compatibility-date=YYYY-MM-DD — pins the Workers runtime
version. Defaults to today (UTC). Match existing peer Workers to
keep behavior consistent — agex-proxy uses 2026-04-20.
--no-workers-dev — suppress the default post-upload
enable-workers.dev-subdomain step. By default (match agex-proxy /
citation-cli-proxy), the script does two writes: the multipart
PUT of the script, then a POST to
/accounts/{id}/workers/scripts/{name}/subdomain with
{enabled: true, previews_enabled: false}. The second POST is
necessary because CF's upload endpoint leaves the .workers.dev
subdomain disabled, unlike the dashboard. Use --no-workers-dev
for private Workers that should only run on Workers routes.
--apply — actually PUT+POST. Without it, dry-run.
--json — merged CloudFlare envelope
{success, result: {upload, subdomain}} (or simulated body in
dry-run).
Idempotency: pre-flight lists the account's Workers scripts and
refuses with exit 1 if NAME already exists. Overwriting an existing
Worker is a separate (future) cf-worker-update.sh responsibility.
cf-workers-route-create.sh
Creates a Workers route on a zone — binds a Worker script to a URL
pattern. The route sits at
POST /zones/:zone_id/workers/routes with body
{pattern, script}.
ZONE — zone name (e.g. culture.dev). Resolved to id via the
usual cf_api_paginated /zones lookup.
PATTERN — CF Workers pattern, scheme-less (culture.dev/afi*,
not https://culture.dev/afi*). Quote it in the shell so the
globbing doesn't eat the *. Scheme-prefixed patterns are
rejected up-front.
SCRIPT — name of an existing Workers script (per
cf-worker-create.sh naming rules).
Flags:
--apply — actually POST. Without it, dry-run.
--json — raw CloudFlare response envelope.
Idempotency: pre-flight lists the zone's routes and refuses with
exit 1 if a route with the identical {pattern, script} pair
already exists. Different scripts mapped to the same pattern (or the
same script on different patterns) are allowed — only exact dupes
are refused.
cf-pages-domain-add.sh
Binds a custom domain to a Cloudflare Pages project — POST /accounts/:id/pages/projects/:project/domains with body
{"name": DOMAIN}. Pre-flight lists the project's existing custom
domains, which also confirms the project exists (a missing project
surfaces CloudFlare's structured error) and enforces idempotency
(refuses if DOMAIN is already attached).
# Dry-run — prints the would-POST body, no mutation:
bash .claude/skills/cultureflare-write/scripts/cf-pages-domain-add.sh \
katvan culture.dev
# Apply for real:
bash .claude/skills/cultureflare-write/scripts/cf-pages-domain-add.sh \
katvan culture.dev --apply
Positional args:
PROJECT — Pages project name (resolved via its /domains list).
DOMAIN — the custom domain / hostname to attach.
Flags:
--apply — actually POST. Without it, dry-run.
--json — raw CloudFlare response envelope (or simulated body in
dry-run).
Exit codes: 0 success (dry-run or apply); 1 account id missing /
project not found / domain already attached / API error; 2 usage
error.
cf-pages-domain-remove.sh
Detaches a custom domain from a Cloudflare Pages project — DELETE /accounts/:id/pages/projects/:project/domains/:domain. Pre-flight
lists the project's custom domains; refuses with exit 1 if DOMAIN
is not attached (a silent no-op would hide a typo). This is the
step that can take a production domain dark, so the dry-run banner
names the project and domain explicitly.
# Dry-run — prints the would-DELETE URL, no mutation:
bash .claude/skills/cultureflare-write/scripts/cf-pages-domain-remove.sh \
culture-dev culture.dev
# Apply for real:
bash .claude/skills/cultureflare-write/scripts/cf-pages-domain-remove.sh \
culture-dev culture.dev --apply
Positional args:
PROJECT — Pages project name (resolved via its /domains list).
DOMAIN — the custom domain / hostname to detach.
Flags:
--apply — actually DELETE. Without it, dry-run.
--json — raw CloudFlare response envelope (or simulated body in
dry-run).
Exit codes: 0 success (dry-run or apply); 1 account id missing /
project not found / domain not attached / API error; 2 usage
error.
Moving a custom domain between projects (the culture.dev →
katvan cutover): CloudFlare lets a hostname be a custom domain on
only one Pages project at a time, so the move is
cf-pages-domain-remove.sh OLD DOMAIN --apply then
cf-pages-domain-add.sh NEW DOMAIN --apply, run back-to-back. See
docs/superpowers/specs/2026-05-15-culture-dev-katvan-cutover-design.md.
3.3 cf-pages-deployment-delete.sh
Deletes a single Pages deployment by SHORT_ID (8-char prefix) or
full UUID. Dry-run by default; --apply to commit.
# Inventory first (read skill) to pick a short_id:
bash .claude/skills/cultureflare/scripts/cf-pages.sh agentirc-dev
# Dry-run:
bash .claude/skills/cultureflare-write/scripts/cf-pages-deployment-delete.sh \
agentirc-dev 66aaccee
# Apply for real:
bash .claude/skills/cultureflare-write/scripts/cf-pages-deployment-delete.sh \
agentirc-dev 66aaccee --apply
The canonical (aliased) deployment is protected by default — it
is whatever <project>.pages.dev currently serves, so deleting it
without replacement breaks the site. If the target is canonical, the
script exits 1 with a refusal message. Override with
--force-canonical, which maps to ?force=true on the CF DELETE
endpoint.
Bulk-deletes Pages deployments in a project. Reach for this when a
project has accumulated hundreds of historical deployments
(issue #1: agentirc-dev had 138).
"Delete all of them" is one typo from an outage, so the apply path is
gated by a three-phase manifest workflow nothing else in this
skill uses:
Plan — the default invocation (no --apply) writes a manifest
file to ./.cf-purge-manifests/<ts>-<project>.md listing every
deployment it could delete. Each row is a GFM task-list item
starting - [ ]; at the bottom sits a canary row whose random
22-char alnum string also lives in the canary: header. No API
mutations happen. The manifest directory is gitignored at the repo
root.
Tick + sign — open the manifest, read each row, and change
- [ ] to - [x] for every deployment you actually want deleted.
Leave the canary row under ## Canary untouched. Then append
exactly one signature line at the bottom:
Example: SIGNED: ori 2026-04-22T14:10:00Z. The signature must be
within CF_PURGE_SIG_TTL seconds (default 3600) of apply-time.
Apply — re-run the script with --manifest <path> --apply.
Before any DELETE fires, the script:
validates the v2 header, canary: field, ids_sha256, and
project + account match,
validates the SIGNED: line (exactly one, well-formed, fresh),
verifies the canary row is present exactly once, its string
matches the header, and its checkbox is untouched (the
"sed-replace all [ ] with [x]" shortcut ticks the canary too
and aborts the whole apply),
refuses if no deployment boxes are ticked — that implies the
operator forgot step 2, not that they want a no-op,
re-fetches live state and rejects on drift (any new
non-canonical deployment added since signing), and
skips any ticked ids that are already gone (idempotent re-runs).
An <manifest>.applied.log is written next to the manifest with
per-id outcomes and final counts — permanent audit trail rather than
stdout-only.
Flags:
--include-canonical — (plan only) include the canonical
deployment in the manifest. The flag is recorded in the manifest
header so the operator signs the canonical-inclusion decision
explicitly. (Apply unconditionally sends ?force=true on every
DELETE since CF Pages marks canonical and every per-branch
preview as aliased; ?force=true is a no-op on unaliased
deployments. The per-row tick is the real consent gate.)
--manifest PATH — (apply only) path to the signed manifest.
--manifest-dir DIR — (plan only) override the default output
directory (./.cf-purge-manifests).
--apply — actually DELETE. Requires --manifest.
--continue-on-error — on a failed DELETE, keep going instead of
halting. The exit code is still non-zero if any DELETE failed.
--json — structured envelope for both plan and apply phases.
CF_PURGE_SIG_TTL / CF_PURGE_SLEEP — env knobs for signature
TTL and inter-delete pacing. CF_PURGE_SLEEP=0 disables the
default 250ms pacing (used by the test suite).
Exit codes: 0 plan wrote manifest (or "nothing to delete") / apply
completed with zero failures; 1 API error / manifest validation
failure / signature invalid / drift detected / any failed DELETE;
2 usage error.
Why tick + canary on top of signing? The signed-manifest layer
answers "is this approval fresh and matching live state?"; the
tick + canary layer answers "which items did the operator actually
pick, and did they review each one?"
Forces the operator to mark each deployment individually —
the approval granularity is per row, not per manifest.
The canary is randomly generated at plan time and its string
must match both the header and the list row, so a "tick the
entire manifest" shortcut (regex-replace or a distracted
sed -i) also ticks the canary, which aborts apply with zero
DELETEs. Per-line review is enforced, not just expected.
Reviewable artifact — the manifest is a concrete diff a peer
can read and challenge before signing; pairs with
one-agent-plans-another-agent-signs.
Time-boxed — a 60-minute-old signature is rejected, so stale
approvals can't be replayed days later.
Drift-aware — a new non-canonical deployment appearing
between plan and apply causes a hard reject, not a silent skip.
This pattern (per-line tick + canary) is the repo-wide convention
for any future bulk-destructive script, not just this one. New
cf-*-delete.sh / cf-*-purge.sh scripts should follow the same
manifest shape.
3.5 Sub-site pattern on culture.dev (agex-style)
The culture.dev/agex, culture.dev/citation-cli, and
culture.dev/afi sub-sites share a single three-resource pattern:
a Direct Upload Pages project NAME (see §cf-pages-project-create.sh);
a WorkerNAME-proxy rendered from
templates/subpath-proxy.js (see §cf-worker-create.sh);
a Workers routeculture.dev/NAME* → NAME-proxy
(see §cf-workers-route-create.sh).
For the render-and-deploy recipe, zehut/shushu onboarding, and the
three-URL verification checklist, read the full reference at
references/subpath-site-pattern.md.
3.6 Templates and references
templates/subpath-proxy.js — subpath-proxy Worker source, two
placeholders (__SUBPATH__, __UPSTREAM__). Derived from the
live agex-proxy script and kept deliberately small so updates
stay reviewable. Render with sed before feeding to
cf-worker-create.sh.
references/subpath-site-pattern.md — architecture note for the
culture.dev/NAME sub-site pattern plus a step-by-step recipe for
standing up a new one.
references/cf-api-gotchas.md — consolidated index of CF API
quirks we've paid for (Pages per_page cap, subdomain auto-suffix,
Workers multipart filename matching, Workers subdomain endpoint
method, Workers subdomain default, zone-scope 10000). Read this
before your first live --apply against a surface you haven't
written to before.
4. Output modes
Default markdown (cf_output_kv for the result block):
Dry-run prefixes with **Dry-run — no changes applied** and shows
the would POST JSON body.
--json passes the CloudFlare response envelope
({success, errors, messages, result}) through unchanged — same
shape as the read skill's --json output for consistency with
downstream jq pipelines.
5. What this skill does NOT do yet
Updates (PUT). No cf-*-update.sh scripts — resources either
exist (keep them) or don't (create new).
Deleting the Pages project itself.cf-pages-deployments-purge.sh
deletes every deployment but leaves the zero-deployment project
behind. A future cf-pages-project-delete.sh will land as a
separate, smaller PR.
Workers / zone deletion. Still Phase 3 territory; new
cf-*-delete.sh scripts can follow the same dry-run-by-default
pattern. (DNS-record deletion now exists — cf-dns-delete.sh; and
single redirect-rule deletion — cf-redirect-delete.sh.) Whether to
re-use the manifest gate depends on blast radius — a single DNS
record rarely warrants it; bulk route deletion probably does.
Account-wide rulesets. This skill only creates zone-level
rulesets. Account-level rulesets (applied across many zones) are
out of scope.
Bulk Redirects. For one-host-to-one-host redirects, Single
Redirects are simpler and cheaper. Bulk Redirects (lists of
URL-to-URL mappings) will get their own script if we ever need one.
6. Adding new write scripts
Follow the pattern cf-redirect-create.sh sets:
Parse args with the same for arg in "$@"; case "$arg" in … esac
loop shape used by read scripts. Support --apply, --json,
-h/--help, plus script-specific flags.
source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh" — symlink
resolves to the read skill's copy.
Resolve names to IDs first with cf_api_paginated and exit 1
with a clear message if the name doesn't match anything.
Pre-flight idempotency check with cf_api (GET) against the
list endpoint for the resource you're about to create / modify.
Exit 1 if a matching resource already exists.
Build the mutating request body as JSON with jq -n --arg … '…'
(never string concatenation — injection risk).
Gate the mutating call on "$apply" == "1". In dry-run, print the
body and exit 0. In apply, cf_api "$path" -X POST --data "$body"
(or PUT / DELETE) and render the response.
Add a bats file under tests/bats/ covering dry-run, apply,
idempotency, name resolution, unknown flag, and the --json
passthrough. Fixtures go in tests/fixtures/.
Run bash tests/shellcheck.sh, bash tests/markdownlint.sh, and
bats tests/bats/ before committing.