mcpbeat

Adding A Redis Command

redis/adding-a-redis-command

Use when adding a new Redis command — or a new overload/variant of one — to the Lettuce client. Covers the full flow: writing the command specification, designing the Lettuce API, adding argument types and the API template, running the interface generators, adding the builder/async/reactive/Kotlin implementations, and adding unit + integration tests. Trigger on requests like "add support for the <X> command", "implement <REDIS COMMAND> in Lettuce", "wire up a new command", or adding a new argument/overload to an existing command.

3k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
5775
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/redis/lettuce --skill adding-a-redis-command

What it tells the agent to use

found in the instruction text
Bash runs shell commands — read the instruction before connecting

The instruction itself

11 sections, as written by the author

Adding a Redis command to Lettuce

Read .agents/docs/architecture.md first for the model

behind this flow — especially that the sync/async/reactive/Kotlin command

interfaces are generated from templates and must never be hand-edited, while the

top-level aggregate interfaces (RedisCommands, …) are hand-written compositions.

The flow

1. Specification → 2. API design → 3. Types + template → ⛔ human review → 4. Generate
                 → 5. Implementations → 6. Tests → 7. Verify

Two things are front-loaded on purpose: you write the spec before code, and you

add argument types + templates before running the generator (the generated

interfaces reference those types, so they must exist to compile).

Copy this checklist into your working notes and tick items off as you go:

Add-a-command progress:
- [ ] 1. Spec: syntax, args, RESP2 + RESP3 replies, errors, version
- [ ] 2. API design: method(s), return type, target group, arg/response types
- [ ] 3. Types + template: arg & response types first, then the template method + Javadoc
- [ ] ⛔ HUMAN REVIEW: maintainer approves the edited template(s) before generating
- [ ] 4. Generate: run the api_generator tests; do NOT hand-edit generated interfaces
- [ ] 5. Implementations: CommandType, RedisCommandBuilder, async, reactive, Kotlin impl
- [ ] 6. Tests: builder unit test + integration base/overloads (@EnabledOnCommand)
- [ ] 7. Verify: unit (mvn test) + a single integration test

1. Start from a specification

Before touching code, have a clear spec (HLD/PRD, usually attached to the

feature-request issue). It must state:

  • Syntax and arguments — the full command grammar, mandatory vs optional args,

ordering, and any subcommands/flags.

  • Response types in RESP2 *and* RESP3 — these can differ (e.g. a reply that is a

flat array in RESP2 but a map or set in RESP3). This directly determines the

CommandOutput you choose and the reactive Mono/Flux mapping.

  • Error scenarios — when the server returns an error, and any client-side

validation the command needs.

  • Version availability — the first server version that supports it (drives

@EnabledOnCommand gating and @since).

Source of truth: the linked HLD/issue (Confluence, Jira, or a GitHub issue) and the

Redis command reference (https://redis.io/commands/<name>). Read them with whatever

tools are available (web fetch, an Atlassian/Jira connector, gh); only ask the

maintainer if you genuinely cannot access the source. Do not guess reply shapes —

confirm them.

The spec is your working context, not a deliverable. Hold it in mind to drive the

work — do not paste it back to the maintainer, write it to a review file, or pause

for "spec approval." Work straight through §2 and §3 on your own judgement; the

only place to stop for the maintainer is the ⛔ checkpoint before generation. If a

design choice is genuinely ambiguous, make a reasonable call and surface it *there*,

with the template, rather than interrupting earlier.

2. Design the Lettuce API from the spec

Decide, from the spec:

  • The method name(s) and overloads. **For every varargs parameter, also declare a

single-argument overload** — e.g. both foo(K key, V value) and

foo(K key, V... values). (Existing commands like hdel/rpush/sadd are

varargs-only; the single-arg overload is the convention for new commands.)

  • The Java return type, mapping the reply to an idiomatic type: a **1/0 integer

reply → Boolean** (established repo convention — cf. copy, expire, hsetnx);

a count/number → Long; a status → String; a bulk value → V. The generator

then wraps it (RedisFuture<T> / Mono<T> / Flux<T>).

  • Which command group it belongs to (STRING → RedisStringCommands, HASH →

RedisHashCommands, generic-key → RedisKeyCommands, …).

  • What argument/wrapper types the signature needs for optional arguments.

3. Add argument types + the template

**Add the types the signature references first — both argument and response

types.** The generated interfaces reference every type in the method signature, so

any *new* type must exist and compile before you generate:

  • Argument types. If the signature takes an options object, create a

*Args implements CompositeArgument class (e.g. io.lettuce.core.CopyArgs) whose

build(args) appends its tokens, and register any argument modifiers in

src/main/java/io/lettuce/core/protocol/CommandKeyword.java.

  • Response / return types. If the command returns a shape with no existing

model, create the value/result type it returns. Reuse existing types where

possible — Value, KeyValue, ScoredValue, GeoCoordinates, GeoWithin,

StreamMessage, KeyScanCursor (all in io.lettuce.core) — and add a new one

only when the reply genuinely doesn't map to any of them.

(This is the Java type that appears in the signature. The matching reply *parser* —

the CommandOutput — is added later with the builder in §5.)

Then add the method + Javadoc to the template

src/main/templates/io/lettuce/core/api/<Group>Commands.java. Declare the sync

return type; this one edit feeds every generated flavor. Follow the

writing-javadoc skill — new public API needs

@since <version> plus @param/@return.

/**
 * Returns the length of the string value stored at {@code key}.
 *
 * @param key the key, must not be {@code null}.
 * @return the length of the string at {@code key}, or {@code 0} when {@code key} does
 *         not exist.
 * @since 7.7
 */
Long strlen(K key);

Do not write the class-level ${intent} placeholder — the generators substitute it.

No suitable group? Create a new one: add the template file under

src/main/templates/…, register its name in

src/test/java/io/lettuce/apigenerator/Constants.java (TEMPLATE_NAMES), and wire

the new group interface into the hand-written aggregate interfaces (RedisCommands,

RedisAsyncCommands, RedisReactiveCommands, and the cluster variants) so they

extend it. Mirror an existing group end-to-end and verify the aggregate wiring.

⛔ Checkpoint — human review before generating

The edited template(s) are the human-authored API contract, and generation fans

them out across every flavor (sync/async/reactive/Kotlin) — after which it's costly to

change. Generation is also the messy, non-idempotent step (see §4). Stop here.

Present the template change — the new method signature and its Javadoc — to the

maintainer and get approval before running the generators. Do not proceed to §4

until the maintainer approves.

4. Run the generators

The generators rewrite every group in Constants.TEMPLATE_NAMES and generation

is not idempotent — run naively and you get noisy diffs across unrelated

interfaces plus mangled imports. Follow the runbook in

.agents/docs/code-generation.md: scope

TEMPLATE_NAMES to your group, run the generators (the runbook has the JDK-8 +

gitcommitid.skip invocation), then — for each regenerated file — **revert it to

HEAD and re-apply only your new method.** The diff carries more than imports

(resurfaced deprecations, signature drift; reactive/Kotlin are especially prone), so a

wholesale regenerate silently changes the public API. Finally revert Constants.

Never commit the Constants change or unrelated regenerated churn.

For an unusual return type (e.g. Flux<Value<Long>>, or Mono<List<Double>>

because Redis returns nulls), register it in RESULT_SPEC / FORCE_FLUX_RESULT /

VALUE_WRAP at the top of CreateReactiveApi.java before generating.

5. Add the implementations

After generation the interfaces declare the new method; now make it real:

Keywordsrc/main/java/io/lettuce/core/protocol/CommandType.java; add the

command name (wire bytes derive from the enum name):

public enum CommandType implements ProtocolKeyword {
    ..., APPEND, GET, GETDEL, ..., STRLEN, LCS, ...
    CommandType() { command = name(); }
}

Subcommand tokens (e.g. PREPARE) go in CommandKeyword, added the same way.

But if the token name already exists in CommandType (e.g. SET, DISCARD), do

not add a duplicate to CommandKeyword — the builder static-imports both enums,

so the bare name becomes ambiguous and won't compile. Reference CommandType.SET /

CommandType.DISCARD directly in the builder instead (same wire bytes).

Buildersrc/main/java/io/lettuce/core/RedisCommandBuilder.java; build the

Command with the CommandOutput chosen from the spec's RESP2/3 reply.

Argument order is the wire order.

public Command<K, V, Long> strlen(K key) {
    notNullKey(key);
    return createCommand(STRLEN, new IntegerOutput<>(codec), key);
}

public Command<K, V, Boolean> copy(K source, K destination, CopyArgs copyArgs) {
    LettuceAssert.notNull(source, "Source " + MUST_NOT_BE_NULL);
    LettuceAssert.notNull(destination, "Destination " + MUST_NOT_BE_NULL);
    CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(source).addKey(destination);
    copyArgs.build(args);
    return createCommand(COPY, new BooleanOutput<>(codec), args);
}

Async dispatchsrc/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java:

public RedisFuture<Long> strlen(K key) { return dispatch(commandBuilder.strlen(key)); }

Reactive dispatchsrc/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java

(createMono for scalars, createDissolvingFlux for List/Set, matching the

generated Mono/Flux):

public Mono<Long> strlen(K key) { return createMono(() -> commandBuilder.strlen(key)); }

Kotlin coroutine impl (hand-written, NOT generated) —

src/main/kotlin/io/lettuce/core/api/coroutines/<Group>CoroutinesCommandsImpl.kt:

override suspend fun strlen(key: K): Long = ops.strlen(key).awaitSingle()

Cluster — a single-key command flows through automatically

(RedisClusterAsyncCommands extends the group interfaces). For a **broadcast /

all-shards** command, hand-code the fan-out override in

RedisAdvancedClusterAsyncCommandsImpl *and* its reactive sibling using

executeOnUpstream + a MultiNodeExecution aggregator (add a helper if no existing

aggregator matches the response policy). See the "Cluster routing" section of

.agents/docs/architecture.md.

Format before you build. After hand-editing (here and after §6), run

mvn formatter:format — the build's formatter:validate step fails the compile on

any unformatted new code, so do it before every compile/test run.

6. Add tests

How tests are named, placed, and run is owned by

.agents/docs/integration-testing.md — follow it.

What is specific to a command:

  • Builder unit test (no server): assert the constructed command and its encoded

args — including the RESP2/3 output shape from §1 — in

src/test/java/io/lettuce/core/Redis<Group>CommandBuilderUnitTests.java.

  • Integration tests: write the base test against the sync RedisCommands API,

then add the overloads (RESP2 / cluster / reactive / Tx) that matter for the

command. Base tests only run under overloads that exist — check the group isn't

missing one its peers have (e.g. the hash group has no RESP2 class) and create it

if so. That base+overload structure is owned by the "Command test scope" section of

.agents/docs/integration-testing.md. Gate

version-specific commands with @EnabledOnCommand("<NAME>"):

@Test
@EnabledOnCommand("COPY")
void copy() {
    redis.set(key, value);
    assertThat(redis.copy(key, key + "2")).isTrue();
}

7. Verify

Run the unit and integration tests as documented in

.agents/docs/integration-testing.md (mvn clean test

for unit; make start + a single *IntegrationTests run for integration).


Top pitfalls

  • Skipping the spec / not checking RESP2 vs RESP3. The reply shape can differ

between protocols; it determines the CommandOutput and reactive mapping. Confirm

it against redis.io, don't assume.

  • Adding Args/wrapper types after generation. The generated interfaces reference

them; if they don't exist first, the project won't compile. Add them upfront.

  • Editing a generated interface instead of the template. Anything with

@generated is overwritten. Edit the template and regenerate. (New group? also

wire it into the hand-written aggregate interfaces.)

  • Forgetting a dispatch layer. Update *both* AbstractRedisAsyncCommands *and*

AbstractRedisReactiveCommands, plus the Kotlin *Impl.kt (not generated).

  • Wrong CommandArgs order / wrong CommandOutput, missing @since in the

template, or missing tests / version gate (@EnabledOnCommand).

How to use it

Copy the folder

Take redis/adding-a-redis-command from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.