Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .claude/commands/infra-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
description: "Shortcut that loads the infra-setup skill — agent-first, human-in-the-loop bootstrap of this Terraform + HCP infra repo."
allowed-tools: Read, Bash, Edit, Write, Glob, Grep, AskUserQuestion
---

# /infra-setup

Explicit entry point for the [`infra-setup`](../skills/infra-setup/SKILL.md) skill. This
command and the skill are **the same procedure, two surfaces** — the command is the
tab-completable trigger; the skill is what auto-loads and holds the logic. Either way you
end up executing `SKILL.md`, so there's no divergent behaviour to reconcile.

Load `../skills/infra-setup/SKILL.md` and drive it end-to-end:

1. **Resume first.** Walk `../skills/infra-setup/references/steps.yaml` and run each step's
`check` to find where setup already is. Report `✓` for green steps; resume at the first
red one. Never assume state from a previous session.
2. **Respect the actor split.** Execute `AGENT` steps yourself. On a `HUMAN` step, stop and
emit the handoff block, wait for `done`, then re-run the `check` before continuing —
never fake a signup, a dashboard click, or a secret paste.
3. **Route to the deep-dives** under `../skills/infra-setup/references/` and to the canonical
`docs/` sections for fine print. Don't duplicate them.
4. **Stop at the done signal** in SKILL.md and hand back to `CONTRIBUTING.md`.

If `$ARGUMENTS` names a phase or provider (e.g. `cloudflare`, `phase 3`), jump straight to
that phase after the resume scan.
192 changes: 192 additions & 0 deletions .claude/skills/infra-setup/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
---
name: infra-setup
description: "Agent-first, human-in-the-loop bootstrap for this Terraform + HCP infra repo. Use when setting up perishdev/infra (or a fork of it for another org) from scratch — wiring HCP Terraform state, the Cloudflare token, and the GitHub App, importing existing resources, and running the first plan. The agent drives end-to-end and pauses only for signups, credential minting, and secret pasting. Trigger on: 'set up infra', 'bootstrap this repo', 'run infra-setup', 'onboard a new org', '/infra-setup'."
---

# infra-setup

Agent-runnable bootstrap for this repo. You (the agent) execute it end-to-end, pausing
only for the steps a human irreducibly must do. Keep this file as the **router**: it
carries the protocol (actor model, handoff, resume) and routes to `references/` for the
per-provider detail and to the repo's canonical `docs/` for the fine print.

> **This is a Skill, not a script.** There is no `infra-setup` executable. Drive it by
> reading this file and the manifest, then running each step. A human invokes it with
> `/infra-setup` or by saying "set up the infra."

> **Orchestrates, does not duplicate.** Authoritative per-step detail lives in
> [`docs/setup.md`](../../../docs/setup.md) (bootstrap) and [`docs/import.md`](../../../docs/import.md)
> (Cloudflare migration). This skill adds the *actor split* and *resume protocol* on top.
> When a step says "see `setup.md#3`", read that section.

## The one idea

**The human is the browser and the keyholder. Everything scriptable is the agent's.**

There is exactly one thing a human must do that an agent cannot: sit at a browser, sign
up for a SaaS, and mint the first credential. Once an HCP token exists (from
`terraform login`), the agent creates workspaces, sets variables, reads plans, and
confirms applies **over the HCP API** — no more clicking. So the human surface shrinks to
three action kinds:

1. **Sign up** for a service (browser-only).
2. **Mint a credential** in a dashboard (browser-only — no API bootstraps the first token).
3. **Paste a secret** into HCP (browser-only — the agent must never see the plaintext).

Everything else — verifying, creating workspaces, importing resources, first plan — is yours.

## Actors

Every step in [`references/steps.yaml`](references/steps.yaml) is tagged with who performs it:

| Tag | Meaning | Your behaviour |
|---|---|---|
| **`AGENT`** | You run it (shell, `gh`, `terraform`, HCP API). | Execute. Verify with the step's `check`. Continue on green. |
| **`HUMAN`** | Irreducibly human (signup, dashboard, secret paste). | **Stop.** Emit the handoff block. Wait for the human to reply `done`. Then run the `check` before continuing. |

### The handoff block

When a step is `HUMAN`, do **not** guess or fake it. Stop and print exactly this shape, then wait:

```
┌─ HUMAN ACTION NEEDED ─────────────────────────────
│ Step: <id> — <title>
│ Why: <one line — what this unblocks>
│ Do this:
│ 1. <precise, copy-pasteable instruction, with URL>
│ 2. …
│ When done, reply "done" and I'll verify.
└───────────────────────────────────────────────────
```

After the human replies, run the step's `check`. If it fails, re-emit the handoff with
what you observed — never silently proceed past a red check.

## Resume protocol

This skill is **idempotent and resumable**. On every run, before doing anything, walk
[`references/steps.yaml`](references/steps.yaml) top to bottom and run each step's `check`
to discover *where setup already is*. Resume at the first step whose check is red. An
all-green repo means "already set up, nothing to do."

```text
for step in steps.yaml:
if run(step.check) is green: skip, print "✓ {step.id}"
else: resume here
```

Never assume state from memory or a prior session — always re-check.
See [`references/steps.yaml`](references/steps.yaml) for the runtime contract (which shell
vars to export before running any check).

## Preflight (AGENT)

Confirm your toolbox. All of these are yours to install if missing — none need a human.

```sh
terraform version # ≥ 1.9 — provisioning + import blocks
gh --version # GitHub CLI — repo ops, Pages, App install checks
jq --version # JSON wrangling for HCP/Cloudflare/GitHub APIs
curl --version # HCP + Cloudflare REST
```

These are the human-readable checks; the manifest's `preflight` block in
[`references/steps.yaml`](references/steps.yaml) enforces the version floor (Terraform ≥ 1.9)
programmatically — run those to gate, not just `terraform version`.

Then detect the credential the whole flow pivots on:

```sh
jq -re '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json \
&& echo "HCP token present — agent can drive the API" \
|| echo "No HCP token yet — first HUMAN step will mint one"
```

## Phases

Run in order. Each routes to a `references/` deep-dive and the canonical `docs/` section.

| # | Phase | Actors | Deep dive |
|---|---|---|---|
| 0 | **HCP bootstrap** — sign up, `terraform login`, get the pivot token | `HUMAN` then `AGENT` | [`references/hcp.md`](references/hcp.md), [`docs/setup.md#1`](../../../docs/setup.md#1-hcp-terraform--organization) |
| 1 | **HCP workspaces** — create `cloudflare` + `github-org`, set VCS + safety toggles | `AGENT` (API) + `HUMAN` VCS OAuth | [`references/hcp.md`](references/hcp.md), [`docs/setup.md#2`](../../../docs/setup.md#2-hcp-terraform--workspaces) |
| 2 | **Cloudflare** — mint scoped token, paste into HCP, verify | `HUMAN` mint/paste, `AGENT` verify | [`references/cloudflare.md`](references/cloudflare.md) |
| 3 | **GitHub** — create + install the GitHub App, paste creds into HCP | `HUMAN` create/install/paste, `AGENT` verify | [`references/github.md`](references/github.md) |
| 4 | **First plan** — `init` + speculative `plan` per leaf, read via API | `AGENT` | [`docs/setup.md#6`](../../../docs/setup.md#6-local-development) |
| 5 | **Migration** — adopt existing resources with import blocks (no re-create) | `AGENT` run, `HUMAN` discovery token | [`references/migration.md`](references/migration.md) |
| 6 | **GCP** *(optional, template only)* — not provisioned today | design decision first | [`references/gcp.md`](references/gcp.md) |

### Phase 0 — HCP bootstrap
The only unavoidable cold-start; produces the token that lets you script the rest.
`HUMAN` signs up + runs `terraform login`; then you own the HCP API. Commands and verify:
[`references/hcp.md`](references/hcp.md#phase-0--bootstrap).

### Phase 1 — HCP workspaces
Two workspaces (`cloudflare`, `github-org`), one per leaf. A human does the one-time
GitHub↔HCP OAuth (browser); you create both workspaces via the API with the right working
dir, path-scoped triggers, remote execution, and auto-apply **off**. Ready-to-run
`create_ws` helper + the safety-toggle rationale: [`references/hcp.md`](references/hcp.md#phase-1--workspaces).

### Phase 2 — Cloudflare
You can't mint a scoped Cloudflare token from nothing, and must never see the plaintext —
so minting/pasting are `HUMAN`, verifying is `AGENT`. Full walkthrough:
[`references/cloudflare.md`](references/cloudflare.md).

### Phase 3 — GitHub
The `github-org` workspace authenticates as a **GitHub App**, not a PAT. Creation +
install are browser flows; three creds get pasted into HCP. Walkthrough incl. the
manifest-flow shortcut: [`references/github.md`](references/github.md).

### Phase 4 — First plan (AGENT)
Prove every credential end-to-end. Per leaf: `terraform init` then `terraform plan`
(speculative, runs in HCP). A VCS-connected workspace **allows `plan` but blocks `apply`**
from the CLI — intentional. Read plans without the UI via
[`docs/hcp-api.md`](../../../docs/hcp-api.md). Green on both leaves = credentials proven.

### Phase 5 — Migration (adopt existing resources)
If the domain/repos already exist (they do for `perish.dev`), **import** them so Terraform
manages them without recreating. Canonical runbook: [`docs/import.md`](../../../docs/import.md);
cross-provider pattern: [`references/migration.md`](references/migration.md). Success signal:
`terraform plan` shows every existing resource as **"will import"**, nothing as **"will create"**.

### Phase 6 — GCP (template, optional)
**Not provisioned today.** GCP is not a managed provider here. Adopting it is a
**locked-design-decision change** — update [`CLAUDE.md`](../../../CLAUDE.md) and
[`terraform/README.md`](../../../terraform/README.md) first, then follow the same actor
split. Template: [`references/gcp.md`](references/gcp.md).

## Done signal

Setup is complete when you can report:

- ✓ HCP org `perishdev` + project `infra` reachable via API.
- ✓ Workspaces `cloudflare` and `github-org` exist, VCS-linked, auto-apply off, fork speculative plans off.
- ✓ All sensitive vars present (`cloudflare_api_token`; `github_app_id`, `github_app_installation_id`, `github_app_pem`).
- ✓ `terraform plan` green on both leaves.
- ✓ Existing resources imported (plan shows imports, no creates) — if migrating.

Then day-to-day work follows [`CONTRIBUTING.md`](../../../CONTRIBUTING.md).

## Porting to another organization

Written concretely for **`perishdev` / `perish.dev`**. To reuse as groundwork for another
org, change these — and nothing else:

| Token | Meaning | Where it appears |
|---|---|---|
| `perishdev` | HCP org **and** GitHub org slug | `terraform/*/versions.tf` (`organization`), every HCP API URL, App install URL, workspace `-org` suffix |
| `perish.dev` | apex domain | `terraform/cloudflare/main.tf` (`local.domain`), Cloudflare token/zone scoping |
| `d8a72309…` | Cloudflare **account ID** | `terraform/cloudflare/main.tf` (`local.account_id`) |
| `78ff9bdc…` | Cloudflare **zone ID** | `terraform/cloudflare/main.tf` (`local.zone_id`), migration commands |
| `perishdev/infra`, `perishdev/perishdev.github.io` | managed repos | `terraform/github/repos.tf`, App install scope |
| `repo-id-CffUfWW6H1x6Bauq` | per-installation HCP status-check ID | `terraform/github/branch_protection.tf` — **regenerated by HCP**, not chosen; see [`docs/ci.md`](../../../docs/ci.md) |

Porting checklist:

1. `grep -rn 'perishdev\|perish\.dev\|d8a72309\|78ff9bdc' terraform/ docs/ .claude/` to enumerate every literal.
2. Replace org/domain/repo literals with the new org's values.
3. Re-derive the two Cloudflare IDs from the new account (read via the Cloudflare API with a
read-only token — see [`references/cloudflare.md`](references/cloudflare.md)).
4. Leave `repo-id-…` alone — HCP emits it when the new VCS connection is made; copy it into
`branch_protection.tf` *after* Phase 1. Never invent it.
5. Re-run this skill from Phase 0. The resume protocol handles the rest.
93 changes: 93 additions & 0 deletions .claude/skills/infra-setup/references/cloudflare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Provider: Cloudflare (agent-first)

Deep dive for [Phase 2](../SKILL.md#phase-2--cloudflare) of the infra-setup skill. What the
agent does, what the human must do, and how to prove it. Canonical bootstrap detail:
[`setup.md#3`](../../../../docs/setup.md#3-cloudflare-api-token). Token scopes + rotation:
[`secrets.md`](../../../../docs/secrets.md#cloudflare-api-token-scopes).

## What this repo manages via Cloudflare

The `perish.dev` zone and all its DNS records (Email Routing + GitHub Pages), through the
`cloudflare/cloudflare` v5 provider. State + token live in the HCP `cloudflare` workspace.

## The actor split

| Action | Actor | Why |
|---|---|---|
| Mint the scoped API token | **HUMAN** | No API to bootstrap the *first* token; it's a dashboard-only action. |
| Paste token into HCP (sensitive var) | **HUMAN** | The agent must never see the plaintext. |
| Verify var presence + shape | **AGENT** | HCP API, no secret exposure. |
| Prove the token works (`plan`) | **AGENT** | Speculative plan runs in HCP. |
| Read account/zone IDs (for porting) | **AGENT** | Cloudflare API, once a token exists. |

## HUMAN — mint + paste (`cf-token`)

The agent emits a handoff block; the human does exactly this:

1. <https://dash.cloudflare.com/profile/api-tokens> → **Create Token** → **Custom token**.
2. Permissions for the `perish.dev` zone and its account:
- Zone — DNS — **Edit**
- Zone — Zone Settings — **Edit**
- *(add a row per new resource type before Terraform manages it — Email Routing Rules,
Rulesets, etc. Edit the token later, don't regenerate — see
[`setup.md`](../../../../docs/setup.md#adding-scopes-to-the-cloudflare-token-later).)*
3. **Account Resources**: Include — your account. **Zone Resources**: Include —
Specific zone — `perish.dev`.
4. Copy the token (shown once).
5. HCP → workspace **`cloudflare`** → Variables → add `cloudflare_api_token` as a
**Terraform** variable, mark **Sensitive**, paste.

> The token needs **Edit** because the persistent workspace *manages* resources. For
> one-time *discovery* during migration, a separate **Read** token is used and thrown
> away — see [`migration.md`](./migration.md) and [`import.md`](../../../../docs/import.md#discovery-token).

## AGENT — verify

The value is redacted, so the agent verifies presence + `sensitive == true`, then proves
the token with a plan:

```sh
HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json)
WS_ID=$(curl -sf "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/cloudflare" \
-H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id')

curl -sf "https://app.terraform.io/api/v2/workspaces/$WS_ID/vars" \
-H "Authorization: Bearer $HCP_TOKEN" \
| jq -e '.data[] | select(.attributes.key=="cloudflare_api_token") | .attributes.sensitive==true' \
&& echo "✓ token present + sensitive"

cd terraform/cloudflare && terraform init && terraform plan # green = token works
```

## AGENT — reading account + zone IDs (porting)

These are **not secrets** (they're `local`s in `terraform/cloudflare/main.tf`:
`account_id = d8a72309…`, `zone_id = 78ff9bdc…`), so reading them doesn't need the
persistent Edit token — and the agent should **not** touch that token (the
never-see-the-plaintext rule holds). Two clean ways to get the IDs when porting:

- **Human reads them off the dashboard** — account ID is in the URL / right-hand
sidebar; zone ID is on the domain's Overview page. Paste them back to the agent.
- **Agent uses the short-lived *read-only* discovery token** (the same throwaway token
minted for migration — see [`migration.md`](./migration.md)), never the Edit token:

```sh
export CF_READ_TOKEN=... # throwaway READ token, deleted right after — NOT the HCP Edit token

# Account ID
curl -sf "https://api.cloudflare.com/client/v4/accounts" \
-H "Authorization: Bearer $CF_READ_TOKEN" | jq -r '.result[0].id'

# Zone ID for the new domain
curl -sf "https://api.cloudflare.com/client/v4/zones?name=<new-domain>" \
-H "Authorization: Bearer $CF_READ_TOKEN" | jq -r '.result[0].id'
```

Write the results into `terraform/cloudflare/main.tf` `locals`. See the Porting table in
[`SKILL.md`](../SKILL.md#porting-to-another-organization).

## Rotation

Zero-downtime, agent-assisted: human mints the new token and pastes it; agent runs a no-op
`plan` to prove it; only then does the human revoke the old one. Steps in
[`secrets.md`](../../../../docs/secrets.md#cloudflare-api-token).
Loading
Loading