diff --git a/.cursor/skills/verify-cf/SKILL.md b/.cursor/skills/verify-cf/SKILL.md new file mode 100644 index 0000000..66880e9 --- /dev/null +++ b/.cursor/skills/verify-cf/SKILL.md @@ -0,0 +1,107 @@ +--- +name: verify-cf +description: "Drive the Cloudflare CLI (`cf`) the way a user/agent does — launch deps, doctor health, run real commands with `--json`, and capture exit/stdout/stderr proof. Use when verifying conduit-ui/cloudflare behavior, agent JSON envelopes, setup, zones, DNS, or tunnels." +--- + +# verify-cf + +Project-local verification for the **Cloudflare management CLI** (`./cf`). Primary surface is the terminal. There is no local HTTP server, database, or long-lived daemon. Cloudflare's HTTP API is the production boundary; live network calls need `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. Without credentials, only missing-creds / setup-validation journeys are driveable against the real binary. + +Helper (always prefer this over ad-hoc paths): + +```bash +.cursor/skills/verify-cf/bin/verify-cf +``` + +Evidence root: `.cursor/skills/verify-cf/artifacts//` (survives cleanup). Set `CF_VERIFY_RUN_ID` to pin a run id across commands. + +## Launch + +No server to keep alive. Launch means: PHP 8.3+, Composer deps installed, `./cf` executable and advertising commands. + +```bash +export CF_VERIFY_RUN_ID="${CF_VERIFY_RUN_ID:-$(date +%Y%m%dT%H%M%S)-$$}" +.cursor/skills/verify-cf/bin/verify-cf launch +# ready marker: "verify-cf ready repo=... run=... evidence=..." +``` + +Teardown for isolate trees only: + +```bash +.cursor/skills/verify-cf/bin/verify-cf cleanup +``` + +Cleanup never deletes evidence under `artifacts/`. + +## Doctor + +Read-only. Run first whenever anything looks off: + +```bash +.cursor/skills/verify-cf/bin/verify-cf doctor +``` + +Worth driving only when exit `0` and output includes `vendor=ok`, `entrypoint=ok`, `cf_list=ok`. `live_creds=present` means zones/DNS/tunnel live journeys may proceed; `live_creds=absent` means stick to missing-creds / setup failure journeys (or Pest mocks for offline CI — not a substitute for a mapped live path). + +## Drive + +Harness: the real `./cf` process via the helper. Always pass `--json` for agent-facing commands (see `docs/agent-output.md`). Prefer process env for credentials over writing the project `.env`. + +```bash +.cursor/skills/verify-cf/bin/verify-cf drive zones --json +# prints: exit=N stdout=... stderr=... meta=... +``` + +For `setup` (writes `.env`), **never** use the developer's checkout `.env`. Create an isolate first: + +```bash +ISOLATE="$(.cursor/skills/verify-cf/bin/verify-cf isolate-setup)" +# or: export CF_BIN="$(cat artifacts//isolate_home.txt)/cf" after isolate-setup +export CF_BIN="$(.cursor/skills/verify-cf/bin/verify-cf isolate-setup | sed -n 's/^isolate=//p')/cf" +.cursor/skills/verify-cf/bin/verify-cf drive setup --token=bad --account-id=bad --non-interactive --json +``` + +Stable handles: command names (`zones`, `dns:list`, `setup`), flags (`--json`, `--non-interactive`, `--force`), and JSON keys (`ok`, `data`, `error`). Do not scrape human tables when `--json` is available. + +`setup --json` uses a different success shape (`success` / `message` / `account_id`), not `{ok,data}` — see feature map. + +## Evidence + +Proof lives under `.cursor/skills/verify-cf/artifacts//`: + +| File | Contents | +|------|----------| +| `*.stdout` | Full stdout (JSON envelopes) | +| `*.stderr` | Full stderr | +| `*.meta.json` | `exit_code` + argv | + +Proof standards: + +1. Exercise the real `./cf` user path (not `Artisan::call` inside Pest) for skill-driven proofs. +2. Capture the command (meta argv), the exit code, and the resulting stdout/stderr — not only a summary. +3. For mutations (DNS/tunnel create/delete, successful setup), follow with a second read (`dns:list`, `zones`, or re-read isolate `.env`) that shows the side effect. +4. Mocks belong in Pest (`composer test`) at the Saloon/Cloudflare HTTP boundary. Do not mock inside a verify-cf drive. Live API failures with valid-looking creds are real failures. +5. When `live_creds=absent`, report live journeys as unmet preconditions — do not weaken them to a mocked Pest test and call them verified. + +## Cleanup + +```bash +.cursor/skills/verify-cf/bin/verify-cf cleanup +``` + +Removes only `/tmp/cf-verify-$CF_VERIFY_RUN_ID` (or `CF_VERIFY_HOME`) created by `isolate-setup`. Does not kill by process name (nothing long-lived). Does not delete `artifacts/`. + +## Helpers + +| Invocation | Purpose | +|------------|---------| +| `bin/verify-cf launch` | Install deps if needed; assert `cf list` usable | +| `bin/verify-cf doctor` | Read-only health | +| `bin/verify-cf drive ` | Run `./cf` + capture evidence | +| `bin/verify-cf isolate-setup` | Disposable tree + vendor symlink for setup | +| `bin/verify-cf evidence-dir` | Print current evidence directory | +| `bin/verify-cf cleanup` | Drop isolate home; keep evidence | + +## Feature map + +See [`features/README.md`](features/README.md). Drive the mapped entry points there; a proof that uses one convenient command is incomplete when the map lists others for that feature. diff --git a/.cursor/skills/verify-cf/artifacts/.gitignore b/.cursor/skills/verify-cf/artifacts/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/.cursor/skills/verify-cf/artifacts/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/.cursor/skills/verify-cf/bin/verify-cf b/.cursor/skills/verify-cf/bin/verify-cf new file mode 100755 index 0000000..d685dc6 --- /dev/null +++ b/.cursor/skills/verify-cf/bin/verify-cf @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Helpers for the verify-cf skill. Invoke from anywhere; resolves the repo root. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# .cursor/skills/verify-cf → repository root +REPO_ROOT="$(cd "$SKILL_DIR/../../.." && pwd)" + +CF="${CF_BIN:-$REPO_ROOT/cf}" +EVIDENCE_ROOT="${CF_VERIFY_EVIDENCE:-$SKILL_DIR/artifacts}" +RUN_ID="${CF_VERIFY_RUN_ID:-$(date +%Y%m%dT%H%M%S)-$$}" +RUN_DIR="$EVIDENCE_ROOT/$RUN_ID" +ISOLATE_ROOT="${CF_VERIFY_HOME:-/tmp/cf-verify-$RUN_ID}" + +usage() { + cat < [args] + +Commands: + launch Ensure PHP/Composer deps; print ready marker + doctor Read-only health check (exit 0 only if driveable) + drive Run ./cf with args; capture exit/stdout/stderr under artifacts + evidence-dir Print this run's evidence directory + cleanup Remove isolate home created by this run (keeps evidence) + isolate-setup Create disposable copy for setup/.env mutation proofs + +Env: + CF_BIN Path to cf entrypoint (default: /cf) + CF_VERIFY_EVIDENCE Evidence root (default: skill artifacts/) + CF_VERIFY_RUN_ID Stable run id (default: timestamp-pid) + CF_VERIFY_HOME Disposable home for isolate-setup + CLOUDFLARE_API_TOKEN Optional live credentials (process env preferred) + CLOUDFLARE_ACCOUNT_ID Optional live credentials +EOF +} + +ensure_run_dir() { + mkdir -p "$RUN_DIR" +} + +cmd_launch() { + if ! command -v php >/dev/null; then + echo "doctor-fail: php not on PATH" >&2 + exit 1 + fi + php_ver="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')" + if [[ "$(printf '%s\n' "$php_ver" "8.3" | sort -V | head -n1)" != "8.3" ]]; then + echo "doctor-fail: need PHP >= 8.3 (got $php_ver)" >&2 + exit 1 + fi + if [[ ! -f "$REPO_ROOT/vendor/autoload.php" ]]; then + (cd "$REPO_ROOT" && composer install --no-interaction) + fi + if [[ ! -x "$CF" && -f "$CF" ]]; then + chmod +x "$CF" + fi + if [[ ! -f "$CF" ]]; then + echo "doctor-fail: missing cf entrypoint at $CF" >&2 + exit 1 + fi + # Ready = usable CLI, not merely that PHP starts. + out="$("$CF" list 2>&1)" || { + echo "doctor-fail: cf list failed" >&2 + echo "$out" >&2 + exit 1 + } + if ! grep -qE '^[[:space:]]*zones[[:space:]]' <<<"$out"; then + echo "doctor-fail: cf list did not advertise zones" >&2 + echo "$out" >&2 + exit 1 + fi + ensure_run_dir + echo "verify-cf ready repo=$REPO_ROOT run=$RUN_ID evidence=$RUN_DIR" +} + +cmd_doctor() { + local fail=0 + echo "repo=$REPO_ROOT" + echo "cf=$CF" + echo "run_id=$RUN_ID" + echo "evidence=$RUN_DIR" + if ! command -v php >/dev/null; then + echo "php=MISSING"; fail=1 + else + echo "php=$(php -v | head -n1)" + fi + if [[ -f "$REPO_ROOT/vendor/autoload.php" ]]; then + echo "vendor=ok" + else + echo "vendor=MISSING"; fail=1 + fi + if [[ -f "$CF" ]]; then + echo "entrypoint=ok" + else + echo "entrypoint=MISSING"; fail=1 + fi + if list_out="$("$CF" list 2>&1)"; then + if grep -qE '^[[:space:]]*zones[[:space:]]' <<<"$list_out"; then + echo "cf_list=ok" + else + echo "cf_list=UNEXPECTED"; fail=1 + fi + else + echo "cf_list=FAIL"; fail=1 + fi + if [[ -n "${CLOUDFLARE_API_TOKEN:-}" && -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]]; then + echo "live_creds=present" + # Do not treat API failure as doctor failure — token may be invalid; drive journeys decide. + if zones_out="$(env CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CLOUDFLARE_ACCOUNT_ID" "$CF" zones --json 2>&1)"; then + echo "zones_json=ok" + else + echo "zones_json=error" + echo "zones_json_body=$(echo "$zones_out" | tr '\n' ' ' | head -c 200)" + fi + else + echo "live_creds=absent (read-only missing-creds journeys only)" + fi + # Refuse to claim ownership of a foreign long-lived process: this CLI has none. + echo "isolation=process-local (no daemon); setup mutations require isolate-setup" + exit "$fail" +} + +cmd_drive() { + if [[ $# -lt 1 ]]; then + echo "drive requires cf arguments, e.g. verify-cf drive zones --json" >&2 + exit 2 + fi + ensure_run_dir + local stamp slug + stamp="$(date +%H%M%S)" + slug="$(printf '%s' "$*" | tr -c 'A-Za-z0-9._:-' '_' | cut -c1-80)" + local base="$RUN_DIR/${stamp}_${slug}" + local stdout_f="${base}.stdout" + local stderr_f="${base}.stderr" + local meta_f="${base}.meta.json" + set +e + "$CF" "$@" >"$stdout_f" 2>"$stderr_f" + local code=$? + set -e + python3 - "$meta_f" "$code" "$CF" "$@" <<'PY' +import json, sys +meta_path = sys.argv[1] +code = int(sys.argv[2]) +argv = sys.argv[3:] +with open(meta_path, "w", encoding="utf-8") as f: + json.dump({"exit_code": code, "argv": argv}, f, indent=2) + f.write("\n") +PY + echo "exit=$code stdout=$stdout_f stderr=$stderr_f meta=$meta_f" + return "$code" +} + +cmd_isolate_setup() { + # Disposable tree so setup can write .env without touching the developer's project .env + rm -rf "$ISOLATE_ROOT" + mkdir -p "$ISOLATE_ROOT" + # Copy app sources; share vendor via symlink to keep isolate cheap. + for item in app bootstrap config cf composer.json composer.lock box.json; do + if [[ -e "$REPO_ROOT/$item" ]]; then + cp -a "$REPO_ROOT/$item" "$ISOLATE_ROOT/" + fi + done + ln -s "$REPO_ROOT/vendor" "$ISOLATE_ROOT/vendor" + chmod +x "$ISOLATE_ROOT/cf" + ensure_run_dir + echo "$ISOLATE_ROOT" >"$RUN_DIR/isolate_home.txt" + echo "isolate=$ISOLATE_ROOT" +} + +cmd_cleanup() { + if [[ -d "$ISOLATE_ROOT" ]]; then + rm -rf "$ISOLATE_ROOT" + echo "removed isolate $ISOLATE_ROOT" + else + echo "no isolate at $ISOLATE_ROOT" + fi + # Never delete evidence. + echo "evidence retained at $RUN_DIR" +} + +cmd_evidence_dir() { + ensure_run_dir + echo "$RUN_DIR" +} + +main() { + local cmd="${1:-}" + shift || true + case "$cmd" in + launch) cmd_launch "$@" ;; + doctor) cmd_doctor "$@" ;; + drive) cmd_drive "$@" ;; + isolate-setup) cmd_isolate_setup "$@" ;; + cleanup) cmd_cleanup "$@" ;; + evidence-dir) cmd_evidence_dir "$@" ;; + -h|--help|help|"") usage; [[ -n "$cmd" ]] || exit 2 ;; + *) echo "unknown command: $cmd" >&2; usage; exit 2 ;; + esac +} + +main "$@" diff --git a/.cursor/skills/verify-cf/features/README.md b/.cursor/skills/verify-cf/features/README.md new file mode 100644 index 0000000..54f19c2 --- /dev/null +++ b/.cursor/skills/verify-cf/features/README.md @@ -0,0 +1,46 @@ +# Cloudflare CLI (`cf`) verification map + +This directory is the maintained source for verifying user-facing behavior of `conduit-ui/cloudflare`. Read this index before driving the app, then use the matching feature file as the recipe. + +## Baseline preconditions + +- Repo root has PHP **>= 8.3** and a completed `composer install` (`vendor/autoload.php` present). +- Drive through `.cursor/skills/verify-cf/bin/verify-cf` with a pinned `CF_VERIFY_RUN_ID`. +- Run `bin/verify-cf doctor` and require `vendor=ok`, `entrypoint=ok`, `cf_list=ok`. +- Prefer process env `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` over the project `.env`. +- Never run mutating `setup` against the developer's checkout — use `isolate-setup` first. +- Never drive a long-lived foreign process: this CLI is one-shot per invocation. +- Concurrent drives are safe for read-only commands; remote Cloudflare mutations on a shared account can collide. + +## Driving conventions + +- Start every recipe from baseline unless its preconditions say otherwise. +- Always pass `--json` on agent-facing commands (`zones`, `dns:*`, `tunnel:*`). +- Treat every command as literal. Keep flags and argument order unchanged. +- Capture evidence with `bin/verify-cf drive ...` (stdout, stderr, meta). +- Restore or delete remote fixtures you create (DNS records, tunnels). Keep proof artifacts. + +## Proof and skip reporting + +- Capture the user action (argv + exit code) and the resulting stdout JSON. +- Mutation proof includes a second user-facing read of the changed state. +- Record the feature ID and entry point with every artifact (filename slug is enough). +- Report an unreachable path with the attempted command and the unmet precondition (e.g. `live_creds=absent`). +- Do not report a skipped live entry point as verified through Pest mocks. + +## Feature entry contract + +Each feature file starts with an H1 title and one paragraph describing the user-visible behavior. It then uses exactly four H2 sections in this order: + +1. `Sub-features` +2. `How to get to it (user POV)` +3. `Driving it with verify-cf` +4. `Gotchas` + +## Features + +- [Agent JSON / missing credentials](./agent-json.md) — stable `--json` failure envelope without creds. +- [Setup](./setup.md) — non-interactive credential onboarding (isolate tree). +- [Zones](./zones.md) — list zones with live API. +- [DNS list](./dns-list.md) — list DNS records for a zone. +- [Tunnel list](./tunnel-list.md) — list Cloudflare tunnels. diff --git a/.cursor/skills/verify-cf/features/agent-json.md b/.cursor/skills/verify-cf/features/agent-json.md new file mode 100644 index 0000000..654b026 --- /dev/null +++ b/.cursor/skills/verify-cf/features/agent-json.md @@ -0,0 +1,31 @@ +# Agent JSON / missing credentials + +When credentials are unset, agent-facing commands fail with a stable JSON envelope on stdout and exit code `1`, so Conduit agents can parse errors without scraping human tables. + +## Sub-features + +- `json-missing-creds` returns `{"ok":false,"error":"CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set"}` for `zones`. +- `json-exit-code` uses exit `1` on that failure (not `0`). +- `json-stdout-only` places the envelope on stdout (stderr may be empty). + +## How to get to it (user POV) + +- Run `./cf zones --json` with neither env var nor `.env` credentials set. +- An agent always passes `--json` (see `docs/agent-output.md`). + +## Driving it with verify-cf + +Preconditions: + +- `bin/verify-cf doctor` reports `cf_list=ok`. +- Process env has no `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` (or they are empty). +- Project `.env` either absent or those keys empty for this drive (override with `env -u` if needed). + +- **Clear creds and list zones.** Run `env -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_ACCOUNT_ID CF_VERIFY_RUN_ID=$CF_VERIFY_RUN_ID .cursor/skills/verify-cf/bin/verify-cf drive zones --json`. Exit code `1`. Stdout JSON has `ok: false`, no `data` key, and `error` exactly `CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set`. +- **Proof.** Keep the `*.stdout`, `*.stderr`, and `*.meta.json` files under `artifacts/$CF_VERIFY_RUN_ID/`. Meta `exit_code` must be `1`; stdout must decode as the failure envelope above. + +## Gotchas + +- `setup --json` uses `{success,message}` — not this `{ok,error}` envelope. Do not mix contracts. +- A populated project `.env` can hide missing process env. Use `env -u` or an isolate with no `.env`. +- Do not treat Pest's `Artisan::call('zones')` as verification of this skill path. diff --git a/.cursor/skills/verify-cf/features/dns-list.md b/.cursor/skills/verify-cf/features/dns-list.md new file mode 100644 index 0000000..9a8dea6 --- /dev/null +++ b/.cursor/skills/verify-cf/features/dns-list.md @@ -0,0 +1,30 @@ +# DNS list + +DNS list shows DNS records for a zone identified by zone ID or domain name. + +## Sub-features + +- `dns-list-json` returns `{ok:true,data:[...]}` for a zone. +- `dns-list-type-filter` narrows results with `--type=`. + +## How to get to it (user POV) + +- Run `./cf dns:list ` or `./cf dns:list --json`. +- Optionally add `--type=A` / `--name=...`. + +## Driving it with verify-cf + +Preconditions: + +- `live_creds=present`. +- A known zone name or ID from a prior `zones --json` drive (store it as `ZONE` for the run). If the account has zero zones, report unmet precondition — do not invent a zone. + +- **List records.** Run `bin/verify-cf drive dns:list "$ZONE" --json`. Exit `0`. Stdout JSON has `ok: true` and `data` as an array. +- **Optional type filter.** Run `bin/verify-cf drive dns:list "$ZONE" --type=A --json`. Exit `0`. Every returned record's `type` is `A` when `data` is non-empty. +- **Proof.** Keep both artifact sets; note `$ZONE` in the run. + +## Gotchas + +- Creating/updating/deleting DNS is out of this feature file — use dedicated journeys and clean up remote records. +- Zone argument accepts ID or domain; prefer the domain string from `zones` for readability. +- DNS:Edit is required for mutations; list needs read access to DNS for that zone. diff --git a/.cursor/skills/verify-cf/features/setup.md b/.cursor/skills/verify-cf/features/setup.md new file mode 100644 index 0000000..f0fda34 --- /dev/null +++ b/.cursor/skills/verify-cf/features/setup.md @@ -0,0 +1,33 @@ +# Setup + +Setup validates a Cloudflare API token against the live API and writes `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` into `.env` for later commands. + +## Sub-features + +- `setup-noninteractive-missing` fails fast when `--non-interactive` lacks `--token` or `--account-id`. +- `setup-bad-token` rejects an invalid token without writing usable credentials. +- `setup-success` (live) verifies the token and writes isolate `.env`. + +## How to get to it (user POV) + +- Run `./cf setup` interactively, or +- Run `./cf setup --token=... --account-id=... --non-interactive` (agents/CI), optionally with `--json`. + +## Driving it with verify-cf + +Preconditions: + +- `bin/verify-cf doctor` reports `cf_list=ok`. +- An isolate tree from `bin/verify-cf isolate-setup` (never the developer checkout). +- `CF_BIN` points at `$isolate/cf`. + +- **Missing token (non-interactive).** Run `bin/verify-cf drive setup --non-interactive --json`. Exit non-zero. Stdout/stderr mention that the token is required. +- **Bad token.** Run `bin/verify-cf drive setup --token=cf_invalid --account-id=00000000000000000000000000000000 --non-interactive --json`. Exit non-zero. Isolate `.env` must not contain `CLOUDFLARE_API_TOKEN=cf_invalid` as a successful save (file may be absent or unchanged from empty). +- **Success (live only).** When `live_creds=present`, run `bin/verify-cf drive setup --token="$CLOUDFLARE_API_TOKEN" --account-id="$CLOUDFLARE_ACCOUNT_ID" --non-interactive --json`. Exit `0`. Stdout JSON has `"success": true` and the account id. Re-read isolate `.env` and confirm both keys are present. +- **Proof.** Retain drive artifacts plus a copy of the isolate `.env` (redact token values in any shared log; keep the file under evidence only when needed). + +## Gotchas + +- Success path hits the live Cloudflare verify-token API — offline CI cannot complete `setup-success`. +- Writing `.env` in the main checkout overwrites developer credentials; always isolate. +- `setup --json` success shape is `{success,message,account_id}`, not `{ok,data}`. diff --git a/.cursor/skills/verify-cf/features/tunnel-list.md b/.cursor/skills/verify-cf/features/tunnel-list.md new file mode 100644 index 0000000..200aa3e --- /dev/null +++ b/.cursor/skills/verify-cf/features/tunnel-list.md @@ -0,0 +1,26 @@ +# Tunnel list + +Tunnel list shows Cloudflare Tunnels for the account. + +## Sub-features + +- `tunnel-list-json` returns `{ok:true,data:[...]}`. + +## How to get to it (user POV) + +- Run `./cf tunnel:list` or `./cf tunnel:list --json` after credentials are configured. + +## Driving it with verify-cf + +Preconditions: + +- `live_creds=present`. +- Token permits Cloudflare Tunnel read/edit as documented in the README. + +- **List tunnels.** Run `bin/verify-cf drive tunnel:list --json`. Exit `0`. Stdout JSON has `ok: true` and `data` as an array (may be empty). +- **Proof.** Retain artifacts; empty list with `ok: true` is valid for accounts with no tunnels. + +## Gotchas + +- `tunnel:create` / `tunnel:expose` / `tunnel:delete` mutate remote state and can print secrets — not covered here; clean up any tunnels you create in other journeys. +- Without live credentials, report unmet precondition. diff --git a/.cursor/skills/verify-cf/features/zones.md b/.cursor/skills/verify-cf/features/zones.md new file mode 100644 index 0000000..08cf275 --- /dev/null +++ b/.cursor/skills/verify-cf/features/zones.md @@ -0,0 +1,28 @@ +# Zones + +Zones lists Cloudflare zones for the configured account so a user can see which domains are manageable. + +## Sub-features + +- `zones-json` returns `{ok:true,data:[...]}` with zone objects. +- `zones-human` prints a table when `--json` is omitted (secondary; prefer JSON for agents). + +## How to get to it (user POV) + +- Run `./cf zones` or `./cf zones --json` after credentials are configured. + +## Driving it with verify-cf + +Preconditions: + +- `bin/verify-cf doctor` reports `live_creds=present` and preferably `zones_json=ok`. +- Process env provides `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. + +- **List zones as JSON.** Run `bin/verify-cf drive zones --json`. Exit `0`. Stdout JSON has `ok: true` and `data` as an array (may be empty for an account with no zones). +- **Proof.** Artifact stdout decodes; if the account has zones, at least one element includes `id` and `name`. Empty `data: []` with `ok: true` is a valid proof for an empty account — record that in the run notes. + +## Gotchas + +- Without live credentials, report unmet precondition — do not substitute Pest mocks. +- Token needs Zone:Read (see README permission table). +- Filter flag `--name=` exists; baseline journey is the unfiltered list.