@rcpooley/botyard 0.1.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/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # botyard
2
+
3
+ Read and send in Discord through your own bot, hosted by
4
+ [Botyard](https://botyard.rcpooley.dev). Made for AI agents like Claude Code,
5
+ and fine for people too.
6
+
7
+ ```sh
8
+ npm install -g @rcpooley/botyard # Node 20 or newer
9
+ ```
10
+
11
+ ## Connecting
12
+
13
+ You don't type a key. On your bot's page at Botyard, click **Connect an agent**,
14
+ choose what the agent may do, and paste the prompt you get into your agent. It
15
+ runs:
16
+
17
+ ```sh
18
+ botyard connect byc_…
19
+ ```
20
+
21
+ The code works once and expires in an hour. The CLI exchanges it for an API key
22
+ and stores the key in `~/.config/botyard/config.json`, which only you can read.
23
+ It never prints the key. Several bots can be connected; one is the default.
24
+
25
+ ## Using it
26
+
27
+ Run `botyard` with no arguments for the full guide: the connected bots, every
28
+ command, and the rules an agent should follow. A taste:
29
+
30
+ ```sh
31
+ botyard servers
32
+ botyard read "My Server" general --limit 20
33
+ botyard send "My Server" general "hello from my agent"
34
+ echo "multi-line text" | botyard send "My Server" general -
35
+ botyard react "My Server" general 1234567890 👍
36
+ ```
37
+
38
+ `--json` prints the raw API response. `--bot <name>` picks a bot.
39
+ `botyard skill install` adds a Claude Code skill so future sessions know the CLI
40
+ is there.
41
+
42
+ For scripts, `BOTYARD_KEY` (and optionally `BOTYARD_URL`) overrides the stored
43
+ config.
44
+
45
+ ## What it can't do
46
+
47
+ Delete messages, kick, ban, or manage channels and roles. Botyard leaves those
48
+ out on purpose. Each key is also limited to the permissions and servers its
49
+ owner picked on Botyard, where it can be changed or revoked at any time.
package/dist/api.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The Botyard agent API, over fetch. Every failure becomes an Error whose
3
+ * message is the API's own sentence -- those are written for whoever is calling,
4
+ * so the CLI passes them on rather than inventing its own.
5
+ */
6
+ export class ApiFailure extends Error {
7
+ status;
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ }
13
+ /** A wait Discord or Botyard asks for that is short enough to just sit out, once. */
14
+ const MAX_RETRY_WAIT_S = 10;
15
+ export async function call(url, key, method, path, body, retried = false) {
16
+ let res;
17
+ try {
18
+ res = await fetch(`${url.replace(/\/+$/, '')}/api/v1${path}`, {
19
+ method,
20
+ headers: {
21
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
22
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
23
+ 'user-agent': 'botyard-cli',
24
+ },
25
+ body: body === undefined ? undefined : JSON.stringify(body),
26
+ signal: AbortSignal.timeout(30_000),
27
+ });
28
+ }
29
+ catch (e) {
30
+ throw new ApiFailure(0, `Could not reach ${url}: ${e instanceof Error ? e.message : String(e)}`);
31
+ }
32
+ if (res.status === 429 && !retried) {
33
+ const wait = Number(res.headers.get('retry-after'));
34
+ if (Number.isFinite(wait) && wait > 0 && wait <= MAX_RETRY_WAIT_S) {
35
+ await new Promise((r) => setTimeout(r, wait * 1000));
36
+ return call(url, key, method, path, body, true);
37
+ }
38
+ }
39
+ const text = await res.text();
40
+ if (!res.ok) {
41
+ let message = `HTTP ${res.status}`;
42
+ try {
43
+ message = JSON.parse(text).error ?? message;
44
+ }
45
+ catch {
46
+ // Not JSON: something in front of Botyard answered. The status will do.
47
+ }
48
+ throw new ApiFailure(res.status, message);
49
+ }
50
+ return (text ? JSON.parse(text) : undefined);
51
+ }
52
+ /** A path segment from something a person typed: a name with spaces, a #channel, an emoji. */
53
+ export const seg = (s) => encodeURIComponent(s);
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { hostname } from 'node:os';
4
+ import { parseArgs } from 'node:util';
5
+ import { ApiFailure, call, seg } from "./api.js";
6
+ import { configPath, DEFAULT_URL, load, pickBot, save } from "./config.js";
7
+ import * as fmt from "./format.js";
8
+ import { installSkill, SKILL, skillDir, skillInstalled } from "./skill.js";
9
+ /**
10
+ * botyard: Discord through Botyard, from a terminal. Made for AI agents first,
11
+ * so the guide (no arguments, or --help) is written to one, and every error is
12
+ * a sentence saying what to do next.
13
+ *
14
+ * One table of commands drives dispatch, the guide and per-command help, so
15
+ * none of them can describe a command the others do not know.
16
+ */
17
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
+ // --- what a command needs --------------------------------------------------
19
+ /** The bot to act as: BOTYARD_KEY wins (for scripts), then --bot, then the default. */
20
+ function target(opts) {
21
+ if (process.env.BOTYARD_KEY)
22
+ return { url: process.env.BOTYARD_URL || DEFAULT_URL, key: process.env.BOTYARD_KEY, bot: null };
23
+ const bot = pickBot(load(), opts.bot);
24
+ return { url: bot.url, key: bot.key, bot };
25
+ }
26
+ async function api(opts, method, path, body) {
27
+ const { url, key } = target(opts);
28
+ return call(url, key, method, path, body);
29
+ }
30
+ const json = (value) => JSON.stringify(value, null, 2);
31
+ /** Message text: the words given, or stdin when the text is "-". */
32
+ function text(raw) {
33
+ if (raw !== '-')
34
+ return raw;
35
+ const input = readFileSync(0, 'utf8');
36
+ return input.endsWith('\n') ? input.slice(0, -1) : input;
37
+ }
38
+ function where(server, channel) {
39
+ return `/servers/${seg(server)}/channels/${seg(channel.replace(/^#/, ''))}`;
40
+ }
41
+ // --- the commands ------------------------------------------------------------
42
+ const COMMANDS = [
43
+ {
44
+ name: 'me',
45
+ group: 'Reading',
46
+ usage: 'me',
47
+ summary: 'which bot this is, what it may do, and the servers it can reach',
48
+ args: 0,
49
+ run: async (_a, opts) => {
50
+ const m = await api(opts, 'GET', '/me');
51
+ return opts.json ? json(m) : fmt.me(m);
52
+ },
53
+ },
54
+ {
55
+ name: 'servers',
56
+ group: 'Reading',
57
+ usage: 'servers',
58
+ summary: 'servers you can reach',
59
+ args: 0,
60
+ run: async (_a, opts) => {
61
+ const list = await api(opts, 'GET', '/servers');
62
+ return opts.json ? json(list) : fmt.servers(list);
63
+ },
64
+ },
65
+ {
66
+ name: 'channels',
67
+ group: 'Reading',
68
+ usage: 'channels <server>',
69
+ summary: 'channels and active threads in a server',
70
+ args: 1,
71
+ run: async ([server], opts) => {
72
+ const list = await api(opts, 'GET', `/servers/${seg(server)}/channels`);
73
+ return opts.json ? json(list) : fmt.channels(list);
74
+ },
75
+ },
76
+ {
77
+ name: 'read',
78
+ group: 'Reading',
79
+ usage: 'read <server> <channel> [--limit 50] [--before <message id>]',
80
+ summary: 'recent messages, oldest first',
81
+ details: ['--limit is 1-100 (default 50). When there are older messages, the output ends with the command for the next page.'],
82
+ args: 2,
83
+ run: async ([server, channel], opts) => {
84
+ const q = new URLSearchParams();
85
+ if (opts.limit)
86
+ q.set('limit', opts.limit);
87
+ if (opts.before)
88
+ q.set('before', opts.before);
89
+ const res = await api(opts, 'GET', `${where(server, channel)}/messages${q.size ? `?${q}` : ''}`);
90
+ if (opts.json)
91
+ return json(res);
92
+ const more = res.older ? `Older: botyard read ${quote(server)} ${quote(channel)} --before ${res.older}` : null;
93
+ return fmt.messages(res.messages, `#${channel.replace(/^#/, '')} in ${server}`, more);
94
+ },
95
+ },
96
+ {
97
+ name: 'message',
98
+ group: 'Reading',
99
+ usage: 'message <server> <channel> <message id>',
100
+ summary: 'one message',
101
+ args: 3,
102
+ run: async ([server, channel, id], opts) => {
103
+ const m = await api(opts, 'GET', `${where(server, channel)}/messages/${seg(id)}`);
104
+ return opts.json ? json(m) : fmt.message(m);
105
+ },
106
+ },
107
+ {
108
+ name: 'pins',
109
+ group: 'Reading',
110
+ usage: 'pins <server> <channel>',
111
+ summary: 'pinned messages',
112
+ args: 2,
113
+ run: async ([server, channel], opts) => {
114
+ const res = await api(opts, 'GET', `${where(server, channel)}/pins`);
115
+ if (opts.json)
116
+ return json(res);
117
+ return fmt.messages(res.messages, `pins in #${channel.replace(/^#/, '')}`, res.more ? '(more pins not shown)' : null);
118
+ },
119
+ },
120
+ {
121
+ name: 'reactions',
122
+ group: 'Reading',
123
+ usage: 'reactions <server> <channel> <message id>',
124
+ summary: 'who reacted with what',
125
+ details: ['One Discord call per emoji, so use it when you need names; `read` already shows the counts.'],
126
+ args: 3,
127
+ run: async ([server, channel, id], opts) => {
128
+ const list = await api(opts, 'GET', `${where(server, channel)}/messages/${seg(id)}/reactions`);
129
+ return opts.json ? json(list) : fmt.reactions(list);
130
+ },
131
+ },
132
+ {
133
+ name: 'send',
134
+ group: 'Writing',
135
+ usage: 'send <server> <channel> <text | -> [--reply-to <id>] [--mentions users|none|all]',
136
+ summary: 'post a message ("-" reads the text from stdin)',
137
+ details: [
138
+ 'Up to 2000 characters. Use "-" and pipe the text in for anything multi-line or full of quotes.',
139
+ 'Mentions ping people by default, but @everyone, @here and roles go out as plain text.',
140
+ '--mentions all lets them ping; only use it when your human asked for exactly that. --mentions none pings nobody.',
141
+ ],
142
+ args: 3,
143
+ rest: true,
144
+ run: async ([server, channel, words], opts) => {
145
+ const body = { content: text(words), replyTo: opts['reply-to'], mentions: opts.mentions };
146
+ const m = await api(opts, 'POST', `${where(server, channel)}/messages`, body);
147
+ return opts.json ? json(m) : `Sent ${m.id}\n${m.url}`;
148
+ },
149
+ },
150
+ {
151
+ name: 'edit',
152
+ group: 'Writing',
153
+ usage: 'edit <server> <channel> <message id> <text | ->',
154
+ summary: "replace the text of one of the bot's own messages",
155
+ args: 4,
156
+ rest: true,
157
+ run: async ([server, channel, id, words], opts) => {
158
+ const m = await api(opts, 'PATCH', `${where(server, channel)}/messages/${seg(id)}`, { content: text(words), mentions: opts.mentions });
159
+ return opts.json ? json(m) : `Edited ${m.id}\n${m.url}`;
160
+ },
161
+ },
162
+ ...['pin', 'unpin'].map((name) => ({
163
+ name,
164
+ group: 'Writing',
165
+ usage: `${name} <server> <channel> <message id>`,
166
+ summary: name === 'pin' ? 'pin a message' : 'unpin a message',
167
+ args: 3,
168
+ run: async ([server, channel, id], opts) => {
169
+ await api(opts, name === 'pin' ? 'PUT' : 'DELETE', `${where(server, channel)}/messages/${seg(id)}/pin`);
170
+ return `${name === 'pin' ? 'Pinned' : 'Unpinned'} ${id}`;
171
+ },
172
+ })),
173
+ ...['react', 'unreact'].map((name) => ({
174
+ name,
175
+ group: 'Writing',
176
+ usage: `${name} <server> <channel> <message id> <emoji>`,
177
+ summary: name === 'react' ? 'add a reaction' : "remove the bot's own reaction",
178
+ details: ['The emoji itself (👍), or a custom one as <:name:id>. :shortcodes: are a Discord-app feature and do not work.'],
179
+ args: 4,
180
+ run: async ([server, channel, id, emoji], opts) => {
181
+ await api(opts, name === 'react' ? 'PUT' : 'DELETE', `${where(server, channel)}/messages/${seg(id)}/reactions/${seg(emoji)}`);
182
+ return `${name === 'react' ? 'Reacted' : 'Removed'} ${emoji} ${name === 'react' ? 'to' : 'from'} ${id}`;
183
+ },
184
+ })),
185
+ {
186
+ name: 'connect',
187
+ group: 'Bots',
188
+ usage: 'connect <code> [--url <site>]',
189
+ summary: 'connect a bot, with the code from a Botyard prompt',
190
+ details: [
191
+ "The code comes from the prompt on the bot's page at Botyard (Connect an agent). It works once, within the hour.",
192
+ 'The key it is exchanged for is stored for you and never printed.',
193
+ ],
194
+ args: 1,
195
+ run: async ([code], opts) => {
196
+ const url = (opts.url || DEFAULT_URL).replace(/\/+$/, '');
197
+ const res = await call(url, null, 'POST', '/connect', { code, client: `botyard CLI on ${hostname()}` });
198
+ const config = load();
199
+ const previous = config.bots[res.me.bot.id];
200
+ config.bots[res.me.bot.id] = {
201
+ id: res.me.bot.id,
202
+ name: res.me.bot.name,
203
+ keyId: res.me.token.keyId,
204
+ key: res.key,
205
+ url,
206
+ connectedAt: new Date().toISOString(),
207
+ };
208
+ if (!config.default || !config.bots[config.default])
209
+ config.default = res.me.bot.id;
210
+ save(config);
211
+ if (opts.json)
212
+ return json(res.me);
213
+ return [
214
+ `Connected to ${res.me.bot.name}.`,
215
+ '',
216
+ fmt.me(res.me),
217
+ '',
218
+ previous && previous.keyId !== res.me.token.keyId
219
+ ? `This replaces the key you had for this bot (${previous.keyId}). It still works until its owner revokes it on Botyard.\n`
220
+ : '',
221
+ config.default === res.me.bot.id ? 'It is your default bot.' : `Your default bot is still ${config.bots[config.default].name}; pass --bot ${quote(res.me.bot.name)} to use this one.`,
222
+ 'Next: run botyard with no arguments to see what you can do.',
223
+ ]
224
+ .filter((l) => l !== '')
225
+ .join('\n');
226
+ },
227
+ },
228
+ {
229
+ name: 'bots',
230
+ group: 'Bots',
231
+ usage: 'bots',
232
+ summary: 'the bots connected here (* = default)',
233
+ args: 0,
234
+ run: async (_a, opts) => {
235
+ const config = load();
236
+ const list = Object.values(config.bots);
237
+ if (opts.json)
238
+ return json(list.map(({ key: _key, ...rest }) => ({ ...rest, default: rest.id === config.default })));
239
+ return list.length === 0 ? 'No bots connected yet.' : botLines(config).join('\n');
240
+ },
241
+ },
242
+ {
243
+ name: 'use',
244
+ group: 'Bots',
245
+ usage: 'use <bot>',
246
+ summary: 'make a bot the default (by name or id)',
247
+ args: 1,
248
+ run: async ([ref]) => {
249
+ const config = load();
250
+ const bot = pickBot(config, ref);
251
+ config.default = bot.id;
252
+ save(config);
253
+ return `${bot.name} is now the default.`;
254
+ },
255
+ },
256
+ {
257
+ name: 'disconnect',
258
+ group: 'Bots',
259
+ usage: 'disconnect <bot>',
260
+ summary: "forget a bot's key here (revoke it on Botyard to end it everywhere)",
261
+ args: 1,
262
+ run: async ([ref]) => {
263
+ const config = load();
264
+ const bot = pickBot(config, ref);
265
+ delete config.bots[bot.id];
266
+ if (config.default === bot.id)
267
+ config.default = Object.keys(config.bots)[0] ?? null;
268
+ save(config);
269
+ return `Forgot ${bot.name}. Its key (${bot.keyId}) still works until it is revoked on the bot's page at Botyard.`;
270
+ },
271
+ },
272
+ {
273
+ name: 'skill',
274
+ group: 'Setup',
275
+ usage: 'skill <install | show>',
276
+ summary: 'install the Claude Code skill that tells future sessions about botyard',
277
+ details: [`install writes ${skillDir()}/SKILL.md (set CLAUDE_CONFIG_DIR to change where). show prints it instead.`],
278
+ args: 1,
279
+ run: async ([action]) => {
280
+ if (action === 'show')
281
+ return SKILL;
282
+ if (action === 'install')
283
+ return `Installed ${installSkill()}. New Claude Code sessions will know about botyard.`;
284
+ throw new Error("Use 'botyard skill install' or 'botyard skill show'.");
285
+ },
286
+ },
287
+ ];
288
+ // --- the guide -------------------------------------------------------------
289
+ function botLines(config) {
290
+ return Object.values(config.bots).map((b) => `${b.id === config.default ? '*' : ' '} ${b.name} (${b.id}) key ${b.keyId}${b.url === DEFAULT_URL ? '' : ` ${b.url}`}`);
291
+ }
292
+ function guide() {
293
+ let bots;
294
+ try {
295
+ const config = load();
296
+ bots = Object.keys(config.bots).length
297
+ ? botLines(config)
298
+ : [" None yet. On the bot's page at Botyard, click Connect an agent, and follow the prompt it gives you."];
299
+ }
300
+ catch (e) {
301
+ bots = [` ${e instanceof Error ? e.message : String(e)}`];
302
+ }
303
+ if (process.env.BOTYARD_KEY)
304
+ bots.unshift(' (BOTYARD_KEY is set, so commands use that key instead)');
305
+ // Usage on one line, what it does under it: the usages are too long to share
306
+ // a line with anything and still fit a terminal.
307
+ const groups = ['Reading', 'Writing', 'Bots', 'Setup'].map((g) => [`${g}:`, ...COMMANDS.filter((c) => c.group === g).map((c) => ` botyard ${c.usage}\n ${c.summary}`)].join('\n'));
308
+ return [
309
+ `botyard ${VERSION}: read and send in Discord through your own bot, hosted by Botyard.`,
310
+ '',
311
+ 'Connected bots:',
312
+ ...bots,
313
+ '',
314
+ ...groups.flatMap((g) => [g, '']),
315
+ 'Options: --bot <name|id> picks a bot, --json prints raw API responses, botyard <command> --help explains one command.',
316
+ 'A <server> or <channel> is an id or a name ("general" or "#general"); quote names with spaces.',
317
+ '',
318
+ 'Rules, for agents:',
319
+ `- Never print, copy or store the key. The CLI keeps it (in ${configPath()}) and sends it for you.`,
320
+ "- Read before you write, and check with your human before posting anything they didn't ask you to post.",
321
+ '- Mentions ping people, not @everyone/@here/roles, unless you pass --mentions all. Only do that when asked.',
322
+ "- If something is refused, the error says why. Permissions belong to the bot's owner, on Botyard; ask them.",
323
+ '- Writes are limited to 60 a minute. Never send or react in a loop.',
324
+ '',
325
+ skillInstalled()
326
+ ? `Claude Code skill: installed (${skillDir()}).`
327
+ : 'Setup: run `botyard skill install` now, so future Claude Code sessions know this CLI is here.',
328
+ ].join('\n');
329
+ }
330
+ function commandHelp(c) {
331
+ return [`botyard ${c.usage}`, '', `${c.summary[0].toUpperCase()}${c.summary.slice(1)}.`, ...(c.details ? ['', ...c.details] : [])].join('\n');
332
+ }
333
+ function quote(s) {
334
+ return /^[\w#.-]+$/.test(s) ? s : `"${s.replace(/"/g, '\\"')}"`;
335
+ }
336
+ // --- main ----------------------------------------------------------------------
337
+ async function main(argv) {
338
+ let parsed;
339
+ try {
340
+ parsed = parseArgs({
341
+ args: argv,
342
+ allowPositionals: true,
343
+ strict: true,
344
+ options: {
345
+ json: { type: 'boolean', default: false },
346
+ help: { type: 'boolean', short: 'h', default: false },
347
+ version: { type: 'boolean', short: 'v', default: false },
348
+ bot: { type: 'string' },
349
+ url: { type: 'string' },
350
+ limit: { type: 'string' },
351
+ before: { type: 'string' },
352
+ 'reply-to': { type: 'string' },
353
+ mentions: { type: 'string' },
354
+ },
355
+ });
356
+ }
357
+ catch (e) {
358
+ process.stderr.write(`botyard: ${e instanceof Error ? e.message : String(e)}. Run botyard for usage.\n`);
359
+ return 2;
360
+ }
361
+ const { values, positionals } = parsed;
362
+ if (values.version) {
363
+ process.stdout.write(`${VERSION}\n`);
364
+ return 0;
365
+ }
366
+ const [name, ...args] = positionals;
367
+ if (!name) {
368
+ process.stdout.write(guide() + '\n');
369
+ return 0;
370
+ }
371
+ const command = COMMANDS.find((c) => c.name === name);
372
+ if (!command) {
373
+ if (name === 'help' || name === 'guide') {
374
+ process.stdout.write(guide() + '\n');
375
+ return 0;
376
+ }
377
+ process.stderr.write(`botyard: no command '${name}'. Run botyard with no arguments for the list.\n`);
378
+ return 2;
379
+ }
380
+ if (values.help) {
381
+ process.stdout.write(commandHelp(command) + '\n');
382
+ return 0;
383
+ }
384
+ const given = command.rest && args.length > command.args ? [...args.slice(0, command.args - 1), args.slice(command.args - 1).join(' ')] : args;
385
+ if (given.length !== command.args) {
386
+ process.stderr.write(`botyard: usage: botyard ${command.usage}\n`);
387
+ return 2;
388
+ }
389
+ try {
390
+ const out = await command.run(given, values);
391
+ if (out)
392
+ process.stdout.write(out + '\n');
393
+ return 0;
394
+ }
395
+ catch (e) {
396
+ const message = e instanceof Error ? e.message : String(e);
397
+ process.stderr.write(`botyard: ${message}\n`);
398
+ return e instanceof ApiFailure && e.status === 0 ? 3 : 1;
399
+ }
400
+ }
401
+ process.exitCode = await main(process.argv.slice(2));
package/dist/config.js ADDED
@@ -0,0 +1,63 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * Where the CLI keeps the keys it has been given, one per bot. The key is the
6
+ * one secret here: the directory is owner-only and the file owner-read/write,
7
+ * and nothing the CLI prints ever includes it. (An agent running as the same
8
+ * user could still read the file -- this keeps the key out of transcripts and
9
+ * screenshots, not out of reach.)
10
+ */
11
+ export const DEFAULT_URL = 'https://botyard.rcpooley.dev';
12
+ export function configDir() {
13
+ if (process.env.BOTYARD_CONFIG_DIR)
14
+ return process.env.BOTYARD_CONFIG_DIR;
15
+ if (process.platform === 'win32' && process.env.APPDATA)
16
+ return join(process.env.APPDATA, 'botyard');
17
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'botyard');
18
+ }
19
+ export function configPath() {
20
+ return join(configDir(), 'config.json');
21
+ }
22
+ export function load() {
23
+ const path = configPath();
24
+ if (!existsSync(path))
25
+ return { version: 1, default: null, bots: {} };
26
+ try {
27
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
28
+ return { version: 1, default: raw.default ?? null, bots: raw.bots ?? {} };
29
+ }
30
+ catch {
31
+ throw new Error(`${path} is not valid JSON. Fix or delete it, then connect again.`);
32
+ }
33
+ }
34
+ /** Written to a temp file and renamed, so a crash mid-write never leaves half a config. */
35
+ export function save(config) {
36
+ const dir = configDir();
37
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ chmodSync(dir, 0o700);
39
+ const tmp = join(dir, `config.json.${process.pid}.tmp`);
40
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
41
+ renameSync(tmp, configPath());
42
+ }
43
+ /** A bot by id or name (case-insensitive), or the default; an error that lists the choices otherwise. */
44
+ export function pickBot(config, ref) {
45
+ const bots = Object.values(config.bots);
46
+ if (bots.length === 0) {
47
+ throw new Error("No bots connected yet. On the bot's page at Botyard, click Connect an agent and follow the prompt.");
48
+ }
49
+ if (ref === undefined) {
50
+ const chosen = (config.default && config.bots[config.default]) || (bots.length === 1 ? bots[0] : undefined);
51
+ if (chosen)
52
+ return chosen;
53
+ throw new Error(`Several bots are connected and none is the default. Pass --bot, or run: botyard use <bot>. Bots: ${names(bots)}`);
54
+ }
55
+ const wanted = ref.trim().toLowerCase();
56
+ const found = config.bots[ref.trim()] ?? bots.find((b) => b.name.toLowerCase() === wanted);
57
+ if (found)
58
+ return found;
59
+ throw new Error(`No connected bot matches '${ref}'. Bots: ${names(bots)}`);
60
+ }
61
+ function names(bots) {
62
+ return bots.map((b) => `${b.name} (${b.id})`).join(', ');
63
+ }
package/dist/format.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Readable output: what an agent parses easily and a person can skim. --json
3
+ * skips all of this and prints the API's own response.
4
+ */
5
+ /** "2026-10-01 13:55 UTC": unambiguous, and short enough to sit on a line with the author. */
6
+ export function when(iso) {
7
+ return iso.replace('T', ' ').slice(0, 16) + ' UTC';
8
+ }
9
+ function pad(s, n) {
10
+ return s.length >= n ? s : s + ' '.repeat(n - s.length);
11
+ }
12
+ export function me(m) {
13
+ const servers = m.servers.length ? m.servers.map((s) => ` ${s.id} ${s.name}`).join('\n') : ' (none)';
14
+ return [
15
+ `Bot: ${m.bot.name} (${m.bot.id}), ${m.bot.status}`,
16
+ `Key: ${m.token.name} (${m.token.keyId})`,
17
+ `Can: ${m.token.scopes.join(', ')}`,
18
+ `Servers: ${m.token.allServers ? 'all of the bot\'s servers, now and later' : 'only these'}`,
19
+ servers,
20
+ ].join('\n');
21
+ }
22
+ export function servers(list) {
23
+ if (list.length === 0)
24
+ return 'No servers this key can reach. The bot may not be in any yet.';
25
+ return list.map((s) => `${pad(s.id, 20)} ${s.name}`).join('\n');
26
+ }
27
+ export function channels(list) {
28
+ if (list.length === 0)
29
+ return 'No channels the bot can see.';
30
+ const byId = new Map(list.map((c) => [c.id, c]));
31
+ return list
32
+ .filter((c) => c.kind !== 'category')
33
+ .map((c) => {
34
+ const parent = c.parentId ? byId.get(c.parentId) : undefined;
35
+ const under = parent ? (c.kind === 'thread' ? ` (thread in #${parent.name})` : ` (in ${parent.name})`) : '';
36
+ return `${pad(c.id, 20)} ${pad(c.kind, 12)} #${c.name}${under}`;
37
+ })
38
+ .join('\n');
39
+ }
40
+ /** One message as a block: a head line, then the content indented, then what hangs off it. */
41
+ export function message(m) {
42
+ const name = m.author.displayName && m.author.displayName !== m.author.username ? `${m.author.displayName} (${m.author.username})` : m.author.username;
43
+ const flags = [m.author.bot ? '[bot]' : '', m.editedAt ? '(edited)' : '', m.pinned ? '[pinned]' : '', m.replyTo ? `↩ reply to ${m.replyTo}` : '']
44
+ .filter(Boolean)
45
+ .join(' ');
46
+ const head = `${m.id} ${when(m.createdAt)} ${name}${flags ? ' ' + flags : ''}`;
47
+ const text = m.content || (m.system ? '(system message)' : '(no text)');
48
+ const lines = [head, ...text.split('\n').map((l) => ` ${l}`)];
49
+ if (m.reactions.length)
50
+ lines.push(' ' + m.reactions.map((r) => `${r.emoji} ${r.count}${r.me ? ' (me)' : ''}`).join(' '));
51
+ for (const a of m.attachments)
52
+ lines.push(` [attachment] ${a.name} ${a.url}`);
53
+ for (const e of m.embeds)
54
+ if (e.title || e.url || e.description)
55
+ lines.push(` [embed] ${[e.title, e.url, e.description].filter(Boolean).join(' — ')}`);
56
+ return lines.join('\n');
57
+ }
58
+ export function messages(list, where, more) {
59
+ if (list.length === 0)
60
+ return `No messages in ${where}.`;
61
+ const out = [`${where}: ${list.length} message${list.length === 1 ? '' : 's'}, oldest first`, '', list.map(message).join('\n\n')];
62
+ if (more)
63
+ out.push('', more);
64
+ return out.join('\n');
65
+ }
66
+ export function reactions(list) {
67
+ if (list.length === 0)
68
+ return 'No reactions.';
69
+ return list
70
+ .map((r) => {
71
+ const shown = r.users.map((u) => u.username).join(', ');
72
+ const hidden = r.count - r.users.length;
73
+ return `${r.emoji} ${r.count} ${shown}${hidden > 0 ? `${shown ? ', ' : ''}+${hidden} more` : ''}`;
74
+ })
75
+ .join('\n');
76
+ }
package/dist/skill.js ADDED
@@ -0,0 +1,35 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * A Claude Code skill, so a future session knows this CLI exists without being
6
+ * told. Deliberately thin: it says when to reach for botyard and to run it for
7
+ * usage, and repeats no commands -- so it cannot drift from the CLI it points
8
+ * at, whatever version is installed later.
9
+ */
10
+ export function skillDir() {
11
+ return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'skills', 'botyard');
12
+ }
13
+ export function skillInstalled() {
14
+ return existsSync(join(skillDir(), 'SKILL.md'));
15
+ }
16
+ export const SKILL = `---
17
+ name: botyard
18
+ description: Read and send Discord messages through the user's own bot, using the botyard CLI. Use whenever the user asks about Discord -- reading a channel, catching up on a server, posting, replying, reacting or pinning -- or mentions Botyard.
19
+ ---
20
+
21
+ # botyard
22
+
23
+ The \`botyard\` CLI is installed and connected to the user's Discord bot (or bots), through Botyard.
24
+
25
+ **Run \`botyard\` with no arguments first.** It lists the connected bots, every command with an example, and the rules for using them. \`botyard <command> --help\` covers one command.
26
+
27
+ Never print, copy or store the key the CLI keeps; it handles authentication itself.
28
+ `;
29
+ export function installSkill() {
30
+ const dir = skillDir();
31
+ mkdirSync(dir, { recursive: true });
32
+ const path = join(dir, 'SKILL.md');
33
+ writeFileSync(path, SKILL);
34
+ return path;
35
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@rcpooley/botyard",
3
+ "version": "0.1.0",
4
+ "description": "Read and send in Discord through your own bot, hosted by Botyard. Made for AI agents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "botyard": "dist/botyard.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc -p .",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "discord",
21
+ "bot",
22
+ "agent",
23
+ "cli",
24
+ "claude"
25
+ ],
26
+ "homepage": "https://botyard.rcpooley.dev",
27
+ "license": "UNLICENSED"
28
+ }