all repos — kitten @ 882e1f457e62406c095407c6cc8410f5764f8627

getting started guide
vi did:web:vt3e.cat
Fri, 12 Jun 2026 20:55:54 +0100
commit

882e1f457e62406c095407c6cc8410f5764f8627

parent

84532fc0726e827829bfb3b608a7194c272dcdd1

3 files changed, 232 insertions(+), 142 deletions(-)

jump to
D DESIGN.md

@@ -1,142 +0,0 @@

-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); -```
A GET-STARTED.md

@@ -0,0 +1,230 @@

+hi! welcome to kitten, this guide will help you get started. + +## table of contents + +- [bootstrapping](#bootstrapping) +- [defining your first command](#defining-your-first-command) +- [adding subcommands](#adding-subcommands) +- [interactive components](#interactive-components) +- [adding autocomplete](#adding-autocomplete) +- [using middleware](#using-middleware) +- [registering our commands and components](#registering-our-commands-and-components) + +## bootstrapping + +to get started, we need to initialise a new discord.js Client as you normally +would, and then pass it to a new Kitten instance. + +```ts +import { Client, GatewayIntentBits } from "discord.js"; +import { Kitten } from "kitten"; + +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); +const kitten = new Kitten(client); +``` + +next, we'll attach an event listener to the client's `clientReady` event, and +call `kitten.sync()` to register our commands with the Discord API, we'll also +log the client in. + +```ts +client.once("clientReady", async () => { + // in development, it's recommened to sync commands to a single guild for + // faster updates + const isDev = process.env.NODE_ENV === "development"; + + await kitten.sync({ + guildId: isDev ? process.env.GUILD_ID : undefined, + }); +}); + +await client.login(process.env.DISCORD_TOKEN); +``` + +and we're ready to start building out some commands and components! + +## defining your first command + +commands are defined using `kitten.command`, options are defined dynamically +which gives you type-safe parameters directly inside of your command's `run` +callback, so you can do away with `interaction.options.getString("param")`. + +```ts +const whoisCommand = 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 the response should be ephemeral", { required: false }), + }, + async run(interaction, { user, ephemeral }) { + await interaction.reply({ + content: `User ID: ${user.id}`, + ephemeral: ephemeral ?? false, + }); + }, +}); +``` + +if you view the type definitions for `user` and `ephemeral`, you'll notice that +they're both appropriately typed. + +## adding subcommands + +if your command has multiple sub-actions, you can chain `.subcommand` definitions. + +```ts +const configCommand = kitten.command("config", { + description: "configure bot settings", +}); + +configCommand.subcommand("set", { + description: "Set a configuration value", + options: { + key: option.string("the setting key", { required: true }), + value: option.string("the value", { required: true }), + }, + async run(interaction, { key, value }) { + await interaction.reply(`set ${key} to ${value}`); + }, +}); +``` + +## interactive components + +kitten simplifies routing and parsing custom IDs for buttons, select menus, and +modals. when you define a component, you define the schema of the payload you +want to store in its `customId`. + +```ts +const closeTicketButton = kitten.button("close-ticket", { + options: { + ticketId: option.string(), + }, + async run(interaction, { ticketId }) { + // ticketId: strign | undefined + await interaction.reply(`closing ticket: ${ticketId}`); + }, +}); + +const button = new ButtonBuilder() + .setLabel("close ticket") + .setCustomId(closeTicketButton.id({ ticketId: "67" })); // serialises to `close-ticket:67` +``` + +kitten will automatically match incoming interactions to the `close-ticket` +handler, and parse the `ticketId` parameter for you. just like with commands. + +modals and select menus work similarly. + +```ts +const modal = new ModalBuilder() // + .setCustomId(myModal.id({ foo: "bar" })); // serialises to `my-modal:bar` + +const selectMenu = new StringSelectMenuBuilder() // + .setCustomId(mySelectMenu.id({ foo: "baz" })); // serialises to `my-select-menu:baz` +``` + +note! custom IDs must be 100 characters or less, so be mindful of how much data +you store in them. + +kitten will throw a `CustomIdTooLongError` if you exceed this limit. + +## adding autocomplete + +you can define autocomplete logic directly within your option definitions. +kitten handles routing the autocomplete interaction and running your helper. + +let's go back to our `config set` subcommand and add an autocomplete helper to +the `key` option. + +```ts +configCommand.subcommand("set", { + description: "Set a configuration value", + options: { + key: option.string("the option key", { + required: true, + async autocomplete(interaction, value) { + // `value` is the current string the user has typed + const keys = ["prefix", "welcomeMessage", "modLogChannel"]; + const matches = keys.filter((key) => key.startsWith(value)).slice(0, 25); + + // and we return an array of objects with `name` and `value` properties + // with a max of 25 results. + return matches.map((key) => ({ name: key, value: key })); + }, + }), + + value: option.string("the value", { required: true }), + }, + async run(interaction, { key, value }) { + await interaction.reply(`set ${key} to ${value}`); + }, +}); +``` + +## using middleware + +when building larger bots, you often need to fetch database records, check +permissions, or rate limit commands. + +kitten's middleware builder lets you chain pre-execution checks and pass their +results down as typed context. + +```ts +const base = kitten.builder(); + +const authorised = base + .use(async (interaction) => { + const user = await db.getUser(interaction.user.id); + + if (!user) { + await interaction.reply({ content: "no account found.", ephemeral: true }); + throw new HaltExecution(); + } + + return { user }; + }) + .use(async (interaction, context) => { + if (!context.user.moderator) { + await interaction.reply({ + content: "you are not authorised to use this command.", + ephemeral: true, + }); + + throw new HaltExecution(); + } + + return {}; + }); +``` + +```ts +const banCommand = authorised.command("ban", { + description: "ban a user", + options: { + user: option.user("the user to ban", { required: true }), + }, + async run(interaction, args, context) { + // context: { user: { id: string; moderator: boolean; ... } } + await banUser(args.user.id, context.user.id); + await interaction.reply(`banned ${args.user.username} (${args.user.id})`); + }, +}); +``` + +## registering our commands and components + +and we're almost there! the final step is to register our commands and +components with kitten. + +you should register all of your commands and components before calling +`kitten.sync()`, otherwise they won't be registered with the Discord API. + +```ts +kitten.register({ + commands: [whoisCommand, configCommand, banCommand], + buttons: [closeTicketButton], +}); +``` + +## all done!
M README.mdREADME.md

@@ -2,6 +2,8 @@ # kitten

a meowing, typesafe interaction router for discord.js +see [GET-STARTED.md](./GET-STARTED.md) for a quick introduction to using kitten + ## copying this project is licensed under the european union public license (eupl) v1.2.