all repos — kitten @ 505da5a220cc5cc2f83c740f82e95c2a26ea5870

rough design doc
vi did:web:vt3e.cat
Sun, 07 Jun 2026 22:07:59 +0100
commit

505da5a220cc5cc2f83c740f82e95c2a26ea5870

parent

8dd7fe69a4ecc1e40c1b108ef50169eaf9ceef11

1 files changed, 144 insertions(+), 0 deletions(-)

jump to
A design.md

@@ -0,0 +1,144 @@

+this file maps out the design of kitten + +my goal with kitten is to write a light abstraction atop discord.js to provide +a simple but powerful sort of interaction routing system. and it's fully type-safe, +too. + +## commands + +we define a command with `kitten.command`, options are typed dynamically so +you get awesome typesafety in the `run` callback. + +```ts +const whois = kitten.command("whois", { + description: "get information about a user", + options: { + user: option.user("the user to get information about", { required: true }), + ephemeral: option.boolean("whether to hide the response"), + }, + async run(interaction, { user, ephemeral }) { + // user: GuildMember/User + // ephemeral: boolean | undefined + }, +}); +``` + +subcommands are similar. + +```ts +const command = kitten.command("command", { + description: "a command with subcommands", +}); + +command.subcommand("sub1", { + description: "the first subcommand", + options: { + user: option.user("a user option", { required: true }), + }, + async run(interaction, { user }) { + // user: GuildMember/User + }, +}); +``` + +kitten will handle registering commands and running commands when they're invoked. + +## component routing + +buttons, modals, and select menus work similarly. + +```ts +const banButton = kitten.button("close-ticket", { + options: { + ticketId: option.string(), + }, + async run(interaction, { ticketId }) { + // ticketId: string | undefined + }, +}); + +const button = new ButtonBuilder().setCustomId( + banButton.id({ ticketId: "1234" }), +); +``` + +this would serialise to `close-ticket:1234` + +## autocomplete + +you can deifne autocomplete handlers inside of optino definitions. + +```ts +const repoSearch = kitten.command("repo", { + description: "search a github repository", + options: { + name: option.string("the repository name", { + required: true, + async autocomplete(interaction, value) { + const matches = await searchRepos(value); + return matches.map((r) => ({ name: r.name, value: r.id })); + }, + }), + }, + async run(interaction, { name }) {}, +}); +``` + +## middleware + +```ts +const base = kitten.builder(); + +const authed = base + .use(async (interaction) => { + const dbUser = await db.getUser(interaction.user.id); + + if (!dbUser) { + await interaction.reply({ content: "no account found", ephemeral: true }); + throw new HaltExecution(); + } + + return { dbUser }; + }) + .use(async (interaction, ctx) => { + // ctx.dbUser: DbUser + const isPremium = ctx.dbUser.subscription === "premium"; + return { isPremium }; + }); + +const premiumStats = authed.command("meow", { + description: "meow", + async run(interaction, args, ctx) { + // ctx.dbUser is typed + // ctx.isPremium is typed + }, +}); +``` + +## usage + +```ts +import { Client, GatewayIntentBits } from "discord.js"; +import { Kitten } from "kitten"; + +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); +const kitten = new Kitten(client); + +import * as commands from "./commands"; +import * as buttons from "./components"; +kitten.register({ commands, components }); + +client.once("ready", async () => { + const isDev = process.env.NODE_ENV === "development"; + + try { + await kitten.sync({ + guildId: isDev ? process.env.GUILD_ID : undefined, + }); + } catch (error) { + console.error("failed to sync commands:", error); + } +}); + +await client.login(process.env.BOT_TOKEN); +```