kimaki 0.4.34 → 0.4.36
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/dist/ai-tool-to-genai.js +1 -3
- package/dist/channel-management.js +1 -1
- package/dist/cli.js +142 -39
- package/dist/commands/abort.js +1 -1
- package/dist/commands/add-project.js +1 -1
- package/dist/commands/agent.js +6 -2
- package/dist/commands/ask-question.js +2 -1
- package/dist/commands/fork.js +7 -7
- package/dist/commands/queue.js +2 -2
- package/dist/commands/remove-project.js +109 -0
- package/dist/commands/resume.js +3 -5
- package/dist/commands/session.js +56 -1
- package/dist/commands/share.js +1 -1
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/user-command.js +3 -6
- package/dist/config.js +1 -1
- package/dist/discord-bot.js +4 -10
- package/dist/discord-utils.js +33 -9
- package/dist/genai.js +4 -6
- package/dist/interaction-handler.js +8 -1
- package/dist/markdown.js +1 -3
- package/dist/message-formatting.js +7 -3
- package/dist/openai-realtime.js +3 -5
- package/dist/opencode.js +2 -3
- package/dist/session-handler.js +42 -25
- package/dist/system-message.js +5 -3
- package/dist/tools.js +9 -22
- package/dist/unnest-code-blocks.js +4 -2
- package/dist/unnest-code-blocks.test.js +40 -15
- package/dist/voice-handler.js +9 -12
- package/dist/voice.js +5 -3
- package/dist/xml.js +2 -4
- package/package.json +3 -2
- package/src/__snapshots__/compact-session-context-no-system.md +24 -24
- package/src/__snapshots__/compact-session-context.md +31 -31
- package/src/ai-tool-to-genai.ts +3 -11
- package/src/channel-management.ts +14 -25
- package/src/cli.ts +290 -195
- package/src/commands/abort.ts +1 -3
- package/src/commands/add-project.ts +8 -14
- package/src/commands/agent.ts +16 -9
- package/src/commands/ask-question.ts +8 -7
- package/src/commands/create-new-project.ts +8 -14
- package/src/commands/fork.ts +23 -27
- package/src/commands/model.ts +14 -11
- package/src/commands/permissions.ts +1 -1
- package/src/commands/queue.ts +6 -19
- package/src/commands/remove-project.ts +136 -0
- package/src/commands/resume.ts +11 -30
- package/src/commands/session.ts +68 -9
- package/src/commands/share.ts +1 -3
- package/src/commands/types.ts +1 -3
- package/src/commands/undo-redo.ts +6 -18
- package/src/commands/user-command.ts +8 -10
- package/src/config.ts +5 -5
- package/src/database.ts +10 -8
- package/src/discord-bot.ts +22 -46
- package/src/discord-utils.ts +35 -18
- package/src/escape-backticks.test.ts +0 -2
- package/src/format-tables.ts +1 -4
- package/src/genai-worker-wrapper.ts +3 -9
- package/src/genai-worker.ts +4 -19
- package/src/genai.ts +10 -42
- package/src/interaction-handler.ts +133 -121
- package/src/markdown.test.ts +10 -32
- package/src/markdown.ts +6 -14
- package/src/message-formatting.ts +13 -14
- package/src/openai-realtime.ts +25 -47
- package/src/opencode.ts +26 -37
- package/src/session-handler.ts +111 -75
- package/src/system-message.ts +13 -3
- package/src/tools.ts +13 -39
- package/src/unnest-code-blocks.test.ts +42 -15
- package/src/unnest-code-blocks.ts +4 -2
- package/src/utils.ts +1 -4
- package/src/voice-handler.ts +34 -78
- package/src/voice.ts +11 -19
- package/src/xml.test.ts +1 -1
- package/src/xml.ts +3 -12
package/src/cli.ts
CHANGED
|
@@ -41,7 +41,6 @@ import {
|
|
|
41
41
|
import path from 'node:path'
|
|
42
42
|
import fs from 'node:fs'
|
|
43
43
|
|
|
44
|
-
|
|
45
44
|
import { createLogger } from './logger.js'
|
|
46
45
|
import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
|
|
47
46
|
import http from 'node:http'
|
|
@@ -60,11 +59,22 @@ async function killProcessOnPort(port: number): Promise<boolean> {
|
|
|
60
59
|
try {
|
|
61
60
|
if (isWindows) {
|
|
62
61
|
// Windows: find PID using netstat, then kill
|
|
63
|
-
const result = spawnSync(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
const result = spawnSync(
|
|
63
|
+
'cmd',
|
|
64
|
+
[
|
|
65
|
+
'/c',
|
|
66
|
+
`for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`,
|
|
67
|
+
],
|
|
68
|
+
{
|
|
69
|
+
shell: false,
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
const pids = result.stdout
|
|
74
|
+
?.trim()
|
|
75
|
+
.split('\n')
|
|
76
|
+
.map((p) => p.trim())
|
|
77
|
+
.filter((p) => /^\d+$/.test(p))
|
|
68
78
|
// Filter out our own PID and take the first (oldest)
|
|
69
79
|
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
|
|
70
80
|
if (targetPid) {
|
|
@@ -78,7 +88,11 @@ async function killProcessOnPort(port: number): Promise<boolean> {
|
|
|
78
88
|
shell: false,
|
|
79
89
|
encoding: 'utf-8',
|
|
80
90
|
})
|
|
81
|
-
const pids = result.stdout
|
|
91
|
+
const pids = result.stdout
|
|
92
|
+
?.trim()
|
|
93
|
+
.split('\n')
|
|
94
|
+
.map((p) => p.trim())
|
|
95
|
+
.filter((p) => /^\d+$/.test(p))
|
|
82
96
|
// Filter out our own PID and take the first (oldest)
|
|
83
97
|
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
|
|
84
98
|
if (targetPid) {
|
|
@@ -104,7 +118,9 @@ async function checkSingleInstance(): Promise<void> {
|
|
|
104
118
|
cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`)
|
|
105
119
|
await killProcessOnPort(lockPort)
|
|
106
120
|
// Wait a moment for port to be released
|
|
107
|
-
await new Promise((resolve) => {
|
|
121
|
+
await new Promise((resolve) => {
|
|
122
|
+
setTimeout(resolve, 500)
|
|
123
|
+
})
|
|
108
124
|
}
|
|
109
125
|
} catch {
|
|
110
126
|
cliLogger.debug('No other kimaki instance detected on lock port')
|
|
@@ -127,7 +143,9 @@ async function startLockServer(): Promise<void> {
|
|
|
127
143
|
if (err.code === 'EADDRINUSE') {
|
|
128
144
|
cliLogger.log('Port still in use, retrying...')
|
|
129
145
|
await killProcessOnPort(lockPort)
|
|
130
|
-
await new Promise((r) => {
|
|
146
|
+
await new Promise((r) => {
|
|
147
|
+
setTimeout(r, 500)
|
|
148
|
+
})
|
|
131
149
|
// Retry once
|
|
132
150
|
server.listen(lockPort, '127.0.0.1')
|
|
133
151
|
} else {
|
|
@@ -137,8 +155,6 @@ async function startLockServer(): Promise<void> {
|
|
|
137
155
|
})
|
|
138
156
|
}
|
|
139
157
|
|
|
140
|
-
|
|
141
|
-
|
|
142
158
|
const EXIT_NO_RESTART = 64
|
|
143
159
|
|
|
144
160
|
type Project = {
|
|
@@ -160,7 +176,11 @@ type CliOptions = {
|
|
|
160
176
|
// Commands to skip when registering user commands (reserved names)
|
|
161
177
|
const SKIP_USER_COMMANDS = ['init']
|
|
162
178
|
|
|
163
|
-
async function registerCommands(
|
|
179
|
+
async function registerCommands(
|
|
180
|
+
token: string,
|
|
181
|
+
appId: string,
|
|
182
|
+
userCommands: OpencodeCommand[] = [],
|
|
183
|
+
) {
|
|
164
184
|
const commands = [
|
|
165
185
|
new SlashCommandBuilder()
|
|
166
186
|
.setName('resume')
|
|
@@ -179,24 +199,27 @@ async function registerCommands(token: string, appId: string, userCommands: Open
|
|
|
179
199
|
.setName('session')
|
|
180
200
|
.setDescription('Start a new OpenCode session')
|
|
181
201
|
.addStringOption((option) => {
|
|
182
|
-
option
|
|
183
|
-
.setName('prompt')
|
|
184
|
-
.setDescription('Prompt content for the session')
|
|
185
|
-
.setRequired(true)
|
|
202
|
+
option.setName('prompt').setDescription('Prompt content for the session').setRequired(true)
|
|
186
203
|
|
|
187
204
|
return option
|
|
188
205
|
})
|
|
189
206
|
.addStringOption((option) => {
|
|
190
207
|
option
|
|
191
208
|
.setName('files')
|
|
192
|
-
.setDescription(
|
|
193
|
-
'Files to mention (comma or space separated; autocomplete)',
|
|
194
|
-
)
|
|
209
|
+
.setDescription('Files to mention (comma or space separated; autocomplete)')
|
|
195
210
|
.setAutocomplete(true)
|
|
196
211
|
.setMaxLength(6000)
|
|
197
212
|
|
|
198
213
|
return option
|
|
199
214
|
})
|
|
215
|
+
.addStringOption((option) => {
|
|
216
|
+
option
|
|
217
|
+
.setName('agent')
|
|
218
|
+
.setDescription('Agent to use for this session')
|
|
219
|
+
.setAutocomplete(true)
|
|
220
|
+
|
|
221
|
+
return option
|
|
222
|
+
})
|
|
200
223
|
.toJSON(),
|
|
201
224
|
new SlashCommandBuilder()
|
|
202
225
|
.setName('add-project')
|
|
@@ -212,13 +235,23 @@ async function registerCommands(token: string, appId: string, userCommands: Open
|
|
|
212
235
|
})
|
|
213
236
|
.toJSON(),
|
|
214
237
|
new SlashCommandBuilder()
|
|
215
|
-
.setName('
|
|
216
|
-
.setDescription('
|
|
238
|
+
.setName('remove-project')
|
|
239
|
+
.setDescription('Remove Discord channels for a project')
|
|
217
240
|
.addStringOption((option) => {
|
|
218
241
|
option
|
|
219
|
-
.setName('
|
|
220
|
-
.setDescription('
|
|
242
|
+
.setName('project')
|
|
243
|
+
.setDescription('Select a project to remove')
|
|
221
244
|
.setRequired(true)
|
|
245
|
+
.setAutocomplete(true)
|
|
246
|
+
|
|
247
|
+
return option
|
|
248
|
+
})
|
|
249
|
+
.toJSON(),
|
|
250
|
+
new SlashCommandBuilder()
|
|
251
|
+
.setName('create-new-project')
|
|
252
|
+
.setDescription('Create a new project folder, initialize git, and start a session')
|
|
253
|
+
.addStringOption((option) => {
|
|
254
|
+
option.setName('name').setDescription('Name for the new project folder').setRequired(true)
|
|
222
255
|
|
|
223
256
|
return option
|
|
224
257
|
})
|
|
@@ -251,10 +284,7 @@ async function registerCommands(token: string, appId: string, userCommands: Open
|
|
|
251
284
|
.setName('queue')
|
|
252
285
|
.setDescription('Queue a message to be sent after the current response finishes')
|
|
253
286
|
.addStringOption((option) => {
|
|
254
|
-
option
|
|
255
|
-
.setName('message')
|
|
256
|
-
.setDescription('The message to queue')
|
|
257
|
-
.setRequired(true)
|
|
287
|
+
option.setName('message').setDescription('The message to queue').setRequired(true)
|
|
258
288
|
|
|
259
289
|
return option
|
|
260
290
|
})
|
|
@@ -286,7 +316,7 @@ async function registerCommands(token: string, appId: string, userCommands: Open
|
|
|
286
316
|
|
|
287
317
|
commands.push(
|
|
288
318
|
new SlashCommandBuilder()
|
|
289
|
-
.setName(commandName)
|
|
319
|
+
.setName(commandName.slice(0, 32)) // Discord limits to 32 chars
|
|
290
320
|
.setDescription(description.slice(0, 100)) // Discord limits to 100 chars
|
|
291
321
|
.addStringOption((option) => {
|
|
292
322
|
option
|
|
@@ -306,19 +336,13 @@ async function registerCommands(token: string, appId: string, userCommands: Open
|
|
|
306
336
|
body: commands,
|
|
307
337
|
})) as any[]
|
|
308
338
|
|
|
309
|
-
cliLogger.info(
|
|
310
|
-
`COMMANDS: Successfully registered ${data.length} slash commands`,
|
|
311
|
-
)
|
|
339
|
+
cliLogger.info(`COMMANDS: Successfully registered ${data.length} slash commands`)
|
|
312
340
|
} catch (error) {
|
|
313
|
-
cliLogger.error(
|
|
314
|
-
'COMMANDS: Failed to register slash commands: ' + String(error),
|
|
315
|
-
)
|
|
341
|
+
cliLogger.error('COMMANDS: Failed to register slash commands: ' + String(error))
|
|
316
342
|
throw error
|
|
317
343
|
}
|
|
318
344
|
}
|
|
319
345
|
|
|
320
|
-
|
|
321
|
-
|
|
322
346
|
async function run({ restart, addChannels }: CliOptions) {
|
|
323
347
|
const forceSetup = Boolean(restart)
|
|
324
348
|
|
|
@@ -328,10 +352,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
328
352
|
const opencodeCheck = spawnSync('which', ['opencode'], { shell: true })
|
|
329
353
|
|
|
330
354
|
if (opencodeCheck.status !== 0) {
|
|
331
|
-
note(
|
|
332
|
-
'OpenCode CLI is required but not found in your PATH.',
|
|
333
|
-
'⚠️ OpenCode Not Found',
|
|
334
|
-
)
|
|
355
|
+
note('OpenCode CLI is required but not found in your PATH.', '⚠️ OpenCode Not Found')
|
|
335
356
|
|
|
336
357
|
const shouldInstall = await confirm({
|
|
337
358
|
message: 'Would you like to install OpenCode right now?',
|
|
@@ -383,10 +404,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
383
404
|
process.env.OPENCODE_PATH = installedPath
|
|
384
405
|
} catch (error) {
|
|
385
406
|
s.stop('Failed to install OpenCode CLI')
|
|
386
|
-
cliLogger.error(
|
|
387
|
-
'Installation error:',
|
|
388
|
-
error instanceof Error ? error.message : String(error),
|
|
389
|
-
)
|
|
407
|
+
cliLogger.error('Installation error:', error instanceof Error ? error.message : String(error))
|
|
390
408
|
process.exit(EXIT_NO_RESTART)
|
|
391
409
|
}
|
|
392
410
|
}
|
|
@@ -396,13 +414,10 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
396
414
|
let token: string
|
|
397
415
|
|
|
398
416
|
const existingBot = db
|
|
399
|
-
.prepare(
|
|
400
|
-
'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
401
|
-
)
|
|
417
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
402
418
|
.get() as { app_id: string; token: string } | undefined
|
|
403
419
|
|
|
404
|
-
const shouldAddChannels =
|
|
405
|
-
!existingBot?.token || forceSetup || Boolean(addChannels)
|
|
420
|
+
const shouldAddChannels = !existingBot?.token || forceSetup || Boolean(addChannels)
|
|
406
421
|
|
|
407
422
|
if (existingBot && !forceSetup) {
|
|
408
423
|
appId = existingBot.app_id
|
|
@@ -473,8 +488,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
473
488
|
'Step 3: Get Bot Token',
|
|
474
489
|
)
|
|
475
490
|
const tokenInput = await password({
|
|
476
|
-
message:
|
|
477
|
-
'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
|
|
491
|
+
message: 'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
|
|
478
492
|
validate(value) {
|
|
479
493
|
if (!value) return 'Bot token is required'
|
|
480
494
|
if (value.length < 50) return 'Invalid token format (too short)'
|
|
@@ -487,10 +501,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
487
501
|
}
|
|
488
502
|
token = tokenInput
|
|
489
503
|
|
|
490
|
-
note(
|
|
491
|
-
`You can get a Gemini api Key at https://aistudio.google.com/apikey`,
|
|
492
|
-
`Gemini API Key`,
|
|
493
|
-
)
|
|
504
|
+
note(`You can get a Gemini api Key at https://aistudio.google.com/apikey`, `Gemini API Key`)
|
|
494
505
|
|
|
495
506
|
const geminiApiKey = await password({
|
|
496
507
|
message:
|
|
@@ -508,9 +519,10 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
508
519
|
|
|
509
520
|
// Store API key in database
|
|
510
521
|
if (geminiApiKey) {
|
|
511
|
-
db.prepare(
|
|
512
|
-
|
|
513
|
-
|
|
522
|
+
db.prepare('INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)').run(
|
|
523
|
+
appId,
|
|
524
|
+
geminiApiKey || null,
|
|
525
|
+
)
|
|
514
526
|
note('API key saved successfully', 'API Key Stored')
|
|
515
527
|
}
|
|
516
528
|
|
|
@@ -557,9 +569,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
557
569
|
guild.roles
|
|
558
570
|
.fetch()
|
|
559
571
|
.then(async (roles) => {
|
|
560
|
-
const existingRole = roles.find(
|
|
561
|
-
(role) => role.name.toLowerCase() === 'kimaki',
|
|
562
|
-
)
|
|
572
|
+
const existingRole = roles.find((role) => role.name.toLowerCase() === 'kimaki')
|
|
563
573
|
if (existingRole) {
|
|
564
574
|
// Move to bottom if not already there
|
|
565
575
|
if (existingRole.position > 1) {
|
|
@@ -588,8 +598,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
588
598
|
|
|
589
599
|
const channels = await getChannelsWithDescriptions(guild)
|
|
590
600
|
const kimakiChans = channels.filter(
|
|
591
|
-
(ch) =>
|
|
592
|
-
ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
|
|
601
|
+
(ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
|
|
593
602
|
)
|
|
594
603
|
|
|
595
604
|
return { guild, channels: kimakiChans }
|
|
@@ -614,14 +623,10 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
614
623
|
s.stop('Connected to Discord!')
|
|
615
624
|
} catch (error) {
|
|
616
625
|
s.stop('Failed to connect to Discord')
|
|
617
|
-
cliLogger.error(
|
|
618
|
-
'Error: ' + (error instanceof Error ? error.message : String(error)),
|
|
619
|
-
)
|
|
626
|
+
cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)))
|
|
620
627
|
process.exit(EXIT_NO_RESTART)
|
|
621
628
|
}
|
|
622
|
-
db.prepare(
|
|
623
|
-
'INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)',
|
|
624
|
-
).run(appId, token)
|
|
629
|
+
db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token)
|
|
625
630
|
|
|
626
631
|
for (const { guild, channels } of kimakiChannels) {
|
|
627
632
|
for (const channel of channels) {
|
|
@@ -631,8 +636,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
631
636
|
).run(channel.id, channel.kimakiDirectory, 'text')
|
|
632
637
|
|
|
633
638
|
const voiceChannel = guild.channels.cache.find(
|
|
634
|
-
(ch) =>
|
|
635
|
-
ch.type === ChannelType.GuildVoice && ch.name === channel.name,
|
|
639
|
+
(ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name,
|
|
636
640
|
)
|
|
637
641
|
|
|
638
642
|
if (voiceChannel) {
|
|
@@ -649,11 +653,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
649
653
|
.flatMap(({ guild, channels }) =>
|
|
650
654
|
channels.map((ch) => {
|
|
651
655
|
const appInfo =
|
|
652
|
-
ch.kimakiApp === appId
|
|
653
|
-
? ' (this bot)'
|
|
654
|
-
: ch.kimakiApp
|
|
655
|
-
? ` (app: ${ch.kimakiApp})`
|
|
656
|
-
: ''
|
|
656
|
+
ch.kimakiApp === appId ? ' (this bot)' : ch.kimakiApp ? ` (app: ${ch.kimakiApp})` : ''
|
|
657
657
|
return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`
|
|
658
658
|
}),
|
|
659
659
|
)
|
|
@@ -671,13 +671,19 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
671
671
|
|
|
672
672
|
// Fetch projects and commands in parallel
|
|
673
673
|
const [projects, allUserCommands] = await Promise.all([
|
|
674
|
-
getClient()
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
674
|
+
getClient()
|
|
675
|
+
.project.list({})
|
|
676
|
+
.then((r) => r.data || [])
|
|
677
|
+
.catch((error) => {
|
|
678
|
+
s.stop('Failed to fetch projects')
|
|
679
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
|
|
680
|
+
discordClient.destroy()
|
|
681
|
+
process.exit(EXIT_NO_RESTART)
|
|
682
|
+
}),
|
|
683
|
+
getClient()
|
|
684
|
+
.command.list({ query: { directory: currentDir } })
|
|
685
|
+
.then((r) => r.data || [])
|
|
686
|
+
.catch(() => []),
|
|
681
687
|
])
|
|
682
688
|
|
|
683
689
|
s.stop(`Found ${projects.length} OpenCode project(s)`)
|
|
@@ -703,16 +709,10 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
703
709
|
)
|
|
704
710
|
|
|
705
711
|
if (availableProjects.length === 0) {
|
|
706
|
-
note(
|
|
707
|
-
'All OpenCode projects already have Discord channels',
|
|
708
|
-
'No New Projects',
|
|
709
|
-
)
|
|
712
|
+
note('All OpenCode projects already have Discord channels', 'No New Projects')
|
|
710
713
|
}
|
|
711
714
|
|
|
712
|
-
if (
|
|
713
|
-
(!existingDirs?.length && availableProjects.length > 0) ||
|
|
714
|
-
shouldAddChannels
|
|
715
|
-
) {
|
|
715
|
+
if ((!existingDirs?.length && availableProjects.length > 0) || shouldAddChannels) {
|
|
716
716
|
const selectedProjects = await multiselect({
|
|
717
717
|
message: 'Select projects to create Discord channels for:',
|
|
718
718
|
options: availableProjects.map((project) => ({
|
|
@@ -773,17 +773,17 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
773
773
|
guildId: targetGuild.id,
|
|
774
774
|
})
|
|
775
775
|
} catch (error) {
|
|
776
|
-
cliLogger.error(
|
|
776
|
+
cliLogger.error(
|
|
777
|
+
`Failed to create channels for ${path.basename(project.worktree)}:`,
|
|
778
|
+
error,
|
|
779
|
+
)
|
|
777
780
|
}
|
|
778
781
|
}
|
|
779
782
|
|
|
780
783
|
s.stop(`Created ${createdChannels.length} channel(s)`)
|
|
781
784
|
|
|
782
785
|
if (createdChannels.length > 0) {
|
|
783
|
-
note(
|
|
784
|
-
createdChannels.map((ch) => `#${ch.name}`).join('\n'),
|
|
785
|
-
'Created Channels',
|
|
786
|
-
)
|
|
786
|
+
note(createdChannels.map((ch) => `#${ch.name}`).join('\n'), 'Created Channels')
|
|
787
787
|
}
|
|
788
788
|
}
|
|
789
789
|
}
|
|
@@ -842,10 +842,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
842
842
|
|
|
843
843
|
if (allChannels.length > 0) {
|
|
844
844
|
const channelLinks = allChannels
|
|
845
|
-
.map(
|
|
846
|
-
(ch) =>
|
|
847
|
-
`• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`,
|
|
848
|
-
)
|
|
845
|
+
.map((ch) => `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
|
|
849
846
|
.join('\n')
|
|
850
847
|
|
|
851
848
|
note(
|
|
@@ -854,63 +851,62 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
854
851
|
)
|
|
855
852
|
}
|
|
856
853
|
|
|
857
|
-
|
|
854
|
+
note(
|
|
855
|
+
'Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `npx kimaki` again to start the bot.',
|
|
856
|
+
'⚠️ Keep Running',
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
outro('✨ Setup complete! Listening for new messages... do not close this process.')
|
|
858
860
|
}
|
|
859
861
|
|
|
860
862
|
cli
|
|
861
863
|
.command('', 'Set up and run the Kimaki Discord bot')
|
|
862
864
|
.option('--restart', 'Prompt for new credentials even if saved')
|
|
863
|
-
.option(
|
|
864
|
-
|
|
865
|
-
'Select OpenCode projects to create Discord channels before starting',
|
|
866
|
-
)
|
|
867
|
-
.option(
|
|
868
|
-
'--data-dir <path>',
|
|
869
|
-
'Data directory for config and database (default: ~/.kimaki)',
|
|
870
|
-
)
|
|
865
|
+
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
866
|
+
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
871
867
|
.option('--install-url', 'Print the bot install URL and exit')
|
|
872
|
-
.action(
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
868
|
+
.action(
|
|
869
|
+
async (options: {
|
|
870
|
+
restart?: boolean
|
|
871
|
+
addChannels?: boolean
|
|
872
|
+
dataDir?: string
|
|
873
|
+
installUrl?: boolean
|
|
874
|
+
}) => {
|
|
875
|
+
try {
|
|
876
|
+
// Set data directory early, before any database access
|
|
877
|
+
if (options.dataDir) {
|
|
878
|
+
setDataDir(options.dataDir)
|
|
879
|
+
cliLogger.log(`Using data directory: ${getDataDir()}`)
|
|
880
|
+
}
|
|
879
881
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
)
|
|
886
|
-
.get() as { app_id: string } | undefined
|
|
882
|
+
if (options.installUrl) {
|
|
883
|
+
const db = getDatabase()
|
|
884
|
+
const existingBot = db
|
|
885
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
886
|
+
.get() as { app_id: string } | undefined
|
|
887
887
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
888
|
+
if (!existingBot) {
|
|
889
|
+
cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
|
|
890
|
+
process.exit(EXIT_NO_RESTART)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
|
|
894
|
+
process.exit(0)
|
|
891
895
|
}
|
|
892
896
|
|
|
893
|
-
|
|
894
|
-
|
|
897
|
+
await checkSingleInstance()
|
|
898
|
+
await startLockServer()
|
|
899
|
+
await run({
|
|
900
|
+
restart: options.restart,
|
|
901
|
+
addChannels: options.addChannels,
|
|
902
|
+
dataDir: options.dataDir,
|
|
903
|
+
})
|
|
904
|
+
} catch (error) {
|
|
905
|
+
cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error))
|
|
906
|
+
process.exit(EXIT_NO_RESTART)
|
|
895
907
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
await startLockServer()
|
|
899
|
-
await run({
|
|
900
|
-
restart: options.restart,
|
|
901
|
-
addChannels: options.addChannels,
|
|
902
|
-
dataDir: options.dataDir,
|
|
903
|
-
})
|
|
904
|
-
} catch (error) {
|
|
905
|
-
cliLogger.error(
|
|
906
|
-
'Unhandled error:',
|
|
907
|
-
error instanceof Error ? error.message : String(error),
|
|
908
|
-
)
|
|
909
|
-
process.exit(EXIT_NO_RESTART)
|
|
910
|
-
}
|
|
911
|
-
})
|
|
912
|
-
|
|
913
|
-
|
|
908
|
+
},
|
|
909
|
+
)
|
|
914
910
|
|
|
915
911
|
cli
|
|
916
912
|
.command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
|
|
@@ -949,9 +945,7 @@ cli
|
|
|
949
945
|
}
|
|
950
946
|
|
|
951
947
|
const botRow = db
|
|
952
|
-
.prepare(
|
|
953
|
-
'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
954
|
-
)
|
|
948
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
955
949
|
.get() as { app_id: string; token: string } | undefined
|
|
956
950
|
|
|
957
951
|
if (!botRow) {
|
|
@@ -966,9 +960,12 @@ cli
|
|
|
966
960
|
const buffer = fs.readFileSync(file)
|
|
967
961
|
|
|
968
962
|
const formData = new FormData()
|
|
969
|
-
formData.append(
|
|
970
|
-
|
|
971
|
-
|
|
963
|
+
formData.append(
|
|
964
|
+
'payload_json',
|
|
965
|
+
JSON.stringify({
|
|
966
|
+
attachments: [{ id: 0, filename: path.basename(file) }],
|
|
967
|
+
}),
|
|
968
|
+
)
|
|
972
969
|
formData.append('files[0]', new Blob([buffer]), path.basename(file))
|
|
973
970
|
|
|
974
971
|
const response = await fetch(
|
|
@@ -976,10 +973,10 @@ cli
|
|
|
976
973
|
{
|
|
977
974
|
method: 'POST',
|
|
978
975
|
headers: {
|
|
979
|
-
|
|
976
|
+
Authorization: `Bot ${botRow.token}`,
|
|
980
977
|
},
|
|
981
978
|
body: formData,
|
|
982
|
-
}
|
|
979
|
+
},
|
|
983
980
|
)
|
|
984
981
|
|
|
985
982
|
if (!response.ok) {
|
|
@@ -997,38 +994,46 @@ cli
|
|
|
997
994
|
|
|
998
995
|
process.exit(0)
|
|
999
996
|
} catch (error) {
|
|
1000
|
-
cliLogger.error(
|
|
1001
|
-
'Error:',
|
|
1002
|
-
error instanceof Error ? error.message : String(error),
|
|
1003
|
-
)
|
|
997
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
|
|
1004
998
|
process.exit(EXIT_NO_RESTART)
|
|
1005
999
|
}
|
|
1006
1000
|
})
|
|
1007
1001
|
|
|
1008
|
-
|
|
1009
1002
|
// Magic prefix used to identify bot-initiated sessions.
|
|
1010
1003
|
// The running bot will recognize this prefix and start a session.
|
|
1011
1004
|
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
|
|
1012
1005
|
|
|
1013
1006
|
cli
|
|
1014
|
-
.command(
|
|
1007
|
+
.command(
|
|
1008
|
+
'start-session',
|
|
1009
|
+
'Start a new session in a Discord channel (creates thread, bot handles the rest)',
|
|
1010
|
+
)
|
|
1015
1011
|
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
1012
|
+
.option('-d, --project <path>', 'Project directory (alternative to --channel)')
|
|
1016
1013
|
.option('-p, --prompt <prompt>', 'Initial prompt for the session')
|
|
1017
1014
|
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
1018
1015
|
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
1019
|
-
.action(
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1016
|
+
.action(
|
|
1017
|
+
async (options: {
|
|
1018
|
+
channel?: string
|
|
1019
|
+
project?: string
|
|
1020
|
+
prompt?: string
|
|
1021
|
+
name?: string
|
|
1022
|
+
appId?: string
|
|
1023
|
+
}) => {
|
|
1024
|
+
try {
|
|
1025
|
+
let { channel: channelId, prompt, name, appId: optionAppId } = options
|
|
1026
|
+
const { project: projectPath } = options
|
|
1027
|
+
|
|
1028
|
+
if (!channelId && !projectPath) {
|
|
1029
|
+
cliLogger.error('Either --channel or --project is required')
|
|
1030
|
+
process.exit(EXIT_NO_RESTART)
|
|
1031
|
+
}
|
|
1027
1032
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1033
|
+
if (!prompt) {
|
|
1034
|
+
cliLogger.error('Prompt is required. Use --prompt <prompt>')
|
|
1035
|
+
process.exit(EXIT_NO_RESTART)
|
|
1036
|
+
}
|
|
1032
1037
|
|
|
1033
1038
|
// Get bot token from env var or database
|
|
1034
1039
|
const envToken = process.env.KIMAKI_BOT_TOKEN
|
|
@@ -1068,22 +1073,112 @@ cli
|
|
|
1068
1073
|
}
|
|
1069
1074
|
|
|
1070
1075
|
if (!botToken) {
|
|
1071
|
-
cliLogger.error(
|
|
1076
|
+
cliLogger.error(
|
|
1077
|
+
'No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.',
|
|
1078
|
+
)
|
|
1072
1079
|
process.exit(EXIT_NO_RESTART)
|
|
1073
1080
|
}
|
|
1074
1081
|
|
|
1075
1082
|
const s = spinner()
|
|
1083
|
+
|
|
1084
|
+
// If --project provided, resolve to channel ID
|
|
1085
|
+
if (projectPath) {
|
|
1086
|
+
const absolutePath = path.resolve(projectPath)
|
|
1087
|
+
|
|
1088
|
+
if (!fs.existsSync(absolutePath)) {
|
|
1089
|
+
cliLogger.error(`Directory does not exist: ${absolutePath}`)
|
|
1090
|
+
process.exit(EXIT_NO_RESTART)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
s.start('Looking up channel for project...')
|
|
1094
|
+
|
|
1095
|
+
// Check if channel already exists for this directory
|
|
1096
|
+
try {
|
|
1097
|
+
const db = getDatabase()
|
|
1098
|
+
const existingChannel = db
|
|
1099
|
+
.prepare(
|
|
1100
|
+
'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
|
|
1101
|
+
)
|
|
1102
|
+
.get(absolutePath, 'text') as { channel_id: string } | undefined
|
|
1103
|
+
|
|
1104
|
+
if (existingChannel) {
|
|
1105
|
+
channelId = existingChannel.channel_id
|
|
1106
|
+
s.message(`Found existing channel: ${channelId}`)
|
|
1107
|
+
} else {
|
|
1108
|
+
// Need to create a new channel
|
|
1109
|
+
s.message('Creating new channel...')
|
|
1110
|
+
|
|
1111
|
+
if (!appId) {
|
|
1112
|
+
s.stop('Missing app ID')
|
|
1113
|
+
cliLogger.error(
|
|
1114
|
+
'App ID is required to create channels. Use --app-id or run `kimaki` first.',
|
|
1115
|
+
)
|
|
1116
|
+
process.exit(EXIT_NO_RESTART)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const client = await createDiscordClient()
|
|
1120
|
+
|
|
1121
|
+
await new Promise<void>((resolve, reject) => {
|
|
1122
|
+
client.once(Events.ClientReady, () => {
|
|
1123
|
+
resolve()
|
|
1124
|
+
})
|
|
1125
|
+
client.once(Events.Error, reject)
|
|
1126
|
+
client.login(botToken)
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
// Get guild from existing channels or first available
|
|
1130
|
+
const guild = await (async () => {
|
|
1131
|
+
// Try to find a guild from existing channels
|
|
1132
|
+
const existingChannelRow = db
|
|
1133
|
+
.prepare(
|
|
1134
|
+
'SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1',
|
|
1135
|
+
)
|
|
1136
|
+
.get() as { channel_id: string } | undefined
|
|
1137
|
+
|
|
1138
|
+
if (existingChannelRow) {
|
|
1139
|
+
try {
|
|
1140
|
+
const ch = await client.channels.fetch(existingChannelRow.channel_id)
|
|
1141
|
+
if (ch && 'guild' in ch && ch.guild) {
|
|
1142
|
+
return ch.guild
|
|
1143
|
+
}
|
|
1144
|
+
} catch {
|
|
1145
|
+
// Channel might be deleted, continue
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
// Fall back to first guild
|
|
1149
|
+
const firstGuild = client.guilds.cache.first()
|
|
1150
|
+
if (!firstGuild) {
|
|
1151
|
+
throw new Error('No guild found. Add the bot to a server first.')
|
|
1152
|
+
}
|
|
1153
|
+
return firstGuild
|
|
1154
|
+
})()
|
|
1155
|
+
|
|
1156
|
+
const { textChannelId } = await createProjectChannels({
|
|
1157
|
+
guild,
|
|
1158
|
+
projectDirectory: absolutePath,
|
|
1159
|
+
appId,
|
|
1160
|
+
botName: client.user?.username,
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
channelId = textChannelId
|
|
1164
|
+
s.message(`Created channel: ${channelId}`)
|
|
1165
|
+
|
|
1166
|
+
client.destroy()
|
|
1167
|
+
}
|
|
1168
|
+
} catch (e) {
|
|
1169
|
+
s.stop('Failed to resolve project')
|
|
1170
|
+
throw e
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1076
1174
|
s.start('Fetching channel info...')
|
|
1077
1175
|
|
|
1078
1176
|
// Get channel info to extract directory from topic
|
|
1079
|
-
const channelResponse = await fetch(
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
},
|
|
1085
|
-
}
|
|
1086
|
-
)
|
|
1177
|
+
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
|
1178
|
+
headers: {
|
|
1179
|
+
Authorization: `Bot ${botToken}`,
|
|
1180
|
+
},
|
|
1181
|
+
})
|
|
1087
1182
|
|
|
1088
1183
|
if (!channelResponse.ok) {
|
|
1089
1184
|
const error = await channelResponse.text()
|
|
@@ -1091,7 +1186,7 @@ cli
|
|
|
1091
1186
|
throw new Error(`Discord API error: ${channelResponse.status} - ${error}`)
|
|
1092
1187
|
}
|
|
1093
1188
|
|
|
1094
|
-
const channelData = await channelResponse.json() as {
|
|
1189
|
+
const channelData = (await channelResponse.json()) as {
|
|
1095
1190
|
id: string
|
|
1096
1191
|
name: string
|
|
1097
1192
|
topic?: string
|
|
@@ -1100,7 +1195,9 @@ cli
|
|
|
1100
1195
|
|
|
1101
1196
|
if (!channelData.topic) {
|
|
1102
1197
|
s.stop('Channel has no topic')
|
|
1103
|
-
throw new Error(
|
|
1198
|
+
throw new Error(
|
|
1199
|
+
`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`,
|
|
1200
|
+
)
|
|
1104
1201
|
}
|
|
1105
1202
|
|
|
1106
1203
|
const extracted = extractTagsArrays({
|
|
@@ -1119,7 +1216,9 @@ cli
|
|
|
1119
1216
|
// Verify app ID matches if both are present
|
|
1120
1217
|
if (channelAppId && appId && channelAppId !== appId) {
|
|
1121
1218
|
s.stop('Channel belongs to different bot')
|
|
1122
|
-
throw new Error(
|
|
1219
|
+
throw new Error(
|
|
1220
|
+
`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`,
|
|
1221
|
+
)
|
|
1123
1222
|
}
|
|
1124
1223
|
|
|
1125
1224
|
s.message('Creating starter message...')
|
|
@@ -1131,13 +1230,13 @@ cli
|
|
|
1131
1230
|
{
|
|
1132
1231
|
method: 'POST',
|
|
1133
1232
|
headers: {
|
|
1134
|
-
|
|
1233
|
+
Authorization: `Bot ${botToken}`,
|
|
1135
1234
|
'Content-Type': 'application/json',
|
|
1136
1235
|
},
|
|
1137
1236
|
body: JSON.stringify({
|
|
1138
1237
|
content: `${BOT_SESSION_PREFIX}\n${prompt}`,
|
|
1139
1238
|
}),
|
|
1140
|
-
}
|
|
1239
|
+
},
|
|
1141
1240
|
)
|
|
1142
1241
|
|
|
1143
1242
|
if (!starterMessageResponse.ok) {
|
|
@@ -1146,7 +1245,7 @@ cli
|
|
|
1146
1245
|
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
|
|
1147
1246
|
}
|
|
1148
1247
|
|
|
1149
|
-
const starterMessage = await starterMessageResponse.json() as { id: string }
|
|
1248
|
+
const starterMessage = (await starterMessageResponse.json()) as { id: string }
|
|
1150
1249
|
|
|
1151
1250
|
s.message('Creating thread...')
|
|
1152
1251
|
|
|
@@ -1157,14 +1256,14 @@ cli
|
|
|
1157
1256
|
{
|
|
1158
1257
|
method: 'POST',
|
|
1159
1258
|
headers: {
|
|
1160
|
-
|
|
1259
|
+
Authorization: `Bot ${botToken}`,
|
|
1161
1260
|
'Content-Type': 'application/json',
|
|
1162
1261
|
},
|
|
1163
1262
|
body: JSON.stringify({
|
|
1164
1263
|
name: threadName.slice(0, 100),
|
|
1165
1264
|
auto_archive_duration: 1440, // 1 day
|
|
1166
1265
|
}),
|
|
1167
|
-
}
|
|
1266
|
+
},
|
|
1168
1267
|
)
|
|
1169
1268
|
|
|
1170
1269
|
if (!threadResponse.ok) {
|
|
@@ -1173,7 +1272,7 @@ cli
|
|
|
1173
1272
|
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`)
|
|
1174
1273
|
}
|
|
1175
1274
|
|
|
1176
|
-
const threadData = await threadResponse.json() as { id: string; name: string }
|
|
1275
|
+
const threadData = (await threadResponse.json()) as { id: string; name: string }
|
|
1177
1276
|
|
|
1178
1277
|
s.stop('Thread created!')
|
|
1179
1278
|
|
|
@@ -1188,14 +1287,10 @@ cli
|
|
|
1188
1287
|
|
|
1189
1288
|
process.exit(0)
|
|
1190
1289
|
} catch (error) {
|
|
1191
|
-
cliLogger.error(
|
|
1192
|
-
'Error:',
|
|
1193
|
-
error instanceof Error ? error.message : String(error),
|
|
1194
|
-
)
|
|
1290
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
|
|
1195
1291
|
process.exit(EXIT_NO_RESTART)
|
|
1196
1292
|
}
|
|
1197
1293
|
})
|
|
1198
1294
|
|
|
1199
|
-
|
|
1200
1295
|
cli.help()
|
|
1201
1296
|
cli.parse()
|