shuvmaki 0.4.26

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.
Files changed (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
package/dist/cli.js ADDED
@@ -0,0 +1,709 @@
1
+ #!/usr/bin/env node
2
+ // Main CLI entrypoint for the Kimaki Discord bot.
3
+ // Handles interactive setup, Discord OAuth, slash command registration,
4
+ // project channel creation, and launching the bot with opencode integration.
5
+ import { cac } from 'cac';
6
+ import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
7
+ import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
8
+ import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
9
+ import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
10
+ import path from 'node:path';
11
+ import fs from 'node:fs';
12
+ import { createLogger } from './logger.js';
13
+ import { spawn, spawnSync, execSync, } from 'node:child_process';
14
+ import http from 'node:http';
15
+ const cliLogger = createLogger('CLI');
16
+ const cli = cac('kimaki');
17
+ process.title = 'kimaki';
18
+ const LOCK_PORT = 29988;
19
+ async function killProcessOnPort(port) {
20
+ const isWindows = process.platform === 'win32';
21
+ const myPid = process.pid;
22
+ try {
23
+ if (isWindows) {
24
+ // Windows: find PID using netstat, then kill
25
+ const result = spawnSync('cmd', [
26
+ '/c',
27
+ `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`,
28
+ ], {
29
+ shell: false,
30
+ encoding: 'utf-8',
31
+ });
32
+ const pids = result.stdout
33
+ ?.trim()
34
+ .split('\n')
35
+ .map((p) => p.trim())
36
+ .filter((p) => /^\d+$/.test(p));
37
+ // Filter out our own PID and take the first (oldest)
38
+ const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
39
+ if (targetPid) {
40
+ cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`);
41
+ spawnSync('taskkill', ['/F', '/PID', targetPid], { shell: false });
42
+ return true;
43
+ }
44
+ }
45
+ else {
46
+ // Unix: use lsof with -sTCP:LISTEN to only find the listening process
47
+ const result = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t'], {
48
+ shell: false,
49
+ encoding: 'utf-8',
50
+ });
51
+ const pids = result.stdout
52
+ ?.trim()
53
+ .split('\n')
54
+ .map((p) => p.trim())
55
+ .filter((p) => /^\d+$/.test(p));
56
+ // Filter out our own PID and take the first (oldest)
57
+ const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
58
+ if (targetPid) {
59
+ const pid = parseInt(targetPid, 10);
60
+ cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`);
61
+ process.kill(pid, 'SIGKILL');
62
+ return true;
63
+ }
64
+ }
65
+ }
66
+ catch (e) {
67
+ cliLogger.debug(`Failed to kill process on port ${port}:`, e);
68
+ }
69
+ return false;
70
+ }
71
+ async function checkSingleInstance() {
72
+ try {
73
+ const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
74
+ signal: AbortSignal.timeout(1000),
75
+ });
76
+ if (response.ok) {
77
+ cliLogger.log('Another kimaki instance detected');
78
+ await killProcessOnPort(LOCK_PORT);
79
+ // Wait a moment for port to be released
80
+ await new Promise((resolve) => {
81
+ setTimeout(resolve, 500);
82
+ });
83
+ }
84
+ }
85
+ catch {
86
+ cliLogger.debug('No other kimaki instance detected on lock port');
87
+ }
88
+ }
89
+ async function startLockServer() {
90
+ return new Promise((resolve, reject) => {
91
+ const server = http.createServer((req, res) => {
92
+ res.writeHead(200);
93
+ res.end('kimaki');
94
+ });
95
+ server.listen(LOCK_PORT, '127.0.0.1');
96
+ server.once('listening', () => {
97
+ resolve();
98
+ });
99
+ server.on('error', async (err) => {
100
+ if (err.code === 'EADDRINUSE') {
101
+ cliLogger.log('Port still in use, retrying...');
102
+ await killProcessOnPort(LOCK_PORT);
103
+ await new Promise((r) => {
104
+ setTimeout(r, 500);
105
+ });
106
+ // Retry once
107
+ server.listen(LOCK_PORT, '127.0.0.1');
108
+ }
109
+ else {
110
+ reject(err);
111
+ }
112
+ });
113
+ });
114
+ }
115
+ const EXIT_NO_RESTART = 64;
116
+ // Commands to skip when registering user commands (reserved names)
117
+ const SKIP_USER_COMMANDS = ['init'];
118
+ async function registerCommands(token, appId, userCommands = []) {
119
+ const commands = [
120
+ new SlashCommandBuilder()
121
+ .setName('resume')
122
+ .setDescription('Resume an existing OpenCode session')
123
+ .addStringOption((option) => {
124
+ option
125
+ .setName('session')
126
+ .setDescription('The session to resume')
127
+ .setRequired(true)
128
+ .setAutocomplete(true);
129
+ return option;
130
+ })
131
+ .toJSON(),
132
+ new SlashCommandBuilder()
133
+ .setName('session')
134
+ .setDescription('Start a new OpenCode session')
135
+ .addStringOption((option) => {
136
+ option
137
+ .setName('prompt')
138
+ .setDescription('Prompt content for the session')
139
+ .setRequired(true);
140
+ return option;
141
+ })
142
+ .addStringOption((option) => {
143
+ option
144
+ .setName('files')
145
+ .setDescription('Files to mention (comma or space separated; autocomplete)')
146
+ .setAutocomplete(true)
147
+ .setMaxLength(6000);
148
+ return option;
149
+ })
150
+ .toJSON(),
151
+ new SlashCommandBuilder()
152
+ .setName('add-project')
153
+ .setDescription('Create Discord channels for a new OpenCode project')
154
+ .addStringOption((option) => {
155
+ option
156
+ .setName('project')
157
+ .setDescription('Select an OpenCode project')
158
+ .setRequired(true)
159
+ .setAutocomplete(true);
160
+ return option;
161
+ })
162
+ .toJSON(),
163
+ new SlashCommandBuilder()
164
+ .setName('create-new-project')
165
+ .setDescription('Create a new project folder, initialize git, and start a session')
166
+ .addStringOption((option) => {
167
+ option
168
+ .setName('name')
169
+ .setDescription('Name for the new project folder')
170
+ .setRequired(true);
171
+ return option;
172
+ })
173
+ .toJSON(),
174
+ new SlashCommandBuilder()
175
+ .setName('accept')
176
+ .setDescription('Accept a pending permission request (this request only)')
177
+ .toJSON(),
178
+ new SlashCommandBuilder()
179
+ .setName('accept-always')
180
+ .setDescription('Accept and auto-approve future requests matching this pattern')
181
+ .toJSON(),
182
+ new SlashCommandBuilder()
183
+ .setName('reject')
184
+ .setDescription('Reject a pending permission request')
185
+ .toJSON(),
186
+ new SlashCommandBuilder()
187
+ .setName('abort')
188
+ .setDescription('Abort the current OpenCode request in this thread')
189
+ .toJSON(),
190
+ new SlashCommandBuilder()
191
+ .setName('stop')
192
+ .setDescription('Abort the current OpenCode request in this thread')
193
+ .toJSON(),
194
+ new SlashCommandBuilder()
195
+ .setName('share')
196
+ .setDescription('Share the current session as a public URL')
197
+ .toJSON(),
198
+ new SlashCommandBuilder()
199
+ .setName('fork')
200
+ .setDescription('Fork the session from a past user message')
201
+ .toJSON(),
202
+ new SlashCommandBuilder()
203
+ .setName('model')
204
+ .setDescription('Set the preferred model for this channel or session')
205
+ .toJSON(),
206
+ new SlashCommandBuilder()
207
+ .setName('agent')
208
+ .setDescription('Set the preferred agent for this channel or session')
209
+ .toJSON(),
210
+ new SlashCommandBuilder()
211
+ .setName('queue')
212
+ .setDescription('Queue a message to be sent after the current response finishes')
213
+ .addStringOption((option) => {
214
+ option
215
+ .setName('message')
216
+ .setDescription('The message to queue')
217
+ .setRequired(true);
218
+ return option;
219
+ })
220
+ .toJSON(),
221
+ new SlashCommandBuilder()
222
+ .setName('clear-queue')
223
+ .setDescription('Clear all queued messages in this thread')
224
+ .toJSON(),
225
+ new SlashCommandBuilder()
226
+ .setName('undo')
227
+ .setDescription('Undo the last assistant message (revert file changes)')
228
+ .toJSON(),
229
+ new SlashCommandBuilder()
230
+ .setName('redo')
231
+ .setDescription('Redo previously undone changes')
232
+ .toJSON(),
233
+ ];
234
+ // Add user-defined commands with -cmd suffix
235
+ for (const cmd of userCommands) {
236
+ if (SKIP_USER_COMMANDS.includes(cmd.name)) {
237
+ continue;
238
+ }
239
+ const commandName = `${cmd.name}-cmd`;
240
+ const description = cmd.description || `Run /${cmd.name} command`;
241
+ commands.push(new SlashCommandBuilder()
242
+ .setName(commandName)
243
+ .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
244
+ .addStringOption((option) => {
245
+ option
246
+ .setName('arguments')
247
+ .setDescription('Arguments to pass to the command')
248
+ .setRequired(false);
249
+ return option;
250
+ })
251
+ .toJSON());
252
+ }
253
+ const rest = new REST().setToken(token);
254
+ try {
255
+ const data = (await rest.put(Routes.applicationCommands(appId), {
256
+ body: commands,
257
+ }));
258
+ cliLogger.info(`COMMANDS: Successfully registered ${data.length} slash commands`);
259
+ }
260
+ catch (error) {
261
+ cliLogger.error('COMMANDS: Failed to register slash commands: ' + String(error));
262
+ throw error;
263
+ }
264
+ }
265
+ async function run({ restart, addChannels }) {
266
+ const forceSetup = Boolean(restart);
267
+ intro('🤖 Discord Bot Setup');
268
+ // Step 0: Check if shuvcode or opencode CLI is available
269
+ // Prefer shuvcode fork over upstream opencode
270
+ const possiblePaths = [
271
+ `${process.env.HOME}/.bun/bin/shuvcode`,
272
+ `${process.env.HOME}/.local/bin/shuvcode`,
273
+ `${process.env.HOME}/.bun/bin/opencode`,
274
+ `${process.env.HOME}/.local/bin/opencode`,
275
+ `${process.env.HOME}/.opencode/bin/opencode`,
276
+ '/usr/local/bin/shuvcode',
277
+ '/usr/local/bin/opencode',
278
+ ];
279
+ const installedPath = possiblePaths.find((p) => {
280
+ try {
281
+ fs.accessSync(p, fs.constants.X_OK);
282
+ return true;
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ });
288
+ // Also check PATH
289
+ const shuvInPath = spawnSync('which', ['shuvcode'], { shell: true }).status === 0;
290
+ const openInPath = spawnSync('which', ['opencode'], { shell: true }).status === 0;
291
+ const cliAvailable = installedPath || shuvInPath || openInPath;
292
+ if (!cliAvailable) {
293
+ note('shuvcode/opencode CLI is required but not found.', '⚠️ CLI Not Found');
294
+ const shouldInstall = await confirm({
295
+ message: 'Would you like to install shuvcode right now?',
296
+ });
297
+ if (isCancel(shouldInstall) || !shouldInstall) {
298
+ cancel('shuvcode/opencode CLI is required to run this bot');
299
+ process.exit(0);
300
+ }
301
+ const s = spinner();
302
+ s.start('Installing shuvcode CLI...');
303
+ try {
304
+ execSync('bun install -g shuvcode', {
305
+ stdio: 'inherit',
306
+ shell: '/bin/bash',
307
+ });
308
+ s.stop('shuvcode CLI installed successfully!');
309
+ // Check if it's now available
310
+ const newPath = `${process.env.HOME}/.bun/bin/shuvcode`;
311
+ try {
312
+ fs.accessSync(newPath, fs.constants.X_OK);
313
+ process.env.OPENCODE_PATH = newPath;
314
+ }
315
+ catch {
316
+ note('shuvcode was installed but may not be available in this session.\n' +
317
+ 'Please restart your terminal and run this command again.', '⚠️ Restart Required');
318
+ process.exit(0);
319
+ }
320
+ }
321
+ catch (error) {
322
+ s.stop('Failed to install shuvcode CLI');
323
+ cliLogger.error('Installation error:', error instanceof Error ? error.message : String(error));
324
+ process.exit(EXIT_NO_RESTART);
325
+ }
326
+ }
327
+ else if (installedPath) {
328
+ // Set the path for spawn calls
329
+ process.env.OPENCODE_PATH = installedPath;
330
+ }
331
+ const db = getDatabase();
332
+ let appId;
333
+ let token;
334
+ const existingBot = db
335
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
336
+ .get();
337
+ const shouldAddChannels = !existingBot?.token || forceSetup || Boolean(addChannels);
338
+ if (existingBot && !forceSetup) {
339
+ appId = existingBot.app_id;
340
+ token = existingBot.token;
341
+ note(`Using saved bot credentials:\nApp ID: ${appId}\n\nTo use different credentials, run with --restart`, 'Existing Bot Found');
342
+ note(`Bot install URL (in case you need to add it to another server):\n${generateBotInstallUrl({ clientId: appId })}`, 'Install URL');
343
+ }
344
+ else {
345
+ if (forceSetup && existingBot) {
346
+ note('Ignoring saved credentials due to --restart flag', 'Restart Setup');
347
+ }
348
+ note('1. Go to https://discord.com/developers/applications\n' +
349
+ '2. Click "New Application"\n' +
350
+ '3. Give your application a name\n' +
351
+ '4. Copy the Application ID from the "General Information" section', 'Step 1: Create Discord Application');
352
+ const appIdInput = await text({
353
+ message: 'Enter your Discord Application ID:',
354
+ placeholder: 'e.g., 1234567890123456789',
355
+ validate(value) {
356
+ if (!value)
357
+ return 'Application ID is required';
358
+ if (!/^\d{17,20}$/.test(value))
359
+ return 'Invalid Application ID format (should be 17-20 digits)';
360
+ },
361
+ });
362
+ if (isCancel(appIdInput)) {
363
+ cancel('Setup cancelled');
364
+ process.exit(0);
365
+ }
366
+ appId = appIdInput;
367
+ note('1. Go to the "Bot" section in the left sidebar\n' +
368
+ '2. Scroll down to "Privileged Gateway Intents"\n' +
369
+ '3. Enable these intents by toggling them ON:\n' +
370
+ ' • SERVER MEMBERS INTENT\n' +
371
+ ' • MESSAGE CONTENT INTENT\n' +
372
+ '4. Click "Save Changes" at the bottom', 'Step 2: Enable Required Intents');
373
+ const intentsConfirmed = await text({
374
+ message: 'Press Enter after enabling both intents:',
375
+ placeholder: 'Enter',
376
+ });
377
+ if (isCancel(intentsConfirmed)) {
378
+ cancel('Setup cancelled');
379
+ process.exit(0);
380
+ }
381
+ note('1. Still in the "Bot" section\n' +
382
+ '2. Click "Reset Token" to generate a new bot token (in case of errors try again)\n' +
383
+ "3. Copy the token (you won't be able to see it again!)", 'Step 3: Get Bot Token');
384
+ const tokenInput = await password({
385
+ message: 'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
386
+ validate(value) {
387
+ if (!value)
388
+ return 'Bot token is required';
389
+ if (value.length < 50)
390
+ return 'Invalid token format (too short)';
391
+ },
392
+ });
393
+ if (isCancel(tokenInput)) {
394
+ cancel('Setup cancelled');
395
+ process.exit(0);
396
+ }
397
+ token = tokenInput;
398
+ note(`You can get a Gemini api Key at https://aistudio.google.com/apikey`, `Gemini API Key`);
399
+ const geminiApiKey = await password({
400
+ message: 'Enter your Gemini API Key for voice channels and audio transcription (optional, press Enter to skip):',
401
+ validate(value) {
402
+ if (value && value.length < 10)
403
+ return 'Invalid API key format';
404
+ return undefined;
405
+ },
406
+ });
407
+ if (isCancel(geminiApiKey)) {
408
+ cancel('Setup cancelled');
409
+ process.exit(0);
410
+ }
411
+ // Store API key in database
412
+ if (geminiApiKey) {
413
+ db.prepare('INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)').run(appId, geminiApiKey || null);
414
+ note('API key saved successfully', 'API Key Stored');
415
+ }
416
+ note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
417
+ const installed = await text({
418
+ message: 'Press Enter AFTER you have installed the bot in your server:',
419
+ placeholder: 'Enter',
420
+ });
421
+ if (isCancel(installed)) {
422
+ cancel('Setup cancelled');
423
+ process.exit(0);
424
+ }
425
+ }
426
+ const s = spinner();
427
+ s.start('Creating Discord client and connecting...');
428
+ const discordClient = await createDiscordClient();
429
+ const guilds = [];
430
+ const kimakiChannels = [];
431
+ const createdChannels = [];
432
+ try {
433
+ await new Promise((resolve, reject) => {
434
+ discordClient.once(Events.ClientReady, async (c) => {
435
+ guilds.push(...Array.from(c.guilds.cache.values()));
436
+ for (const guild of guilds) {
437
+ const channels = await getChannelsWithDescriptions(guild);
438
+ const kimakiChans = channels.filter((ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId));
439
+ if (kimakiChans.length > 0) {
440
+ kimakiChannels.push({ guild, channels: kimakiChans });
441
+ }
442
+ }
443
+ resolve(null);
444
+ });
445
+ discordClient.once(Events.Error, reject);
446
+ discordClient.login(token).catch(reject);
447
+ });
448
+ s.stop('Connected to Discord!');
449
+ }
450
+ catch (error) {
451
+ s.stop('Failed to connect to Discord');
452
+ cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)));
453
+ process.exit(EXIT_NO_RESTART);
454
+ }
455
+ db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token);
456
+ for (const { guild, channels } of kimakiChannels) {
457
+ for (const channel of channels) {
458
+ if (channel.kimakiDirectory) {
459
+ db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text');
460
+ const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
461
+ if (voiceChannel) {
462
+ db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice');
463
+ }
464
+ }
465
+ }
466
+ }
467
+ if (kimakiChannels.length > 0) {
468
+ const channelList = kimakiChannels
469
+ .flatMap(({ guild, channels }) => channels.map((ch) => {
470
+ const appInfo = ch.kimakiApp === appId
471
+ ? ' (this bot)'
472
+ : ch.kimakiApp
473
+ ? ` (app: ${ch.kimakiApp})`
474
+ : '';
475
+ return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`;
476
+ }))
477
+ .join('\n');
478
+ note(channelList, 'Existing Kimaki Channels');
479
+ }
480
+ s.start('Starting OpenCode server...');
481
+ const currentDir = process.cwd();
482
+ let getClient = await initializeOpencodeForDirectory(currentDir);
483
+ s.stop('OpenCode server started!');
484
+ s.start('Fetching OpenCode projects...');
485
+ let projects = [];
486
+ try {
487
+ const projectsResponse = await getClient().project.list({});
488
+ if (!projectsResponse.data) {
489
+ throw new Error('Failed to fetch projects');
490
+ }
491
+ projects = projectsResponse.data;
492
+ s.stop(`Found ${projects.length} OpenCode project(s)`);
493
+ }
494
+ catch (error) {
495
+ s.stop('Failed to fetch projects');
496
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
497
+ discordClient.destroy();
498
+ process.exit(EXIT_NO_RESTART);
499
+ }
500
+ const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
501
+ .filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
502
+ .map((ch) => ch.kimakiDirectory)
503
+ .filter(Boolean));
504
+ const availableProjects = deduplicateByKey(projects.filter((project) => !existingDirs.includes(project.worktree)), (x) => x.worktree);
505
+ if (availableProjects.length === 0) {
506
+ note('All OpenCode projects already have Discord channels', 'No New Projects');
507
+ }
508
+ if ((!existingDirs?.length && availableProjects.length > 0) ||
509
+ shouldAddChannels) {
510
+ const selectedProjects = await multiselect({
511
+ message: 'Select projects to create Discord channels for:',
512
+ options: availableProjects.map((project) => ({
513
+ value: project.id,
514
+ label: `${path.basename(project.worktree)} (${project.worktree})`,
515
+ })),
516
+ required: false,
517
+ });
518
+ if (!isCancel(selectedProjects) && selectedProjects.length > 0) {
519
+ let targetGuild;
520
+ if (guilds.length === 0) {
521
+ cliLogger.error('No Discord servers found! The bot must be installed in at least one server.');
522
+ process.exit(EXIT_NO_RESTART);
523
+ }
524
+ if (guilds.length === 1) {
525
+ targetGuild = guilds[0];
526
+ note(`Using server: ${targetGuild.name}`, 'Server Selected');
527
+ }
528
+ else {
529
+ const guildSelection = await multiselect({
530
+ message: 'Select a Discord server to create channels in:',
531
+ options: guilds.map((guild) => ({
532
+ value: guild.id,
533
+ label: `${guild.name} (${guild.memberCount} members)`,
534
+ })),
535
+ required: true,
536
+ maxItems: 1,
537
+ });
538
+ if (isCancel(guildSelection)) {
539
+ cancel('Setup cancelled');
540
+ process.exit(0);
541
+ }
542
+ targetGuild = guilds.find((g) => g.id === guildSelection[0]);
543
+ }
544
+ s.start('Creating Discord channels...');
545
+ for (const projectId of selectedProjects) {
546
+ const project = projects.find((p) => p.id === projectId);
547
+ if (!project)
548
+ continue;
549
+ try {
550
+ const { textChannelId, channelName } = await createProjectChannels({
551
+ guild: targetGuild,
552
+ projectDirectory: project.worktree,
553
+ appId,
554
+ botName: discordClient.user?.username,
555
+ });
556
+ createdChannels.push({
557
+ name: channelName,
558
+ id: textChannelId,
559
+ guildId: targetGuild.id,
560
+ });
561
+ }
562
+ catch (error) {
563
+ cliLogger.error(`Failed to create channels for ${path.basename(project.worktree)}:`, error);
564
+ }
565
+ }
566
+ s.stop(`Created ${createdChannels.length} channel(s)`);
567
+ if (createdChannels.length > 0) {
568
+ note(createdChannels.map((ch) => `#${ch.name}`).join('\n'), 'Created Channels');
569
+ }
570
+ }
571
+ }
572
+ // Fetch user-defined commands using the already-running server
573
+ const allUserCommands = [];
574
+ try {
575
+ const commandsResponse = await getClient().command.list({
576
+ query: { directory: currentDir },
577
+ });
578
+ if (commandsResponse.data) {
579
+ allUserCommands.push(...commandsResponse.data);
580
+ }
581
+ }
582
+ catch {
583
+ // Ignore errors fetching commands
584
+ }
585
+ // Log available user commands
586
+ const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
587
+ if (registrableCommands.length > 0) {
588
+ const commandList = registrableCommands
589
+ .map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
590
+ .join('\n');
591
+ note(`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`, 'OpenCode Commands');
592
+ }
593
+ cliLogger.log('Registering slash commands asynchronously...');
594
+ void registerCommands(token, appId, allUserCommands)
595
+ .then(() => {
596
+ cliLogger.log('Slash commands registered!');
597
+ })
598
+ .catch((error) => {
599
+ cliLogger.error('Failed to register slash commands:', error instanceof Error ? error.message : String(error));
600
+ });
601
+ s.start('Starting Discord bot...');
602
+ await startDiscordBot({ token, appId, discordClient });
603
+ s.stop('Discord bot is running!');
604
+ const allChannels = [];
605
+ allChannels.push(...createdChannels);
606
+ kimakiChannels.forEach(({ guild, channels }) => {
607
+ channels.forEach((ch) => {
608
+ allChannels.push({
609
+ name: ch.name,
610
+ id: ch.id,
611
+ guildId: guild.id,
612
+ directory: ch.kimakiDirectory,
613
+ });
614
+ });
615
+ });
616
+ if (allChannels.length > 0) {
617
+ const channelLinks = allChannels
618
+ .map((ch) => `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
619
+ .join('\n');
620
+ note(`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!`, '🚀 Ready to Use');
621
+ }
622
+ outro('✨ Setup complete!');
623
+ }
624
+ cli
625
+ .command('', 'Set up and run the Kimaki Discord bot')
626
+ .option('--restart', 'Prompt for new credentials even if saved')
627
+ .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
628
+ .action(async (options) => {
629
+ try {
630
+ await checkSingleInstance();
631
+ await startLockServer();
632
+ await run({
633
+ restart: options.restart,
634
+ addChannels: options.addChannels,
635
+ });
636
+ }
637
+ catch (error) {
638
+ cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error));
639
+ process.exit(EXIT_NO_RESTART);
640
+ }
641
+ });
642
+ cli
643
+ .command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
644
+ .option('-s, --session <sessionId>', 'OpenCode session ID')
645
+ .action(async (files, options) => {
646
+ try {
647
+ const { session: sessionId } = options;
648
+ if (!sessionId) {
649
+ cliLogger.error('Session ID is required. Use --session <sessionId>');
650
+ process.exit(EXIT_NO_RESTART);
651
+ }
652
+ if (!files || files.length === 0) {
653
+ cliLogger.error('At least one file path is required');
654
+ process.exit(EXIT_NO_RESTART);
655
+ }
656
+ const resolvedFiles = files.map((f) => path.resolve(f));
657
+ for (const file of resolvedFiles) {
658
+ if (!fs.existsSync(file)) {
659
+ cliLogger.error(`File not found: ${file}`);
660
+ process.exit(EXIT_NO_RESTART);
661
+ }
662
+ }
663
+ const db = getDatabase();
664
+ const threadRow = db
665
+ .prepare('SELECT thread_id FROM thread_sessions WHERE session_id = ?')
666
+ .get(sessionId);
667
+ if (!threadRow) {
668
+ cliLogger.error(`No Discord thread found for session: ${sessionId}`);
669
+ process.exit(EXIT_NO_RESTART);
670
+ }
671
+ const botRow = db
672
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
673
+ .get();
674
+ if (!botRow) {
675
+ cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.');
676
+ process.exit(EXIT_NO_RESTART);
677
+ }
678
+ const s = spinner();
679
+ s.start(`Uploading ${resolvedFiles.length} file(s)...`);
680
+ for (const file of resolvedFiles) {
681
+ const buffer = fs.readFileSync(file);
682
+ const formData = new FormData();
683
+ formData.append('payload_json', JSON.stringify({
684
+ attachments: [{ id: 0, filename: path.basename(file) }],
685
+ }));
686
+ formData.append('files[0]', new Blob([buffer]), path.basename(file));
687
+ const response = await fetch(`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`, {
688
+ method: 'POST',
689
+ headers: {
690
+ Authorization: `Bot ${botRow.token}`,
691
+ },
692
+ body: formData,
693
+ });
694
+ if (!response.ok) {
695
+ const error = await response.text();
696
+ throw new Error(`Discord API error: ${response.status} - ${error}`);
697
+ }
698
+ }
699
+ s.stop(`Uploaded ${resolvedFiles.length} file(s)!`);
700
+ note(`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`, '✅ Success');
701
+ process.exit(0);
702
+ }
703
+ catch (error) {
704
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
705
+ process.exit(EXIT_NO_RESTART);
706
+ }
707
+ });
708
+ cli.help();
709
+ cli.parse();