kimaki 0.0.3 → 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/src/cli.ts ADDED
@@ -0,0 +1,551 @@
1
+ #!/usr/bin/env node
2
+ import { cac } from 'cac'
3
+ import {
4
+ intro,
5
+ outro,
6
+ text,
7
+ password,
8
+ note,
9
+ cancel,
10
+ isCancel,
11
+ log,
12
+ multiselect,
13
+ spinner,
14
+ } from '@clack/prompts'
15
+ import { generateBotInstallUrl } from './utils.js'
16
+ import {
17
+ getChannelsWithDescriptions,
18
+ createDiscordClient,
19
+ getDatabase,
20
+ startDiscordBot,
21
+ initializeOpencodeForDirectory,
22
+ type ChannelWithTags,
23
+ } from './discordBot.js'
24
+ import type { OpencodeClient } from '@opencode-ai/sdk'
25
+ import {
26
+ Events,
27
+ ChannelType,
28
+ type CategoryChannel,
29
+ type Guild,
30
+ REST,
31
+ Routes,
32
+ SlashCommandBuilder,
33
+ } from 'discord.js'
34
+ import path from 'node:path'
35
+ import fs from 'node:fs'
36
+ import { createLogger } from './logger.js'
37
+
38
+ const cliLogger = createLogger('CLI')
39
+ const cli = cac('kimaki')
40
+
41
+ process.title = 'kimaki'
42
+
43
+ const EXIT_NO_RESTART = 64
44
+
45
+ type Project = {
46
+ id: string
47
+ worktree: string
48
+ vcs?: string
49
+ time: {
50
+ created: number
51
+ initialized?: number
52
+ }
53
+ }
54
+
55
+ type CliOptions = {
56
+ restart?: boolean
57
+ addChannels?: boolean
58
+ }
59
+
60
+ async function registerCommands(token: string, appId: string) {
61
+ const commands = [
62
+ new SlashCommandBuilder()
63
+ .setName('resume')
64
+ .setDescription('Resume an existing OpenCode session')
65
+ .addStringOption((option) => {
66
+ option
67
+ .setName('session')
68
+ .setDescription('The session to resume')
69
+ .setRequired(true)
70
+ .setAutocomplete(true)
71
+
72
+ return option
73
+ })
74
+ .toJSON(),
75
+ new SlashCommandBuilder()
76
+ .setName('session')
77
+ .setDescription('Start a new OpenCode session')
78
+ .addStringOption((option) => {
79
+ option
80
+ .setName('prompt')
81
+ .setDescription('Prompt content for the session')
82
+ .setRequired(true)
83
+
84
+ return option
85
+ })
86
+ .addStringOption((option) => {
87
+ option
88
+ .setName('files')
89
+ .setDescription(
90
+ 'Files to mention (comma or space separated; autocomplete)',
91
+ )
92
+ .setAutocomplete(true)
93
+ .setMaxLength(6000)
94
+
95
+ return option
96
+ })
97
+ .toJSON(),
98
+ ]
99
+
100
+ const rest = new REST().setToken(token)
101
+
102
+ try {
103
+ const data = (await rest.put(Routes.applicationCommands(appId), {
104
+ body: commands,
105
+ })) as any[]
106
+
107
+ cliLogger.info(
108
+ `COMMANDS: Successfully registered ${data.length} slash commands`,
109
+ )
110
+ } catch (error) {
111
+ cliLogger.error(
112
+ 'COMMANDS: Failed to register slash commands: ' + String(error),
113
+ )
114
+ throw error
115
+ }
116
+ }
117
+
118
+ async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
119
+ const existingCategory = guild.channels.cache.find(
120
+ (channel): channel is CategoryChannel => {
121
+ if (channel.type !== ChannelType.GuildCategory) {
122
+ return false
123
+ }
124
+
125
+ return channel.name.toLowerCase() === 'kimaki'
126
+ },
127
+ )
128
+
129
+ if (existingCategory) {
130
+ return existingCategory
131
+ }
132
+
133
+ return guild.channels.create({
134
+ name: 'Kimaki',
135
+ type: ChannelType.GuildCategory,
136
+ })
137
+ }
138
+
139
+ async function run({ restart, addChannels }: CliOptions) {
140
+ const forceSetup = Boolean(restart)
141
+ const shouldAddChannels = Boolean(addChannels)
142
+
143
+ intro('🤖 Discord Bot Setup')
144
+
145
+ const db = getDatabase()
146
+ let appId: string
147
+ let token: string
148
+
149
+ const existingBot = db
150
+ .prepare(
151
+ 'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
152
+ )
153
+ .get() as { app_id: string; token: string } | undefined
154
+
155
+ if (existingBot && !forceSetup) {
156
+ appId = existingBot.app_id
157
+ token = existingBot.token
158
+
159
+ note(
160
+ `Using saved bot credentials:\nApp ID: ${appId}\n\nTo use different credentials, run with --restart`,
161
+ 'Existing Bot Found',
162
+ )
163
+
164
+ note(
165
+ `Bot install URL (in case you need to add it to another server):\n${generateBotInstallUrl({ clientId: appId })}`,
166
+ 'Install URL',
167
+ )
168
+ } else {
169
+ if (forceSetup && existingBot) {
170
+ note('Ignoring saved credentials due to --restart flag', 'Restart Setup')
171
+ }
172
+
173
+ note(
174
+ '1. Go to https://discord.com/developers/applications\n' +
175
+ '2. Click "New Application"\n' +
176
+ '3. Give your application a name\n' +
177
+ '4. Copy the Application ID from the "General Information" section',
178
+ 'Step 1: Create Discord Application',
179
+ )
180
+
181
+ const appIdInput = await text({
182
+ message: 'Enter your Discord Application ID:',
183
+ placeholder: 'e.g., 1234567890123456789',
184
+ validate(value) {
185
+ if (!value) return 'Application ID is required'
186
+ if (!/^\d{17,20}$/.test(value))
187
+ return 'Invalid Application ID format (should be 17-20 digits)'
188
+ },
189
+ })
190
+
191
+ if (isCancel(appIdInput)) {
192
+ cancel('Setup cancelled')
193
+ process.exit(0)
194
+ }
195
+ appId = appIdInput
196
+
197
+ note(
198
+ '1. Go to the "Bot" section in the left sidebar\n' +
199
+ '2. Click "Reset Token" to generate a new bot token\n' +
200
+ "3. Copy the token (you won't be able to see it again!)",
201
+ 'Step 2: Get Bot Token',
202
+ )
203
+
204
+ const tokenInput = await password({
205
+ message: 'Enter your Discord Bot Token (will be hidden):',
206
+ validate(value) {
207
+ if (!value) return 'Bot token is required'
208
+ if (value.length < 50) return 'Invalid token format (too short)'
209
+ },
210
+ })
211
+
212
+ if (isCancel(tokenInput)) {
213
+ cancel('Setup cancelled')
214
+ process.exit(0)
215
+ }
216
+ token = tokenInput
217
+
218
+ db.prepare(
219
+ 'INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)',
220
+ ).run(appId, token)
221
+
222
+ note('Token saved to database', 'Credentials Stored')
223
+
224
+ note(
225
+ `Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`,
226
+ 'Step 3: Install Bot to Server',
227
+ )
228
+
229
+ const installed = await text({
230
+ message: 'Press Enter AFTER you have installed the bot in your server:',
231
+ placeholder: 'Press Enter to continue',
232
+ validate() {
233
+ return undefined
234
+ },
235
+ })
236
+
237
+ if (isCancel(installed)) {
238
+ cancel('Setup cancelled')
239
+ process.exit(0)
240
+ }
241
+ }
242
+
243
+ const s = spinner()
244
+ s.start('Creating Discord client and connecting...')
245
+
246
+ const discordClient = await createDiscordClient()
247
+
248
+ const guilds: Guild[] = []
249
+ const kimakiChannels: { guild: Guild; channels: ChannelWithTags[] }[] = []
250
+ const createdChannels: { name: string; id: string; guildId: string }[] = []
251
+
252
+ try {
253
+ await new Promise((resolve, reject) => {
254
+ discordClient.once(Events.ClientReady, async (c) => {
255
+ guilds.push(...Array.from(c.guilds.cache.values()))
256
+
257
+ for (const guild of guilds) {
258
+ const channels = await getChannelsWithDescriptions(guild)
259
+ const kimakiChans = channels.filter(
260
+ (ch) =>
261
+ ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
262
+ )
263
+
264
+ if (kimakiChans.length > 0) {
265
+ kimakiChannels.push({ guild, channels: kimakiChans })
266
+ }
267
+ }
268
+
269
+ resolve(null)
270
+ })
271
+
272
+ discordClient.once(Events.Error, reject)
273
+
274
+ discordClient.login(token).catch(reject)
275
+ })
276
+
277
+ s.stop('Connected to Discord!')
278
+ } catch (error) {
279
+ s.stop('Failed to connect to Discord')
280
+ cliLogger.error(
281
+ 'Error: ' + (error instanceof Error ? error.message : String(error)),
282
+ )
283
+ process.exit(EXIT_NO_RESTART)
284
+ }
285
+
286
+ for (const { guild, channels } of kimakiChannels) {
287
+ for (const channel of channels) {
288
+ if (channel.kimakiDirectory) {
289
+ db.prepare(
290
+ 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
291
+ ).run(channel.id, channel.kimakiDirectory, 'text')
292
+
293
+ const voiceChannel = guild.channels.cache.find(
294
+ (ch) =>
295
+ ch.type === ChannelType.GuildVoice && ch.name === channel.name,
296
+ )
297
+
298
+ if (voiceChannel) {
299
+ db.prepare(
300
+ 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
301
+ ).run(voiceChannel.id, channel.kimakiDirectory, 'voice')
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ if (kimakiChannels.length > 0) {
308
+ const channelList = kimakiChannels
309
+ .flatMap(({ guild, channels }) =>
310
+ channels.map((ch) => {
311
+ const appInfo =
312
+ ch.kimakiApp === appId
313
+ ? ' (this bot)'
314
+ : ch.kimakiApp
315
+ ? ` (app: ${ch.kimakiApp})`
316
+ : ''
317
+ return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`
318
+ }),
319
+ )
320
+ .join('\n')
321
+
322
+ note(channelList, 'Existing Kimaki Channels')
323
+ }
324
+
325
+ s.start('Starting OpenCode server...')
326
+
327
+ let client: OpencodeClient
328
+
329
+ try {
330
+ const currentDir = process.cwd()
331
+ client = await initializeOpencodeForDirectory(currentDir)
332
+ s.stop('OpenCode server started!')
333
+ } catch (error) {
334
+ s.stop('Failed to start OpenCode')
335
+ cliLogger.error(
336
+ 'Error:',
337
+ error instanceof Error ? error.message : String(error),
338
+ )
339
+ discordClient.destroy()
340
+ process.exit(EXIT_NO_RESTART)
341
+ }
342
+
343
+ s.start('Fetching OpenCode projects...')
344
+
345
+ let projects: Project[] = []
346
+
347
+ try {
348
+ const projectsResponse = await client.project.list()
349
+ if (!projectsResponse.data) {
350
+ throw new Error('Failed to fetch projects')
351
+ }
352
+ projects = projectsResponse.data
353
+ s.stop(`Found ${projects.length} OpenCode project(s)`)
354
+ } catch (error) {
355
+ s.stop('Failed to fetch projects')
356
+ cliLogger.error(
357
+ 'Error:',
358
+ error instanceof Error ? error.message : String(error),
359
+ )
360
+ discordClient.destroy()
361
+ process.exit(EXIT_NO_RESTART)
362
+ }
363
+
364
+ const existingDirs = kimakiChannels.flatMap(({ channels }) =>
365
+ channels.map((ch) => ch.kimakiDirectory).filter(Boolean),
366
+ )
367
+
368
+ const availableProjects = projects.filter(
369
+ (project) => !existingDirs.includes(project.worktree),
370
+ )
371
+
372
+ if (availableProjects.length === 0) {
373
+ note(
374
+ 'All OpenCode projects already have Discord channels',
375
+ 'No New Projects',
376
+ )
377
+ }
378
+
379
+ if (shouldAddChannels && availableProjects.length > 0) {
380
+ const selectedProjects = await multiselect({
381
+ message: 'Select projects to create Discord channels for:',
382
+ options: availableProjects.map((project) => ({
383
+ value: project.id,
384
+ label: `${path.basename(project.worktree)} (${project.worktree})`,
385
+ })),
386
+ required: false,
387
+ })
388
+
389
+ if (!isCancel(selectedProjects) && selectedProjects.length > 0) {
390
+ let targetGuild: Guild
391
+ if (guilds.length === 0) {
392
+ cliLogger.error(
393
+ 'No Discord servers found! The bot must be installed in at least one server.',
394
+ )
395
+ process.exit(EXIT_NO_RESTART)
396
+ }
397
+
398
+ if (guilds.length === 1) {
399
+ targetGuild = guilds[0]!
400
+ } else {
401
+ const guildId = await text({
402
+ message: 'Enter the Discord server ID to create channels in:',
403
+ placeholder: guilds[0]?.id,
404
+ validate(value) {
405
+ if (!value) return 'Server ID is required'
406
+ if (!guilds.find((g) => g.id === value)) return 'Invalid server ID'
407
+ },
408
+ })
409
+
410
+ if (isCancel(guildId)) {
411
+ cancel('Setup cancelled')
412
+ process.exit(0)
413
+ }
414
+
415
+ targetGuild = guilds.find((g) => g.id === guildId)!
416
+ }
417
+
418
+ s.start('Creating Discord channels...')
419
+
420
+ for (const projectId of selectedProjects) {
421
+ const project = projects.find((p) => p.id === projectId)
422
+ if (!project) continue
423
+
424
+ const baseName = path.basename(project.worktree)
425
+ const channelName = `kimaki-${baseName}`
426
+ .toLowerCase()
427
+ .replace(/[^a-z0-9-]/g, '-')
428
+ .slice(0, 100)
429
+
430
+ try {
431
+ const kimakiCategory = await ensureKimakiCategory(targetGuild)
432
+
433
+ const textChannel = await targetGuild.channels.create({
434
+ name: channelName,
435
+ type: ChannelType.GuildText,
436
+ parent: kimakiCategory,
437
+ topic: `<kimaki><directory>${project.worktree}</directory><app>${appId}</app></kimaki>`,
438
+ })
439
+
440
+ const voiceChannel = await targetGuild.channels.create({
441
+ name: channelName,
442
+ type: ChannelType.GuildVoice,
443
+ parent: kimakiCategory,
444
+ })
445
+
446
+ db.prepare(
447
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
448
+ ).run(textChannel.id, project.worktree, 'text')
449
+
450
+ db.prepare(
451
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
452
+ ).run(voiceChannel.id, project.worktree, 'voice')
453
+
454
+ createdChannels.push({
455
+ name: textChannel.name,
456
+ id: textChannel.id,
457
+ guildId: targetGuild.id,
458
+ })
459
+ } catch (error) {
460
+ cliLogger.error(`Failed to create channels for ${baseName}:`, error)
461
+ }
462
+ }
463
+
464
+ s.stop(`Created ${createdChannels.length} channel(s)`)
465
+
466
+ if (createdChannels.length > 0) {
467
+ note(
468
+ createdChannels.map((ch) => `#${ch.name}`).join('\n'),
469
+ 'Created Channels',
470
+ )
471
+ }
472
+ }
473
+ }
474
+
475
+ cliLogger.log('Registering slash commands asynchronously...')
476
+ void registerCommands(token, appId)
477
+ .then(() => {
478
+ cliLogger.log('Slash commands registered!')
479
+ })
480
+ .catch((error) => {
481
+ cliLogger.error(
482
+ 'Failed to register slash commands:',
483
+ error instanceof Error ? error.message : String(error),
484
+ )
485
+ })
486
+
487
+ s.start('Starting Discord bot...')
488
+ await startDiscordBot({ token, appId, discordClient })
489
+ s.stop('Discord bot is running!')
490
+
491
+ const allChannels: {
492
+ name: string
493
+ id: string
494
+ guildId: string
495
+ directory?: string
496
+ }[] = []
497
+
498
+ allChannels.push(...createdChannels)
499
+
500
+ kimakiChannels.forEach(({ guild, channels }) => {
501
+ channels.forEach((ch) => {
502
+ allChannels.push({
503
+ name: ch.name,
504
+ id: ch.id,
505
+ guildId: guild.id,
506
+ directory: ch.kimakiDirectory,
507
+ })
508
+ })
509
+ })
510
+
511
+ if (allChannels.length > 0) {
512
+ const channelLinks = allChannels
513
+ .map(
514
+ (ch) =>
515
+ `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`,
516
+ )
517
+ .join('\n')
518
+
519
+ note(
520
+ `Your kimaki channels are ready! Click any link below to open in Discord:\n\n${channelLinks}\n\nSend a message in any channel to start using OpenCode!`,
521
+ '🚀 Ready to Use',
522
+ )
523
+ }
524
+
525
+ outro('✨ Setup complete!')
526
+ }
527
+
528
+ cli
529
+ .command('', 'Set up and run the Kimaki Discord bot')
530
+ .option('--restart', 'Prompt for new credentials even if saved')
531
+ .option(
532
+ '--add-channels',
533
+ 'Select OpenCode projects to create Discord channels before starting',
534
+ )
535
+ .action(async (options: { restart?: boolean; addChannels?: boolean }) => {
536
+ try {
537
+ await run({
538
+ restart: options.restart,
539
+ addChannels: options.addChannels,
540
+ })
541
+ } catch (error) {
542
+ cliLogger.error(
543
+ 'Unhandled error:',
544
+ error instanceof Error ? error.message : String(error),
545
+ )
546
+ process.exit(EXIT_NO_RESTART)
547
+ }
548
+ })
549
+
550
+ cli.help()
551
+ cli.parse()