diff --git a/.claude/skills/bootstrap-sibling/SKILL.md b/.claude/skills/bootstrap-sibling/SKILL.md new file mode 100644 index 0000000..3c14882 --- /dev/null +++ b/.claude/skills/bootstrap-sibling/SKILL.md @@ -0,0 +1,72 @@ +--- +name: bootstrap-sibling +description: > + Bootstrap a new aligned AgentCulture sibling end-to-end: GitHub repo + create, local clone, afi-cli scaffold, and pypi/testpypi Trusted-Publishing + Environments. Each ghafi step dry-runs first and waits for confirmation + before applying. Use when starting a new sibling (e.g. "bootstrap a new + sibling called X", "create a new agentculture repo", "stand up the next + sibling"). +--- + +# Bootstrap Sibling + +Single-command bootstrap for a new AgentCulture sibling repo. Chains the +four steps documented in the project's `CLAUDE.md` ("Bootstrap walkthrough +(new sibling)") with dry-run-then-apply gates, so an agent or human can +drive a full sibling stand-up without remembering the verb order. + +The script does **not** automate the PyPI-side trusted-publisher +registration — that's a one-time web flow per project, and ghafi by design +cannot perform it. The script prints the PyPI registration URLs at the end +as a checklist. + +## When to use + +- Standing up a brand-new sibling under the `agentculture` org. +- After every `ghafi` release, to verify the bootstrap path still works + end-to-end against a throwaway repo name. + +## When **not** to use + +- Modifying an existing sibling — use the individual `ghafi repo …` verbs. +- Repos outside the AgentCulture org with non-default conventions. + +## Usage + +Run from the `ghafi` repo root. The script will `git clone` the new repo +into a sibling path next to ghafi (`../`). + +```bash +bash .claude/skills/bootstrap-sibling/scripts/bootstrap.sh \ + --name \ + --description "" \ + [--org agentculture] \ + [--private] +``` + +Without `--apply`, every ghafi step prints its dry-run output and the +script exits before mutating anything. Pass `--apply` (after reviewing the +dry-run) to commit: + +```bash +bash .claude/skills/bootstrap-sibling/scripts/bootstrap.sh \ + --name --description "..." --apply +``` + +## What the script does + +1. `ghafi repo create --org --description "<…>" ` — POST `/orgs/{org}/repos`. +2. `git clone https://github.com//.git ../` — local clone next to ghafi. +3. `ghafi repo scaffold ../` — shells to `afi cli cite`; writes the + reference template under `.afi/reference/python-cli/`. **Note:** this + does not instantiate `{{slug}}` into a runnable package; that's a + follow-up the script does not perform. +4. `ghafi repo env --owner --name pypi --branch main ` — PUT pypi env (main only). +5. `ghafi repo env --owner --name testpypi ` — PUT testpypi env (any branch). +6. Prints a manual checklist: register the trusted publisher on pypi.org + and test.pypi.org pointing at `/`, workflow `publish.yml`. + +If `GITHUB_TOKEN` and `GH_TOKEN` are both unset, the script bridges from +`gh auth token` so users with `gh` already authenticated don't need a +separate PAT. diff --git a/.claude/skills/bootstrap-sibling/scripts/bootstrap.sh b/.claude/skills/bootstrap-sibling/scripts/bootstrap.sh new file mode 100755 index 0000000..105fa81 --- /dev/null +++ b/.claude/skills/bootstrap-sibling/scripts/bootstrap.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# bootstrap.sh — chain `ghafi repo {create,scaffold,env}` to stand up a +# new AgentCulture sibling end-to-end. Dry-run by default; --apply to +# commit. See ../SKILL.md for rationale. + +set -euo pipefail + +ORG="agentculture" +NAME="" +DESCRIPTION="" +PRIVATE="" +APPLY="" + +usage() { + cat <<'EOF' +Usage: + bootstrap.sh --name --description "<…>" [--org agentculture] [--private] [--apply] + +Without --apply, every ghafi step prints its dry-run output and the +script exits before performing any mutation. With --apply, the script +runs the full bootstrap (repo create, local clone, scaffold, env pypi, +env testpypi) with a confirmation prompt after the dry-run review. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) NAME="$2"; shift 2 ;; + --description) DESCRIPTION="$2"; shift 2 ;; + --org) ORG="$2"; shift 2 ;; + --private) PRIVATE="--private"; shift ;; + --apply) APPLY="--apply"; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown arg: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ -z "$NAME" || -z "$DESCRIPTION" ]]; then + echo "error: --name and --description are required" >&2 + usage + exit 2 +fi + +# Bridge gh auth → GITHUB_TOKEN if not already set. +if [[ -z "${GITHUB_TOKEN:-}" && -z "${GH_TOKEN:-}" ]]; then + if command -v gh >/dev/null 2>&1; then + GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" + if [[ -n "$GITHUB_TOKEN" ]]; then + export GITHUB_TOKEN + echo "note: bridged GITHUB_TOKEN from \`gh auth token\`" + fi + fi +fi + +GHAFI=(uv run ghafi) +REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +TARGET="$REPO_ROOT/../$NAME" + +echo "=== Plan ===" +echo " org: $ORG" +echo " name: $NAME" +echo " description: $DESCRIPTION" +echo " private: ${PRIVATE:-false}" +echo " local target: $TARGET" +echo " mode: ${APPLY:-dry-run}" +echo + +# Step 1: repo create (always dry-run first; --apply if requested) +echo "=== Step 1: repo create (dry-run preview) ===" +"${GHAFI[@]}" repo create --org "$ORG" --description "$DESCRIPTION" $PRIVATE "$NAME" +echo + +# Step 4 + 5 dry-run preview (envs) +echo "=== Step 4: repo env pypi (dry-run preview) ===" +"${GHAFI[@]}" repo env --owner "$ORG" --name pypi --branch main "$NAME" +echo +echo "=== Step 5: repo env testpypi (dry-run preview) ===" +"${GHAFI[@]}" repo env --owner "$ORG" --name testpypi "$NAME" +echo + +if [[ -z "$APPLY" ]]; then + echo "Dry-run complete. Re-run with --apply to commit." + exit 0 +fi + +# Confirmation gate before applying. +read -r -p "Apply the bootstrap (create + clone + scaffold + 2× env)? [y/N] " confirm +if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + echo "Aborted." + exit 1 +fi + +echo +echo "=== Step 1: repo create --apply ===" +"${GHAFI[@]}" repo create --org "$ORG" --description "$DESCRIPTION" $PRIVATE "$NAME" --apply + +echo +echo "=== Step 2: git clone $ORG/$NAME → $TARGET ===" +git clone "https://github.com/$ORG/$NAME.git" "$TARGET" + +echo +echo "=== Step 3: repo scaffold --apply ===" +"${GHAFI[@]}" repo scaffold --apply "$TARGET" + +echo +echo "=== Step 4: repo env pypi --apply ===" +"${GHAFI[@]}" repo env --owner "$ORG" --name pypi --branch main --apply "$NAME" + +echo +echo "=== Step 5: repo env testpypi --apply ===" +"${GHAFI[@]}" repo env --owner "$ORG" --name testpypi --apply "$NAME" + +cat < + Verify that claims in CLAUDE.md (GitHub endpoints, required scopes, + bootstrap step order) still match what the code in `ghafi/` actually + does. Run before merging anything that touches CLAUDE.md or the + `ghafi.cli._commands.repo` module — catches doc drift that a normal + pytest pass would not. +--- + +# Doc / Test Alignment + +A small, opinionated drift detector. CLAUDE.md makes empirical claims +about external systems — which GitHub REST endpoints `ghafi` calls, +which token scopes those calls require, what step order the bootstrap +walkthrough specifies. The mutation-safety pytest catches code regressions; +this skill catches the *documentation* equivalent. + +This is a **stub** — v0 covers a narrow set of claims. Extend the script +when a new failure mode is found. + +## When to use + +- Reviewer is about to approve a PR that touches `CLAUDE.md`, + `ghafi/cli/_commands/repo.py`, or `ghafi/_api.py`. +- Before cutting a release. +- Periodically (e.g. quarterly) as a sweep to catch silent drift from + GitHub API changes. + +## What it checks (v0) + +1. **Endpoint mentions in CLAUDE.md exist in code.** Every `/repos/...`, + `/orgs/...`, `/user/...` URL referenced in CLAUDE.md should appear + somewhere in `ghafi/`. Strings only — does not validate semantics. +2. **Bootstrap step list matches verb set.** The "Bootstrap walkthrough" + section should reference the same `repo {create,scaffold,env}` verbs + that `ghafi/cli/_commands/repo.py` registers, and no others. +3. **Scope list claim is empirically backed.** If CLAUDE.md says scope + X is required, there should be a comment/test/CHANGELOG entry showing + it was tested. (v0 just lists scopes for human review; v1 should diff + against an `_api` annotation.) + +## What it does **not** check + +- Whether GitHub's actual scope requirements have changed upstream + (would require live API calls). +- Whether endpoint payloads still match GitHub's current schema. +- Prose accuracy beyond URLs and verb names. + +These are deferred to a future revision; the user should add cases as +real drift is observed. + +## Usage + +```bash +bash .claude/skills/doc-test-align/scripts/check.sh +``` + +Exit codes: + +- `0` — no drift detected. +- `1` — drift detected; details printed to stdout. +- `2` — script error (missing files, bad invocation). + +The script is grep-and-compare only; no network, no test runs. Pair it +with the existing `markdownlint-cli2` and `portability-lint.sh` checks +in CI when you trust the v1 surface. diff --git a/.claude/skills/doc-test-align/scripts/check.sh b/.claude/skills/doc-test-align/scripts/check.sh new file mode 100755 index 0000000..100dbff --- /dev/null +++ b/.claude/skills/doc-test-align/scripts/check.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# check.sh — v0 doc/test alignment checker. See ../SKILL.md. +# +# Compares CLAUDE.md claims to ghafi/ source code. Stub: only catches a +# narrow class of drift today. Extend cases as real failures surface. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +cd "$REPO_ROOT" + +CLAUDE_MD="CLAUDE.md" +SRC_DIR="ghafi" + +if [[ ! -f "$CLAUDE_MD" ]]; then + echo "error: $CLAUDE_MD not found in $REPO_ROOT" >&2 + exit 2 +fi + +drift=0 + +echo "=== Check 1: endpoint prefixes in CLAUDE.md exist in $SRC_DIR/ ===" +# Pull each endpoint URL from CLAUDE.md, strip {placeholders}, then +# check that each non-empty static segment is referenced somewhere in +# ghafi/. Coarse but stable. +endpoints=$(grep -oE '/(repos|orgs|user)(/[A-Za-z0-9_{}/.:-]+)?' "$CLAUDE_MD" | sort -u) +if [[ -z "$endpoints" ]]; then + echo " (no endpoint mentions found in $CLAUDE_MD — nothing to check)" +else + while IFS= read -r ep; do + # Replace {placeholder} with a separator and extract static segments. + cleaned=$(echo "$ep" | sed -E 's|\{[^}]+\}| |g') + miss=() + for seg in $cleaned; do + seg_trim="${seg#/}" + seg_trim="${seg_trim%/}" + [[ -z "$seg_trim" ]] && continue + if ! grep -RqF -- "$seg_trim" "$SRC_DIR/" 2>/dev/null; then + miss+=("$seg_trim") + fi + done + if [[ ${#miss[@]} -gt 0 ]]; then + echo " DRIFT: $CLAUDE_MD mentions '$ep' but these segments are absent from $SRC_DIR/: ${miss[*]}" + drift=1 + fi + done <<<"$endpoints" + if [[ "$drift" -eq 0 ]]; then + echo " OK — every endpoint segment in $CLAUDE_MD has a code match" + fi +fi +echo + +echo "=== Check 2: bootstrap walkthrough verbs match registered verbs ===" +# Introspect the live parser instead of regex'ing source — robust to +# multi-line add_parser() calls. If introspection fails (uv missing, +# import error, etc.) exit 2 so the failure is actionable rather than +# masquerading as drift. +introspect_stderr=$(mktemp) +trap 'rm -f "$introspect_stderr"' EXIT +if ! registered=$(uv run python -c ' +import argparse +from ghafi.cli import _build_parser +p = _build_parser() +sub = next(a for a in p._actions if isinstance(a, argparse._SubParsersAction)) +repo = sub.choices["repo"] +sub2 = next(a for a in repo._actions if isinstance(a, argparse._SubParsersAction)) +print("\n".join(sorted(sub2.choices.keys()))) +' 2>"$introspect_stderr"); then + echo " ERROR: parser introspection failed:" >&2 + sed 's/^/ /' "$introspect_stderr" >&2 + exit 2 +fi +registered=$(echo "$registered" | sort -u) +mentioned=$(grep -oE 'ghafi repo [a-z]+' "$CLAUDE_MD" \ + | awk '{print $3}' | sort -u || true) +echo " registered: $(echo "$registered" | tr '\n' ' ')" +echo " mentioned: $(echo "$mentioned" | tr '\n' ' ')" +for v in $mentioned; do + if ! grep -qx "$v" <<<"$registered"; then + echo " DRIFT: $CLAUDE_MD references 'ghafi repo $v' but no such verb is registered" + drift=1 + fi +done +echo + +echo "=== Check 3: scope claims (informational only, v0) ===" +grep -nE '^- \`[a-z_:]+\` —' "$CLAUDE_MD" | sed 's/^/ /' +echo " (v0: review the above by eye against the code; v1 should diff against _api annotations)" +echo + +if [[ "$drift" -ne 0 ]]; then + echo "DRIFT detected. Update $CLAUDE_MD or the code so they agree." + exit 1 +fi + +echo "No drift detected." diff --git a/CHANGELOG.md b/CHANGELOG.md index 443af34..3dcaced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.2] - 2026-04-27 + +### Added + +- doc/test alignment skill (`.claude/skills/doc-test-align/`) — drift detector for CLAUDE.md endpoint and verb claims +- bootstrap-sibling skill (`.claude/skills/bootstrap-sibling/`) — chains the four-step sibling bootstrap with dry-run-then-apply gates +- mutation-safety pytest module — asserts every mutating verb has --apply defaulting to False and performs no writes in dry-run +- Bootstrap walkthrough section in CLAUDE.md covering the four-step path from no-repo to Trusted-Publishing-ready + +### Changed + +- CLAUDE.md GitHub authentication: `repo` scope is sufficient for Environments (per GitHub REST docs); `admin:repo_hook` is no longer claimed required for v0.x verbs +- CLAUDE.md GitHub authentication: documented `GITHUB_TOKEN=$(gh auth token)` bridge for users with gh authenticated but no PAT exported + +### Fixed + +- Empirically incorrect scope claim for `ghafi repo env` — verified against agentculture/irc-lens bootstrap + ## [0.0.1] - 2026-04-26 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index be0ff4c..2b6d172 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,20 +55,56 @@ CHANGELOG.md # Keep-a-Changelog `ghafi` reads the token from `GITHUB_TOKEN` first, falling back to `GH_TOKEN`. There is **no `gh` CLI fallback in the Python layer** — `ghafi` is stdlib-only (zero runtime deps) and uses `urllib` directly. If both env vars are unset, every GitHub-touching verb exits `2` with a remediation hint pointing at this file. -Required scopes: +If you have `gh` authenticated but no PAT exported, bridge it for one command: `GITHUB_TOKEN=$(gh auth token) ghafi …`. Or export it for the shell: `export GITHUB_TOKEN=$(gh auth token)`. This keeps ghafi stdlib-only while leveraging your existing `gh` session. -- `repo` — create user-owned repositories. -- `admin:repo_hook` — manage Actions permissions and Environments. -- `admin:org` — additional, only when creating org-owned repositories. +Required scopes (verified against the v0.x verb set): + +- `repo` — create user-owned repositories, manage Environments (PUT `/repos/{owner}/{repo}/environments/{name}`), and write Actions repository permissions (PUT `/repos/{owner}/{repo}/actions/permissions`, used by `repo create` to enable workflows). All three accept classic-PAT `repo` per GitHub REST docs; this is what `gh auth login` gives you by default. +- `admin:org` — only when creating **org-owned** repositories (org membership with create-repo permission is the actual gate; the scope is required for some org configurations). +- `admin:repo_hook` — **not currently needed** by any v0.x verb. Would be required only if ghafi grew verbs that manage repository webhooks; the existing Environments and Actions-permissions endpoints both accept `repo` alone. ## Mutation safety contract -Every verb that writes to GitHub **defaults to dry-run**. Pass `--apply` to commit. In dry-run, `ghafi` prints the JSON body it would POST/PUT and exits 0. This is enforced in code review (no automated check yet — flag it manually for any new mutating verb). Rationale: agents call `ghafi` in loops; safe defaults are the difference between a useful tool and a foot-gun. +Every verb that writes to GitHub **defaults to dry-run**. Pass `--apply` to commit. In dry-run, `ghafi` prints the JSON body it would POST/PUT and exits 0. This is enforced both in code review and by `tests/test_mutation_safety.py`, which walks the argparse tree to assert every mutating verb exposes `--apply` defaulting to False and performs no HTTP writes (or `subprocess.run` invocations) without it. Add new mutating verbs to that test's `MUTATING_VERBS` list. Rationale: agents call `ghafi` in loops; safe defaults are the difference between a useful tool and a foot-gun. ## Trusted Publishing `ghafi repo env` creates the GitHub-side Environment only. The PyPI side — registering the trusted publisher on pypi.org / test.pypi.org — is a one-time web flow per project; see . Environments created by `ghafi repo env` store no secrets and configure no reviewers, since OIDC carries the auth. +## Bootstrap walkthrough (new sibling) + +From "no repo" to "Trusted-Publishing-ready sibling" in four automated steps plus one manual web-flow step. Each ghafi `--apply` step prints the JSON body in dry-run first; review before adding the flag. + +1. **Create on GitHub** + + ```bash + ghafi repo create --org agentculture --description "" # dry-run + ghafi repo create --org agentculture --description "" --apply + ``` + +2. **Clone locally as a sibling** + + ```bash + git clone https://github.com/agentculture/.git ../ + ``` + +3. **Cite the afi-cli reference template** — note this **does not** instantiate a runnable project; it writes the template into `.afi/reference/python-cli/` (the cite-don't-import pattern). You instantiate `{{slug}}/` into the actual package separately. + + ```bash + ghafi repo scaffold --apply ../ + ``` + +4. **Create both Trusted Publishing environments** + + ```bash + ghafi repo env --owner agentculture --name pypi --branch main --apply + ghafi repo env --owner agentculture --name testpypi --apply + ``` + +5. **Manual (one-time per project, web only):** register the trusted publisher on and , pointing at `agentculture/`, workflow `publish.yml`, environment `pypi` (and `testpypi` on the test side). + +A `.claude/skills/bootstrap-sibling/scripts/bootstrap.sh` helper drives the full flow with a confirmation gate: it dry-runs the GitHub mutations first, then on `--apply` runs `repo create`, performs the local `git clone`, runs `repo scaffold`, and creates the `pypi` and `testpypi` environments as separate calls. + ## Conventions in use - **Packaging:** `uv` + `pyproject.toml` (hatchling backend), `[project.scripts]` entry point. diff --git a/pyproject.toml b/pyproject.toml index e9bb8d8..34167da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ghafi" -version = "0.0.1" +version = "0.0.2" description = "ghafi — GitHub Agent First Interface; an AgentCulture manager." readme = "README.md" license = "MIT" diff --git a/tests/test_mutation_safety.py b/tests/test_mutation_safety.py new file mode 100644 index 0000000..9dec532 --- /dev/null +++ b/tests/test_mutation_safety.py @@ -0,0 +1,82 @@ +"""Mutation-safety contract. + +Every verb that writes state — to GitHub or the local file system — +must expose ``--apply`` defaulting to False, and must not perform any +write when ``--apply`` is absent. CLAUDE.md describes the contract; +this module enforces it programmatically so the "flag it manually in +code review" gap is closed for every new mutating verb. +""" + +from __future__ import annotations + +import argparse + +import pytest + +from ghafi.cli import _build_parser +from ghafi.cli import main as cli_main + +# Subcommands that mutate state (GitHub or local FS) and therefore must +# expose --apply defaulting to False. Add new mutating verbs here when +# they're introduced. +MUTATING_VERBS: list[list[str]] = [ + ["repo", "create"], + ["repo", "scaffold"], + ["repo", "env"], +] + +WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} + + +def _walk_to_leaf(parser: argparse.ArgumentParser, path: list[str]) -> argparse.ArgumentParser: + target = parser + for token in path: + sub_action = next( + (a for a in target._actions if isinstance(a, argparse._SubParsersAction)), + None, + ) + assert sub_action is not None, f"no sub-action under {target.prog!r} for {token!r}" + assert token in sub_action.choices, f"{token!r} not in {list(sub_action.choices)}" + target = sub_action.choices[token] + return target + + +def _find_apply(parser: argparse.ArgumentParser) -> argparse.Action | None: + for action in parser._actions: + if "--apply" in action.option_strings: + return action + return None + + +@pytest.mark.parametrize("verb", MUTATING_VERBS, ids=lambda v: " ".join(v)) +def test_mutating_verb_has_apply_default_false(verb: list[str]) -> None: + """Structural: --apply exists on every mutating verb and defaults to False.""" + leaf = _walk_to_leaf(_build_parser(), verb) + apply_action = _find_apply(leaf) + assert apply_action is not None, f"`ghafi {' '.join(verb)}` is missing --apply" + assert ( + apply_action.default is False + ), f"`ghafi {' '.join(verb)}` --apply default must be False, got {apply_action.default!r}" + + +def test_repo_create_dry_run_does_not_call_api(http_stub) -> None: + """Behavioral: dry-run `repo create` performs no HTTP writes.""" + rc = cli_main(["repo", "create", "demo", "--org", "agentculture"]) + assert rc == 0 + writes = [(m, p) for (m, p, _payload, _q) in http_stub.calls if m in WRITE_METHODS] + assert writes == [], f"dry-run leaked writes: {writes}" + + +def test_repo_env_dry_run_does_not_call_api(http_stub) -> None: + """Behavioral: dry-run `repo env` performs no HTTP writes.""" + rc = cli_main(["repo", "env", "demo", "--owner", "agentculture"]) + assert rc == 0 + writes = [(m, p) for (m, p, _payload, _q) in http_stub.calls if m in WRITE_METHODS] + assert writes == [], f"dry-run leaked writes: {writes}" + + +def test_repo_scaffold_dry_run_does_not_invoke_afi(afi_stub) -> None: + """Behavioral: dry-run `repo scaffold` does not shell out to afi.""" + rc = cli_main(["repo", "scaffold", "/tmp/demo"]) + assert rc == 0 + assert afi_stub.calls == [], f"dry-run leaked subprocess calls: {afi_stub.calls}" diff --git a/uv.lock b/uv.lock index 9c30acc..6917c05 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "ghafi" -version = "0.0.1" +version = "0.0.2" source = { editable = "." } [package.dev-dependencies]