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.
Files changed (79) 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 +142 -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 +56 -1
  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 +2 -3
  25. package/dist/session-handler.js +42 -25
  26. package/dist/system-message.js +5 -3
  27. package/dist/tools.js +9 -22
  28. package/dist/unnest-code-blocks.js +4 -2
  29. package/dist/unnest-code-blocks.test.js +40 -15
  30. package/dist/voice-handler.js +9 -12
  31. package/dist/voice.js +5 -3
  32. package/dist/xml.js +2 -4
  33. package/package.json +3 -2
  34. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  35. package/src/__snapshots__/compact-session-context.md +31 -31
  36. package/src/ai-tool-to-genai.ts +3 -11
  37. package/src/channel-management.ts +14 -25
  38. package/src/cli.ts +290 -195
  39. package/src/commands/abort.ts +1 -3
  40. package/src/commands/add-project.ts +8 -14
  41. package/src/commands/agent.ts +16 -9
  42. package/src/commands/ask-question.ts +8 -7
  43. package/src/commands/create-new-project.ts +8 -14
  44. package/src/commands/fork.ts +23 -27
  45. package/src/commands/model.ts +14 -11
  46. package/src/commands/permissions.ts +1 -1
  47. package/src/commands/queue.ts +6 -19
  48. package/src/commands/remove-project.ts +136 -0
  49. package/src/commands/resume.ts +11 -30
  50. package/src/commands/session.ts +68 -9
  51. package/src/commands/share.ts +1 -3
  52. package/src/commands/types.ts +1 -3
  53. package/src/commands/undo-redo.ts +6 -18
  54. package/src/commands/user-command.ts +8 -10
  55. package/src/config.ts +5 -5
  56. package/src/database.ts +10 -8
  57. package/src/discord-bot.ts +22 -46
  58. package/src/discord-utils.ts +35 -18
  59. package/src/escape-backticks.test.ts +0 -2
  60. package/src/format-tables.ts +1 -4
  61. package/src/genai-worker-wrapper.ts +3 -9
  62. package/src/genai-worker.ts +4 -19
  63. package/src/genai.ts +10 -42
  64. package/src/interaction-handler.ts +133 -121
  65. package/src/markdown.test.ts +10 -32
  66. package/src/markdown.ts +6 -14
  67. package/src/message-formatting.ts +13 -14
  68. package/src/openai-realtime.ts +25 -47
  69. package/src/opencode.ts +26 -37
  70. package/src/session-handler.ts +111 -75
  71. package/src/system-message.ts +13 -3
  72. package/src/tools.ts +13 -39
  73. package/src/unnest-code-blocks.test.ts +42 -15
  74. package/src/unnest-code-blocks.ts +4 -2
  75. package/src/utils.ts +1 -4
  76. package/src/voice-handler.ts +34 -78
  77. package/src/voice.ts +11 -19
  78. package/src/xml.test.ts +1 -1
  79. 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,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('create-new-project')
216
- .setDescription('Create a new project folder, initialize git, and start a session')
238
+ .setName('remove-project')
239
+ .setDescription('Remove Discord channels for a project')
217
240
  .addStringOption((option) => {
218
241
  option
219
- .setName('name')
220
- .setDescription('Name for the new project folder')
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
- 'INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)',
513
- ).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
+ )
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().project.list({}).then((r) => r.data || []).catch((error) => {
675
- s.stop('Failed to fetch projects')
676
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
677
- discordClient.destroy()
678
- process.exit(EXIT_NO_RESTART)
679
- }),
680
- 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(() => []),
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(`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
+ )
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
- 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.')
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
- '--add-channels',
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(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string; installUrl?: boolean }) => {
873
- try {
874
- // Set data directory early, before any database access
875
- if (options.dataDir) {
876
- setDataDir(options.dataDir)
877
- cliLogger.log(`Using data directory: ${getDataDir()}`)
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
- if (options.installUrl) {
881
- const db = getDatabase()
882
- const existingBot = db
883
- .prepare(
884
- 'SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
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
- if (!existingBot) {
889
- cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
890
- 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)
891
895
  }
892
896
 
893
- console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
894
- 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)
895
907
  }
896
-
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(
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('payload_json', JSON.stringify({
970
- attachments: [{ id: 0, filename: path.basename(file) }]
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
- 'Authorization': `Bot ${botRow.token}`,
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('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
+ )
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(async (options: { channel?: string; prompt?: string; name?: string; appId?: string }) => {
1020
- try {
1021
- const { channel: channelId, prompt, name, appId: optionAppId } = options
1022
-
1023
- if (!channelId) {
1024
- cliLogger.error('Channel ID is required. Use --channel <channelId>')
1025
- process.exit(EXIT_NO_RESTART)
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
- if (!prompt) {
1029
- cliLogger.error('Prompt is required. Use --prompt <prompt>')
1030
- process.exit(EXIT_NO_RESTART)
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('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
+ )
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
- `https://discord.com/api/v10/channels/${channelId}`,
1081
- {
1082
- headers: {
1083
- 'Authorization': `Bot ${botToken}`,
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(`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
+ )
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(`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
+ )
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
- 'Authorization': `Bot ${botToken}`,
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
- 'Authorization': `Bot ${botToken}`,
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()