kimaki 0.4.43 → 0.4.44

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.
@@ -1,8 +1,11 @@
1
1
  // Core Discord bot module that handles message events and bot lifecycle.
2
2
  // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
- import { getDatabase, closeDatabase, getThreadWorktree } from './database.js';
5
- import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
4
+ import { getDatabase, closeDatabase, getThreadWorktree, createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelWorktreesEnabled, } from './database.js';
5
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
6
+ import { formatWorktreeName } from './commands/worktree.js';
7
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
8
+ import { createWorktreeWithSubmodules } from './worktree-utils.js';
6
9
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
7
10
  import { getOpencodeSystemMessage } from './system-message.js';
8
11
  import { getFileAttachments, getTextAttachments } from './message-formatting.js';
@@ -39,7 +42,7 @@ export async function createDiscordClient() {
39
42
  partials: [Partials.Channel, Partials.Message, Partials.User, Partials.ThreadMember],
40
43
  });
41
44
  }
42
- export async function startDiscordBot({ token, appId, discordClient, }) {
45
+ export async function startDiscordBot({ token, appId, discordClient, useWorktrees, }) {
43
46
  if (!discordClient) {
44
47
  discordClient = await createDiscordClient();
45
48
  }
@@ -299,20 +302,76 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
299
302
  return;
300
303
  }
301
304
  const hasVoice = message.attachments.some((a) => a.contentType?.startsWith('audio/'));
302
- const threadName = hasVoice
305
+ const baseThreadName = hasVoice
303
306
  ? 'Voice Message'
304
307
  : message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread';
308
+ // Check if worktrees should be enabled (CLI flag OR channel setting)
309
+ const shouldUseWorktrees = useWorktrees || getChannelWorktreesEnabled(textChannel.id);
310
+ // Add worktree prefix if worktrees are enabled
311
+ const threadName = shouldUseWorktrees
312
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
313
+ : baseThreadName;
305
314
  const thread = await message.startThread({
306
315
  name: threadName.slice(0, 80),
307
316
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
308
317
  reason: 'Start Claude session',
309
318
  });
310
319
  discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
320
+ // Create worktree if worktrees are enabled (CLI flag OR channel setting)
321
+ let sessionDirectory = projectDirectory;
322
+ if (shouldUseWorktrees) {
323
+ const worktreeName = formatWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
324
+ discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
325
+ // Store pending worktree immediately so bot knows about it
326
+ createPendingWorktree({
327
+ threadId: thread.id,
328
+ worktreeName,
329
+ projectDirectory,
330
+ });
331
+ // Initialize OpenCode and create worktree
332
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
333
+ if (getClient instanceof Error) {
334
+ discordLogger.error(`[WORKTREE] Failed to init OpenCode: ${getClient.message}`);
335
+ setWorktreeError({ threadId: thread.id, errorMessage: getClient.message });
336
+ await thread.send({
337
+ content: `⚠️ Failed to create worktree: ${getClient.message}\nUsing main project directory instead.`,
338
+ flags: SILENT_MESSAGE_FLAGS,
339
+ });
340
+ }
341
+ else {
342
+ const clientV2 = getOpencodeClientV2(projectDirectory);
343
+ if (!clientV2) {
344
+ discordLogger.error(`[WORKTREE] No v2 client for ${projectDirectory}`);
345
+ setWorktreeError({ threadId: thread.id, errorMessage: 'No OpenCode v2 client' });
346
+ }
347
+ else {
348
+ const worktreeResult = await createWorktreeWithSubmodules({
349
+ clientV2,
350
+ directory: projectDirectory,
351
+ name: worktreeName,
352
+ });
353
+ if (worktreeResult instanceof Error) {
354
+ const errMsg = worktreeResult.message;
355
+ discordLogger.error(`[WORKTREE] Creation failed: ${errMsg}`);
356
+ setWorktreeError({ threadId: thread.id, errorMessage: errMsg });
357
+ await thread.send({
358
+ content: `⚠️ Failed to create worktree: ${errMsg}\nUsing main project directory instead.`,
359
+ flags: SILENT_MESSAGE_FLAGS,
360
+ });
361
+ }
362
+ else {
363
+ setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory });
364
+ sessionDirectory = worktreeResult.directory;
365
+ discordLogger.log(`[WORKTREE] Created: ${worktreeResult.directory} (branch: ${worktreeResult.branch})`);
366
+ }
367
+ }
368
+ }
369
+ }
311
370
  let messageContent = message.content || '';
312
371
  const transcription = await processVoiceAttachment({
313
372
  message,
314
373
  thread,
315
- projectDirectory,
374
+ projectDirectory: sessionDirectory,
316
375
  isNewThread: true,
317
376
  appId: currentAppId,
318
377
  });
@@ -327,7 +386,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
327
386
  await handleOpencodeSession({
328
387
  prompt: promptWithAttachments,
329
388
  thread,
330
- projectDirectory,
389
+ projectDirectory: sessionDirectory,
331
390
  originalMessage: message,
332
391
  images: fileAttachments,
333
392
  channelId: textChannel.id,
@@ -349,33 +408,30 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
349
408
  }
350
409
  });
351
410
  // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
411
+ // Uses embed marker instead of database to avoid race conditions
412
+ const AUTO_START_MARKER = 'kimaki:start';
352
413
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
353
414
  try {
354
415
  if (!newlyCreated) {
355
416
  return;
356
417
  }
357
- // Check if this thread is marked for auto-start in the database
358
- const db = getDatabase();
359
- const pendingRow = db
360
- .prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
361
- .get(thread.id);
362
- if (!pendingRow) {
363
- return; // Not a CLI-initiated auto-start thread
364
- }
365
- // Remove from pending table
366
- db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id);
367
- discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
368
418
  // Only handle threads in text channels
369
419
  const parent = thread.parent;
370
420
  if (!parent || parent.type !== ChannelType.GuildText) {
371
421
  return;
372
422
  }
373
- // Get the starter message for the prompt
423
+ // Get the starter message to check for auto-start marker
374
424
  const starterMessage = await thread.fetchStarterMessage().catch(() => null);
375
425
  if (!starterMessage) {
376
426
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
377
427
  return;
378
428
  }
429
+ // Check if starter message has the auto-start embed marker
430
+ const hasAutoStartMarker = starterMessage.embeds.some((embed) => embed.footer?.text === AUTO_START_MARKER);
431
+ if (!hasAutoStartMarker) {
432
+ return; // Not a CLI-initiated auto-start thread
433
+ }
434
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
379
435
  const prompt = starterMessage.content.trim();
380
436
  if (!prompt) {
381
437
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
@@ -4,6 +4,8 @@
4
4
  import { Events } from 'discord.js';
5
5
  import { handleSessionCommand, handleSessionAutocomplete } from './commands/session.js';
6
6
  import { handleNewWorktreeCommand } from './commands/worktree.js';
7
+ import { handleMergeWorktreeCommand } from './commands/merge-worktree.js';
8
+ import { handleEnableWorktreesCommand, handleDisableWorktreesCommand, } from './commands/worktree-settings.js';
7
9
  import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js';
8
10
  import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js';
9
11
  import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './commands/remove-project.js';
@@ -57,6 +59,15 @@ export function registerInteractionHandler({ discordClient, appId, }) {
57
59
  case 'new-worktree':
58
60
  await handleNewWorktreeCommand({ command: interaction, appId });
59
61
  return;
62
+ case 'merge-worktree':
63
+ await handleMergeWorktreeCommand({ command: interaction, appId });
64
+ return;
65
+ case 'enable-worktrees':
66
+ await handleEnableWorktreesCommand({ command: interaction, appId });
67
+ return;
68
+ case 'disable-worktrees':
69
+ await handleDisableWorktreesCommand({ command: interaction, appId });
70
+ return;
60
71
  case 'resume':
61
72
  await handleResumeCommand({ command: interaction, appId });
62
73
  return;
@@ -2,7 +2,7 @@
2
2
  // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
3
  // Handles streaming events, permissions, abort signals, and message queuing.
4
4
  import prettyMilliseconds from 'pretty-ms';
5
- import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, } from './database.js';
5
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, getThreadWorktree, } from './database.js';
6
6
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2, } from './opencode.js';
7
7
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
8
8
  import { formatPart } from './message-formatting.js';
@@ -755,6 +755,15 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
755
755
  sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
756
756
  return { providerID, modelID };
757
757
  })();
758
+ // Get worktree info if this thread is in a worktree
759
+ const worktreeInfo = getThreadWorktree(thread.id);
760
+ const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
761
+ ? {
762
+ worktreeDirectory: worktreeInfo.worktree_directory,
763
+ branch: worktreeInfo.worktree_name,
764
+ mainRepoDirectory: worktreeInfo.project_directory,
765
+ }
766
+ : undefined;
758
767
  // Use session.command API for slash commands, session.prompt for regular messages
759
768
  const response = command
760
769
  ? await getClient().session.command({
@@ -770,7 +779,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
770
779
  path: { id: session.id },
771
780
  body: {
772
781
  parts,
773
- system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
782
+ system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
774
783
  model: modelParam,
775
784
  agent: agentPreference,
776
785
  },
@@ -1,7 +1,7 @@
1
1
  // OpenCode system prompt generator.
2
2
  // Creates the system message injected into every OpenCode session,
3
3
  // including Discord-specific formatting rules, diff commands, and permissions info.
4
- export function getOpencodeSystemMessage({ sessionId, channelId, }) {
4
+ export function getOpencodeSystemMessage({ sessionId, channelId, worktree, }) {
5
5
  return `
6
6
  The user is reading your messages from inside Discord, via kimaki.xyz
7
7
 
@@ -50,6 +50,26 @@ Use this for handoff when:
50
50
  - User asks to "handoff", "continue in new thread", or "start fresh session"
51
51
  - You detect you're running low on context window space
52
52
  - A complex task would benefit from a clean slate with summarized context
53
+ `
54
+ : ''}${worktree
55
+ ? `
56
+ ## worktree
57
+
58
+ This session is running inside a git worktree.
59
+ - **Worktree path:** \`${worktree.worktreeDirectory}\`
60
+ - **Branch:** \`${worktree.branch}\`
61
+ - **Main repo:** \`${worktree.mainRepoDirectory}\`
62
+
63
+ Before finishing a task, ask the user if they want to merge changes back to the main branch.
64
+
65
+ To merge (without leaving the worktree):
66
+ \`\`\`bash
67
+ # Get the default branch name
68
+ DEFAULT_BRANCH=$(git -C ${worktree.mainRepoDirectory} symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main")
69
+
70
+ # Merge worktree branch into main
71
+ git -C ${worktree.mainRepoDirectory} checkout $DEFAULT_BRANCH && git -C ${worktree.mainRepoDirectory} merge ${worktree.branch}
72
+ \`\`\`
53
73
  `
54
74
  : ''}
55
75
  ## showing diffs
@@ -72,6 +92,10 @@ bunx critique HEAD~1 --web "Update dependencies"
72
92
 
73
93
  Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
74
94
 
95
+ To compare two branches:
96
+
97
+ bunx critique main feature-branch --web "Compare branches"
98
+
75
99
  The command outputs a URL - share that URL with the user so they can see the diff.
76
100
 
77
101
  ## markdown
@@ -0,0 +1,50 @@
1
+ // Worktree utility functions.
2
+ // Wrapper for OpenCode worktree creation that also initializes git submodules.
3
+ import { exec } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { createLogger } from './logger.js';
6
+ export const execAsync = promisify(exec);
7
+ const logger = createLogger('WORKTREE-UTILS');
8
+ /**
9
+ * Create a worktree using OpenCode SDK and initialize git submodules.
10
+ * This wrapper ensures submodules are properly set up in new worktrees.
11
+ */
12
+ export async function createWorktreeWithSubmodules({ clientV2, directory, name, }) {
13
+ // 1. Create worktree via OpenCode SDK
14
+ const response = await clientV2.worktree.create({
15
+ directory,
16
+ worktreeCreateInput: { name },
17
+ });
18
+ if (response.error) {
19
+ return new Error(`SDK error: ${JSON.stringify(response.error)}`);
20
+ }
21
+ if (!response.data) {
22
+ return new Error('No worktree data returned from SDK');
23
+ }
24
+ const worktreeDir = response.data.directory;
25
+ // 2. Init submodules in new worktree (don't block on failure)
26
+ try {
27
+ logger.log(`Initializing submodules in ${worktreeDir}`);
28
+ await execAsync('git submodule update --init --recursive', {
29
+ cwd: worktreeDir,
30
+ });
31
+ logger.log(`Submodules initialized in ${worktreeDir}`);
32
+ }
33
+ catch (e) {
34
+ // Log but don't fail - submodules might not exist
35
+ logger.warn(`Failed to init submodules in ${worktreeDir}: ${e instanceof Error ? e.message : String(e)}`);
36
+ }
37
+ // 3. Install dependencies using ni (detects package manager from lockfile)
38
+ try {
39
+ logger.log(`Installing dependencies in ${worktreeDir}`);
40
+ await execAsync('npx -y ni', {
41
+ cwd: worktreeDir,
42
+ });
43
+ logger.log(`Dependencies installed in ${worktreeDir}`);
44
+ }
45
+ catch (e) {
46
+ // Log but don't fail - might not be a JS project or might fail for various reasons
47
+ logger.warn(`Failed to install dependencies in ${worktreeDir}: ${e instanceof Error ? e.message : String(e)}`);
48
+ }
49
+ return response.data;
50
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.43",
5
+ "version": "0.4.44",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
package/src/cli.ts CHANGED
@@ -174,6 +174,7 @@ type CliOptions = {
174
174
  restart?: boolean
175
175
  addChannels?: boolean
176
176
  dataDir?: string
177
+ useWorktrees?: boolean
177
178
  }
178
179
 
179
180
  // Commands to skip when registering user commands (reserved names)
@@ -249,6 +250,18 @@ async function registerCommands({
249
250
  return option
250
251
  })
251
252
  .toJSON(),
253
+ new SlashCommandBuilder()
254
+ .setName('merge-worktree')
255
+ .setDescription('Merge the worktree branch into the default branch')
256
+ .toJSON(),
257
+ new SlashCommandBuilder()
258
+ .setName('enable-worktrees')
259
+ .setDescription('Enable automatic git worktree creation for new sessions in this channel')
260
+ .toJSON(),
261
+ new SlashCommandBuilder()
262
+ .setName('disable-worktrees')
263
+ .setDescription('Disable automatic git worktree creation for new sessions in this channel')
264
+ .toJSON(),
252
265
  new SlashCommandBuilder()
253
266
  .setName('add-project')
254
267
  .setDescription('Create Discord channels for a new OpenCode project')
@@ -516,7 +529,7 @@ async function backgroundInit({
516
529
  }
517
530
  }
518
531
 
519
- async function run({ restart, addChannels }: CliOptions) {
532
+ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
520
533
  const forceSetup = Boolean(restart)
521
534
 
522
535
  intro('🤖 Discord Bot Setup')
@@ -827,7 +840,7 @@ async function run({ restart, addChannels }: CliOptions) {
827
840
  const isQuickStart = existingBot && !forceSetup && !addChannels
828
841
  if (isQuickStart) {
829
842
  s.start('Starting Discord bot...')
830
- await startDiscordBot({ token, appId, discordClient })
843
+ await startDiscordBot({ token, appId, discordClient, useWorktrees })
831
844
  s.stop('Discord bot is running!')
832
845
 
833
846
  // Background: OpenCode init + slash command registration (non-blocking)
@@ -998,7 +1011,7 @@ async function run({ restart, addChannels }: CliOptions) {
998
1011
  })
999
1012
 
1000
1013
  s.start('Starting Discord bot...')
1001
- await startDiscordBot({ token, appId, discordClient })
1014
+ await startDiscordBot({ token, appId, discordClient, useWorktrees })
1002
1015
  s.stop('Discord bot is running!')
1003
1016
 
1004
1017
  showReadyMessage({ kimakiChannels, createdChannels, appId })
@@ -1011,12 +1024,14 @@ cli
1011
1024
  .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
1012
1025
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
1013
1026
  .option('--install-url', 'Print the bot install URL and exit')
1027
+ .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
1014
1028
  .action(
1015
1029
  async (options: {
1016
1030
  restart?: boolean
1017
1031
  addChannels?: boolean
1018
1032
  dataDir?: string
1019
1033
  installUrl?: boolean
1034
+ useWorktrees?: boolean
1020
1035
  }) => {
1021
1036
  try {
1022
1037
  // Set data directory early, before any database access
@@ -1046,6 +1061,7 @@ cli
1046
1061
  restart: options.restart,
1047
1062
  addChannels: options.addChannels,
1048
1063
  dataDir: options.dataDir,
1064
+ useWorktrees: options.useWorktrees,
1049
1065
  })
1050
1066
  } catch (error) {
1051
1067
  cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error))
@@ -1386,6 +1402,13 @@ cli
1386
1402
  const DISCORD_MAX_LENGTH = 2000
1387
1403
  let starterMessage: { id: string }
1388
1404
 
1405
+ // Embed marker for auto-start sessions (unless --notify-only)
1406
+ // Bot checks for this embed footer to know it should start a session
1407
+ const AUTO_START_MARKER = 'kimaki:start'
1408
+ const autoStartEmbed = notifyOnly
1409
+ ? undefined
1410
+ : [{ color: 0x2b2d31, footer: { text: AUTO_START_MARKER } }]
1411
+
1389
1412
  if (prompt.length > DISCORD_MAX_LENGTH) {
1390
1413
  // Send as file attachment with a short summary
1391
1414
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ')
@@ -1407,6 +1430,7 @@ cli
1407
1430
  JSON.stringify({
1408
1431
  content: summaryContent,
1409
1432
  attachments: [{ id: 0, filename: 'prompt.md' }],
1433
+ embeds: autoStartEmbed,
1410
1434
  }),
1411
1435
  )
1412
1436
  const buffer = fs.readFileSync(tmpFile)
@@ -1446,6 +1470,7 @@ cli
1446
1470
  },
1447
1471
  body: JSON.stringify({
1448
1472
  content: prompt,
1473
+ embeds: autoStartEmbed,
1449
1474
  }),
1450
1475
  },
1451
1476
  )
@@ -1486,19 +1511,6 @@ cli
1486
1511
 
1487
1512
  const threadData = (await threadResponse.json()) as { id: string; name: string }
1488
1513
 
1489
- // Mark thread for auto-start if not notify-only
1490
- // This is optional - only works if local database exists (for local bot auto-start)
1491
- if (!notifyOnly) {
1492
- try {
1493
- const db = getDatabase()
1494
- db.prepare('INSERT OR REPLACE INTO pending_auto_start (thread_id) VALUES (?)').run(
1495
- threadData.id,
1496
- )
1497
- } catch {
1498
- // Database not available (e.g., CI environment) - skip auto-start marking
1499
- }
1500
- }
1501
-
1502
1514
  s.stop('Thread created!')
1503
1515
 
1504
1516
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
@@ -1518,5 +1530,190 @@ cli
1518
1530
  }
1519
1531
  })
1520
1532
 
1533
+ cli
1534
+ .command('add-project [directory]', 'Create Discord channels for a project directory')
1535
+ .option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
1536
+ .option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
1537
+ .action(
1538
+ async (
1539
+ directory: string | undefined,
1540
+ options: {
1541
+ guild?: string
1542
+ appId?: string
1543
+ },
1544
+ ) => {
1545
+ try {
1546
+ const absolutePath = path.resolve(directory || '.')
1547
+
1548
+ if (!fs.existsSync(absolutePath)) {
1549
+ cliLogger.error(`Directory does not exist: ${absolutePath}`)
1550
+ process.exit(EXIT_NO_RESTART)
1551
+ }
1552
+
1553
+ // Get bot token from env var or database
1554
+ const envToken = process.env.KIMAKI_BOT_TOKEN
1555
+ let botToken: string | undefined
1556
+ let appId: string | undefined = options.appId
1557
+
1558
+ if (envToken) {
1559
+ botToken = envToken
1560
+ if (!appId) {
1561
+ try {
1562
+ const db = getDatabase()
1563
+ const botRow = db
1564
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1565
+ .get() as { app_id: string } | undefined
1566
+ appId = botRow?.app_id
1567
+ } catch {
1568
+ // Database might not exist in CI
1569
+ }
1570
+ }
1571
+ } else {
1572
+ try {
1573
+ const db = getDatabase()
1574
+ const botRow = db
1575
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
1576
+ .get() as { app_id: string; token: string } | undefined
1577
+
1578
+ if (botRow) {
1579
+ botToken = botRow.token
1580
+ appId = appId || botRow.app_id
1581
+ }
1582
+ } catch (e) {
1583
+ cliLogger.error('Database error:', e instanceof Error ? e.message : String(e))
1584
+ }
1585
+ }
1586
+
1587
+ if (!botToken) {
1588
+ cliLogger.error(
1589
+ 'No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.',
1590
+ )
1591
+ process.exit(EXIT_NO_RESTART)
1592
+ }
1593
+
1594
+ if (!appId) {
1595
+ cliLogger.error(
1596
+ 'App ID is required to create channels. Use --app-id or run `kimaki` first.',
1597
+ )
1598
+ process.exit(EXIT_NO_RESTART)
1599
+ }
1600
+
1601
+ const s = spinner()
1602
+ s.start('Checking for existing channel...')
1603
+
1604
+ // Check if channel already exists
1605
+ try {
1606
+ const db = getDatabase()
1607
+ const existingChannel = db
1608
+ .prepare(
1609
+ 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
1610
+ )
1611
+ .get(absolutePath, 'text', appId) as { channel_id: string } | undefined
1612
+
1613
+ if (existingChannel) {
1614
+ s.stop('Channel already exists')
1615
+ note(
1616
+ `Channel already exists for this directory.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`,
1617
+ '⚠️ Already Exists',
1618
+ )
1619
+ process.exit(0)
1620
+ }
1621
+ } catch {
1622
+ // Database might not exist, continue to create
1623
+ }
1624
+
1625
+ s.message('Connecting to Discord...')
1626
+ const client = await createDiscordClient()
1627
+
1628
+ await new Promise<void>((resolve, reject) => {
1629
+ client.once(Events.ClientReady, () => {
1630
+ resolve()
1631
+ })
1632
+ client.once(Events.Error, reject)
1633
+ client.login(botToken)
1634
+ })
1635
+
1636
+ s.message('Finding guild...')
1637
+
1638
+ // Find guild
1639
+ let guild: Guild
1640
+ if (options.guild) {
1641
+ const foundGuild = client.guilds.cache.get(options.guild)
1642
+ if (!foundGuild) {
1643
+ s.stop('Guild not found')
1644
+ cliLogger.error(`Guild not found: ${options.guild}`)
1645
+ client.destroy()
1646
+ process.exit(EXIT_NO_RESTART)
1647
+ }
1648
+ guild = foundGuild
1649
+ } else {
1650
+ // Auto-detect: prefer guild with existing channels for this bot, else first guild
1651
+ const db = getDatabase()
1652
+ const existingChannelRow = db
1653
+ .prepare(
1654
+ 'SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1',
1655
+ )
1656
+ .get(appId) as { channel_id: string } | undefined
1657
+
1658
+ if (existingChannelRow) {
1659
+ try {
1660
+ const ch = await client.channels.fetch(existingChannelRow.channel_id)
1661
+ if (ch && 'guild' in ch && ch.guild) {
1662
+ guild = ch.guild
1663
+ } else {
1664
+ throw new Error('Channel has no guild')
1665
+ }
1666
+ } catch {
1667
+ // Channel might be deleted, fall back to first guild
1668
+ const firstGuild = client.guilds.cache.first()
1669
+ if (!firstGuild) {
1670
+ s.stop('No guild found')
1671
+ cliLogger.error('No guild found. Add the bot to a server first.')
1672
+ client.destroy()
1673
+ process.exit(EXIT_NO_RESTART)
1674
+ }
1675
+ guild = firstGuild
1676
+ }
1677
+ } else {
1678
+ const firstGuild = client.guilds.cache.first()
1679
+ if (!firstGuild) {
1680
+ s.stop('No guild found')
1681
+ cliLogger.error('No guild found. Add the bot to a server first.')
1682
+ client.destroy()
1683
+ process.exit(EXIT_NO_RESTART)
1684
+ }
1685
+ guild = firstGuild
1686
+ }
1687
+ }
1688
+
1689
+ s.message(`Creating channels in ${guild.name}...`)
1690
+
1691
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
1692
+ guild,
1693
+ projectDirectory: absolutePath,
1694
+ appId,
1695
+ botName: client.user?.username,
1696
+ })
1697
+
1698
+ client.destroy()
1699
+
1700
+ s.stop('Channels created!')
1701
+
1702
+ const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`
1703
+
1704
+ note(
1705
+ `Created channels for project:\n\n📝 Text: #${channelName}\n🔊 Voice: #${channelName}\n📁 Directory: ${absolutePath}\n\nURL: ${channelUrl}`,
1706
+ '✅ Success',
1707
+ )
1708
+
1709
+ console.log(channelUrl)
1710
+ process.exit(0)
1711
+ } catch (error) {
1712
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
1713
+ process.exit(EXIT_NO_RESTART)
1714
+ }
1715
+ },
1716
+ )
1717
+
1521
1718
  cli.help()
1522
1719
  cli.parse()