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