kimaki 0.4.35 → 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.
Files changed (75) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +1 -1
  3. package/dist/cli.js +135 -39
  4. package/dist/commands/abort.js +1 -1
  5. package/dist/commands/add-project.js +1 -1
  6. package/dist/commands/agent.js +6 -2
  7. package/dist/commands/ask-question.js +2 -1
  8. package/dist/commands/fork.js +7 -7
  9. package/dist/commands/queue.js +2 -2
  10. package/dist/commands/remove-project.js +109 -0
  11. package/dist/commands/resume.js +3 -5
  12. package/dist/commands/session.js +2 -2
  13. package/dist/commands/share.js +1 -1
  14. package/dist/commands/undo-redo.js +2 -2
  15. package/dist/commands/user-command.js +3 -6
  16. package/dist/config.js +1 -1
  17. package/dist/discord-bot.js +4 -10
  18. package/dist/discord-utils.js +33 -9
  19. package/dist/genai.js +4 -6
  20. package/dist/interaction-handler.js +8 -1
  21. package/dist/markdown.js +1 -3
  22. package/dist/message-formatting.js +7 -3
  23. package/dist/openai-realtime.js +3 -5
  24. package/dist/opencode.js +1 -1
  25. package/dist/session-handler.js +25 -15
  26. package/dist/system-message.js +5 -3
  27. package/dist/tools.js +9 -22
  28. package/dist/voice-handler.js +9 -12
  29. package/dist/voice.js +5 -3
  30. package/dist/xml.js +2 -4
  31. package/package.json +3 -2
  32. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  33. package/src/__snapshots__/compact-session-context.md +31 -31
  34. package/src/ai-tool-to-genai.ts +3 -11
  35. package/src/channel-management.ts +14 -25
  36. package/src/cli.ts +282 -195
  37. package/src/commands/abort.ts +1 -3
  38. package/src/commands/add-project.ts +8 -14
  39. package/src/commands/agent.ts +16 -9
  40. package/src/commands/ask-question.ts +8 -7
  41. package/src/commands/create-new-project.ts +8 -14
  42. package/src/commands/fork.ts +23 -27
  43. package/src/commands/model.ts +14 -11
  44. package/src/commands/permissions.ts +1 -1
  45. package/src/commands/queue.ts +6 -19
  46. package/src/commands/remove-project.ts +136 -0
  47. package/src/commands/resume.ts +11 -30
  48. package/src/commands/session.ts +4 -13
  49. package/src/commands/share.ts +1 -3
  50. package/src/commands/types.ts +1 -3
  51. package/src/commands/undo-redo.ts +6 -18
  52. package/src/commands/user-command.ts +8 -10
  53. package/src/config.ts +5 -5
  54. package/src/database.ts +10 -8
  55. package/src/discord-bot.ts +22 -46
  56. package/src/discord-utils.ts +35 -18
  57. package/src/escape-backticks.test.ts +0 -2
  58. package/src/format-tables.ts +1 -4
  59. package/src/genai-worker-wrapper.ts +3 -9
  60. package/src/genai-worker.ts +4 -19
  61. package/src/genai.ts +10 -42
  62. package/src/interaction-handler.ts +133 -121
  63. package/src/markdown.test.ts +10 -32
  64. package/src/markdown.ts +6 -14
  65. package/src/message-formatting.ts +13 -14
  66. package/src/openai-realtime.ts +25 -47
  67. package/src/opencode.ts +24 -34
  68. package/src/session-handler.ts +91 -61
  69. package/src/system-message.ts +13 -3
  70. package/src/tools.ts +13 -39
  71. package/src/utils.ts +1 -4
  72. package/src/voice-handler.ts +34 -78
  73. package/src/voice.ts +11 -19
  74. package/src/xml.test.ts +1 -1
  75. 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('cmd', ['/c', `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`], {
64
- shell: false,
65
- encoding: 'utf-8',
66
- })
67
- const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p))
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?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p))
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) => { setTimeout(resolve, 500) })
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) => { setTimeout(r, 500) })
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(token: string, appId: string, userCommands: OpencodeCommand[] = []) {
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,19 +199,14 @@ 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
 
@@ -220,13 +235,23 @@ async function registerCommands(token: string, appId: string, userCommands: Open
220
235
  })
221
236
  .toJSON(),
222
237
  new SlashCommandBuilder()
223
- .setName('create-new-project')
224
- .setDescription('Create a new project folder, initialize git, and start a session')
238
+ .setName('remove-project')
239
+ .setDescription('Remove Discord channels for a project')
225
240
  .addStringOption((option) => {
226
241
  option
227
- .setName('name')
228
- .setDescription('Name for the new project folder')
242
+ .setName('project')
243
+ .setDescription('Select a project to remove')
229
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)
230
255
 
231
256
  return option
232
257
  })
@@ -259,10 +284,7 @@ async function registerCommands(token: string, appId: string, userCommands: Open
259
284
  .setName('queue')
260
285
  .setDescription('Queue a message to be sent after the current response finishes')
261
286
  .addStringOption((option) => {
262
- option
263
- .setName('message')
264
- .setDescription('The message to queue')
265
- .setRequired(true)
287
+ option.setName('message').setDescription('The message to queue').setRequired(true)
266
288
 
267
289
  return option
268
290
  })
@@ -294,7 +316,7 @@ async function registerCommands(token: string, appId: string, userCommands: Open
294
316
 
295
317
  commands.push(
296
318
  new SlashCommandBuilder()
297
- .setName(commandName)
319
+ .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
298
320
  .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
299
321
  .addStringOption((option) => {
300
322
  option
@@ -314,19 +336,13 @@ async function registerCommands(token: string, appId: string, userCommands: Open
314
336
  body: commands,
315
337
  })) as any[]
316
338
 
317
- cliLogger.info(
318
- `COMMANDS: Successfully registered ${data.length} slash commands`,
319
- )
339
+ cliLogger.info(`COMMANDS: Successfully registered ${data.length} slash commands`)
320
340
  } catch (error) {
321
- cliLogger.error(
322
- 'COMMANDS: Failed to register slash commands: ' + String(error),
323
- )
341
+ cliLogger.error('COMMANDS: Failed to register slash commands: ' + String(error))
324
342
  throw error
325
343
  }
326
344
  }
327
345
 
328
-
329
-
330
346
  async function run({ restart, addChannels }: CliOptions) {
331
347
  const forceSetup = Boolean(restart)
332
348
 
@@ -336,10 +352,7 @@ async function run({ restart, addChannels }: CliOptions) {
336
352
  const opencodeCheck = spawnSync('which', ['opencode'], { shell: true })
337
353
 
338
354
  if (opencodeCheck.status !== 0) {
339
- note(
340
- 'OpenCode CLI is required but not found in your PATH.',
341
- '⚠️ OpenCode Not Found',
342
- )
355
+ note('OpenCode CLI is required but not found in your PATH.', '⚠️ OpenCode Not Found')
343
356
 
344
357
  const shouldInstall = await confirm({
345
358
  message: 'Would you like to install OpenCode right now?',
@@ -391,10 +404,7 @@ async function run({ restart, addChannels }: CliOptions) {
391
404
  process.env.OPENCODE_PATH = installedPath
392
405
  } catch (error) {
393
406
  s.stop('Failed to install OpenCode CLI')
394
- cliLogger.error(
395
- 'Installation error:',
396
- error instanceof Error ? error.message : String(error),
397
- )
407
+ cliLogger.error('Installation error:', error instanceof Error ? error.message : String(error))
398
408
  process.exit(EXIT_NO_RESTART)
399
409
  }
400
410
  }
@@ -404,13 +414,10 @@ async function run({ restart, addChannels }: CliOptions) {
404
414
  let token: string
405
415
 
406
416
  const existingBot = db
407
- .prepare(
408
- 'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
409
- )
417
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
410
418
  .get() as { app_id: string; token: string } | undefined
411
419
 
412
- const shouldAddChannels =
413
- !existingBot?.token || forceSetup || Boolean(addChannels)
420
+ const shouldAddChannels = !existingBot?.token || forceSetup || Boolean(addChannels)
414
421
 
415
422
  if (existingBot && !forceSetup) {
416
423
  appId = existingBot.app_id
@@ -481,8 +488,7 @@ async function run({ restart, addChannels }: CliOptions) {
481
488
  'Step 3: Get Bot Token',
482
489
  )
483
490
  const tokenInput = await password({
484
- message:
485
- '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):',
486
492
  validate(value) {
487
493
  if (!value) return 'Bot token is required'
488
494
  if (value.length < 50) return 'Invalid token format (too short)'
@@ -495,10 +501,7 @@ async function run({ restart, addChannels }: CliOptions) {
495
501
  }
496
502
  token = tokenInput
497
503
 
498
- note(
499
- `You can get a Gemini api Key at https://aistudio.google.com/apikey`,
500
- `Gemini API Key`,
501
- )
504
+ note(`You can get a Gemini api Key at https://aistudio.google.com/apikey`, `Gemini API Key`)
502
505
 
503
506
  const geminiApiKey = await password({
504
507
  message:
@@ -516,9 +519,10 @@ async function run({ restart, addChannels }: CliOptions) {
516
519
 
517
520
  // Store API key in database
518
521
  if (geminiApiKey) {
519
- db.prepare(
520
- 'INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)',
521
- ).run(appId, geminiApiKey || null)
522
+ db.prepare('INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)').run(
523
+ appId,
524
+ geminiApiKey || null,
525
+ )
522
526
  note('API key saved successfully', 'API Key Stored')
523
527
  }
524
528
 
@@ -565,9 +569,7 @@ async function run({ restart, addChannels }: CliOptions) {
565
569
  guild.roles
566
570
  .fetch()
567
571
  .then(async (roles) => {
568
- const existingRole = roles.find(
569
- (role) => role.name.toLowerCase() === 'kimaki',
570
- )
572
+ const existingRole = roles.find((role) => role.name.toLowerCase() === 'kimaki')
571
573
  if (existingRole) {
572
574
  // Move to bottom if not already there
573
575
  if (existingRole.position > 1) {
@@ -596,8 +598,7 @@ async function run({ restart, addChannels }: CliOptions) {
596
598
 
597
599
  const channels = await getChannelsWithDescriptions(guild)
598
600
  const kimakiChans = channels.filter(
599
- (ch) =>
600
- ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
601
+ (ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
601
602
  )
602
603
 
603
604
  return { guild, channels: kimakiChans }
@@ -622,14 +623,10 @@ async function run({ restart, addChannels }: CliOptions) {
622
623
  s.stop('Connected to Discord!')
623
624
  } catch (error) {
624
625
  s.stop('Failed to connect to Discord')
625
- cliLogger.error(
626
- 'Error: ' + (error instanceof Error ? error.message : String(error)),
627
- )
626
+ cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)))
628
627
  process.exit(EXIT_NO_RESTART)
629
628
  }
630
- db.prepare(
631
- 'INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)',
632
- ).run(appId, token)
629
+ db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token)
633
630
 
634
631
  for (const { guild, channels } of kimakiChannels) {
635
632
  for (const channel of channels) {
@@ -639,8 +636,7 @@ async function run({ restart, addChannels }: CliOptions) {
639
636
  ).run(channel.id, channel.kimakiDirectory, 'text')
640
637
 
641
638
  const voiceChannel = guild.channels.cache.find(
642
- (ch) =>
643
- ch.type === ChannelType.GuildVoice && ch.name === channel.name,
639
+ (ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name,
644
640
  )
645
641
 
646
642
  if (voiceChannel) {
@@ -657,11 +653,7 @@ async function run({ restart, addChannels }: CliOptions) {
657
653
  .flatMap(({ guild, channels }) =>
658
654
  channels.map((ch) => {
659
655
  const appInfo =
660
- ch.kimakiApp === appId
661
- ? ' (this bot)'
662
- : ch.kimakiApp
663
- ? ` (app: ${ch.kimakiApp})`
664
- : ''
656
+ ch.kimakiApp === appId ? ' (this bot)' : ch.kimakiApp ? ` (app: ${ch.kimakiApp})` : ''
665
657
  return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`
666
658
  }),
667
659
  )
@@ -679,13 +671,19 @@ async function run({ restart, addChannels }: CliOptions) {
679
671
 
680
672
  // Fetch projects and commands in parallel
681
673
  const [projects, allUserCommands] = await Promise.all([
682
- getClient().project.list({}).then((r) => r.data || []).catch((error) => {
683
- s.stop('Failed to fetch projects')
684
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
685
- discordClient.destroy()
686
- process.exit(EXIT_NO_RESTART)
687
- }),
688
- getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
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(() => []),
689
687
  ])
690
688
 
691
689
  s.stop(`Found ${projects.length} OpenCode project(s)`)
@@ -711,16 +709,10 @@ async function run({ restart, addChannels }: CliOptions) {
711
709
  )
712
710
 
713
711
  if (availableProjects.length === 0) {
714
- note(
715
- 'All OpenCode projects already have Discord channels',
716
- 'No New Projects',
717
- )
712
+ note('All OpenCode projects already have Discord channels', 'No New Projects')
718
713
  }
719
714
 
720
- if (
721
- (!existingDirs?.length && availableProjects.length > 0) ||
722
- shouldAddChannels
723
- ) {
715
+ if ((!existingDirs?.length && availableProjects.length > 0) || shouldAddChannels) {
724
716
  const selectedProjects = await multiselect({
725
717
  message: 'Select projects to create Discord channels for:',
726
718
  options: availableProjects.map((project) => ({
@@ -781,17 +773,17 @@ async function run({ restart, addChannels }: CliOptions) {
781
773
  guildId: targetGuild.id,
782
774
  })
783
775
  } catch (error) {
784
- cliLogger.error(`Failed to create channels for ${path.basename(project.worktree)}:`, error)
776
+ cliLogger.error(
777
+ `Failed to create channels for ${path.basename(project.worktree)}:`,
778
+ error,
779
+ )
785
780
  }
786
781
  }
787
782
 
788
783
  s.stop(`Created ${createdChannels.length} channel(s)`)
789
784
 
790
785
  if (createdChannels.length > 0) {
791
- note(
792
- createdChannels.map((ch) => `#${ch.name}`).join('\n'),
793
- 'Created Channels',
794
- )
786
+ note(createdChannels.map((ch) => `#${ch.name}`).join('\n'), 'Created Channels')
795
787
  }
796
788
  }
797
789
  }
@@ -850,10 +842,7 @@ async function run({ restart, addChannels }: CliOptions) {
850
842
 
851
843
  if (allChannels.length > 0) {
852
844
  const channelLinks = allChannels
853
- .map(
854
- (ch) =>
855
- `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`,
856
- )
845
+ .map((ch) => `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
857
846
  .join('\n')
858
847
 
859
848
  note(
@@ -862,63 +851,62 @@ async function run({ restart, addChannels }: CliOptions) {
862
851
  )
863
852
  }
864
853
 
865
- outro('✨ Setup complete!')
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.')
866
860
  }
867
861
 
868
862
  cli
869
863
  .command('', 'Set up and run the Kimaki Discord bot')
870
864
  .option('--restart', 'Prompt for new credentials even if saved')
871
- .option(
872
- '--add-channels',
873
- 'Select OpenCode projects to create Discord channels before starting',
874
- )
875
- .option(
876
- '--data-dir <path>',
877
- 'Data directory for config and database (default: ~/.kimaki)',
878
- )
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)')
879
867
  .option('--install-url', 'Print the bot install URL and exit')
880
- .action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string; installUrl?: boolean }) => {
881
- try {
882
- // Set data directory early, before any database access
883
- if (options.dataDir) {
884
- setDataDir(options.dataDir)
885
- cliLogger.log(`Using data directory: ${getDataDir()}`)
886
- }
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
+ }
887
881
 
888
- if (options.installUrl) {
889
- const db = getDatabase()
890
- const existingBot = db
891
- .prepare(
892
- 'SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
893
- )
894
- .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
895
887
 
896
- if (!existingBot) {
897
- cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
898
- process.exit(EXIT_NO_RESTART)
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)
899
895
  }
900
896
 
901
- console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
902
- process.exit(0)
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)
903
907
  }
904
-
905
- await checkSingleInstance()
906
- await startLockServer()
907
- await run({
908
- restart: options.restart,
909
- addChannels: options.addChannels,
910
- dataDir: options.dataDir,
911
- })
912
- } catch (error) {
913
- cliLogger.error(
914
- 'Unhandled error:',
915
- error instanceof Error ? error.message : String(error),
916
- )
917
- process.exit(EXIT_NO_RESTART)
918
- }
919
- })
920
-
921
-
908
+ },
909
+ )
922
910
 
923
911
  cli
924
912
  .command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
@@ -957,9 +945,7 @@ cli
957
945
  }
958
946
 
959
947
  const botRow = db
960
- .prepare(
961
- 'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
962
- )
948
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
963
949
  .get() as { app_id: string; token: string } | undefined
964
950
 
965
951
  if (!botRow) {
@@ -974,9 +960,12 @@ cli
974
960
  const buffer = fs.readFileSync(file)
975
961
 
976
962
  const formData = new FormData()
977
- formData.append('payload_json', JSON.stringify({
978
- attachments: [{ id: 0, filename: path.basename(file) }]
979
- }))
963
+ formData.append(
964
+ 'payload_json',
965
+ JSON.stringify({
966
+ attachments: [{ id: 0, filename: path.basename(file) }],
967
+ }),
968
+ )
980
969
  formData.append('files[0]', new Blob([buffer]), path.basename(file))
981
970
 
982
971
  const response = await fetch(
@@ -984,10 +973,10 @@ cli
984
973
  {
985
974
  method: 'POST',
986
975
  headers: {
987
- 'Authorization': `Bot ${botRow.token}`,
976
+ Authorization: `Bot ${botRow.token}`,
988
977
  },
989
978
  body: formData,
990
- }
979
+ },
991
980
  )
992
981
 
993
982
  if (!response.ok) {
@@ -1005,38 +994,46 @@ cli
1005
994
 
1006
995
  process.exit(0)
1007
996
  } catch (error) {
1008
- cliLogger.error(
1009
- 'Error:',
1010
- error instanceof Error ? error.message : String(error),
1011
- )
997
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
1012
998
  process.exit(EXIT_NO_RESTART)
1013
999
  }
1014
1000
  })
1015
1001
 
1016
-
1017
1002
  // Magic prefix used to identify bot-initiated sessions.
1018
1003
  // The running bot will recognize this prefix and start a session.
1019
1004
  const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
1020
1005
 
1021
1006
  cli
1022
- .command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
1007
+ .command(
1008
+ 'start-session',
1009
+ 'Start a new session in a Discord channel (creates thread, bot handles the rest)',
1010
+ )
1023
1011
  .option('-c, --channel <channelId>', 'Discord channel ID')
1012
+ .option('-d, --project <path>', 'Project directory (alternative to --channel)')
1024
1013
  .option('-p, --prompt <prompt>', 'Initial prompt for the session')
1025
1014
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
1026
1015
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
1027
- .action(async (options: { channel?: string; prompt?: string; name?: string; appId?: string }) => {
1028
- try {
1029
- const { channel: channelId, prompt, name, appId: optionAppId } = options
1030
-
1031
- if (!channelId) {
1032
- cliLogger.error('Channel ID is required. Use --channel <channelId>')
1033
- process.exit(EXIT_NO_RESTART)
1034
- }
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
+ }
1035
1032
 
1036
- if (!prompt) {
1037
- cliLogger.error('Prompt is required. Use --prompt <prompt>')
1038
- process.exit(EXIT_NO_RESTART)
1039
- }
1033
+ if (!prompt) {
1034
+ cliLogger.error('Prompt is required. Use --prompt <prompt>')
1035
+ process.exit(EXIT_NO_RESTART)
1036
+ }
1040
1037
 
1041
1038
  // Get bot token from env var or database
1042
1039
  const envToken = process.env.KIMAKI_BOT_TOKEN
@@ -1076,22 +1073,112 @@ cli
1076
1073
  }
1077
1074
 
1078
1075
  if (!botToken) {
1079
- cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.')
1076
+ cliLogger.error(
1077
+ 'No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.',
1078
+ )
1080
1079
  process.exit(EXIT_NO_RESTART)
1081
1080
  }
1082
1081
 
1083
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
+
1084
1174
  s.start('Fetching channel info...')
1085
1175
 
1086
1176
  // Get channel info to extract directory from topic
1087
- const channelResponse = await fetch(
1088
- `https://discord.com/api/v10/channels/${channelId}`,
1089
- {
1090
- headers: {
1091
- 'Authorization': `Bot ${botToken}`,
1092
- },
1093
- }
1094
- )
1177
+ const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
1178
+ headers: {
1179
+ Authorization: `Bot ${botToken}`,
1180
+ },
1181
+ })
1095
1182
 
1096
1183
  if (!channelResponse.ok) {
1097
1184
  const error = await channelResponse.text()
@@ -1099,7 +1186,7 @@ cli
1099
1186
  throw new Error(`Discord API error: ${channelResponse.status} - ${error}`)
1100
1187
  }
1101
1188
 
1102
- const channelData = await channelResponse.json() as {
1189
+ const channelData = (await channelResponse.json()) as {
1103
1190
  id: string
1104
1191
  name: string
1105
1192
  topic?: string
@@ -1108,7 +1195,9 @@ cli
1108
1195
 
1109
1196
  if (!channelData.topic) {
1110
1197
  s.stop('Channel has no topic')
1111
- throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`)
1198
+ throw new Error(
1199
+ `Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`,
1200
+ )
1112
1201
  }
1113
1202
 
1114
1203
  const extracted = extractTagsArrays({
@@ -1127,7 +1216,9 @@ cli
1127
1216
  // Verify app ID matches if both are present
1128
1217
  if (channelAppId && appId && channelAppId !== appId) {
1129
1218
  s.stop('Channel belongs to different bot')
1130
- throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`)
1219
+ throw new Error(
1220
+ `Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`,
1221
+ )
1131
1222
  }
1132
1223
 
1133
1224
  s.message('Creating starter message...')
@@ -1139,13 +1230,13 @@ cli
1139
1230
  {
1140
1231
  method: 'POST',
1141
1232
  headers: {
1142
- 'Authorization': `Bot ${botToken}`,
1233
+ Authorization: `Bot ${botToken}`,
1143
1234
  'Content-Type': 'application/json',
1144
1235
  },
1145
1236
  body: JSON.stringify({
1146
1237
  content: `${BOT_SESSION_PREFIX}\n${prompt}`,
1147
1238
  }),
1148
- }
1239
+ },
1149
1240
  )
1150
1241
 
1151
1242
  if (!starterMessageResponse.ok) {
@@ -1154,7 +1245,7 @@ cli
1154
1245
  throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
1155
1246
  }
1156
1247
 
1157
- const starterMessage = await starterMessageResponse.json() as { id: string }
1248
+ const starterMessage = (await starterMessageResponse.json()) as { id: string }
1158
1249
 
1159
1250
  s.message('Creating thread...')
1160
1251
 
@@ -1165,14 +1256,14 @@ cli
1165
1256
  {
1166
1257
  method: 'POST',
1167
1258
  headers: {
1168
- 'Authorization': `Bot ${botToken}`,
1259
+ Authorization: `Bot ${botToken}`,
1169
1260
  'Content-Type': 'application/json',
1170
1261
  },
1171
1262
  body: JSON.stringify({
1172
1263
  name: threadName.slice(0, 100),
1173
1264
  auto_archive_duration: 1440, // 1 day
1174
1265
  }),
1175
- }
1266
+ },
1176
1267
  )
1177
1268
 
1178
1269
  if (!threadResponse.ok) {
@@ -1181,7 +1272,7 @@ cli
1181
1272
  throw new Error(`Discord API error: ${threadResponse.status} - ${error}`)
1182
1273
  }
1183
1274
 
1184
- const threadData = await threadResponse.json() as { id: string; name: string }
1275
+ const threadData = (await threadResponse.json()) as { id: string; name: string }
1185
1276
 
1186
1277
  s.stop('Thread created!')
1187
1278
 
@@ -1196,14 +1287,10 @@ cli
1196
1287
 
1197
1288
  process.exit(0)
1198
1289
  } catch (error) {
1199
- cliLogger.error(
1200
- 'Error:',
1201
- error instanceof Error ? error.message : String(error),
1202
- )
1290
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
1203
1291
  process.exit(EXIT_NO_RESTART)
1204
1292
  }
1205
1293
  })
1206
1294
 
1207
-
1208
1295
  cli.help()
1209
1296
  cli.parse()