{"owner":"redis","repo":"go-redis","hasSkills":true,"totalSkillsCount":11,"totalTokensCount":11541,"categories":["anthropic-skill","claude-rule","root-instruction","command-prompt"],"hasMcp":false,"mcpConfig":null,"found":[".claude/skills/prepare-release/SKILL.md",".claude/skills/commit-style/SKILL.md",".claude/skills/testing/SKILL.md","CLAUDE.md",".claude/skills/add-command/SKILL.md",".claude/skills/update-ci-image/SKILL.md","AGENTS.md",".claude/commands/check-ci.md",".claude/skills/add-command/references/cluster-routing-wiring.md",".claude/skills/add-command/references/module-commands.md",".claude/skills/add-command/references/core-command-pattern.md"],"skills":{".claude/skills/prepare-release/SKILL.md":"---\nname: prepare-release\ndescription: Use when preparing a go-redis release — cutting a new version, bumping version.go, updating the go-redis dependency version in submodule go.mod files, or writing the RELEASE-NOTES.md entry for a new vX.Y.Z. Covers choosing the next semver, finding the last release and gathering merged PRs since then, the release-notes format and what to exclude, running scripts/release.sh to bump versions, and verifying with the scripts/tag.sh dry-run. Does NOT create tags, push, or commit — publishing stays a manual maintainer step.\nallowed-tools: Bash(git describe:*), Bash(gh pr list:*)\n---\n\n# Preparing a go-redis release\n\nStage everything for a release locally, then stop before anything is published.\n\n## This skill never publishes\n\n- **Never** run `scripts/tag.sh ... -t` — the `-t` flag creates and pushes git tags.\n- **Never** `git push` anything (no commits, no tags).\n- **Never** commit on the maintainer's behalf unless they explicitly ask.\n\nThe deliverable is a reviewable diff: bumped versions plus a new `RELEASE-NOTES.md`\nentry. A human reviews it and runs the publish step.\n\n## 1. Pick the next version\n\nThe current version is the source of truth in `version.go`:\n\n```sh\ngrep 'return' version.go        # e.g. return \"9.21.0\"\n```\n\nChoose the next `vX.Y.Z` by semver, based on what shipped since the last release:\n\n- **patch** (`Z`) — bug fixes only; drop-in upgrade.\n- **minor** (`Y`) — new features, no breaking changes; drop-in upgrade.\n- **major** (`X`) — breaking changes.\n\nConfirm the level with the user if the changeset is ambiguous.\n\n## 2. Find the last release and gather changes\n\n`scripts/tag.sh` also tags every public submodule (`extra/redisotel/vX.Y.Z`, …),\nso a naive `git describe` returns a submodule tag. Match the **root** tag only:\n\n```sh\nLAST=$(git describe --tags --abbrev=0 --match 'v[0-9]*')   # e.g. v9.21.0\ngit log \"$LAST\"..HEAD --oneline\ngh pr list --state merged --limit 100 \\\n  --json number,title,author,mergedAt,url --search \"merged:>=<last-release-date>\"\n```\n\nCategorize the PRs: highlights, new features, bug fixes, performance,\ntesting/infrastructure. Exclude dependabot bumps, typo-only doc fixes, internal\nrefactors with no user-facing effect, and `dependabot[bot]` from the contributor\nlist.\n\n## 3. Write the release notes\n\nPrepend a new `# X.Y.Z (YYYY-MM-DD)` section to the **top** of `RELEASE-NOTES.md`\n(newest first; leave older entries untouched). Follow\n[`.github/RELEASE_NOTES_TEMPLATE.md`](../../../.github/RELEASE_NOTES_TEMPLATE.md)\nexactly — section order, emoji headers, the `([#PR](url)) by [@user](url)` link\nformat, and the `**Full Changelog**` compare link `${LAST}...vX.Y.Z`. Open the\nlead line with the release type and whether it is a drop-in upgrade, matching the\nexisting entries.\n\nThat template carries the full \"what to exclude\" and formatting rules — read it,\ndon't reinvent them. `release-drafter` separately auto-drafts a GitHub release\nfrom PR labels (`.github/release-drafter-config.yml`); `RELEASE-NOTES.md` is the\ncurated, hand-written record and is the file you edit.\n\n## 4. Bump versions\n\nRun the repo's bump script. It rewrites the go-redis dependency version in every\nsubmodule `go.mod`, runs `go mod tidy`, and bumps `version.go`. It does **not**\ncommit, push, or switch branches:\n\n```sh\nTAG=vX.Y.Z ./scripts/release.sh\n```\n\n## 5. Verify (dry run only)\n\nRun the tag script's **dry run** — it checks that `version.go` and every `go.mod`\nalready match the tag, and prints the tags it *would* push. No `-t`:\n\n```sh\n./scripts/tag.sh vX.Y.Z         # DRY RUN — must pass cleanly; never add -t here\nmake build\ngit diff --stat                 # review version.go, submodule go.mod, RELEASE-NOTES.md\n```\n\nFix anything the dry run flags before handing off.\n\n## 6. Hand off\n\nReport that the release is staged and list the publish steps for the maintainer\nto run themselves (this skill does not do them):\n\n1. Review `git diff`, then commit (`chore(release): vX.Y.Z` — see the\n   `commit-style` skill; no AI-attribution trailer).\n2. Open and merge the release PR.\n3. After merge, tag and push: `./scripts/tag.sh vX.Y.Z -t`.\n4. `release-drafter` publishes the GitHub release; reconcile it with the\n   `RELEASE-NOTES.md` entry if needed.\n\nStop after step 5. Do not commit, tag, or push.\n",".claude/skills/commit-style/SKILL.md":"---\nname: commit-style\ndescription: Use when writing a commit message or opening a PR in the go-redis repo — covers the Conventional-Commit format, the repo's scope vocabulary (pool, conn, sentinel, command, …), breaking-change syntax, and the rule that NO AI-attribution trailer is added.\n---\n\n# go-redis commit messages\n\nConventional Commits, short and exact — `<type>(<scope>): <imperative summary>`. Subject ≤50 chars (hard cap 72), imperative mood (\"add\", not \"added\"), no trailing period. Body only when the *why* isn't obvious from the diff (breaking change, security fix, migration, non-obvious rationale); wrap at 72, bullets `-`. State why, not what — the diff says what.\n\n- **Types:** `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also `build`, `ci`, `style`, `revert`).\n- **Scope** = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`, `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`, `deps`, `ci`, `tests`, `docs`. Omit scope only for genuinely cross-cutting changes.\n- **Breaking change:** `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line.\n- **Issues/PRs:** reference at the end — `Closes #42`, `Refs #17`.\n\n## No attribution trailer\n\nDo **not** add `Co-Authored-By: Claude …`, \"Generated with Claude Code\", or any AI-attribution line to commits or PR bodies in this repo. This overrides both the default harness behavior (which appends such trailers) and any other commit skill.\n\n## Examples\n\n```\nfix(pool): release wantConn slot on dial timeout\n\nThe waiter stayed queued after the dial deadline fired, leaking a\nslot per timed-out connection under load.\n\nCloses #3812\n```\n\n```\nfeat(streams): support explicit LIMIT 0 in XTRIM trimming\n```\n",".claude/skills/testing/SKILL.md":"---\nname: testing\ndescription: Use when running go-redis tests of any kind — the full suite, a single test, or a filtered/focused subset (e.g. only cluster, sentinel, or pool specs); bringing up or tearing down the Docker test stack; the e2e maintenance-notifications suite (including the proxy-free logic tests); building or formatting; or setting the REDIS_VERSION / RE_CLUSTER / REDIS_PORT knobs that drive the test image and version gating. Covers how to focus a Ginkgo spec and how to run plain go tests.\n---\n\n# Running tests, builds, and the Docker stack\n\nTests run against a Redis stack started via Docker Compose. Profiles in `docker-compose.yml` control which services come up (`standalone`, `cluster`, `sentinel`, `all`, `e2e`, …).\n\n## Make targets\n\n```sh\nmake docker.start          # bring up the full test stack (profile: all)\nmake docker.stop\nmake test                  # docker.start -> test.ci -> docker.stop\nmake test.ci               # run tests assuming containers are already up\nmake test.ci.skip-vectorsets   # used when REDIS_VERSION < 8\nmake bench                 # go test -bench=. (root module only)\nmake fmt                   # gofumpt + goimports (-local github.com/redis/go-redis)\nmake build\nmake go_mod_tidy           # go mod tidy across every module\n```\n\nThe Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) for `test.ci`, `go_mod_tidy`, etc.\n\n## E2E (maintenance notifications)\n\nNeeds an extra `cae-resp-proxy` service:\n\n```sh\nmake test.e2e              # auto-starts the e2e profile, runs ./maintnotifications/e2e/, tears down\nmake test.e2e.docker       # subset that runs inside docker\nmake test.e2e.logic        # logic-only tests, no proxy required\n```\n\n## Env knobs (passed through the Makefile)\n\n- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and `main_test.go` version-gating helpers (`SkipBeforeRedisVersion` / `SkipAfterRedisVersion`).\n- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g. `redislabs/client-libs-test:8.8-m03`. (To change the default tag, use the `update-ci-image` skill — it lives in several files.)\n- `RE_CLUSTER=true` — point tests at a Redis Enterprise cluster instead of the docker-compose stack. The suite then skips ring/sentinel/TLS-cluster setup.\n- `RCE_DOCKER=true` — using Redis CE in docker (default for `make test`).\n- `REDIS_PORT` — override the default standalone port (`6380`) used by `main_test.go`.\n\n## Running a single test\n\nThe root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega` forks). `go test -run` matches the Go-level wrapper, so to focus a Ginkgo spec use the Ginkgo focus flag:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"ZAdd\"\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\nPlain `go test`-style tests (most files outside the Ginkgo suite, e.g. `internal/...`, `maintnotifications/...`) work the usual way:\n\n```sh\ngo test -run TestConnStateMachine ./internal/pool/...\ngo test -race -run TestCircuitBreaker ./maintnotifications/...\n```\n\n## Version-gating\n\nGate Redis-version-specific tests with `SkipBeforeRedisVersion` / `SkipAfterRedisVersion` (driven by `REDIS_VERSION`) rather than skipping at the suite level.\n","CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nRepository guidance — layout, commands, architecture, and conventions — is\nmaintained in [`AGENTS.md`](AGENTS.md) so it stays shared across AI tools. It is\nimported below; edit `AGENTS.md`, not this file, for that content.\n\n@AGENTS.md\n\n## Claude-specific\n\n- Repo-local **skills** under `.claude/skills/` auto-trigger from their\n  descriptions — no need to invoke them manually (the set is listed in\n  `AGENTS.md`).\n- **Slash commands** under `.claude/commands/` (e.g. `/check-ci`) are available\n  in-session.\n- Architectural **specs** under `.claude/specs/` are read on demand; open the\n  relevant one before changing that subsystem.\n",".claude/skills/add-command/SKILL.md":"---\nname: add-command\ndescription: Use when adding a new Redis command (or RediSearch / TimeSeries / VectorSet / module subcommand) to go-redis — covers fetching the command spec and docs, the Cmder type, Cmdable interface wiring, RESP parsing, tests, and the custom-vet rule that enforces SetVal.\nallowed-tools: WebFetch\n---\n\n# Adding a new Redis command\n\nRouter for adding a Redis command to the root `redis` package. Read the reference file for the area you're touching — don't load all of them.\n\n## Step 0 — Get the command spec AND docs FIRST\n\nBefore writing any Go, know the exact command shape (arguments, optional flags, reply structure, since-version, key positions) **and** its documented semantics (what each reply field means, RESP2-vs-RESP3 differences, examples). Fetch **two** sources — the machine-readable spec and the prose docs. They cover different gaps: the spec nails arguments and key positions, the docs nail what the reply actually looks like.\n\n### A. Machine-readable spec (arguments, key specs, reply schema)\n\nResolve the spec in this order:\n\n1. **Spec file argument** — if the user passed a path to a spec/JSON file, read it.\n2. **PR URL argument** — if the user passed a `github.com/redis/redis` PR (or other repo) URL, fetch the diff and read the command definition + `src/commands/<cmd>.json` it adds.\n3. **No argument** — fetch the machine-readable spec from redis/redis:\n   ```\n   https://raw.githubusercontent.com/redis/redis/unstable/src/commands/<command>.json\n   ```\n   Container subcommands use `<container>-<sub>.json` (e.g. `client-info.json`). **A 404 means it's a module command** (RediSearch, TimeSeries, VectorSet, Bloom) — those specs live in the **module's own repo**, not redis/redis. Switch to the module repo (see `references/module-commands.md` §1) or ask the user for the spec/PR. Don't retry the redis/redis URL.\n\n### B. Prose docs (semantics, return value, RESP2/RESP3 reply, examples)\n\nThe JSON `reply_schema` is often thin or missing; the docs spell out what the reply actually is — including separate **RESP2 Reply** and **RESP3 Reply** sections, which decide your `readReply` and any module RESP2-vs-RESP3 handling. Fetch the command's doc page:\n\n```\nhttps://redis.io/docs/latest/commands/<command>/\n```\n\nRaw markdown source (alternative, good for diffing or when the rendered page is noisy):\n`https://raw.githubusercontent.com/redis/redis-doc/master/commands/<command>.md`.\n**Module commands** (RediSearch, TimeSeries, …) are documented under their own\npath on redis.io (e.g. `/docs/latest/commands/ft.search/`) or in the module repo\n— see `references/module-commands.md`.\n\nRead the **Return value / RESP2 Reply / RESP3 Reply** sections plus the examples, and reconcile them against the JSON `reply_schema`. When the two disagree, the docs' reply description (and a quick `redis-cli` check) win.\n\n### Map both sources to the implementation\n\n| Source | Drives |\n|--------|--------|\n| spec `arguments` | method signature, args-slice build order, optional `FooArgs` struct |\n| spec `reply_schema` + docs **Return value** | the `readReply` parser and Cmder result type |\n| docs **RESP2 Reply / RESP3 Reply** | RESP2-vs-RESP3 branching in `readReply` (see `references/module-commands.md`) |\n| spec `since` + docs `@history` | `SkipBeforeRedisVersion(...)` in the integration test, doc comment |\n| spec `key_specs` | key-position maps in `command.go`; cluster routing |\n| spec `command_flags` (e.g. `READONLY`, no key) | keyless / fan-out handling, cluster routing |\n| docs examples | integration-test cases and expected values |\n\nIf you can't get either source, STOP and ask the user — guessing the reply shape produces a broken `readReply`.\n\n## Decide before you start\n\n1. **Which `*_commands.go` file?** — pick the existing file matching the data type (`string_commands.go`, `hash_commands.go`, `search_commands.go`, …). Only create a new file for a genuinely new category. New files need a matching `XxxCmdable` interface embedded in `Cmdable` (`commands.go`).\n2. **Reuse an existing Cmder, or define a new one?** — simple replies (`int`, `string`, `bool`, `[]string`, map) reuse `*IntCmd`, `*StringCmd`, `*BoolCmd`, `*StringSliceCmd`, `*MapStringStringCmd`, … Define a new Cmder only for a structured reply that no existing type fits.\n3. **`Foo` vs `FooWithArgs`?** — for commands with optional flags, expose a positional `Foo(...)` for the common path plus `FooWithArgs(ctx, key, *FooArgs)` for the full surface.\n\n## Reference files\n\n| Read this | When |\n|-----------|------|\n| `references/core-command-pattern.md` | Always — the 7-step Go pattern (interface method, Cmder type, RESP parsing, tests, vet, fmt). Worked example: `LCS`. |\n| `references/module-commands.md` | Adding a RediSearch / TimeSeries / VectorSet / Bloom subcommand — naming, RESP2-vs-RESP3 shape differences, where the Cmd type lives. |\n| `references/cluster-routing-wiring.md` | Command is keyless, fans out, multi-key, or aggregates across shards. |\n\nWhen several apply, read in order: core → module → cluster.\n\n## Pitfalls (full list in core-command-pattern.md)\n\n- Forgetting to embed the new `XxxCmdable` interface in `Cmdable` — compiles on `*Client`, silently unreachable via `UniversalClient`.\n- Forgetting `Clone()` — pipelines reuse Cmders; shared `val` causes cross-execution bugs.\n- Skipping `SetVal` because \"nothing calls it\" — hooks do, and the `setval` custom-vet check fails the build.\n- Using `time.Duration` directly in args — server gets nanoseconds. Convert per spec.\n",".claude/skills/update-ci-image/SKILL.md":"---\nname: update-ci-image\ndescription: Use when adding, updating, or removing a `redislabs/client-libs-test` image tag used by the test stack — covers every place a Redis version → image-tag mapping lives so local `make test` and GitHub CI stay in sync.\n---\n\n# Updating the CI test image\n\nThe `redislabs/client-libs-test` image is referenced in **six** places (five live config + one doc). Miss one and either local `make test` and CI diverge, or a single CI job uses the wrong image. Always edit all relevant locations in one change.\n\n## The six locations\n\n| # | File | What lives here |\n|---|------|-----------------|\n| 1 | `Makefile` | `CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:<tag>` — default for local `make test` / `make docker.start`. |\n| 2 | `docker-compose.yml` | `x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:<tag>}` — fallback when the env var is unset. Should match the Makefile default. |\n| 3 | `.github/workflows/build.yml` | `redis_version_mapping` bash assoc array (`[\"8.X.x\"]=\"<tag>\"`). Drives the `benchmark` job and the `test-redis-ce` matrix via `run-tests`. |\n| 4 | `.github/actions/run-tests/action.yml` | Identical `redis_version_mapping` array — the composite action that the matrix jobs invoke. Keep in lockstep with `build.yml`. |\n| 5 | `.github/workflows/doctests.yaml` | Hardcoded `image: redislabs/client-libs-test:<tag>` on the `redis-stack` service — doctests do not consult the mapping. |\n| 6 | `CONTRIBUTING.md` | Prose line \"By default the docker image … is `redislabs/client-libs-test:<tag>`.\" Documentation only — no CI impact, but goes stale silently. Update it to match the Makefile default. |\n\n## Tasks\n\n### Update the tag for an existing Redis version (e.g. bump 8.8 → a new release)\n\n1. `Makefile` — replace the tag after `redislabs/client-libs-test:` on the `CLIENT_LIBS_TEST_IMAGE` line.\n2. `docker-compose.yml` — replace the tag in the `:-redislabs/client-libs-test:<tag>` default.\n3. `.github/workflows/build.yml` — update the `[\"8.X.x\"]=\"<tag>\"` line in `redis_version_mapping`.\n4. `.github/actions/run-tests/action.yml` — update the matching `[\"8.X.x\"]=\"<tag>\"` line.\n5. `.github/workflows/doctests.yaml` — update the `image:` line **only if** doctests should run against this version. Doctests typically pin to the latest stable; check before changing.\n6. `CONTRIBUTING.md` — update the \"By default the docker image … is `redislabs/client-libs-test:<tag>`.\" prose line to match the new `Makefile` default. This one is easy to forget because nothing breaks when it's stale.\n\nIf the version that doctests run against (`Makefile` default) and the mapping for that same minor version diverge, you have a bug — they should resolve to the same tag.\n\n### Add a new Redis version to the CI matrix\n\n1. `.github/workflows/build.yml`:\n   - Add `- \"<X.Y>.x\"` to **both** `redis-version` matrices (the `benchmark` job and the `test-redis-ce` job — they have separate lists).\n   - Add `[\"<X.Y>.x\"]=\"<tag>\"` to `redis_version_mapping`.\n2. `.github/actions/run-tests/action.yml` — add the same `[\"<X.Y>.x\"]=\"<tag>\"` entry to its `redis_version_mapping`.\n3. If this becomes the new default for local dev, also update `Makefile` (`REDIS_VERSION ?=` and `CLIENT_LIBS_TEST_IMAGE ?=`) and `docker-compose.yml`.\n\n### Remove a Redis version\n\n1. `.github/workflows/build.yml` — drop the `- \"<X.Y>.x\"` line from both `redis-version` matrices and the `[\"<X.Y>.x\"]=\"...\"` entry.\n2. `.github/actions/run-tests/action.yml` — drop the same `[\"<X.Y>.x\"]=\"...\"` mapping entry.\n3. If `Makefile` / `docker-compose.yml` / `doctests.yaml` were pinned to this version, pick a replacement (usually the next-newest version still in the matrix) and update them too, along with the `CONTRIBUTING.md` prose line.\n\n### Switch to a custom build (e.g. `custom-26172898734-debian` for an unreleased server feature)\n\nSame as \"update the tag,\" but think about scope:\n- Custom tags usually correspond to a single Redis minor — only touch the mapping entry for that minor, not all five rows.\n- Custom tags often shouldn't be the local default if they're transient. Confirm with the user before touching `Makefile` / `docker-compose.yml`.\n- Leave `REDIS_VERSION` (the numeric `8.8` / `8.6` etc.) alone — it drives `SkipBeforeRedisVersion` gating in `main_test.go`, not the image. The custom image still reports its base version.\n\n## Verification\n\nAfter editing, search for any stale tag references:\n\n```sh\n# Use the Grep tool, not bash grep.\n# Pattern: literal old tag, e.g. \"8.8-rc1\" or \"client-libs-test:8.8\"\n```\n\nExpected matches: zero in `Makefile`, `docker-compose.yml`, `.github/**`, and `CONTRIBUTING.md`. A reference in `AGENTS.md` or the `testing` skill documentation (e.g. \"e.g. `redislabs/client-libs-test:8.8-m03`\") is illustrative — leave it unless the doc is misleading after the change.\n\n## Gotchas\n\n- **Two mappings, not one.** `build.yml` and `run-tests/action.yml` each declare their own `redis_version_mapping`. They are not deduped. Always edit both.\n- **Two matrices in `build.yml`.** The `benchmark` job and the `test-redis-ce` job each have their own `redis-version` list. Adding/removing a version means editing both.\n- **`doctests.yaml` is hardcoded.** It doesn't read the mapping. If the team intends \"doctests follow the latest stable,\" verify the tag matches `Makefile`'s default after any change.\n- **Don't change `REDIS_VERSION` when only swapping the image.** The number gates version-specific test skips (`SkipBeforeRedisVersion(8.8, ...)`). A custom image built off 8.8 should keep `REDIS_VERSION=8.8`.\n- **Env var vs. default.** Local users can override with `CLIENT_LIBS_TEST_IMAGE=...`; `docker-compose.yml` uses that env var first and falls back to its baked-in default. So the docker-compose default only matters when the Makefile isn't driving things — but it's still load-bearing for direct `docker compose up` calls. Keep it in sync.\n","AGENTS.md":"# AGENTS.md\n\nGuidance for AI coding agents (and humans) working in this repository. This is\nthe shared, tool-agnostic source of truth: Claude Code loads it through\n`CLAUDE.md` (which imports this file), and other agents (Codex, Cursor, Aider,\nZed, …) read `AGENTS.md` directly. Edit repository guidance here, not in\n`CLAUDE.md`.\n\n## Repository\n\ngo-redis is the official Redis client for Go. Module path:\n`github.com/redis/go-redis/v9` (Go 1.26+). The repo is a multi-module workspace\n— every directory containing a `go.mod` is built and tested independently:\n\n- root (`github.com/redis/go-redis/v9`) — the client library.\n- `extra/redisotel`, `extra/redisotel-native`, `extra/redisprometheus`,\n  `extra/rediscensus`, `extra/rediscmd` — instrumentation adapters with their\n  own module paths (so they can pin large telemetry deps without forcing them on\n  root consumers).\n- `internal/customvet` — custom `go vet` analyzers (also its own module).\n- `maintnotifications/e2e`, `doctests`, `fuzz`, examples under `example/` —\n  separate modules.\n\nThe Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) when running\n`test.ci`, `go_mod_tidy`, etc. When you add a dependency in one module, you\nalmost never need to update the others.\n\n## Common commands\n\nTests run against a Redis stack started via Docker Compose. Profiles in\n`docker-compose.yml` control which services come up (`standalone`, `cluster`,\n`sentinel`, `all`, `e2e`).\n\n```sh\nmake docker.start                # bring up the full test stack (profile: all)\nmake docker.stop\nmake test                        # docker.start -> test.ci -> docker.stop\nmake test.ci                     # run tests assuming containers are already up\nmake test.ci.skip-vectorsets     # when REDIS_VERSION < 8\nmake bench                       # go test -bench=. (root module only)\nmake fmt                         # gofumpt + goimports -local github.com/redis/go-redis\nmake build\nmake go_mod_tidy                 # go mod tidy across every module\n```\n\nE2E (maintenance notifications) needs the extra `cae-resp-proxy` service:\n\n```sh\nmake test.e2e                    # starts e2e profile, runs ./maintnotifications/e2e/, tears down\nmake test.e2e.docker             # subset that runs inside docker\nmake test.e2e.logic              # logic-only tests, no proxy required\n```\n\nRun a single test. The root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega`\nforks), so `go test -run` matches the Go-level wrapper and you focus a spec with\nthe Ginkgo flag:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"ZAdd\"\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\nPlain `go test` tests (most files outside the Ginkgo suite, e.g. `internal/...`,\n`maintnotifications/...`) work the usual way:\n\n```sh\ngo test -run TestConnStateMachine ./internal/pool/...\ngo test -race -run TestCircuitBreaker ./maintnotifications/...\n```\n\nEnv knobs (passed through the Makefile):\n\n- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and\n  `main_test.go` version-gating (`SkipBeforeRedisVersion` /\n  `SkipAfterRedisVersion`).\n- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g.\n  `redislabs/client-libs-test:8.8-m03`.\n- `RE_CLUSTER=true` — run against a Redis Enterprise cluster instead of the\n  docker-compose stack (the suite then skips ring/sentinel/TLS-cluster setup).\n- `RCE_DOCKER=true` — Redis CE in docker (default for `make test`).\n- `REDIS_PORT` — override the default standalone port (`6380`).\n\nCI also runs the custom vet tool:\n`go vet -vettool ./internal/customvet/customvet ./...`. The `setval` analyzer\nrequires every `Cmder` with a `Result()` to also have a `SetVal()`.\n\n## Architecture\n\n### Client types (root package)\n\nAll clients are in the root package and share most plumbing:\n\n- `Client` (`redis.go`) — single-node client.\n- `ClusterClient` (`osscluster.go`) — Redis Cluster aware. `osscluster_router.go`\n  routes commands to the right shard; `internal/routing/` handles cluster-wide\n  aggregation policies (e.g. fan-out for `KEYS`, `DBSIZE`).\n- `Ring` (`ring.go`) — client-side sharding across independent Redis nodes\n  (consistent hashing, no cluster protocol).\n- Failover client (`sentinel.go`) — Sentinel-managed failover.\n- `UniversalClient` (`universal.go`) — wrapper that picks one of the above based\n  on options.\n\nCommand surface lives in topical files: `string_commands.go`, `hash_commands.go`,\n`stream_commands.go`, `search_commands.go`, `vectorset_commands.go`, etc. Each\nfile defines methods on the shared `Cmdable` interface so every client type gets\nthe same API.\n\n### Hooks (`redis.go` `hooksMixin`)\n\nThree hook chains run around every operation: `DialHook`, `ProcessHook`,\n`ProcessPipelineHook`. Hooks are registered via `client.AddHook(...)` and chain\nin FIFO order; each hook must call `next` to continue. When a hook wraps an\nerror, it must call `cmd.SetErr(wrappedErr)` so the typed-error helpers\n(`redis.IsLoadingError`, `IsMovedError`, etc. in `error.go`) keep working through\n`errors.As`. The README has a longer pipeline-hook example.\n\n**Hook contract** (behavior is undefined otherwise — the mechanism assumes\nwell-behaved hooks rather than defending every corner case): a hook must call\n`next` (unless deliberately terminating the command), must **not** call `Close`\nor other client control methods (it runs on the goroutine `Close` waits for, so\nit deadlocks), must **not** panic, and must **not** mutate client or connection\nstate. It may observe and wrap errors only. User callbacks such as `CacheSizer`\nfollow the same no-panic rule. Client-side caching additionally requires the\n**built-in** push processor; a custom `PushNotificationProcessor` is unsupported\nwith CSC (the client logs a warning at init). See `.claude/specs/push.md`.\n\n### Connection pool (`internal/pool`)\n\nOwns dialing, idle/active connection bookkeeping, conn state (`conn_state.go`),\npubsub-conn lifecycle (`pubsub.go`), and the dial-retry/backoff logic that powers\n`DialerRetries` / `DialerRetryBackoff` (also exposed at `dial_retry_backoff.go`\nin the root). `OnConnect`, `MinIdleConns`, and the buffer-size options\n(`ReadBufferSize`/`WriteBufferSize`, default 32 KiB since v9.12) flow through\nhere.\n\n### Protocol (`internal/proto`)\n\nRESP2/RESP3 reader and writer. Push notifications (RESP3 `>`-prefixed frames) are\npeeked here and dispatched via the `push/` package. The `push.Registry` lets\ncallers register handlers for specific notification names;\n`maintnotifications/push_notification_handler.go` is how `maintnotifications`\nplugs in.\n\n### Maintenance notifications (`maintnotifications/`)\n\nThis is a non-trivial subsystem worth understanding before touching\ncluster/handoff code. It listens for RESP3 push notifications about cluster\nmaintenance (`MOVING`, `MIGRATING`, `MIGRATED`, `FAILING_OVER`, `FAILED_OVER`\nfor standalone; `SMIGRATING`, `SMIGRATED` for cluster) and performs seamless\nconnection handoff to new endpoints. Key pieces:\n\n- `manager.go` — coordinates state transitions.\n- `handoff_worker.go` — moves in-flight ops to new connections.\n- `pool_hook.go` — integrates with `internal/pool` to mark/replace connections.\n- `circuit_breaker.go` — backs off when the upstream is unhealthy.\n- `state.go` — per-connection state machine.\n- E2E coverage lives in `maintnotifications/e2e/` and drives a fault-injector /\n  RESP proxy (`cae-resp-proxy`).\n\nConfiguration is via `redis.Options.MaintNotificationsConfig`; modes are\n`ModeAuto` (default), `ModeEnabled` (require server support), `ModeDisabled`.\nRESP3 (`Protocol: 3`) is required.\n\n### Authentication (`auth/`, `internal/auth/streaming`)\n\nFour credential sources, in priority order: streaming provider (e.g. Entra ID\nvia `go-redis-entraid`), context-based provider, function provider, static\n`Username`/`Password`. The streaming provider is what enables token rotation\nwithout reconnecting — the listener in `auth/reauth_credentials_listener.go`\nissues `AUTH` on each refresh.\n\n### Internal helpers\n\n- `internal/hscan` — struct scanning for `HGETALL` results (`Scan` interface\n  re-exported as `redis.Scanner`).\n- `internal/hashtag` — extracts `{tag}` segments for cluster slot routing.\n- `internal/routing` — aggregator policies and shard pickers used by\n  `ClusterClient` for multi-shard commands.\n- `internal/otel` — small OpenTelemetry shim used to keep root free of telemetry\n  deps; full instrumentation lives in `extra/redisotel-native`.\n\n## Architectural specs\n\nRead the relevant design doc **before** changing code in that subsystem. They\ncover invariants and decisions that aren't obvious from the code, and are plain\nmarkdown any tool or editor can open:\n\n- `.claude/specs/pool.md` — connection pool: `wantConn` queue and FIFO\n  discipline, `ConnState` machine, dial retry/backoff, hook integration, the\n  re-auth/handoff coexistence contract.\n- `.claude/specs/cluster-routing.md` — slot computation, MOVED/ASK redirection,\n  request/response policies, aggregators, replica routing, topology reload,\n  cross-slot rules.\n- `.claude/specs/maintnotifications.md` — RESP3 push notification protocol, mode\n  handshake, per-conn state, handoff worker pool, circuit breaker, endpoint-type\n  resolution, cluster vs. standalone differences.\n\n## Conventions\n\n- New `Cmder` type → also implement `SetVal` (the custom vet `setval` check\n  enforces this; `SetErr` is on the embedded `baseCmd`).\n- Wrap errors with custom error types that implement `Unwrap`, or use\n  `fmt.Errorf(\"...: %w\", err)`. Always call `cmd.SetErr(...)` after wrapping so\n  typed-error checks still pass.\n- `gofumpt` + `goimports -local github.com/redis/go-redis` is the formatter\n  (`make fmt`); CI runs both.\n- Don't log directly — use `internal.Logger` (set via `redis.SetLogger`);\n  `logging.Disable()` is called in tests.\n- Version-gate Redis-version-specific tests with `SkipBeforeRedisVersion` /\n  `SkipAfterRedisVersion` rather than skipping at the suite level.\n\n### Commits and PRs\n\nConventional Commits, short and exact — `<type>(<scope>): <imperative summary>`.\nSubject ≤50 chars (hard cap 72), imperative (\"add\", not \"added\"), no trailing\nperiod. Body only when the *why* isn't obvious from the diff; wrap at 72.\n\n- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also\n  `build`, `ci`, `style`, `revert`).\n- Scope = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`,\n  `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`,\n  `deps`, `ci`, `tests`, `docs`. Omit only for genuinely cross-cutting changes.\n- Breaking change: `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line.\n  Reference issues/PRs at the end — `Closes #42`, `Refs #17`.\n- **No AI-attribution trailer.** Do not add `Co-Authored-By: …`, \"Generated with\n  …\", or any AI-attribution line to commits or PR bodies in this repo.\n\n## Repo-specific tooling\n\n`.claude/` holds shared AI config:\n\n- `commands/` — slash commands (e.g. `check-ci`, which summarizes a PR's CI).\n- `skills/` — task playbooks: `testing`, `add-command`, `commit-style`,\n  `update-ci-image`, `prepare-release`.\n- `specs/` — the architecture docs listed above.\n\nFor Claude Code, the skills auto-trigger from their descriptions. For other\ntools, each `SKILL.md` is plain markdown you can open and follow directly.\n",".claude/commands/check-ci.md":"---\ndescription: Summarize GitHub Actions CI status for the current branch's PR (build, lint, e2e, govulncheck, ...)\nargument-hint: \"[pr-number]\"\nallowed-tools: Bash(gh pr checks:*), Bash(gh pr view:*), Bash(gh run view:*), Bash(gh pr list:*), Bash(git branch:*)\n---\n\n## Context\n\n- Branch: !`git branch --show-current`\n- PR: !`gh pr view $1 --json number,title,url,state 2>/dev/null || echo \"NO_PR\"`\n- Checks (name / state / elapsed / url): !`gh pr checks $1 2>&1 || true`\n\n## Task\n\nReport the CI state for this PR. If the PR line is `NO_PR`, say there is no open PR for this branch and stop.\n\nOtherwise, from the checks block:\n\n1. **Counts first.** One line: `N passing · M failing · K pending` (and skipped/cancelled if any).\n2. **Failures and pending only.** For each check that is not passing, list its name, state, and the log URL. Skip the passing ones, they are noise.\n3. **Prioritize.** Order failures by what blocks a merge most: `test-redis-ce` (the main correctness matrix) and `E2E Tests (Mock Proxy)` first, then `lint` and `govulncheck`, then `CodeQL` / `doctests` / `check-spelling`, then everything else (benchmarks last).\n4. **Verdict.** One closing line:\n   - All green → \"All checks pass, nothing to do.\"\n   - Any red → name the single most important failing check to look at first.\n   - Only pending → say which are still running and that it is not done yet.\n\nKeep it short. No preamble, no restating this prompt. The argument `$1` is an optional PR number; when omitted, the current branch's PR is used.\n",".claude/skills/add-command/references/cluster-routing-wiring.md":"# Cluster routing for new commands\n\nRead this only when the new command is keyless, fans out, targets multiple keys, or aggregates results across shards. **Most simple single-key commands need no cluster-side change** — slot routing is inferred from the key argument automatically.\n\nThis file (the skill reference) is a wiring checklist. For the full routing *design* — slot computation, MOVED/ASK, aggregator internals — read the separate architecture spec at the repo path `.claude/specs/cluster-routing.md` before changing routing code.\n\n## When a command needs wiring\n\n| Command shape | What to wire |\n|---------------|--------------|\n| Keyless (e.g. `DBSIZE`, `FLUSHALL`, server-info commands) | Add the lowercase name to `keylessCommands` (`command.go:25`). Otherwise `ClusterClient` can't pick a node. |\n| Unusual key position (key not the first arg) | Update the key-position / key-spec maps in `command.go`. |\n| Fan-out across all shards, results aggregated | Add a request/response policy in `command_policy_resolver.go` and the matching aggregator in `internal/routing/aggregator.go` (policy declared in `internal/routing/policy.go`). |\n| Multi-key, cross-slot | Confirm the cross-slot rules in `.claude/specs/cluster-routing.md`; commands spanning slots error unless the command supports it. |\n\n## Source of truth = the command spec\n\nThe spec's `key_specs` and `command_flags` tell you the routing class:\n- empty `key_specs` → keyless → `keylessCommands`.\n- `command_flags` with no write flag → `READONLY` → eligible for replica routing.\n- multiple key specs / `numkeys`-style args → multi-key; check cross-slot handling.\n\n## Verify\n\nAfter wiring, exercise against the cluster profile:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n",".claude/skills/add-command/references/module-commands.md":"# Module commands (RediSearch / TimeSeries / VectorSet / Bloom)\n\nModule subcommands (`FT.*`, `TS.*`, `V*`, `BF.*`/`CF.*`) follow the core pattern but with three differences. Read `references/core-command-pattern.md` first. Worked example: **FTHybrid** (`search_commands.go`).\n\n## 1. Where the spec lives\n\nModule command specs are **not** in `redis/redis/src/commands/`. They live in the module's own repo:\n- RediSearch → `RediSearch/RediSearch`\n- TimeSeries → `RedisTimeSeries/RedisTimeSeries`\n- VectorSet / Bloom → their respective repos\n\nIf Step 0's redis/redis fetch 404s, the command is a module command — get the spec from the module repo or ask the user for the spec file / PR.\n\n## 2. Where the Cmder type lives\n\nModule Cmd types are defined in the **module's** `*_commands.go` file, not `command.go`. `FTHybridCmd` lives in `search_commands.go` (grep `type FTHybridCmd`), registered on the `SearchCmdable` interface at the top of that file, which is embedded in `Cmdable` (`commands.go`). TimeSeries → `timeseries_commands.go`, VectorSet → `vectorset_commands.go`. The required-method set is identical to core (SetVal/Val/Result/String/readReply/Clone).\n\n## 3. RESP2 vs RESP3 reply shapes\n\nModule commands frequently return a **map in RESP3** and a **flat array in RESP2** for the same logical reply. The parser must handle both. FTHybrid reads the raw slice, then `parseFTHybrid` walks it tolerating three shapes:\n\n```go\nfunc (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) {\n\tdata, err := rd.ReadSlice()   // generic slice read\n\tif err != nil {\n\t\treturn err\n\t}\n\tresult, cursorResult, err := parseFTHybrid(data, cmd.withCursor)\n\t// ...assign cmd.val / cmd.cursorVal...\n}\n\n// inside parseFTHybrid, per result item:\nif itemMap, ok := item.(map[string]interface{}); ok {      // RESP3 map\n\tresults = append(results, itemMap)\n} else if rawMap, ok := item.(map[interface{}]interface{}); ok {  // alt RESP3\n\t// stringify keys\n} else if itemData, ok := item.([]interface{}); ok {       // RESP2 array of k/v pairs\n\t// walk pairs\n}\n```\n\nPattern: don't assume RESP3. Try the map shape, fall back to the array (key-value pairs) shape. Tests run RESP3 by default but RESP2 must still parse for older servers.\n\n## 4. Common module conventions\n\n- Subcommand keyword is uppercase in args (`\"SEARCH\"`, `\"VSIM\"`, `\"WITHCURSOR\"`); the command token is lowercase (`\"ft.hybrid\"`).\n- Cursor-style commands carry parse state on the Cmd struct (FTHybrid's `withCursor`, `cursorVal`) — set it in the constructor from the options.\n- Module commands are almost always `FooWithArgs(ctx, index, *FooOptions)` plus a thin positional `Foo(...)`.\n- Version-gate integration tests against the **module** version, not the core Redis version.\n",".claude/skills/add-command/references/core-command-pattern.md":"# Core command pattern\n\nThe 7-step Go pattern for adding a command to the root `redis` package. Worked example: **LCS** (grep `func (c cmdable) LCS` in `string_commands.go` and `type LCSCmd struct` in `command.go`) — it uses a `cmdable` receiver, a query struct, a custom Cmder, and a registered `CmdType`. Copy its shape exactly.\n\n## 1. Method on the `cmdable` interface\n\nIn the matching `*_commands.go` file. Receiver is `cmdable` — this is what makes the command available on every client type (`Client`, `ClusterClient`, `Ring`, sentinel, pipelines). A `*Client` receiver compiles but is unreachable via `UniversalClient` / `Cmdable`.\n\n```go\nfunc (c cmdable) LCS(ctx context.Context, q *LCSQuery) *LCSCmd {\n\tcmd := NewLCSCmd(ctx, q)\n\t_ = c(ctx, cmd)\n\treturn cmd\n}\n```\n\nFor commands with many optional flags, expose `Foo(...)` for the common path plus `FooWithArgs(ctx, key, *FooArgs)` for the full surface (see `FTHybrid` / `FTHybridWithArgs` in `search_commands.go`).\n\nConventions:\n- Lowercase the command name in the args slice (`\"lcs\"`, not `\"LCS\"`); subcommand flags are uppercase (`\"LEN\"`, `\"IDX\"`, `\"MINMATCHLEN\"`).\n- Pre-size the args slice (`make([]interface{}, 3, 7)`).\n- Convert `time.Duration` to whatever the server expects — never pass it raw (server gets nanoseconds). Use `formatMs` / `formatSec` in `commands.go`.\n- Doc comment: state `Redis X.Y+` (from the spec `since`) and link the redis.io command page.\n\n## 2. New `XxxCmdable` interface goes into `Cmdable`\n\nOnly if you added a **new** `*_commands.go` file with its own interface. Embed it in `Cmdable` (`commands.go`, alphabetical in the embed list — e.g. `SearchCmdable` at `commands.go:242`). Without this the method is unreachable through `UniversalClient`.\n\n## 3. Custom Cmder type\n\nDefine only when no existing Cmd type (`*IntCmd`, `*StringCmd`, `*BoolCmd`, `*StringSliceCmd`, `*MapStringStringCmd`, …) fits the reply. Put it at the bottom of `command.go` (module commands may keep it in their own `*_commands.go`, like `FTHybridCmd`). Follow `LCSCmd`:\n\n```go\ntype LCSCmd struct {\n\tbaseCmd\n\treadType uint8      // command-specific parse state\n\tval      *LCSMatch\n}\n\nvar _ Cmder = (*LCSCmd)(nil)\n\nfunc NewLCSCmd(ctx context.Context, q *LCSQuery) *LCSCmd {\n\targs := make([]interface{}, 3, 7)\n\targs[0], args[1], args[2] = \"lcs\", q.Key1, q.Key2\n\t// ...append optional flags...\n\tcmd := &LCSCmd{readType: 1}\n\tcmd.baseCmd = baseCmd{ctx: ctx, args: args, cmdType: CmdTypeLCS}\n\treturn cmd\n}\n\nfunc (cmd *LCSCmd) SetVal(val *LCSMatch)              { cmd.val = val }\nfunc (cmd *LCSCmd) Val() *LCSMatch                    { return cmd.val }\nfunc (cmd *LCSCmd) Result() (*LCSMatch, error)        { return cmd.val, cmd.err }\nfunc (cmd *LCSCmd) String() string                    { return cmdString(cmd, cmd.val) }\nfunc (cmd *LCSCmd) readReply(rd *proto.Reader) error  { /* parse RESP */ }\nfunc (cmd *LCSCmd) Clone() Cmder                       { /* deep-copy val */ }\n```\n\nRequired methods — the `setval` analyzer (`internal/customvet/checks/setval`) fails the build if `Result` exists without `SetVal`:\n- `SetVal(...)` — hooks mutate results through it. (`SetErr` is inherited from `baseCmd`.)\n- `Val()`, `Result()` — public accessors.\n- `String()` — via `cmdString`.\n- `readReply(rd *proto.Reader) error` — RESP parser. Use `rd.ReadArrayLen`, `rd.ReadInt`, `rd.ReadString`, `rd.ReadFloat`, `rd.ReadFixedMapLen`, `rd.ReadFixedArrayLen`. Validate element counts when the protocol guarantees a fixed shape. Always propagate the reader's error — never return `nil` after a partial read.\n- `Clone()` — deep-copy `val` (including slices/maps) so pipeline/transaction reuse is safe.\n\n**CmdType:** set `cmdType: CmdTypeXxx` in the constructor. If you add a new `CmdTypeXxx` constant (`command.go`), also add the matching case in the `ExtractCommandValue` switch further down `command.go` — this is what lets hooks read the typed value. Grep `CmdTypeLCS` to find both the const declaration and its `case` in `ExtractCommandValue`; copy that shape.\n\n## 4. RESP3 vs RESP2\n\nIf the reply differs between protocols, the parser must handle both — this applies to **any** command with a protocol-dependent shape, core or module, not just modules. Typical case: a map in RESP3, a flat key/value array in RESP2. Branch on the reply type with `rd.PeekReplyType()` (check `proto.RespMap` for RESP3) or `rd.IsResp3()`. Tests run RESP3 by default but RESP2 must still parse for older servers. See `references/module-commands.md` for the FTHybrid worked example.\n\n## 5. Tests — both layers\n\n- **Unit test** in `xxx_commands_unit_test.go` (no Redis) — assert the args slice your method builds for the interesting flag combinations. Pattern: `search_commands_unit_test.go:120` captures `m.lastCmd.(*FTHybridCmd)` and checks args.\n- **Integration test** in `xxx_commands_test.go` (Ginkgo, docker stack). Gate version-specific commands: `SkipBeforeRedisVersion(8.8, \"X requires Redis 8.8+\")` (version from spec `since`). Cover happy path, errors, optional-arg combos.\n\n```sh\ngo test -run TestFTHybridWithArgsRejectsUnsupportedVectors .\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"LCS\"\n```\n\n## 6. Custom vet check\n\n```sh\ncd internal/customvet && go build .\ngo vet -vettool ./internal/customvet/customvet ./...\n```\n\nSame check CI runs at the end of `make test.ci`; catches a missing `SetVal`.\n\n## 7. Format\n\n```sh\nmake fmt\n```\n\n(`gofumpt` + `goimports -local github.com/redis/go-redis`.)\n\n## Pitfalls\n\n- Forgetting to embed the new `XxxCmdable` interface in `Cmdable` — compiles on `*Client`, silently unreachable via `UniversalClient`.\n- Forgetting `Clone()` — pipelines reuse Cmders; shared `val` causes cross-execution bugs.\n- Skipping `SetVal` because \"nothing calls it\" — hooks do, and custom-vet fails the build.\n- Returning `nil` from `readReply` after a partial read — always propagate the reader's error.\n- `time.Duration` passed raw — server gets nanoseconds; convert per spec.\n- New `CmdTypeXxx` added but not registered in the type-routing switch in `command.go`.\n"},"files":{".claude/skills/prepare-release/SKILL.md":"---\nname: prepare-release\ndescription: Use when preparing a go-redis release — cutting a new version, bumping version.go, updating the go-redis dependency version in submodule go.mod files, or writing the RELEASE-NOTES.md entry for a new vX.Y.Z. Covers choosing the next semver, finding the last release and gathering merged PRs since then, the release-notes format and what to exclude, running scripts/release.sh to bump versions, and verifying with the scripts/tag.sh dry-run. Does NOT create tags, push, or commit — publishing stays a manual maintainer step.\nallowed-tools: Bash(git describe:*), Bash(gh pr list:*)\n---\n\n# Preparing a go-redis release\n\nStage everything for a release locally, then stop before anything is published.\n\n## This skill never publishes\n\n- **Never** run `scripts/tag.sh ... -t` — the `-t` flag creates and pushes git tags.\n- **Never** `git push` anything (no commits, no tags).\n- **Never** commit on the maintainer's behalf unless they explicitly ask.\n\nThe deliverable is a reviewable diff: bumped versions plus a new `RELEASE-NOTES.md`\nentry. A human reviews it and runs the publish step.\n\n## 1. Pick the next version\n\nThe current version is the source of truth in `version.go`:\n\n```sh\ngrep 'return' version.go        # e.g. return \"9.21.0\"\n```\n\nChoose the next `vX.Y.Z` by semver, based on what shipped since the last release:\n\n- **patch** (`Z`) — bug fixes only; drop-in upgrade.\n- **minor** (`Y`) — new features, no breaking changes; drop-in upgrade.\n- **major** (`X`) — breaking changes.\n\nConfirm the level with the user if the changeset is ambiguous.\n\n## 2. Find the last release and gather changes\n\n`scripts/tag.sh` also tags every public submodule (`extra/redisotel/vX.Y.Z`, …),\nso a naive `git describe` returns a submodule tag. Match the **root** tag only:\n\n```sh\nLAST=$(git describe --tags --abbrev=0 --match 'v[0-9]*')   # e.g. v9.21.0\ngit log \"$LAST\"..HEAD --oneline\ngh pr list --state merged --limit 100 \\\n  --json number,title,author,mergedAt,url --search \"merged:>=<last-release-date>\"\n```\n\nCategorize the PRs: highlights, new features, bug fixes, performance,\ntesting/infrastructure. Exclude dependabot bumps, typo-only doc fixes, internal\nrefactors with no user-facing effect, and `dependabot[bot]` from the contributor\nlist.\n\n## 3. Write the release notes\n\nPrepend a new `# X.Y.Z (YYYY-MM-DD)` section to the **top** of `RELEASE-NOTES.md`\n(newest first; leave older entries untouched). Follow\n[`.github/RELEASE_NOTES_TEMPLATE.md`](../../../.github/RELEASE_NOTES_TEMPLATE.md)\nexactly — section order, emoji headers, the `([#PR](url)) by [@user](url)` link\nformat, and the `**Full Changelog**` compare link `${LAST}...vX.Y.Z`. Open the\nlead line with the release type and whether it is a drop-in upgrade, matching the\nexisting entries.\n\nThat template carries the full \"what to exclude\" and formatting rules — read it,\ndon't reinvent them. `release-drafter` separately auto-drafts a GitHub release\nfrom PR labels (`.github/release-drafter-config.yml`); `RELEASE-NOTES.md` is the\ncurated, hand-written record and is the file you edit.\n\n## 4. Bump versions\n\nRun the repo's bump script. It rewrites the go-redis dependency version in every\nsubmodule `go.mod`, runs `go mod tidy`, and bumps `version.go`. It does **not**\ncommit, push, or switch branches:\n\n```sh\nTAG=vX.Y.Z ./scripts/release.sh\n```\n\n## 5. Verify (dry run only)\n\nRun the tag script's **dry run** — it checks that `version.go` and every `go.mod`\nalready match the tag, and prints the tags it *would* push. No `-t`:\n\n```sh\n./scripts/tag.sh vX.Y.Z         # DRY RUN — must pass cleanly; never add -t here\nmake build\ngit diff --stat                 # review version.go, submodule go.mod, RELEASE-NOTES.md\n```\n\nFix anything the dry run flags before handing off.\n\n## 6. Hand off\n\nReport that the release is staged and list the publish steps for the maintainer\nto run themselves (this skill does not do them):\n\n1. Review `git diff`, then commit (`chore(release): vX.Y.Z` — see the\n   `commit-style` skill; no AI-attribution trailer).\n2. Open and merge the release PR.\n3. After merge, tag and push: `./scripts/tag.sh vX.Y.Z -t`.\n4. `release-drafter` publishes the GitHub release; reconcile it with the\n   `RELEASE-NOTES.md` entry if needed.\n\nStop after step 5. Do not commit, tag, or push.\n",".claude/skills/commit-style/SKILL.md":"---\nname: commit-style\ndescription: Use when writing a commit message or opening a PR in the go-redis repo — covers the Conventional-Commit format, the repo's scope vocabulary (pool, conn, sentinel, command, …), breaking-change syntax, and the rule that NO AI-attribution trailer is added.\n---\n\n# go-redis commit messages\n\nConventional Commits, short and exact — `<type>(<scope>): <imperative summary>`. Subject ≤50 chars (hard cap 72), imperative mood (\"add\", not \"added\"), no trailing period. Body only when the *why* isn't obvious from the diff (breaking change, security fix, migration, non-obvious rationale); wrap at 72, bullets `-`. State why, not what — the diff says what.\n\n- **Types:** `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also `build`, `ci`, `style`, `revert`).\n- **Scope** = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`, `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`, `deps`, `ci`, `tests`, `docs`. Omit scope only for genuinely cross-cutting changes.\n- **Breaking change:** `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line.\n- **Issues/PRs:** reference at the end — `Closes #42`, `Refs #17`.\n\n## No attribution trailer\n\nDo **not** add `Co-Authored-By: Claude …`, \"Generated with Claude Code\", or any AI-attribution line to commits or PR bodies in this repo. This overrides both the default harness behavior (which appends such trailers) and any other commit skill.\n\n## Examples\n\n```\nfix(pool): release wantConn slot on dial timeout\n\nThe waiter stayed queued after the dial deadline fired, leaking a\nslot per timed-out connection under load.\n\nCloses #3812\n```\n\n```\nfeat(streams): support explicit LIMIT 0 in XTRIM trimming\n```\n",".claude/skills/testing/SKILL.md":"---\nname: testing\ndescription: Use when running go-redis tests of any kind — the full suite, a single test, or a filtered/focused subset (e.g. only cluster, sentinel, or pool specs); bringing up or tearing down the Docker test stack; the e2e maintenance-notifications suite (including the proxy-free logic tests); building or formatting; or setting the REDIS_VERSION / RE_CLUSTER / REDIS_PORT knobs that drive the test image and version gating. Covers how to focus a Ginkgo spec and how to run plain go tests.\n---\n\n# Running tests, builds, and the Docker stack\n\nTests run against a Redis stack started via Docker Compose. Profiles in `docker-compose.yml` control which services come up (`standalone`, `cluster`, `sentinel`, `all`, `e2e`, …).\n\n## Make targets\n\n```sh\nmake docker.start          # bring up the full test stack (profile: all)\nmake docker.stop\nmake test                  # docker.start -> test.ci -> docker.stop\nmake test.ci               # run tests assuming containers are already up\nmake test.ci.skip-vectorsets   # used when REDIS_VERSION < 8\nmake bench                 # go test -bench=. (root module only)\nmake fmt                   # gofumpt + goimports (-local github.com/redis/go-redis)\nmake build\nmake go_mod_tidy           # go mod tidy across every module\n```\n\nThe Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) for `test.ci`, `go_mod_tidy`, etc.\n\n## E2E (maintenance notifications)\n\nNeeds an extra `cae-resp-proxy` service:\n\n```sh\nmake test.e2e              # auto-starts the e2e profile, runs ./maintnotifications/e2e/, tears down\nmake test.e2e.docker       # subset that runs inside docker\nmake test.e2e.logic        # logic-only tests, no proxy required\n```\n\n## Env knobs (passed through the Makefile)\n\n- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and `main_test.go` version-gating helpers (`SkipBeforeRedisVersion` / `SkipAfterRedisVersion`).\n- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g. `redislabs/client-libs-test:8.8-m03`. (To change the default tag, use the `update-ci-image` skill — it lives in several files.)\n- `RE_CLUSTER=true` — point tests at a Redis Enterprise cluster instead of the docker-compose stack. The suite then skips ring/sentinel/TLS-cluster setup.\n- `RCE_DOCKER=true` — using Redis CE in docker (default for `make test`).\n- `REDIS_PORT` — override the default standalone port (`6380`) used by `main_test.go`.\n\n## Running a single test\n\nThe root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega` forks). `go test -run` matches the Go-level wrapper, so to focus a Ginkgo spec use the Ginkgo focus flag:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"ZAdd\"\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\nPlain `go test`-style tests (most files outside the Ginkgo suite, e.g. `internal/...`, `maintnotifications/...`) work the usual way:\n\n```sh\ngo test -run TestConnStateMachine ./internal/pool/...\ngo test -race -run TestCircuitBreaker ./maintnotifications/...\n```\n\n## Version-gating\n\nGate Redis-version-specific tests with `SkipBeforeRedisVersion` / `SkipAfterRedisVersion` (driven by `REDIS_VERSION`) rather than skipping at the suite level.\n","CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nRepository guidance — layout, commands, architecture, and conventions — is\nmaintained in [`AGENTS.md`](AGENTS.md) so it stays shared across AI tools. It is\nimported below; edit `AGENTS.md`, not this file, for that content.\n\n@AGENTS.md\n\n## Claude-specific\n\n- Repo-local **skills** under `.claude/skills/` auto-trigger from their\n  descriptions — no need to invoke them manually (the set is listed in\n  `AGENTS.md`).\n- **Slash commands** under `.claude/commands/` (e.g. `/check-ci`) are available\n  in-session.\n- Architectural **specs** under `.claude/specs/` are read on demand; open the\n  relevant one before changing that subsystem.\n",".claude/skills/add-command/SKILL.md":"---\nname: add-command\ndescription: Use when adding a new Redis command (or RediSearch / TimeSeries / VectorSet / module subcommand) to go-redis — covers fetching the command spec and docs, the Cmder type, Cmdable interface wiring, RESP parsing, tests, and the custom-vet rule that enforces SetVal.\nallowed-tools: WebFetch\n---\n\n# Adding a new Redis command\n\nRouter for adding a Redis command to the root `redis` package. Read the reference file for the area you're touching — don't load all of them.\n\n## Step 0 — Get the command spec AND docs FIRST\n\nBefore writing any Go, know the exact command shape (arguments, optional flags, reply structure, since-version, key positions) **and** its documented semantics (what each reply field means, RESP2-vs-RESP3 differences, examples). Fetch **two** sources — the machine-readable spec and the prose docs. They cover different gaps: the spec nails arguments and key positions, the docs nail what the reply actually looks like.\n\n### A. Machine-readable spec (arguments, key specs, reply schema)\n\nResolve the spec in this order:\n\n1. **Spec file argument** — if the user passed a path to a spec/JSON file, read it.\n2. **PR URL argument** — if the user passed a `github.com/redis/redis` PR (or other repo) URL, fetch the diff and read the command definition + `src/commands/<cmd>.json` it adds.\n3. **No argument** — fetch the machine-readable spec from redis/redis:\n   ```\n   https://raw.githubusercontent.com/redis/redis/unstable/src/commands/<command>.json\n   ```\n   Container subcommands use `<container>-<sub>.json` (e.g. `client-info.json`). **A 404 means it's a module command** (RediSearch, TimeSeries, VectorSet, Bloom) — those specs live in the **module's own repo**, not redis/redis. Switch to the module repo (see `references/module-commands.md` §1) or ask the user for the spec/PR. Don't retry the redis/redis URL.\n\n### B. Prose docs (semantics, return value, RESP2/RESP3 reply, examples)\n\nThe JSON `reply_schema` is often thin or missing; the docs spell out what the reply actually is — including separate **RESP2 Reply** and **RESP3 Reply** sections, which decide your `readReply` and any module RESP2-vs-RESP3 handling. Fetch the command's doc page:\n\n```\nhttps://redis.io/docs/latest/commands/<command>/\n```\n\nRaw markdown source (alternative, good for diffing or when the rendered page is noisy):\n`https://raw.githubusercontent.com/redis/redis-doc/master/commands/<command>.md`.\n**Module commands** (RediSearch, TimeSeries, …) are documented under their own\npath on redis.io (e.g. `/docs/latest/commands/ft.search/`) or in the module repo\n— see `references/module-commands.md`.\n\nRead the **Return value / RESP2 Reply / RESP3 Reply** sections plus the examples, and reconcile them against the JSON `reply_schema`. When the two disagree, the docs' reply description (and a quick `redis-cli` check) win.\n\n### Map both sources to the implementation\n\n| Source | Drives |\n|--------|--------|\n| spec `arguments` | method signature, args-slice build order, optional `FooArgs` struct |\n| spec `reply_schema` + docs **Return value** | the `readReply` parser and Cmder result type |\n| docs **RESP2 Reply / RESP3 Reply** | RESP2-vs-RESP3 branching in `readReply` (see `references/module-commands.md`) |\n| spec `since` + docs `@history` | `SkipBeforeRedisVersion(...)` in the integration test, doc comment |\n| spec `key_specs` | key-position maps in `command.go`; cluster routing |\n| spec `command_flags` (e.g. `READONLY`, no key) | keyless / fan-out handling, cluster routing |\n| docs examples | integration-test cases and expected values |\n\nIf you can't get either source, STOP and ask the user — guessing the reply shape produces a broken `readReply`.\n\n## Decide before you start\n\n1. **Which `*_commands.go` file?** — pick the existing file matching the data type (`string_commands.go`, `hash_commands.go`, `search_commands.go`, …). Only create a new file for a genuinely new category. New files need a matching `XxxCmdable` interface embedded in `Cmdable` (`commands.go`).\n2. **Reuse an existing Cmder, or define a new one?** — simple replies (`int`, `string`, `bool`, `[]string`, map) reuse `*IntCmd`, `*StringCmd`, `*BoolCmd`, `*StringSliceCmd`, `*MapStringStringCmd`, … Define a new Cmder only for a structured reply that no existing type fits.\n3. **`Foo` vs `FooWithArgs`?** — for commands with optional flags, expose a positional `Foo(...)` for the common path plus `FooWithArgs(ctx, key, *FooArgs)` for the full surface.\n\n## Reference files\n\n| Read this | When |\n|-----------|------|\n| `references/core-command-pattern.md` | Always — the 7-step Go pattern (interface method, Cmder type, RESP parsing, tests, vet, fmt). Worked example: `LCS`. |\n| `references/module-commands.md` | Adding a RediSearch / TimeSeries / VectorSet / Bloom subcommand — naming, RESP2-vs-RESP3 shape differences, where the Cmd type lives. |\n| `references/cluster-routing-wiring.md` | Command is keyless, fans out, multi-key, or aggregates across shards. |\n\nWhen several apply, read in order: core → module → cluster.\n\n## Pitfalls (full list in core-command-pattern.md)\n\n- Forgetting to embed the new `XxxCmdable` interface in `Cmdable` — compiles on `*Client`, silently unreachable via `UniversalClient`.\n- Forgetting `Clone()` — pipelines reuse Cmders; shared `val` causes cross-execution bugs.\n- Skipping `SetVal` because \"nothing calls it\" — hooks do, and the `setval` custom-vet check fails the build.\n- Using `time.Duration` directly in args — server gets nanoseconds. Convert per spec.\n",".claude/skills/update-ci-image/SKILL.md":"---\nname: update-ci-image\ndescription: Use when adding, updating, or removing a `redislabs/client-libs-test` image tag used by the test stack — covers every place a Redis version → image-tag mapping lives so local `make test` and GitHub CI stay in sync.\n---\n\n# Updating the CI test image\n\nThe `redislabs/client-libs-test` image is referenced in **six** places (five live config + one doc). Miss one and either local `make test` and CI diverge, or a single CI job uses the wrong image. Always edit all relevant locations in one change.\n\n## The six locations\n\n| # | File | What lives here |\n|---|------|-----------------|\n| 1 | `Makefile` | `CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:<tag>` — default for local `make test` / `make docker.start`. |\n| 2 | `docker-compose.yml` | `x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:<tag>}` — fallback when the env var is unset. Should match the Makefile default. |\n| 3 | `.github/workflows/build.yml` | `redis_version_mapping` bash assoc array (`[\"8.X.x\"]=\"<tag>\"`). Drives the `benchmark` job and the `test-redis-ce` matrix via `run-tests`. |\n| 4 | `.github/actions/run-tests/action.yml` | Identical `redis_version_mapping` array — the composite action that the matrix jobs invoke. Keep in lockstep with `build.yml`. |\n| 5 | `.github/workflows/doctests.yaml` | Hardcoded `image: redislabs/client-libs-test:<tag>` on the `redis-stack` service — doctests do not consult the mapping. |\n| 6 | `CONTRIBUTING.md` | Prose line \"By default the docker image … is `redislabs/client-libs-test:<tag>`.\" Documentation only — no CI impact, but goes stale silently. Update it to match the Makefile default. |\n\n## Tasks\n\n### Update the tag for an existing Redis version (e.g. bump 8.8 → a new release)\n\n1. `Makefile` — replace the tag after `redislabs/client-libs-test:` on the `CLIENT_LIBS_TEST_IMAGE` line.\n2. `docker-compose.yml` — replace the tag in the `:-redislabs/client-libs-test:<tag>` default.\n3. `.github/workflows/build.yml` — update the `[\"8.X.x\"]=\"<tag>\"` line in `redis_version_mapping`.\n4. `.github/actions/run-tests/action.yml` — update the matching `[\"8.X.x\"]=\"<tag>\"` line.\n5. `.github/workflows/doctests.yaml` — update the `image:` line **only if** doctests should run against this version. Doctests typically pin to the latest stable; check before changing.\n6. `CONTRIBUTING.md` — update the \"By default the docker image … is `redislabs/client-libs-test:<tag>`.\" prose line to match the new `Makefile` default. This one is easy to forget because nothing breaks when it's stale.\n\nIf the version that doctests run against (`Makefile` default) and the mapping for that same minor version diverge, you have a bug — they should resolve to the same tag.\n\n### Add a new Redis version to the CI matrix\n\n1. `.github/workflows/build.yml`:\n   - Add `- \"<X.Y>.x\"` to **both** `redis-version` matrices (the `benchmark` job and the `test-redis-ce` job — they have separate lists).\n   - Add `[\"<X.Y>.x\"]=\"<tag>\"` to `redis_version_mapping`.\n2. `.github/actions/run-tests/action.yml` — add the same `[\"<X.Y>.x\"]=\"<tag>\"` entry to its `redis_version_mapping`.\n3. If this becomes the new default for local dev, also update `Makefile` (`REDIS_VERSION ?=` and `CLIENT_LIBS_TEST_IMAGE ?=`) and `docker-compose.yml`.\n\n### Remove a Redis version\n\n1. `.github/workflows/build.yml` — drop the `- \"<X.Y>.x\"` line from both `redis-version` matrices and the `[\"<X.Y>.x\"]=\"...\"` entry.\n2. `.github/actions/run-tests/action.yml` — drop the same `[\"<X.Y>.x\"]=\"...\"` mapping entry.\n3. If `Makefile` / `docker-compose.yml` / `doctests.yaml` were pinned to this version, pick a replacement (usually the next-newest version still in the matrix) and update them too, along with the `CONTRIBUTING.md` prose line.\n\n### Switch to a custom build (e.g. `custom-26172898734-debian` for an unreleased server feature)\n\nSame as \"update the tag,\" but think about scope:\n- Custom tags usually correspond to a single Redis minor — only touch the mapping entry for that minor, not all five rows.\n- Custom tags often shouldn't be the local default if they're transient. Confirm with the user before touching `Makefile` / `docker-compose.yml`.\n- Leave `REDIS_VERSION` (the numeric `8.8` / `8.6` etc.) alone — it drives `SkipBeforeRedisVersion` gating in `main_test.go`, not the image. The custom image still reports its base version.\n\n## Verification\n\nAfter editing, search for any stale tag references:\n\n```sh\n# Use the Grep tool, not bash grep.\n# Pattern: literal old tag, e.g. \"8.8-rc1\" or \"client-libs-test:8.8\"\n```\n\nExpected matches: zero in `Makefile`, `docker-compose.yml`, `.github/**`, and `CONTRIBUTING.md`. A reference in `AGENTS.md` or the `testing` skill documentation (e.g. \"e.g. `redislabs/client-libs-test:8.8-m03`\") is illustrative — leave it unless the doc is misleading after the change.\n\n## Gotchas\n\n- **Two mappings, not one.** `build.yml` and `run-tests/action.yml` each declare their own `redis_version_mapping`. They are not deduped. Always edit both.\n- **Two matrices in `build.yml`.** The `benchmark` job and the `test-redis-ce` job each have their own `redis-version` list. Adding/removing a version means editing both.\n- **`doctests.yaml` is hardcoded.** It doesn't read the mapping. If the team intends \"doctests follow the latest stable,\" verify the tag matches `Makefile`'s default after any change.\n- **Don't change `REDIS_VERSION` when only swapping the image.** The number gates version-specific test skips (`SkipBeforeRedisVersion(8.8, ...)`). A custom image built off 8.8 should keep `REDIS_VERSION=8.8`.\n- **Env var vs. default.** Local users can override with `CLIENT_LIBS_TEST_IMAGE=...`; `docker-compose.yml` uses that env var first and falls back to its baked-in default. So the docker-compose default only matters when the Makefile isn't driving things — but it's still load-bearing for direct `docker compose up` calls. Keep it in sync.\n","AGENTS.md":"# AGENTS.md\n\nGuidance for AI coding agents (and humans) working in this repository. This is\nthe shared, tool-agnostic source of truth: Claude Code loads it through\n`CLAUDE.md` (which imports this file), and other agents (Codex, Cursor, Aider,\nZed, …) read `AGENTS.md` directly. Edit repository guidance here, not in\n`CLAUDE.md`.\n\n## Repository\n\ngo-redis is the official Redis client for Go. Module path:\n`github.com/redis/go-redis/v9` (Go 1.26+). The repo is a multi-module workspace\n— every directory containing a `go.mod` is built and tested independently:\n\n- root (`github.com/redis/go-redis/v9`) — the client library.\n- `extra/redisotel`, `extra/redisotel-native`, `extra/redisprometheus`,\n  `extra/rediscensus`, `extra/rediscmd` — instrumentation adapters with their\n  own module paths (so they can pin large telemetry deps without forcing them on\n  root consumers).\n- `internal/customvet` — custom `go vet` analyzers (also its own module).\n- `maintnotifications/e2e`, `doctests`, `fuzz`, examples under `example/` —\n  separate modules.\n\nThe Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) when running\n`test.ci`, `go_mod_tidy`, etc. When you add a dependency in one module, you\nalmost never need to update the others.\n\n## Common commands\n\nTests run against a Redis stack started via Docker Compose. Profiles in\n`docker-compose.yml` control which services come up (`standalone`, `cluster`,\n`sentinel`, `all`, `e2e`).\n\n```sh\nmake docker.start                # bring up the full test stack (profile: all)\nmake docker.stop\nmake test                        # docker.start -> test.ci -> docker.stop\nmake test.ci                     # run tests assuming containers are already up\nmake test.ci.skip-vectorsets     # when REDIS_VERSION < 8\nmake bench                       # go test -bench=. (root module only)\nmake fmt                         # gofumpt + goimports -local github.com/redis/go-redis\nmake build\nmake go_mod_tidy                 # go mod tidy across every module\n```\n\nE2E (maintenance notifications) needs the extra `cae-resp-proxy` service:\n\n```sh\nmake test.e2e                    # starts e2e profile, runs ./maintnotifications/e2e/, tears down\nmake test.e2e.docker             # subset that runs inside docker\nmake test.e2e.logic              # logic-only tests, no proxy required\n```\n\nRun a single test. The root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega`\nforks), so `go test -run` matches the Go-level wrapper and you focus a spec with\nthe Ginkgo flag:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"ZAdd\"\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\nPlain `go test` tests (most files outside the Ginkgo suite, e.g. `internal/...`,\n`maintnotifications/...`) work the usual way:\n\n```sh\ngo test -run TestConnStateMachine ./internal/pool/...\ngo test -race -run TestCircuitBreaker ./maintnotifications/...\n```\n\nEnv knobs (passed through the Makefile):\n\n- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and\n  `main_test.go` version-gating (`SkipBeforeRedisVersion` /\n  `SkipAfterRedisVersion`).\n- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g.\n  `redislabs/client-libs-test:8.8-m03`.\n- `RE_CLUSTER=true` — run against a Redis Enterprise cluster instead of the\n  docker-compose stack (the suite then skips ring/sentinel/TLS-cluster setup).\n- `RCE_DOCKER=true` — Redis CE in docker (default for `make test`).\n- `REDIS_PORT` — override the default standalone port (`6380`).\n\nCI also runs the custom vet tool:\n`go vet -vettool ./internal/customvet/customvet ./...`. The `setval` analyzer\nrequires every `Cmder` with a `Result()` to also have a `SetVal()`.\n\n## Architecture\n\n### Client types (root package)\n\nAll clients are in the root package and share most plumbing:\n\n- `Client` (`redis.go`) — single-node client.\n- `ClusterClient` (`osscluster.go`) — Redis Cluster aware. `osscluster_router.go`\n  routes commands to the right shard; `internal/routing/` handles cluster-wide\n  aggregation policies (e.g. fan-out for `KEYS`, `DBSIZE`).\n- `Ring` (`ring.go`) — client-side sharding across independent Redis nodes\n  (consistent hashing, no cluster protocol).\n- Failover client (`sentinel.go`) — Sentinel-managed failover.\n- `UniversalClient` (`universal.go`) — wrapper that picks one of the above based\n  on options.\n\nCommand surface lives in topical files: `string_commands.go`, `hash_commands.go`,\n`stream_commands.go`, `search_commands.go`, `vectorset_commands.go`, etc. Each\nfile defines methods on the shared `Cmdable` interface so every client type gets\nthe same API.\n\n### Hooks (`redis.go` `hooksMixin`)\n\nThree hook chains run around every operation: `DialHook`, `ProcessHook`,\n`ProcessPipelineHook`. Hooks are registered via `client.AddHook(...)` and chain\nin FIFO order; each hook must call `next` to continue. When a hook wraps an\nerror, it must call `cmd.SetErr(wrappedErr)` so the typed-error helpers\n(`redis.IsLoadingError`, `IsMovedError`, etc. in `error.go`) keep working through\n`errors.As`. The README has a longer pipeline-hook example.\n\n**Hook contract** (behavior is undefined otherwise — the mechanism assumes\nwell-behaved hooks rather than defending every corner case): a hook must call\n`next` (unless deliberately terminating the command), must **not** call `Close`\nor other client control methods (it runs on the goroutine `Close` waits for, so\nit deadlocks), must **not** panic, and must **not** mutate client or connection\nstate. It may observe and wrap errors only. User callbacks such as `CacheSizer`\nfollow the same no-panic rule. Client-side caching additionally requires the\n**built-in** push processor; a custom `PushNotificationProcessor` is unsupported\nwith CSC (the client logs a warning at init). See `.claude/specs/push.md`.\n\n### Connection pool (`internal/pool`)\n\nOwns dialing, idle/active connection bookkeeping, conn state (`conn_state.go`),\npubsub-conn lifecycle (`pubsub.go`), and the dial-retry/backoff logic that powers\n`DialerRetries` / `DialerRetryBackoff` (also exposed at `dial_retry_backoff.go`\nin the root). `OnConnect`, `MinIdleConns`, and the buffer-size options\n(`ReadBufferSize`/`WriteBufferSize`, default 32 KiB since v9.12) flow through\nhere.\n\n### Protocol (`internal/proto`)\n\nRESP2/RESP3 reader and writer. Push notifications (RESP3 `>`-prefixed frames) are\npeeked here and dispatched via the `push/` package. The `push.Registry` lets\ncallers register handlers for specific notification names;\n`maintnotifications/push_notification_handler.go` is how `maintnotifications`\nplugs in.\n\n### Maintenance notifications (`maintnotifications/`)\n\nThis is a non-trivial subsystem worth understanding before touching\ncluster/handoff code. It listens for RESP3 push notifications about cluster\nmaintenance (`MOVING`, `MIGRATING`, `MIGRATED`, `FAILING_OVER`, `FAILED_OVER`\nfor standalone; `SMIGRATING`, `SMIGRATED` for cluster) and performs seamless\nconnection handoff to new endpoints. Key pieces:\n\n- `manager.go` — coordinates state transitions.\n- `handoff_worker.go` — moves in-flight ops to new connections.\n- `pool_hook.go` — integrates with `internal/pool` to mark/replace connections.\n- `circuit_breaker.go` — backs off when the upstream is unhealthy.\n- `state.go` — per-connection state machine.\n- E2E coverage lives in `maintnotifications/e2e/` and drives a fault-injector /\n  RESP proxy (`cae-resp-proxy`).\n\nConfiguration is via `redis.Options.MaintNotificationsConfig`; modes are\n`ModeAuto` (default), `ModeEnabled` (require server support), `ModeDisabled`.\nRESP3 (`Protocol: 3`) is required.\n\n### Authentication (`auth/`, `internal/auth/streaming`)\n\nFour credential sources, in priority order: streaming provider (e.g. Entra ID\nvia `go-redis-entraid`), context-based provider, function provider, static\n`Username`/`Password`. The streaming provider is what enables token rotation\nwithout reconnecting — the listener in `auth/reauth_credentials_listener.go`\nissues `AUTH` on each refresh.\n\n### Internal helpers\n\n- `internal/hscan` — struct scanning for `HGETALL` results (`Scan` interface\n  re-exported as `redis.Scanner`).\n- `internal/hashtag` — extracts `{tag}` segments for cluster slot routing.\n- `internal/routing` — aggregator policies and shard pickers used by\n  `ClusterClient` for multi-shard commands.\n- `internal/otel` — small OpenTelemetry shim used to keep root free of telemetry\n  deps; full instrumentation lives in `extra/redisotel-native`.\n\n## Architectural specs\n\nRead the relevant design doc **before** changing code in that subsystem. They\ncover invariants and decisions that aren't obvious from the code, and are plain\nmarkdown any tool or editor can open:\n\n- `.claude/specs/pool.md` — connection pool: `wantConn` queue and FIFO\n  discipline, `ConnState` machine, dial retry/backoff, hook integration, the\n  re-auth/handoff coexistence contract.\n- `.claude/specs/cluster-routing.md` — slot computation, MOVED/ASK redirection,\n  request/response policies, aggregators, replica routing, topology reload,\n  cross-slot rules.\n- `.claude/specs/maintnotifications.md` — RESP3 push notification protocol, mode\n  handshake, per-conn state, handoff worker pool, circuit breaker, endpoint-type\n  resolution, cluster vs. standalone differences.\n\n## Conventions\n\n- New `Cmder` type → also implement `SetVal` (the custom vet `setval` check\n  enforces this; `SetErr` is on the embedded `baseCmd`).\n- Wrap errors with custom error types that implement `Unwrap`, or use\n  `fmt.Errorf(\"...: %w\", err)`. Always call `cmd.SetErr(...)` after wrapping so\n  typed-error checks still pass.\n- `gofumpt` + `goimports -local github.com/redis/go-redis` is the formatter\n  (`make fmt`); CI runs both.\n- Don't log directly — use `internal.Logger` (set via `redis.SetLogger`);\n  `logging.Disable()` is called in tests.\n- Version-gate Redis-version-specific tests with `SkipBeforeRedisVersion` /\n  `SkipAfterRedisVersion` rather than skipping at the suite level.\n\n### Commits and PRs\n\nConventional Commits, short and exact — `<type>(<scope>): <imperative summary>`.\nSubject ≤50 chars (hard cap 72), imperative (\"add\", not \"added\"), no trailing\nperiod. Body only when the *why* isn't obvious from the diff; wrap at 72.\n\n- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also\n  `build`, `ci`, `style`, `revert`).\n- Scope = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`,\n  `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`,\n  `deps`, `ci`, `tests`, `docs`. Omit only for genuinely cross-cutting changes.\n- Breaking change: `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line.\n  Reference issues/PRs at the end — `Closes #42`, `Refs #17`.\n- **No AI-attribution trailer.** Do not add `Co-Authored-By: …`, \"Generated with\n  …\", or any AI-attribution line to commits or PR bodies in this repo.\n\n## Repo-specific tooling\n\n`.claude/` holds shared AI config:\n\n- `commands/` — slash commands (e.g. `check-ci`, which summarizes a PR's CI).\n- `skills/` — task playbooks: `testing`, `add-command`, `commit-style`,\n  `update-ci-image`, `prepare-release`.\n- `specs/` — the architecture docs listed above.\n\nFor Claude Code, the skills auto-trigger from their descriptions. For other\ntools, each `SKILL.md` is plain markdown you can open and follow directly.\n",".claude/commands/check-ci.md":"---\ndescription: Summarize GitHub Actions CI status for the current branch's PR (build, lint, e2e, govulncheck, ...)\nargument-hint: \"[pr-number]\"\nallowed-tools: Bash(gh pr checks:*), Bash(gh pr view:*), Bash(gh run view:*), Bash(gh pr list:*), Bash(git branch:*)\n---\n\n## Context\n\n- Branch: !`git branch --show-current`\n- PR: !`gh pr view $1 --json number,title,url,state 2>/dev/null || echo \"NO_PR\"`\n- Checks (name / state / elapsed / url): !`gh pr checks $1 2>&1 || true`\n\n## Task\n\nReport the CI state for this PR. If the PR line is `NO_PR`, say there is no open PR for this branch and stop.\n\nOtherwise, from the checks block:\n\n1. **Counts first.** One line: `N passing · M failing · K pending` (and skipped/cancelled if any).\n2. **Failures and pending only.** For each check that is not passing, list its name, state, and the log URL. Skip the passing ones, they are noise.\n3. **Prioritize.** Order failures by what blocks a merge most: `test-redis-ce` (the main correctness matrix) and `E2E Tests (Mock Proxy)` first, then `lint` and `govulncheck`, then `CodeQL` / `doctests` / `check-spelling`, then everything else (benchmarks last).\n4. **Verdict.** One closing line:\n   - All green → \"All checks pass, nothing to do.\"\n   - Any red → name the single most important failing check to look at first.\n   - Only pending → say which are still running and that it is not done yet.\n\nKeep it short. No preamble, no restating this prompt. The argument `$1` is an optional PR number; when omitted, the current branch's PR is used.\n",".claude/skills/add-command/references/cluster-routing-wiring.md":"# Cluster routing for new commands\n\nRead this only when the new command is keyless, fans out, targets multiple keys, or aggregates results across shards. **Most simple single-key commands need no cluster-side change** — slot routing is inferred from the key argument automatically.\n\nThis file (the skill reference) is a wiring checklist. For the full routing *design* — slot computation, MOVED/ASK, aggregator internals — read the separate architecture spec at the repo path `.claude/specs/cluster-routing.md` before changing routing code.\n\n## When a command needs wiring\n\n| Command shape | What to wire |\n|---------------|--------------|\n| Keyless (e.g. `DBSIZE`, `FLUSHALL`, server-info commands) | Add the lowercase name to `keylessCommands` (`command.go:25`). Otherwise `ClusterClient` can't pick a node. |\n| Unusual key position (key not the first arg) | Update the key-position / key-spec maps in `command.go`. |\n| Fan-out across all shards, results aggregated | Add a request/response policy in `command_policy_resolver.go` and the matching aggregator in `internal/routing/aggregator.go` (policy declared in `internal/routing/policy.go`). |\n| Multi-key, cross-slot | Confirm the cross-slot rules in `.claude/specs/cluster-routing.md`; commands spanning slots error unless the command supports it. |\n\n## Source of truth = the command spec\n\nThe spec's `key_specs` and `command_flags` tell you the routing class:\n- empty `key_specs` → keyless → `keylessCommands`.\n- `command_flags` with no write flag → `READONLY` → eligible for replica routing.\n- multiple key specs / `numkeys`-style args → multi-key; check cross-slot handling.\n\n## Verify\n\nAfter wiring, exercise against the cluster profile:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n",".claude/skills/add-command/references/module-commands.md":"# Module commands (RediSearch / TimeSeries / VectorSet / Bloom)\n\nModule subcommands (`FT.*`, `TS.*`, `V*`, `BF.*`/`CF.*`) follow the core pattern but with three differences. Read `references/core-command-pattern.md` first. Worked example: **FTHybrid** (`search_commands.go`).\n\n## 1. Where the spec lives\n\nModule command specs are **not** in `redis/redis/src/commands/`. They live in the module's own repo:\n- RediSearch → `RediSearch/RediSearch`\n- TimeSeries → `RedisTimeSeries/RedisTimeSeries`\n- VectorSet / Bloom → their respective repos\n\nIf Step 0's redis/redis fetch 404s, the command is a module command — get the spec from the module repo or ask the user for the spec file / PR.\n\n## 2. Where the Cmder type lives\n\nModule Cmd types are defined in the **module's** `*_commands.go` file, not `command.go`. `FTHybridCmd` lives in `search_commands.go` (grep `type FTHybridCmd`), registered on the `SearchCmdable` interface at the top of that file, which is embedded in `Cmdable` (`commands.go`). TimeSeries → `timeseries_commands.go`, VectorSet → `vectorset_commands.go`. The required-method set is identical to core (SetVal/Val/Result/String/readReply/Clone).\n\n## 3. RESP2 vs RESP3 reply shapes\n\nModule commands frequently return a **map in RESP3** and a **flat array in RESP2** for the same logical reply. The parser must handle both. FTHybrid reads the raw slice, then `parseFTHybrid` walks it tolerating three shapes:\n\n```go\nfunc (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) {\n\tdata, err := rd.ReadSlice()   // generic slice read\n\tif err != nil {\n\t\treturn err\n\t}\n\tresult, cursorResult, err := parseFTHybrid(data, cmd.withCursor)\n\t// ...assign cmd.val / cmd.cursorVal...\n}\n\n// inside parseFTHybrid, per result item:\nif itemMap, ok := item.(map[string]interface{}); ok {      // RESP3 map\n\tresults = append(results, itemMap)\n} else if rawMap, ok := item.(map[interface{}]interface{}); ok {  // alt RESP3\n\t// stringify keys\n} else if itemData, ok := item.([]interface{}); ok {       // RESP2 array of k/v pairs\n\t// walk pairs\n}\n```\n\nPattern: don't assume RESP3. Try the map shape, fall back to the array (key-value pairs) shape. Tests run RESP3 by default but RESP2 must still parse for older servers.\n\n## 4. Common module conventions\n\n- Subcommand keyword is uppercase in args (`\"SEARCH\"`, `\"VSIM\"`, `\"WITHCURSOR\"`); the command token is lowercase (`\"ft.hybrid\"`).\n- Cursor-style commands carry parse state on the Cmd struct (FTHybrid's `withCursor`, `cursorVal`) — set it in the constructor from the options.\n- Module commands are almost always `FooWithArgs(ctx, index, *FooOptions)` plus a thin positional `Foo(...)`.\n- Version-gate integration tests against the **module** version, not the core Redis version.\n",".claude/skills/add-command/references/core-command-pattern.md":"# Core command pattern\n\nThe 7-step Go pattern for adding a command to the root `redis` package. Worked example: **LCS** (grep `func (c cmdable) LCS` in `string_commands.go` and `type LCSCmd struct` in `command.go`) — it uses a `cmdable` receiver, a query struct, a custom Cmder, and a registered `CmdType`. Copy its shape exactly.\n\n## 1. Method on the `cmdable` interface\n\nIn the matching `*_commands.go` file. Receiver is `cmdable` — this is what makes the command available on every client type (`Client`, `ClusterClient`, `Ring`, sentinel, pipelines). A `*Client` receiver compiles but is unreachable via `UniversalClient` / `Cmdable`.\n\n```go\nfunc (c cmdable) LCS(ctx context.Context, q *LCSQuery) *LCSCmd {\n\tcmd := NewLCSCmd(ctx, q)\n\t_ = c(ctx, cmd)\n\treturn cmd\n}\n```\n\nFor commands with many optional flags, expose `Foo(...)` for the common path plus `FooWithArgs(ctx, key, *FooArgs)` for the full surface (see `FTHybrid` / `FTHybridWithArgs` in `search_commands.go`).\n\nConventions:\n- Lowercase the command name in the args slice (`\"lcs\"`, not `\"LCS\"`); subcommand flags are uppercase (`\"LEN\"`, `\"IDX\"`, `\"MINMATCHLEN\"`).\n- Pre-size the args slice (`make([]interface{}, 3, 7)`).\n- Convert `time.Duration` to whatever the server expects — never pass it raw (server gets nanoseconds). Use `formatMs` / `formatSec` in `commands.go`.\n- Doc comment: state `Redis X.Y+` (from the spec `since`) and link the redis.io command page.\n\n## 2. New `XxxCmdable` interface goes into `Cmdable`\n\nOnly if you added a **new** `*_commands.go` file with its own interface. Embed it in `Cmdable` (`commands.go`, alphabetical in the embed list — e.g. `SearchCmdable` at `commands.go:242`). Without this the method is unreachable through `UniversalClient`.\n\n## 3. Custom Cmder type\n\nDefine only when no existing Cmd type (`*IntCmd`, `*StringCmd`, `*BoolCmd`, `*StringSliceCmd`, `*MapStringStringCmd`, …) fits the reply. Put it at the bottom of `command.go` (module commands may keep it in their own `*_commands.go`, like `FTHybridCmd`). Follow `LCSCmd`:\n\n```go\ntype LCSCmd struct {\n\tbaseCmd\n\treadType uint8      // command-specific parse state\n\tval      *LCSMatch\n}\n\nvar _ Cmder = (*LCSCmd)(nil)\n\nfunc NewLCSCmd(ctx context.Context, q *LCSQuery) *LCSCmd {\n\targs := make([]interface{}, 3, 7)\n\targs[0], args[1], args[2] = \"lcs\", q.Key1, q.Key2\n\t// ...append optional flags...\n\tcmd := &LCSCmd{readType: 1}\n\tcmd.baseCmd = baseCmd{ctx: ctx, args: args, cmdType: CmdTypeLCS}\n\treturn cmd\n}\n\nfunc (cmd *LCSCmd) SetVal(val *LCSMatch)              { cmd.val = val }\nfunc (cmd *LCSCmd) Val() *LCSMatch                    { return cmd.val }\nfunc (cmd *LCSCmd) Result() (*LCSMatch, error)        { return cmd.val, cmd.err }\nfunc (cmd *LCSCmd) String() string                    { return cmdString(cmd, cmd.val) }\nfunc (cmd *LCSCmd) readReply(rd *proto.Reader) error  { /* parse RESP */ }\nfunc (cmd *LCSCmd) Clone() Cmder                       { /* deep-copy val */ }\n```\n\nRequired methods — the `setval` analyzer (`internal/customvet/checks/setval`) fails the build if `Result` exists without `SetVal`:\n- `SetVal(...)` — hooks mutate results through it. (`SetErr` is inherited from `baseCmd`.)\n- `Val()`, `Result()` — public accessors.\n- `String()` — via `cmdString`.\n- `readReply(rd *proto.Reader) error` — RESP parser. Use `rd.ReadArrayLen`, `rd.ReadInt`, `rd.ReadString`, `rd.ReadFloat`, `rd.ReadFixedMapLen`, `rd.ReadFixedArrayLen`. Validate element counts when the protocol guarantees a fixed shape. Always propagate the reader's error — never return `nil` after a partial read.\n- `Clone()` — deep-copy `val` (including slices/maps) so pipeline/transaction reuse is safe.\n\n**CmdType:** set `cmdType: CmdTypeXxx` in the constructor. If you add a new `CmdTypeXxx` constant (`command.go`), also add the matching case in the `ExtractCommandValue` switch further down `command.go` — this is what lets hooks read the typed value. Grep `CmdTypeLCS` to find both the const declaration and its `case` in `ExtractCommandValue`; copy that shape.\n\n## 4. RESP3 vs RESP2\n\nIf the reply differs between protocols, the parser must handle both — this applies to **any** command with a protocol-dependent shape, core or module, not just modules. Typical case: a map in RESP3, a flat key/value array in RESP2. Branch on the reply type with `rd.PeekReplyType()` (check `proto.RespMap` for RESP3) or `rd.IsResp3()`. Tests run RESP3 by default but RESP2 must still parse for older servers. See `references/module-commands.md` for the FTHybrid worked example.\n\n## 5. Tests — both layers\n\n- **Unit test** in `xxx_commands_unit_test.go` (no Redis) — assert the args slice your method builds for the interesting flag combinations. Pattern: `search_commands_unit_test.go:120` captures `m.lastCmd.(*FTHybridCmd)` and checks args.\n- **Integration test** in `xxx_commands_test.go` (Ginkgo, docker stack). Gate version-specific commands: `SkipBeforeRedisVersion(8.8, \"X requires Redis 8.8+\")` (version from spec `since`). Cover happy path, errors, optional-arg combos.\n\n```sh\ngo test -run TestFTHybridWithArgsRejectsUnsupportedVectors .\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"LCS\"\n```\n\n## 6. Custom vet check\n\n```sh\ncd internal/customvet && go build .\ngo vet -vettool ./internal/customvet/customvet ./...\n```\n\nSame check CI runs at the end of `make test.ci`; catches a missing `SetVal`.\n\n## 7. Format\n\n```sh\nmake fmt\n```\n\n(`gofumpt` + `goimports -local github.com/redis/go-redis`.)\n\n## Pitfalls\n\n- Forgetting to embed the new `XxxCmdable` interface in `Cmdable` — compiles on `*Client`, silently unreachable via `UniversalClient`.\n- Forgetting `Clone()` — pipelines reuse Cmders; shared `val` causes cross-execution bugs.\n- Skipping `SetVal` because \"nothing calls it\" — hooks do, and custom-vet fails the build.\n- Returning `nil` from `readReply` after a partial read — always propagate the reader's error.\n- `time.Duration` passed raw — server gets nanoseconds; convert per spec.\n- New `CmdTypeXxx` added but not registered in the type-routing switch in `command.go`.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AGENTS.md\n\nGuidance for AI coding agents (and humans) working in this repository. This is\nthe shared, tool-agnostic source of truth: Claude Code loads it through\n`CLAUDE.md` (which imports this file), and other agents (Codex, Cursor, Aider,\nZed, …) read `AGENTS.md` directly. Edit repository guidance here, not in\n`CLAUDE.md`.\n\n## Repository\n\ngo-redis is the official Redis client for Go. Module path:\n`github.com/redis/go-redis/v9` (Go 1.26+). The repo is a multi-module workspace\n— every directory containing a `go.mod` is built and tested independently:\n\n- root (`github.com/redis/go-redis/v9`) — the client library.\n- `extra/redisotel`, `extra/redisotel-native`, `extra/redisprometheus`,\n  `extra/rediscensus`, `extra/rediscmd` — instrumentation adapters with their\n  own module paths (so they can pin large telemetry deps without forcing them on\n  root consumers).\n- `internal/customvet` — custom `go vet` analyzers (also its own module).\n- `maintnotifications/e2e`, `doctests`, `fuzz`, examples under `example/` —\n  separate modules.\n\nThe Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) when running\n`test.ci`, `go_mod_tidy`, etc. When you add a dependency in one module, you\nalmost never need to update the others.\n\n## Common commands\n\nTests run against a Redis stack started via Docker Compose. Profiles in\n`docker-compose.yml` control which services come up (`standalone`, `cluster`,\n`sentinel`, `all`, `e2e`).\n\n```sh\nmake docker.start                # bring up the full test stack (profile: all)\nmake docker.stop\nmake test                        # docker.start -> test.ci -> docker.stop\nmake test.ci                     # run tests assuming containers are already up\nmake test.ci.skip-vectorsets     # when REDIS_VERSION < 8\nmake bench                       # go test -bench=. (root module only)\nmake fmt                         # gofumpt + goimports -local github.com/redis/go-redis\nmake build\nmake go_mod_tidy                 # go mod tidy across every module\n```\n\nE2E (maintenance notifications) needs the extra `cae-resp-proxy` service:\n\n```sh\nmake test.e2e                    # starts e2e profile, runs ./maintnotifications/e2e/, tears down\nmake test.e2e.docker             # subset that runs inside docker\nmake test.e2e.logic              # logic-only tests, no proxy required\n```\n\nRun a single test. The root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega`\nforks), so `go test -run` matches the Go-level wrapper and you focus a spec with\nthe Ginkgo flag:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"ZAdd\"\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\nPlain `go test` tests (most files outside the Ginkgo suite, e.g. `internal/...`,\n`maintnotifications/...`) work the usual way:\n\n```sh\ngo test -run TestConnStateMachine ./internal/pool/...\ngo test -race -run TestCircuitBreaker ./maintnotifications/...\n```\n\nEnv knobs (passed through the Makefile):\n\n- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and\n  `main_test.go` version-gating (`SkipBeforeRedisVersion` /\n  `SkipAfterRedisVersion`).\n- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g.\n  `redislabs/client-libs-test:8.8-m03`.\n- `RE_CLUSTER=true` — run against a Redis Enterprise cluster instead of the\n  docker-compose stack (the suite then skips ring/sentinel/TLS-cluster setup).\n- `RCE_DOCKER=true` — Redis CE in docker (default for `make test`).\n- `REDIS_PORT` — override the default standalone port (`6380`).\n\nCI also runs the custom vet tool:\n`go vet -vettool ./internal/customvet/customvet ./...`. The `setval` analyzer\nrequires every `Cmder` with a `Result()` to also have a `SetVal()`.\n\n## Architecture\n\n### Client types (root package)\n\nAll clients are in the root package and share most plumbing:\n\n- `Client` (`redis.go`) — single-node client.\n- `ClusterClient` (`osscluster.go`) — Redis Cluster aware. `osscluster_router.go`\n  routes commands to the right shard; `internal/routing/` handles cluster-wide\n  aggregation policies (e.g. fan-out for `KEYS`, `DBSIZE`).\n- `Ring` (`ring.go`) — client-side sharding across independent Redis nodes\n  (consistent hashing, no cluster protocol).\n- Failover client (`sentinel.go`) — Sentinel-managed failover.\n- `UniversalClient` (`universal.go`) — wrapper that picks one of the above based\n  on options.\n\nCommand surface lives in topical files: `string_commands.go`, `hash_commands.go`,\n`stream_commands.go`, `search_commands.go`, `vectorset_commands.go`, etc. Each\nfile defines methods on the shared `Cmdable` interface so every client type gets\nthe same API.\n\n### Hooks (`redis.go` `hooksMixin`)\n\nThree hook chains run around every operation: `DialHook`, `ProcessHook`,\n`ProcessPipelineHook`. Hooks are registered via `client.AddHook(...)` and chain\nin FIFO order; each hook must call `next` to continue. When a hook wraps an\nerror, it must call `cmd.SetErr(wrappedErr)` so the typed-error helpers\n(`redis.IsLoadingError`, `IsMovedError`, etc. in `error.go`) keep working through\n`errors.As`. The README has a longer pipeline-hook example.\n\n**Hook contract** (behavior is undefined otherwise — the mechanism assumes\nwell-behaved hooks rather than defending every corner case): a hook must call\n`next` (unless deliberately terminating the command), must **not** call `Close`\nor other client control methods (it runs on the goroutine `Close` waits for, so\nit deadlocks), must **not** panic, and must **not** mutate client or connection\nstate. It may observe and wrap errors only. User callbacks such as `CacheSizer`\nfollow the same no-panic rule. Client-side caching additionally requires the\n**built-in** push processor; a custom `PushNotificationProcessor` is unsupported\nwith CSC (the client logs a warning at init). See `.claude/specs/push.md`.\n\n### Connection pool (`internal/pool`)\n\nOwns dialing, idle/active connection bookkeeping, conn state (`conn_state.go`),\npubsub-conn lifecycle (`pubsub.go`), and the dial-retry/backoff logic that powers\n`DialerRetries` / `DialerRetryBackoff` (also exposed at `dial_retry_backoff.go`\nin the root). `OnConnect`, `MinIdleConns`, and the buffer-size options\n(`ReadBufferSize`/`WriteBufferSize`, default 32 KiB since v9.12) flow through\nhere.\n\n### Protocol (`internal/proto`)\n\nRESP2/RESP3 reader and writer. Push notifications (RESP3 `>`-prefixed frames) are\npeeked here and dispatched via the `push/` package. The `push.Registry` lets\ncallers register handlers for specific notification names;\n`maintnotifications/push_notification_handler.go` is how `maintnotifications`\nplugs in.\n\n### Maintenance notifications (`maintnotifications/`)\n\nThis is a non-trivial subsystem worth understanding before touching\ncluster/handoff code. It listens for RESP3 push notifications about cluster\nmaintenance (`MOVING`, `MIGRATING`, `MIGRATED`, `FAILING_OVER`, `FAILED_OVER`\nfor standalone; `SMIGRATING`, `SMIGRATED` for cluster) and performs seamless\nconnection handoff to new endpoints. Key pieces:\n\n- `manager.go` — coordinates state transitions.\n- `handoff_worker.go` — moves in-flight ops to new connections.\n- `pool_hook.go` — integrates with `internal/pool` to mark/replace connections.\n- `circuit_breaker.go` — backs off when the upstream is unhealthy.\n- `state.go` — per-connection state machine.\n- E2E coverage lives in `maintnotifications/e2e/` and drives a fault-injector /\n  RESP proxy (`cae-resp-proxy`).\n\nConfiguration is via `redis.Options.MaintNotificationsConfig`; modes are\n`ModeAuto` (default), `ModeEnabled` (require server support), `ModeDisabled`.\nRESP3 (`Protocol: 3`) is required.\n\n### Authentication (`auth/`, `internal/auth/streaming`)\n\nFour credential sources, in priority order: streaming provider (e.g. Entra ID\nvia `go-redis-entraid`), context-based provider, function provider, static\n`Username`/`Password`. The streaming provider is what enables token rotation\nwithout reconnecting — the listener in `auth/reauth_credentials_listener.go`\nissues `AUTH` on each refresh.\n\n### Internal helpers\n\n- `internal/hscan` — struct scanning for `HGETALL` results (`Scan` interface\n  re-exported as `redis.Scanner`).\n- `internal/hashtag` — extracts `{tag}` segments for cluster slot routing.\n- `internal/routing` — aggregator policies and shard pickers used by\n  `ClusterClient` for multi-shard commands.\n- `internal/otel` — small OpenTelemetry shim used to keep root free of telemetry\n  deps; full instrumentation lives in `extra/redisotel-native`.\n\n## Architectural specs\n\nRead the relevant design doc **before** changing code in that subsystem. They\ncover invariants and decisions that aren't obvious from the code, and are plain\nmarkdown any tool or editor can open:\n\n- `.claude/specs/pool.md` — connection pool: `wantConn` queue and FIFO\n  discipline, `ConnState` machine, dial retry/backoff, hook integration, the\n  re-auth/handoff coexistence contract.\n- `.claude/specs/cluster-routing.md` — slot computation, MOVED/ASK redirection,\n  request/response policies, aggregators, replica routing, topology reload,\n  cross-slot rules.\n- `.claude/specs/maintnotifications.md` — RESP3 push notification protocol, mode\n  handshake, per-conn state, handoff worker pool, circuit breaker, endpoint-type\n  resolution, cluster vs. standalone differences.\n\n## Conventions\n\n- New `Cmder` type → also implement `SetVal` (the custom vet `setval` check\n  enforces this; `SetErr` is on the embedded `baseCmd`).\n- Wrap errors with custom error types that implement `Unwrap`, or use\n  `fmt.Errorf(\"...: %w\", err)`. Always call `cmd.SetErr(...)` after wrapping so\n  typed-error checks still pass.\n- `gofumpt` + `goimports -local github.com/redis/go-redis` is the formatter\n  (`make fmt`); CI runs both.\n- Don't log directly — use `internal.Logger` (set via `redis.SetLogger`);\n  `logging.Disable()` is called in tests.\n- Version-gate Redis-version-specific tests with `SkipBeforeRedisVersion` /\n  `SkipAfterRedisVersion` rather than skipping at the suite level.\n\n### Commits and PRs\n\nConventional Commits, short and exact — `<type>(<scope>): <imperative summary>`.\nSubject ≤50 chars (hard cap 72), imperative (\"add\", not \"added\"), no trailing\nperiod. Body only when the *why* isn't obvious from the diff; wrap at 72.\n\n- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also\n  `build`, `ci`, `style`, `revert`).\n- Scope = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`,\n  `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`,\n  `deps`, `ci`, `tests`, `docs`. Omit only for genuinely cross-cutting changes.\n- Breaking change: `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line.\n  Reference issues/PRs at the end — `Closes #42`, `Refs #17`.\n- **No AI-attribution trailer.** Do not add `Co-Authored-By: …`, \"Generated with\n  …\", or any AI-attribution line to commits or PR bodies in this repo.\n\n## Repo-specific tooling\n\n`.claude/` holds shared AI config:\n\n- `commands/` — slash commands (e.g. `check-ci`, which summarizes a PR's CI).\n- `skills/` — task playbooks: `testing`, `add-command`, `commit-style`,\n  `update-ci-image`, `prepare-release`.\n- `specs/` — the architecture docs listed above.\n\nFor Claude Code, the skills auto-trigger from their descriptions. For other\ntools, each `SKILL.md` is plain markdown you can open and follow directly.\n","isInternal":false,"tokens":2858,"sizeBytes":11283},{"name":"cluster-routing-wiring.md","path":".claude/skills/add-command/references/cluster-routing-wiring.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/skills/add-command/references/cluster-routing-wiring.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Cluster routing for new commands\n\nRead this only when the new command is keyless, fans out, targets multiple keys, or aggregates results across shards. **Most simple single-key commands need no cluster-side change** — slot routing is inferred from the key argument automatically.\n\nThis file (the skill reference) is a wiring checklist. For the full routing *design* — slot computation, MOVED/ASK, aggregator internals — read the separate architecture spec at the repo path `.claude/specs/cluster-routing.md` before changing routing code.\n\n## When a command needs wiring\n\n| Command shape | What to wire |\n|---------------|--------------|\n| Keyless (e.g. `DBSIZE`, `FLUSHALL`, server-info commands) | Add the lowercase name to `keylessCommands` (`command.go:25`). Otherwise `ClusterClient` can't pick a node. |\n| Unusual key position (key not the first arg) | Update the key-position / key-spec maps in `command.go`. |\n| Fan-out across all shards, results aggregated | Add a request/response policy in `command_policy_resolver.go` and the matching aggregator in `internal/routing/aggregator.go` (policy declared in `internal/routing/policy.go`). |\n| Multi-key, cross-slot | Confirm the cross-slot rules in `.claude/specs/cluster-routing.md`; commands spanning slots error unless the command supports it. |\n\n## Source of truth = the command spec\n\nThe spec's `key_specs` and `command_flags` tell you the routing class:\n- empty `key_specs` → keyless → `keylessCommands`.\n- `command_flags` with no write flag → `READONLY` → eligible for replica routing.\n- multiple key specs / `numkeys`-style args → multi-key; check cross-slot handling.\n\n## Verify\n\nAfter wiring, exercise against the cluster profile:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n","isInternal":false,"tokens":405,"sizeBytes":1780},{"name":"core-command-pattern.md","path":".claude/skills/add-command/references/core-command-pattern.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/skills/add-command/references/core-command-pattern.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Core command pattern\n\nThe 7-step Go pattern for adding a command to the root `redis` package. Worked example: **LCS** (grep `func (c cmdable) LCS` in `string_commands.go` and `type LCSCmd struct` in `command.go`) — it uses a `cmdable` receiver, a query struct, a custom Cmder, and a registered `CmdType`. Copy its shape exactly.\n\n## 1. Method on the `cmdable` interface\n\nIn the matching `*_commands.go` file. Receiver is `cmdable` — this is what makes the command available on every client type (`Client`, `ClusterClient`, `Ring`, sentinel, pipelines). A `*Client` receiver compiles but is unreachable via `UniversalClient` / `Cmdable`.\n\n```go\nfunc (c cmdable) LCS(ctx context.Context, q *LCSQuery) *LCSCmd {\n\tcmd := NewLCSCmd(ctx, q)\n\t_ = c(ctx, cmd)\n\treturn cmd\n}\n```\n\nFor commands with many optional flags, expose `Foo(...)` for the common path plus `FooWithArgs(ctx, key, *FooArgs)` for the full surface (see `FTHybrid` / `FTHybridWithArgs` in `search_commands.go`).\n\nConventions:\n- Lowercase the command name in the args slice (`\"lcs\"`, not `\"LCS\"`); subcommand flags are uppercase (`\"LEN\"`, `\"IDX\"`, `\"MINMATCHLEN\"`).\n- Pre-size the args slice (`make([]interface{}, 3, 7)`).\n- Convert `time.Duration` to whatever the server expects — never pass it raw (server gets nanoseconds). Use `formatMs` / `formatSec` in `commands.go`.\n- Doc comment: state `Redis X.Y+` (from the spec `since`) and link the redis.io command page.\n\n## 2. New `XxxCmdable` interface goes into `Cmdable`\n\nOnly if you added a **new** `*_commands.go` file with its own interface. Embed it in `Cmdable` (`commands.go`, alphabetical in the embed list — e.g. `SearchCmdable` at `commands.go:242`). Without this the method is unreachable through `UniversalClient`.\n\n## 3. Custom Cmder type\n\nDefine only when no existing Cmd type (`*IntCmd`, `*StringCmd`, `*BoolCmd`, `*StringSliceCmd`, `*MapStringStringCmd`, …) fits the reply. Put it at the bottom of `command.go` (module commands may keep it in their own `*_commands.go`, like `FTHybridCmd`). Follow `LCSCmd`:\n\n```go\ntype LCSCmd struct {\n\tbaseCmd\n\treadType uint8      // command-specific parse state\n\tval      *LCSMatch\n}\n\nvar _ Cmder = (*LCSCmd)(nil)\n\nfunc NewLCSCmd(ctx context.Context, q *LCSQuery) *LCSCmd {\n\targs := make([]interface{}, 3, 7)\n\targs[0], args[1], args[2] = \"lcs\", q.Key1, q.Key2\n\t// ...append optional flags...\n\tcmd := &LCSCmd{readType: 1}\n\tcmd.baseCmd = baseCmd{ctx: ctx, args: args, cmdType: CmdTypeLCS}\n\treturn cmd\n}\n\nfunc (cmd *LCSCmd) SetVal(val *LCSMatch)              { cmd.val = val }\nfunc (cmd *LCSCmd) Val() *LCSMatch                    { return cmd.val }\nfunc (cmd *LCSCmd) Result() (*LCSMatch, error)        { return cmd.val, cmd.err }\nfunc (cmd *LCSCmd) String() string                    { return cmdString(cmd, cmd.val) }\nfunc (cmd *LCSCmd) readReply(rd *proto.Reader) error  { /* parse RESP */ }\nfunc (cmd *LCSCmd) Clone() Cmder                       { /* deep-copy val */ }\n```\n\nRequired methods — the `setval` analyzer (`internal/customvet/checks/setval`) fails the build if `Result` exists without `SetVal`:\n- `SetVal(...)` — hooks mutate results through it. (`SetErr` is inherited from `baseCmd`.)\n- `Val()`, `Result()` — public accessors.\n- `String()` — via `cmdString`.\n- `readReply(rd *proto.Reader) error` — RESP parser. Use `rd.ReadArrayLen`, `rd.ReadInt`, `rd.ReadString`, `rd.ReadFloat`, `rd.ReadFixedMapLen`, `rd.ReadFixedArrayLen`. Validate element counts when the protocol guarantees a fixed shape. Always propagate the reader's error — never return `nil` after a partial read.\n- `Clone()` — deep-copy `val` (including slices/maps) so pipeline/transaction reuse is safe.\n\n**CmdType:** set `cmdType: CmdTypeXxx` in the constructor. If you add a new `CmdTypeXxx` constant (`command.go`), also add the matching case in the `ExtractCommandValue` switch further down `command.go` — this is what lets hooks read the typed value. Grep `CmdTypeLCS` to find both the const declaration and its `case` in `ExtractCommandValue`; copy that shape.\n\n## 4. RESP3 vs RESP2\n\nIf the reply differs between protocols, the parser must handle both — this applies to **any** command with a protocol-dependent shape, core or module, not just modules. Typical case: a map in RESP3, a flat key/value array in RESP2. Branch on the reply type with `rd.PeekReplyType()` (check `proto.RespMap` for RESP3) or `rd.IsResp3()`. Tests run RESP3 by default but RESP2 must still parse for older servers. See `references/module-commands.md` for the FTHybrid worked example.\n\n## 5. Tests — both layers\n\n- **Unit test** in `xxx_commands_unit_test.go` (no Redis) — assert the args slice your method builds for the interesting flag combinations. Pattern: `search_commands_unit_test.go:120` captures `m.lastCmd.(*FTHybridCmd)` and checks args.\n- **Integration test** in `xxx_commands_test.go` (Ginkgo, docker stack). Gate version-specific commands: `SkipBeforeRedisVersion(8.8, \"X requires Redis 8.8+\")` (version from spec `since`). Cover happy path, errors, optional-arg combos.\n\n```sh\ngo test -run TestFTHybridWithArgsRejectsUnsupportedVectors .\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"LCS\"\n```\n\n## 6. Custom vet check\n\n```sh\ncd internal/customvet && go build .\ngo vet -vettool ./internal/customvet/customvet ./...\n```\n\nSame check CI runs at the end of `make test.ci`; catches a missing `SetVal`.\n\n## 7. Format\n\n```sh\nmake fmt\n```\n\n(`gofumpt` + `goimports -local github.com/redis/go-redis`.)\n\n## Pitfalls\n\n- Forgetting to embed the new `XxxCmdable` interface in `Cmdable` — compiles on `*Client`, silently unreachable via `UniversalClient`.\n- Forgetting `Clone()` — pipelines reuse Cmders; shared `val` causes cross-execution bugs.\n- Skipping `SetVal` because \"nothing calls it\" — hooks do, and custom-vet fails the build.\n- Returning `nil` from `readReply` after a partial read — always propagate the reader's error.\n- `time.Duration` passed raw — server gets nanoseconds; convert per spec.\n- New `CmdTypeXxx` added but not registered in the type-routing switch in `command.go`.\n","isInternal":false,"tokens":1654,"sizeBytes":6059},{"name":"module-commands.md","path":".claude/skills/add-command/references/module-commands.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/skills/add-command/references/module-commands.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Module commands (RediSearch / TimeSeries / VectorSet / Bloom)\n\nModule subcommands (`FT.*`, `TS.*`, `V*`, `BF.*`/`CF.*`) follow the core pattern but with three differences. Read `references/core-command-pattern.md` first. Worked example: **FTHybrid** (`search_commands.go`).\n\n## 1. Where the spec lives\n\nModule command specs are **not** in `redis/redis/src/commands/`. They live in the module's own repo:\n- RediSearch → `RediSearch/RediSearch`\n- TimeSeries → `RedisTimeSeries/RedisTimeSeries`\n- VectorSet / Bloom → their respective repos\n\nIf Step 0's redis/redis fetch 404s, the command is a module command — get the spec from the module repo or ask the user for the spec file / PR.\n\n## 2. Where the Cmder type lives\n\nModule Cmd types are defined in the **module's** `*_commands.go` file, not `command.go`. `FTHybridCmd` lives in `search_commands.go` (grep `type FTHybridCmd`), registered on the `SearchCmdable` interface at the top of that file, which is embedded in `Cmdable` (`commands.go`). TimeSeries → `timeseries_commands.go`, VectorSet → `vectorset_commands.go`. The required-method set is identical to core (SetVal/Val/Result/String/readReply/Clone).\n\n## 3. RESP2 vs RESP3 reply shapes\n\nModule commands frequently return a **map in RESP3** and a **flat array in RESP2** for the same logical reply. The parser must handle both. FTHybrid reads the raw slice, then `parseFTHybrid` walks it tolerating three shapes:\n\n```go\nfunc (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) {\n\tdata, err := rd.ReadSlice()   // generic slice read\n\tif err != nil {\n\t\treturn err\n\t}\n\tresult, cursorResult, err := parseFTHybrid(data, cmd.withCursor)\n\t// ...assign cmd.val / cmd.cursorVal...\n}\n\n// inside parseFTHybrid, per result item:\nif itemMap, ok := item.(map[string]interface{}); ok {      // RESP3 map\n\tresults = append(results, itemMap)\n} else if rawMap, ok := item.(map[interface{}]interface{}); ok {  // alt RESP3\n\t// stringify keys\n} else if itemData, ok := item.([]interface{}); ok {       // RESP2 array of k/v pairs\n\t// walk pairs\n}\n```\n\nPattern: don't assume RESP3. Try the map shape, fall back to the array (key-value pairs) shape. Tests run RESP3 by default but RESP2 must still parse for older servers.\n\n## 4. Common module conventions\n\n- Subcommand keyword is uppercase in args (`\"SEARCH\"`, `\"VSIM\"`, `\"WITHCURSOR\"`); the command token is lowercase (`\"ft.hybrid\"`).\n- Cursor-style commands carry parse state on the Cmd struct (FTHybrid's `withCursor`, `cursorVal`) — set it in the constructor from the options.\n- Module commands are almost always `FooWithArgs(ctx, index, *FooOptions)` plus a thin positional `Foo(...)`.\n- Version-gate integration tests against the **module** version, not the core Redis version.\n","isInternal":false,"tokens":721,"sizeBytes":2743},{"name":"SKILL.md","path":".claude/skills/add-command/SKILL.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/skills/add-command/SKILL.md","title":"add-command","category":"anthropic-skill","format":"markdown","content":"---\nname: add-command\ndescription: Use when adding a new Redis command (or RediSearch / TimeSeries / VectorSet / module subcommand) to go-redis — covers fetching the command spec and docs, the Cmder type, Cmdable interface wiring, RESP parsing, tests, and the custom-vet rule that enforces SetVal.\nallowed-tools: WebFetch\n---\n\n# Adding a new Redis command\n\nRouter for adding a Redis command to the root `redis` package. Read the reference file for the area you're touching — don't load all of them.\n\n## Step 0 — Get the command spec AND docs FIRST\n\nBefore writing any Go, know the exact command shape (arguments, optional flags, reply structure, since-version, key positions) **and** its documented semantics (what each reply field means, RESP2-vs-RESP3 differences, examples). Fetch **two** sources — the machine-readable spec and the prose docs. They cover different gaps: the spec nails arguments and key positions, the docs nail what the reply actually looks like.\n\n### A. Machine-readable spec (arguments, key specs, reply schema)\n\nResolve the spec in this order:\n\n1. **Spec file argument** — if the user passed a path to a spec/JSON file, read it.\n2. **PR URL argument** — if the user passed a `github.com/redis/redis` PR (or other repo) URL, fetch the diff and read the command definition + `src/commands/<cmd>.json` it adds.\n3. **No argument** — fetch the machine-readable spec from redis/redis:\n   ```\n   https://raw.githubusercontent.com/redis/redis/unstable/src/commands/<command>.json\n   ```\n   Container subcommands use `<container>-<sub>.json` (e.g. `client-info.json`). **A 404 means it's a module command** (RediSearch, TimeSeries, VectorSet, Bloom) — those specs live in the **module's own repo**, not redis/redis. Switch to the module repo (see `references/module-commands.md` §1) or ask the user for the spec/PR. Don't retry the redis/redis URL.\n\n### B. Prose docs (semantics, return value, RESP2/RESP3 reply, examples)\n\nThe JSON `reply_schema` is often thin or missing; the docs spell out what the reply actually is — including separate **RESP2 Reply** and **RESP3 Reply** sections, which decide your `readReply` and any module RESP2-vs-RESP3 handling. Fetch the command's doc page:\n\n```\nhttps://redis.io/docs/latest/commands/<command>/\n```\n\nRaw markdown source (alternative, good for diffing or when the rendered page is noisy):\n`https://raw.githubusercontent.com/redis/redis-doc/master/commands/<command>.md`.\n**Module commands** (RediSearch, TimeSeries, …) are documented under their own\npath on redis.io (e.g. `/docs/latest/commands/ft.search/`) or in the module repo\n— see `references/module-commands.md`.\n\nRead the **Return value / RESP2 Reply / RESP3 Reply** sections plus the examples, and reconcile them against the JSON `reply_schema`. When the two disagree, the docs' reply description (and a quick `redis-cli` check) win.\n\n### Map both sources to the implementation\n\n| Source | Drives |\n|--------|--------|\n| spec `arguments` | method signature, args-slice build order, optional `FooArgs` struct |\n| spec `reply_schema` + docs **Return value** | the `readReply` parser and Cmder result type |\n| docs **RESP2 Reply / RESP3 Reply** | RESP2-vs-RESP3 branching in `readReply` (see `references/module-commands.md`) |\n| spec `since` + docs `@history` | `SkipBeforeRedisVersion(...)` in the integration test, doc comment |\n| spec `key_specs` | key-position maps in `command.go`; cluster routing |\n| spec `command_flags` (e.g. `READONLY`, no key) | keyless / fan-out handling, cluster routing |\n| docs examples | integration-test cases and expected values |\n\nIf you can't get either source, STOP and ask the user — guessing the reply shape produces a broken `readReply`.\n\n## Decide before you start\n\n1. **Which `*_commands.go` file?** — pick the existing file matching the data type (`string_commands.go`, `hash_commands.go`, `search_commands.go`, …). Only create a new file for a genuinely new category. New files need a matching `XxxCmdable` interface embedded in `Cmdable` (`commands.go`).\n2. **Reuse an existing Cmder, or define a new one?** — simple replies (`int`, `string`, `bool`, `[]string`, map) reuse `*IntCmd`, `*StringCmd`, `*BoolCmd`, `*StringSliceCmd`, `*MapStringStringCmd`, … Define a new Cmder only for a structured reply that no existing type fits.\n3. **`Foo` vs `FooWithArgs`?** — for commands with optional flags, expose a positional `Foo(...)` for the common path plus `FooWithArgs(ctx, key, *FooArgs)` for the full surface.\n\n## Reference files\n\n| Read this | When |\n|-----------|------|\n| `references/core-command-pattern.md` | Always — the 7-step Go pattern (interface method, Cmder type, RESP parsing, tests, vet, fmt). Worked example: `LCS`. |\n| `references/module-commands.md` | Adding a RediSearch / TimeSeries / VectorSet / Bloom subcommand — naming, RESP2-vs-RESP3 shape differences, where the Cmd type lives. |\n| `references/cluster-routing-wiring.md` | Command is keyless, fans out, multi-key, or aggregates across shards. |\n\nWhen several apply, read in order: core → module → cluster.\n\n## Pitfalls (full list in core-command-pattern.md)\n\n- Forgetting to embed the new `XxxCmdable` interface in `Cmdable` — compiles on `*Client`, silently unreachable via `UniversalClient`.\n- Forgetting `Clone()` — pipelines reuse Cmders; shared `val` causes cross-execution bugs.\n- Skipping `SetVal` because \"nothing calls it\" — hooks do, and the `setval` custom-vet check fails the build.\n- Using `time.Duration` directly in args — server gets nanoseconds. Convert per spec.\n","frontmatter":{"name":"add-command","description":"Use when adding a new Redis command (or RediSearch / TimeSeries / VectorSet / module subcommand) to go-redis — covers fetching the command spec and docs, the Cmder type, Cmdable interface wiring, RESP parsing, tests, and the custom-vet rule that enforces SetVal.","allowed-tools":"WebFetch","allowedTools":["WebFetch"]},"allowedTools":["WebFetch"],"isInternal":false,"tokens":1375,"sizeBytes":5568},{"name":"SKILL.md","path":".claude/skills/commit-style/SKILL.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/skills/commit-style/SKILL.md","title":"commit-style","category":"anthropic-skill","format":"markdown","content":"---\nname: commit-style\ndescription: Use when writing a commit message or opening a PR in the go-redis repo — covers the Conventional-Commit format, the repo's scope vocabulary (pool, conn, sentinel, command, …), breaking-change syntax, and the rule that NO AI-attribution trailer is added.\n---\n\n# go-redis commit messages\n\nConventional Commits, short and exact — `<type>(<scope>): <imperative summary>`. Subject ≤50 chars (hard cap 72), imperative mood (\"add\", not \"added\"), no trailing period. Body only when the *why* isn't obvious from the diff (breaking change, security fix, migration, non-obvious rationale); wrap at 72, bullets `-`. State why, not what — the diff says what.\n\n- **Types:** `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also `build`, `ci`, `style`, `revert`).\n- **Scope** = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`, `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`, `deps`, `ci`, `tests`, `docs`. Omit scope only for genuinely cross-cutting changes.\n- **Breaking change:** `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line.\n- **Issues/PRs:** reference at the end — `Closes #42`, `Refs #17`.\n\n## No attribution trailer\n\nDo **not** add `Co-Authored-By: Claude …`, \"Generated with Claude Code\", or any AI-attribution line to commits or PR bodies in this repo. This overrides both the default harness behavior (which appends such trailers) and any other commit skill.\n\n## Examples\n\n```\nfix(pool): release wantConn slot on dial timeout\n\nThe waiter stayed queued after the dial deadline fired, leaking a\nslot per timed-out connection under load.\n\nCloses #3812\n```\n\n```\nfeat(streams): support explicit LIMIT 0 in XTRIM trimming\n```\n","frontmatter":{"name":"commit-style","description":"Use when writing a commit message or opening a PR in the go-redis repo — covers the Conventional-Commit format, the repo's scope vocabulary (pool, conn, sentinel, command, …), breaking-change syntax, and the rule that NO AI-attribution trailer is added."},"isInternal":false,"tokens":449,"sizeBytes":1727},{"name":"SKILL.md","path":".claude/skills/prepare-release/SKILL.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/skills/prepare-release/SKILL.md","title":"prepare-release","category":"anthropic-skill","format":"markdown","content":"---\nname: prepare-release\ndescription: Use when preparing a go-redis release — cutting a new version, bumping version.go, updating the go-redis dependency version in submodule go.mod files, or writing the RELEASE-NOTES.md entry for a new vX.Y.Z. Covers choosing the next semver, finding the last release and gathering merged PRs since then, the release-notes format and what to exclude, running scripts/release.sh to bump versions, and verifying with the scripts/tag.sh dry-run. Does NOT create tags, push, or commit — publishing stays a manual maintainer step.\nallowed-tools: Bash(git describe:*), Bash(gh pr list:*)\n---\n\n# Preparing a go-redis release\n\nStage everything for a release locally, then stop before anything is published.\n\n## This skill never publishes\n\n- **Never** run `scripts/tag.sh ... -t` — the `-t` flag creates and pushes git tags.\n- **Never** `git push` anything (no commits, no tags).\n- **Never** commit on the maintainer's behalf unless they explicitly ask.\n\nThe deliverable is a reviewable diff: bumped versions plus a new `RELEASE-NOTES.md`\nentry. A human reviews it and runs the publish step.\n\n## 1. Pick the next version\n\nThe current version is the source of truth in `version.go`:\n\n```sh\ngrep 'return' version.go        # e.g. return \"9.21.0\"\n```\n\nChoose the next `vX.Y.Z` by semver, based on what shipped since the last release:\n\n- **patch** (`Z`) — bug fixes only; drop-in upgrade.\n- **minor** (`Y`) — new features, no breaking changes; drop-in upgrade.\n- **major** (`X`) — breaking changes.\n\nConfirm the level with the user if the changeset is ambiguous.\n\n## 2. Find the last release and gather changes\n\n`scripts/tag.sh` also tags every public submodule (`extra/redisotel/vX.Y.Z`, …),\nso a naive `git describe` returns a submodule tag. Match the **root** tag only:\n\n```sh\nLAST=$(git describe --tags --abbrev=0 --match 'v[0-9]*')   # e.g. v9.21.0\ngit log \"$LAST\"..HEAD --oneline\ngh pr list --state merged --limit 100 \\\n  --json number,title,author,mergedAt,url --search \"merged:>=<last-release-date>\"\n```\n\nCategorize the PRs: highlights, new features, bug fixes, performance,\ntesting/infrastructure. Exclude dependabot bumps, typo-only doc fixes, internal\nrefactors with no user-facing effect, and `dependabot[bot]` from the contributor\nlist.\n\n## 3. Write the release notes\n\nPrepend a new `# X.Y.Z (YYYY-MM-DD)` section to the **top** of `RELEASE-NOTES.md`\n(newest first; leave older entries untouched). Follow\n[`.github/RELEASE_NOTES_TEMPLATE.md`](../../../.github/RELEASE_NOTES_TEMPLATE.md)\nexactly — section order, emoji headers, the `([#PR](url)) by [@user](url)` link\nformat, and the `**Full Changelog**` compare link `${LAST}...vX.Y.Z`. Open the\nlead line with the release type and whether it is a drop-in upgrade, matching the\nexisting entries.\n\nThat template carries the full \"what to exclude\" and formatting rules — read it,\ndon't reinvent them. `release-drafter` separately auto-drafts a GitHub release\nfrom PR labels (`.github/release-drafter-config.yml`); `RELEASE-NOTES.md` is the\ncurated, hand-written record and is the file you edit.\n\n## 4. Bump versions\n\nRun the repo's bump script. It rewrites the go-redis dependency version in every\nsubmodule `go.mod`, runs `go mod tidy`, and bumps `version.go`. It does **not**\ncommit, push, or switch branches:\n\n```sh\nTAG=vX.Y.Z ./scripts/release.sh\n```\n\n## 5. Verify (dry run only)\n\nRun the tag script's **dry run** — it checks that `version.go` and every `go.mod`\nalready match the tag, and prints the tags it *would* push. No `-t`:\n\n```sh\n./scripts/tag.sh vX.Y.Z         # DRY RUN — must pass cleanly; never add -t here\nmake build\ngit diff --stat                 # review version.go, submodule go.mod, RELEASE-NOTES.md\n```\n\nFix anything the dry run flags before handing off.\n\n## 6. Hand off\n\nReport that the release is staged and list the publish steps for the maintainer\nto run themselves (this skill does not do them):\n\n1. Review `git diff`, then commit (`chore(release): vX.Y.Z` — see the\n   `commit-style` skill; no AI-attribution trailer).\n2. Open and merge the release PR.\n3. After merge, tag and push: `./scripts/tag.sh vX.Y.Z -t`.\n4. `release-drafter` publishes the GitHub release; reconcile it with the\n   `RELEASE-NOTES.md` entry if needed.\n\nStop after step 5. Do not commit, tag, or push.\n","frontmatter":{"name":"prepare-release","description":"Use when preparing a go-redis release — cutting a new version, bumping version.go, updating the go-redis dependency version in submodule go.mod files, or writing the RELEASE-NOTES.md entry for a new vX.Y.Z. Covers choosing the next semver, finding the last release and gathering merged PRs since then, the release-notes format and what to exclude, running scripts/release.sh to bump versions, and verifying with the scripts/tag.sh dry-run. Does NOT create tags, push, or commit — publishing stays a manual maintainer step.","allowed-tools":"Bash(git describe:*), Bash(gh pr list:*)","allowedTools":["Bash(git","describe:*),","Bash(gh","pr","list:*)"]},"allowedTools":["Bash(git","describe:*),","Bash(gh","pr","list:*)"],"isInternal":false,"tokens":1115,"sizeBytes":4312},{"name":"SKILL.md","path":".claude/skills/testing/SKILL.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/skills/testing/SKILL.md","title":"testing","category":"anthropic-skill","format":"markdown","content":"---\nname: testing\ndescription: Use when running go-redis tests of any kind — the full suite, a single test, or a filtered/focused subset (e.g. only cluster, sentinel, or pool specs); bringing up or tearing down the Docker test stack; the e2e maintenance-notifications suite (including the proxy-free logic tests); building or formatting; or setting the REDIS_VERSION / RE_CLUSTER / REDIS_PORT knobs that drive the test image and version gating. Covers how to focus a Ginkgo spec and how to run plain go tests.\n---\n\n# Running tests, builds, and the Docker stack\n\nTests run against a Redis stack started via Docker Compose. Profiles in `docker-compose.yml` control which services come up (`standalone`, `cluster`, `sentinel`, `all`, `e2e`, …).\n\n## Make targets\n\n```sh\nmake docker.start          # bring up the full test stack (profile: all)\nmake docker.stop\nmake test                  # docker.start -> test.ci -> docker.stop\nmake test.ci               # run tests assuming containers are already up\nmake test.ci.skip-vectorsets   # used when REDIS_VERSION < 8\nmake bench                 # go test -bench=. (root module only)\nmake fmt                   # gofumpt + goimports (-local github.com/redis/go-redis)\nmake build\nmake go_mod_tidy           # go mod tidy across every module\n```\n\nThe Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) for `test.ci`, `go_mod_tidy`, etc.\n\n## E2E (maintenance notifications)\n\nNeeds an extra `cae-resp-proxy` service:\n\n```sh\nmake test.e2e              # auto-starts the e2e profile, runs ./maintnotifications/e2e/, tears down\nmake test.e2e.docker       # subset that runs inside docker\nmake test.e2e.logic        # logic-only tests, no proxy required\n```\n\n## Env knobs (passed through the Makefile)\n\n- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and `main_test.go` version-gating helpers (`SkipBeforeRedisVersion` / `SkipAfterRedisVersion`).\n- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g. `redislabs/client-libs-test:8.8-m03`. (To change the default tag, use the `update-ci-image` skill — it lives in several files.)\n- `RE_CLUSTER=true` — point tests at a Redis Enterprise cluster instead of the docker-compose stack. The suite then skips ring/sentinel/TLS-cluster setup.\n- `RCE_DOCKER=true` — using Redis CE in docker (default for `make test`).\n- `REDIS_PORT` — override the default standalone port (`6380`) used by `main_test.go`.\n\n## Running a single test\n\nThe root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega` forks). `go test -run` matches the Go-level wrapper, so to focus a Ginkgo spec use the Ginkgo focus flag:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"ZAdd\"\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\nPlain `go test`-style tests (most files outside the Ginkgo suite, e.g. `internal/...`, `maintnotifications/...`) work the usual way:\n\n```sh\ngo test -run TestConnStateMachine ./internal/pool/...\ngo test -race -run TestCircuitBreaker ./maintnotifications/...\n```\n\n## Version-gating\n\nGate Redis-version-specific tests with `SkipBeforeRedisVersion` / `SkipAfterRedisVersion` (driven by `REDIS_VERSION`) rather than skipping at the suite level.\n","frontmatter":{"name":"testing","description":"Use when running go-redis tests of any kind — the full suite, a single test, or a filtered/focused subset (e.g. only cluster, sentinel, or pool specs); bringing up or tearing down the Docker test stack; the e2e maintenance-notifications suite (including the proxy-free logic tests); building or formatting; or setting the REDIS_VERSION / RE_CLUSTER / REDIS_PORT knobs that drive the test image and version gating. Covers how to focus a Ginkgo spec and how to run plain go tests."},"isInternal":false,"tokens":802,"sizeBytes":3159},{"name":"SKILL.md","path":".claude/skills/update-ci-image/SKILL.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/skills/update-ci-image/SKILL.md","title":"update-ci-image","category":"anthropic-skill","format":"markdown","content":"---\nname: update-ci-image\ndescription: Use when adding, updating, or removing a `redislabs/client-libs-test` image tag used by the test stack — covers every place a Redis version → image-tag mapping lives so local `make test` and GitHub CI stay in sync.\n---\n\n# Updating the CI test image\n\nThe `redislabs/client-libs-test` image is referenced in **six** places (five live config + one doc). Miss one and either local `make test` and CI diverge, or a single CI job uses the wrong image. Always edit all relevant locations in one change.\n\n## The six locations\n\n| # | File | What lives here |\n|---|------|-----------------|\n| 1 | `Makefile` | `CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:<tag>` — default for local `make test` / `make docker.start`. |\n| 2 | `docker-compose.yml` | `x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:<tag>}` — fallback when the env var is unset. Should match the Makefile default. |\n| 3 | `.github/workflows/build.yml` | `redis_version_mapping` bash assoc array (`[\"8.X.x\"]=\"<tag>\"`). Drives the `benchmark` job and the `test-redis-ce` matrix via `run-tests`. |\n| 4 | `.github/actions/run-tests/action.yml` | Identical `redis_version_mapping` array — the composite action that the matrix jobs invoke. Keep in lockstep with `build.yml`. |\n| 5 | `.github/workflows/doctests.yaml` | Hardcoded `image: redislabs/client-libs-test:<tag>` on the `redis-stack` service — doctests do not consult the mapping. |\n| 6 | `CONTRIBUTING.md` | Prose line \"By default the docker image … is `redislabs/client-libs-test:<tag>`.\" Documentation only — no CI impact, but goes stale silently. Update it to match the Makefile default. |\n\n## Tasks\n\n### Update the tag for an existing Redis version (e.g. bump 8.8 → a new release)\n\n1. `Makefile` — replace the tag after `redislabs/client-libs-test:` on the `CLIENT_LIBS_TEST_IMAGE` line.\n2. `docker-compose.yml` — replace the tag in the `:-redislabs/client-libs-test:<tag>` default.\n3. `.github/workflows/build.yml` — update the `[\"8.X.x\"]=\"<tag>\"` line in `redis_version_mapping`.\n4. `.github/actions/run-tests/action.yml` — update the matching `[\"8.X.x\"]=\"<tag>\"` line.\n5. `.github/workflows/doctests.yaml` — update the `image:` line **only if** doctests should run against this version. Doctests typically pin to the latest stable; check before changing.\n6. `CONTRIBUTING.md` — update the \"By default the docker image … is `redislabs/client-libs-test:<tag>`.\" prose line to match the new `Makefile` default. This one is easy to forget because nothing breaks when it's stale.\n\nIf the version that doctests run against (`Makefile` default) and the mapping for that same minor version diverge, you have a bug — they should resolve to the same tag.\n\n### Add a new Redis version to the CI matrix\n\n1. `.github/workflows/build.yml`:\n   - Add `- \"<X.Y>.x\"` to **both** `redis-version` matrices (the `benchmark` job and the `test-redis-ce` job — they have separate lists).\n   - Add `[\"<X.Y>.x\"]=\"<tag>\"` to `redis_version_mapping`.\n2. `.github/actions/run-tests/action.yml` — add the same `[\"<X.Y>.x\"]=\"<tag>\"` entry to its `redis_version_mapping`.\n3. If this becomes the new default for local dev, also update `Makefile` (`REDIS_VERSION ?=` and `CLIENT_LIBS_TEST_IMAGE ?=`) and `docker-compose.yml`.\n\n### Remove a Redis version\n\n1. `.github/workflows/build.yml` — drop the `- \"<X.Y>.x\"` line from both `redis-version` matrices and the `[\"<X.Y>.x\"]=\"...\"` entry.\n2. `.github/actions/run-tests/action.yml` — drop the same `[\"<X.Y>.x\"]=\"...\"` mapping entry.\n3. If `Makefile` / `docker-compose.yml` / `doctests.yaml` were pinned to this version, pick a replacement (usually the next-newest version still in the matrix) and update them too, along with the `CONTRIBUTING.md` prose line.\n\n### Switch to a custom build (e.g. `custom-26172898734-debian` for an unreleased server feature)\n\nSame as \"update the tag,\" but think about scope:\n- Custom tags usually correspond to a single Redis minor — only touch the mapping entry for that minor, not all five rows.\n- Custom tags often shouldn't be the local default if they're transient. Confirm with the user before touching `Makefile` / `docker-compose.yml`.\n- Leave `REDIS_VERSION` (the numeric `8.8` / `8.6` etc.) alone — it drives `SkipBeforeRedisVersion` gating in `main_test.go`, not the image. The custom image still reports its base version.\n\n## Verification\n\nAfter editing, search for any stale tag references:\n\n```sh\n# Use the Grep tool, not bash grep.\n# Pattern: literal old tag, e.g. \"8.8-rc1\" or \"client-libs-test:8.8\"\n```\n\nExpected matches: zero in `Makefile`, `docker-compose.yml`, `.github/**`, and `CONTRIBUTING.md`. A reference in `AGENTS.md` or the `testing` skill documentation (e.g. \"e.g. `redislabs/client-libs-test:8.8-m03`\") is illustrative — leave it unless the doc is misleading after the change.\n\n## Gotchas\n\n- **Two mappings, not one.** `build.yml` and `run-tests/action.yml` each declare their own `redis_version_mapping`. They are not deduped. Always edit both.\n- **Two matrices in `build.yml`.** The `benchmark` job and the `test-redis-ce` job each have their own `redis-version` list. Adding/removing a version means editing both.\n- **`doctests.yaml` is hardcoded.** It doesn't read the mapping. If the team intends \"doctests follow the latest stable,\" verify the tag matches `Makefile`'s default after any change.\n- **Don't change `REDIS_VERSION` when only swapping the image.** The number gates version-specific test skips (`SkipBeforeRedisVersion(8.8, ...)`). A custom image built off 8.8 should keep `REDIS_VERSION=8.8`.\n- **Env var vs. default.** Local users can override with `CLIENT_LIBS_TEST_IMAGE=...`; `docker-compose.yml` uses that env var first and falls back to its baked-in default. So the docker-compose default only matters when the Makefile isn't driving things — but it's still load-bearing for direct `docker compose up` calls. Keep it in sync.\n","frontmatter":{"name":"update-ci-image","description":"Use when adding, updating, or removing a `redislabs/client-libs-test` image tag used by the test stack — covers every place a Redis version → image-tag mapping lives so local `make test` and GitHub CI stay in sync."},"isInternal":false,"tokens":1551,"sizeBytes":5975},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nRepository guidance — layout, commands, architecture, and conventions — is\nmaintained in [`AGENTS.md`](AGENTS.md) so it stays shared across AI tools. It is\nimported below; edit `AGENTS.md`, not this file, for that content.\n\n@AGENTS.md\n\n## Claude-specific\n\n- Repo-local **skills** under `.claude/skills/` auto-trigger from their\n  descriptions — no need to invoke them manually (the set is listed in\n  `AGENTS.md`).\n- **Slash commands** under `.claude/commands/` (e.g. `/check-ci`) are available\n  in-session.\n- Architectural **specs** under `.claude/specs/` are read on demand; open the\n  relevant one before changing that subsystem.\n","isInternal":false,"tokens":191,"sizeBytes":757},{"name":"check-ci.md","path":".claude/commands/check-ci.md","rawUrl":"https://raw.githubusercontent.com/redis/go-redis/HEAD/.claude/commands/check-ci.md","title":"Command: /check-ci","category":"command-prompt","format":"markdown","content":"---\ndescription: Summarize GitHub Actions CI status for the current branch's PR (build, lint, e2e, govulncheck, ...)\nargument-hint: \"[pr-number]\"\nallowed-tools: Bash(gh pr checks:*), Bash(gh pr view:*), Bash(gh run view:*), Bash(gh pr list:*), Bash(git branch:*)\n---\n\n## Context\n\n- Branch: !`git branch --show-current`\n- PR: !`gh pr view $1 --json number,title,url,state 2>/dev/null || echo \"NO_PR\"`\n- Checks (name / state / elapsed / url): !`gh pr checks $1 2>&1 || true`\n\n## Task\n\nReport the CI state for this PR. If the PR line is `NO_PR`, say there is no open PR for this branch and stop.\n\nOtherwise, from the checks block:\n\n1. **Counts first.** One line: `N passing · M failing · K pending` (and skipped/cancelled if any).\n2. **Failures and pending only.** For each check that is not passing, list its name, state, and the log URL. Skip the passing ones, they are noise.\n3. **Prioritize.** Order failures by what blocks a merge most: `test-redis-ce` (the main correctness matrix) and `E2E Tests (Mock Proxy)` first, then `lint` and `govulncheck`, then `CodeQL` / `doctests` / `check-spelling`, then everything else (benchmarks last).\n4. **Verdict.** One closing line:\n   - All green → \"All checks pass, nothing to do.\"\n   - Any red → name the single most important failing check to look at first.\n   - Only pending → say which are still running and that it is not done yet.\n\nKeep it short. No preamble, no restating this prompt. The argument `$1` is an optional PR number; when omitted, the current branch's PR is used.\n","frontmatter":{"description":"Summarize GitHub Actions CI status for the current branch's PR (build, lint, e2e, govulncheck, ...)","argument-hint":"[pr-number]","allowed-tools":"Bash(gh pr checks:*), Bash(gh pr view:*), Bash(gh run view:*), Bash(gh pr list:*), Bash(git branch:*)","allowedTools":["Bash(gh","pr","checks:*),","Bash(gh","pr","view:*),","Bash(gh","run","view:*),","Bash(gh","pr","list:*),","Bash(git","branch:*)"]},"allowedTools":["Bash(gh","pr","checks:*),","Bash(gh","pr","view:*),","Bash(gh","run","view:*),","Bash(gh","pr","list:*),","Bash(git","branch:*)"],"isInternal":false,"tokens":420,"sizeBytes":1533}],"systemPromptSnippet":"<yakaai_skills repo=\"redis/go-redis\">\n<!-- File: AGENTS.md (Tokens: ~2858 | Category: root-instruction) -->\n# AGENTS.md\n\nGuidance for AI coding agents (and humans) working in this repository. This is\nthe shared, tool-agnostic source of truth: Claude Code loads it through\n`CLAUDE.md` (which imports this file), and other agents (Codex, Cursor, Aider,\nZed, …) read `AGENTS.md` directly. Edit repository guidance here, not in\n`CLAUDE.md`.\n\n## Repository\n\ngo-redis is the official Redis client for Go. Module path:\n`github.com/redis/go-redis/v9` (Go 1.26+). The repo is a multi-module workspace\n— every directory containing a `go.mod` is built and tested independently:\n\n- root (`github.com/redis/go-redis/v9`) — the client library.\n- `extra/redisotel`, `extra/redisotel-native`, `extra/redisprometheus`,\n  `extra/rediscensus`, `extra/rediscmd` — instrumentation adapters with their\n  own module paths (so they can pin large telemetry deps without forcing them on\n  root consumers).\n- `internal/customvet` — custom `go vet` analyzers (also its own module).\n- `maintnotifications/e2e`, `doctests`, `fuzz`, examples under `example/` —\n  separate modules.\n\nThe Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) when running\n`test.ci`, `go_mod_tidy`, etc. When you add a dependency in one module, you\nalmost never need to update the others.\n\n## Common commands\n\nTests run against a Redis stack started via Docker Compose. Profiles in\n`docker-compose.yml` control which services come up (`standalone`, `cluster`,\n`sentinel`, `all`, `e2e`).\n\n```sh\nmake docker.start                # bring up the full test stack (profile: all)\nmake docker.stop\nmake test                        # docker.start -> test.ci -> docker.stop\nmake test.ci                     # run tests assuming containers are already up\nmake test.ci.skip-vectorsets     # when REDIS_VERSION < 8\nmake bench                       # go test -bench=. (root module only)\nmake fmt                         # gofumpt + goimports -local github.com/redis/go-redis\nmake build\nmake go_mod_tidy                 # go mod tidy across every module\n```\n\nE2E (maintenance notifications) needs the extra `cae-resp-proxy` service:\n\n```sh\nmake test.e2e                    # starts e2e profile, runs ./maintnotifications/e2e/, tears down\nmake test.e2e.docker             # subset that runs inside docker\nmake test.e2e.logic              # logic-only tests, no proxy required\n```\n\nRun a single test. The root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega`\nforks), so `go test -run` matches the Go-level wrapper and you focus a spec with\nthe Ginkgo flag:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"ZAdd\"\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\nPlain `go test` tests (most files outside the Ginkgo suite, e.g. `internal/...`,\n`maintnotifications/...`) work the usual way:\n\n```sh\ngo test -run TestConnStateMachine ./internal/pool/...\ngo test -race -run TestCircuitBreaker ./maintnotifications/...\n```\n\nEnv knobs (passed through the Makefile):\n\n- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and\n  `main_test.go` version-gating (`SkipBeforeRedisVersion` /\n  `SkipAfterRedisVersion`).\n- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g.\n  `redislabs/client-libs-test:8.8-m03`.\n- `RE_CLUSTER=true` — run against a Redis Enterprise cluster instead of the\n  docker-compose stack (the suite then skips ring/sentinel/TLS-cluster setup).\n- `RCE_DOCKER=true` — Redis CE in docker (default for `make test`).\n- `REDIS_PORT` — override the default standalone port (`6380`).\n\nCI also runs the custom vet tool:\n`go vet -vettool ./internal/customvet/customvet ./...`. The `setval` analyzer\nrequires every `Cmder` with a `Result()` to also have a `SetVal()`.\n\n## Architecture\n\n### Client types (root package)\n\nAll clients are in the root package and share most plumbing:\n\n- `Client` (`redis.go`) — single-node client.\n- `ClusterClient` (`osscluster.go`) — Redis Cluster aware. `osscluster_router.go`\n  routes commands to the right shard; `internal/routing/` handles cluster-wide\n  aggregation policies (e.g. fan-out for `KEYS`, `DBSIZE`).\n- `Ring` (`ring.go`) — client-side sharding across independent Redis nodes\n  (consistent hashing, no cluster protocol).\n- Failover client (`sentinel.go`) — Sentinel-managed failover.\n- `UniversalClient` (`universal.go`) — wrapper that picks one of the above based\n  on options.\n\nCommand surface lives in topical files: `string_commands.go`, `hash_commands.go`,\n`stream_commands.go`, `search_commands.go`, `vectorset_commands.go`, etc. Each\nfile defines methods on the shared `Cmdable` interface so every client type gets\nthe same API.\n\n### Hooks (`redis.go` `hooksMixin`)\n\nThree hook chains run around every operation: `DialHook`, `ProcessHook`,\n`ProcessPipelineHook`. Hooks are registered via `client.AddHook(...)` and chain\nin FIFO order; each hook must call `next` to continue. When a hook wraps an\nerror, it must call `cmd.SetErr(wrappedErr)` so the typed-error helpers\n(`redis.IsLoadingError`, `IsMovedError`, etc. in `error.go`) keep working through\n`errors.As`. The README has a longer pipeline-hook example.\n\n**Hook contract** (behavior is undefined otherwise — the mechanism assumes\nwell-behaved hooks rather than defending every corner case): a hook must call\n`next` (unless deliberately terminating the command), must **not** call `Close`\nor other client control methods (it runs on the goroutine `Close` waits for, so\nit deadlocks), must **not** panic, and must **not** mutate client or connection\nstate. It may observe and wrap errors only. User callbacks such as `CacheSizer`\nfollow the same no-panic rule. Client-side caching additionally requires the\n**built-in** push processor; a custom `PushNotificationProcessor` is unsupported\nwith CSC (the client logs a warning at init). See `.claude/specs/push.md`.\n\n### Connection pool (`internal/pool`)\n\nOwns dialing, idle/active connection bookkeeping, conn state (`conn_state.go`),\npubsub-conn lifecycle (`pubsub.go`), and the dial-retry/backoff logic that powers\n`DialerRetries` / `DialerRetryBackoff` (also exposed at `dial_retry_backoff.go`\nin the root). `OnConnect`, `MinIdleConns`, and the buffer-size options\n(`ReadBufferSize`/`WriteBufferSize`, default 32 KiB since v9.12) flow through\nhere.\n\n### Protocol (`internal/proto`)\n\nRESP2/RESP3 reader and writer. Push notifications (RESP3 `>`-prefixed frames) are\npeeked here and dispatched via the `push/` package. The `push.Registry` lets\ncallers register handlers for specific notification names;\n`maintnotifications/push_notification_handler.go` is how `maintnotifications`\nplugs in.\n\n### Maintenance notifications (`maintnotifications/`)\n\nThis is a non-trivial subsystem worth understanding before touching\ncluster/handoff code. It listens for RESP3 push notifications about cluster\nmaintenance (`MOVING`, `MIGRATING`, `MIGRATED`, `FAILING_OVER`, `FAILED_OVER`\nfor standalone; `SMIGRATING`, `SMIGRATED` for cluster) and performs seamless\nconnection handoff to new endpoints. Key pieces:\n\n- `manager.go` — coordinates state transitions.\n- `handoff_worker.go` — moves in-flight ops to new connections.\n- `pool_hook.go` — integrates with `internal/pool` to mark/replace connections.\n- `circuit_breaker.go` — backs off when the upstream is unhealthy.\n- `state.go` — per-connection state machine.\n- E2E coverage lives in `maintnotifications/e2e/` and drives a fault-injector /\n  RESP proxy (`cae-resp-proxy`).\n\nConfiguration is via `redis.Options.MaintNotificationsConfig`; modes are\n`ModeAuto` (default), `ModeEnabled` (require server support), `ModeDisabled`.\nRESP3 (`Protocol: 3`) is required.\n\n### Authentication (`auth/`, `internal/auth/streaming`)\n\nFour credential sources, in priority order: streaming provider (e.g. Entra ID\nvia `go-redis-entraid`), context-based provider, function provider, static\n`Username`/`Password`. The streaming provider is what enables token rotation\nwithout reconnecting — the listener in `auth/reauth_credentials_listener.go`\nissues `AUTH` on each refresh.\n\n### Internal helpers\n\n- `internal/hscan` — struct scanning for `HGETALL` results (`Scan` interface\n  re-exported as `redis.Scanner`).\n- `internal/hashtag` — extracts `{tag}` segments for cluster slot routing.\n- `internal/routing` — aggregator policies and shard pickers used by\n  `ClusterClient` for multi-shard commands.\n- `internal/otel` — small OpenTelemetry shim used to keep root free of telemetry\n  deps; full instrumentation lives in `extra/redisotel-native`.\n\n## Architectural specs\n\nRead the relevant design doc **before** changing code in that subsystem. They\ncover invariants and decisions that aren't obvious from the code, and are plain\nmarkdown any tool or editor can open:\n\n- `.claude/specs/pool.md` — connection pool: `wantConn` queue and FIFO\n  discipline, `ConnState` machine, dial retry/backoff, hook integration, the\n  re-auth/handoff coexistence contract.\n- `.claude/specs/cluster-routing.md` — slot computation, MOVED/ASK redirection,\n  request/response policies, aggregators, replica routing, topology reload,\n  cross-slot rules.\n- `.claude/specs/maintnotifications.md` — RESP3 push notification protocol, mode\n  handshake, per-conn state, handoff worker pool, circuit breaker, endpoint-type\n  resolution, cluster vs. standalone differences.\n\n## Conventions\n\n- New `Cmder` type → also implement `SetVal` (the custom vet `setval` check\n  enforces this; `SetErr` is on the embedded `baseCmd`).\n- Wrap errors with custom error types that implement `Unwrap`, or use\n  `fmt.Errorf(\"...: %w\", err)`. Always call `cmd.SetErr(...)` after wrapping so\n  typed-error checks still pass.\n- `gofumpt` + `goimports -local github.com/redis/go-redis` is the formatter\n  (`make fmt`); CI runs both.\n- Don't log directly — use `internal.Logger` (set via `redis.SetLogger`);\n  `logging.Disable()` is called in tests.\n- Version-gate Redis-version-specific tests with `SkipBeforeRedisVersion` /\n  `SkipAfterRedisVersion` rather than skipping at the suite level.\n\n### Commits and PRs\n\nConventional Commits, short and exact — `<type>(<scope>): <imperative summary>`.\nSubject ≤50 chars (hard cap 72), imperative (\"add\", not \"added\"), no trailing\nperiod. Body only when the *why* isn't obvious from the diff; wrap at 72.\n\n- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also\n  `build`, `ci`, `style`, `revert`).\n- Scope = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`,\n  `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`,\n  `deps`, `ci`, `tests`, `docs`. Omit only for genuinely cross-cutting changes.\n- Breaking change: `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line.\n  Reference issues/PRs at the end — `Closes #42`, `Refs #17`.\n- **No AI-attribution trailer.** Do not add `Co-Authored-By: …`, \"Generated with\n  …\", or any AI-attribution line to commits or PR bodies in this repo.\n\n## Repo-specific tooling\n\n`.claude/` holds shared AI config:\n\n- `commands/` — slash commands (e.g. `check-ci`, which summarizes a PR's CI).\n- `skills/` — task playbooks: `testing`, `add-command`, `commit-style`,\n  `update-ci-image`, `prepare-release`.\n- `specs/` — the architecture docs listed above.\n\nFor Claude Code, the skills auto-trigger from their descriptions. For other\ntools, each `SKILL.md` is plain markdown you can open and follow directly.\n\n<!-- File: .claude/skills/add-command/references/cluster-routing-wiring.md (Tokens: ~405 | Category: anthropic-skill) -->\n# Cluster routing for new commands\n\nRead this only when the new command is keyless, fans out, targets multiple keys, or aggregates results across shards. **Most simple single-key commands need no cluster-side change** — slot routing is inferred from the key argument automatically.\n\nThis file (the skill reference) is a wiring checklist. For the full routing *design* — slot computation, MOVED/ASK, aggregator internals — read the separate architecture spec at the repo path `.claude/specs/cluster-routing.md` before changing routing code.\n\n## When a command needs wiring\n\n| Command shape | What to wire |\n|---------------|--------------|\n| Keyless (e.g. `DBSIZE`, `FLUSHALL`, server-info commands) | Add the lowercase name to `keylessCommands` (`command.go:25`). Otherwise `ClusterClient` can't pick a node. |\n| Unusual key position (key not the first arg) | Update the key-position / key-spec maps in `command.go`. |\n| Fan-out across all shards, results aggregated | Add a request/response policy in `command_policy_resolver.go` and the matching aggregator in `internal/routing/aggregator.go` (policy declared in `internal/routing/policy.go`). |\n| Multi-key, cross-slot | Confirm the cross-slot rules in `.claude/specs/cluster-routing.md`; commands spanning slots error unless the command supports it. |\n\n## Source of truth = the command spec\n\nThe spec's `key_specs` and `command_flags` tell you the routing class:\n- empty `key_specs` → keyless → `keylessCommands`.\n- `command_flags` with no write flag → `READONLY` → eligible for replica routing.\n- multiple key specs / `numkeys`-style args → multi-key; check cross-slot handling.\n\n## Verify\n\nAfter wiring, exercise against the cluster profile:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\n<!-- File: .claude/skills/add-command/references/core-command-pattern.md (Tokens: ~1654 | Category: anthropic-skill) -->\n# Core command pattern\n\nThe 7-step Go pattern for adding a command to the root `redis` package. Worked example: **LCS** (grep `func (c cmdable) LCS` in `string_commands.go` and `type LCSCmd struct` in `command.go`) — it uses a `cmdable` receiver, a query struct, a custom Cmder, and a registered `CmdType`. Copy its shape exactly.\n\n## 1. Method on the `cmdable` interface\n\nIn the matching `*_commands.go` file. Receiver is `cmdable` — this is what makes the command available on every client type (`Client`, `ClusterClient`, `Ring`, sentinel, pipelines). A `*Client` receiver compiles but is unreachable via `UniversalClient` / `Cmdable`.\n\n```go\nfunc (c cmdable) LCS(ctx context.Context, q *LCSQuery) *LCSCmd {\n\tcmd := NewLCSCmd(ctx, q)\n\t_ = c(ctx, cmd)\n\treturn cmd\n}\n```\n\nFor commands with many optional flags, expose `Foo(...)` for the common path plus `FooWithArgs(ctx, key, *FooArgs)` for the full surface (see `FTHybrid` / `FTHybridWithArgs` in `search_commands.go`).\n\nConventions:\n- Lowercase the command name in the args slice (`\"lcs\"`, not `\"LCS\"`); subcommand flags are uppercase (`\"LEN\"`, `\"IDX\"`, `\"MINMATCHLEN\"`).\n- Pre-size the args slice (`make([]interface{}, 3, 7)`).\n- Convert `time.Duration` to whatever the server expects — never pass it raw (server gets nanoseconds). Use `formatMs` / `formatSec` in `commands.go`.\n- Doc comment: state `Redis X.Y+` (from the spec `since`) and link the redis.io command page.\n\n## 2. New `XxxCmdable` interface goes into `Cmdable`\n\nOnly if you added a **new** `*_commands.go` file with its own interface. Embed it in `Cmdable` (`commands.go`, alphabetical in the embed list — e.g. `SearchCmdable` at `commands.go:242`). Without this the method is unreachable through `UniversalClient`.\n\n## 3. Custom Cmder type\n\nDefine only when no existing Cmd type (`*IntCmd`, `*StringCmd`, `*BoolCmd`, `*StringSliceCmd`, `*MapStringStringCmd`, …) fits the reply. Put it at the bottom of `command.go` (module commands may keep it in their own `*_commands.go`, like `FTHybridCmd`). Follow `LCSCmd`:\n\n```go\ntype LCSCmd struct {\n\tbaseCmd\n\treadType uint8      // command-specific parse state\n\tval      *LCSMatch\n}\n\nvar _ Cmder = (*LCSCmd)(nil)\n\nfunc NewLCSCmd(ctx context.Context, q *LCSQuery) *LCSCmd {\n\targs := make([]interface{}, 3, 7)\n\targs[0], args[1], args[2] = \"lcs\", q.Key1, q.Key2\n\t// ...append optional flags...\n\tcmd := &LCSCmd{readType: 1}\n\tcmd.baseCmd = baseCmd{ctx: ctx, args: args, cmdType: CmdTypeLCS}\n\treturn cmd\n}\n\nfunc (cmd *LCSCmd) SetVal(val *LCSMatch)              { cmd.val = val }\nfunc (cmd *LCSCmd) Val() *LCSMatch                    { return cmd.val }\nfunc (cmd *LCSCmd) Result() (*LCSMatch, error)        { return cmd.val, cmd.err }\nfunc (cmd *LCSCmd) String() string                    { return cmdString(cmd, cmd.val) }\nfunc (cmd *LCSCmd) readReply(rd *proto.Reader) error  { /* parse RESP */ }\nfunc (cmd *LCSCmd) Clone() Cmder                       { /* deep-copy val */ }\n```\n\nRequired methods — the `setval` analyzer (`internal/customvet/checks/setval`) fails the build if `Result` exists without `SetVal`:\n- `SetVal(...)` — hooks mutate results through it. (`SetErr` is inherited from `baseCmd`.)\n- `Val()`, `Result()` — public accessors.\n- `String()` — via `cmdString`.\n- `readReply(rd *proto.Reader) error` — RESP parser. Use `rd.ReadArrayLen`, `rd.ReadInt`, `rd.ReadString`, `rd.ReadFloat`, `rd.ReadFixedMapLen`, `rd.ReadFixedArrayLen`. Validate element counts when the protocol guarantees a fixed shape. Always propagate the reader's error — never return `nil` after a partial read.\n- `Clone()` — deep-copy `val` (including slices/maps) so pipeline/transaction reuse is safe.\n\n**CmdType:** set `cmdType: CmdTypeXxx` in the constructor. If you add a new `CmdTypeXxx` constant (`command.go`), also add the matching case in the `ExtractCommandValue` switch further down `command.go` — this is what lets hooks read the typed value. Grep `CmdTypeLCS` to find both the const declaration and its `case` in `ExtractCommandValue`; copy that shape.\n\n## 4. RESP3 vs RESP2\n\nIf the reply differs between protocols, the parser must handle both — this applies to **any** command with a protocol-dependent shape, core or module, not just modules. Typical case: a map in RESP3, a flat key/value array in RESP2. Branch on the reply type with `rd.PeekReplyType()` (check `proto.RespMap` for RESP3) or `rd.IsResp3()`. Tests run RESP3 by default but RESP2 must still parse for older servers. See `references/module-commands.md` for the FTHybrid worked example.\n\n## 5. Tests — both layers\n\n- **Unit test** in `xxx_commands_unit_test.go` (no Redis) — assert the args slice your method builds for the interesting flag combinations. Pattern: `search_commands_unit_test.go:120` captures `m.lastCmd.(*FTHybridCmd)` and checks args.\n- **Integration test** in `xxx_commands_test.go` (Ginkgo, docker stack). Gate version-specific commands: `SkipBeforeRedisVersion(8.8, \"X requires Redis 8.8+\")` (version from spec `since`). Cover happy path, errors, optional-arg combos.\n\n```sh\ngo test -run TestFTHybridWithArgsRejectsUnsupportedVectors .\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"LCS\"\n```\n\n## 6. Custom vet check\n\n```sh\ncd internal/customvet && go build .\ngo vet -vettool ./internal/customvet/customvet ./...\n```\n\nSame check CI runs at the end of `make test.ci`; catches a missing `SetVal`.\n\n## 7. Format\n\n```sh\nmake fmt\n```\n\n(`gofumpt` + `goimports -local github.com/redis/go-redis`.)\n\n## Pitfalls\n\n- Forgetting to embed the new `XxxCmdable` interface in `Cmdable` — compiles on `*Client`, silently unreachable via `UniversalClient`.\n- Forgetting `Clone()` — pipelines reuse Cmders; shared `val` causes cross-execution bugs.\n- Skipping `SetVal` because \"nothing calls it\" — hooks do, and custom-vet fails the build.\n- Returning `nil` from `readReply` after a partial read — always propagate the reader's error.\n- `time.Duration` passed raw — server gets nanoseconds; convert per spec.\n- New `CmdTypeXxx` added but not registered in the type-routing switch in `command.go`.\n\n<!-- File: .claude/skills/add-command/references/module-commands.md (Tokens: ~721 | Category: anthropic-skill) -->\n# Module commands (RediSearch / TimeSeries / VectorSet / Bloom)\n\nModule subcommands (`FT.*`, `TS.*`, `V*`, `BF.*`/`CF.*`) follow the core pattern but with three differences. Read `references/core-command-pattern.md` first. Worked example: **FTHybrid** (`search_commands.go`).\n\n## 1. Where the spec lives\n\nModule command specs are **not** in `redis/redis/src/commands/`. They live in the module's own repo:\n- RediSearch → `RediSearch/RediSearch`\n- TimeSeries → `RedisTimeSeries/RedisTimeSeries`\n- VectorSet / Bloom → their respective repos\n\nIf Step 0's redis/redis fetch 404s, the command is a module command — get the spec from the module repo or ask the user for the spec file / PR.\n\n## 2. Where the Cmder type lives\n\nModule Cmd types are defined in the **module's** `*_commands.go` file, not `command.go`. `FTHybridCmd` lives in `search_commands.go` (grep `type FTHybridCmd`), registered on the `SearchCmdable` interface at the top of that file, which is embedded in `Cmdable` (`commands.go`). TimeSeries → `timeseries_commands.go`, VectorSet → `vectorset_commands.go`. The required-method set is identical to core (SetVal/Val/Result/String/readReply/Clone).\n\n## 3. RESP2 vs RESP3 reply shapes\n\nModule commands frequently return a **map in RESP3** and a **flat array in RESP2** for the same logical reply. The parser must handle both. FTHybrid reads the raw slice, then `parseFTHybrid` walks it tolerating three shapes:\n\n```go\nfunc (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) {\n\tdata, err := rd.ReadSlice()   // generic slice read\n\tif err != nil {\n\t\treturn err\n\t}\n\tresult, cursorResult, err := parseFTHybrid(data, cmd.withCursor)\n\t// ...assign cmd.val / cmd.cursorVal...\n}\n\n// inside parseFTHybrid, per result item:\nif itemMap, ok := item.(map[string]interface{}); ok {      // RESP3 map\n\tresults = append(results, itemMap)\n} else if rawMap, ok := item.(map[interface{}]interface{}); ok {  // alt RESP3\n\t// stringify keys\n} else if itemData, ok := item.([]interface{}); ok {       // RESP2 array of k/v pairs\n\t// walk pairs\n}\n```\n\nPattern: don't assume RESP3. Try the map shape, fall back to the array (key-value pairs) shape. Tests run RESP3 by default but RESP2 must still parse for older servers.\n\n## 4. Common module conventions\n\n- Subcommand keyword is uppercase in args (`\"SEARCH\"`, `\"VSIM\"`, `\"WITHCURSOR\"`); the command token is lowercase (`\"ft.hybrid\"`).\n- Cursor-style commands carry parse state on the Cmd struct (FTHybrid's `withCursor`, `cursorVal`) — set it in the constructor from the options.\n- Module commands are almost always `FooWithArgs(ctx, index, *FooOptions)` plus a thin positional `Foo(...)`.\n- Version-gate integration tests against the **module** version, not the core Redis version.\n\n<!-- File: .claude/skills/add-command/SKILL.md (Tokens: ~1375 | Category: anthropic-skill) -->\n---\nname: add-command\ndescription: Use when adding a new Redis command (or RediSearch / TimeSeries / VectorSet / module subcommand) to go-redis — covers fetching the command spec and docs, the Cmder type, Cmdable interface wiring, RESP parsing, tests, and the custom-vet rule that enforces SetVal.\nallowed-tools: WebFetch\n---\n\n# Adding a new Redis command\n\nRouter for adding a Redis command to the root `redis` package. Read the reference file for the area you're touching — don't load all of them.\n\n## Step 0 — Get the command spec AND docs FIRST\n\nBefore writing any Go, know the exact command shape (arguments, optional flags, reply structure, since-version, key positions) **and** its documented semantics (what each reply field means, RESP2-vs-RESP3 differences, examples). Fetch **two** sources — the machine-readable spec and the prose docs. They cover different gaps: the spec nails arguments and key positions, the docs nail what the reply actually looks like.\n\n### A. Machine-readable spec (arguments, key specs, reply schema)\n\nResolve the spec in this order:\n\n1. **Spec file argument** — if the user passed a path to a spec/JSON file, read it.\n2. **PR URL argument** — if the user passed a `github.com/redis/redis` PR (or other repo) URL, fetch the diff and read the command definition + `src/commands/<cmd>.json` it adds.\n3. **No argument** — fetch the machine-readable spec from redis/redis:\n   ```\n   https://raw.githubusercontent.com/redis/redis/unstable/src/commands/<command>.json\n   ```\n   Container subcommands use `<container>-<sub>.json` (e.g. `client-info.json`). **A 404 means it's a module command** (RediSearch, TimeSeries, VectorSet, Bloom) — those specs live in the **module's own repo**, not redis/redis. Switch to the module repo (see `references/module-commands.md` §1) or ask the user for the spec/PR. Don't retry the redis/redis URL.\n\n### B. Prose docs (semantics, return value, RESP2/RESP3 reply, examples)\n\nThe JSON `reply_schema` is often thin or missing; the docs spell out what the reply actually is — including separate **RESP2 Reply** and **RESP3 Reply** sections, which decide your `readReply` and any module RESP2-vs-RESP3 handling. Fetch the command's doc page:\n\n```\nhttps://redis.io/docs/latest/commands/<command>/\n```\n\nRaw markdown source (alternative, good for diffing or when the rendered page is noisy):\n`https://raw.githubusercontent.com/redis/redis-doc/master/commands/<command>.md`.\n**Module commands** (RediSearch, TimeSeries, …) are documented under their own\npath on redis.io (e.g. `/docs/latest/commands/ft.search/`) or in the module repo\n— see `references/module-commands.md`.\n\nRead the **Return value / RESP2 Reply / RESP3 Reply** sections plus the examples, and reconcile them against the JSON `reply_schema`. When the two disagree, the docs' reply description (and a quick `redis-cli` check) win.\n\n### Map both sources to the implementation\n\n| Source | Drives |\n|--------|--------|\n| spec `arguments` | method signature, args-slice build order, optional `FooArgs` struct |\n| spec `reply_schema` + docs **Return value** | the `readReply` parser and Cmder result type |\n| docs **RESP2 Reply / RESP3 Reply** | RESP2-vs-RESP3 branching in `readReply` (see `references/module-commands.md`) |\n| spec `since` + docs `@history` | `SkipBeforeRedisVersion(...)` in the integration test, doc comment |\n| spec `key_specs` | key-position maps in `command.go`; cluster routing |\n| spec `command_flags` (e.g. `READONLY`, no key) | keyless / fan-out handling, cluster routing |\n| docs examples | integration-test cases and expected values |\n\nIf you can't get either source, STOP and ask the user — guessing the reply shape produces a broken `readReply`.\n\n## Decide before you start\n\n1. **Which `*_commands.go` file?** — pick the existing file matching the data type (`string_commands.go`, `hash_commands.go`, `search_commands.go`, …). Only create a new file for a genuinely new category. New files need a matching `XxxCmdable` interface embedded in `Cmdable` (`commands.go`).\n2. **Reuse an existing Cmder, or define a new one?** — simple replies (`int`, `string`, `bool`, `[]string`, map) reuse `*IntCmd`, `*StringCmd`, `*BoolCmd`, `*StringSliceCmd`, `*MapStringStringCmd`, … Define a new Cmder only for a structured reply that no existing type fits.\n3. **`Foo` vs `FooWithArgs`?** — for commands with optional flags, expose a positional `Foo(...)` for the common path plus `FooWithArgs(ctx, key, *FooArgs)` for the full surface.\n\n## Reference files\n\n| Read this | When |\n|-----------|------|\n| `references/core-command-pattern.md` | Always — the 7-step Go pattern (interface method, Cmder type, RESP parsing, tests, vet, fmt). Worked example: `LCS`. |\n| `references/module-commands.md` | Adding a RediSearch / TimeSeries / VectorSet / Bloom subcommand — naming, RESP2-vs-RESP3 shape differences, where the Cmd type lives. |\n| `references/cluster-routing-wiring.md` | Command is keyless, fans out, multi-key, or aggregates across shards. |\n\nWhen several apply, read in order: core → module → cluster.\n\n## Pitfalls (full list in core-command-pattern.md)\n\n- Forgetting to embed the new `XxxCmdable` interface in `Cmdable` — compiles on `*Client`, silently unreachable via `UniversalClient`.\n- Forgetting `Clone()` — pipelines reuse Cmders; shared `val` causes cross-execution bugs.\n- Skipping `SetVal` because \"nothing calls it\" — hooks do, and the `setval` custom-vet check fails the build.\n- Using `time.Duration` directly in args — server gets nanoseconds. Convert per spec.\n\n<!-- File: .claude/skills/commit-style/SKILL.md (Tokens: ~449 | Category: anthropic-skill) -->\n---\nname: commit-style\ndescription: Use when writing a commit message or opening a PR in the go-redis repo — covers the Conventional-Commit format, the repo's scope vocabulary (pool, conn, sentinel, command, …), breaking-change syntax, and the rule that NO AI-attribution trailer is added.\n---\n\n# go-redis commit messages\n\nConventional Commits, short and exact — `<type>(<scope>): <imperative summary>`. Subject ≤50 chars (hard cap 72), imperative mood (\"add\", not \"added\"), no trailing period. Body only when the *why* isn't obvious from the diff (breaking change, security fix, migration, non-obvious rationale); wrap at 72, bullets `-`. State why, not what — the diff says what.\n\n- **Types:** `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also `build`, `ci`, `style`, `revert`).\n- **Scope** = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`, `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`, `deps`, `ci`, `tests`, `docs`. Omit scope only for genuinely cross-cutting changes.\n- **Breaking change:** `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line.\n- **Issues/PRs:** reference at the end — `Closes #42`, `Refs #17`.\n\n## No attribution trailer\n\nDo **not** add `Co-Authored-By: Claude …`, \"Generated with Claude Code\", or any AI-attribution line to commits or PR bodies in this repo. This overrides both the default harness behavior (which appends such trailers) and any other commit skill.\n\n## Examples\n\n```\nfix(pool): release wantConn slot on dial timeout\n\nThe waiter stayed queued after the dial deadline fired, leaking a\nslot per timed-out connection under load.\n\nCloses #3812\n```\n\n```\nfeat(streams): support explicit LIMIT 0 in XTRIM trimming\n```\n\n<!-- File: .claude/skills/prepare-release/SKILL.md (Tokens: ~1115 | Category: anthropic-skill) -->\n---\nname: prepare-release\ndescription: Use when preparing a go-redis release — cutting a new version, bumping version.go, updating the go-redis dependency version in submodule go.mod files, or writing the RELEASE-NOTES.md entry for a new vX.Y.Z. Covers choosing the next semver, finding the last release and gathering merged PRs since then, the release-notes format and what to exclude, running scripts/release.sh to bump versions, and verifying with the scripts/tag.sh dry-run. Does NOT create tags, push, or commit — publishing stays a manual maintainer step.\nallowed-tools: Bash(git describe:*), Bash(gh pr list:*)\n---\n\n# Preparing a go-redis release\n\nStage everything for a release locally, then stop before anything is published.\n\n## This skill never publishes\n\n- **Never** run `scripts/tag.sh ... -t` — the `-t` flag creates and pushes git tags.\n- **Never** `git push` anything (no commits, no tags).\n- **Never** commit on the maintainer's behalf unless they explicitly ask.\n\nThe deliverable is a reviewable diff: bumped versions plus a new `RELEASE-NOTES.md`\nentry. A human reviews it and runs the publish step.\n\n## 1. Pick the next version\n\nThe current version is the source of truth in `version.go`:\n\n```sh\ngrep 'return' version.go        # e.g. return \"9.21.0\"\n```\n\nChoose the next `vX.Y.Z` by semver, based on what shipped since the last release:\n\n- **patch** (`Z`) — bug fixes only; drop-in upgrade.\n- **minor** (`Y`) — new features, no breaking changes; drop-in upgrade.\n- **major** (`X`) — breaking changes.\n\nConfirm the level with the user if the changeset is ambiguous.\n\n## 2. Find the last release and gather changes\n\n`scripts/tag.sh` also tags every public submodule (`extra/redisotel/vX.Y.Z`, …),\nso a naive `git describe` returns a submodule tag. Match the **root** tag only:\n\n```sh\nLAST=$(git describe --tags --abbrev=0 --match 'v[0-9]*')   # e.g. v9.21.0\ngit log \"$LAST\"..HEAD --oneline\ngh pr list --state merged --limit 100 \\\n  --json number,title,author,mergedAt,url --search \"merged:>=<last-release-date>\"\n```\n\nCategorize the PRs: highlights, new features, bug fixes, performance,\ntesting/infrastructure. Exclude dependabot bumps, typo-only doc fixes, internal\nrefactors with no user-facing effect, and `dependabot[bot]` from the contributor\nlist.\n\n## 3. Write the release notes\n\nPrepend a new `# X.Y.Z (YYYY-MM-DD)` section to the **top** of `RELEASE-NOTES.md`\n(newest first; leave older entries untouched). Follow\n[`.github/RELEASE_NOTES_TEMPLATE.md`](../../../.github/RELEASE_NOTES_TEMPLATE.md)\nexactly — section order, emoji headers, the `([#PR](url)) by [@user](url)` link\nformat, and the `**Full Changelog**` compare link `${LAST}...vX.Y.Z`. Open the\nlead line with the release type and whether it is a drop-in upgrade, matching the\nexisting entries.\n\nThat template carries the full \"what to exclude\" and formatting rules — read it,\ndon't reinvent them. `release-drafter` separately auto-drafts a GitHub release\nfrom PR labels (`.github/release-drafter-config.yml`); `RELEASE-NOTES.md` is the\ncurated, hand-written record and is the file you edit.\n\n## 4. Bump versions\n\nRun the repo's bump script. It rewrites the go-redis dependency version in every\nsubmodule `go.mod`, runs `go mod tidy`, and bumps `version.go`. It does **not**\ncommit, push, or switch branches:\n\n```sh\nTAG=vX.Y.Z ./scripts/release.sh\n```\n\n## 5. Verify (dry run only)\n\nRun the tag script's **dry run** — it checks that `version.go` and every `go.mod`\nalready match the tag, and prints the tags it *would* push. No `-t`:\n\n```sh\n./scripts/tag.sh vX.Y.Z         # DRY RUN — must pass cleanly; never add -t here\nmake build\ngit diff --stat                 # review version.go, submodule go.mod, RELEASE-NOTES.md\n```\n\nFix anything the dry run flags before handing off.\n\n## 6. Hand off\n\nReport that the release is staged and list the publish steps for the maintainer\nto run themselves (this skill does not do them):\n\n1. Review `git diff`, then commit (`chore(release): vX.Y.Z` — see the\n   `commit-style` skill; no AI-attribution trailer).\n2. Open and merge the release PR.\n3. After merge, tag and push: `./scripts/tag.sh vX.Y.Z -t`.\n4. `release-drafter` publishes the GitHub release; reconcile it with the\n   `RELEASE-NOTES.md` entry if needed.\n\nStop after step 5. Do not commit, tag, or push.\n\n<!-- File: .claude/skills/testing/SKILL.md (Tokens: ~802 | Category: anthropic-skill) -->\n---\nname: testing\ndescription: Use when running go-redis tests of any kind — the full suite, a single test, or a filtered/focused subset (e.g. only cluster, sentinel, or pool specs); bringing up or tearing down the Docker test stack; the e2e maintenance-notifications suite (including the proxy-free logic tests); building or formatting; or setting the REDIS_VERSION / RE_CLUSTER / REDIS_PORT knobs that drive the test image and version gating. Covers how to focus a Ginkgo spec and how to run plain go tests.\n---\n\n# Running tests, builds, and the Docker stack\n\nTests run against a Redis stack started via Docker Compose. Profiles in `docker-compose.yml` control which services come up (`standalone`, `cluster`, `sentinel`, `all`, `e2e`, …).\n\n## Make targets\n\n```sh\nmake docker.start          # bring up the full test stack (profile: all)\nmake docker.stop\nmake test                  # docker.start -> test.ci -> docker.stop\nmake test.ci               # run tests assuming containers are already up\nmake test.ci.skip-vectorsets   # used when REDIS_VERSION < 8\nmake bench                 # go test -bench=. (root module only)\nmake fmt                   # gofumpt + goimports (-local github.com/redis/go-redis)\nmake build\nmake go_mod_tidy           # go mod tidy across every module\n```\n\nThe Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) for `test.ci`, `go_mod_tidy`, etc.\n\n## E2E (maintenance notifications)\n\nNeeds an extra `cae-resp-proxy` service:\n\n```sh\nmake test.e2e              # auto-starts the e2e profile, runs ./maintnotifications/e2e/, tears down\nmake test.e2e.docker       # subset that runs inside docker\nmake test.e2e.logic        # logic-only tests, no proxy required\n```\n\n## Env knobs (passed through the Makefile)\n\n- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and `main_test.go` version-gating helpers (`SkipBeforeRedisVersion` / `SkipAfterRedisVersion`).\n- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g. `redislabs/client-libs-test:8.8-m03`. (To change the default tag, use the `update-ci-image` skill — it lives in several files.)\n- `RE_CLUSTER=true` — point tests at a Redis Enterprise cluster instead of the docker-compose stack. The suite then skips ring/sentinel/TLS-cluster setup.\n- `RCE_DOCKER=true` — using Redis CE in docker (default for `make test`).\n- `REDIS_PORT` — override the default standalone port (`6380`) used by `main_test.go`.\n\n## Running a single test\n\nThe root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega` forks). `go test -run` matches the Go-level wrapper, so to focus a Ginkgo spec use the Ginkgo focus flag:\n\n```sh\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"ZAdd\"\ngo test -run TestGinkgoSuite . -ginkgo.focus=\"cluster\"\n```\n\nPlain `go test`-style tests (most files outside the Ginkgo suite, e.g. `internal/...`, `maintnotifications/...`) work the usual way:\n\n```sh\ngo test -run TestConnStateMachine ./internal/pool/...\ngo test -race -run TestCircuitBreaker ./maintnotifications/...\n```\n\n## Version-gating\n\nGate Redis-version-specific tests with `SkipBeforeRedisVersion` / `SkipAfterRedisVersion` (driven by `REDIS_VERSION`) rather than skipping at the suite level.\n\n<!-- File: .claude/skills/update-ci-image/SKILL.md (Tokens: ~1551 | Category: anthropic-skill) -->\n---\nname: update-ci-image\ndescription: Use when adding, updating, or removing a `redislabs/client-libs-test` image tag used by the test stack — covers every place a Redis version → image-tag mapping lives so local `make test` and GitHub CI stay in sync.\n---\n\n# Updating the CI test image\n\nThe `redislabs/client-libs-test` image is referenced in **six** places (five live config + one doc). Miss one and either local `make test` and CI diverge, or a single CI job uses the wrong image. Always edit all relevant locations in one change.\n\n## The six locations\n\n| # | File | What lives here |\n|---|------|-----------------|\n| 1 | `Makefile` | `CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:<tag>` — default for local `make test` / `make docker.start`. |\n| 2 | `docker-compose.yml` | `x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:<tag>}` — fallback when the env var is unset. Should match the Makefile default. |\n| 3 | `.github/workflows/build.yml` | `redis_version_mapping` bash assoc array (`[\"8.X.x\"]=\"<tag>\"`). Drives the `benchmark` job and the `test-redis-ce` matrix via `run-tests`. |\n| 4 | `.github/actions/run-tests/action.yml` | Identical `redis_version_mapping` array — the composite action that the matrix jobs invoke. Keep in lockstep with `build.yml`. |\n| 5 | `.github/workflows/doctests.yaml` | Hardcoded `image: redislabs/client-libs-test:<tag>` on the `redis-stack` service — doctests do not consult the mapping. |\n| 6 | `CONTRIBUTING.md` | Prose line \"By default the docker image … is `redislabs/client-libs-test:<tag>`.\" Documentation only — no CI impact, but goes stale silently. Update it to match the Makefile default. |\n\n## Tasks\n\n### Update the tag for an existing Redis version (e.g. bump 8.8 → a new release)\n\n1. `Makefile` — replace the tag after `redislabs/client-libs-test:` on the `CLIENT_LIBS_TEST_IMAGE` line.\n2. `docker-compose.yml` — replace the tag in the `:-redislabs/client-libs-test:<tag>` default.\n3. `.github/workflows/build.yml` — update the `[\"8.X.x\"]=\"<tag>\"` line in `redis_version_mapping`.\n4. `.github/actions/run-tests/action.yml` — update the matching `[\"8.X.x\"]=\"<tag>\"` line.\n5. `.github/workflows/doctests.yaml` — update the `image:` line **only if** doctests should run against this version. Doctests typically pin to the latest stable; check before changing.\n6. `CONTRIBUTING.md` — update the \"By default the docker image … is `redislabs/client-libs-test:<tag>`.\" prose line to match the new `Makefile` default. This one is easy to forget because nothing breaks when it's stale.\n\nIf the version that doctests run against (`Makefile` default) and the mapping for that same minor version diverge, you have a bug — they should resolve to the same tag.\n\n### Add a new Redis version to the CI matrix\n\n1. `.github/workflows/build.yml`:\n   - Add `- \"<X.Y>.x\"` to **both** `redis-version` matrices (the `benchmark` job and the `test-redis-ce` job — they have separate lists).\n   - Add `[\"<X.Y>.x\"]=\"<tag>\"` to `redis_version_mapping`.\n2. `.github/actions/run-tests/action.yml` — add the same `[\"<X.Y>.x\"]=\"<tag>\"` entry to its `redis_version_mapping`.\n3. If this becomes the new default for local dev, also update `Makefile` (`REDIS_VERSION ?=` and `CLIENT_LIBS_TEST_IMAGE ?=`) and `docker-compose.yml`.\n\n### Remove a Redis version\n\n1. `.github/workflows/build.yml` — drop the `- \"<X.Y>.x\"` line from both `redis-version` matrices and the `[\"<X.Y>.x\"]=\"...\"` entry.\n2. `.github/actions/run-tests/action.yml` — drop the same `[\"<X.Y>.x\"]=\"...\"` mapping entry.\n3. If `Makefile` / `docker-compose.yml` / `doctests.yaml` were pinned to this version, pick a replacement (usually the next-newest version still in the matrix) and update them too, along with the `CONTRIBUTING.md` prose line.\n\n### Switch to a custom build (e.g. `custom-26172898734-debian` for an unreleased server feature)\n\nSame as \"update the tag,\" but think about scope:\n- Custom tags usually correspond to a single Redis minor — only touch the mapping entry for that minor, not all five rows.\n- Custom tags often shouldn't be the local default if they're transient. Confirm with the user before touching `Makefile` / `docker-compose.yml`.\n- Leave `REDIS_VERSION` (the numeric `8.8` / `8.6` etc.) alone — it drives `SkipBeforeRedisVersion` gating in `main_test.go`, not the image. The custom image still reports its base version.\n\n## Verification\n\nAfter editing, search for any stale tag references:\n\n```sh\n# Use the Grep tool, not bash grep.\n# Pattern: literal old tag, e.g. \"8.8-rc1\" or \"client-libs-test:8.8\"\n```\n\nExpected matches: zero in `Makefile`, `docker-compose.yml`, `.github/**`, and `CONTRIBUTING.md`. A reference in `AGENTS.md` or the `testing` skill documentation (e.g. \"e.g. `redislabs/client-libs-test:8.8-m03`\") is illustrative — leave it unless the doc is misleading after the change.\n\n## Gotchas\n\n- **Two mappings, not one.** `build.yml` and `run-tests/action.yml` each declare their own `redis_version_mapping`. They are not deduped. Always edit both.\n- **Two matrices in `build.yml`.** The `benchmark` job and the `test-redis-ce` job each have their own `redis-version` list. Adding/removing a version means editing both.\n- **`doctests.yaml` is hardcoded.** It doesn't read the mapping. If the team intends \"doctests follow the latest stable,\" verify the tag matches `Makefile`'s default after any change.\n- **Don't change `REDIS_VERSION` when only swapping the image.** The number gates version-specific test skips (`SkipBeforeRedisVersion(8.8, ...)`). A custom image built off 8.8 should keep `REDIS_VERSION=8.8`.\n- **Env var vs. default.** Local users can override with `CLIENT_LIBS_TEST_IMAGE=...`; `docker-compose.yml` uses that env var first and falls back to its baked-in default. So the docker-compose default only matters when the Makefile isn't driving things — but it's still load-bearing for direct `docker compose up` calls. Keep it in sync.\n\n<!-- File: CLAUDE.md (Tokens: ~191 | Category: claude-rule) -->\n# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nRepository guidance — layout, commands, architecture, and conventions — is\nmaintained in [`AGENTS.md`](AGENTS.md) so it stays shared across AI tools. It is\nimported below; edit `AGENTS.md`, not this file, for that content.\n\n@AGENTS.md\n\n## Claude-specific\n\n- Repo-local **skills** under `.claude/skills/` auto-trigger from their\n  descriptions — no need to invoke them manually (the set is listed in\n  `AGENTS.md`).\n- **Slash commands** under `.claude/commands/` (e.g. `/check-ci`) are available\n  in-session.\n- Architectural **specs** under `.claude/specs/` are read on demand; open the\n  relevant one before changing that subsystem.\n\n<!-- File: .claude/commands/check-ci.md (Tokens: ~420 | Category: command-prompt) -->\n---\ndescription: Summarize GitHub Actions CI status for the current branch's PR (build, lint, e2e, govulncheck, ...)\nargument-hint: \"[pr-number]\"\nallowed-tools: Bash(gh pr checks:*), Bash(gh pr view:*), Bash(gh run view:*), Bash(gh pr list:*), Bash(git branch:*)\n---\n\n## Context\n\n- Branch: !`git branch --show-current`\n- PR: !`gh pr view $1 --json number,title,url,state 2>/dev/null || echo \"NO_PR\"`\n- Checks (name / state / elapsed / url): !`gh pr checks $1 2>&1 || true`\n\n## Task\n\nReport the CI state for this PR. If the PR line is `NO_PR`, say there is no open PR for this branch and stop.\n\nOtherwise, from the checks block:\n\n1. **Counts first.** One line: `N passing · M failing · K pending` (and skipped/cancelled if any).\n2. **Failures and pending only.** For each check that is not passing, list its name, state, and the log URL. Skip the passing ones, they are noise.\n3. **Prioritize.** Order failures by what blocks a merge most: `test-redis-ce` (the main correctness matrix) and `E2E Tests (Mock Proxy)` first, then `lint` and `govulncheck`, then `CodeQL` / `doctests` / `check-spelling`, then everything else (benchmarks last).\n4. **Verdict.** One closing line:\n   - All green → \"All checks pass, nothing to do.\"\n   - Any red → name the single most important failing check to look at first.\n   - Only pending → say which are still running and that it is not done yet.\n\nKeep it short. No preamble, no restating this prompt. The argument `$1` is an optional PR number; when omitted, the current branch's PR is used.\n</yakaai_skills>"}