Getting Started
Why this exists
A puzzle says "the key is LEMON" and I want the plaintext, not a lecture. An agent gets a ROT-47 string in a challenge and needs to flip it without leaving the process. Each cipher is small, but every one of them has conventions that decide whether your answer matches the author's: does Playfair fold J into I, does Bacon use 24 or 26 letters, which rotors is "Enigma" even. Every online tool picks something different and rarely says what.
So @agntn/ciphers picks once, writes it down, and gives every cipher the same two methods. No HTTP, no keys, no telemetry. It runs in your process and that's it.
Install
pnpm add @agntn/ciphers
First call
import { create } from "@agntn/ciphers";
const vigenere = create("vigenere");
const encoded = vigenere.encode("ATTACK AT DAWN", { key: "LEMON" });
console.log(encoded.text); // LXFOPV EF RNHR
const decoded = vigenere.decode(encoded.text, { key: "LEMON" });
console.log(decoded.text); // ATTACK AT DAWN
Importing the package registers all eighteen ciphers. create(name) gives you one cached instance and wants the exact registry name. resolveCipher("Rail Fence") lowercases, turns spaces into hyphens, and then also wants an exact match. No prefixes, no fuzzy matching, a typo is an UnknownCipherError. Guessing which cipher you meant is how you get the wrong plaintext with a straight face.
The shape
Every encode and decode returns this:
interface CipherResult {
text: string; // the output
cipher: string; // "vigenere"
operation: "encode" | "decode";
options: Record<string, unknown>; // what was actually applied, defaults included
normalizedInput?: string; // optional, for a cipher that reshapes its input; the built-ins leave it out
}
Two options every cipher understands: preserveCase (default true) keeps lowercase letters lowercase, stripNonAlpha (default false) drops everything that is not a letter before the cipher runs. The rest is per cipher - shift, key, rails, a, b, period, positions, rings, plugboard. Each cipher's page lists its own, and so does create(name).info().
What ships
| Cipher | Family | Options | Self-inverse |
|---|---|---|---|
| caesar | shift | shift (1 to 25, default 3) | ✗ |
| rot13 | shift | ✓ | |
| rot47 | shift | ✓ | |
| atbash | reflection | ✓ | |
| vigenere | polyalphabetic | key | ✗ |
| trithemius | polyalphabetic | ✗ | |
| alberti | polyalphabetic | key, period | ✗ |
| affine | multiplicative | a (default 5), b (default 8) | ✗ |
| playfair | digraph | key | ✗ |
| polybius | fractionation | key optional | ✗ |
| morse | fractionation | ✗ | |
| bacon | fractionation | ✗ | |
| tap-code | fractionation | ✗ | |
| adfgvx | fractionation | key optional | ✗ |
| bifid | fractionation | key optional, period (default 5) | ✗ |
| rail-fence | transposition | rails (default 3) | ✗ |
| columnar | transposition | key | ✗ |
| enigma | rotor | positions, rings, plugboard | ✓ |
Errors
import { CipherError, InvalidOptionError, MissingOptionError, UnknownCipherError } from "@agntn/ciphers";
try {
create("caesar").encode("HELLO", { shift: 26 });
} catch (error) {
if (error instanceof InvalidOptionError) {
error.option; // "shift"
error.reason; // why 26 is not a shift
}
}
No key where a key is required - MissingOptionError. Value out of range - InvalidOptionError with the option, the value and the reason. Anything else a cipher throws gets wrapped by normalizeError into a CipherError that names the cipher. The CLI prints the message and exits 1. No stack trace, you did nothing wrong, the input did.
Next
- Encode and decode: options, case, what happens to punctuation, the round trip.
- Analysis: brute force a Caesar, read a histogram.
- CLI: the
cipherscommand. - Agents: five tools over MCP, Pi and OMP.
- Custom ciphers: extend
Cipher, register it. - Playground: every cipher, in the page.