spearkit 0.2.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.
- package/.claude/skills/spearkit/SKILL.md +236 -0
- package/.claude/skills/spearkit/reference/cheatsheet.md +218 -0
- package/AGENTS.md +164 -0
- package/README.md +22 -0
- package/dist/index.cjs +1767 -228
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1223 -361
- package/dist/index.d.ts +1223 -361
- package/dist/index.js +1726 -233
- package/dist/index.js.map +1 -1
- package/docs/README.md +63 -0
- package/docs/api-reference.md +589 -0
- package/docs/client.md +207 -0
- package/docs/commands.md +198 -0
- package/docs/components.md +274 -0
- package/docs/context.md +201 -0
- package/docs/cooldown.md +124 -0
- package/docs/env.md +130 -0
- package/docs/events.md +152 -0
- package/docs/getting-started.md +147 -0
- package/docs/loading.md +142 -0
- package/docs/logging.md +195 -0
- package/docs/migration.md +160 -0
- package/docs/options.md +163 -0
- package/docs/plugins.md +116 -0
- package/docs/prefix.md +180 -0
- package/docs/scheduler.md +87 -0
- package/docs/usage.md +178 -0
- package/llms-full.txt +3367 -0
- package/llms.txt +39 -0
- package/package.json +9 -3
|
@@ -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
|
@@ -22,6 +22,15 @@ npm install spearkit discord.js
|
|
|
22
22
|
- **Usage tracking** — record who used what to a database and/or a Discord channel ([guide](./docs/usage.md)).
|
|
23
23
|
- **dotenv built in** — auto-load `.env` and read typed env vars ([guide](./docs/env.md)).
|
|
24
24
|
- **Plugins & file-based loading** for organising larger bots.
|
|
25
|
+
- **Guards** — declarative `requireAnyRole`/`requireUserPermissions`/`guildOnly`/`requireOwner` preconditions on commands, components and prefix commands ([API ref](./docs/api-reference.md#guards--declarative-preconditions)).
|
|
26
|
+
- **Context-menu commands** — `userCommand` / `messageCommand` with typed `targetUser` / `targetMessage` ([API ref](./docs/api-reference.md#context-menu-commands)).
|
|
27
|
+
- **Preset embeds** — `ctx.success/info/warn/error` and `client.embeds` factory with configurable colors/icons ([API ref](./docs/api-reference.md#embeds--preset-replies)).
|
|
28
|
+
- **Pagination & confirmation** — `paginate(...)` and `confirm(...)` button flows with user-only filter and timeout.
|
|
29
|
+
- **Typed prefix args** — `prefixCommand({ args: a => a.snowflake("target").duration("d").rest("reason"), run: ctx => ctx.options })`.
|
|
30
|
+
- **Primitives** — `KeyedLock`, `safeFetch.{member,channel,...}`, `formatDuration`/`parseDuration`/`discordTimestamp`, `MemoryCache` (TTL + counters + rate limit), `loadConfig` (JSON/JSON5/YAML).
|
|
31
|
+
- **Logger transports** — multi-sink (`consoleSink`, `jsonlSink`, `webhookSink`); per-level routing.
|
|
32
|
+
- **Scheduler extras** — `scheduler.delay/followUp/reconcile` for one-shot jobs and on-ready recovery.
|
|
33
|
+
- **Deploy strategy** — `deployAllCommands({ dryRun, strategy: "diff" })` for safe CI deploys.
|
|
25
34
|
|
|
26
35
|
## Documentation
|
|
27
36
|
|
|
@@ -29,6 +38,19 @@ npm install spearkit discord.js
|
|
|
29
38
|
- **Guides & API reference** ([`docs/`](./docs)) — the Markdown the site is built from.
|
|
30
39
|
- **Examples** ([`examples/`](./examples)) — one folder per topic (commands, options, components, events, loading, …).
|
|
31
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
|
+
|
|
32
54
|
## Quick start
|
|
33
55
|
|
|
34
56
|
```ts
|