spearkit 0.3.0 → 0.3.1

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.
@@ -0,0 +1,236 @@
1
+ ---
2
+ name: spearkit
3
+ description: >-
4
+ Write and edit Discord bots built with the spearkit package ("discord.js++"):
5
+ type-safe slash commands, options, subcommands, autocomplete, buttons / select
6
+ menus / modals with custom-id routing, events, cooldowns, scheduled tasks,
7
+ prefix commands, guards, context menus, pagination / confirm, preset embeds,
8
+ structured logging, usage tracking and dotenv. Use whenever a file imports from
9
+ "spearkit", or when asked to build or modify a Discord bot, slash command,
10
+ button, select menu, modal, autocomplete, or interaction handler in a spearkit
11
+ project.
12
+ ---
13
+
14
+ # spearkit
15
+
16
+ spearkit is a developer-experience-first layer over [discord.js](https://discord.js.org).
17
+ It **re-exports the entire discord.js surface** (so it is a drop-in replacement)
18
+ and adds a fully type-safe API: handlers never see `any`/`unknown`. Install with
19
+ `npm install spearkit discord.js`.
20
+
21
+ When a task needs more than this file, load
22
+ [`reference/cheatsheet.md`](reference/cheatsheet.md) (every exported symbol + ready
23
+ recipes). The package also ships `llms-full.txt` (all docs in one file) and a
24
+ `docs/` folder; in a consumer project they sit under `node_modules/spearkit/`.
25
+
26
+ ## Non-negotiable rules
27
+
28
+ 1. **Import only from `"spearkit"`** — spearkit's helpers *and* every discord.js
29
+ symbol (`Client`, `EmbedBuilder`, `GatewayIntentBits`, `REST`, `Routes`,
30
+ `PermissionFlagsBits`, …). Never add a separate `import … from "discord.js"`.
31
+ 2. **Use `SpearClient`** (extends discord.js `Client`; routes interactions).
32
+ `intents` is optional → defaults to `Intents.default` (`[Guilds]`).
33
+ 3. **Co-locate** definition + handler in one object; `client.register(...)` it.
34
+ **Never** write an `interactionCreate` switch or parse custom ids by hand —
35
+ spearkit routes commands by name and components by custom-id namespace.
36
+ 4. **Lifecycle order:** `register(...)` → `await client.start(token)` →
37
+ `await client.deployCommands({ guildId })`. Deploy **after** `start()`; deploy
38
+ only when command *definitions* change (not every restart).
39
+ 5. **The ready event is `clientReady`**, not `ready` (discord.js v14.16 rename).
40
+ 6. **Trust inference.** Required options are non-nullable, optional ones are
41
+ `T | undefined`, `choices` narrow to a literal union, custom-id `{param}`s and
42
+ modal field keys are typed. Do not cast; do not annotate handler args.
43
+ 7. **Hidden replies:** `ctx.replyEphemeral(...)` or `ctx.reply({ content, ephemeral: true })`
44
+ (spearkit normalizes `ephemeral`). Do not hand-set message flags.
45
+
46
+ ## Minimal bot
47
+
48
+ ```ts
49
+ import { SpearClient, Intents, command, option, event } from "spearkit";
50
+
51
+ const client = new SpearClient({ intents: Intents.default });
52
+
53
+ const greet = command({
54
+ name: "greet",
55
+ description: "Greet someone",
56
+ options: { who: option.user({ description: "Who", required: true }) },
57
+ run: (ctx) => ctx.reply(`Hello ${ctx.options.who}!`), // ctx.options.who: User
58
+ });
59
+
60
+ client.register(greet, event("clientReady", (c) => console.log(c.user.tag)));
61
+ await client.start(process.env.DISCORD_TOKEN); // falls back to DISCORD_TOKEN
62
+ await client.deployCommands({ guildId: process.env.GUILD_ID }); // omit guildId → global (slow)
63
+ ```
64
+
65
+ ## Recipes
66
+
67
+ ### Slash command with typed options
68
+
69
+ ```ts
70
+ const echo = command({
71
+ name: "echo",
72
+ description: "Repeat a message",
73
+ options: {
74
+ text: option.string({ description: "What to say", required: true }), // string
75
+ times: option.integer({ description: "Count", minValue: 1, maxValue: 5 }),// number | undefined
76
+ mode: option.string({
77
+ description: "Visibility",
78
+ choices: [{ name: "Everyone", value: "public" }, { name: "Just me", value: "private" }],
79
+ }), // "public" | "private" | undefined
80
+ },
81
+ run: (ctx) =>
82
+ ctx.reply({
83
+ content: ctx.options.text.repeat(ctx.options.times ?? 1),
84
+ ephemeral: ctx.options.mode === "private",
85
+ }),
86
+ });
87
+ ```
88
+
89
+ Option builders: `option.string|integer|number|boolean|user|channel|role|mentionable|attachment`.
90
+ Co-located autocomplete: `option.string({ autocomplete: (ctx) => choices.filter(c => c.startsWith(ctx.value)).map(c => ({ name: c, value: c })) })`.
91
+
92
+ ### Subcommands
93
+
94
+ ```ts
95
+ import { commandGroup, subcommand, subcommandGroup, option } from "spearkit";
96
+
97
+ commandGroup({
98
+ name: "admin", description: "Admin tools", guildOnly: true,
99
+ subcommands: {
100
+ say: subcommand({
101
+ description: "Speak",
102
+ options: { message: option.string({ description: "Text", required: true }) },
103
+ run: (ctx) => ctx.reply(ctx.options.message),
104
+ }),
105
+ },
106
+ groups: {
107
+ users: subcommandGroup({
108
+ description: "Manage users",
109
+ subcommands: {
110
+ ban: subcommand({
111
+ description: "Ban", options: { target: option.user({ description: "Who", required: true }) },
112
+ run: (ctx) => ctx.reply(`Banned ${ctx.options.target.tag}`),
113
+ }),
114
+ },
115
+ }),
116
+ },
117
+ });
118
+ ```
119
+
120
+ ### Components (button / select / modal)
121
+
122
+ ```ts
123
+ import { button, stringSelect, modal, textInput, row } from "spearkit";
124
+
125
+ const vote = button({
126
+ id: "vote:{choice}", // {choice} → typed param
127
+ label: "Yes", style: "Success", // "Primary" | "Secondary" | "Success" | "Danger"
128
+ run: (ctx) => ctx.update(`You chose ${ctx.params.choice}`), // ctx.params.choice: string
129
+ });
130
+
131
+ const colour = stringSelect({
132
+ id: "colour",
133
+ options: [{ label: "Red", value: "red" }, { label: "Blue", value: "blue" }],
134
+ run: (ctx) => ctx.replyEphemeral(ctx.values.join(", ")), // ctx.values: string[]
135
+ });
136
+
137
+ const feedback = modal({
138
+ id: "feedback:{ticket}",
139
+ title: "Feedback",
140
+ fields: {
141
+ summary: textInput({ label: "Summary", required: true }),
142
+ detail: textInput({ label: "Details", style: "Paragraph" }),
143
+ },
144
+ run: (ctx) => ctx.reply(`#${ctx.params.ticket}: ${ctx.fields.summary}`), // params + fields typed
145
+ });
146
+
147
+ client.register(vote, colour, feedback);
148
+
149
+ // build() requires exactly the {param}s the id declares (ids cap at 100 chars):
150
+ await channel.send({ content: "Choose:", components: [row(vote.build({ choice: "yes" })), row(colour.build())] });
151
+ ```
152
+
153
+ Component builders: `button`, `linkButton`, `stringSelect`, `userSelect`,
154
+ `roleSelect`, `channelSelect`, `mentionableSelect`, `modal` (+ `textInput`), `row`.
155
+ Component context: `ctx.params`, `ctx.update`, `ctx.deferUpdate`, `ctx.showModal`,
156
+ `ctx.message`; selects add `ctx.values` (+ `ctx.users/roles/channels/members`);
157
+ modals add `ctx.fields`.
158
+
159
+ ### Guards, cooldown, context menus, pagination/confirm
160
+
161
+ ```ts
162
+ import { command, requireAnyRole, guildOnly, userCommand, paginate, confirm } from "spearkit";
163
+
164
+ const purge = command({
165
+ name: "purge", description: "Delete messages",
166
+ guards: [guildOnly(), requireAnyRole(["MOD_ROLE_ID"])],
167
+ cooldown: { duration: 10_000, scope: "user" },
168
+ run: (ctx) => ctx.replySuccess("Done"),
169
+ });
170
+
171
+ const report = userCommand({ name: "Report", run: (ctx) => ctx.replyEphemeral(`Reported ${ctx.targetUser.tag}`) });
172
+
173
+ await paginate(ctx.interaction, items, {
174
+ pageSize: 10,
175
+ render: (slice, { page, pages }) =>
176
+ new EmbedBuilder().setTitle(`Page ${page + 1}/${pages}`).setDescription(slice.join("\n")),
177
+ });
178
+
179
+ const { confirmed } = await confirm(ctx.interaction, {
180
+ body: "Reset everything?", confirm: { label: "Reset", style: "Danger" },
181
+ });
182
+ if (!confirmed) return ctx.error("Cancelled.");
183
+ ```
184
+
185
+ Guards usable on `command`/`prefixCommand`/`button`/`userCommand`/`messageCommand`
186
+ configs, or client-wide via `new SpearClient({ guards: [...] })`.
187
+
188
+ ## Context (every handler)
189
+
190
+ `reply` · `replyEphemeral` · `defer({ ephemeral? })` · `editReply` · `followUp` ·
191
+ `send` (state-aware) · `error(msg)` · preset embeds `success/info/warn/error`
192
+ (+ `replySuccess/Info/Warn/Error`) · accessors `client/user/member/guild/guildId/
193
+ channel/channelId/locale` · state `deferred/replied`. Commands add `ctx.options`,
194
+ `ctx.commandName`, `ctx.subcommand`, `ctx.showModal`.
195
+
196
+ ## Subsystems (configured on `SpearClient` options)
197
+
198
+ - **Cooldowns** — `command({ cooldown: number | CooldownConfig })` or
199
+ `new SpearClient({ cooldown })`; scopes `user|guild|channel|global`, plus
200
+ `exempt`/`overrides`.
201
+ - **Scheduled tasks** — `task({ name, cron?, interval?, runOnStart?, run })`,
202
+ `client.schedule(...)`, `client.scheduler.delay/followUp/reconcile`, `cron(expr)`.
203
+ - **Prefix commands** — `prefixCommand({ name, aliases?, cooldown?, guards?, args?, run })`
204
+ + `new SpearClient({ prefix: "!" })`. Typed args:
205
+ `args: (a) => a.snowflake("target").duration("d").rest("reason")` → `ctx.options`.
206
+ Reading **other users'** content needs the privileged `MessageContent` intent —
207
+ use `Intents.messages` and enable it in the Developer Portal.
208
+ - **Context menus** — `userCommand`/`messageCommand`; deploy with slash commands
209
+ via `client.deployAllCommands({ guildId, strategy: "diff", dryRun? })`.
210
+ - **Logging** — `client.logger`; `new SpearClient({ logger: { level, transports: [consoleSink, jsonlSink(path), webhookSink({ url })] } })`.
211
+ - **Usage tracking** — `new SpearClient({ usage: { store?, channel?, format? } })`;
212
+ `MemoryUsageStore`, `JsonFileUsageStore`.
213
+ - **Env** — `.env` auto-loads on `start()`; read with `env.string/number/boolean/require`.
214
+ - **Plugins** — `definePlugin({ name, setup(client) })`, then `await client.use(plugin)`.
215
+ - **File-based loading** — `await client.load(dir)`. Imports **compiled JS**
216
+ (default extensions `.js/.mjs/.cjs`); build before running compiled output.
217
+ - **Primitives** — `KeyedLock`, `safeFetch.{member,channel,message,user,guild,role,try}`,
218
+ `formatDuration`/`parseDuration`/`discordTimestamp`/`relativeTimestamp`,
219
+ `MemoryCache`, `loadConfig`.
220
+
221
+ ## Mistakes to avoid
222
+
223
+ - Importing from `"discord.js"` (use `"spearkit"`).
224
+ - Listening on `"ready"` instead of `"clientReady"`.
225
+ - `deployCommands()` before `start()`, or deploying on every restart.
226
+ - Hand-rolling an `interactionCreate` switch or splitting custom ids manually.
227
+ - Passing wrong/missing params to `component.build(...)` (typed; pass exactly the
228
+ declared `{param}`s).
229
+ - Forgetting the `MessageContent` intent when prefix commands must read other
230
+ users' messages.
231
+
232
+ ## Verify your work
233
+
234
+ In a spearkit repo, run `npm run typecheck` (and `npm test`) — option values,
235
+ custom-id params and modal fields are statically checked, so a type error usually
236
+ means the definition and handler disagree.
@@ -0,0 +1,218 @@
1
+ # spearkit cheatsheet
2
+
3
+ Condensed map of every spearkit export. All importable from `"spearkit"` alongside
4
+ the full discord.js surface. For prose and edge cases, read the package's
5
+ `llms-full.txt` or `docs/`.
6
+
7
+ ## Client
8
+
9
+ ```ts
10
+ new SpearClient(options?: Partial<ClientOptions>) // intents optional → Intents.default
11
+ client.commands // CommandRegistry
12
+ client.events // EventRegistry
13
+ client.components // ComponentRegistry
14
+ client.register(...items) // route SlashCommand | EventDef | ComponentDef | PrefixCommand | ContextMenu | ScheduledTask
15
+ client.use(...plugins): Promise<this>
16
+ client.load(dir, options?): Promise<number> // imports compiled JS (.js/.mjs/.cjs)
17
+ client.start(token?): Promise<this> // login; falls back to DISCORD_TOKEN
18
+ client.deployCommands({ guildId? }): Promise<DeployResult> // after start()
19
+ client.deployAllCommands({ guildId?, applicationId?, strategy?: "diff", dryRun? }) // slash + context menus
20
+ client.schedule(taskConfig); client.scheduler // TaskScheduler
21
+ client.cooldowns; client.prefix; client.usage; client.logger; client.embeds
22
+
23
+ const Intents = { none: [], default: [Guilds], guilds: [Guilds, GuildMembers],
24
+ messages: [Guilds, GuildMessages, MessageContent], all: /* every intent */ }
25
+ ```
26
+
27
+ ## Commands & options
28
+
29
+ ```ts
30
+ command({ name, description, options?, defaultMemberPermissions?, nsfw?, guildOnly?,
31
+ nameLocalizations?, descriptionLocalizations?, guards?, cooldown?, run })
32
+ commandGroup({ name, description, subcommands?, groups?, defaultMemberPermissions?, nsfw?, guildOnly?, ... })
33
+ subcommand({ description, options?, run, ... })
34
+ subcommandGroup({ description, subcommands, ... })
35
+
36
+ option.string({ description, required?, choices?, minLength?, maxLength?, autocomplete? }) // string
37
+ option.integer({ description, required?, choices?, minValue?, maxValue?, autocomplete? }) // number
38
+ option.number(...) // number
39
+ option.boolean(...) // boolean
40
+ option.user(...) // User
41
+ option.channel({ ..., channelTypes? }) // channel union
42
+ option.role(...) // Role | APIRole
43
+ option.mentionable(...) // user/role/member
44
+ option.attachment(...) // Attachment
45
+
46
+ // choices: { name, value, nameLocalizations? }[] (value narrows the resolved type to a literal union)
47
+ // autocomplete: (ctx: AutocompleteContext) => Awaitable<OptionChoice[]> ctx.value / ctx.respond(...)
48
+ ```
49
+
50
+ `CommandContext`: `options`, `commandName`, `subcommand`, `showModal(modal)` + BaseContext.
51
+
52
+ ## Components
53
+
54
+ ```ts
55
+ button({ id, label?, style?, emoji?, disabled?, guards?, run }) // style: "Primary"|"Secondary"|"Success"|"Danger"|ButtonStyle.*
56
+ linkButton({ url, label?, emoji?, disabled? }) // returns ButtonBuilder (no handler)
57
+ stringSelect({ id, options, placeholder?, minValues?, maxValues?, disabled?, guards?, run })
58
+ userSelect / roleSelect / mentionableSelect ({ id, placeholder?, minValues?, maxValues?, disabled?, guards?, run })
59
+ channelSelect({ id, channelTypes?, placeholder?, minValues?, maxValues?, disabled?, guards?, run })
60
+ modal({ id, title, fields, run }) // fields: Record<string, TextInputDef>
61
+ textInput({ label, style?, placeholder?, required?, minLength?, maxLength?, value? }) // style: "Short"|"Paragraph"
62
+ row(...components): ActionRowBuilder
63
+
64
+ component.build(params?) // requires exactly the {param}s in id; no args when none
65
+
66
+ // id pattern: "name" or "name:{a}:{b}" → ctx.params.a, ctx.params.b (string)
67
+ // MAX_CUSTOM_ID_LENGTH = 100
68
+ ```
69
+
70
+ Contexts: `ButtonContext` (params); `StringSelectContext` (values, value);
71
+ `UserSelectContext`/`MentionableSelectContext` (values, users, members[, roles]);
72
+ `RoleSelectContext` (values, roles); `ChannelSelectContext` (values, channels);
73
+ `ModalContext` (params, fields). All component contexts: `update`, `deferUpdate`,
74
+ `showModal`, `message`, `customId`.
75
+
76
+ ## Events
77
+
78
+ ```ts
79
+ event("clientReady", (c) => ...) // name from discord.js ClientEvents (args inferred)
80
+ event({ name, once?, run })
81
+ // errors + rejections route to the client's "error" event
82
+ ```
83
+
84
+ ## Contexts (BaseContext — every handler)
85
+
86
+ ```ts
87
+ reply(input) · replyEphemeral(input) · defer({ ephemeral? }) · editReply(input) ·
88
+ followUp(input) · send(input) · error(message)
89
+ success(input) · info(input) · warn(input) · error(input) // preset embeds, state-aware
90
+ replySuccess/replyInfo/replyWarn/replyError(input)
91
+ client · user · member · guild · guildId · channel · channelId · locale · deferred · replied
92
+ // ReplyInput = string | (InteractionReplyOptions & { ephemeral?: boolean })
93
+ ```
94
+
95
+ ## Guards
96
+
97
+ ```ts
98
+ guildOnly(reason?) · dmOnly(reason?) · requireAnyRole(ids, reason?) · requireAllRoles(ids, reason?)
99
+ requireOwner(ownerIds, reason?) · requireUserPermissions(perm, reason?) · requireBotPermissions(perm, reason?)
100
+ guard(predicate) · denied(reason?)
101
+ // per-handler: { guards: [...] } on command/prefixCommand/button/userCommand/messageCommand
102
+ // client-wide: new SpearClient({ guards: [...] })
103
+ ```
104
+
105
+ ## Cooldowns
106
+
107
+ ```ts
108
+ command({ cooldown: number | CooldownConfig })
109
+ new SpearClient({ cooldown: CooldownConfig })
110
+ CooldownConfig = { duration, scope?: "user"|"guild"|"channel"|"global",
111
+ exempt?: { users?, roles? }, overrides?: { users?, roles? },
112
+ message?: string | ((remainingMs) => string) }
113
+ ```
114
+
115
+ ## Scheduler
116
+
117
+ ```ts
118
+ task({ name, cron?, interval?, runOnStart?, run: (client) => ... }) // register() it
119
+ client.schedule(config)
120
+ cron(expr).next(from?: Date): Date
121
+ client.scheduler.delay(name, ms, fn) -> { cancel() }
122
+ client.scheduler.followUp(name, [10_000, 30_000], (i) => ...) -> { cancel() }
123
+ client.scheduler.reconcile(name, async (client) => ...) // once on ready
124
+ ```
125
+
126
+ ## Prefix commands
127
+
128
+ ```ts
129
+ new SpearClient({ prefix: "!" | string[] | { prefix, mention?, ignoreBots?, caseInsensitive? } })
130
+ prefixCommand({ name, aliases?, description?, cooldown?, guards?, args?, run })
131
+ // PrefixContext: message, commandName, args: string[], rest: string, reply, send, options (when args used)
132
+ prefixArgs() builder: .string(n) .integer(n) .number(n) .boolean(n) .snowflake(n) .duration(n) .rest(n)
133
+ prefixCommand({ args: (a) => a.snowflake("target").duration("d").rest("reason"), run: (ctx) => ctx.options })
134
+ // reading other users' content requires the privileged MessageContent intent (Intents.messages)
135
+ ```
136
+
137
+ ## Context menus
138
+
139
+ ```ts
140
+ userCommand({ name, guards?, cooldown?, run: (ctx) => ctx.targetUser / ctx.targetMember })
141
+ messageCommand({ name, guards?, cooldown?, run: (ctx) => ctx.targetMessage })
142
+ // deploy with slash commands via client.deployAllCommands(...)
143
+ ```
144
+
145
+ ## Pagination & confirm
146
+
147
+ ```ts
148
+ paginate(interaction, items, { render, pageSize?, user?, timeoutMs?, controls?: "prev-next"|"first-prev-next-last", ephemeral?, labels?, namespace? })
149
+ buildPaginatorPage(items, page, options) -> { payload, pages }
150
+ confirm(interaction, { body, title?, confirm?, cancel?, user?, timeoutMs?, ephemeral?, namespace? })
151
+ -> { confirmed: boolean, reason: "confirm"|"cancel"|"timeout", interaction? }
152
+ ```
153
+
154
+ ## Embeds
155
+
156
+ ```ts
157
+ client.embeds.{ error|success|info|warn|build(level, input) }
158
+ createEmbeds(opts?) // alias for new Embeds(opts)
159
+ new SpearClient({ embeds: { /* colors/icons per level */ } })
160
+ // BaseContext exposes ctx.success/info/warn/error + replySuccess/Info/Warn/Error
161
+ ```
162
+
163
+ ## Logging
164
+
165
+ ```ts
166
+ new Logger({ level, transports: [consoleSink, jsonlSink("./logs/bot.jsonl"), webhookSink({ url, minLevel: "error" })] })
167
+ logger.debug/info/warn/error(message, { error?, data? }); logger.child(scope); logger.setLevel(level); logger.enabled(level)
168
+ logger.addTransport(sink); logger.setTransports([sinks])
169
+ new SpearClient({ logger: { level: "debug" } }) // client.logger
170
+ ```
171
+
172
+ ## Usage tracking
173
+
174
+ ```ts
175
+ new SpearClient({ usage: { store?, channel?, format? } }) // client.usage
176
+ MemoryUsageStore // record, all, size, byUser(id), clear
177
+ JsonFileUsageStore(path)
178
+ // UsageEvent { type: "command"|"prefix"|"component"|"event", name, userId?, userTag?, guildId?, channelId?,
179
+ // detail?, outcome?: "success"|"error", durationMs?, errorMessage?, options?, timestamp }
180
+ ```
181
+
182
+ ## Env
183
+
184
+ ```ts
185
+ loadEnv({ path?, override? }); parseEnv(content)
186
+ env.string(k, fallback?) · env.number(k, fallback?) · env.boolean(k, fallback?) · env.require(k)
187
+ // SpearClient auto-loads .env on start(); configure/disable via the `dotenv` option
188
+ ```
189
+
190
+ ## Plugins & loading
191
+
192
+ ```ts
193
+ definePlugin({ name, setup(client) }); await client.use(plugin)
194
+ collectModules(dir, options?); loadInto(client, dir, options?)
195
+ client.load(dir, { extensions?: readonly string[], recursive? }) // defaults [.js,.mjs,.cjs], true
196
+ ```
197
+
198
+ ## Primitives
199
+
200
+ ```ts
201
+ new KeyedLock(); lock.tryAcquire(key, ttl?); lock.run(key, fn, { onBusy?, ttl? }); lock.isHeld(key); lock.forget(key); lock.dispose()
202
+ safeFetch.{ member, channel, message, user, guild, role, try } // each returns T | null
203
+ withSafeTimeout(promise, ms) // T | null
204
+ formatDuration(ms, { locale?: "en"|"tr"|UnitLabels, largest?, units? })
205
+ parseDuration(input): number | null
206
+ discordTimestamp(date, style?: "t"|"T"|"d"|"D"|"f"|"F"|"R"); relativeTimestamp(date)
207
+ new MemoryCache() // CacheStore: get/set/delete/has/increment/rateLimit/clear (TTL + counters + fixed-window rate limit)
208
+ loadConfig({ file, parser?, schema?, encoding? }); loadConfigAsync(opts) // JSON/JSON5/YAML
209
+ lookup(table, resourceName?) -> (key) => value
210
+ ```
211
+
212
+ ## Error handling
213
+
214
+ ```ts
215
+ client.commands.onError((error, interaction) => interaction.reply({ content: "Oops.", flags: 64 }))
216
+ client.components.onError((error, interaction) => console.error(error))
217
+ // handler errors never crash the process; uncaught ones flow through client.logger
218
+ ```
package/AGENTS.md ADDED
@@ -0,0 +1,164 @@
1
+ # AGENTS.md — writing code with spearkit
2
+
3
+ Authoritative rules for AI agents (and humans) writing Discord bots with
4
+ **spearkit**. Read this first; it is short on purpose. For depth, load
5
+ [`llms-full.txt`](./llms-full.txt) (every guide + the full API reference in one
6
+ file) or browse [`docs/`](./docs) and [`examples/`](./examples).
7
+
8
+ spearkit is **discord.js++**: it re-exports the entire discord.js surface (so it
9
+ is a drop-in replacement) and adds a fully type-safe layer for slash commands,
10
+ options, components, events, cooldowns, scheduled tasks, prefix commands,
11
+ logging, usage tracking and dotenv. Install: `npm install spearkit discord.js`.
12
+
13
+ ## Golden rules
14
+
15
+ 1. **Import everything from `"spearkit"`.** Both spearkit's helpers and every
16
+ discord.js symbol (`Client`, `EmbedBuilder`, `GatewayIntentBits`, `REST`,
17
+ `Routes`, …) come from `"spearkit"`. Do **not** add a separate
18
+ `import … from "discord.js"`.
19
+ 2. **Use `SpearClient`, not `Client`.** It extends discord.js `Client` and wires
20
+ up command/event/component routing. `intents` may be omitted (defaults to
21
+ `Intents.default`).
22
+ 3. **Co-locate definition + handler.** A command's options and `run`, a button's
23
+ look and click logic, a modal's fields and submit — each in one object.
24
+ 4. **Never write an `interactionCreate` switch.** Define commands/components and
25
+ `client.register(...)` them; spearkit routes by command name and custom-id
26
+ namespace and decodes typed params for you.
27
+ 5. **Lifecycle order:** `client.register(...)` → `await client.start(token)` →
28
+ `await client.deployCommands({ guildId })`. Deploy **after** `start()` (it
29
+ uses the client's authenticated REST and the ready application id).
30
+ 6. **The ready event is `clientReady`**, not `ready` (discord.js v14.16 rename).
31
+ 7. **Trust inference.** Required options are non-nullable, optional ones are
32
+ `T | undefined`, `choices` narrow to a literal union, custom-id `{param}`s and
33
+ modal field keys are typed. Don't cast; don't annotate handler args.
34
+
35
+ ## Canonical patterns
36
+
37
+ ### Client + lifecycle
38
+
39
+ ```ts
40
+ import { SpearClient, Intents, command, option, event } from "spearkit";
41
+
42
+ const client = new SpearClient({ intents: Intents.default }); // Intents: none|default|guilds|messages|all
43
+
44
+ const ready = event("clientReady", (c) => console.log(`Online as ${c.user.tag}`));
45
+
46
+ client.register(/* commands, events, components */ ready);
47
+ await client.start(process.env.DISCORD_TOKEN); // falls back to DISCORD_TOKEN
48
+ await client.deployCommands({ guildId: process.env.GUILD_ID }); // omit guildId for global (slow)
49
+ ```
50
+
51
+ ### Slash commands + typed options
52
+
53
+ ```ts
54
+ const echo = command({
55
+ name: "echo",
56
+ description: "Repeat a message",
57
+ options: {
58
+ text: option.string({ description: "What to say", required: true }), // string
59
+ times: option.integer({ description: "Count", minValue: 1, maxValue: 5 }), // number | undefined
60
+ who: option.user({ description: "Mention", required: true }), // User
61
+ },
62
+ run: (ctx) =>
63
+ ctx.reply(ctx.options.text.repeat(ctx.options.times ?? 1)),
64
+ });
65
+ ```
66
+
67
+ Builders: `option.string|integer|number|boolean|user|channel|role|mentionable|attachment`.
68
+ Autocomplete is co-located: `option.string({ autocomplete: (ctx) => [{ name, value }] })`.
69
+ Subcommands: `commandGroup({ name, description, subcommands, groups })` with
70
+ `subcommand(...)` and `subcommandGroup(...)`.
71
+
72
+ ### Interactive components
73
+
74
+ ```ts
75
+ import { button, stringSelect, modal, textInput, row } from "spearkit";
76
+
77
+ const vote = button({
78
+ id: "vote:{choice}", // {choice} → typed param
79
+ label: "Yes", style: "Success", // "Primary"|"Secondary"|"Success"|"Danger"
80
+ run: (ctx) => ctx.update(`You chose ${ctx.params.choice}`), // ctx.params.choice: string
81
+ });
82
+
83
+ const colour = stringSelect({
84
+ id: "colour",
85
+ options: [{ label: "Red", value: "red" }, { label: "Blue", value: "blue" }],
86
+ run: (ctx) => ctx.replyEphemeral(ctx.values.join(", ")), // ctx.values: string[]
87
+ });
88
+
89
+ const feedback = modal({
90
+ id: "feedback:{ticket}",
91
+ title: "Feedback",
92
+ fields: { summary: textInput({ label: "Summary", required: true }) },
93
+ run: (ctx) => ctx.reply(`#${ctx.params.ticket}: ${ctx.fields.summary}`), // typed params + fields
94
+ });
95
+
96
+ client.register(vote, colour, feedback);
97
+
98
+ // Put them in a message — build() requires exactly the params the id declares:
99
+ await channel.send({ content: "Choose:", components: [row(vote.build({ choice: "yes" })), row(colour.build())] });
100
+ ```
101
+
102
+ Builders: `button`, `linkButton`, `stringSelect`, `userSelect`, `roleSelect`,
103
+ `channelSelect`, `mentionableSelect`, `modal` (+ `textInput`), `row`.
104
+ Component context: `ctx.params`, `ctx.update(...)`, `ctx.deferUpdate()`,
105
+ `ctx.showModal(modal)`; selects add `ctx.values` (+ `ctx.users/roles/channels/members`);
106
+ modals add `ctx.fields`.
107
+
108
+ ### Context (every handler)
109
+
110
+ `ctx.reply` · `ctx.replyEphemeral` · `ctx.defer({ ephemeral? })` · `ctx.editReply`
111
+ · `ctx.followUp` · `ctx.send` (state-aware) · `ctx.error(msg)` · preset embeds
112
+ `ctx.success/info/warn/error` (+ `ctx.replySuccess/Info/Warn/Error`) · accessors
113
+ `ctx.client/user/member/guild/guildId/channel/channelId/locale` · state
114
+ `ctx.deferred/replied`. For hidden replies prefer `ctx.replyEphemeral(...)` or
115
+ `ctx.reply({ content, ephemeral: true })` — spearkit normalizes it.
116
+
117
+ ## Subsystems (each has a guide in docs/)
118
+
119
+ - **Guards** — `guards: [...]` on `command`/`prefixCommand`/`button`/`userCommand`/
120
+ `messageCommand`, or client-wide `new SpearClient({ guards })`. Helpers:
121
+ `guildOnly`, `dmOnly`, `requireAnyRole`, `requireAllRoles`, `requireOwner`,
122
+ `requireUserPermissions`, `requireBotPermissions`, `guard`, `denied`.
123
+ - **Cooldowns** — `command({ cooldown: number | CooldownConfig })` or
124
+ `new SpearClient({ cooldown })`; scopes `user|guild|channel|global`, `exempt`,
125
+ `overrides`.
126
+ - **Scheduled tasks** — `task({ name, cron?, interval?, runOnStart?, run })`,
127
+ `client.schedule(...)`, `client.scheduler.delay/followUp/reconcile`, `cron(expr)`.
128
+ - **Prefix commands** — `prefixCommand({ name, aliases?, cooldown?, guards?, args?, run })`
129
+ + `new SpearClient({ prefix: "!" })`. Typed args:
130
+ `args: (a) => a.snowflake("target").duration("d").rest("reason")` → `ctx.options`.
131
+ Reading **other users'** message content needs the privileged `MessageContent`
132
+ intent — use `Intents.messages` and enable it in the Developer Portal.
133
+ - **Context menus** — `userCommand({ name, run: (ctx) => ctx.targetUser })`,
134
+ `messageCommand({ name, run: (ctx) => ctx.targetMessage })`. Deploy slash +
135
+ menus together with `client.deployAllCommands({ guildId, strategy: "diff", dryRun? })`.
136
+ - **Pagination / confirm** — `paginate(interaction, items, { pageSize, render, user?, timeoutMs?, controls?, ephemeral? })`;
137
+ `const { confirmed } = await confirm(interaction, { body, confirm?, cancel?, user?, timeoutMs?, ephemeral? })`.
138
+ - **Logging** — `client.logger`; configure via `new SpearClient({ logger: { level, transports: [consoleSink, jsonlSink(path), webhookSink({ url })] } })`.
139
+ - **Usage tracking** — `new SpearClient({ usage: { store?, channel?, format? } })`;
140
+ `MemoryUsageStore`, `JsonFileUsageStore`.
141
+ - **Env** — `.env` auto-loads on `start()`; read with `env.string/number/boolean/require`.
142
+ - **Plugins** — `definePlugin({ name, setup(client) })`, then `await client.use(plugin)`.
143
+ - **File-based loading** — `await client.load(dir)`. Imports **compiled JS**
144
+ (default extensions `.js/.mjs/.cjs`), so build before running compiled output.
145
+ - **Primitives** — `KeyedLock`, `safeFetch.{member,channel,message,user,guild,role,try}`,
146
+ `formatDuration`/`parseDuration`/`discordTimestamp`/`relativeTimestamp`,
147
+ `MemoryCache`, `loadConfig`.
148
+
149
+ ## Common mistakes to avoid
150
+
151
+ - Importing from `"discord.js"` in a spearkit project (use `"spearkit"`).
152
+ - Listening on `"ready"` instead of `"clientReady"`.
153
+ - Calling `deployCommands()` before `start()`.
154
+ - Hand-rolling an `interactionCreate` switch or parsing custom ids by hand.
155
+ - Passing the wrong/missing params to `component.build(...)` (it is typed — pass
156
+ exactly the `{param}`s in the id; custom ids cap at 100 chars).
157
+ - Deploying on every restart — only deploy when command *definitions* change.
158
+
159
+ ## For maintainers
160
+
161
+ `docs/*.md` is the single source of truth. `llms.txt`, `llms-full.txt` and the
162
+ `website/public/` copies are generated — run `npm run docs:llms` after editing
163
+ docs. A ready-to-use agent skill lives at
164
+ [`.claude/skills/spearkit/`](./.claude/skills/spearkit).
package/README.md CHANGED
@@ -38,6 +38,19 @@ npm install spearkit discord.js
38
38
  - **Guides & API reference** ([`docs/`](./docs)) — the Markdown the site is built from.
39
39
  - **Examples** ([`examples/`](./examples)) — one folder per topic (commands, options, components, events, loading, …).
40
40
 
41
+ ## For AI agents
42
+
43
+ spearkit ships machine-readable guidance so coding agents write correct code with it:
44
+
45
+ - **[`AGENTS.md`](./AGENTS.md)** — the golden rules and canonical patterns, auto-read by most coding agents.
46
+ - **[`llms.txt`](./llms.txt)** — an [llmstxt.org](https://llmstxt.org) index of the docs; **[`llms-full.txt`](./llms-full.txt)** is every guide and the full API reference in one file.
47
+ - **Agent skill** ([`.claude/skills/spearkit/`](./.claude/skills/spearkit)) — a drop-in [Agent Skill](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills) with recipes and a symbol cheatsheet.
48
+
49
+ `AGENTS.md`, `llms.txt`, `llms-full.txt`, `docs/` and the agent skill ship in the
50
+ npm package as plain files (no install hook), so an installed copy lives under
51
+ `node_modules/spearkit/` — e.g. `node_modules/spearkit/.claude/skills/spearkit/SKILL.md`.
52
+ The `llms` files are generated from `docs/`; run `npm run docs:llms` after editing docs.
53
+
41
54
  ## Quick start
42
55
 
43
56
  ```ts