Guide
Custom ciphers
Extend the abstract Cipher, register the class, and it shows up in create, the CLI and the tools.
One class, four methods
Every built-in is a concrete class extending the exported abstract Cipher. Yours is the same shape, one file:
reverse.ts
import {
Cipher,
InvalidOptionError,
getOpt,
register,
type CipherBaseOptions,
type CipherInfo,
type CipherResult,
} from "@agntn/ciphers";
class Reverse extends Cipher {
name(): string {
return "reverse";
}
info(): CipherInfo {
return {
name: "reverse",
label: "Reverse",
description: "Text backwards, optionally in blocks",
family: "transposition",
selfInverse: true,
options: [
{ name: "block", type: "number", required: false, default: 0, description: "Block length, 0 for the whole text" },
],
keyspace: "n (block lengths)",
};
}
encode(text: string, options: Readonly<CipherBaseOptions> = {}): CipherResult {
const block = getOpt<number>(options, "block", 0);
if (!Number.isInteger(block) || block < 0) {
throw new InvalidOptionError("block", block, "must be a non-negative integer");
}
const size = block === 0 ? text.length : block;
let out = "";
for (let i = 0; i < text.length; i += size) {
out += [...text.slice(i, i + size)].reverse().join("");
}
return { text: out, cipher: "reverse", operation: "encode", options: { block } };
}
decode(text: string, options: Readonly<CipherBaseOptions> = {}): CipherResult {
return { ...this.encode(text, options), operation: "decode" };
}
}
register("reverse", Reverse);
import "./reverse";
import { create } from "@agntn/ciphers";
create("reverse").encode("ATTACK AT DAWN", { block: 6 }).text; // "KCATTAAD TA NW"
The contract
name()is the registry name,info()describes it. Put every option intoinfo().optionswith a type, required or not, a default and a description.ciphers info,cipher_infoand the Playground form read that list, so an option you skip there is invisible to all three.MissingOptionErrorfor a required option that is absent,InvalidOptionErrorfor one that is wrong, both before you touch the text. Anything else - wrap it withnormalizeError(error, "reverse")so callers get aCipherErrorthat names the cipher.- Honour
preserveCaseandstripNonAlphawhen they make sense for your alphabet. Every built-in defaults them the same way,trueandfalse. - Fill
normalizedInputwhen you had to reshape the input, so a caller sees why the round trip differs. register(name, Class)takes a constructor, not an instance.createbuilds one and caches it. Register the same name again and the cached instance is dropped, so a hot reload actually takes.
Where it shows up
Once the module is imported, the cipher is in ciphers(), has() says true, resolveCipher finds it. The CLI and the MCP server only load the built-ins. To use a custom cipher there you fork the binary or build your own around create. There is no plugin path.
Conventions worth copying
The built-ins agree on a few things. A custom cipher that follows them surprises nobody:
- Latin alphabets are A to Z. Non-ASCII letters, digits and punctuation pass through untouched unless
stripNonAlphais set. - The
optionsechoed in the result are the values actually applied, defaults included. - Shift 0, one rail, a multiplier sharing a factor with 26 - an option that turns the cipher into an identity or breaks the bijection is an error, not a silent pass.