spearkit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt ADDED
@@ -0,0 +1,4619 @@
1
+ # spearkit v0.4.0 — full documentation for LLMs
2
+
3
+ > discord.js++ — a developer-experience-first Discord library. Drop-in compatible with discord.js, with ergonomic events, slash commands, and interactive components.
4
+
5
+ This file concatenates every spearkit guide and the complete API reference. It is generated from docs/*.md (the single source of truth). Install: `npm install spearkit discord.js`. Import every symbol — spearkit additions and the entire re-exported discord.js surface — from `spearkit`.
6
+
7
+ ---
8
+
9
+ ## Use cases — reach for
10
+
11
+ **Bot setup & lifecycle**
12
+
13
+ | Want to… | Reach for |
14
+ | --- | --- |
15
+ | Start a bot and connect | `new SpearClient({ intents })` + `await client.start(token)` |
16
+ | Choose gateway intents | `Intents.none / default / guilds / messages / all` |
17
+ | Wire up handlers | `client.register(...)`; one file per handler → `client.load(dir)` |
18
+ | Push commands to Discord | `client.deployCommands({ guildId })`; slash + context menus, safe CI → `client.deployAllCommands({ strategy: "diff", dryRun })` |
19
+ | Package a reusable feature | `definePlugin(...)` + `client.use(...)` |
20
+ | Migrate an existing discord.js bot | import from `"spearkit"`, swap `Client` → `SpearClient` |
21
+
22
+ **Commands & input**
23
+
24
+ | Want to… | Reach for |
25
+ | --- | --- |
26
+ | A slash command | `command({ name, description, run })` |
27
+ | Typed inputs to a command | `options: { x: option.string/integer/number/boolean/user/channel/role/mentionable/attachment(...) }` |
28
+ | Group many commands under one name | `commandGroup` + `subcommand` / `subcommandGroup` |
29
+ | Suggest values while the user types | `option.string({ autocomplete })` |
30
+ | A right-click "Apps" action on a user/message | `userCommand` / `messageCommand` |
31
+ | A classic `!text` command | `prefixCommand(...)` + `new SpearClient({ prefix })` |
32
+ | Parse `!cmd` arguments into typed values | `args: (a) => a.snowflake().duration().rest()` → `ctx.options` |
33
+ | Avoid `Unknown interaction` (10062) on slow work | `command({ autoDefer: true })` / `new SpearClient({ autoDefer: true })` |
34
+
35
+ **Interactivity (components)**
36
+
37
+ | Want to… | Reach for |
38
+ | --- | --- |
39
+ | A clickable button | `button({ id, run })` → `row(btn.build(...))` |
40
+ | A URL button (no handler) | `linkButton` |
41
+ | A dropdown of fixed options | `stringSelect` |
42
+ | Pick users / roles / channels / mentionables | `userSelect` / `roleSelect` / `channelSelect` / `mentionableSelect` |
43
+ | A form with text fields | `modal` + `textInput` |
44
+ | Carry data through a component | custom-id params `id: "x:{id}"` → `ctx.params.id` |
45
+ | A paged list with next/prev | `paginate(...)` |
46
+ | An "Are you sure?" yes/no gate | `confirm(...)` |
47
+
48
+ **Replies & UX**
49
+
50
+ | Want to… | Reach for |
51
+ | --- | --- |
52
+ | Reply, public or hidden | `ctx.reply(...)` / `ctx.replyEphemeral(...)` |
53
+ | Work that takes >3s | `ctx.defer()` then `ctx.editReply(...)` |
54
+ | A styled success/error/info/warn embed | `ctx.success/error/info/warn(...)` |
55
+ | "Reply, edit, or follow-up — whichever fits" | `ctx.send(...)` |
56
+
57
+ **Cross-cutting concerns**
58
+
59
+ | Want to… | Reach for |
60
+ | --- | --- |
61
+ | React to gateway events | `event(name, run)`; once on startup → `event("clientReady", ...)` |
62
+ | Rate-limit a command/handler | `cooldown` (per-command or client-wide) |
63
+ | Restrict by role / permission / owner / guild | guards: `requireAnyRole` / `requireUserPermissions` / `requireOwner` / `guildOnly` |
64
+ | Run jobs on cron or interval | `task({ cron \| interval })` / `client.schedule(...)` |
65
+ | Delay once / staged follow-ups / recover on restart | `client.scheduler.delay` / `followUp` / `reconcile` |
66
+ | Structured logs to file/webhook | `client.logger` + `consoleSink` / `jsonlSink` / `webhookSink` |
67
+ | Track who used what | `new SpearClient({ usage })` + `MemoryUsageStore` / `JsonFileUsageStore` |
68
+ | Read typed env / load `.env` | `env.string/number/boolean/require` (auto-loaded on `start()`) |
69
+ | Shut down cleanly on SIGINT/SIGTERM | `client.enableGracefulShutdown({ onShutdown })` |
70
+ | Permission / role-hierarchy preflight | `moderationCheck(...)`, `missingPermissions(...)`, `canActOn(...)`, `ctx.botMissing(...)` |
71
+ | Wait for a reply / click / modal submission | `ctx.awaitMessageFrom(...)` / `ctx.awaitModal(...)` / `awaitComponent(...)` |
72
+ | Branch on a Discord API error | `isDiscordError(err, DiscordErrorCode.X)` / `explainDiscordError(err)` |
73
+ | Per-guild prefix from a store | `prefix: { dynamic: (message) => ... }` |
74
+
75
+ **Utilities (primitives)**
76
+
77
+ | Want to… | Reach for |
78
+ | --- | --- |
79
+ | Stop concurrent runs per key (e.g. per user) | `KeyedLock` |
80
+ | Fetch that returns `null` instead of throwing | `safeFetch.{member,channel,message,user,guild,role}` |
81
+ | Format/parse `"1h30m"` durations | `formatDuration` / `parseDuration` |
82
+ | Render `<t:…>` Discord timestamps | `discordTimestamp` / `relativeTimestamp` |
83
+ | In-memory cache / counters / rate-limit window | `MemoryCache` |
84
+ | Load JSON/JSON5/YAML config | `loadConfig` |
85
+ | Persist key-value data / per-guild settings | `MemoryStore` / `JsonStore` + `createSettings({ store, defaults })` |
86
+ | Split text to Discord's 2000-char limit | `chunkMessage(text)` / `truncate(text, max)` |
87
+
88
+ ---
89
+
90
+ # spearkit documentation
91
+
92
+ **discord.js++** — a developer-experience-first layer over discord.js. spearkit
93
+ re-exports all of discord.js (so it's a drop-in replacement) and adds an
94
+ ergonomic, fully type-safe API for events, slash commands and interactive
95
+ components.
96
+
97
+ ## Contents
98
+
99
+ 1. [Getting started](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/getting-started.md) — install, first bot, project layout.
100
+ 2. [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — `SpearClient`, intents, `register`, `start`, deployment.
101
+ 3. [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — slash commands, subcommands, permissions, deployment.
102
+ 4. [Options](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/options.md) — typed option builders, choices, autocomplete.
103
+ 5. [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — buttons, selects, modals, custom-id routing.
104
+ 6. [Context menus](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context-menus.md) — user and message "Apps" commands.
105
+ 7. [Events](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/events.md) — the `event()` helper and the event registry.
106
+ 8. [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md) — reply helpers shared by every handler.
107
+ 9. [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md) — role/permission/owner/guild preconditions.
108
+ 10. [Auto-defer](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/auto-defer.md) — beat the 3-second `Unknown interaction` error.
109
+ 11. [Permissions & hierarchy](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/permissions.md) — moderation preflight checks.
110
+ 12. [Discord API errors](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/errors.md) — recognise and recover from `DiscordAPIError`.
111
+ 13. [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — per-user/role/guild rate limiting.
112
+ 14. [Scheduled tasks](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/scheduler.md) — cron and interval jobs.
113
+ 15. [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md) — classic `!text` commands.
114
+ 16. [Collectors](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/collectors.md) — await messages, modals and component clicks.
115
+ 17. [Key-value store & settings](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/store.md) — persist per-guild config + dynamic prefix.
116
+ 18. [Messages & limits](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/messages.md) — split long output, truncate text.
117
+ 19. [Logging](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md) — structured, leveled, scoped logging.
118
+ 20. [Usage tracking](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/usage.md) — record who used what (store + Discord channel).
119
+ 21. [Environment & dotenv](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/env.md) — load `.env` and read typed env vars.
120
+ 22. [Graceful shutdown](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/shutdown.md) — close cleanly on `SIGINT`/`SIGTERM`.
121
+ 23. [Plugins](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/plugins.md) — bundling features into reusable units.
122
+ 24. [File-based loading](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/loading.md) — one file per command/event/component.
123
+ 25. [Migrating from discord.js](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/migration.md) — the drop-in path.
124
+ 26. [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md) — every exported symbol.
125
+
126
+ ## Why spearkit
127
+
128
+ - **Drop-in.** `import { Client, EmbedBuilder } from "spearkit"` — every discord.js
129
+ export is available, so you can migrate one file at a time.
130
+ - **Fully type-safe.** No `any` or `unknown` leaks into your handlers. Option
131
+ values, custom-id params and modal fields are all inferred from your
132
+ definitions.
133
+ - **Co-located.** A command's options and handler, a button's appearance and
134
+ click logic, a modal's fields and submit logic — each lives in one place.
135
+ - **No boilerplate.** No `interactionCreate` switch statements; spearkit routes
136
+ commands, autocomplete, buttons, selects and modals for you.
137
+
138
+ ## Thirty-second tour
139
+
140
+ ```ts
141
+ import { SpearClient, Intents, command, option, button, row, event } from "spearkit";
142
+
143
+ const client = new SpearClient({ intents: Intents.default });
144
+
145
+ const greet = command({
146
+ name: "greet",
147
+ description: "Greet someone",
148
+ options: { who: option.user({ description: "Who", required: true }) },
149
+ run: (ctx) => ctx.reply(`Hello ${ctx.options.who}!`), // who: User
150
+ });
151
+
152
+ const ping = button({
153
+ id: "ping:{n}",
154
+ label: "Ping",
155
+ run: (ctx) => ctx.update(`pong #${ctx.params.n}`), // n: string
156
+ });
157
+
158
+ client.register(greet, ping, event("clientReady", (c) => console.log(c.user.tag)));
159
+ await client.start(process.env.DISCORD_TOKEN);
160
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
161
+ ```
162
+
163
+ ---
164
+
165
+ # Getting started
166
+
167
+ spearkit is **discord.js++**: it re-exports the entire discord.js surface and adds a
168
+ fully type-safe layer for events, slash commands and interactive components. This
169
+ page takes you from an empty folder to a running bot that responds to a slash
170
+ command.
171
+
172
+ ## Install
173
+
174
+ spearkit sits alongside discord.js, so install both:
175
+
176
+ ```bash
177
+ npm install spearkit discord.js
178
+ ```
179
+
180
+ Everything in your code imports from `"spearkit"` — including the plain discord.js
181
+ symbols, which spearkit re-exports unchanged.
182
+
183
+ ## Credentials you need
184
+
185
+ Create an application in the [Discord Developer Portal](https://discord.com/developers/applications)
186
+ and collect three values:
187
+
188
+ | Value | Where to find it | Used for |
189
+ | ----- | ---------------- | -------- |
190
+ | Bot token | Application → **Bot** → *Reset Token* | `client.start(token)` |
191
+ | Application id | Application → **General Information** → *Application ID* | command deployment (spearkit reads it from the client once ready) |
192
+ | Test guild id | Right-click your server in Discord (with Developer Mode on) → *Copy Server ID* | guild-scoped deploy |
193
+
194
+ Keep the token secret. The examples below read these from the environment
195
+ (`DISCORD_TOKEN`, `GUILD_ID`).
196
+
197
+ ## Your first bot
198
+
199
+ ```ts
200
+ import { SpearClient, Intents, command, option, event } from "spearkit";
201
+
202
+ const client = new SpearClient({ intents: Intents.default });
203
+
204
+ const greet = command({
205
+ name: "greet",
206
+ description: "Greet someone",
207
+ options: {
208
+ who: option.user({ description: "Who to greet", required: true }),
209
+ },
210
+ run: (ctx) => ctx.reply(`Hello ${ctx.options.who}!`), // ctx.options.who: User
211
+ });
212
+
213
+ const ready = event("clientReady", (c) => console.log(`Online as ${c.user.tag}`));
214
+
215
+ client.register(greet, ready);
216
+
217
+ await client.start(process.env.DISCORD_TOKEN);
218
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
219
+ ```
220
+
221
+ What each step does:
222
+
223
+ 1. **`new SpearClient({ intents })`** — a discord.js `Client` with command, event
224
+ and component routing wired up. `Intents.default` is `[Guilds]`, enough for
225
+ slash commands and interactions.
226
+ 2. **`command({ ... })`** — defines a leaf slash command. Required options resolve
227
+ to their value type (`who` is a `User`); optional options would resolve to
228
+ `value | undefined`.
229
+ 3. **`client.register(...)`** — routes each item to the matching registry
230
+ (commands, events, components) by its kind.
231
+ 4. **`client.start(token)`** — logs in. With no argument it falls back to the
232
+ `DISCORD_TOKEN` environment variable.
233
+ 5. **`client.deployCommands({ guildId })`** — pushes your command definitions to
234
+ Discord over the client's own authenticated REST connection. Must run after the
235
+ client is ready (i.e. after `start`).
236
+
237
+ ### Guild vs global deploy
238
+
239
+ `deployCommands` takes an optional `guildId`:
240
+
241
+ - **Guild deploy** (`{ guildId }`) registers commands in a single server. Changes
242
+ appear **instantly** — ideal while developing.
243
+ - **Global deploy** (omit `guildId`) registers commands across every server the
244
+ bot is in. Propagation can take up to an hour.
245
+
246
+ ```ts
247
+ await client.deployCommands({ guildId: process.env.GUILD_ID }); // instant, one guild
248
+ await client.deployCommands(); // global, slow to propagate
249
+ ```
250
+
251
+ You only need to deploy when your command *definitions* change (names,
252
+ descriptions, options) — not on every restart.
253
+
254
+ ## Suggested project layout
255
+
256
+ As a bot grows, give each command, event and component its own file:
257
+
258
+ ```
259
+ my-bot/
260
+ src/
261
+ index.ts # construct the client, register/load, start, deploy
262
+ commands/
263
+ greet.ts
264
+ ping.ts
265
+ events/
266
+ ready.ts
267
+ components/
268
+ vote.ts
269
+ package.json
270
+ tsconfig.json
271
+ ```
272
+
273
+ A module exports a command, event or component as a default or named export:
274
+
275
+ ```ts
276
+ // src/commands/ping.ts
277
+ import { command } from "spearkit";
278
+
279
+ export default command({
280
+ name: "ping",
281
+ description: "Check that the bot is alive",
282
+ run: (ctx) => ctx.reply(`Pong! ${ctx.client.ws.ping}ms`),
283
+ });
284
+ ```
285
+
286
+ You can wire the pieces up explicitly with `register`, or let spearkit discover them
287
+ with `client.load` (see [File-based loading](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/loading.md)).
288
+
289
+ ## Running it
290
+
291
+ **With tsx** (run TypeScript directly, great for development):
292
+
293
+ ```bash
294
+ npx tsx src/index.ts
295
+ ```
296
+
297
+ **Compiled JavaScript** (for production):
298
+
299
+ ```bash
300
+ npx tsc # emit JS into dist/ per your tsconfig
301
+ node dist/index.js
302
+ ```
303
+
304
+ Note that `client.load` imports **compiled JavaScript**, so if you use file-based
305
+ loading you must build before running the compiled output. Explicit `register`
306
+ calls work the same under `tsx` or `node`.
307
+
308
+ ## See also
309
+
310
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — `SpearClient`, intents, `register`, `start`, deployment.
311
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — slash commands, subcommands, options, deployment.
312
+
313
+ ---
314
+
315
+ # Migrating from discord.js
316
+
317
+ spearkit re-exports the entire discord.js surface, so adopting it is not a rewrite —
318
+ it is a one-line import change followed by *optional*, incremental cleanup. You can
319
+ move to spearkit today and start using its ergonomic helpers whenever you like.
320
+
321
+ ## The drop-in story
322
+
323
+ Change `from "discord.js"` to `from "spearkit"`. Nothing else has to change: every
324
+ discord.js export is available under the same name with the same types.
325
+
326
+ ```ts
327
+ // before
328
+ import { Client, EmbedBuilder, GatewayIntentBits } from "discord.js";
329
+
330
+ // after — identical behaviour
331
+ import { Client, EmbedBuilder, GatewayIntentBits } from "spearkit";
332
+ ```
333
+
334
+ The full classic surface is there — builders, enums, the REST client, route
335
+ helpers, the `Events` map, and so on:
336
+
337
+ ```ts
338
+ import {
339
+ ActionRowBuilder,
340
+ ButtonBuilder,
341
+ ButtonStyle,
342
+ Client,
343
+ EmbedBuilder,
344
+ Events,
345
+ GatewayIntentBits,
346
+ REST,
347
+ Routes,
348
+ SlashCommandBuilder,
349
+ } from "spearkit";
350
+
351
+ const client = new Client({ intents: [GatewayIntentBits.Guilds] });
352
+
353
+ const pingCommand = new SlashCommandBuilder()
354
+ .setName("ping")
355
+ .setDescription("Replies with an embed and a button");
356
+
357
+ client.once(Events.ClientReady, (c) => {
358
+ console.log(`Ready as ${c.user.tag}`);
359
+ });
360
+
361
+ client.on(Events.InteractionCreate, async (interaction) => {
362
+ if (!interaction.isChatInputCommand()) return;
363
+ if (interaction.commandName !== "ping") return;
364
+
365
+ const embed = new EmbedBuilder().setTitle("Pong!").setDescription(`Latency: ${client.ws.ping}ms`);
366
+ const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
367
+ new ButtonBuilder().setCustomId("again").setLabel("Again").setStyle(ButtonStyle.Primary),
368
+ );
369
+ await interaction.reply({ embeds: [embed], components: [buttons] });
370
+ });
371
+
372
+ async function deploy(token: string, appId: string): Promise<void> {
373
+ const rest = new REST().setToken(token);
374
+ await rest.put(Routes.applicationCommands(appId), { body: [pingCommand.toJSON()] });
375
+ }
376
+ ```
377
+
378
+ This file is 100% classic discord.js — only the import source changed. It keeps
379
+ working exactly as before.
380
+
381
+ ## Incremental adoption
382
+
383
+ Once your imports point at spearkit, you can convert pieces one at a time. There is no
384
+ big-bang migration; old and new styles coexist.
385
+
386
+ 1. **Swap the client.** Replace `new Client(...)` with `new SpearClient(...)`. It
387
+ *is* a discord.js `Client` (it extends it), so your existing `client.on`,
388
+ `client.once`, `client.ws`, `client.rest` code is unchanged — but now it also
389
+ routes interactions to spearkit's registries.
390
+
391
+ ```ts
392
+ import { SpearClient, Intents } from "spearkit";
393
+
394
+ const client = new SpearClient({ intents: Intents.default });
395
+ ```
396
+
397
+ 2. **Move commands to `command()`.** Replace a hand-written `SlashCommandBuilder`
398
+ plus its branch of the `interactionCreate` switch with a single co-located
399
+ definition. Option values become fully typed.
400
+ 3. **Move events to `event()`.** Replace `client.on(Events.X, ...)` listeners with
401
+ `event("x", ...)` definitions and register them.
402
+ 4. **Move components to spearkit builders.** Replace manual `ButtonBuilder` +
403
+ custom-id parsing with `button()`, `stringSelect()`, `modal()`, etc. — spearkit
404
+ routes them by custom-id namespace and decodes `{param}`s for you.
405
+
406
+ Convert at whatever pace suits you; un-migrated handlers keep running through your
407
+ existing `interactionCreate` listener.
408
+
409
+ ## Before and after
410
+
411
+ The classic approach hand-routes every interaction through one big switch and
412
+ parses custom ids by hand:
413
+
414
+ ```ts
415
+ // discord.js: one listener routes everything by hand
416
+ import { Client, Events, GatewayIntentBits } from "discord.js";
417
+
418
+ const client = new Client({ intents: [GatewayIntentBits.Guilds] });
419
+
420
+ client.on(Events.InteractionCreate, async (interaction) => {
421
+ if (interaction.isChatInputCommand()) {
422
+ if (interaction.commandName === "greet") {
423
+ const who = interaction.options.getUser("who", true);
424
+ await interaction.reply(`Hello ${who}!`);
425
+ }
426
+ } else if (interaction.isButton()) {
427
+ const [name, choice] = interaction.customId.split(":"); // manual parsing
428
+ if (name === "vote") {
429
+ await interaction.update({ content: `You chose ${choice}` });
430
+ }
431
+ }
432
+ });
433
+ ```
434
+
435
+ spearkit co-locates each command and component with its handler, and routes
436
+ interactions for you — no switch, no manual id parsing:
437
+
438
+ ```ts
439
+ // spearkit: each handler owns its definition; routing is automatic
440
+ import { SpearClient, Intents, command, option, button, row } from "spearkit";
441
+
442
+ const client = new SpearClient({ intents: Intents.default });
443
+
444
+ const greet = command({
445
+ name: "greet",
446
+ description: "Greet someone",
447
+ options: { who: option.user({ description: "Who to greet", required: true }) },
448
+ run: (ctx) => ctx.reply(`Hello ${ctx.options.who}!`), // who: User
449
+ });
450
+
451
+ const vote = button({
452
+ id: "vote:{choice}", // {choice} is a typed param
453
+ label: "Yes",
454
+ style: "Success",
455
+ run: (ctx) => ctx.update(`You chose ${ctx.params.choice}`), // choice: string
456
+ });
457
+
458
+ client.register(greet, vote);
459
+ await client.start(process.env.DISCORD_TOKEN);
460
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
461
+
462
+ // build() requires exactly the params the id pattern declares:
463
+ await channel.send({ content: "Vote:", components: [row(vote.build({ choice: "yes" }))] });
464
+ ```
465
+
466
+ The option value (`who`) and the custom-id param (`choice`) are inferred from the
467
+ definitions — no casts, no `getUser`/`split` boilerplate, and no `interactionCreate`
468
+ switch to maintain.
469
+
470
+ ## See also
471
+
472
+ - [Getting started](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/getting-started.md) — install spearkit and build a first bot.
473
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — define slash commands with typed options.
474
+ - [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — buttons, selects, modals and custom-id routing.
475
+
476
+ ---
477
+
478
+ # Client
479
+
480
+ `SpearClient` is a discord.js `Client` with command, event and component
481
+ registries — plus interaction routing — wired up for you. You construct it the
482
+ same way you construct a discord.js client, register your handlers, log in, and
483
+ (optionally) push your slash commands to Discord.
484
+
485
+ ```ts
486
+ import { SpearClient, Intents } from "spearkit";
487
+
488
+ const client = new SpearClient({ intents: Intents.default });
489
+ ```
490
+
491
+ ## Constructing a client
492
+
493
+ `new SpearClient(options?)` takes the same options as discord.js'
494
+ `ClientOptions`, except `intents` may be omitted: it defaults to
495
+ `Intents.default` (just the `Guilds` intent, enough for slash commands and
496
+ interactions).
497
+
498
+ ```ts
499
+ import { SpearClient, Intents } from "spearkit";
500
+
501
+ // Explicit preset.
502
+ const a = new SpearClient({ intents: Intents.messages });
503
+
504
+ // Omitted — falls back to Intents.default.
505
+ const b = new SpearClient();
506
+ ```
507
+
508
+ The options type is exported as `SpearClientOptions` — `Partial<ClientOptions> &
509
+ SpearOptions`. Every discord.js option (`partials`, `presence`, `sweepers`, …) is
510
+ available, plus spearkit's own: `logger`, `dotenv`, `cooldown`, `prefix`, `usage`,
511
+ `embeds`, `guards` and `autoDefer` (each covered in its own guide).
512
+
513
+ ### Intents presets
514
+
515
+ `Intents` is a set of ready-made arrays of `GatewayIntentBits`. Pass one as
516
+ `intents`, or compose your own array of `GatewayIntentBits` if you need
517
+ something in between.
518
+
519
+ | Preset | Contents |
520
+ | ------ | -------- |
521
+ | `Intents.none` | `[]` |
522
+ | `Intents.default` | `[Guilds]` |
523
+ | `Intents.guilds` | `[Guilds, GuildMembers]` |
524
+ | `Intents.messages` | `[Guilds, GuildMessages, MessageContent]` |
525
+ | `Intents.all` | Every intent, including privileged ones. |
526
+
527
+ `Intents.messages` includes `MessageContent`, and `Intents.guilds` includes
528
+ `GuildMembers` — both are **privileged intents**. You must enable them in the
529
+ Discord developer portal for your application, otherwise the gateway will reject
530
+ the connection. `Intents.all` includes every privileged intent for the same
531
+ reason.
532
+
533
+ ```ts
534
+ import { SpearClient, GatewayIntentBits } from "spearkit";
535
+
536
+ // A custom intent set, mixing a preset idea with explicit bits.
537
+ const client = new SpearClient({
538
+ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
539
+ });
540
+ ```
541
+
542
+ ## Registries and subsystems
543
+
544
+ Every client owns a set of registries and subsystems, each populated by
545
+ `register` (or `load`) or configured by an option:
546
+
547
+ | Member | Type | Holds / does |
548
+ | ------ | ---- | ------------ |
549
+ | `client.commands` | `CommandRegistry` | Slash commands; dispatches chat-input and autocomplete interactions. |
550
+ | `client.events` | `EventRegistry` | Event listeners; attached to the client automatically. |
551
+ | `client.components` | `ComponentRegistry` | Buttons, selects and modals; routed by custom-id namespace. |
552
+ | `client.contextMenus` | `ContextMenuRegistry` | User / message context-menu ("Apps") commands. |
553
+ | `client.prefix` | `PrefixRegistry` | Prefix (text) commands, dispatched from `messageCreate`. |
554
+ | `client.scheduler` | `TaskScheduler` | Cron / interval tasks; started on ready, stopped on `destroy`. |
555
+ | `client.cooldowns` | `CooldownManager` | Shared rate-limit state across commands and prefix commands. |
556
+ | `client.usage` | `UsageTracker` | Records who used what to a store and/or channel. |
557
+ | `client.logger` | `Logger` | Structured, scoped logger used across spearkit. |
558
+ | `client.embeds` | `Embeds` | Preset embed factory behind `ctx.success/error/...`. |
559
+
560
+ You rarely touch the registries directly — `register` routes items into the right
561
+ one — but they are public for inspection and advanced control (e.g.
562
+ `client.commands.size`, `client.commands.toJSON()`). Each subsystem has its own
563
+ guide: [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md), [Scheduled tasks](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/scheduler.md),
564
+ [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md), [Context menus](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context-menus.md),
565
+ [Logging](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md), [Usage tracking](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/usage.md), [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md).
566
+
567
+ ## Registering handlers
568
+
569
+ `client.register(...items)` accepts commands, events, components, context-menu
570
+ commands, prefix commands and scheduled tasks in a single call and routes each to
571
+ its registry by kind. The accepted union is exported as `Registerable`
572
+ (`SlashCommand | EventDef | ComponentDef | ScheduledTask | PrefixCommand |
573
+ ContextMenuCommand`). It returns the client for chaining.
574
+
575
+ ```ts
576
+ import { SpearClient, command, event, button, option } from "spearkit";
577
+
578
+ const client = new SpearClient();
579
+
580
+ const greet = command({
581
+ name: "greet",
582
+ description: "Greet someone",
583
+ options: { who: option.user({ description: "Who", required: true }) },
584
+ run: (ctx) => ctx.reply(`Hello ${ctx.options.who}!`), // who: User
585
+ });
586
+
587
+ const ready = event("clientReady", (c) => {
588
+ console.log(`Logged in as ${c.user.tag}`); // c: Client<true>
589
+ });
590
+
591
+ const ping = button({
592
+ id: "ping:{n}",
593
+ label: "Ping",
594
+ run: (ctx) => ctx.reply(`pong #${ctx.params.n}`), // n: string
595
+ });
596
+
597
+ // Commands, events and components in one call.
598
+ client.register(greet, ready, ping);
599
+ ```
600
+
601
+ ## Plugins
602
+
603
+ `client.use(...plugins)` installs one or more plugins, awaiting each plugin's
604
+ `setup`. It is async and returns the client.
605
+
606
+ ```ts
607
+ import { SpearClient } from "spearkit";
608
+ import { statsPlugin } from "./plugins/stats.js";
609
+
610
+ const client = new SpearClient();
611
+ await client.use(statsPlugin);
612
+ ```
613
+
614
+ See [Plugins](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/plugins.md) for authoring `SpearPlugin`s.
615
+
616
+ ## File-based loading
617
+
618
+ `client.load(dir, options?)` recursively imports a directory and registers every
619
+ spearkit-registrable export it finds — commands, events, components, scheduled
620
+ tasks and prefix commands. It returns the number of items registered.
621
+
622
+ ```ts
623
+ import { SpearClient } from "spearkit";
624
+
625
+ const client = new SpearClient();
626
+ const count = await client.load("./src/commands");
627
+ console.log(`Loaded ${count} handlers`);
628
+ ```
629
+
630
+ See [File-based loading](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/loading.md) for the layout and `LoadOptions`.
631
+
632
+ ## Starting and deploying
633
+
634
+ `client.start(token?)` logs in. If you omit the token it falls back to the
635
+ `DISCORD_TOKEN` environment variable, and throws if neither is present.
636
+
637
+ ```ts
638
+ import { SpearClient } from "spearkit";
639
+
640
+ const client = new SpearClient();
641
+
642
+ // Pass a token explicitly…
643
+ await client.start("your-token");
644
+
645
+ // …or set DISCORD_TOKEN and call start() with no argument.
646
+ await client.start();
647
+ ```
648
+
649
+ `client.deployCommands({ guildId })` pushes the registered slash commands to
650
+ Discord using the client's own authenticated REST connection — there is no
651
+ separate token or application id to supply. Because it reads the application id
652
+ from the logged-in client, it **must run after the client is ready**. Pass a
653
+ `guildId` to deploy instantly to a single guild (ideal for development); omit it
654
+ to deploy globally.
655
+
656
+ ```ts
657
+ import { SpearClient, Intents } from "spearkit";
658
+
659
+ const client = new SpearClient({ intents: Intents.default });
660
+ // …register commands…
661
+
662
+ await client.start(); // uses DISCORD_TOKEN
663
+
664
+ // Deploy once the client is ready.
665
+ client.once("clientReady", async () => {
666
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
667
+ });
668
+ ```
669
+
670
+ ## Reliability: auto-defer and graceful shutdown
671
+
672
+ A slow handler that doesn't respond within Discord's 3-second window dies with
673
+ `Unknown interaction` (10062). Set `autoDefer` to have spearkit `deferReply()`
674
+ automatically just before that window closes — per handler (`command({ autoDefer:
675
+ true })`, `userCommand`/`messageCommand`) or for every slash + context-menu
676
+ handler at once:
677
+
678
+ ```ts
679
+ const client = new SpearClient({ autoDefer: true });
680
+ // or { ephemeral: true, delayMs: 1500 } for a hidden defer / earlier fire.
681
+ ```
682
+
683
+ With auto-defer on, respond via `ctx.send(...)` or `ctx.editReply(...)` — the
684
+ initial reply slot may already be taken by the safety defer.
685
+
686
+ `client.enableGracefulShutdown(options?)` closes the bot cleanly on `SIGINT` /
687
+ `SIGTERM`: it runs an optional `onShutdown` hook, calls `destroy()` (stopping the
688
+ scheduler and gateway), and exits, with a hard timeout so a wedged shutdown can't
689
+ hang. It returns a disposer that removes the signal handlers.
690
+
691
+ ```ts
692
+ client.enableGracefulShutdown({ onShutdown: () => db.close() });
693
+ ```
694
+
695
+ ## Everything discord.js still works
696
+
697
+ `SpearClient` extends discord.js `Client`, so the full client surface is
698
+ available unchanged. spearkit adds registries on top — it never hides what is
699
+ underneath:
700
+
701
+ ```ts
702
+ import { SpearClient } from "spearkit";
703
+
704
+ const client = new SpearClient();
705
+
706
+ client.on("guildCreate", (guild) => console.log(`Joined ${guild.name}`));
707
+ client.ws.on("VOICE_SERVER_UPDATE", () => {});
708
+
709
+ await client.start();
710
+
711
+ console.log(client.application?.id); // application
712
+ console.log(client.user?.tag); // user
713
+ console.log(client.rest); // REST manager (used by deployCommands)
714
+
715
+ await client.destroy(); // graceful shutdown
716
+ ```
717
+
718
+ ## See also
719
+
720
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — defining slash commands you register here.
721
+ - [Plugins](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/plugins.md) — bundling features for `client.use`.
722
+ - [File-based loading](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/loading.md) — populating the client from a directory.
723
+
724
+ ---
725
+
726
+ # Commands
727
+
728
+ Slash commands in spearkit are defined as a single object: the metadata, the typed
729
+ options, and the handler all live together. spearkit serialises them for discord
730
+ and routes incoming interactions to the right handler for you.
731
+
732
+ ## A first command
733
+
734
+ ```ts
735
+ import { command } from "spearkit";
736
+
737
+ export const ping = command({
738
+ name: "ping",
739
+ description: "Check latency",
740
+ run: (ctx) => ctx.reply(`Pong! ${ctx.client.ws.ping}ms`),
741
+ });
742
+ ```
743
+
744
+ Register it on a client (`client.register(ping)`) and deploy it (see
745
+ [Deployment](#deployment)). That's the whole loop.
746
+
747
+ ## The command context
748
+
749
+ The handler receives a `CommandContext`. It wraps the discord.js
750
+ `ChatInputCommandInteraction` and adds ergonomic accessors and reply helpers.
751
+
752
+ | Member | Description |
753
+ | ------ | ----------- |
754
+ | `ctx.options` | Resolved, fully-typed option values (see [Options](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/options.md)). |
755
+ | `ctx.commandName` | The invoked command name. |
756
+ | `ctx.subcommand` | The invoked subcommand name, or `null`. |
757
+ | `ctx.showModal(modal)` | Present a modal in response. |
758
+ | `ctx.user` / `ctx.member` / `ctx.guild` / `ctx.guildId` / `ctx.channel` / `ctx.channelId` / `ctx.locale` | Actor and location accessors. |
759
+ | `ctx.reply` / `ctx.replyEphemeral` / `ctx.defer` / `ctx.editReply` / `ctx.followUp` / `ctx.send` / `ctx.error` | Reply helpers (see [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md)). |
760
+ | `ctx.interaction` | The raw discord.js interaction, for anything not wrapped. |
761
+
762
+ ```ts
763
+ import { command, option } from "spearkit";
764
+
765
+ export const echo = command({
766
+ name: "echo",
767
+ description: "Repeat a message",
768
+ options: {
769
+ text: option.string({ description: "What to say", required: true }),
770
+ times: option.integer({ description: "Repeat count", minValue: 1, maxValue: 5 }),
771
+ },
772
+ run: (ctx) => {
773
+ ctx.options.text; // string
774
+ ctx.options.times; // number | undefined
775
+ return ctx.reply({
776
+ content: ctx.options.text.repeat(ctx.options.times ?? 1),
777
+ ephemeral: true,
778
+ });
779
+ },
780
+ });
781
+ ```
782
+
783
+ Options are covered in depth in [Options](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/options.md).
784
+
785
+ ## Command metadata
786
+
787
+ ```ts
788
+ import { command, PermissionFlagsBits } from "spearkit";
789
+
790
+ export const purge = command({
791
+ name: "purge",
792
+ description: "Delete recent messages",
793
+ guildOnly: true, // only usable in guilds
794
+ nsfw: false, // age-restricted command
795
+ defaultMemberPermissions: PermissionFlagsBits.ManageMessages, // who sees it by default
796
+ nameLocalizations: { tr: "temizle" }, // localized name
797
+ descriptionLocalizations: { tr: "Mesajları sil" },
798
+ run: (ctx) => ctx.reply("…"),
799
+ });
800
+ ```
801
+
802
+ | Field | Type | Effect |
803
+ | ----- | ---- | ------ |
804
+ | `guildOnly` | `boolean` | Restricts the command to guild contexts. |
805
+ | `nsfw` | `boolean` | Marks the command age-restricted. |
806
+ | `defaultMemberPermissions` | `PermissionResolvable \| null` | Default permission gate (members without it don't see the command). |
807
+ | `nameLocalizations` / `descriptionLocalizations` | `LocalizationMap` | Per-locale name/description. |
808
+ | `cooldown` | `number \| CooldownConfig` | Rate-limit the command (a number is milliseconds). See [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md). |
809
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. See [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md). |
810
+ | `autoDefer` | `boolean \| { ephemeral?, delayMs? }` | Auto-`deferReply()` if the handler is slow (>~2s), preventing `Unknown interaction`. Respond via `ctx.send`/`ctx.editReply`. |
811
+
812
+ ## Subcommands and groups
813
+
814
+ For commands with subcommands, use `commandGroup` together with `subcommand`
815
+ and (optionally) `subcommandGroup`. Each subcommand has its own typed options
816
+ and handler; spearkit routes to the right one automatically.
817
+
818
+ ```ts
819
+ import { commandGroup, subcommand, subcommandGroup, option } from "spearkit";
820
+
821
+ export const admin = commandGroup({
822
+ name: "admin",
823
+ description: "Administration",
824
+ guildOnly: true,
825
+ // Direct subcommands: /admin say
826
+ subcommands: {
827
+ say: subcommand({
828
+ description: "Make the bot say something",
829
+ options: { message: option.string({ description: "Message", required: true }) },
830
+ run: (ctx) => ctx.reply(ctx.options.message),
831
+ }),
832
+ },
833
+ // Grouped subcommands: /admin users ban
834
+ groups: {
835
+ users: subcommandGroup({
836
+ description: "Manage users",
837
+ subcommands: {
838
+ ban: subcommand({
839
+ description: "Ban a member",
840
+ options: {
841
+ target: option.user({ description: "Member", required: true }),
842
+ reason: option.string({ description: "Reason" }),
843
+ },
844
+ run: (ctx) =>
845
+ ctx.reply(`Banned ${ctx.options.target.tag}: ${ctx.options.reason ?? "no reason"}`),
846
+ }),
847
+ },
848
+ }),
849
+ },
850
+ });
851
+ ```
852
+
853
+ Inside a subcommand handler, `ctx.options` is typed from *that subcommand's*
854
+ options. There is no `switch (subcommand)` to write — spearkit dispatches by the
855
+ invoked subcommand group/name.
856
+
857
+ ## The command registry
858
+
859
+ `client.commands` is a `CommandRegistry`. You usually feed it through
860
+ `client.register(...)`, but you can use it directly:
861
+
862
+ ```ts
863
+ import { CommandRegistry } from "spearkit";
864
+
865
+ const registry = new CommandRegistry();
866
+ registry.add(ping, echo, admin);
867
+
868
+ registry.get("ping"); // SlashCommand | undefined
869
+ registry.names; // string[]
870
+ registry.size; // number
871
+ registry.remove("ping"); // boolean
872
+ registry.toJSON(); // REST payloads for all commands
873
+ ```
874
+
875
+ `SpearClient` calls `registry.handle(interaction)` and
876
+ `registry.handleAutocomplete(interaction)` for you on every interaction.
877
+
878
+ ### Error handling
879
+
880
+ If a handler throws, spearkit catches it. By default it emits the client's `error`
881
+ event and replies with an ephemeral "something went wrong" message. Override
882
+ that:
883
+
884
+ ```ts
885
+ client.commands.onError((error, interaction) => {
886
+ console.error(`/${interaction.commandName} failed`, error);
887
+ if (!interaction.replied && !interaction.deferred) {
888
+ return interaction.reply({ content: "Command failed.", ephemeral: true });
889
+ }
890
+ });
891
+ ```
892
+
893
+ ## Deployment
894
+
895
+ Commands must be registered with discord before they appear. spearkit gives you two
896
+ ways.
897
+
898
+ **From the client** (uses the client's authenticated REST; call after ready):
899
+
900
+ ```ts
901
+ await client.start(process.env.DISCORD_TOKEN);
902
+ await client.deployCommands({ guildId: process.env.GUILD_ID }); // omit guildId for global
903
+ ```
904
+
905
+ **Standalone** (a separate deploy script, no running client needed):
906
+
907
+ ```ts
908
+ import { CommandRegistry } from "spearkit";
909
+
910
+ const registry = new CommandRegistry().add(ping, echo, admin);
911
+ await registry.deploy({
912
+ token: process.env.DISCORD_TOKEN,
913
+ applicationId: process.env.DISCORD_APP_ID,
914
+ guildId: process.env.GUILD_ID, // optional
915
+ });
916
+ ```
917
+
918
+ Guild deploys apply **instantly** and are ideal during development. Global
919
+ deploys (no `guildId`) can take up to an hour to propagate.
920
+
921
+ ## See also
922
+
923
+ - [Options](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/options.md) — typed option builders, choices, autocomplete.
924
+ - [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — buttons, selects, modals.
925
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — registering and deploying from the client.
926
+ - [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md) — the reply helpers every handler shares.
927
+ - [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — rate-limit a command with `cooldown`.
928
+ - [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md) — gate a command with `guards`.
929
+
930
+ ---
931
+
932
+ # Options
933
+
934
+ Slash command options are declared as a map of name → builder. spearkit infers the
935
+ exact value type each option resolves to, so your handler's `ctx.options` is
936
+ fully typed — no casts, no `any`, no manual `getString` calls.
937
+
938
+ ```ts
939
+ import { command, option } from "spearkit";
940
+
941
+ command({
942
+ name: "profile",
943
+ description: "Show a profile",
944
+ options: {
945
+ user: option.user({ description: "Whose profile", required: true }),
946
+ detailed: option.boolean({ description: "Show extra detail" }),
947
+ },
948
+ run: (ctx) => {
949
+ ctx.options.user; // User
950
+ ctx.options.detailed; // boolean | undefined
951
+ },
952
+ });
953
+ ```
954
+
955
+ ## Builders and resolved types
956
+
957
+ | Builder | Resolved type | Type-specific config |
958
+ | ------- | ------------- | -------------------- |
959
+ | `option.string(config)` | `string` | `choices`, `minLength`, `maxLength`, `autocomplete` |
960
+ | `option.integer(config)` | `number` | `choices`, `minValue`, `maxValue`, `autocomplete` |
961
+ | `option.number(config)` | `number` | `choices`, `minValue`, `maxValue`, `autocomplete` |
962
+ | `option.boolean(config)` | `boolean` | — |
963
+ | `option.user(config)` | `User` | — |
964
+ | `option.channel(config)` | channel union | `channelTypes` |
965
+ | `option.role(config)` | `Role \| APIRole` | — |
966
+ | `option.mentionable(config)` | user / role / member | — |
967
+ | `option.attachment(config)` | `Attachment` | — |
968
+
969
+ Every builder accepts the common config:
970
+
971
+ ```ts
972
+ {
973
+ description: string; // required
974
+ required?: boolean; // default: false
975
+ nameLocalizations?: LocalizationMap;
976
+ descriptionLocalizations?: LocalizationMap;
977
+ }
978
+ ```
979
+
980
+ ## Inference rules
981
+
982
+ spearkit narrows the resolved type from your declaration:
983
+
984
+ ```ts
985
+ options: {
986
+ // required → the value type, never undefined
987
+ name: option.string({ description: "Name", required: true }), // string
988
+
989
+ // optional (default) → value | undefined
990
+ age: option.integer({ description: "Age" }), // number | undefined
991
+
992
+ // choices → a literal union of the choice values
993
+ size: option.string({
994
+ description: "Size",
995
+ choices: [
996
+ { name: "Small", value: "sm" },
997
+ { name: "Large", value: "lg" },
998
+ ],
999
+ }), // "sm" | "lg" | undefined
1000
+ }
1001
+ ```
1002
+
1003
+ - **Required** options resolve to the value type.
1004
+ - **Optional** options resolve to `value | undefined` (spearkit converts discord's
1005
+ absent value to `undefined`, never `null`).
1006
+ - **`choices`** narrow string/integer/number options to a literal union of the
1007
+ declared `value`s.
1008
+
1009
+ ```ts
1010
+ run: (ctx) => {
1011
+ const name: string = ctx.options.name;
1012
+ const age: number | undefined = ctx.options.age;
1013
+ const size: "sm" | "lg" | undefined = ctx.options.size;
1014
+ };
1015
+ ```
1016
+
1017
+ ## Numeric and length constraints
1018
+
1019
+ ```ts
1020
+ options: {
1021
+ count: option.integer({ description: "How many", minValue: 1, maxValue: 100 }),
1022
+ code: option.string({ description: "Code", minLength: 4, maxLength: 8 }),
1023
+ }
1024
+ ```
1025
+
1026
+ ## Channel options
1027
+
1028
+ Restrict the selectable channel types with `channelTypes` (from discord.js
1029
+ `ChannelType`):
1030
+
1031
+ ```ts
1032
+ import { option, ChannelType } from "spearkit";
1033
+
1034
+ options: {
1035
+ target: option.channel({
1036
+ description: "A text or announcement channel",
1037
+ channelTypes: [ChannelType.GuildText, ChannelType.GuildAnnouncement],
1038
+ }),
1039
+ }
1040
+ ```
1041
+
1042
+ ## Choices
1043
+
1044
+ `choices` are `{ name, value }` pairs. `name` is shown to the user; `value` is
1045
+ what your handler receives (and what spearkit narrows the type to).
1046
+
1047
+ ```ts
1048
+ option.integer({
1049
+ description: "Priority",
1050
+ choices: [
1051
+ { name: "Low", value: 1 },
1052
+ { name: "High", value: 2 },
1053
+ ],
1054
+ // optional per-choice localization:
1055
+ // choices: [{ name: "Low", value: 1, nameLocalizations: { tr: "Düşük" } }],
1056
+ }); // 1 | 2 | undefined
1057
+ ```
1058
+
1059
+ ## Autocomplete
1060
+
1061
+ Provide an `autocomplete` handler instead of fixed `choices` to suggest values
1062
+ as the user types. spearkit marks the option as autocompletable, routes the
1063
+ autocomplete interaction, and (for subcommands) finds the right option.
1064
+
1065
+ ```ts
1066
+ const fruits = ["apple", "apricot", "banana", "cherry"];
1067
+
1068
+ option.string({
1069
+ description: "Fruit",
1070
+ required: true,
1071
+ autocomplete: (ctx) =>
1072
+ fruits
1073
+ .filter((f) => f.startsWith(ctx.value))
1074
+ .map((f) => ({ name: f, value: f })),
1075
+ });
1076
+ ```
1077
+
1078
+ The autocomplete handler receives an `AutocompleteContext`:
1079
+
1080
+ | Member | Description |
1081
+ | ------ | ----------- |
1082
+ | `ctx.value` | The current partial value typed by the user. |
1083
+ | `ctx.focusedName` | The name of the option being completed. |
1084
+ | `ctx.commandName` | The command being completed. |
1085
+ | `ctx.client` / `ctx.user` / `ctx.guild` / `ctx.guildId` | Accessors. |
1086
+ | `ctx.respond(choices)` | Send suggestions (capped at discord's 25). |
1087
+
1088
+ Returning the choices array (as above) is enough — spearkit calls `respond` for
1089
+ you. Returning `[]` shows no suggestions.
1090
+
1091
+ ## See also
1092
+
1093
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — using options inside commands and subcommands.
1094
+ - [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — buttons, selects and modals.
1095
+
1096
+ ---
1097
+
1098
+ # Components
1099
+
1100
+ Buttons, select menus and modals in spearkit follow one pattern: define the
1101
+ appearance, the **custom-id pattern**, and the handler in one place; register
1102
+ it; then `build()` the discord.js component to put in a message. spearkit decodes
1103
+ incoming interactions and routes them to your handler — no `interactionCreate`
1104
+ switch statements, no manual custom-id parsing.
1105
+
1106
+ ```ts
1107
+ import { button, row } from "spearkit";
1108
+
1109
+ const vote = button({
1110
+ id: "vote:{choice}",
1111
+ label: "Yes",
1112
+ style: "Success",
1113
+ run: (ctx) => ctx.update(`You chose ${ctx.params.choice}`), // ctx.params.choice: string
1114
+ });
1115
+
1116
+ client.register(vote); // or client.components.add(vote)
1117
+
1118
+ await channel.send({
1119
+ content: "Cast your vote:",
1120
+ components: [row(vote.build({ choice: "yes" }))], // build() requires { choice }
1121
+ });
1122
+ ```
1123
+
1124
+ ## Custom-id patterns
1125
+
1126
+ The `id` is a pattern with the grammar `name` or `name:{param}` or
1127
+ `name:{a}:{b}`. The leading `name` is the routing **namespace**; each `{param}`
1128
+ becomes a positional value carried in the custom-id.
1129
+
1130
+ - In the handler, params are available as a typed object: `ctx.params.choice`.
1131
+ - `build(params)` requires **exactly** those params and encodes them into the
1132
+ custom-id.
1133
+
1134
+ ```ts
1135
+ const page = button({
1136
+ id: "page:{id}:{dir}",
1137
+ label: "Next",
1138
+ run: (ctx) => ctx.update(`item ${ctx.params.id}, going ${ctx.params.dir}`),
1139
+ });
1140
+
1141
+ page.build({ id: "42", dir: "next" }); // custom-id "page:42:next"
1142
+ ```
1143
+
1144
+ spearkit percent-escapes param values, so they may safely contain `:`. Custom-ids
1145
+ are limited to 100 characters (`MAX_CUSTOM_ID_LENGTH`); `build()` throws if you
1146
+ exceed it.
1147
+
1148
+ For advanced use, the codec is exported directly: `compilePattern`,
1149
+ `buildCustomId`, `parseCustomId`, and `paramsFromValues`.
1150
+
1151
+ ## Buttons
1152
+
1153
+ ```ts
1154
+ import { button, linkButton, ButtonStyle } from "spearkit";
1155
+
1156
+ const confirm = button({
1157
+ id: "confirm:{action}",
1158
+ label: "Confirm",
1159
+ style: ButtonStyle.Danger, // or the string "Danger"
1160
+ emoji: "⚠️",
1161
+ disabled: false,
1162
+ run: (ctx) => ctx.update(`Confirmed: ${ctx.params.action}`),
1163
+ });
1164
+
1165
+ // Link buttons have no handler and no custom-id:
1166
+ const docs = linkButton({ url: "https://example.com", label: "Docs" });
1167
+ ```
1168
+
1169
+ `style` accepts the string names `"Primary"`, `"Secondary"`, `"Success"`,
1170
+ `"Danger"`, or the `ButtonStyle` enum. It defaults to `"Secondary"`.
1171
+
1172
+ All component builders (`button`, the five selects, and `modal`) also accept
1173
+ `guards?: readonly Guard[]` — preconditions evaluated before the handler runs.
1174
+ See [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md).
1175
+
1176
+ The `ButtonContext` adds, on top of the shared [reply helpers](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md):
1177
+
1178
+ | Member | Description |
1179
+ | ------ | ----------- |
1180
+ | `ctx.params` | Decoded custom-id params. |
1181
+ | `ctx.update(input)` | Edit the message the button is on. |
1182
+ | `ctx.deferUpdate()` | Acknowledge without editing yet. |
1183
+ | `ctx.showModal(modal)` | Open a modal in response. |
1184
+ | `ctx.message` | The message the button belongs to. |
1185
+ | `ctx.customId` | The raw custom-id. |
1186
+
1187
+ ## Select menus
1188
+
1189
+ There are five select builders. All share `placeholder`, `minValues`,
1190
+ `maxValues`, and `disabled`; the string select additionally takes `options`,
1191
+ and the channel select takes `channelTypes`.
1192
+
1193
+ ```ts
1194
+ import { stringSelect, channelSelect, ChannelType } from "spearkit";
1195
+
1196
+ const colour = stringSelect({
1197
+ id: "colour",
1198
+ placeholder: "Pick a colour",
1199
+ minValues: 1,
1200
+ maxValues: 1,
1201
+ options: [
1202
+ { label: "Red", value: "red" },
1203
+ { label: "Green", value: "green", description: "the calm one" },
1204
+ { label: "Blue", value: "blue", default: true },
1205
+ ],
1206
+ run: (ctx) => ctx.reply({ content: `You picked ${ctx.values.join(", ")}`, ephemeral: true }),
1207
+ });
1208
+
1209
+ const pickChannel = channelSelect({
1210
+ id: "pick-channel",
1211
+ channelTypes: [ChannelType.GuildText],
1212
+ run: (ctx) => ctx.reply({ content: `${ctx.values.length} channel(s)`, ephemeral: true }),
1213
+ });
1214
+ ```
1215
+
1216
+ Each select context exposes the relevant resolved data:
1217
+
1218
+ | Builder | Context | Extra accessors |
1219
+ | ------- | ------- | --------------- |
1220
+ | `stringSelect` | `StringSelectContext` | `values: string[]`, `value: string \| undefined` |
1221
+ | `userSelect` | `UserSelectContext` | `values`, `users`, `members` |
1222
+ | `roleSelect` | `RoleSelectContext` | `values`, `roles` |
1223
+ | `channelSelect` | `ChannelSelectContext` | `values`, `channels` |
1224
+ | `mentionableSelect` | `MentionableSelectContext` | `values`, `users`, `roles`, `members` |
1225
+
1226
+ Select contexts also have `ctx.params`, `ctx.update`, `ctx.deferUpdate`,
1227
+ `ctx.showModal`, and the shared reply helpers.
1228
+
1229
+ ## Modals
1230
+
1231
+ A modal declares its `fields` as a map of name → `textInput`. The submit handler
1232
+ receives the submitted values in `ctx.fields`, keyed (and typed) by those names,
1233
+ plus any custom-id params in `ctx.params`.
1234
+
1235
+ ```ts
1236
+ import { modal, textInput } from "spearkit";
1237
+
1238
+ const feedback = modal({
1239
+ id: "feedback:{ticket}",
1240
+ title: "Feedback",
1241
+ fields: {
1242
+ summary: textInput({ label: "Summary", required: true }),
1243
+ detail: textInput({ label: "Details", style: "Paragraph", maxLength: 2000 }),
1244
+ },
1245
+ run: (ctx) =>
1246
+ ctx.reply({
1247
+ // ctx.params.ticket: string, ctx.fields.summary / ctx.fields.detail: string
1248
+ content: `#${ctx.params.ticket}: ${ctx.fields.summary}`,
1249
+ ephemeral: true,
1250
+ }),
1251
+ });
1252
+ ```
1253
+
1254
+ `textInput` config: `label` (required), `style` (`"Short"` default, or
1255
+ `"Paragraph"`, or a `TextInputStyle`), `placeholder`, `required`, `minLength`,
1256
+ `maxLength`, `value`.
1257
+
1258
+ Open a modal from a command or a component handler with `showModal` — modals
1259
+ cannot be the *response* to another modal, but they can follow a command or a
1260
+ button/select:
1261
+
1262
+ ```ts
1263
+ import { command } from "spearkit";
1264
+
1265
+ const ask = command({
1266
+ name: "ask",
1267
+ description: "Open the feedback form",
1268
+ run: (ctx) => ctx.showModal(feedback.build({ ticket: "1234" })),
1269
+ });
1270
+ ```
1271
+
1272
+ ## Action rows
1273
+
1274
+ `row(...components)` wraps builders in an `ActionRowBuilder`. A row holds up to
1275
+ five buttons, or exactly one select menu.
1276
+
1277
+ ```ts
1278
+ import { row } from "spearkit";
1279
+
1280
+ const components = [
1281
+ row(confirm.build({ action: "delete" }), docs),
1282
+ row(colour.build()),
1283
+ ];
1284
+ await channel.send({ content: "Choose:", components });
1285
+ ```
1286
+
1287
+ ## Registering and routing
1288
+
1289
+ Register components like anything else:
1290
+
1291
+ ```ts
1292
+ client.register(vote, colour, feedback);
1293
+ // equivalently:
1294
+ client.components.add(vote, colour, feedback);
1295
+ ```
1296
+
1297
+ `SpearClient` routes every button, select and modal interaction to the matching
1298
+ namespace automatically. The `ComponentRegistry` API:
1299
+
1300
+ | Member | Description |
1301
+ | ------ | ----------- |
1302
+ | `add(...defs)` | Register components (override by namespace). |
1303
+ | `size` | Number registered. |
1304
+ | `onError(handler)` | Set the error handler. |
1305
+ | `handle(interaction)` | Route an interaction; returns `true` if matched. |
1306
+ | `setDefaultGuards(guards)` | Guards run before each component's own guards. |
1307
+
1308
+ `setLogger` and `setUsageHook` also exist; the client wires all three for you.
1309
+
1310
+ ### Error handling
1311
+
1312
+ By default a throwing handler emits the client `error` event and replies with an
1313
+ ephemeral message. Customise it:
1314
+
1315
+ ```ts
1316
+ client.components.onError((error, interaction) => {
1317
+ console.error("component failed", error);
1318
+ });
1319
+ ```
1320
+
1321
+ ## End-to-end example
1322
+
1323
+ ```ts
1324
+ import {
1325
+ SpearClient,
1326
+ Intents,
1327
+ command,
1328
+ button,
1329
+ stringSelect,
1330
+ modal,
1331
+ textInput,
1332
+ row,
1333
+ } from "spearkit";
1334
+
1335
+ const client = new SpearClient({ intents: Intents.default });
1336
+
1337
+ const open = button({
1338
+ id: "open-form:{topic}",
1339
+ label: "Open form",
1340
+ style: "Primary",
1341
+ run: (ctx) => ctx.showModal(form.build({ topic: ctx.params.topic })),
1342
+ });
1343
+
1344
+ const rating = stringSelect({
1345
+ id: "rating",
1346
+ placeholder: "Rate us",
1347
+ options: [
1348
+ { label: "Good", value: "good" },
1349
+ { label: "Bad", value: "bad" },
1350
+ ],
1351
+ run: (ctx) => ctx.reply({ content: `Thanks: ${ctx.value}`, ephemeral: true }),
1352
+ });
1353
+
1354
+ const form = modal({
1355
+ id: "form:{topic}",
1356
+ title: "Tell us more",
1357
+ fields: { body: textInput({ label: "Message", style: "Paragraph", required: true }) },
1358
+ run: (ctx) => ctx.reply({ content: `[${ctx.params.topic}] ${ctx.fields.body}`, ephemeral: true }),
1359
+ });
1360
+
1361
+ const panel = command({
1362
+ name: "panel",
1363
+ description: "Show the panel",
1364
+ run: (ctx) =>
1365
+ ctx.reply({
1366
+ content: "How was it?",
1367
+ components: [row(open.build({ topic: "support" })), row(rating.build())],
1368
+ }),
1369
+ });
1370
+
1371
+ client.register(panel, open, rating, form);
1372
+ ```
1373
+
1374
+ ## See also
1375
+
1376
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — opening components from commands.
1377
+ - [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md) — the reply/update helpers contexts share.
1378
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — registration and routing.
1379
+
1380
+ ---
1381
+
1382
+ # Context-menu commands
1383
+
1384
+ Context-menu commands are the right-click **"Apps"** actions Discord shows on a
1385
+ user or a message. spearkit makes them first-class: define one with `userCommand`
1386
+ or `messageCommand`, register it like anything else, and deploy it alongside your
1387
+ slash commands. The handler gets a typed `targetUser` or `targetMessage`.
1388
+
1389
+ ```ts
1390
+ import { userCommand } from "spearkit";
1391
+
1392
+ export const whois = userCommand({
1393
+ name: "Who is this?",
1394
+ run: (ctx) => ctx.replyEphemeral(`That's ${ctx.targetUser.tag}.`),
1395
+ });
1396
+ ```
1397
+
1398
+ `name` is the label shown in the Apps menu (no description — Discord does not show
1399
+ one for context-menu commands).
1400
+
1401
+ ## User vs message commands
1402
+
1403
+ | Builder | Appears on | Target context |
1404
+ | ------- | ---------- | -------------- |
1405
+ | `userCommand` | a user (right-click → Apps) | `ctx.targetUser`, `ctx.targetMember` |
1406
+ | `messageCommand` | a message (right-click → Apps) | `ctx.targetMessage` |
1407
+
1408
+ ```ts
1409
+ import { messageCommand } from "spearkit";
1410
+
1411
+ export const report = messageCommand({
1412
+ name: "Report message",
1413
+ run: (ctx) =>
1414
+ ctx.replyEphemeral(`Reported message ${ctx.targetMessage.id}.`),
1415
+ });
1416
+ ```
1417
+
1418
+ Both handler contexts extend the shared [`BaseContext`](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md), so
1419
+ `ctx.reply`, `ctx.replyEphemeral`, `ctx.defer`, `ctx.success/error/...` and the
1420
+ usual accessors are all available.
1421
+
1422
+ ## Metadata, cooldowns and guards
1423
+
1424
+ Both builders accept the same metadata, plus a `cooldown` and `guards`:
1425
+
1426
+ | Field | Type | Effect |
1427
+ | ----- | ---- | ------ |
1428
+ | `name` | `string` | The Apps-menu label. |
1429
+ | `defaultMemberPermissions` | `PermissionResolvable \| null` | Default permission gate. |
1430
+ | `nsfw` | `boolean` | Marks the command age-restricted. |
1431
+ | `guildOnly` | `boolean` | Restricts it to guild contexts. |
1432
+ | `nameLocalizations` | `LocalizationMap` | Per-locale label. |
1433
+ | `cooldown` | `number \| CooldownConfig` | Rate limit (shares `client.cooldowns`). |
1434
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. |
1435
+ | `autoDefer` | `boolean \| { ephemeral?, delayMs? }` | Auto-`deferReply()` if the handler is slow, preventing `Unknown interaction`. |
1436
+
1437
+ ```ts
1438
+ import { userCommand, guildOnly, requireUserPermissions, PermissionFlagsBits } from "spearkit";
1439
+
1440
+ export const warn = userCommand({
1441
+ name: "Warn user",
1442
+ guildOnly: true,
1443
+ cooldown: 5_000,
1444
+ guards: [requireUserPermissions(PermissionFlagsBits.ModerateMembers)],
1445
+ run: (ctx) => ctx.replyEphemeral(`Warned ${ctx.targetUser.tag}.`),
1446
+ });
1447
+ ```
1448
+
1449
+ See [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) and [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md) for the shared options.
1450
+
1451
+ ## Registering and deploying
1452
+
1453
+ Register context-menu commands like everything else with `client.register(...)`.
1454
+ They route automatically — spearkit dispatches user- and message-context-menu
1455
+ interactions to the matching command.
1456
+
1457
+ ```ts
1458
+ client.register(whois, report, warn);
1459
+ ```
1460
+
1461
+ Because context menus and slash commands deploy to the same Discord endpoint,
1462
+ push them together with `deployAllCommands` once you mix the two — it sends both
1463
+ in a single request. (`deployCommands` is slash-only.)
1464
+
1465
+ ```ts
1466
+ await client.start(process.env.DISCORD_TOKEN);
1467
+ await client.deployAllCommands({ guildId: process.env.GUILD_ID }); // slash + menus
1468
+ ```
1469
+
1470
+ `deployAllCommands` also supports a `dryRun` flag and a `strategy: "diff"` that
1471
+ skips the PUT when the remote set already matches — handy in CI:
1472
+
1473
+ ```ts
1474
+ // Preview without touching Discord:
1475
+ const result = await client.deployAllCommands({ guildId, dryRun: true });
1476
+ // → { skipped: true, reason: "dry-run", body: [...] }
1477
+
1478
+ // Only deploy when something changed:
1479
+ await client.deployAllCommands({ guildId, strategy: "diff" });
1480
+ ```
1481
+
1482
+ ## The registry
1483
+
1484
+ `client.contextMenus` is a `ContextMenuRegistry`. The client wires it to the
1485
+ logger and cooldown manager and routes interactions for you, so you rarely touch
1486
+ it directly:
1487
+
1488
+ ```ts
1489
+ client.contextMenus.size; // number registered
1490
+ client.contextMenus.all(); // ContextMenuCommand[]
1491
+ client.contextMenus.toJSON(); // REST payloads (also included by deployAllCommands)
1492
+ ```
1493
+
1494
+ Note: context-menu commands are **not** picked up by `client.load(...)`
1495
+ directory loading — register them explicitly with `client.register(...)`.
1496
+
1497
+ ## See also
1498
+
1499
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — slash commands.
1500
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — `deployAllCommands` and registration.
1501
+ - [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md) / [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — the shared preconditions.
1502
+ - [Contexts](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context.md) — the reply helpers every handler shares.
1503
+
1504
+ ---
1505
+
1506
+ # Events
1507
+
1508
+ `event()` defines a reusable, loadable discord.js event listener with a
1509
+ fully-typed handler. The handler's arguments are inferred from discord.js'
1510
+ `ClientEvents`, so you never annotate them by hand. Register an event with the
1511
+ client and spearkit attaches the listener for you.
1512
+
1513
+ ```ts
1514
+ import { event } from "spearkit";
1515
+
1516
+ export default event("messageCreate", (message) => {
1517
+ if (message.author.bot) return;
1518
+ // message is fully typed as Message
1519
+ });
1520
+ ```
1521
+
1522
+ ## Defining an event
1523
+
1524
+ `event` has two forms. The positional form takes the event name and handler:
1525
+
1526
+ ```ts
1527
+ import { event } from "spearkit";
1528
+
1529
+ const onMessage = event("messageCreate", (message) => {
1530
+ // message: Message
1531
+ console.log(message.content);
1532
+ });
1533
+
1534
+ const onReady = event("clientReady", (client) => {
1535
+ // client: Client<true> — the ready client
1536
+ console.log(`Logged in as ${client.user.tag}`);
1537
+ });
1538
+ ```
1539
+
1540
+ The object form (`EventConfig`) additionally accepts `once`, which runs the
1541
+ handler at most once and then auto-detaches:
1542
+
1543
+ ```ts
1544
+ import { event } from "spearkit";
1545
+
1546
+ const onceReady = event({
1547
+ name: "clientReady",
1548
+ once: true,
1549
+ run: (client) => {
1550
+ // client: Client<true>
1551
+ console.log(`Ready as ${client.user.tag}`);
1552
+ },
1553
+ });
1554
+ ```
1555
+
1556
+ Both forms return an `EventDef` — a type-erased, ready-to-attach listener
1557
+ (`{ name, once, attach, detach }`). Register it like anything else:
1558
+
1559
+ ```ts
1560
+ import { SpearClient } from "spearkit";
1561
+
1562
+ const client = new SpearClient();
1563
+ client.register(onMessage, onReady);
1564
+ ```
1565
+
1566
+ ### Handlers are fully typed from `ClientEvents`
1567
+
1568
+ The event name drives the parameter types. There is nothing to import or
1569
+ annotate — picking `"messageCreate"` types the argument as `Message`, picking
1570
+ `"guildMemberAdd"` types it as `GuildMember`, and so on. The handler type is
1571
+ exported as `EventHandler<E>` (`(...args: ClientEvents[E]) => Awaitable<void>`).
1572
+
1573
+ ```ts
1574
+ import { event } from "spearkit";
1575
+
1576
+ const onJoin = event("guildMemberAdd", (member) => {
1577
+ // member: GuildMember
1578
+ void member.roles.add("123456789012345678");
1579
+ });
1580
+
1581
+ const onReaction = event("messageReactionAdd", (reaction, user) => {
1582
+ // reaction: MessageReaction | PartialMessageReaction
1583
+ // user: User | PartialUser
1584
+ console.log(`${user.id} reacted with ${reaction.emoji.name}`);
1585
+ });
1586
+ ```
1587
+
1588
+ ### Intents are required
1589
+
1590
+ An event only fires if the client connected with the matching gateway intents.
1591
+ For example, `messageCreate` with message content needs `Intents.messages` (or
1592
+ at least the `GuildMessages` / `MessageContent` bits); `guildMemberAdd` needs
1593
+ `GuildMembers`. See [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) for the intent presets.
1594
+
1595
+ ## Errors are routed, not fatal
1596
+
1597
+ If a handler throws synchronously or rejects a returned promise, spearkit catches
1598
+ it and emits it on the client's `error` event instead of crashing the process.
1599
+ Listen for `error` to log or report failures centrally:
1600
+
1601
+ ```ts
1602
+ import { SpearClient } from "spearkit";
1603
+
1604
+ const client = new SpearClient();
1605
+
1606
+ client.on("error", (err) => {
1607
+ console.error("A handler failed:", err);
1608
+ });
1609
+ ```
1610
+
1611
+ ## Inline listeners still work
1612
+
1613
+ Because spearkit re-exports discord.js, the plain `client.on(...)` / `client.once(...)`
1614
+ listeners work exactly as before — they are the same methods. Reach for them for
1615
+ quick, inline, client-local listeners:
1616
+
1617
+ ```ts
1618
+ import { SpearClient } from "spearkit";
1619
+
1620
+ const client = new SpearClient();
1621
+ client.on("guildCreate", (guild) => console.log(`Joined ${guild.name}`));
1622
+ ```
1623
+
1624
+ Use `event()` when you want a listener that is **reusable and loadable** — a
1625
+ self-contained module you can export, register from anywhere, or pick up via
1626
+ `client.load(...)`. Note that inline `client.on` listeners do **not** get the
1627
+ automatic error-routing that `event()` handlers do.
1628
+
1629
+ ## The `EventRegistry`
1630
+
1631
+ `client.events` is an `EventRegistry`. The client attaches it automatically at
1632
+ construction and again when you `register` an event, so you usually never call
1633
+ its methods directly. They are available for advanced control:
1634
+
1635
+ | Member | Type | Description |
1636
+ | ------ | ---- | ----------- |
1637
+ | `add(...defs)` | `this` | Register one or more `EventDef`s (and attach them to already-attached clients). |
1638
+ | `size` | `number` | Number of registered listeners. |
1639
+ | `attachAll(client)` | `void` | Attach every registered listener to a client. |
1640
+ | `detachAll(client)` | `void` | Detach every registered listener from a client. |
1641
+
1642
+ ```ts
1643
+ import { SpearClient, event } from "spearkit";
1644
+
1645
+ const client = new SpearClient();
1646
+ client.events.add(event("warn", (info) => console.warn(info)));
1647
+
1648
+ console.log(client.events.size); // 1
1649
+
1650
+ // Detach all spearkit-managed listeners (e.g. before a hot reload).
1651
+ client.events.detachAll(client);
1652
+ ```
1653
+
1654
+ ## See also
1655
+
1656
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — registering events and the required intents.
1657
+ - [File-based loading](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/loading.md) — one event per file, auto-registered.
1658
+
1659
+ ---
1660
+
1661
+ # Contexts
1662
+
1663
+ Every spearkit handler — command, button, select, modal — receives a context
1664
+ object. They all share `BaseContext`, which smooths over discord.js'
1665
+ reply/defer/edit/follow-up state machine and exposes the common
1666
+ actor/location accessors. Learn it once and it applies everywhere.
1667
+
1668
+ ```ts
1669
+ import { command, option } from "spearkit";
1670
+
1671
+ export default command({
1672
+ name: "hello",
1673
+ description: "Say hello",
1674
+ options: { name: option.string({ description: "Name", required: true }) },
1675
+ run: (ctx) => ctx.reply(`Hi, ${ctx.options.name}!`),
1676
+ });
1677
+ ```
1678
+
1679
+ `CommandContext`, `ButtonContext`, `StringSelectContext`, modal contexts and the
1680
+ rest extend `BaseContext`, adding their own specifics (e.g. `ctx.options`,
1681
+ `ctx.params`, `ctx.fields`) on top of everything below.
1682
+
1683
+ ## Reply helpers
1684
+
1685
+ | Method | Returns | Behaviour |
1686
+ | ------ | ------- | --------- |
1687
+ | `reply(input)` | `Promise<InteractionResponse>` | Send the initial response. |
1688
+ | `replyEphemeral(input)` | `Promise<InteractionResponse>` | Reply, hidden to everyone but the invoking user. |
1689
+ | `defer({ ephemeral })` | `Promise<InteractionResponse>` | Acknowledge now, respond later via `editReply`. |
1690
+ | `editReply(input)` | `Promise<Message>` | Edit the original (or deferred) response. |
1691
+ | `followUp(input)` | `Promise<Message>` | Add a message after the initial response. |
1692
+ | `send(input)` | `Promise<void>` | State-aware: replies, edits, or follows up automatically. |
1693
+ | `error(input, options?)` | `Promise<void>` | State-aware preset error embed; ephemeral by default. |
1694
+ | `success` / `info` / `warn` `(input, options?)` | `Promise<void>` | State-aware preset embeds. |
1695
+ | `replyError` / `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `Promise<InteractionResponse>` | Initial-reply preset embeds. |
1696
+
1697
+ ```ts
1698
+ import { command } from "spearkit";
1699
+
1700
+ export default command({
1701
+ name: "demo",
1702
+ description: "Reply helpers",
1703
+ run: async (ctx) => {
1704
+ await ctx.reply("Working on it…");
1705
+ await ctx.followUp("…almost done.");
1706
+ },
1707
+ });
1708
+ ```
1709
+
1710
+ ### `send` is the one most handlers need
1711
+
1712
+ `send` inspects the interaction state and does the right thing:
1713
+
1714
+ - not yet answered → `reply`
1715
+ - already deferred → `editReply`
1716
+ - already replied → `followUp`
1717
+
1718
+ This means you can call `send` without tracking whether you deferred, which is
1719
+ ideal for shared helpers that may run before or after a `defer`.
1720
+
1721
+ ```ts
1722
+ import { command } from "spearkit";
1723
+
1724
+ export default command({
1725
+ name: "report",
1726
+ description: "Generate a report",
1727
+ run: async (ctx) => {
1728
+ await ctx.defer(); // acknowledge while we do slow work
1729
+ const data = await buildReport();
1730
+ await ctx.send(data); // sees the deferred state → edits the reply
1731
+ },
1732
+ });
1733
+ ```
1734
+
1735
+ ### `error` for ephemeral failures
1736
+
1737
+ `error(input, options?)` sends a state-aware preset **error embed** — ephemeral
1738
+ by default (pass `{ ephemeral: false }` to make it public) — perfect for
1739
+ validation failures that only the invoking user should see.
1740
+
1741
+ ```ts
1742
+ import { command, option } from "spearkit";
1743
+
1744
+ export default command({
1745
+ name: "kick",
1746
+ description: "Kick a member",
1747
+ options: { who: option.user({ description: "Member", required: true }) },
1748
+ run: async (ctx) => {
1749
+ if (!ctx.guild) return ctx.error("This command only works in a server.");
1750
+ await ctx.reply(`Kicked ${ctx.options.who}.`);
1751
+ },
1752
+ });
1753
+ ```
1754
+
1755
+ ## Preset embeds
1756
+
1757
+ `BaseContext` builds consistent, colored embeds from `client.embeds` (or a shared
1758
+ default). Each takes an `EmbedPresetInput` — a plain string, or a structured body
1759
+ (`{ title?, description?, fields?, footer?, ... }`) — and an optional
1760
+ `{ ephemeral? }`.
1761
+
1762
+ | Method | Sends via | Default visibility |
1763
+ | ------ | --------- | ------------------ |
1764
+ | `success(input, options?)` | `send` (state-aware) | public |
1765
+ | `info(input, options?)` | `send` (state-aware) | public |
1766
+ | `warn(input, options?)` | `send` (state-aware) | public |
1767
+ | `error(input, options?)` | `send` (state-aware) | **ephemeral** |
1768
+ | `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `reply` (initial only) | public |
1769
+ | `replyError(input, options?)` | `reply` (initial only) | **ephemeral** |
1770
+
1771
+ ```ts
1772
+ import { command } from "spearkit";
1773
+
1774
+ export default command({
1775
+ name: "save",
1776
+ description: "Save settings",
1777
+ run: async (ctx) => {
1778
+ await ctx.success("Settings saved."); // green embed, public
1779
+ await ctx.warn({ title: "Heads up", description: "Quota is almost full." });
1780
+ // error defaults to ephemeral; make it public with { ephemeral: false }:
1781
+ // await ctx.error("Failed to save.", { ephemeral: false });
1782
+ },
1783
+ });
1784
+ ```
1785
+
1786
+ Configure the colors/icons with the client `embeds` option; see the
1787
+ [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#embeds--preset-replies).
1788
+
1789
+ ## The `{ ephemeral: true }` shortcut
1790
+
1791
+ discord.js represents an ephemeral reply with `flags: MessageFlags.Ephemeral`.
1792
+ spearkit lets you write the more obvious `{ ephemeral: true }` on any reply payload
1793
+ and maps it to that flag for you. The input type is `ReplyInput`
1794
+ (`string | ReplyData`), where `ReplyData` is discord.js'
1795
+ `InteractionReplyOptions` plus the optional `ephemeral` boolean.
1796
+
1797
+ ```ts
1798
+ import { command, EmbedBuilder } from "spearkit";
1799
+
1800
+ export default command({
1801
+ name: "secret",
1802
+ description: "Only you can see this",
1803
+ run: (ctx) =>
1804
+ ctx.reply({
1805
+ embeds: [new EmbedBuilder().setTitle("Just for you")],
1806
+ ephemeral: true, // mapped to MessageFlags.Ephemeral
1807
+ }),
1808
+ });
1809
+ ```
1810
+
1811
+ `replyEphemeral(input)` is sugar for the same thing, accepting either a string
1812
+ or a payload:
1813
+
1814
+ ```ts
1815
+ await ctx.replyEphemeral("Saved.");
1816
+ await ctx.replyEphemeral({ embeds: [embed] });
1817
+ ```
1818
+
1819
+ If you set `flags` yourself, spearkit preserves them and adds the ephemeral flag
1820
+ rather than overwriting it.
1821
+
1822
+ ### Exported helpers
1823
+
1824
+ spearkit exports the two functions it uses internally, so you can normalise reply
1825
+ input yourself (e.g. in a plugin or shared utility):
1826
+
1827
+ - `normalizeReply(input: ReplyInput): InteractionReplyOptions` — converts a
1828
+ string or `ReplyData` into a discord.js reply payload, applying the ephemeral
1829
+ flag mapping.
1830
+ - `asEphemeral(input: ReplyInput): ReplyData` — marks any input ephemeral,
1831
+ regardless of how it was passed.
1832
+
1833
+ ```ts
1834
+ import { normalizeReply, asEphemeral } from "spearkit";
1835
+
1836
+ normalizeReply("hi");
1837
+ // → { content: "hi" }
1838
+
1839
+ normalizeReply({ content: "hi", ephemeral: true });
1840
+ // → { content: "hi", flags: MessageFlags.Ephemeral }
1841
+
1842
+ asEphemeral("hidden");
1843
+ // → { content: "hidden", ephemeral: true }
1844
+ ```
1845
+
1846
+ ## Accessors
1847
+
1848
+ `BaseContext` forwards the common interaction fields so you do not reach through
1849
+ `ctx.interaction` for everyday data:
1850
+
1851
+ | Accessor | Description |
1852
+ | -------- | ----------- |
1853
+ | `interaction` | The raw discord.js interaction. |
1854
+ | `client` | The `SpearClient` (typed as the interaction's client). |
1855
+ | `user` | The invoking `User`. |
1856
+ | `member` | The invoking guild member (or `null` outside a guild). |
1857
+ | `guild` | The `Guild`, or `null` in DMs. |
1858
+ | `guildId` | The guild id, or `null`. |
1859
+ | `channel` | The channel the interaction came from. |
1860
+ | `channelId` | The channel id. |
1861
+ | `locale` | The user's locale. |
1862
+ | `deferred` | Whether the interaction is already deferred. |
1863
+ | `replied` | Whether the interaction already received an initial response. |
1864
+ | `botPermissions` | The bot's resolved permissions in the channel (`PermissionsBitField`, zero-fetch). |
1865
+
1866
+ ```ts
1867
+ import { command } from "spearkit";
1868
+
1869
+ export default command({
1870
+ name: "whereami",
1871
+ description: "Report context",
1872
+ run: (ctx) =>
1873
+ ctx.reply(
1874
+ ctx.guild
1875
+ ? `In ${ctx.guild.name} (#${ctx.channelId}), locale ${ctx.locale}.`
1876
+ : "We're in a DM.",
1877
+ ),
1878
+ });
1879
+ ```
1880
+
1881
+ `deferred` and `replied` let you branch when you are not using `send`:
1882
+
1883
+ ```ts
1884
+ import { button } from "spearkit";
1885
+
1886
+ export default button({
1887
+ id: "refresh",
1888
+ label: "Refresh",
1889
+ run: async (ctx) => {
1890
+ if (ctx.replied || ctx.deferred) await ctx.followUp("Refreshed.");
1891
+ else await ctx.reply("Refreshed.");
1892
+ },
1893
+ });
1894
+ ```
1895
+
1896
+ ## Permission preflights
1897
+
1898
+ `BaseContext` reads the permissions Discord already attached to the interaction —
1899
+ no extra fetches — so you can check before attempting a privileged action:
1900
+
1901
+ ```ts
1902
+ import { command, PermissionFlagsBits } from "spearkit";
1903
+
1904
+ export default command({
1905
+ name: "slowmode",
1906
+ description: "Set slowmode",
1907
+ run: async (ctx) => {
1908
+ const missing = ctx.botMissing(PermissionFlagsBits.ManageChannels);
1909
+ if (missing.length > 0) return ctx.error(`I'm missing: ${missing.join(", ")}`);
1910
+ // …apply slowmode…
1911
+ },
1912
+ });
1913
+ ```
1914
+
1915
+ - `ctx.botPermissions` — the bot's `PermissionsBitField` in the current channel.
1916
+ - `ctx.botMissing(required)` — permission names the bot lacks here (`[]` if none).
1917
+ - `ctx.userMissing(required)` — permission names the invoking user lacks here.
1918
+
1919
+ For role-hierarchy and moderation preflights (acting on self/owner, comparing top
1920
+ roles) see `moderationCheck` and the permission helpers in the
1921
+ [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#permissions--moderation).
1922
+
1923
+ ## Awaiting input
1924
+
1925
+ When a flow needs a follow-up message or a modal, the context wraps discord.js
1926
+ collectors so you skip the boilerplate. Both resolve to `null` on timeout.
1927
+
1928
+ ```ts
1929
+ import { command, modal, textInput } from "spearkit";
1930
+
1931
+ const nameModal = modal({ id: "name", title: "Your name", fields: { name: textInput({ label: "Name" }) }, run: () => {} });
1932
+
1933
+ export default command({
1934
+ name: "setup",
1935
+ description: "Interactive setup",
1936
+ run: async (ctx) => {
1937
+ // Wait for the user to type an answer in this channel:
1938
+ const reply = await ctx.awaitMessageFrom(ctx.user.id, { time: 30_000 });
1939
+ if (reply === null) return ctx.error("Timed out.");
1940
+ // Or show a modal and await its submission:
1941
+ const submission = await ctx.awaitModal(nameModal);
1942
+ if (submission !== null) await submission.reply(`Hi, ${submission.fields.getTextInputValue("name")}!`);
1943
+ },
1944
+ });
1945
+ ```
1946
+
1947
+ The standalone `awaitMessage`, `awaitComponent` and `showAndAwaitModal` helpers
1948
+ are also exported; see the [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#collectors).
1949
+
1950
+ ## See also
1951
+
1952
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — `CommandContext`, options and `showModal`.
1953
+ - [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — button, select and modal contexts.
1954
+
1955
+ ---
1956
+
1957
+ # Guards
1958
+
1959
+ Guards are declarative **preconditions** that run before a handler. They work
1960
+ uniformly across slash commands, components (buttons, selects, modals), prefix
1961
+ commands and context-menu commands — and can also be applied client-wide. A
1962
+ guard returns `true` to allow the handler, or a denial (with an optional reason)
1963
+ to block it; spearkit replies with the reason and the handler never runs.
1964
+
1965
+ ```ts
1966
+ import { command, requireUserPermissions, PermissionFlagsBits } from "spearkit";
1967
+
1968
+ export const purge = command({
1969
+ name: "purge",
1970
+ description: "Bulk-delete messages",
1971
+ guards: [requireUserPermissions(PermissionFlagsBits.ManageMessages)],
1972
+ run: (ctx) => ctx.reply("Purged."),
1973
+ });
1974
+ ```
1975
+
1976
+ ## Where guards attach
1977
+
1978
+ Pass `guards: [...]` to any handler definition, or set client-wide defaults that
1979
+ run before every handler's own guards.
1980
+
1981
+ ```ts
1982
+ import {
1983
+ SpearClient,
1984
+ command,
1985
+ button,
1986
+ prefixCommand,
1987
+ userCommand,
1988
+ guildOnly,
1989
+ } from "spearkit";
1990
+
1991
+ // Per-handler — on commands, components, prefix and context-menu commands.
1992
+ command({ name: "kick", description: "…", guards: [guildOnly()], run: () => {} });
1993
+ button({ id: "del:{id}", guards: [guildOnly()], run: () => {} });
1994
+ prefixCommand({ name: "ban", guards: [guildOnly()], run: () => {} });
1995
+ userCommand({ name: "Report", guards: [guildOnly()], run: () => {} });
1996
+
1997
+ // Client-wide — applied before each handler's own guards.
1998
+ const client = new SpearClient({ guards: [guildOnly()] });
1999
+ ```
2000
+
2001
+ Client-wide guards run first; if they pass, the handler's own guards run next.
2002
+ The first denial short-circuits the rest.
2003
+
2004
+ ## Built-in guards
2005
+
2006
+ Each built-in returns a `Guard` and accepts an optional custom `reason`. When
2007
+ omitted, a sensible default message is used (shown below).
2008
+
2009
+ | Guard | Denies unless… | Default reason |
2010
+ | ----- | -------------- | -------------- |
2011
+ | `guildOnly(reason?)` | used inside a guild | `"This can only be used in a server."` |
2012
+ | `dmOnly(reason?)` | used in a DM | `"This can only be used in DMs."` |
2013
+ | `requireAnyRole(roleIds, reason?)` | the member holds **any** of `roleIds` | `"You don't have permission to use this."` |
2014
+ | `requireAllRoles(roleIds, reason?)` | the member holds **every** id in `roleIds` | `"You're missing one of the required roles."` |
2015
+ | `requireOwner(ownerIds, reason?)` | the user id is in `ownerIds` | `"This is owner-only."` |
2016
+ | `requireUserPermissions(permission, reason?)` | the member has the Discord `permission` | `"You don't have permission to use this."` |
2017
+ | `requireBotPermissions(permission, reason?)` | the bot's member has the Discord `permission` | `"I don't have permission to do that here."` |
2018
+
2019
+ ```ts
2020
+ import {
2021
+ command,
2022
+ requireAnyRole,
2023
+ requireBotPermissions,
2024
+ PermissionFlagsBits,
2025
+ } from "spearkit";
2026
+
2027
+ export const announce = command({
2028
+ name: "announce",
2029
+ description: "Post an announcement",
2030
+ guards: [
2031
+ requireAnyRole(["111111111111111111"], "Staff only."),
2032
+ requireBotPermissions(PermissionFlagsBits.SendMessages),
2033
+ ],
2034
+ run: (ctx) => ctx.reply("Announced."),
2035
+ });
2036
+ ```
2037
+
2038
+ ## Custom guards
2039
+
2040
+ `guard(predicate)` wraps an inline predicate so a one-off check still types as a
2041
+ `Guard`. The predicate receives a `GuardContext` and returns a `GuardResult`;
2042
+ use `denied(reason?)` to build a denial.
2043
+
2044
+ ```ts
2045
+ import { command, guard, denied } from "spearkit";
2046
+
2047
+ const cooldownOver = guard((ctx) =>
2048
+ isReady(ctx.user.id) ? true : denied("Still warming up — try again soon."),
2049
+ );
2050
+
2051
+ export const cast = command({
2052
+ name: "cast",
2053
+ description: "Cast a spell",
2054
+ guards: [cooldownOver],
2055
+ run: (ctx) => ctx.reply("✨"),
2056
+ });
2057
+ ```
2058
+
2059
+ `GuardContext` exposes the actor/location fields every handler shares, so the
2060
+ same guard works on commands, components, prefix and context-menu handlers:
2061
+
2062
+ ```ts
2063
+ interface GuardContext {
2064
+ client: Client;
2065
+ user: User;
2066
+ member: GuildMember | APIInteractionGuildMember | null;
2067
+ guild: Guild | null;
2068
+ guildId: string | null;
2069
+ channelId: string | null;
2070
+ }
2071
+
2072
+ type GuardResult = boolean | { allowed: false; reason?: string };
2073
+ type Guard<TCtx extends GuardContext = GuardContext> = (ctx: TCtx) => Awaitable<GuardResult>;
2074
+ ```
2075
+
2076
+ ## Running guards manually
2077
+
2078
+ `runGuards(ctx, guards)` evaluates a list in order and short-circuits on the
2079
+ first denial — useful if you build your own dispatch on top of spearkit.
2080
+
2081
+ ```ts
2082
+ import { runGuards, guildOnly } from "spearkit";
2083
+
2084
+ const result = await runGuards(ctx, [guildOnly()]);
2085
+ if (!result.allowed) {
2086
+ // result.reason is the denial message (or undefined)
2087
+ }
2088
+ ```
2089
+
2090
+ `runGuards` resolves to `RunGuardsResult`:
2091
+
2092
+ ```ts
2093
+ type RunGuardsResult = { allowed: true } | { allowed: false; reason: string | undefined };
2094
+ ```
2095
+
2096
+ ## See also
2097
+
2098
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — `guards` on slash commands.
2099
+ - [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — `guards` on buttons, selects and modals.
2100
+ - [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md) — `guards` on text commands.
2101
+ - [Context menus](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context-menus.md) — `guards` on "Apps" actions.
2102
+ - [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — the other built-in precondition.
2103
+
2104
+ ---
2105
+
2106
+ # Permissions & hierarchy
2107
+
2108
+ Moderation commands fail in two predictable ways: the bot lacks a permission in
2109
+ the channel (`Missing Permissions`, 50013), or the target sits above the bot (or
2110
+ the moderator) in the role list. Both are checkable *before* you act, so you can
2111
+ bail out with a clear message instead of a half-finished action and an exception.
2112
+
2113
+ ## Did the bot/user get the permissions? (zero-fetch)
2114
+
2115
+ Every interaction carries the bot's and the invoker's resolved permissions for
2116
+ the current channel. `ctx.botMissing(...)` / `ctx.userMissing(...)` read them
2117
+ with no API calls and return the **missing** flag names:
2118
+
2119
+ ```ts
2120
+ import { PermissionFlagsBits, command, formatPermissions } from "spearkit";
2121
+
2122
+ export const slowmode = command({
2123
+ name: "slowmode",
2124
+ description: "Set channel slowmode",
2125
+ run: async (ctx) => {
2126
+ const missing = ctx.botMissing(PermissionFlagsBits.ManageChannels);
2127
+ if (missing.length > 0) return ctx.error(`I need: ${formatPermissions(missing)}`);
2128
+ // …
2129
+ },
2130
+ });
2131
+ ```
2132
+
2133
+ `formatPermissions(...)` renders flag names as a friendly list
2134
+ (`"Manage Channels, Ban Members"`).
2135
+
2136
+ ## Permissions in another channel
2137
+
2138
+ For a channel other than the current one, use the standalone helpers:
2139
+
2140
+ ```ts
2141
+ import { botMissingPermissions, hasPermissions, missingPermissions } from "spearkit";
2142
+
2143
+ const missing = botMissingPermissions(targetChannel, [PermissionFlagsBits.SendMessages]);
2144
+ if (missing.length > 0) return ctx.error("I can't post there.");
2145
+
2146
+ // or for a specific member/role:
2147
+ hasPermissions(targetChannel, member, PermissionFlagsBits.ViewChannel); // boolean
2148
+ missingPermissions(targetChannel, role, [PermissionFlagsBits.Connect]); // PermissionsString[]
2149
+ ```
2150
+
2151
+ ## Role hierarchy
2152
+
2153
+ `moderationCheck(...)` validates both the moderator and the bot against a target,
2154
+ returning a ready-to-show reason on the first failing rule (self, server owner,
2155
+ moderator hierarchy, bot hierarchy):
2156
+
2157
+ ```ts
2158
+ import { moderationCheck } from "spearkit";
2159
+
2160
+ const moderator = await ctx.guild!.members.fetch(ctx.user.id);
2161
+ const check = moderationCheck({ moderator, target, action: "ban" });
2162
+ if (!check.ok) return ctx.error(check.reason);
2163
+ await target.ban();
2164
+ ```
2165
+
2166
+ The `me` (bot) member defaults to `target.guild.members.me`; pass `me: null` to
2167
+ skip the bot check. `action` is the verb used in messages (default `"moderate"`).
2168
+
2169
+ Lower-level primitives are exported too:
2170
+
2171
+ - `canActOn(actor, target)` — boolean: not self, target isn't the owner, actor is
2172
+ the owner or outranks the target.
2173
+ - `compareRoles(a, b)` — highest-role position comparison (`>0`, `<0`, `0`).
2174
+
2175
+ ---
2176
+
2177
+ # Auto-defer
2178
+
2179
+ The single most common discord.js error is
2180
+ `DiscordAPIError[10062]: Unknown interaction`. An interaction token is valid for
2181
+ only **3 seconds** before your first response; any handler that awaits a database
2182
+ query or an HTTP call risks blowing past that window, after which the interaction
2183
+ is dead and every reply throws.
2184
+
2185
+ Auto-defer removes the footgun: spearkit arms a timer when your handler starts
2186
+ and, if you haven't responded in time, calls `deferReply()` for you. The timer is
2187
+ cancelled the instant your handler replies or defers itself.
2188
+
2189
+ ## Per command
2190
+
2191
+ ```ts
2192
+ import { command, option } from "spearkit";
2193
+
2194
+ export const weather = command({
2195
+ name: "weather",
2196
+ description: "Look up the weather",
2197
+ autoDefer: true, // defers automatically if the handler takes too long
2198
+ options: { city: option.string({ description: "City", required: true }) },
2199
+ run: async (ctx) => {
2200
+ const report = await fetchWeather(ctx.options.city); // slow
2201
+ await ctx.send(`Weather in ${ctx.options.city}: ${report}`);
2202
+ },
2203
+ });
2204
+ ```
2205
+
2206
+ > With auto-defer on, respond via `ctx.send(...)` or `ctx.editReply(...)`, not
2207
+ > `ctx.reply(...)` — the initial reply slot may already be taken by the
2208
+ > auto-defer. `ctx.send` is state-aware and always does the right thing.
2209
+
2210
+ ## Options
2211
+
2212
+ `autoDefer` accepts `true` (defaults) or an object:
2213
+
2214
+ ```ts
2215
+ command({
2216
+ name: "report",
2217
+ description: "Generate a report",
2218
+ autoDefer: { ephemeral: true, delayMs: 1500 },
2219
+ run: async (ctx) => ctx.send("…"),
2220
+ });
2221
+ ```
2222
+
2223
+ | Field | Default | Meaning |
2224
+ | --- | --- | --- |
2225
+ | `ephemeral` | `false` | Defer as a hidden ("thinking…") response. |
2226
+ | `delayMs` | `2000` | How long to wait before the safety defer fires. Kept under the 3s cutoff. |
2227
+
2228
+ ## Client-wide default
2229
+
2230
+ Apply auto-defer to **every** slash command and context menu; each handler can
2231
+ still override with its own `autoDefer`.
2232
+
2233
+ ```ts
2234
+ import { SpearClient } from "spearkit";
2235
+
2236
+ const client = new SpearClient({ autoDefer: true });
2237
+ ```
2238
+
2239
+ ## Scope
2240
+
2241
+ Auto-defer covers slash commands and context menus (answered with `deferReply`).
2242
+ Component handlers (buttons/selects) usually respond instantly with `update`, so
2243
+ they're not auto-deferred — call `ctx.deferUpdate()` yourself if a component
2244
+ handler does slow work.
2245
+
2246
+ ## Lower-level helpers
2247
+
2248
+ `normalizeAutoDefer(input)` resolves `true`/object/`undefined` into an
2249
+ `AutoDeferConfig`; `armAutoDefer(interaction, config)` arms the timer and returns
2250
+ a cancel function. Both are exported for custom dispatch.
2251
+
2252
+ ---
2253
+
2254
+ # Cooldowns
2255
+
2256
+ Rate-limit commands per user, per guild, per channel, or globally — with per-role
2257
+ and per-user exemptions and overrides.
2258
+ Cooldowns are enforced automatically by command dispatch: when an actor is
2259
+ still on cooldown, spearkit replies (ephemerally) with a message and the
2260
+ handler does not run.
2261
+
2262
+ ## Per-command
2263
+
2264
+ Pass a number (milliseconds) or a full config to any command:
2265
+
2266
+ ```ts
2267
+ import { command } from "spearkit";
2268
+
2269
+ export const daily = command({
2270
+ name: "daily",
2271
+ description: "Claim your daily reward",
2272
+ cooldown: 86_400_000, // once per day, per user
2273
+ run: (ctx) => ctx.reply("Reward claimed!"),
2274
+ });
2275
+ ```
2276
+
2277
+ ## Client-wide default
2278
+
2279
+ A default applies to every command; a command's own `cooldown` overrides it.
2280
+
2281
+ ```ts
2282
+ import { SpearClient } from "spearkit";
2283
+
2284
+ const client = new SpearClient({ cooldown: { duration: 3000 } });
2285
+ ```
2286
+
2287
+ ## Scope
2288
+
2289
+ `scope` controls what the cooldown is keyed on. Default `"user"`.
2290
+
2291
+ ```ts
2292
+ command({
2293
+ name: "announce",
2294
+ description: "Post an announcement",
2295
+ cooldown: { duration: 60_000, scope: "guild" }, // one per guild per minute
2296
+ run: (ctx) => ctx.reply("Announced."),
2297
+ });
2298
+ ```
2299
+
2300
+ | Scope | Keyed on |
2301
+ | --- | --- |
2302
+ | `user` | the invoking user (default) |
2303
+ | `guild` | the guild |
2304
+ | `channel` | the channel |
2305
+ | `global` | everyone shares one bucket |
2306
+
2307
+ ## Exemptions — who waits and who doesn't
2308
+
2309
+ `exempt` lists users and roles that bypass the cooldown entirely.
2310
+
2311
+ ```ts
2312
+ command({
2313
+ name: "purge",
2314
+ description: "Bulk delete",
2315
+ cooldown: {
2316
+ duration: 10_000,
2317
+ exempt: { roles: ["111111111111111111"], users: ["222222222222222222"] },
2318
+ },
2319
+ run: (ctx) => ctx.reply("Purged."),
2320
+ });
2321
+ ```
2322
+
2323
+ ## Per-role / per-user overrides
2324
+
2325
+ `overrides` gives specific roles or users a different duration (milliseconds).
2326
+ A user override beats role overrides; among matching roles the most lenient
2327
+ (shortest) duration wins. Use `0` to effectively disable the wait for them.
2328
+
2329
+ ```ts
2330
+ command({
2331
+ name: "search",
2332
+ description: "Search the archive",
2333
+ cooldown: {
2334
+ duration: 10_000, // everyone else
2335
+ overrides: {
2336
+ roles: { "333333333333333333": 2_000 }, // VIP role: 2s
2337
+ users: { "444444444444444444": 0 }, // this user: no wait
2338
+ },
2339
+ },
2340
+ run: (ctx) => ctx.reply("Searching…"),
2341
+ });
2342
+ ```
2343
+
2344
+ ## The message
2345
+
2346
+ `message` customises what blocked users see — a string, or a function of the
2347
+ remaining milliseconds.
2348
+
2349
+ ```ts
2350
+ command({
2351
+ name: "spin",
2352
+ description: "Spin the wheel",
2353
+ cooldown: {
2354
+ duration: 5_000,
2355
+ message: (ms) => `Hold on — ${Math.ceil(ms / 1000)}s to go.`,
2356
+ },
2357
+ run: (ctx) => ctx.reply("🎡"),
2358
+ });
2359
+ ```
2360
+
2361
+ ## The manager
2362
+
2363
+ `client.cooldowns` is the shared `CooldownManager` (also used by
2364
+ [prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md)). Use it directly for custom flows:
2365
+
2366
+ ```ts
2367
+ const result = client.cooldowns.consume("vote", 5_000, {
2368
+ userId: "1",
2369
+ roleIds: [],
2370
+ guildId: null,
2371
+ channelId: null,
2372
+ });
2373
+ if (!result.allowed) console.log(`wait ${result.remaining}ms`);
2374
+ ```
2375
+
2376
+ `consume` records the use and returns `{ allowed: true }` or
2377
+ `{ allowed: false, remaining }`. `peek` checks without recording; `reset` and
2378
+ `clear` drop tracked cooldowns.
2379
+
2380
+ ---
2381
+
2382
+ # Scheduled tasks
2383
+
2384
+ Run work on a cron schedule or a fixed interval. The client starts the
2385
+ scheduler when it becomes ready and stops it on `destroy()`, so timers never
2386
+ outlive your bot.
2387
+
2388
+ ## Define a task
2389
+
2390
+ Provide exactly one of `cron` or `interval` (if both are set, the interval is used):
2391
+
2392
+ ```ts
2393
+ import { task } from "spearkit";
2394
+
2395
+ export const heartbeat = task({
2396
+ name: "heartbeat",
2397
+ interval: 60_000, // every minute
2398
+ runOnStart: true, // also run once on startup
2399
+ run: (client) => client.logger.info("still alive"),
2400
+ });
2401
+ ```
2402
+
2403
+ Register it like anything else:
2404
+
2405
+ ```ts
2406
+ client.register(heartbeat);
2407
+ ```
2408
+
2409
+ Or define and register in one call:
2410
+
2411
+ ```ts
2412
+ client.schedule({
2413
+ name: "cleanup",
2414
+ cron: "0 3 * * *", // 03:00 local time, every day
2415
+ run: async (client) => {
2416
+ // …purge expired records…
2417
+ },
2418
+ });
2419
+ ```
2420
+
2421
+ ## Cron syntax
2422
+
2423
+ Standard 5-field expressions, evaluated in the host's **local** time:
2424
+
2425
+ ```
2426
+ ┌─ minute (0-59)
2427
+ │ ┌─ hour (0-23)
2428
+ │ │ ┌─ day of month (1-31)
2429
+ │ │ │ ┌─ month (1-12)
2430
+ │ │ │ │ ┌─ day of week (0-6, Sunday = 0)
2431
+ │ │ │ │ │
2432
+ * * * * *
2433
+ ```
2434
+
2435
+ Each field supports `*`, ranges (`1-5`), lists (`1,3,5`) and steps (`*/15`).
2436
+ When both day-of-month and day-of-week are restricted, a date matches if
2437
+ **either** does (standard cron behaviour).
2438
+
2439
+ Aliases: `@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`, `@hourly`.
2440
+
2441
+ ```ts
2442
+ task({ name: "report", cron: "@daily", run: () => {} });
2443
+ task({ name: "poll", cron: "*/5 * * * *", run: () => {} }); // every 5 minutes
2444
+ task({ name: "mondays", cron: "0 9 * * 1", run: () => {} }); // Mon 09:00
2445
+ ```
2446
+
2447
+ Compute the next run yourself with `cron`:
2448
+
2449
+ ```ts
2450
+ import { cron } from "spearkit";
2451
+
2452
+ const next = cron("*/15 * * * *").next(new Date());
2453
+ ```
2454
+
2455
+ ## One-shot jobs, follow-ups and on-ready recovery
2456
+
2457
+ Beyond recurring tasks, the scheduler runs one-shot timers (they `unref()`
2458
+ themselves, so they never keep the process alive) and a once-on-ready reconciler.
2459
+
2460
+ ```ts
2461
+ // Run once after a delay; returns a cancel handle.
2462
+ const handle = client.scheduler.delay("remind", 10 * 60_000, async () => {
2463
+ // …remind the moderator if nothing happened…
2464
+ });
2465
+ handle.cancel(); // true if it was still pending
2466
+
2467
+ // A series of fires measured from "now"; the callback gets the fire index.
2468
+ client.scheduler.followUp("escalate", [10_000, 30_000, 60_000], (i) => {
2469
+ // i = 0, then 1, then 2
2470
+ });
2471
+
2472
+ // Run once the first time the scheduler starts (typically on clientReady) and
2473
+ // never again — ideal for restart recovery.
2474
+ client.scheduler.reconcile("voice-sessions", async (client) => {
2475
+ // …close orphaned voice sessions, reapply cached state…
2476
+ });
2477
+ ```
2478
+
2479
+ ## The scheduler
2480
+
2481
+ `client.scheduler` is the `TaskScheduler`:
2482
+
2483
+ ```ts
2484
+ client.scheduler.size; // number of tasks
2485
+ client.scheduler.active; // started?
2486
+ client.scheduler.list(); // every task
2487
+ client.scheduler.remove("heartbeat"); // cancel + forget
2488
+ client.scheduler.stop(); // cancel all timers
2489
+ ```
2490
+
2491
+ Task errors are caught and logged through `client.logger` (scope `scheduler`),
2492
+ so a throwing task never crashes the process or stops future runs.
2493
+
2494
+ ---
2495
+
2496
+ # Prefix commands
2497
+
2498
+ Alongside slash commands, spearkit can dispatch classic text/prefix commands like
2499
+ `!ping`. You define them with `prefixCommand`, enable them with the client's
2500
+ `prefix` option, and spearkit parses each `messageCreate` for you — matching the
2501
+ prefix, splitting arguments, and routing to the right handler.
2502
+
2503
+ ## Enabling prefix commands
2504
+
2505
+ Prefix commands are off until you set the `prefix` option on the client. It
2506
+ accepts a string, an array of strings, or a `PrefixOptions` object:
2507
+
2508
+ ```ts
2509
+ import { Intents, SpearClient } from "spearkit";
2510
+
2511
+ // A single prefix.
2512
+ new SpearClient({ intents: Intents.messages, prefix: "!" });
2513
+
2514
+ // Several prefixes.
2515
+ new SpearClient({ intents: Intents.messages, prefix: ["!", "?"] });
2516
+
2517
+ // Full control.
2518
+ new SpearClient({
2519
+ intents: Intents.messages,
2520
+ prefix: {
2521
+ prefix: "!",
2522
+ mention: true, // also trigger on a leading @bot mention (default true)
2523
+ ignoreBots: true, // skip messages authored by bots (default true)
2524
+ caseInsensitive: true, // match command names ignoring case (default true)
2525
+ },
2526
+ });
2527
+ ```
2528
+
2529
+ ### Dynamic (per-guild) prefixes
2530
+
2531
+ Pass `dynamic` to resolve extra prefix(es) per message — for example a custom
2532
+ per-guild prefix from a database or [`createSettings`](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#persistent-storage).
2533
+ Dynamic prefixes are tried in addition to any static `prefix`; return
2534
+ `null`/`undefined` for none. It runs on every candidate message, so keep it fast
2535
+ (and cached).
2536
+
2537
+ ```ts
2538
+ new SpearClient({
2539
+ intents: Intents.messages,
2540
+ prefix: {
2541
+ prefix: "!", // static fallback
2542
+ dynamic: async (message) =>
2543
+ message.guildId ? await settings.get(message.guildId).then((s) => s.prefix) : null,
2544
+ },
2545
+ });
2546
+ ```
2547
+
2548
+ ## You need the MessageContent intent
2549
+
2550
+ Reading the text of other users' messages is a **privileged** gateway intent.
2551
+ Without `MessageContent` your bot still receives `messageCreate`, but
2552
+ `message.content` arrives empty for messages it was not mentioned in or did not
2553
+ author — so no prefix command will ever match.
2554
+
2555
+ Use the `Intents.messages` preset, which includes `Guilds`, `GuildMessages`, and
2556
+ the privileged `MessageContent` bit:
2557
+
2558
+ ```ts
2559
+ import { Intents, SpearClient } from "spearkit";
2560
+
2561
+ const client = new SpearClient({ intents: Intents.messages, prefix: "!" });
2562
+ ```
2563
+
2564
+ You must also toggle **Message Content Intent** on for your application in the
2565
+ Discord Developer Portal, or the gateway will reject the connection.
2566
+
2567
+ ## Defining a command
2568
+
2569
+ `prefixCommand` takes the command name, the handler, and a few optional fields:
2570
+
2571
+ ```ts
2572
+ import { prefixCommand } from "spearkit";
2573
+
2574
+ export const ping = prefixCommand({
2575
+ name: "ping",
2576
+ description: "Check that the bot is alive",
2577
+ run: (ctx) => ctx.reply("Pong!"),
2578
+ });
2579
+ ```
2580
+
2581
+ Register it like anything else, with `client.register(...)`:
2582
+
2583
+ ```ts
2584
+ import { SpearClient } from "spearkit";
2585
+
2586
+ const client = new SpearClient({ prefix: "!" });
2587
+ client.register(ping);
2588
+ ```
2589
+
2590
+ | Field | Type | Effect |
2591
+ | ----- | ---- | ------ |
2592
+ | `name` | `string` | The word after the prefix that triggers the command. |
2593
+ | `aliases` | `string[]` | Extra names that also trigger it. |
2594
+ | `description` | `string` | Human description, for your own help command. |
2595
+ | `cooldown` | `number \| CooldownConfig` | Per-user rate limit (a number is milliseconds). |
2596
+ | `guards` | `readonly Guard[]` | Preconditions run before the handler. See [Guards](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md). |
2597
+ | `args` | `(a) => PrefixArgsBuilder` | Typed argument schema; shapes `ctx.options`. See [Typed arguments](#typed-arguments). |
2598
+ | `run` | `(ctx: PrefixContext) => void \| Promise<void>` | The handler. |
2599
+
2600
+ ## The prefix context
2601
+
2602
+ The handler receives a `PrefixContext`. It wraps the triggering `Message` and
2603
+ adds the parsed arguments plus reply helpers.
2604
+
2605
+ | Member | Description |
2606
+ | ------ | ----------- |
2607
+ | `ctx.message` | The triggering discord.js `Message`. |
2608
+ | `ctx.commandName` | The matched name as the user typed it (an alias if they used one). |
2609
+ | `ctx.args` | Whitespace-split arguments after the command name (`string[]`). |
2610
+ | `ctx.rest` | The raw text after the command name (unsplit). |
2611
+ | `ctx.options` | Typed parsed arguments from the `args` schema (`{}` when none). |
2612
+ | `ctx.author` / `ctx.member` / `ctx.guild` / `ctx.guildId` / `ctx.channel` / `ctx.channelId` | Actor and location accessors. |
2613
+ | `ctx.reply(content)` | Reply to the triggering message. |
2614
+ | `ctx.send(content)` | Send a message to the same channel without a reply reference. |
2615
+
2616
+ ```ts
2617
+ import { prefixCommand } from "spearkit";
2618
+
2619
+ export const echo = prefixCommand({
2620
+ name: "echo",
2621
+ description: "Repeat what you said",
2622
+ run: (ctx) => {
2623
+ if (ctx.args.length === 0) return ctx.reply("Give me something to echo.");
2624
+ // `args` is split on whitespace; `rest` is the untouched remainder.
2625
+ return ctx.reply(ctx.rest);
2626
+ },
2627
+ });
2628
+ ```
2629
+
2630
+ `ctx.args` and `ctx.rest` are two views of the same input: `!say hello world`
2631
+ gives `args === ["hello", "world"]` and `rest === "hello world"`.
2632
+
2633
+ ## Typed arguments
2634
+
2635
+ Pass an `args` schema to parse positional arguments into typed values. Chain
2636
+ builder methods — first token → first arg, second → second, and so on — and read
2637
+ the result from `ctx.options`. Each method requires a name and takes optional
2638
+ settings (`required`, `default`, and per-type bounds).
2639
+
2640
+ ```ts
2641
+ import { prefixCommand } from "spearkit";
2642
+
2643
+ export const mute = prefixCommand({
2644
+ name: "mute",
2645
+ description: "Mute a member",
2646
+ args: (a) =>
2647
+ a
2648
+ .snowflake("target", { required: true }) // raw id or <@mention> → string
2649
+ .duration("duration", { required: true }) // "1h30m" → number (ms)
2650
+ .rest("reason", { default: "No reason given" }), // remaining text → string
2651
+ run: (ctx) => {
2652
+ ctx.options.target; // string
2653
+ ctx.options.duration; // number
2654
+ ctx.options.reason; // string
2655
+ return ctx.reply(`Muted <@${ctx.options.target}> for ${ctx.options.duration}ms.`);
2656
+ },
2657
+ });
2658
+ ```
2659
+
2660
+ Builder methods: `.string`, `.integer`, `.number`, `.boolean`, `.snowflake`,
2661
+ `.duration`, `.rest`. A missing required argument — or a value that fails to
2662
+ parse — makes spearkit reply with an error and skip the handler. Without an `args`
2663
+ schema, `ctx.options` is `{}`; use `ctx.args` / `ctx.rest` for raw access.
2664
+
2665
+ ## Aliases
2666
+
2667
+ List alternative names in `aliases`; any of them triggers the command, and
2668
+ `ctx.commandName` reports whichever the user typed:
2669
+
2670
+ ```ts
2671
+ import { prefixCommand } from "spearkit";
2672
+
2673
+ export const help = prefixCommand({
2674
+ name: "help",
2675
+ aliases: ["h", "commands"],
2676
+ run: (ctx) => ctx.reply(`You used "${ctx.commandName}".`),
2677
+ });
2678
+ ```
2679
+
2680
+ ## Cooldowns
2681
+
2682
+ Prefix commands share the client's cooldown manager (`client.cooldowns`) with
2683
+ slash commands, so the API is identical. Pass `cooldown` as a number of
2684
+ milliseconds or a full `CooldownConfig`:
2685
+
2686
+ ```ts
2687
+ import { prefixCommand } from "spearkit";
2688
+
2689
+ export const daily = prefixCommand({
2690
+ name: "daily",
2691
+ description: "Claim your daily reward",
2692
+ cooldown: 5_000, // one use per user per 5s
2693
+ run: (ctx) => ctx.reply("Reward claimed! Come back soon."),
2694
+ });
2695
+ ```
2696
+
2697
+ When a user is on cooldown, spearkit replies with the remaining time and does not
2698
+ run the handler. A per-command `cooldown` overrides the client-wide `cooldown`
2699
+ default. See [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) for scopes and configuration.
2700
+
2701
+ ## The prefix registry
2702
+
2703
+ `client.prefix` is a `PrefixRegistry`. The client wires it to `messageCreate`,
2704
+ the logger, and the cooldown manager for you, so you rarely call it directly. It
2705
+ is available for introspection and advanced control:
2706
+
2707
+ ```ts
2708
+ client.prefix.get("ping"); // PrefixCommand | undefined (also resolves aliases)
2709
+ client.prefix.list(); // PrefixCommand[] (excludes aliases)
2710
+ client.prefix.size; // number of commands
2711
+ ```
2712
+
2713
+ ### Error handling
2714
+
2715
+ If a handler throws, spearkit catches it, logs it, and calls your error hook if you
2716
+ set one — the process never crashes:
2717
+
2718
+ ```ts
2719
+ client.prefix.onError((error, message) => {
2720
+ console.error(`prefix command failed in #${message.channelId}`, error);
2721
+ });
2722
+ ```
2723
+
2724
+ ## See also
2725
+
2726
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) — slash commands.
2727
+ - [Cooldowns](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md) — the shared rate limiter.
2728
+ - [Usage tracking](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/usage.md) — record who runs which prefix commands.
2729
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — the `prefix` option and intent presets.
2730
+
2731
+ ---
2732
+
2733
+ # Logging
2734
+
2735
+ spearkit ships a small, dependency-free structured logger. Every client owns one
2736
+ at `client.logger`, and spearkit routes its own command, component, event, and
2737
+ gateway errors through it. You can use the same logger for your code, or build a
2738
+ standalone one.
2739
+
2740
+ ## A first logger
2741
+
2742
+ ```ts
2743
+ import { Logger } from "spearkit";
2744
+
2745
+ const log = new Logger(); // level "info", logs to the console
2746
+ log.info("bot starting");
2747
+ log.error("connection lost", { error: new Error("ECONNRESET") });
2748
+ ```
2749
+
2750
+ A logger is constructed from `LoggerOptions`:
2751
+
2752
+ ```ts
2753
+ import { Logger } from "spearkit";
2754
+
2755
+ const log = new Logger({
2756
+ level: "debug", // minimum severity to emit; default "info"
2757
+ scope: "worker", // a prefix attached to every entry
2758
+ // sink: consoleSink (the default)
2759
+ });
2760
+ ```
2761
+
2762
+ ## Levels
2763
+
2764
+ There are four levels, lowest to highest: `debug`, `info`, `warn`, `error`. The
2765
+ logger emits an entry only if its level is at or above the configured threshold.
2766
+ The default threshold is **`info`**, so `debug` entries are suppressed until you
2767
+ lower it. A fifth threshold, `"silent"`, suppresses everything.
2768
+
2769
+ ```ts
2770
+ import { Logger } from "spearkit";
2771
+
2772
+ const log = new Logger(); // threshold "info"
2773
+ log.debug("only visible at debug"); // suppressed by default
2774
+ log.info("visible");
2775
+
2776
+ log.setLevel("debug"); // now debug entries are emitted too
2777
+ log.enabled("debug"); // true
2778
+ log.setLevel("silent"); // suppress everything
2779
+ ```
2780
+
2781
+ | Method | Level | Use for |
2782
+ | ------ | ----- | ------- |
2783
+ | `log.debug(msg, opts?)` | `debug` | Verbose diagnostics, off by default. |
2784
+ | `log.info(msg, opts?)` | `info` | Normal operational messages. |
2785
+ | `log.warn(msg, opts?)` | `warn` | Recoverable problems worth attention. |
2786
+ | `log.error(msg, opts?)` | `error` | Failures; attach the cause via `{ error }`. |
2787
+
2788
+ `log.level` reads the current threshold, `log.setLevel(level)` changes it, and
2789
+ `log.enabled(level)` reports whether an entry of that level would be emitted —
2790
+ handy to guard expensive message construction.
2791
+
2792
+ ## Scopes and child loggers
2793
+
2794
+ `log.child("scope")` returns a child logger whose entries carry an extra scope
2795
+ segment. A child **shares its parent's threshold and sink**, so changing the
2796
+ level on any logger in the tree affects them all.
2797
+
2798
+ ```ts
2799
+ import { Logger } from "spearkit";
2800
+
2801
+ const log = new Logger({ scope: "app" });
2802
+ const db = log.child("db"); // scope "app:db"
2803
+ const cache = db.child("cache"); // scope "app:db:cache"
2804
+
2805
+ db.info("connected");
2806
+ log.setLevel("debug"); // affects log, db, and cache
2807
+ cache.debug("warm"); // now emitted
2808
+ ```
2809
+
2810
+ spearkit uses this internally: the client creates `commands`, `components`,
2811
+ `events`, `scheduler`, `prefix`, and `usage` children off `client.logger`, so
2812
+ every subsystem's output is scoped and a single `setLevel` controls them all.
2813
+
2814
+ ## Structured `data` and `error`
2815
+
2816
+ Both arguments live in the optional second parameter (`LogOptions`):
2817
+
2818
+ ```ts
2819
+ import { Logger } from "spearkit";
2820
+
2821
+ const log = new Logger();
2822
+
2823
+ log.info("command finished", {
2824
+ data: { command: "ping", ms: 12, cached: true },
2825
+ });
2826
+
2827
+ try {
2828
+ throw new Error("kaboom");
2829
+ } catch (cause) {
2830
+ log.error("handler failed", {
2831
+ error: cause instanceof Error ? cause : new Error(String(cause)),
2832
+ data: { command: "purge", guildId: "123" },
2833
+ });
2834
+ }
2835
+ ```
2836
+
2837
+ `data` is a flat record of primitives (`string | number | boolean | bigint |
2838
+ null | undefined`). `error` is an `Error`; the default sink renders its stack.
2839
+
2840
+ ### Coercing unknown throws
2841
+
2842
+ A `catch` binding is `unknown`. `toError(value)` turns any thrown value into an
2843
+ `Error` so it fits `{ error }`:
2844
+
2845
+ ```ts
2846
+ import { Logger, toError } from "spearkit";
2847
+
2848
+ const log = new Logger();
2849
+ try {
2850
+ JSON.parse("{");
2851
+ } catch (cause) {
2852
+ log.error("parse failed", { error: toError(cause) });
2853
+ }
2854
+ ```
2855
+
2856
+ ## Custom sinks
2857
+
2858
+ A sink is `(entry: LogEntry) => void`. The default is `consoleSink`, which writes
2859
+ human-readable lines to the console (stderr for `warn`/`error`). Pass your own to
2860
+ route entries anywhere — JSON lines, a file, an aggregator:
2861
+
2862
+ ```ts
2863
+ import { Logger, type LogEntry } from "spearkit";
2864
+
2865
+ const log = new Logger({
2866
+ level: "debug",
2867
+ sink: (entry: LogEntry) => {
2868
+ process.stdout.write(
2869
+ JSON.stringify({
2870
+ level: entry.level,
2871
+ message: entry.message,
2872
+ scope: entry.scope,
2873
+ at: entry.timestamp.toISOString(),
2874
+ data: entry.data,
2875
+ error: entry.error?.message,
2876
+ }) + "\n",
2877
+ );
2878
+ },
2879
+ });
2880
+
2881
+ log.child("commands").info("dispatched", { data: { name: "ping" } });
2882
+ ```
2883
+
2884
+ A `LogEntry` is the fully-resolved record handed to the sink:
2885
+
2886
+ | Field | Type | Notes |
2887
+ | ----- | ---- | ----- |
2888
+ | `level` | `LogLevel` | One of `debug`/`info`/`warn`/`error`. |
2889
+ | `message` | `string` | The log message. |
2890
+ | `scope` | `string \| undefined` | The accumulated scope, if any. |
2891
+ | `timestamp` | `Date` | When the entry was created. |
2892
+ | `error` | `Error \| undefined` | The attached error, if any. |
2893
+ | `data` | `Record<string, LogValue> \| undefined` | The structured metadata, if any. |
2894
+
2895
+ ## Configuring via the client
2896
+
2897
+ Pass `logger` to `SpearClient`. Give it `LoggerOptions` to build one, or a
2898
+ `Logger` instance you already have:
2899
+
2900
+ ```ts
2901
+ import { SpearClient, Logger } from "spearkit";
2902
+
2903
+ // Build from options:
2904
+ const a = new SpearClient({ logger: { level: "debug" } });
2905
+
2906
+ // Or reuse an instance (e.g. one shared with non-Discord code):
2907
+ const shared = new Logger({ level: "info", scope: "svc" });
2908
+ const b = new SpearClient({ logger: shared });
2909
+
2910
+ a.logger.info("ready");
2911
+ ```
2912
+
2913
+ The client logs all command, component, and event handler errors plus gateway
2914
+ errors through `client.logger`. Set `level: "debug"` to see dispatch traces from
2915
+ every subsystem:
2916
+
2917
+ ```ts
2918
+ import { SpearClient } from "spearkit";
2919
+
2920
+ const client = new SpearClient({ logger: { level: "debug" } });
2921
+ // client.logger.child("commands"), ".child('events')", etc. all log at debug now
2922
+ ```
2923
+
2924
+ ## See also
2925
+
2926
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — the `logger` and other construction options.
2927
+ - [Environment & dotenv](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/env.md) — load configuration before you start.
2928
+
2929
+ ---
2930
+
2931
+ # Discord API errors
2932
+
2933
+ discord.js reports REST failures as `DiscordAPIError` with a numeric `code`
2934
+ (`10008` "Unknown Message", `50013` "Missing Permissions", `50007` "Cannot send
2935
+ DMs to this user", …). Catching *everything* turns recoverable failures — a
2936
+ deleted message, a closed DM — into crashes or scary stack traces. spearkit gives
2937
+ you named codes, a type-narrowing predicate, and a friendly explanation.
2938
+
2939
+ ## Recognise and recover
2940
+
2941
+ `isDiscordError(err, code?)` narrows the throw and optionally matches a code
2942
+ (or a list). Perfect for "ignore this one, re-throw the rest":
2943
+
2944
+ ```ts
2945
+ import { DiscordErrorCode, isDiscordError } from "spearkit";
2946
+
2947
+ try {
2948
+ await message.delete();
2949
+ } catch (err) {
2950
+ if (isDiscordError(err, DiscordErrorCode.UnknownMessage)) return; // already gone
2951
+ throw err;
2952
+ }
2953
+ ```
2954
+
2955
+ ```ts
2956
+ // match any of several codes
2957
+ if (isDiscordError(err, [DiscordErrorCode.UnknownChannel, DiscordErrorCode.MissingAccess])) {
2958
+ return;
2959
+ }
2960
+ ```
2961
+
2962
+ ## Friendly messages
2963
+
2964
+ `explainDiscordError(err)` returns an end-user-appropriate sentence for a
2965
+ recognised failure, or `null` otherwise (fall back to a generic message + log):
2966
+
2967
+ ```ts
2968
+ import { explainDiscordError } from "spearkit";
2969
+
2970
+ catch (err) {
2971
+ await ctx.error(explainDiscordError(err) ?? "Something went wrong.");
2972
+ }
2973
+ ```
2974
+
2975
+ spearkit already routes its own command/context-menu errors through
2976
+ `explainDiscordError`, so a handler that throws `Missing Permissions` shows the
2977
+ user *"I'm missing the permissions needed to do that."* instead of a generic
2978
+ error.
2979
+
2980
+ ## Named codes
2981
+
2982
+ `DiscordErrorCode` is a curated map of the codes bots actually hit:
2983
+
2984
+ | Name | Code | When |
2985
+ | --- | --- | --- |
2986
+ | `UnknownChannel` | 10003 | Channel gone/invisible |
2987
+ | `UnknownMessage` | 10008 | Message deleted |
2988
+ | `UnknownMember` | 10007 | Member left |
2989
+ | `UnknownInteraction` | 10062 | Token expired (the 3s window) |
2990
+ | `MissingAccess` | 50001 | No access to the resource |
2991
+ | `CannotSendMessagesToThisUser` | 50007 | DMs closed / blocked |
2992
+ | `MissingPermissions` | 50013 | Missing a permission |
2993
+ | `InteractionHasAlreadyBeenAcknowledged` | 40060 | Double-acked |
2994
+
2995
+ (See the type for the full set — it mirrors discord.js' `RESTJSONErrorCodes`.)
2996
+
2997
+ ## Transport & rate-limit errors
2998
+
2999
+ - `isHTTPError(err)` — a transport-level `HTTPError` (timeout, 5xx, aborted): an
3000
+ HTTP status with no Discord JSON code.
3001
+ - `isRateLimitError(err)` — a `DiscordAPIError` with HTTP status `429`.
3002
+ `explainDiscordError` handles this case first, returning a "try again in a
3003
+ moment" message.
3004
+
3005
+ ---
3006
+
3007
+ # Usage tracking
3008
+
3009
+ Usage tracking records **who used what**: every command, component, context-menu
3010
+ and prefix-command invocation — successful or errored — becomes a `UsageEvent`
3011
+ that spearkit can persist to a
3012
+ store and/or mirror into a Discord channel. Turn it on with the client's `usage`
3013
+ option.
3014
+
3015
+ ## Usage tracking vs the logger
3016
+
3017
+ These look similar but answer different questions, and they are completely
3018
+ independent sinks:
3019
+
3020
+ | | Logger | Usage tracking |
3021
+ | --- | --- | --- |
3022
+ | Question | *What is the bot doing?* (diagnostics) | *Who used which feature?* (audit) |
3023
+ | Content | Free-form messages, levels, errors, internals | Structured `UsageEvent`s for every completed use (with its `outcome`) |
3024
+ | Sinks | Console / your log pipeline | A database store and/or a Discord channel |
3025
+ | Configured by | the `logger` option | the `usage` option |
3026
+
3027
+ Both successes and handler errors are recorded as usage events — an error carries
3028
+ `outcome: "error"` and an `errorMessage` — so usage is a complete audit trail.
3029
+ The [logger](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md) is for debugging; usage tracking is for analytics,
3030
+ audit trails, and "top commands" dashboards.
3031
+
3032
+ ## Enabling it
3033
+
3034
+ Set the `usage` option. Provide a `store` (a database), a `channel` (a Discord
3035
+ channel id to mirror events into), or both:
3036
+
3037
+ ```ts
3038
+ import { MemoryUsageStore, SpearClient } from "spearkit";
3039
+
3040
+ const client = new SpearClient({
3041
+ usage: {
3042
+ store: new MemoryUsageStore(),
3043
+ channel: "123456789012345678", // optional: also post each event here
3044
+ },
3045
+ });
3046
+ ```
3047
+
3048
+ Once enabled, spearkit auto-tracks every command, component, context-menu and
3049
+ prefix-command invocation — successes and errors alike — with no tracking code in
3050
+ your handlers.
3051
+
3052
+ ## The usage event
3053
+
3054
+ Each tracked use is a `UsageEvent`:
3055
+
3056
+ ```ts
3057
+ interface UsageEvent {
3058
+ type: UsageType; // "command" | "prefix" | "component" | "event"
3059
+ name: string; // command / component / event name
3060
+ userId?: string;
3061
+ userTag?: string;
3062
+ guildId?: string | null;
3063
+ channelId?: string | null;
3064
+ detail?: string; // free-form extra detail
3065
+ outcome?: UsageOutcome; // "success" | "error"
3066
+ durationMs?: number; // handler wall-clock time
3067
+ options?: Readonly<Record<string, UsageMetaValue>>; // snapshot of typed options
3068
+ errorMessage?: string; // set when outcome === "error"
3069
+ timestamp: Date;
3070
+ }
3071
+ type UsageType = "command" | "prefix" | "component" | "event";
3072
+ type UsageOutcome = "success" | "error";
3073
+ type UsageMetaValue = string | number | boolean | null;
3074
+ ```
3075
+
3076
+ ## Stores (the database)
3077
+
3078
+ A store is any object implementing `UsageStore`:
3079
+
3080
+ ```ts
3081
+ interface UsageStore {
3082
+ record(event: UsageEvent): void | Promise<void>;
3083
+ all(): UsageEvent[] | Promise<readonly UsageEvent[]>;
3084
+ }
3085
+ ```
3086
+
3087
+ spearkit ships two.
3088
+
3089
+ ### MemoryUsageStore
3090
+
3091
+ In-memory and synchronous — ideal for tests, prototypes, and live dashboards.
3092
+ Pass an optional cap to keep only the most recent N events:
3093
+
3094
+ ```ts
3095
+ import { MemoryUsageStore } from "spearkit";
3096
+
3097
+ const store = new MemoryUsageStore(1_000); // keep the last 1,000 events
3098
+
3099
+ store.all(); // readonly UsageEvent[]
3100
+ store.size; // number of events held
3101
+ store.byUser("123..."); // UsageEvent[] for one user id
3102
+ store.clear(); // forget everything
3103
+ ```
3104
+
3105
+ ### JsonFileUsageStore
3106
+
3107
+ Durable and dependency-free: appends one event per line as newline-delimited
3108
+ JSON (`.jsonl`). `all()` reads the file back and parses it, so it behaves like a
3109
+ small file-backed database:
3110
+
3111
+ ```ts
3112
+ import { JsonFileUsageStore } from "spearkit";
3113
+
3114
+ const store = new JsonFileUsageStore("./usage.jsonl");
3115
+
3116
+ await store.record({ type: "command", name: "ping", timestamp: new Date() });
3117
+ const events = await store.all(); // readonly UsageEvent[]
3118
+ ```
3119
+
3120
+ The directory is created on demand. Because `all()` is async here (it reads from
3121
+ disk), always `await` it.
3122
+
3123
+ ## Querying the store
3124
+
3125
+ `client.usage.store` is the store you configured — query it directly. Note that
3126
+ `all()` may be synchronous (`MemoryUsageStore`) or asynchronous
3127
+ (`JsonFileUsageStore`); awaiting works for both:
3128
+
3129
+ ```ts
3130
+ const store = client.usage.store;
3131
+ if (store !== undefined) {
3132
+ const events = await store.all();
3133
+ const topCommand = events.filter((e) => e.type === "command").length;
3134
+ console.log(`${topCommand} command uses recorded`);
3135
+ }
3136
+ ```
3137
+
3138
+ ## The Discord channel reporter
3139
+
3140
+ Besides (or instead of) a store, you can mirror each event into a Discord
3141
+ channel. Pass `channel` (a channel id) in the `usage` option; spearkit posts one
3142
+ line per event using `formatUsage`:
3143
+
3144
+ ```ts
3145
+ import { SpearClient } from "spearkit";
3146
+
3147
+ new SpearClient({ usage: { channel: "123456789012345678" } });
3148
+ ```
3149
+
3150
+ `formatUsage(event)` is the default renderer (e.g. `` `command` **ping** by
3151
+ user#0001 in <#…> ``). Override it with `format` to control the line:
3152
+
3153
+ ```ts
3154
+ import { SpearClient, type UsageEvent } from "spearkit";
3155
+
3156
+ new SpearClient({
3157
+ usage: {
3158
+ channel: "123456789012345678",
3159
+ format: (event: UsageEvent) => `${event.userTag ?? "someone"} used ${event.name}`,
3160
+ },
3161
+ });
3162
+ ```
3163
+
3164
+ ## The usage tracker
3165
+
3166
+ `client.usage` is a `UsageTracker`. The client configures it from the `usage`
3167
+ option, but you can drive it directly:
3168
+
3169
+ | Member | Description |
3170
+ | ------ | ----------- |
3171
+ | `setStore(store)` | Set (or swap) the persistence store. |
3172
+ | `reportTo(channelId, format?)` | Mirror events into a channel, optionally with a custom formatter. |
3173
+ | `track(event)` | Record a use. Returns immediately; storing/reporting run in the background. |
3174
+ | `store` | The configured store, for querying. |
3175
+ | `enabled` | `true` if a store or channel is configured. |
3176
+
3177
+ ```ts
3178
+ import { JsonFileUsageStore } from "spearkit";
3179
+
3180
+ client.usage.setStore(new JsonFileUsageStore("./usage.jsonl"));
3181
+ client.usage.reportTo("123456789012345678");
3182
+
3183
+ // Record a custom event yourself (e.g. a non-command action).
3184
+ client.usage.track({ type: "event", name: "signup", timestamp: new Date() });
3185
+ ```
3186
+
3187
+ Tracking is fire-and-forget: a slow store or channel never blocks command
3188
+ handling, and any failure is logged rather than thrown.
3189
+
3190
+ ## See also
3191
+
3192
+ - [Logging](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md) — diagnostics, the other sink.
3193
+ - [Commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/commands.md) / [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md) / [Components](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/components.md) — what gets tracked.
3194
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — the `usage` option.
3195
+
3196
+ ---
3197
+
3198
+ # Environment & dotenv
3199
+
3200
+ spearkit includes a tiny, dependency-free `.env` loader and a typed reader over
3201
+ `process.env`, so a bot needs no extra dotenv dependency. The client auto-loads
3202
+ `.env` on `start()`, and the same helpers are exported for your own use.
3203
+
3204
+ ## Loading a `.env` file
3205
+
3206
+ `loadEnv(options?)` reads a `.env` file and merges it into `process.env`. By
3207
+ default it reads `.env` from the current working directory. Variables already
3208
+ present in `process.env` win unless you pass `override: true`. A missing file is
3209
+ ignored — it simply returns `{}` — so it is safe to call unconditionally.
3210
+
3211
+ ```ts
3212
+ import { loadEnv } from "spearkit";
3213
+
3214
+ const parsed = loadEnv(); // reads ./.env
3215
+ loadEnv({ path: ".env.local" }); // a different file
3216
+ loadEnv({ override: true }); // let the file win over existing vars
3217
+ ```
3218
+
3219
+ `loadEnv` returns the parsed key/value pairs it read from the file:
3220
+
3221
+ ```ts
3222
+ import { loadEnv } from "spearkit";
3223
+
3224
+ const parsed = loadEnv(); // ParsedEnv = Record<string, string>
3225
+ console.log(Object.keys(parsed));
3226
+ ```
3227
+
3228
+ ## Parsing without touching `process.env`
3229
+
3230
+ `parseEnv(text)` parses `.env`-formatted text into a flat object and never
3231
+ mutates `process.env`. It understands single/double quotes, a leading `export `,
3232
+ `#` comments, and `\n`/`\r`/`\t` escapes inside double quotes.
3233
+
3234
+ ```ts
3235
+ import { parseEnv } from "spearkit";
3236
+
3237
+ const vars = parseEnv(`
3238
+ # a comment
3239
+ export TOKEN="abc#notacomment"
3240
+ GREETING="line one\nline two"
3241
+ RAW='no $escapes here'
3242
+ `);
3243
+
3244
+ vars.TOKEN; // "abc#notacomment"
3245
+ vars.GREETING; // "line one\nline two" (real newline)
3246
+ vars.RAW; // "no $escapes here"
3247
+ ```
3248
+
3249
+ ## The typed `env` reader
3250
+
3251
+ `env` reads from `process.env` with coercion and optional fallbacks. Empty
3252
+ strings count as missing.
3253
+
3254
+ ```ts
3255
+ import { env } from "spearkit";
3256
+
3257
+ env.string("REGION"); // string | undefined
3258
+ env.string("REGION", "eu"); // string (fallback when missing)
3259
+
3260
+ env.number("PORT"); // number | undefined
3261
+ env.number("PORT", 3000); // number (fallback when missing or non-numeric)
3262
+
3263
+ env.boolean("DEBUG"); // boolean | undefined
3264
+ env.boolean("DEBUG", false); // boolean
3265
+
3266
+ env.require("DISCORD_TOKEN"); // string, throws if missing or empty
3267
+ ```
3268
+
3269
+ `env.boolean` treats `true`/`1`/`yes`/`on` as `true` and `false`/`0`/`no`/`off`
3270
+ as `false` (case-insensitive); anything else yields the fallback. `env.require`
3271
+ throws a descriptive error when the variable is missing or empty — use it for
3272
+ values your bot cannot run without.
3273
+
3274
+ ```ts
3275
+ import { loadEnv, env } from "spearkit";
3276
+
3277
+ loadEnv();
3278
+ const token = env.require("DISCORD_TOKEN"); // guaranteed string
3279
+ const port = env.number("PORT", 8080); // number
3280
+ const verbose = env.boolean("VERBOSE", false);
3281
+ ```
3282
+
3283
+ ## Auto-loading on the client
3284
+
3285
+ `SpearClient` calls `loadEnv()` for you inside `client.start()`, so `.env` is
3286
+ picked up before login. That means `await client.start()` finds
3287
+ `DISCORD_TOKEN` from `.env` without any extra wiring:
3288
+
3289
+ ```ts
3290
+ import { SpearClient } from "spearkit";
3291
+
3292
+ const client = new SpearClient();
3293
+
3294
+ async function main(): Promise<void> {
3295
+ await client.start(); // loads .env, then reads DISCORD_TOKEN
3296
+ }
3297
+
3298
+ void main();
3299
+ ```
3300
+
3301
+ ### The `dotenv` option
3302
+
3303
+ Control the auto-load with the `dotenv` construction option:
3304
+
3305
+ ```ts
3306
+ import { SpearClient } from "spearkit";
3307
+
3308
+ // Default: load ./.env on start.
3309
+ new SpearClient({ dotenv: true });
3310
+
3311
+ // Disable auto-loading entirely (e.g. env is provided by the platform).
3312
+ new SpearClient({ dotenv: false });
3313
+
3314
+ // Customize: same shape as loadEnv's options.
3315
+ new SpearClient({ dotenv: { path: ".env.production", override: true } });
3316
+ ```
3317
+
3318
+ | `dotenv` value | Effect |
3319
+ | -------------- | ------ |
3320
+ | `true` / omitted | Load `.env` from the cwd on `start()`. |
3321
+ | `false` | Skip auto-loading; `process.env` is used as-is. |
3322
+ | `{ path?, override? }` | Load with those `loadEnv` options. |
3323
+
3324
+ ## See also
3325
+
3326
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — the `dotenv` and other construction options.
3327
+ - [Logging](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md) — structured logging that pairs with `env`-driven config.
3328
+
3329
+ ---
3330
+
3331
+ # Plugins
3332
+
3333
+ A plugin is a named, reusable bundle of commands, events and components. It lets
3334
+ you package a feature once and install it into any `SpearClient` with a single
3335
+ call — useful for sharing functionality across bots or splitting a large bot into
3336
+ self-contained features.
3337
+
3338
+ ## Defining a plugin
3339
+
3340
+ `definePlugin` is an identity helper: it returns the object you pass it, but gives
3341
+ it the `SpearPlugin` type and editor hints.
3342
+
3343
+ ```ts
3344
+ interface SpearPlugin {
3345
+ name: string;
3346
+ setup(client: SpearClient): Awaitable<void>;
3347
+ }
3348
+ ```
3349
+
3350
+ A plugin has a `name` and a `setup` function. `setup` receives the client and
3351
+ registers whatever the feature needs — commands, events, components — typically
3352
+ via `client.register`.
3353
+
3354
+ ```ts
3355
+ import { definePlugin, button, command, event, option, row } from "spearkit";
3356
+
3357
+ export const moderation = definePlugin({
3358
+ name: "moderation",
3359
+ setup(client) {
3360
+ const confirmKick = button({
3361
+ id: "kick:{userId}",
3362
+ label: "Confirm kick",
3363
+ style: "Danger",
3364
+ run: (ctx) => ctx.update(`Kicked <@${ctx.params.userId}> (demo).`), // userId: string
3365
+ });
3366
+
3367
+ const warn = command({
3368
+ name: "warn",
3369
+ description: "Warn a member",
3370
+ options: {
3371
+ member: option.user({ description: "Member", required: true }),
3372
+ reason: option.string({ description: "Reason" }),
3373
+ },
3374
+ run: (ctx) =>
3375
+ ctx.reply({
3376
+ // member: User, reason: string | undefined
3377
+ content: `Warning ${ctx.options.member.tag}: ${ctx.options.reason ?? "no reason given"}`,
3378
+ components: [row(confirmKick.build({ userId: ctx.options.member.id }))],
3379
+ ephemeral: true,
3380
+ }),
3381
+ });
3382
+
3383
+ const ready = event("clientReady", (c) => console.log(`[moderation] ready on ${c.user.tag}`));
3384
+
3385
+ client.register(warn, confirmKick, ready);
3386
+ },
3387
+ });
3388
+ ```
3389
+
3390
+ Everything declared inside `setup` is local to the plugin; only what you pass to
3391
+ `client.register` becomes active on the client.
3392
+
3393
+ ## Installing a plugin
3394
+
3395
+ Install one or more plugins with `client.use`. It runs each plugin's `setup` in
3396
+ order and resolves to the client, so you can chain it with the rest of startup.
3397
+
3398
+ ```ts
3399
+ import { SpearClient, Intents } from "spearkit";
3400
+ import { moderation } from "./plugins/moderation.js";
3401
+
3402
+ const client = new SpearClient({ intents: Intents.default });
3403
+
3404
+ await client.use(moderation);
3405
+
3406
+ await client.start(process.env.DISCORD_TOKEN);
3407
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
3408
+ ```
3409
+
3410
+ `use` accepts several plugins at once:
3411
+
3412
+ ```ts
3413
+ await client.use(moderation, welcome, tickets);
3414
+ ```
3415
+
3416
+ ## Asynchronous setup
3417
+
3418
+ `setup` may be async — `client.use` awaits each one before moving to the next. Use
3419
+ this to load data, connect to a database, or fetch remote config before
3420
+ registering handlers.
3421
+
3422
+ ```ts
3423
+ export const tags = definePlugin({
3424
+ name: "tags",
3425
+ async setup(client) {
3426
+ const store = await openTagStore(); // await anything you need first
3427
+
3428
+ client.register(
3429
+ command({
3430
+ name: "tag",
3431
+ description: "Show a saved tag",
3432
+ options: { name: option.string({ description: "Tag name", required: true }) },
3433
+ run: (ctx) => ctx.reply(store.get(ctx.options.name) ?? "No such tag."),
3434
+ }),
3435
+ );
3436
+ },
3437
+ });
3438
+ ```
3439
+
3440
+ Because `use` awaits `setup`, every plugin is fully installed before
3441
+ `client.start` runs.
3442
+
3443
+ ## See also
3444
+
3445
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — `register`, `use`, `start`, and the registries plugins write to.
3446
+ - [File-based loading](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/loading.md) — discover commands, events and components from a directory instead of bundling them by hand.
3447
+
3448
+ ---
3449
+
3450
+ # Collectors
3451
+
3452
+ discord.js collectors are powerful but fiddly: you wire an event emitter, set a
3453
+ `time`, write a `filter`, remember that dismissed modals need their own timeout,
3454
+ and translate the "timed out" rejection into something you can branch on.
3455
+ spearkit collapses the common cases to a single `await` that resolves to the
3456
+ result — or `null` on timeout.
3457
+
3458
+ Beyond these, see the [pagination and confirm helpers](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md#pagination--confirmation)
3459
+ for ready-made paged lists and yes/no gates.
3460
+
3461
+ ## Wait for a message ("type your answer")
3462
+
3463
+ `ctx.awaitMessageFrom(userId?, options?)` waits for the next message in the
3464
+ current channel from a user (defaults to the invoking user):
3465
+
3466
+ ```ts
3467
+ await ctx.reply("What's your favourite colour?");
3468
+ const answer = await ctx.awaitMessageFrom(ctx.user.id, { time: 30_000 });
3469
+ if (answer === null) return ctx.followUp("Timed out.");
3470
+ await ctx.followUp(`Nice — ${answer.content}!`);
3471
+ ```
3472
+
3473
+ The standalone `awaitMessage(channel, options)` does the same for any text
3474
+ channel; `options` takes `{ filter, time }` (default `time` 60s).
3475
+
3476
+ ## Wait for a modal submission
3477
+
3478
+ `ctx.awaitModal(modal, options?)` (on command and component contexts) shows a
3479
+ modal and waits for the submission — scoped to the same user and that modal's
3480
+ custom-id, always bounded — sidestepping the "Unknown interaction after a
3481
+ cancelled modal" trap:
3482
+
3483
+ ```ts
3484
+ import { modal, textInput } from "spearkit";
3485
+
3486
+ const form = modal({
3487
+ id: "feedback",
3488
+ title: "Feedback",
3489
+ fields: { text: textInput({ label: "Your feedback", required: true }) },
3490
+ run: (ctx) => ctx.replyEphemeral("thanks"), // routed fallback
3491
+ });
3492
+
3493
+ const submitted = await ctx.awaitModal(form.build(), { time: 120_000 });
3494
+ if (submitted === null) return; // dismissed or timed out
3495
+ await submitted.reply({ content: submitted.fields.getTextInputValue("text"), ephemeral: true });
3496
+ ```
3497
+
3498
+ This is the inline alternative to registering a separate modal handler and
3499
+ threading state through its custom-id.
3500
+
3501
+ ## Wait for a component click
3502
+
3503
+ `awaitComponent(message, options)` waits for the next button/select interaction
3504
+ on a message. `options` takes `{ filter, time, componentType }`. You must still
3505
+ acknowledge the returned interaction (`update`/`deferUpdate`/`reply`):
3506
+
3507
+ ```ts
3508
+ import { awaitComponent } from "spearkit";
3509
+
3510
+ const sent = await ctx.channel!.send({ content: "Pick one", components: [row] });
3511
+ const click = await awaitComponent(sent, { time: 15_000 });
3512
+ if (click === null) return;
3513
+ await click.update("Got it!");
3514
+ ```
3515
+
3516
+ ---
3517
+
3518
+ # Key-value store & settings
3519
+
3520
+ Almost every community bot needs to remember *something* per guild — a custom
3521
+ prefix, a mod-log channel, a welcome message — and reaches for a database on day
3522
+ one. spearkit ships a dependency-free `KeyValueStore` interface with two
3523
+ backends, plus a typed per-guild settings helper. Swap in Redis/SQL later by
3524
+ implementing the same interface.
3525
+
3526
+ ## Stores
3527
+
3528
+ ```ts
3529
+ import { JsonStore, MemoryStore } from "spearkit";
3530
+
3531
+ const dev = new MemoryStore(); // in-memory, great for tests
3532
+ const prod = new JsonStore("data/db.json"); // durable JSON file
3533
+ ```
3534
+
3535
+ Both implement `KeyValueStore`:
3536
+
3537
+ ```ts
3538
+ await store.set("key", { any: "json" });
3539
+ await store.get<{ any: string }>("key"); // typed read, or undefined
3540
+ await store.has("key");
3541
+ await store.delete("key"); // → boolean (existed?)
3542
+ await store.keys(); // → string[]
3543
+ await store.clear();
3544
+ ```
3545
+
3546
+ `MemoryStore` deep-clones on read and write, so callers can't mutate stored
3547
+ state. `JsonStore` serves reads from an in-memory cache and commits writes
3548
+ atomically (temp file + rename) through a queue — a crash mid-write can't corrupt
3549
+ the file, and concurrent writes don't interleave.
3550
+
3551
+ ## Typed per-guild settings
3552
+
3553
+ `createSettings` wraps a store with defaults. `get` always returns a complete
3554
+ object; `set` persists *only* the overrides, so widening `defaults` later is
3555
+ safe.
3556
+
3557
+ ```ts
3558
+ import { JsonStore, createSettings } from "spearkit";
3559
+
3560
+ const settings = createSettings({
3561
+ store: new JsonStore("data/guilds.json"),
3562
+ defaults: { prefix: "!", modLogChannelId: null as string | null },
3563
+ });
3564
+
3565
+ const cfg = await settings.get(guildId); // { prefix, modLogChannelId }
3566
+ await settings.set(guildId, { prefix: "?" }); // shallow-merged + persisted
3567
+ await settings.reset(guildId); // back to defaults
3568
+ ```
3569
+
3570
+ Pass `namespace` to keep several settings groups in one store:
3571
+
3572
+ ```ts
3573
+ const guilds = createSettings({ store, defaults: { prefix: "!" }, namespace: "guild" });
3574
+ const users = createSettings({ store, defaults: { xp: 0 }, namespace: "user" });
3575
+ ```
3576
+
3577
+ ## Dynamic per-guild prefix
3578
+
3579
+ A stored prefix is only useful if prefix commands respect it. `prefix.dynamic`
3580
+ resolves extra prefix(es) per message — combine it with `createSettings` for true
3581
+ per-guild prefixes:
3582
+
3583
+ ```ts
3584
+ const client = new SpearClient({
3585
+ prefix: {
3586
+ dynamic: async (message) =>
3587
+ message.guildId ? (await settings.get(message.guildId)).prefix : null,
3588
+ },
3589
+ });
3590
+ ```
3591
+
3592
+ The resolver runs on every candidate message, so keep it fast (cache or use the
3593
+ in-memory `JsonStore` cache). Returned prefixes are tried *in addition* to any
3594
+ static `prefix`. See [Prefix commands](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md) for the rest of the prefix
3595
+ system.
3596
+
3597
+ ## Namespacing a raw store
3598
+
3599
+ `namespaced(store, prefix)` returns a `KeyValueStore` whose keys are
3600
+ transparently prefixed — handy for sharing one file across features:
3601
+
3602
+ ```ts
3603
+ import { namespaced } from "spearkit";
3604
+
3605
+ const tags = namespaced(store, "tags");
3606
+ await tags.set("hello", "world"); // stored under "tags:hello"
3607
+ ```
3608
+
3609
+ ---
3610
+
3611
+ # Messages & limits
3612
+
3613
+ Discord caps a message's `content` at **2000 characters**. Long output — a log
3614
+ dump, a list, an AI response — silently fails or throws unless you split it.
3615
+ spearkit ships two helpers (in addition to the duration/timestamp formatters; see
3616
+ the [API reference](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/api-reference.md)).
3617
+
3618
+ ## Split long output
3619
+
3620
+ `chunkMessage(text, options?)` breaks text into chunks that each fit the limit,
3621
+ preferring line boundaries (and word boundaries for an over-long single line) so
3622
+ you never lose the tail:
3623
+
3624
+ ```ts
3625
+ import { chunkMessage } from "spearkit";
3626
+
3627
+ const parts = chunkMessage(hugeLog); // default max = 2000
3628
+ await ctx.reply(parts[0] ?? "(empty)");
3629
+ for (const part of parts.slice(1)) await ctx.followUp(part);
3630
+ ```
3631
+
3632
+ Pass `{ max }` to target a smaller budget (e.g. inside a code block or embed
3633
+ description). `MESSAGE_CHARACTER_LIMIT` (2000) is exported as the default.
3634
+
3635
+ ## Truncate
3636
+
3637
+ `truncate(text, max, suffix?)` cuts text to `max` characters, appending the
3638
+ suffix (default `…`) — the result, suffix included, never exceeds `max`:
3639
+
3640
+ ```ts
3641
+ import { truncate } from "spearkit";
3642
+
3643
+ embed.setFooter({ text: truncate(reason, 100) });
3644
+ truncate("a very long reason", 10); // → "a very lo…"
3645
+ ```
3646
+
3647
+ ---
3648
+
3649
+ # Graceful shutdown
3650
+
3651
+ A `Ctrl-C` or a container stop sends your process a signal. If you don't handle
3652
+ it, the process dies mid-flight — the gateway connection, scheduler timers, and
3653
+ any open database handles are reaped abruptly. Graceful shutdown runs an optional
3654
+ cleanup hook, calls `client.destroy()` (which also stops spearkit's scheduler),
3655
+ then exits — with a hard timeout so a wedged shutdown can't hang forever.
3656
+
3657
+ ## On a SpearClient
3658
+
3659
+ ```ts
3660
+ client.enableGracefulShutdown({
3661
+ onShutdown: () => db.close(), // flush state before we exit
3662
+ });
3663
+ await client.start();
3664
+ ```
3665
+
3666
+ Progress is logged through `client.logger`. The method returns a disposer that
3667
+ removes the signal handlers (useful for tests / hot-reload).
3668
+
3669
+ ## Standalone
3670
+
3671
+ `gracefulShutdown(client, options)` works with any object that has a `destroy()`
3672
+ method:
3673
+
3674
+ ```ts
3675
+ import { gracefulShutdown } from "spearkit";
3676
+
3677
+ gracefulShutdown(client, { onShutdown: () => db.close() });
3678
+ ```
3679
+
3680
+ ## Options
3681
+
3682
+ | Field | Default | Meaning |
3683
+ | --- | --- | --- |
3684
+ | `signals` | `["SIGINT", "SIGTERM"]` | Signals to listen for. |
3685
+ | `timeoutMs` | `10000` | Force-exit if shutdown exceeds this. |
3686
+ | `onShutdown` | — | Runs before `destroy()`; receives the signal. |
3687
+ | `exit` | `true` | Call `process.exit()` when done (set `false` in tests). |
3688
+ | `logger` | — | `{ info?, error? }` progress logger. |
3689
+
3690
+ Shutdown runs **once** — repeated signals during teardown are ignored.
3691
+
3692
+ ---
3693
+
3694
+ # File-based loading
3695
+
3696
+ Instead of importing and registering every handler by hand, you can keep one
3697
+ handler per file and let spearkit discover them. The loader imports a directory,
3698
+ inspects each module's exports, and registers everything that is a command,
3699
+ event, component, scheduled task or prefix command.
3700
+
3701
+ ## `client.load`
3702
+
3703
+ ```ts
3704
+ load(dir: string, options?: LoadOptions): Promise<number>
3705
+ ```
3706
+
3707
+ `client.load` imports `dir` and registers every spearkit-registrable export it finds,
3708
+ resolving to the number of items registered.
3709
+
3710
+ ```ts
3711
+ import { fileURLToPath } from "node:url";
3712
+ import { SpearClient, Intents } from "spearkit";
3713
+
3714
+ const here = fileURLToPath(new URL(".", import.meta.url));
3715
+
3716
+ const client = new SpearClient({ intents: Intents.default });
3717
+
3718
+ const loaded =
3719
+ (await client.load(`${here}commands`)) +
3720
+ (await client.load(`${here}events`)) +
3721
+ (await client.load(`${here}components`));
3722
+ console.log(`Loaded ${loaded} modules.`);
3723
+
3724
+ await client.start(process.env.DISCORD_TOKEN);
3725
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
3726
+ ```
3727
+
3728
+ ### What gets registered
3729
+
3730
+ For every imported file, spearkit walks **all** of its exports — default *and*
3731
+ named — and registers each value that is a command (`command`, `commandGroup`),
3732
+ an event (`event`), a component (`button`, `stringSelect`, `modal`, …), a
3733
+ scheduled task (`task`) or a prefix command (`prefixCommand`). Other exports
3734
+ (helpers, constants, types) are ignored, and context-menu commands are **not**
3735
+ auto-detected — register those explicitly. So both of these are picked up:
3736
+
3737
+ ```ts
3738
+ // default export
3739
+ export default command({ name: "ping", description: "…", run: (ctx) => ctx.reply("pong") });
3740
+ ```
3741
+
3742
+ ```ts
3743
+ // named export
3744
+ export const vote = button({ id: "vote:{choice}", label: "Vote", run: (ctx) => ctx.update(ctx.params.choice) });
3745
+ ```
3746
+
3747
+ ### Options
3748
+
3749
+ ```ts
3750
+ interface LoadOptions {
3751
+ extensions?: readonly string[]; // default: [".js", ".mjs", ".cjs"]
3752
+ recursive?: boolean; // default: true
3753
+ }
3754
+ ```
3755
+
3756
+ - **`extensions`** — which file extensions to import. By default the loader reads
3757
+ `.js`, `.mjs` and `.cjs` — i.e. **compiled JavaScript**, not `.ts` source.
3758
+ - **`recursive`** — by default the loader descends into subdirectories. Pass
3759
+ `recursive: false` to load only the top level.
3760
+
3761
+ ```ts
3762
+ await client.load(`${here}features`, { recursive: false });
3763
+ ```
3764
+
3765
+ > The loader imports compiled JavaScript. **Build your TypeScript first**, then run
3766
+ > (and load) the emitted output — `npx tsc && node dist/index.js`. Loading a
3767
+ > directory of `.ts` source files will not match the default extensions.
3768
+
3769
+ ## Standalone helpers
3770
+
3771
+ `client.load` is the method form of `loadInto`. Both helpers are exported if you
3772
+ want to collect or register modules separately.
3773
+
3774
+ ```ts
3775
+ collectModules(dir: string, options?: LoadOptions): Promise<Registerable[]>
3776
+ loadInto(client: SpearClient, dir: string, options?: LoadOptions): Promise<number>
3777
+ ```
3778
+
3779
+ - **`collectModules`** imports a directory and returns the registrable exports it
3780
+ found, without touching any client. Use it to inspect, filter, or combine
3781
+ modules before registering.
3782
+ - **`loadInto`** calls `collectModules` and then `client.register(...)` for you,
3783
+ returning the count.
3784
+
3785
+ ```ts
3786
+ import { collectModules, loadInto } from "spearkit";
3787
+
3788
+ // Inspect before registering:
3789
+ const items = await collectModules(`${here}commands`);
3790
+ console.log(`Found ${items.length} modules`);
3791
+ client.register(...items);
3792
+
3793
+ // Or do both in one step:
3794
+ const count = await loadInto(client, `${here}events`);
3795
+ ```
3796
+
3797
+ ## Example layout
3798
+
3799
+ The `examples/file-based-loading` project keeps each handler in its own file:
3800
+
3801
+ ```
3802
+ file-based-loading/
3803
+ index.ts # construct client, load each folder, start, deploy
3804
+ commands/
3805
+ ping.ts # export default command({ ... })
3806
+ echo.ts # export default command({ ... })
3807
+ events/
3808
+ ready.ts # export default event("clientReady", ...)
3809
+ components/
3810
+ vote.ts # export const vote = button({ ... })
3811
+ ```
3812
+
3813
+ A command file looks like this:
3814
+
3815
+ ```ts
3816
+ // commands/echo.ts
3817
+ import { command, option } from "spearkit";
3818
+
3819
+ export default command({
3820
+ name: "echo",
3821
+ description: "Repeat a message",
3822
+ options: {
3823
+ text: option.string({ description: "What to say", required: true }),
3824
+ loud: option.boolean({ description: "Shout it" }),
3825
+ },
3826
+ // text: string, loud: boolean | undefined
3827
+ run: (ctx) => ctx.reply(ctx.options.loud ? ctx.options.text.toUpperCase() : ctx.options.text),
3828
+ });
3829
+ ```
3830
+
3831
+ Build the project, then run the compiled `index.js`; `client.load` will import the
3832
+ emitted `.js` files and register `ping`, `echo`, `ready` and `vote` for you.
3833
+
3834
+ ## See also
3835
+
3836
+ - [Client](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/client.md) — `load`, `register`, and the registries the loader writes to.
3837
+ - [Events](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/events.md) — the `event()` helper that event modules export.
3838
+
3839
+ ---
3840
+
3841
+ # API reference
3842
+
3843
+ Every symbol spearkit exports, in addition to the entire re-exported discord.js
3844
+ surface. Import any of these from `"spearkit"`.
3845
+
3846
+ ```ts
3847
+ import { SpearClient, command, option, event, button, modal, row /* … */ } from "spearkit";
3848
+ ```
3849
+
3850
+ ---
3851
+
3852
+ ## Client
3853
+
3854
+ ### `class SpearClient extends Client`
3855
+
3856
+ A discord.js `Client` with registries and interaction routing wired up.
3857
+
3858
+ ```ts
3859
+ new SpearClient(options?: SpearClientOptions)
3860
+ ```
3861
+
3862
+ | Member | Type | Description |
3863
+ | ------ | ---- | ----------- |
3864
+ | `commands` | `CommandRegistry` | Slash command registry + dispatcher. |
3865
+ | `events` | `EventRegistry` | Event listener registry. |
3866
+ | `components` | `ComponentRegistry` | Button/select/modal router. |
3867
+ | `logger` | `Logger` | Structured logger (`client.logger.child(scope)` for sub-scopes). |
3868
+ | `cooldowns` | `CooldownManager` | Shared cooldown manager (also used by prefix commands). |
3869
+ | `scheduler` | `TaskScheduler` | Cron / interval task scheduler. |
3870
+ | `prefix` | `PrefixRegistry` | Prefix (text) command registry. |
3871
+ | `usage` | `UsageTracker` | Usage tracker — records who used what. |
3872
+ | `embeds` | `Embeds` | Preset embed factory behind `ctx.success/error/...`. |
3873
+ | `contextMenus` | `ContextMenuRegistry` | User / message context-menu registry. |
3874
+ | `register(...items: Registerable[])` | `this` | Route each item to the matching registry. |
3875
+ | `use(...plugins: SpearPlugin[])` | `Promise<this>` | Run each plugin's `setup`. |
3876
+ | `load(dir: string, options?: LoadOptions)` | `Promise<number>` | Import a directory and register its exports. Returns count. |
3877
+ | `start(token?: string)` | `Promise<this>` | Log in (falls back to `DISCORD_TOKEN`). |
3878
+ | `deployCommands(options?: { guildId?: string })` | `Promise<DeployResult>` | Push commands using the client's REST. Call after ready. |
3879
+ | `deployAllCommands(options?)` | `Promise<DeployResult \| { skipped: true; reason; body }>` | Deploy slash + context menus together; supports `dryRun` and `strategy: "diff"`. |
3880
+ | `schedule(config: TaskConfig)` | `ScheduledTask` | Define and register a scheduled task in one call. |
3881
+ | `enableGracefulShutdown(options?: GracefulShutdownOptions)` | `() => void` | Tear down cleanly on `SIGINT`/`SIGTERM`; returns a disposer. |
3882
+
3883
+ Inherits everything from discord.js `Client` (`on`, `once`, `login`, `ws`, `rest`, `application`, `user`, …).
3884
+
3885
+ ### `type SpearClientOptions = Partial<ClientOptions> & SpearOptions`
3886
+
3887
+ discord.js `ClientOptions` (with `intents` optional — it defaults to
3888
+ `Intents.default`) intersected with spearkit's own options (`SpearOptions`):
3889
+
3890
+ | Option | Type | Configures |
3891
+ | ------ | ---- | ---------- |
3892
+ | `logger` | `Logger \| LoggerOptions` | The `client.logger`. |
3893
+ | `dotenv` | `boolean \| LoadEnvOptions` | Auto-load `.env` on `start()` (default `true`). |
3894
+ | `cooldown` | `CooldownInput` | Default cooldown applied to every command. |
3895
+ | `prefix` | `string \| readonly string[] \| PrefixOptions` | Enable prefix commands. |
3896
+ | `usage` | `UsageOptions` | Usage-tracking store and/or channel. |
3897
+ | `embeds` | `Embeds \| EmbedsOptions` | Preset embed factory. |
3898
+ | `guards` | `readonly Guard[]` | Default guards run before every handler. |
3899
+ | `autoDefer` | `AutoDeferInput` | Default auto-defer for slash + context-menu handlers. |
3900
+
3901
+ ### `const Intents`
3902
+
3903
+ Ready-made intent presets (arrays of `GatewayIntentBits`).
3904
+
3905
+ | Key | Contents |
3906
+ | --- | -------- |
3907
+ | `Intents.none` | `[]` |
3908
+ | `Intents.default` | `[Guilds]` |
3909
+ | `Intents.guilds` | `[Guilds, GuildMembers]` |
3910
+ | `Intents.messages` | `[Guilds, GuildMessages, MessageContent]` |
3911
+ | `Intents.all` | Every intent (includes privileged). |
3912
+
3913
+ ### `type Registerable = SlashCommand | EventDef | ComponentDef | ScheduledTask | PrefixCommand | ContextMenuCommand`
3914
+
3915
+ The union accepted by `SpearClient.register`.
3916
+
3917
+ ---
3918
+
3919
+ ## Commands
3920
+
3921
+ ### `function command<O, R>(config): SlashCommand`
3922
+
3923
+ Define a leaf slash command.
3924
+
3925
+ ```ts
3926
+ interface CommandConfig<O extends OptionMap, R> {
3927
+ name: string;
3928
+ description: string;
3929
+ options?: O;
3930
+ defaultMemberPermissions?: PermissionResolvable | null;
3931
+ nsfw?: boolean;
3932
+ guildOnly?: boolean;
3933
+ nameLocalizations?: LocalizationMap;
3934
+ descriptionLocalizations?: LocalizationMap;
3935
+ cooldown?: CooldownInput;
3936
+ guards?: readonly Guard[];
3937
+ autoDefer?: AutoDeferInput;
3938
+ run: (ctx: CommandContext<O>) => Awaitable<R>;
3939
+ }
3940
+ ```
3941
+
3942
+ ### `function commandGroup(config: CommandGroupConfig): SlashCommand`
3943
+
3944
+ Define a command that routes to subcommands and/or subcommand groups.
3945
+
3946
+ ```ts
3947
+ interface CommandGroupConfig {
3948
+ name: string;
3949
+ description: string;
3950
+ subcommands?: Record<string, Subcommand>;
3951
+ groups?: Record<string, SubcommandGroup>;
3952
+ defaultMemberPermissions?: PermissionResolvable | null;
3953
+ nsfw?: boolean;
3954
+ guildOnly?: boolean;
3955
+ nameLocalizations?: LocalizationMap;
3956
+ descriptionLocalizations?: LocalizationMap;
3957
+ cooldown?: CooldownInput;
3958
+ guards?: readonly Guard[];
3959
+ autoDefer?: AutoDeferInput;
3960
+ }
3961
+ ```
3962
+
3963
+ ### `function subcommand<O, R>(config): Subcommand`
3964
+
3965
+ ```ts
3966
+ interface SubcommandConfig<O extends OptionMap, R> {
3967
+ description: string;
3968
+ options?: O;
3969
+ nameLocalizations?: LocalizationMap;
3970
+ descriptionLocalizations?: LocalizationMap;
3971
+ run: (ctx: CommandContext<O>) => Awaitable<R>;
3972
+ }
3973
+ ```
3974
+
3975
+ ### `function subcommandGroup(config: SubcommandGroupConfig): SubcommandGroup`
3976
+
3977
+ ```ts
3978
+ interface SubcommandGroupConfig {
3979
+ description: string;
3980
+ subcommands: Record<string, Subcommand>;
3981
+ nameLocalizations?: LocalizationMap;
3982
+ descriptionLocalizations?: LocalizationMap;
3983
+ }
3984
+ ```
3985
+
3986
+ ### `class SlashCommand`
3987
+
3988
+ | Member | Type | Description |
3989
+ | ------ | ---- | ----------- |
3990
+ | `name` | `string` | Top-level command name. |
3991
+ | `hasAutocomplete` | `boolean` | True if any option declares autocomplete. |
3992
+ | `toJSON()` | `RESTPostAPIChatInputApplicationCommandsJSONBody` | REST payload. |
3993
+ | `execute(interaction)` | `Promise<void>` | Run for a chat-input interaction. |
3994
+ | `autocomplete(interaction)` | `Promise<void>` | Run autocomplete for the focused option. |
3995
+ | `cooldown` | `CooldownConfig \| undefined` | Resolved cooldown, when set. |
3996
+ | `guards` | `readonly Guard[] \| undefined` | Guards run before `execute`. |
3997
+ | `autoDefer` | `AutoDeferConfig \| undefined` | Resolved auto-defer config, when set. |
3998
+
3999
+ ### `class CommandContext<O> extends BaseContext<ChatInputCommandInteraction>`
4000
+
4001
+ | Member | Type | Description |
4002
+ | ------ | ---- | ----------- |
4003
+ | `options` | `ResolvedOptions<O>` | Resolved, fully-typed option values. |
4004
+ | `commandName` | `string` | Invoked command name. |
4005
+ | `subcommand` | `string \| null` | Invoked subcommand, if any. |
4006
+ | `showModal(modal)` | `Promise<void>` | Present a modal. |
4007
+ | `awaitModal(modal, options?)` | `Promise<ModalSubmitInteraction \| null>` | Show a modal and await its submission (scoped to this user). |
4008
+
4009
+ Plus all `BaseContext` members.
4010
+
4011
+ ### `class CommandRegistry`
4012
+
4013
+ | Member | Type | Description |
4014
+ | ------ | ---- | ----------- |
4015
+ | `add(...commands: SlashCommand[])` | `this` | Register commands (override by name). |
4016
+ | `remove(name: string)` | `boolean` | Remove a command. |
4017
+ | `get(name: string)` | `SlashCommand \| undefined` | Look up a command. |
4018
+ | `all()` | `SlashCommand[]` | All commands. |
4019
+ | `names` | `string[]` | All command names. |
4020
+ | `size` | `number` | Count. |
4021
+ | `onError(handler: CommandErrorHandler)` | `this` | Set the error handler. |
4022
+ | `toJSON()` | `RESTPostAPIApplicationCommandsJSONBody[]` | Serialise all commands. |
4023
+ | `handle(interaction)` | `Promise<void>` | Dispatch a chat-input interaction. |
4024
+ | `handleAutocomplete(interaction)` | `Promise<void>` | Dispatch an autocomplete interaction. |
4025
+ | `deploy(options: DeployOptions)` | `Promise<DeployResult>` | Push commands to discord. |
4026
+ | `setLogger(logger: Logger)` | `this` | Attach a debug logger for dispatch tracing. |
4027
+ | `setCooldowns(manager: CooldownManager, default?: CooldownConfig)` | `this` | Wire a shared cooldown manager and optional default. |
4028
+ | `setDefaultGuards(guards: readonly Guard[])` | `this` | Guards run before each command's own guards. |
4029
+ | `setUsageHook(hook: (event: UsageEvent) => void)` | `this` | Called after each dispatch (success or error). |
4030
+
4031
+ ```ts
4032
+ type CommandErrorHandler = (error: Error, interaction: ChatInputCommandInteraction) => Awaitable<void>;
4033
+ interface DeployOptions { token?: string; applicationId: string; guildId?: string; rest?: REST; }
4034
+ type DeployResult = RESTPutAPIApplicationCommandsResult | RESTPutAPIApplicationGuildCommandsResult;
4035
+ ```
4036
+
4037
+ ---
4038
+
4039
+ ## Options
4040
+
4041
+ ### `const option`
4042
+
4043
+ Type-safe option builders. Each returns an `OptionDef` whose resolved value type
4044
+ is inferred (required → value, optional → `value | undefined`, `choices` →
4045
+ literal union).
4046
+
4047
+ | Builder | Resolved type | Extra config |
4048
+ | ------- | ------------- | ------------ |
4049
+ | `option.string(config)` | `string` | `choices?`, `minLength?`, `maxLength?`, `autocomplete?` |
4050
+ | `option.integer(config)` | `number` | `choices?`, `minValue?`, `maxValue?`, `autocomplete?` |
4051
+ | `option.number(config)` | `number` | `choices?`, `minValue?`, `maxValue?`, `autocomplete?` |
4052
+ | `option.boolean(config)` | `boolean` | — |
4053
+ | `option.user(config)` | `User` | — |
4054
+ | `option.channel(config)` | channel union | `channelTypes?` |
4055
+ | `option.role(config)` | `Role \| APIRole` | — |
4056
+ | `option.mentionable(config)` | user/role/member | — |
4057
+ | `option.attachment(config)` | `Attachment` | — |
4058
+
4059
+ Common config (`BaseConfig`):
4060
+
4061
+ ```ts
4062
+ {
4063
+ description: string;
4064
+ required?: boolean; // default false
4065
+ nameLocalizations?: LocalizationMap;
4066
+ descriptionLocalizations?: LocalizationMap;
4067
+ }
4068
+ ```
4069
+
4070
+ `choices` items are `OptionChoice<V>`:
4071
+
4072
+ ```ts
4073
+ interface OptionChoice<V extends string | number = string | number> {
4074
+ name: string;
4075
+ value: V;
4076
+ nameLocalizations?: LocalizationMap;
4077
+ }
4078
+ ```
4079
+
4080
+ `autocomplete`:
4081
+
4082
+ ```ts
4083
+ type AutocompleteHandler<V extends string | number> =
4084
+ (ctx: AutocompleteContext) => Awaitable<OptionChoice<V>[]>;
4085
+ ```
4086
+
4087
+ ### Option types
4088
+
4089
+ | Symbol | Description |
4090
+ | ------ | ----------- |
4091
+ | `interface OptionDef<TValue, TRequired>` | A described option (phantom-typed for inference). |
4092
+ | `type AnyOptionDef` | `OptionDef<OptionValue, boolean>`. |
4093
+ | `type OptionMap` | `Record<string, AnyOptionDef>`. |
4094
+ | `type ResolvedOption<O>` | The handler value for one option. |
4095
+ | `type ResolvedOptions<O>` | The handler's `options` object. |
4096
+ | `type OptionValue` | Union of all possible resolved values. |
4097
+ | `type AllowedChannelType` | Channel types valid for a channel option. |
4098
+ | `function toAPIOption(name, def)` | Serialise one option to REST. |
4099
+ | `function readOption(resolver, name, def)` | Read a resolved value (null → undefined). |
4100
+ | `function optionsHaveAutocomplete(options)` | True if any option has autocomplete. |
4101
+
4102
+ ### `class AutocompleteContext`
4103
+
4104
+ | Member | Type | Description |
4105
+ | ------ | ---- | ----------- |
4106
+ | `interaction` | `AutocompleteInteraction` | Raw interaction. |
4107
+ | `client` / `user` / `guild` / `guildId` | — | Convenience accessors. |
4108
+ | `commandName` | `string` | Command being completed. |
4109
+ | `focusedName` | `string` | Name of the focused option. |
4110
+ | `value` | `string` | Current partial value typed by the user. |
4111
+ | `respond(choices: OptionChoice[])` | `Promise<void>` | Send up to 25 suggestions. |
4112
+
4113
+ ---
4114
+
4115
+ ## Events
4116
+
4117
+ ### `function event(name, run): EventDef` / `function event(config): EventDef`
4118
+
4119
+ ```ts
4120
+ type EventHandler<E extends keyof ClientEvents> = (...args: ClientEvents[E]) => Awaitable<void>;
4121
+ interface EventConfig<E extends keyof ClientEvents> { name: E; once?: boolean; run: EventHandler<E>; }
4122
+ interface EventDef { name: keyof ClientEvents; once: boolean; attach(client: Client): void; detach(client: Client): void; }
4123
+ ```
4124
+
4125
+ Thrown errors and rejected promises are routed to the client's `error` event.
4126
+
4127
+ ### `class EventRegistry`
4128
+
4129
+ | Member | Type | Description |
4130
+ | ------ | ---- | ----------- |
4131
+ | `add(...defs: EventDef[])` | `this` | Register listeners. |
4132
+ | `size` | `number` | Count. |
4133
+ | `attachAll(client: Client)` | `void` | Attach every listener. |
4134
+ | `detachAll(client: Client)` | `void` | Detach every listener. |
4135
+
4136
+ ---
4137
+
4138
+ ## Components
4139
+
4140
+ ### Builders
4141
+
4142
+ | Function | Returns | Notes |
4143
+ | -------- | ------- | ----- |
4144
+ | `button(config)` | `Button<P>` | Interactive button. |
4145
+ | `linkButton(config)` | `ButtonBuilder` | URL button, no handler. |
4146
+ | `stringSelect(config)` | `StringSelect<P>` | String select; takes `options`. |
4147
+ | `userSelect(config)` | `UserSelect<P>` | User select. |
4148
+ | `roleSelect(config)` | `RoleSelect<P>` | Role select. |
4149
+ | `channelSelect(config)` | `ChannelSelect<P>` | Channel select; takes `channelTypes?`. |
4150
+ | `mentionableSelect(config)` | `MentionableSelect<P>` | User + role select. |
4151
+ | `modal(config)` | `Modal<P>` | Modal with `fields`. |
4152
+ | `textInput(config)` | `TextInputDef` | A modal text-input field. |
4153
+ | `row(...components)` | `ActionRowBuilder<C>` | Wrap components in a row. |
4154
+
4155
+ Each registrable component (`Button`, `StringSelect`, …, `Modal`) extends its
4156
+ routing interface and adds `build(...args: BuildArgs<P>)`, which returns the
4157
+ discord.js builder. `build` requires exactly the params declared in the id
4158
+ pattern.
4159
+
4160
+ ```ts
4161
+ interface ButtonConfig<P extends string, R> {
4162
+ id: P; // pattern: "name" or "name:{param}"
4163
+ label?: string;
4164
+ style?: ButtonStyleInput; // "Primary" | "Secondary" | "Success" | "Danger" | ButtonStyle.*
4165
+ emoji?: ComponentEmojiResolvable;
4166
+ disabled?: boolean;
4167
+ guards?: readonly Guard[];
4168
+ run: (ctx: ButtonContext<Params<P>>) => Awaitable<R>;
4169
+ }
4170
+
4171
+ interface LinkButtonConfig { url: string; label?: string; emoji?: ComponentEmojiResolvable; disabled?: boolean; }
4172
+
4173
+ interface StringSelectConfig<P extends string, R> {
4174
+ id: P;
4175
+ options: readonly SelectMenuComponentOptionData[];
4176
+ placeholder?: string; minValues?: number; maxValues?: number; disabled?: boolean;
4177
+ guards?: readonly Guard[];
4178
+ run: (ctx: StringSelectContext<Params<P>>) => Awaitable<R>;
4179
+ }
4180
+
4181
+ interface EntitySelectConfig<P extends string> {
4182
+ id: P; placeholder?: string; minValues?: number; maxValues?: number; disabled?: boolean;
4183
+ guards?: readonly Guard[];
4184
+ }
4185
+ // user/role/mentionable selects take EntitySelectConfig & { run };
4186
+ // channelSelect additionally takes { channelTypes?: readonly ChannelType[] }.
4187
+
4188
+ function textInput(config: {
4189
+ label: string;
4190
+ style?: TextInputStyleInput; // "Short" | "Paragraph" | TextInputStyle
4191
+ placeholder?: string; required?: boolean; minLength?: number; maxLength?: number; value?: string;
4192
+ }): TextInputDef;
4193
+
4194
+ interface ModalConfig<P extends string, F extends Record<string, TextInputDef>, R> {
4195
+ id: P;
4196
+ title: string;
4197
+ fields: F;
4198
+ guards?: readonly Guard[];
4199
+ run: (ctx: ModalContext<Params<P>, keyof F & string>) => Awaitable<R>;
4200
+ }
4201
+ ```
4202
+
4203
+ ### Component contexts
4204
+
4205
+ | Class | Extra members |
4206
+ | ----- | ------------- |
4207
+ | `MessageComponentContext<P, I>` | `params`, `customId`, `message`, `update(input)`, `deferUpdate()`, `showModal(modal)`, `awaitModal(modal, options?)` (+ BaseContext) |
4208
+ | `ButtonContext<P>` | — |
4209
+ | `StringSelectContext<P>` | `values: string[]`, `value: string \| undefined` |
4210
+ | `UserSelectContext<P>` | `values`, `users`, `members` |
4211
+ | `RoleSelectContext<P>` | `values`, `roles` |
4212
+ | `ChannelSelectContext<P>` | `values`, `channels` |
4213
+ | `MentionableSelectContext<P>` | `values`, `users`, `roles`, `members` |
4214
+ | `ModalContext<P, F>` | `params`, `fields: Record<F, string>`, `customId` (+ BaseContext) |
4215
+
4216
+ ### `class ComponentRegistry`
4217
+
4218
+ | Member | Type | Description |
4219
+ | ------ | ---- | ----------- |
4220
+ | `add(...defs: ComponentDef[])` | `this` | Register components (override by namespace). |
4221
+ | `onError(handler: ComponentErrorHandler)` | `this` | Set the error handler. |
4222
+ | `size` | `number` | Count. |
4223
+ | `handle(interaction: Interaction)` | `Promise<boolean>` | Route an interaction; `true` if matched. |
4224
+ | `setLogger(logger: Logger)` | `this` | Debug logger for dispatch tracing. |
4225
+ | `setUsageHook(hook: (event: UsageEvent) => void)` | `this` | Called after each component run (success or error). |
4226
+ | `setDefaultGuards(guards: readonly Guard[])` | `this` | Guards run before each component's own guards. |
4227
+
4228
+ ```ts
4229
+ type ComponentErrorHandler = (error: Error, interaction: RepliableInteraction) => Awaitable<void>;
4230
+ type ComponentDef = ButtonRoute | StringSelectRoute | UserSelectRoute | RoleSelectRoute
4231
+ | ChannelSelectRoute | MentionableSelectRoute | ModalRoute;
4232
+ ```
4233
+
4234
+ ### Custom-id codec
4235
+
4236
+ | Symbol | Description |
4237
+ | ------ | ----------- |
4238
+ | `type ParamNames<S>` | Union of `{param}` names in a pattern. |
4239
+ | `type Params<S>` | The params object a pattern resolves to. |
4240
+ | `type BuildArgs<S>` | `build()` args (none when no params). |
4241
+ | `const MAX_CUSTOM_ID_LENGTH` | `100`. |
4242
+ | `function compilePattern(pattern)` | → `CompiledPattern { pattern, namespace, paramNames }`. |
4243
+ | `function buildCustomId(compiled, params)` | Encode a concrete id. |
4244
+ | `function parseCustomId(customId)` | → `ParsedCustomId { namespace, values }`. |
4245
+ | `function paramsFromValues(paramNames, values)` | Map values onto names. |
4246
+
4247
+ ---
4248
+
4249
+ ## Contexts (shared)
4250
+
4251
+ ### `abstract class BaseContext<I>`
4252
+
4253
+ The base for every interaction context.
4254
+
4255
+ | Member | Type | Description |
4256
+ | ------ | ---- | ----------- |
4257
+ | `interaction` | `I` | Raw discord.js interaction. |
4258
+ | `client` / `user` / `member` / `guild` / `guildId` / `channel` / `channelId` / `locale` | — | Accessors. |
4259
+ | `deferred` / `replied` | `boolean` | Interaction state. |
4260
+ | `reply(input)` | `Promise<InteractionResponse>` | Initial response. |
4261
+ | `replyEphemeral(input)` | `Promise<InteractionResponse>` | Hidden reply. |
4262
+ | `defer({ ephemeral? })` | `Promise<InteractionResponse>` | Acknowledge, respond later. |
4263
+ | `editReply(input)` | `Promise<Message>` | Edit the response. |
4264
+ | `followUp(input)` | `Promise<Message>` | Additional message. |
4265
+ | `send(input)` | `Promise<void>` | State-aware reply/edit/followUp. |
4266
+ | `error(input, options?)` | `Promise<void>` | State-aware preset error embed; defaults to ephemeral (pass `{ ephemeral: false }` to override). |
4267
+ | `success` / `info` / `warn` `(input, options?)` | `Promise<void>` | State-aware preset embeds (green / blue / yellow). |
4268
+ | `replyError(input, options?)` | `Promise<InteractionResponse>` | Initial-reply error embed; defaults to ephemeral. |
4269
+ | `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `Promise<InteractionResponse>` | Initial-reply preset embeds. |
4270
+ | `botPermissions` | `Readonly<PermissionsBitField>` | The bot's resolved permissions in the channel (zero-fetch). |
4271
+ | `botMissing(required)` | `PermissionsString[]` | Permission names the bot is missing here. |
4272
+ | `userMissing(required)` | `PermissionsString[]` | Permission names the invoking user is missing here. |
4273
+ | `awaitMessageFrom(userId?, options?)` | `Promise<Message \| null>` | Wait for the next message from a user in this channel. |
4274
+
4275
+ ```ts
4276
+ type ReplyData = InteractionReplyOptions & { ephemeral?: boolean };
4277
+ type ReplyInput = string | ReplyData;
4278
+ function normalizeReply(input: ReplyInput): InteractionReplyOptions;
4279
+ function asEphemeral(input: ReplyInput): ReplyData;
4280
+ ```
4281
+
4282
+ ---
4283
+
4284
+ ## Plugins
4285
+
4286
+ ```ts
4287
+ interface SpearPlugin { name: string; setup(client: SpearClient): Awaitable<void>; }
4288
+ function definePlugin(plugin: SpearPlugin): SpearPlugin;
4289
+ ```
4290
+
4291
+ ---
4292
+
4293
+ ## Loading
4294
+
4295
+ ```ts
4296
+ interface LoadOptions { extensions?: readonly string[]; recursive?: boolean; } // defaults: [.js,.mjs,.cjs], true
4297
+ function collectModules(dir: string, options?: LoadOptions): Promise<Registerable[]>;
4298
+ function loadInto(client: SpearClient, dir: string, options?: LoadOptions): Promise<number>;
4299
+ ```
4300
+
4301
+ `SpearClient.load(dir, options?)` is the method form of `loadInto`.
4302
+
4303
+ ---
4304
+
4305
+ ## Added in 0.2
4306
+
4307
+ New subsystems, each with a dedicated guide. The `SpearClient` options
4308
+ `{ logger?, dotenv?, cooldown?, prefix?, usage?, embeds?, guards? }` configure them.
4309
+
4310
+ ### Logging — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/logging.md)
4311
+
4312
+ ```ts
4313
+ class Logger { log(level, message, options?): void; debug/info/warn/error(message: string, options?: { error?: Error; data?: Record<string, LogValue> }): void; child(scope: string): Logger; setLevel(level: LogThreshold): this; enabled(level: LogLevel): boolean; addTransport(sink): this; setTransports(sinks): this; }
4314
+ type LogLevel = "debug" | "info" | "warn" | "error";
4315
+ type LogThreshold = LogLevel | "silent";
4316
+ function consoleSink(entry: LogEntry): void;
4317
+ function toError(value: unknown): Error;
4318
+ // client.logger is a Logger; new SpearClient({ logger: { level: "debug" } })
4319
+ ```
4320
+
4321
+ ### Environment — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/env.md)
4322
+
4323
+ ```ts
4324
+ function parseEnv(content: string): Record<string, string>;
4325
+ function loadEnv(options?: { path?: string; override?: boolean }): Record<string, string>;
4326
+ const env: { string(k, fallback?); number(k, fallback?); boolean(k, fallback?); require(k): string };
4327
+ // client auto-loads .env on start(); disable/configure via the dotenv option
4328
+ ```
4329
+
4330
+ ### Cooldowns — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/cooldown.md)
4331
+
4332
+ ```ts
4333
+ interface CooldownConfig { duration: number; scope?: "user" | "guild" | "channel" | "global"; exempt?: { users?: string[]; roles?: string[] }; overrides?: { users?: Record<string, number>; roles?: Record<string, number> }; message?: string | ((remainingMs: number) => string); }
4334
+ class CooldownManager { consume(bucket, input, actor, now?); peek(...); reset(...); clear(); }
4335
+ type CooldownInput = number | CooldownConfig; // a bare ms duration, or a full config
4336
+ type CooldownScope = "user" | "guild" | "channel" | "global";
4337
+ type CooldownResult = { allowed: true } | { allowed: false; remaining: number };
4338
+ interface CooldownActor { userId; roleIds; guildId; channelId; } // also: CooldownExemptions, CooldownOverrides
4339
+ function normalizeCooldown(input: CooldownInput): CooldownConfig;
4340
+ function effectiveDuration(config: CooldownConfig, actor: CooldownActor): number | null; // null = exempt
4341
+ function formatCooldownMessage(config: CooldownConfig, remainingMs: number): string;
4342
+ // command({ cooldown: number | CooldownConfig }); new SpearClient({ cooldown }); client.cooldowns
4343
+ ```
4344
+
4345
+ ### Scheduled tasks — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/scheduler.md)
4346
+
4347
+ ```ts
4348
+ function task(config: { name: string; cron?: string; interval?: number; runOnStart?: boolean; run: (client: SpearClient) => Awaitable<void> }): ScheduledTask;
4349
+ function cron(expression: string): CronExpression; // .next(from?: Date): Date
4350
+ class TaskScheduler { add/remove/list/size/active/start/stop/setLogger; delay/followUp/reconcile (see "Scheduler — one-shot + reconcile") }
4351
+ // client.register(task(...)); client.schedule(config); client.scheduler
4352
+ ```
4353
+
4354
+ ### Prefix commands — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/prefix.md)
4355
+
4356
+ ```ts
4357
+ function prefixCommand<TArgs, R>(config: { name: string; aliases?: readonly string[]; description?: string; cooldown?: CooldownInput; guards?: readonly Guard[]; args?: (a: PrefixArgsBuilder<{}>) => PrefixArgsBuilder<TArgs>; run: (ctx: PrefixContext<TArgs>) => Awaitable<R> }): PrefixCommand;
4358
+ class PrefixContext<TArgs> { message; commandName; args: string[]; rest: string; options: TArgs; client; author; member; guild; guildId; channel; channelId; reply(content); send(content); }
4359
+ // new SpearClient({ prefix: "!" | string[] | { prefix, mention?, ignoreBots?, caseInsensitive? } }); client.prefix
4360
+ // reading others' content needs the privileged MessageContent intent (Intents.messages)
4361
+ ```
4362
+
4363
+ ### Usage tracking — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/usage.md)
4364
+
4365
+ ```ts
4366
+ interface UsageEvent { type: UsageType; name: string; userId?; userTag?; guildId?; channelId?; detail?; outcome?: UsageOutcome; durationMs?: number; options?: Readonly<Record<string, UsageMetaValue>>; errorMessage?: string; timestamp: Date; }
4367
+ type UsageType = "command" | "prefix" | "component" | "event";
4368
+ type UsageOutcome = "success" | "error";
4369
+ type UsageMetaValue = string | number | boolean | null;
4370
+ function formatUsage(event: UsageEvent): string; // default channel-line renderer
4371
+ interface UsageStore { record(event): Awaitable<void>; all(): Awaitable<readonly UsageEvent[]>; }
4372
+ class MemoryUsageStore { record; all; size; byUser(id); clear; }
4373
+ class JsonFileUsageStore { constructor(path: string); record; all; }
4374
+ class UsageTracker { setStore(store); reportTo(channelId, format?); track(event); store; enabled; }
4375
+ // new SpearClient({ usage: { store?, channel?, format? } }); client.usage
4376
+ ```
4377
+
4378
+ ---
4379
+
4380
+ ## Added in 0.3
4381
+
4382
+ Driven by patterns repeated across long-running production bots: the role/
4383
+ permission checks, `.catch(() => null)` fetches, embed factories, pagination
4384
+ /confirm flows, mention/duration parsing, locks, config loaders and pluggable
4385
+ log/usage transports a real Discord bot ends up writing.
4386
+
4387
+ ### Embeds — preset replies
4388
+
4389
+ ```ts
4390
+ class Embeds { constructor(options?: EmbedsOptions); error(input); success(input); info(input); warn(input); build(level, input); readonly colors: EmbedColors; readonly icons: EmbedIcons; }
4391
+ const defaultEmbeds: Embeds; // shared default used when `client.embeds` is unset
4392
+ const DEFAULT_EMBED_COLORS: EmbedColors; // red / green / blue / yellow
4393
+ const DEFAULT_EMBED_ICONS: EmbedIcons; // ⛔ ✅ ℹ️ ⚠️
4394
+ // SpearClient owns one as `client.embeds`; configure via the `embeds` option.
4395
+ // BaseContext gains ctx.success/info/warn/error (state-aware send) + replySuccess/replyInfo/replyWarn/replyError.
4396
+ ```
4397
+
4398
+ ### Guards — declarative preconditions — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/guards.md)
4399
+
4400
+ ```ts
4401
+ type Guard<TCtx extends GuardContext = GuardContext> = (ctx: TCtx) => Awaitable<GuardResult>;
4402
+ interface GuardContext { client; user; member; guild; guildId; channelId; }
4403
+ type GuardResult = boolean | { allowed: false; reason?: string };
4404
+ type RunGuardsResult = { allowed: true } | { allowed: false; reason: string | undefined };
4405
+ function runGuards<TCtx extends GuardContext>(ctx: TCtx, guards?: readonly Guard<TCtx>[]): Promise<RunGuardsResult>;
4406
+ function denied(reason?: string): GuardResult;
4407
+ function guildOnly(reason?: string): Guard;
4408
+ function dmOnly(reason?: string): Guard;
4409
+ function requireAnyRole(roleIds: readonly string[], reason?: string): Guard;
4410
+ function requireAllRoles(roleIds: readonly string[], reason?: string): Guard;
4411
+ function requireOwner(ownerIds: readonly string[], reason?: string): Guard;
4412
+ function requireUserPermissions(permission: PermissionResolvable, reason?: string): Guard;
4413
+ function requireBotPermissions(permission: PermissionResolvable, reason?: string): Guard;
4414
+ function guard<TCtx>(predicate: Guard<TCtx>): Guard<TCtx>;
4415
+ // every built-in guard takes an optional custom `reason`; each has a sensible default message.
4416
+ // per-handler: command({ guards: [...] }), prefixCommand({ guards }), button({ guards }), userCommand({ guards }), ...
4417
+ // client-wide: new SpearClient({ guards: [...] })
4418
+ ```
4419
+
4420
+ ### Context-menu commands — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/context-menus.md)
4421
+
4422
+ ```ts
4423
+ interface ContextMenuMeta { defaultMemberPermissions?: PermissionResolvable | null; nsfw?: boolean; guildOnly?: boolean; nameLocalizations?: LocalizationMap; cooldown?: CooldownInput; guards?: readonly Guard[]; autoDefer?: AutoDeferInput; }
4424
+ function userCommand<R>(config: ContextMenuMeta & { name: string; run: (ctx: UserContextMenuContext) => Awaitable<R> }): UserContextMenu;
4425
+ function messageCommand<R>(config: ContextMenuMeta & { name: string; run: (ctx: MessageContextMenuContext) => Awaitable<R> }): MessageContextMenu;
4426
+ // UserContextMenuContext adds ctx.targetUser, ctx.targetMember; MessageContextMenuContext adds ctx.targetMessage (+ BaseContext).
4427
+ // ContextMenuCommand = UserContextMenu | MessageContextMenu; client.contextMenus is a ContextMenuRegistry.
4428
+ // Deploy slash commands + menus together with client.deployAllCommands({ guildId }).
4429
+ ```
4430
+
4431
+ ### Prefix typed arguments
4432
+
4433
+ ```ts
4434
+ function prefixArgs(): PrefixArgsBuilder<{}>;
4435
+ // builder methods — each requires a `name` and takes an optional options object:
4436
+ // .string(name, { required?, minLength?, maxLength?, default? }) -> string
4437
+ // .integer(name, { required?, minValue?, maxValue?, default? }) -> number
4438
+ // .number(name, { required?, minValue?, maxValue?, default? }) -> number
4439
+ // .boolean(name, { required?, default? }) -> boolean
4440
+ // .snowflake(name, { required?, default? }) -> string (accepts raw ids and <@u>/<#c>/<@&r> mentions)
4441
+ // .duration(name, { required?, default? }) -> number ("1h30m" parsed to ms)
4442
+ // .rest(name, { required?, default? }) -> string (remaining text)
4443
+ // prefixCommand({ args: (a) => a.snowflake("target", { required: true }).duration("dur").rest("reason", { default: "No reason" }), run: (ctx) => ctx.options });
4444
+ ```
4445
+
4446
+ ### Pagination + Confirmation
4447
+
4448
+ ```ts
4449
+ function paginate<T>(interaction, items, { render, pageSize?, user?, timeoutMs?, controls?: "prev-next" | "first-prev-next-last", ephemeral?, namespace?, labels?: { first?; prev?; next?; last? } }): Promise<void>;
4450
+ function buildPaginatorPage<T>(items, page, options): Promise<{ payload; pages }>;
4451
+ function confirm(interaction, { body, title?, confirm?: { label?; style? }, cancel?: { label?; style? }, user?, timeoutMs?, ephemeral?, namespace? }): Promise<{ confirmed: boolean; reason: "confirm" | "cancel" | "timeout"; interaction? }>; // style: "Primary" | "Secondary" | "Success" | "Danger"
4452
+ ```
4453
+
4454
+ ### Primitives
4455
+
4456
+ ```ts
4457
+ class KeyedLock { constructor(options?: { ttl?: number; sweep?: number }); tryAcquire(key, ttl?); run(key, fn, { onBusy?, ttl? }); isHeld(key); forget(key); dispose(); readonly size: number; }
4458
+ const safeFetch = { member, channel, message, user, guild, role, try }; // each returns T | null; also exported standalone as fetchMember/fetchChannel/fetchMessage/fetchUser/fetchGuild/fetchRole/safeTry
4459
+ function withSafeTimeout<T>(p: Promise<T>, ms): Promise<T | null>;
4460
+ function formatDuration(ms, opts?: { locale?: string | UnitLabels; largest?: number; units?: readonly DurationUnit[] }): string; // locale: "en"|"en-US"|"en-GB"|"tr"|"tr-TR" or a custom label set; unknown locales fall back to en
4461
+ function parseDuration(input: string): number | null;
4462
+ function discordTimestamp(date, style?: "t"|"T"|"d"|"D"|"f"|"F"|"R"): string;
4463
+ function relativeTimestamp(date): string;
4464
+ interface CacheStore { get; set; delete; has; increment; rateLimit; clear; }
4465
+ class MemoryCache implements CacheStore { /* TTL, counter, fixed-window rate limit */ }
4466
+ function createCache(): CacheStore; // default in-memory cache
4467
+ function loadConfig<T>({ file, parser?, schema?, encoding? }): T;
4468
+ function loadConfigAsync<T>(opts): Promise<T>;
4469
+ function lookup<K, V>(table, resourceName?): (key: K) => V;
4470
+ function lookupOptional<K, V>(table): (key: K) => V | undefined; // non-throwing variant of lookup
4471
+ ```
4472
+
4473
+ ### Logger transports
4474
+
4475
+ ```ts
4476
+ new Logger({ level, transports: [consoleSink, jsonlSink("./logs/bot.jsonl"), webhookSink({ url, minLevel: "error" })] });
4477
+ function jsonlSink(path: string, { minLevel? }?): LogSink;
4478
+ function webhookSink({ url, minLevel?, username? }): LogSink;
4479
+ function consoleSink(entry: LogEntry): void; // default human-readable console transport
4480
+ // Logger.addTransport(sink), setTransports([sinks])
4481
+ ```
4482
+
4483
+ ### Scheduler — one-shot + reconcile
4484
+
4485
+ ```ts
4486
+ client.scheduler.delay(name, ms, fn) -> { cancel(): boolean };
4487
+ client.scheduler.followUp(name, [10_000, 30_000, 60_000], (i) => ...) -> { cancel(): boolean };
4488
+ client.scheduler.reconcile("voice-sessions", async (client) => { /* once on ready */ });
4489
+ ```
4490
+
4491
+ ### Deploy diff + dry run
4492
+
4493
+ ```ts
4494
+ client.deployAllCommands({ guildId, dryRun: true }); // returns { skipped, body, reason: "dry-run" }
4495
+ client.deployAllCommands({ guildId, strategy: "diff" }); // skips PUT when remote matches
4496
+ client.deployAllCommands({ applicationId: "...", strategy: "diff" }); // explicit app id, no ready required
4497
+ ```
4498
+ ---
4499
+
4500
+ ## Added in 0.4
4501
+
4502
+ Reliability and moderation helpers distilled from production bots: never lose an
4503
+ interaction to the 3-second window, shut down cleanly, run permission/hierarchy
4504
+ preflights, persist per-guild settings, and await replies without hand-rolled
4505
+ collectors.
4506
+
4507
+ ### Auto-defer — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/auto-defer.md)
4508
+
4509
+ ```ts
4510
+ type AutoDeferInput = boolean | { ephemeral?: boolean; delayMs?: number };
4511
+ interface AutoDeferConfig { ephemeral: boolean; delayMs: number; }
4512
+ const DEFAULT_AUTO_DEFER_DELAY_MS = 2000;
4513
+ function normalizeAutoDefer(input?: AutoDeferInput): AutoDeferConfig | undefined;
4514
+ function armAutoDefer(interaction, config: AutoDeferConfig): () => void; // returns a cancel fn
4515
+ type AutoDeferrableInteraction = ChatInputCommandInteraction | UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction;
4516
+ // Enable per handler: command({ autoDefer: true }), userCommand({ autoDefer }), messageCommand({ autoDefer })
4517
+ // Or globally: new SpearClient({ autoDefer: true }). With it on, respond via ctx.send / ctx.editReply.
4518
+ // Arms a timer when the handler starts; defers if it hasn't responded by ~2s, preventing "Unknown interaction" (10062).
4519
+ ```
4520
+
4521
+ ### Graceful shutdown
4522
+
4523
+ ```ts
4524
+ interface GracefulShutdownOptions {
4525
+ signals?: readonly NodeJS.Signals[]; // default ["SIGINT", "SIGTERM"]
4526
+ timeoutMs?: number; // force-exit after this; default 10000
4527
+ exit?: boolean; // call process.exit when done; default true
4528
+ onShutdown?: (signal: NodeJS.Signals) => Awaitable<void>; // runs before client.destroy()
4529
+ logger?: { info?(msg): void; error?(msg, meta?): void };
4530
+ }
4531
+ interface Destroyable { destroy(): Awaitable<void>; } // a discord.js Client qualifies
4532
+ interface ShutdownLogger { info?(message: string): void; error?(message: string, meta?: unknown): void; }
4533
+ function gracefulShutdown(client: Destroyable, options?: GracefulShutdownOptions): () => void;
4534
+ // SpearClient.enableGracefulShutdown(options?) wires it with client.logger and returns a disposer.
4535
+ ```
4536
+
4537
+ ### Permissions & moderation — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/permissions.md)
4538
+
4539
+ ```ts
4540
+ type PermissionHolder = GuildMember | Role;
4541
+ function missingPermissions(channel: GuildBasedChannel, who: PermissionHolder, required: PermissionResolvable): PermissionsString[];
4542
+ function botMissingPermissions(channel: GuildBasedChannel, required: PermissionResolvable): PermissionsString[];
4543
+ function hasPermissions(channel: GuildBasedChannel, who: PermissionHolder, required: PermissionResolvable): boolean;
4544
+ function compareRoles(a: GuildMember, b: GuildMember): number; // by highest-role position
4545
+ function canActOn(actor: GuildMember, target: GuildMember): boolean;
4546
+ function formatPermissions(permissions: PermissionResolvable): string; // human, comma-separated
4547
+
4548
+ type ModerationCheckResult = { ok: true } | { ok: false; reason: string };
4549
+ interface ModerationCheckOptions { moderator: GuildMember; target: GuildMember; me?: GuildMember | null; action?: string; }
4550
+ function moderationCheck(options: ModerationCheckOptions): ModerationCheckResult; // self / owner / role-hierarchy preflight
4551
+ ```
4552
+
4553
+ ### Persistent storage
4554
+
4555
+ ```ts
4556
+ interface KeyValueStore {
4557
+ get<T>(key: string): Promise<T | undefined>;
4558
+ set<T>(key: string, value: T): Promise<void>;
4559
+ has(key: string): Promise<boolean>;
4560
+ delete(key: string): Promise<boolean>;
4561
+ keys(): Promise<string[]>;
4562
+ clear(): Promise<void>;
4563
+ }
4564
+ class MemoryStore implements KeyValueStore { /* deep-cloned in-memory */ }
4565
+ class JsonStore implements KeyValueStore { constructor(path: string); /* atomic JSON file */ }
4566
+ function namespaced(store: KeyValueStore, prefix: string): KeyValueStore;
4567
+
4568
+ interface SettingsManager<T> { readonly defaults: T; readonly store: KeyValueStore; get(id): Promise<T>; set(id, patch: Partial<T>): Promise<T>; reset(id): Promise<void>; }
4569
+ interface CreateSettingsOptions<T> { store: KeyValueStore; defaults: T; namespace?: string; } // namespace default "settings"
4570
+ function createSettings<T extends Record<string, unknown>>(options: CreateSettingsOptions<T>): SettingsManager<T>;
4571
+ ```
4572
+
4573
+ ### Collectors
4574
+
4575
+ ```ts
4576
+ interface AwaitMessageOptions { filter?: (m: Message) => boolean; time?: number; } // time default 60000
4577
+ function awaitMessage(channel: CollectableChannel, options?: AwaitMessageOptions): Promise<Message | null>;
4578
+ interface AwaitComponentOptions { filter?; time?; componentType?: ComponentType; } // time default 60000
4579
+ function awaitComponent(message: Message, options?: AwaitComponentOptions): Promise<MessageComponentInteraction | null>;
4580
+ interface AwaitModalOptions { time?: number; filter?: (i: ModalSubmitInteraction) => boolean; } // time default 120000
4581
+ function showAndAwaitModal(interaction: ModalShowingInteraction, modal: ModalLike, options?: AwaitModalOptions): Promise<ModalSubmitInteraction | null>;
4582
+ // Context sugar: ctx.awaitMessageFrom(userId?, options?) and ctx.awaitModal(modal, options?) (command + component contexts).
4583
+ ```
4584
+
4585
+ ### Discord errors — [guide](https://raw.githubusercontent.com/byigitt/spearkit/main/docs/errors.md)
4586
+
4587
+ ```ts
4588
+ const DiscordErrorCode = { UnknownChannel, UnknownGuild, UnknownMember, UnknownMessage, UnknownUser,
4589
+ UnknownInteraction, MissingAccess, CannotExecuteActionOnDMChannel, CannotSendMessagesToThisUser,
4590
+ MissingPermissions, InvalidFormBodyOrContentType, InteractionHasAlreadyBeenAcknowledged,
4591
+ MaximumNumberOfGuildsReached, MaximumNumberOfReactionsReached } as const; // named RESTJSONErrorCodes
4592
+ type DiscordErrorCodeValue = (typeof DiscordErrorCode)[keyof typeof DiscordErrorCode];
4593
+ function isDiscordError(error: unknown, code?: number | string | readonly (number | string)[]): error is DiscordAPIError;
4594
+ function isHTTPError(error: unknown): error is HTTPError;
4595
+ function isRateLimitError(error: unknown): boolean; // HTTP 429
4596
+ function explainDiscordError(error: unknown): string | null; // end-user-friendly sentence, or null
4597
+ // The default command/component error reply uses explainDiscordError(...) when it can.
4598
+ ```
4599
+
4600
+ ### Message formatting
4601
+
4602
+ ```ts
4603
+ const MESSAGE_CHARACTER_LIMIT = 2000;
4604
+ function truncate(text: string, max: number, suffix?: string): string; // suffix default "…"
4605
+ interface ChunkOptions { max?: number; } // default MESSAGE_CHARACTER_LIMIT
4606
+ function chunkMessage(text: string, options?: ChunkOptions): string[]; // splits on line/word boundaries
4607
+ ```
4608
+
4609
+ ### Dynamic prefixes
4610
+
4611
+ ```ts
4612
+ // PrefixOptions gains a per-message resolver (e.g. a per-guild prefix from a store):
4613
+ interface PrefixOptions { /* …prefix, mention, ignoreBots, caseInsensitive… */
4614
+ dynamic?: (message: Message) => Awaitable<string | readonly string[] | null | undefined>;
4615
+ }
4616
+ // Dynamic prefixes are tried in addition to any static prefix. Keep the resolver fast (cache it).
4617
+ ```
4618
+
4619
+ ---