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 into info().options with a type, required or not, a default and a description. ciphers info, cipher_info and the Playground form read that list, so an option you skip there is invisible to all three.
  • MissingOptionError for a required option that is absent, InvalidOptionError for one that is wrong, both before you touch the text. Anything else - wrap it with normalizeError(error, "reverse") so callers get a CipherError that names the cipher.
  • Honour preserveCase and stripNonAlpha when they make sense for your alphabet. Every built-in defaults them the same way, true and false.
  • Fill normalizedInput when you had to reshape the input, so a caller sees why the round trip differs.
  • register(name, Class) takes a constructor, not an instance. create builds 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 stripNonAlpha is set.
  • The options echoed 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.

@agntn/ciphers·MIT license· Classical ciphers, for lessons and puzzles. Not for protecting anything, ever.