Add a new Redis command (or command variant) to node-redis end-to-end — the `<NAME>.ts` Command file, its registration with JSDoc in the package `commands/index.ts`, and a co-located `<NAME>.spec.ts` with arg + behavior tests. Use when asked to implement, add, or wire up a Redis command in the client or a module package (json/search/bloom/time-series).
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
The command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
File Explorer
2 files
Showing SKILL.md
SKILL.md
Source instructions · Read-only preview
name
implement-command
description
Add a new Redis command (or command variant) to node-redis end-to-end — the `<NAME>.ts` Command file, its registration with JSDoc in the package `commands/index.ts`, and a co-located `<NAME>.spec.ts` with arg + behavior tests. Use when asked to implement, add, or wire up a Redis command in the client or a module package (json/search/bloom/time-series).
Implement a node-redis Command
Overview
A command in node-redis is a single Command object exported from
packages/<pkg>/lib/commands/<NAME>.ts. It declares how to serialize arguments
onto the wire (parseCommand) and how to map the RESP reply to a JS value
(transformReply). It becomes callable on clients only after it is registered
in the package's commands/index.ts. Both the raw name and a camelCase alias
are exposed (HSET and hSet).
This skill is for @redis/client core commands and for module packages
(@redis/json, @redis/search, @redis/bloom, @redis/time-series). It does
not cover RESP codec changes or new client transports.
Before writing, read 2-3 existing commands with a similar shape (simple
key read, key + options, variadic, RESP2/3-divergent reply) and mirror them.
Consult redis.io/commands for argument order and
reply type, but treat existing parseCommand/transformReply files as the
source of truth for repo conventions.
Naming: file name = raw wire name with subcommand _ separators
(ACL_CAT, CONFIG_GET, CLUSTER_FORGET). A distinct reply variant gets its
own file (HRANDFIELD_COUNT_WITHVALUES). Module commands drop the dotted
prefix from the file name (ARRAPPEND.ts → wire JSON.ARRAPPEND).
Step 0 — Gather inputs (ask the user first)
New commands are often implemented before they are publicly released, so
redis.io may not document them yet and a default redis:latest may not have
them. Before writing any code, ask the user for three things:
The command spec. Ask for the redis/redis JSON spec file — one per
command under
src/commands/<name>.json
(subcommands use -, e.g. client-info.json, acl-cat.json). If the
command is unreleased, ask the user to paste the spec from their branch.
As a fallback on a live server: redis-cli --json COMMAND DOCS <name> and
COMMAND INFO <name>.
A running Redis instance that has the command. This is the single most
useful input — ask for it explicitly and up front. Ask for connection
details (host/port, TLS, auth, module loaded). Use it to explore real
behavior and confirm the implementation matches the spec — do not rely on
the spec alone. Probe every argument branch and diff the real reply against
your transformReply. A quick redis-cli session or a throwaway probe
script (packages/client is already wired for tsx) is enough; never commit
the probe.
The first server version that ships the command. Ask which Redis (or
module) version introduced it — the spec's since field is the answer when
present; otherwise ask the user directly. You need this to (a) write @since
in the JSDoc (Step 2) and (b) gate the behavior tests with
minimumDockerVersion (Step 3) so they don't run — and fail — on older
servers in CI.
If the user cannot provide a spec, derive arguments/reply from
redis.io/commands but flag that it is unverified.
If they cannot provide a live instance, implement from the spec but state that
runtime behavior was not confirmed. If the introducing version is unknown, say
so and leave @since/minimumDockerVersion out rather than guessing.
Reading the spec JSON → mapping to a Command
The redis/redis spec drives every part of the Command object. Example
(getex.json, trimmed):
as const satisfies Command is mandatory — it preserves the literal arg types
for the public API while type-checking the shape.
Command flags (all optional)
IS_READ_ONLY: true — read command; routable to replicas. Set for reads, omit/false for writes.
CACHEABLE: true — eligible for client-side caching. Only for pure reads with no side effects.
NOT_KEYED_COMMAND: true — command takes no key (server/connection level, e.g. PING, CONFIG_GET).
IS_FORWARD_COMMAND — internal; do not set on new commands.
parseCommand — serialize args via CommandParser
First arg is always parser. Push the wire name first, then args in order.
Use the parser helpers — do not hand-build arrays:
push(...args) — raw args (the command token, flags, stringified numbers).
pushKey(key) — a key. Registers it for cluster slot routing. Use for every key, never push a key.
pushKeys(keys) / pushKeysLength(keys) — multiple keys; the Length variant prefixes the count.
pushVariadic(vals) — a RedisVariadicArgument (one value or array) as flat args.
pushVariadicWithLength(vals) — same, prefixed with the count (e.g. FIELDS <n> ...).
pushVariadicNumber(vals) — number or array of numbers, stringified.
Numbers are not auto-stringified by push — call .toString(). Optional
trailing args go in an options object; export its interface (see
SET.ts's SetOptions). Encode keyword flags conditionally:
Pass-through (reply already the right shape): transformReply: undefined as unknown as () => <ReplyType>.
Function: (reply: <RawType>) => <JsType>. Use UnwrapReply<...> to read the raw RESP container.
RESP-version keyed: { 2: (reply) => ..., 3: (reply) => ... } when RESP2 and RESP3 shapes differ (e.g. flat array vs map/tuple). See HRANDFIELD_COUNT_WITHVALUES.ts.
Unify RESP2 onto the RESP3 shape
When the server returns different shapes per protocol, the library exposes one
return type to callers: the RESP3 shape is the source of truth, and the
RESP2 reply is transformed to look like it. So the keyed form is almost always:
3: — pass-through (undefined as unknown as () => <ReplyType>), because
RESP3 already has the target shape (map, tuple, big-number, double, ...).
2: — a function that reshapes the flat/legacy RESP2 reply into that same
<ReplyType>. Type its input UnwrapReply<Resp2Reply<ReplyType>> so the raw
RESP2 container is visible while the output type still matches RESP3.
Canonical example — HELLO.ts turns the RESP2 flat array ([k, v, k, v, ...])
into the RESP3 map, while RESP3 passes through:
Reuse shared transformers where one exists (HGETALL.ts uses
transformTuplesReply for 2:, map pass-through for 3:). Only when RESP3
still needs reshaping does 3: get its own function too. Verify the actual
per-protocol shapes against the live instance (Step 0) — connect once with
RESP: 2 and once with RESP: 3 and diff.
Reply types live in RESP/types: BlobStringReply, SimpleStringReply<'OK'>,
NumberReply, DoubleReply, NullReply, BooleanReply, ArrayReply<T>,
TuplesReply<[...]>, MapReply, UnwrapReply.
RESP3 is the default. No separate RESP3 test is needed for a new command;
the default test setup already exercises RESP3.
Type-mapping precision caveats
A BLOB_STRING reply cannot be remapped to Number via type mapping; only RESP3 DOUBLE/BIG_NUMBER are precision-risky.
If a NumberReply can exceed Number.MAX_SAFE_INTEGER (2^53-1), add a @remarks line to the JSDoc (Step 2) telling users to do client.withTypeMapping({ [RESP_TYPES.NUMBER]: String }). See the ARGREP entries in the client index for the exact wording.
Module package commands
Import from the published client subpath, prefix the wire name, and reuse
shared transformers (packages/json/lib/commands/ARRAPPEND.ts):
import the command, then add it to the default-export map twice: the raw
name (shorthand) and a camelCase alias. Every entry MUST have a JSDoc block
directly above it — npm run check:command-jsdoc fails on any registry entry
without an attached JSDoc comment (no blank-line gap allowed).
importGETfrom'./GET';
// ...exportdefault {
/**
* Returns the value of a key, or null if the key does not exist
* @paramkey - Key to read
* @since 1.0.0
*/GET,
/**
* Returns the value of a key, or null if the key does not exist
* @paramkey - Key to read
* @since 1.0.0
*/get: GET,
} satisfiesRedisCommands;
Keep both JSDoc blocks (raw + alias) in sync. Document every parseCommand
param after parser with @param. Add @since <version> with the introducing
server version from Step 0 (the spec's since); omit it only if that version is
unknown. Add @remarks for the precision caveat above when relevant. For module
packages the registry files are
packages/<pkg>/lib/commands/index.ts (bloom: per-family .../<family>/index.ts).
Step 3 — Write <NAME>.spec.ts (co-located)
Two layers: arg serialization (no server) + behavior (real server, server +
cluster topologies). Mirror GET.spec.ts:
parseArgs(COMMAND, ...args) asserts the exact wire array — cover each option/flag branch and variadic shapes. Arg tests need no server, so never gate them by version.
testUtils.testAll(name, fn, { client, cluster }) runs the same body against a standalone server and a cluster. Use it so cluster key routing (pushKey) is exercised. Drop cluster only when the command is genuinely cluster-incompatible.
Gate every behavior test by the introducing version (Step 0). Spread minimumDockerVersion: [major, minor] into both the client and cluster options (as above). CI runs multiple server versions; without the gate the test runs on older servers that lack the command and fails. [8, 8] = "8.8 and newer". Apply the same to testWithClient/testWithCluster by spreading it into their single options object. Omit only if the version is genuinely unknown.
Pick the right GLOBAL.SERVERS.* / GLOBAL.CLUSTERS.* setup (see test-utils.ts); OPEN is the default.
Docker is required — test-utils starts real Redis containers.
Step 4 — Build, verify, lint
npm run build # tsc --build (project references)
npm run check:command-jsdoc # registry JSDoc gate
npm run test-single -- packages/<pkg>/lib/commands/<NAME>.spec.ts
npm run lint # changed files
If the build fails on stale dist/ from project references:
find packages -type d -name "dist" -execrm -rf {} + && npm run build
For module packages, build the client first (or whole repo) — they import from
@redis/client/dist.
Completion checklist
Asked the user for spec, a live instance with the command, and the introducing server version (Step 0); probed real behavior against the live instance.
<NAME>.ts created with parseCommand + transformReply, as const satisfies Command.
Flags set correctly (IS_READ_ONLY for reads, CACHEABLE only for side-effect-free reads, NOT_KEYED_COMMAND if no key).
Every key uses pushKey/pushKeys; numbers stringified; options behind an exported interface.
RESP2/3 divergence handled via keyed transformReply: RESP3 is the target shape (usually 3: pass-through), RESP2 transformed to match it; both shapes verified against the live instance.
Registered in commands/index.ts: import + raw entry + camelCase alias, each with JSDoc (@param per arg; @since for the introducing version; @remarks for >2^53 precision).
<NAME>.spec.ts: parseArgs covers all branches; testUtils.testAll covers server + cluster; behavior tests gated with minimumDockerVersion on both client and cluster.
npm run build, npm run check:command-jsdoc, the spec, and npm run lint all pass.
Commit message uses Conventional Commits; no company-internal refs.