kimaki 0.4.55 → 0.4.57

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 (83) hide show
  1. package/dist/cli.js +307 -91
  2. package/dist/commands/create-new-project.js +1 -0
  3. package/dist/commands/diff.js +130 -0
  4. package/dist/commands/mention-mode.js +51 -0
  5. package/dist/commands/merge-worktree.js +96 -9
  6. package/dist/commands/model.js +147 -19
  7. package/dist/commands/permissions.js +31 -36
  8. package/dist/commands/queue.js +3 -1
  9. package/dist/commands/session.js +1 -0
  10. package/dist/commands/unset-model.js +149 -0
  11. package/dist/commands/user-command.js +2 -0
  12. package/dist/commands/verbosity.js +2 -1
  13. package/dist/commands/worktree.js +7 -3
  14. package/dist/config.js +10 -1
  15. package/dist/database.js +55 -33
  16. package/dist/discord-bot.js +117 -23
  17. package/dist/discord-utils.js +40 -0
  18. package/dist/discord-utils.test.js +37 -0
  19. package/dist/generated/internal/class.js +2 -2
  20. package/dist/generated/internal/prismaNamespace.js +15 -6
  21. package/dist/generated/internal/prismaNamespaceBrowser.js +15 -6
  22. package/dist/generated/models/channel_mention_mode.js +1 -0
  23. package/dist/generated/models/global_models.js +1 -0
  24. package/dist/interaction-handler.js +24 -6
  25. package/dist/logger.js +1 -0
  26. package/dist/message-formatting.js +30 -7
  27. package/dist/opencode-plugin.js +266 -52
  28. package/dist/opencode.js +36 -10
  29. package/dist/session-handler.js +84 -51
  30. package/dist/system-message.js +68 -15
  31. package/dist/unnest-code-blocks.test.js +26 -0
  32. package/dist/voice.js +7 -1
  33. package/dist/worktree-utils.js +74 -2
  34. package/package.json +10 -9
  35. package/src/__snapshots__/compact-session-context-no-system.md +30 -30
  36. package/src/__snapshots__/compact-session-context.md +41 -47
  37. package/src/__snapshots__/first-session-no-info.md +3991 -1174
  38. package/src/__snapshots__/first-session-with-info.md +3994 -1177
  39. package/src/__snapshots__/session-1.md +3991 -1174
  40. package/src/__snapshots__/session-2.md +4 -276
  41. package/src/__snapshots__/session-3.md +4182 -18879
  42. package/src/__snapshots__/session-with-tools.md +3991 -1174
  43. package/src/cli.ts +353 -114
  44. package/src/commands/create-new-project.ts +1 -0
  45. package/src/commands/diff.ts +148 -0
  46. package/src/commands/mention-mode.ts +72 -0
  47. package/src/commands/merge-worktree.ts +130 -9
  48. package/src/commands/model.ts +187 -25
  49. package/src/commands/permissions.ts +44 -42
  50. package/src/commands/queue.ts +3 -1
  51. package/src/commands/session.ts +1 -0
  52. package/src/commands/unset-model.ts +183 -0
  53. package/src/commands/user-command.ts +2 -0
  54. package/src/commands/verbosity.ts +2 -1
  55. package/src/commands/worktree.ts +10 -2
  56. package/src/config.ts +13 -1
  57. package/src/database.ts +61 -36
  58. package/src/discord-bot.ts +130 -25
  59. package/src/discord-utils.test.ts +39 -0
  60. package/src/discord-utils.ts +52 -0
  61. package/src/generated/browser.ts +10 -5
  62. package/src/generated/client.ts +10 -5
  63. package/src/generated/internal/class.ts +22 -12
  64. package/src/generated/internal/prismaNamespace.ts +174 -86
  65. package/src/generated/internal/prismaNamespaceBrowser.ts +23 -10
  66. package/src/generated/models/bot_tokens.ts +100 -0
  67. package/src/generated/models/channel_directories.ts +128 -0
  68. package/src/generated/models/channel_mention_mode.ts +1300 -0
  69. package/src/generated/models/global_models.ts +1256 -0
  70. package/src/generated/models/thread_sessions.ts +0 -100
  71. package/src/generated/models.ts +2 -1
  72. package/src/interaction-handler.ts +33 -6
  73. package/src/logger.ts +1 -0
  74. package/src/message-formatting.ts +36 -8
  75. package/src/opencode-plugin.ts +319 -0
  76. package/src/opencode.ts +43 -10
  77. package/src/schema.sql +15 -5
  78. package/src/session-handler.ts +99 -57
  79. package/src/system-message.ts +94 -14
  80. package/src/unnest-code-blocks.test.ts +27 -0
  81. package/src/voice.ts +7 -1
  82. package/src/worktree-utils.ts +84 -2
  83. package/src/generated/models/pending_auto_start.ts +0 -1192
package/dist/cli.js CHANGED
@@ -6,16 +6,19 @@ import { cac } from '@xmorse/cac';
6
6
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, } from '@clack/prompts';
7
7
  import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
8
8
  import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
9
- import { getBotToken, setBotToken, setGeminiApiKey, setChannelDirectory, findChannelsByDirectory, findChannelByAppId, getThreadSession, getThreadIdBySessionId, setPendingAutoStart, } from './database.js';
9
+ import { getBotToken, setBotToken, setChannelDirectory, findChannelsByDirectory, findChannelByAppId, getThreadSession, getThreadIdBySessionId, getPrisma, } from './database.js';
10
+ import { formatWorktreeName } from './commands/worktree.js';
11
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
12
+ import yaml from 'js-yaml';
10
13
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
11
14
  import path from 'node:path';
12
15
  import fs from 'node:fs';
13
16
  import * as errore from 'errore';
14
17
  import { createLogger, LogPrefix } from './logger.js';
15
- import { uploadFilesToDiscord } from './discord-utils.js';
18
+ import { uploadFilesToDiscord, stripMentions } from './discord-utils.js';
16
19
  import { spawn, spawnSync, execSync } from 'node:child_process';
17
20
  import http from 'node:http';
18
- import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity } from './config.js';
21
+ import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity, setDefaultMentionMode, getProjectsDir } from './config.js';
19
22
  import { sanitizeAgentName } from './commands/agent.js';
20
23
  const cliLogger = createLogger(LogPrefix.CLI);
21
24
  // Strip bracketed paste escape sequences from terminal input.
@@ -165,6 +168,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
165
168
  .setAutocomplete(true);
166
169
  return option;
167
170
  })
171
+ .setDMPermission(false)
168
172
  .toJSON(),
169
173
  new SlashCommandBuilder()
170
174
  .setName('new-session')
@@ -188,6 +192,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
188
192
  .setAutocomplete(true);
189
193
  return option;
190
194
  })
195
+ .setDMPermission(false)
191
196
  .toJSON(),
192
197
  new SlashCommandBuilder()
193
198
  .setName('new-worktree')
@@ -199,14 +204,22 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
199
204
  .setRequired(false);
200
205
  return option;
201
206
  })
207
+ .setDMPermission(false)
202
208
  .toJSON(),
203
209
  new SlashCommandBuilder()
204
210
  .setName('merge-worktree')
205
211
  .setDescription('Merge the worktree branch into the default branch')
212
+ .setDMPermission(false)
206
213
  .toJSON(),
207
214
  new SlashCommandBuilder()
208
215
  .setName('toggle-worktrees')
209
216
  .setDescription('Toggle automatic git worktree creation for new sessions in this channel')
217
+ .setDMPermission(false)
218
+ .toJSON(),
219
+ new SlashCommandBuilder()
220
+ .setName('toggle-mention-mode')
221
+ .setDescription('Toggle mention-only mode (bot only responds when @mentioned)')
222
+ .setDMPermission(false)
210
223
  .toJSON(),
211
224
  new SlashCommandBuilder()
212
225
  .setName('add-project')
@@ -219,6 +232,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
219
232
  .setAutocomplete(true);
220
233
  return option;
221
234
  })
235
+ .setDMPermission(false)
222
236
  .toJSON(),
223
237
  new SlashCommandBuilder()
224
238
  .setName('remove-project')
@@ -231,6 +245,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
231
245
  .setAutocomplete(true);
232
246
  return option;
233
247
  })
248
+ .setDMPermission(false)
234
249
  .toJSON(),
235
250
  new SlashCommandBuilder()
236
251
  .setName('create-new-project')
@@ -239,38 +254,57 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
239
254
  option.setName('name').setDescription('Name for the new project folder').setRequired(true);
240
255
  return option;
241
256
  })
257
+ .setDMPermission(false)
242
258
  .toJSON(),
243
259
  new SlashCommandBuilder()
244
260
  .setName('abort')
245
261
  .setDescription('Abort the current OpenCode request in this thread')
262
+ .setDMPermission(false)
246
263
  .toJSON(),
247
264
  new SlashCommandBuilder()
248
265
  .setName('compact')
249
266
  .setDescription('Compact the session context by summarizing conversation history')
267
+ .setDMPermission(false)
250
268
  .toJSON(),
251
269
  new SlashCommandBuilder()
252
270
  .setName('stop')
253
271
  .setDescription('Abort the current OpenCode request in this thread')
272
+ .setDMPermission(false)
254
273
  .toJSON(),
255
274
  new SlashCommandBuilder()
256
275
  .setName('share')
257
276
  .setDescription('Share the current session as a public URL')
277
+ .setDMPermission(false)
278
+ .toJSON(),
279
+ new SlashCommandBuilder()
280
+ .setName('diff')
281
+ .setDescription('Show git diff as a shareable URL')
282
+ .setDMPermission(false)
258
283
  .toJSON(),
259
284
  new SlashCommandBuilder()
260
285
  .setName('fork')
261
286
  .setDescription('Fork the session from a past user message')
287
+ .setDMPermission(false)
262
288
  .toJSON(),
263
289
  new SlashCommandBuilder()
264
290
  .setName('model')
265
291
  .setDescription('Set the preferred model for this channel or session')
292
+ .setDMPermission(false)
293
+ .toJSON(),
294
+ new SlashCommandBuilder()
295
+ .setName('unset-model-override')
296
+ .setDescription('Remove model override and use default instead')
297
+ .setDMPermission(false)
266
298
  .toJSON(),
267
299
  new SlashCommandBuilder()
268
300
  .setName('login')
269
301
  .setDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect')
302
+ .setDMPermission(false)
270
303
  .toJSON(),
271
304
  new SlashCommandBuilder()
272
305
  .setName('agent')
273
306
  .setDescription('Set the preferred agent for this channel or session')
307
+ .setDMPermission(false)
274
308
  .toJSON(),
275
309
  new SlashCommandBuilder()
276
310
  .setName('queue')
@@ -279,18 +313,22 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
279
313
  option.setName('message').setDescription('The message to queue').setRequired(true);
280
314
  return option;
281
315
  })
316
+ .setDMPermission(false)
282
317
  .toJSON(),
283
318
  new SlashCommandBuilder()
284
319
  .setName('clear-queue')
285
320
  .setDescription('Clear all queued messages in this thread')
321
+ .setDMPermission(false)
286
322
  .toJSON(),
287
323
  new SlashCommandBuilder()
288
324
  .setName('undo')
289
325
  .setDescription('Undo the last assistant message (revert file changes)')
326
+ .setDMPermission(false)
290
327
  .toJSON(),
291
328
  new SlashCommandBuilder()
292
329
  .setName('redo')
293
330
  .setDescription('Redo previously undone changes')
331
+ .setDMPermission(false)
294
332
  .toJSON(),
295
333
  new SlashCommandBuilder()
296
334
  .setName('verbosity')
@@ -303,10 +341,12 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
303
341
  .addChoices({ name: 'tools-and-text (default)', value: 'tools-and-text' }, { name: 'text-and-essential-tools', value: 'text-and-essential-tools' }, { name: 'text-only', value: 'text-only' });
304
342
  return option;
305
343
  })
344
+ .setDMPermission(false)
306
345
  .toJSON(),
307
346
  new SlashCommandBuilder()
308
347
  .setName('restart-opencode-server')
309
348
  .setDescription('Restart the opencode server for this channel only (fixes state/auth/plugins)')
349
+ .setDMPermission(false)
310
350
  .toJSON(),
311
351
  ];
312
352
  // Add user-defined commands with -cmd suffix
@@ -315,7 +355,8 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
315
355
  continue;
316
356
  }
317
357
  // Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
318
- const sanitizedName = cmd.name.replace(/:/g, '-');
358
+ // Also convert to lowercase since Discord only allows lowercase in command names
359
+ const sanitizedName = cmd.name.toLowerCase().replace(/:/g, '-');
319
360
  const commandName = `${sanitizedName}-cmd`;
320
361
  const description = cmd.description || `Run /${cmd.name} command`;
321
362
  commands.push(new SlashCommandBuilder()
@@ -328,6 +369,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
328
369
  .setRequired(false);
329
370
  return option;
330
371
  })
372
+ .setDMPermission(false)
331
373
  .toJSON());
332
374
  }
333
375
  // Add agent-specific quick commands like /plan-agent, /build-agent
@@ -340,6 +382,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
340
382
  commands.push(new SlashCommandBuilder()
341
383
  .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
342
384
  .setDescription(description.slice(0, 100))
385
+ .setDMPermission(false)
343
386
  .toJSON());
344
387
  }
345
388
  const rest = new REST().setToken(token);
@@ -501,6 +544,32 @@ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels })
501
544
  process.exit(EXIT_NO_RESTART);
502
545
  }
503
546
  }
547
+ else {
548
+ // OpenCode found, check version is recent enough for plugin support
549
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
550
+ const versionCheck = spawnSync(opencodeCommand, ['--version'], {
551
+ shell: true,
552
+ encoding: 'utf-8',
553
+ });
554
+ const version = versionCheck.stdout?.trim();
555
+ if (version) {
556
+ const [major, minor, patch] = version.split('.').map(Number);
557
+ // Minimum version 1.1.51 required for plugin API (@opencode-ai/plugin/tool subpath)
558
+ const MIN_MAJOR = 1;
559
+ const MIN_MINOR = 1;
560
+ const MIN_PATCH = 51;
561
+ const tooOld = (major || 0) < MIN_MAJOR ||
562
+ ((major || 0) === MIN_MAJOR && (minor || 0) < MIN_MINOR) ||
563
+ ((major || 0) === MIN_MAJOR && (minor || 0) === MIN_MINOR && (patch || 0) < MIN_PATCH);
564
+ if (tooOld) {
565
+ note(`Installed OpenCode version ${version} is too old.\n` +
566
+ `Kimaki requires OpenCode >= ${MIN_MAJOR}.${MIN_MINOR}.${MIN_PATCH}.\n` +
567
+ `Please update: curl -fsSL https://opencode.ai/install | bash`, 'OpenCode Update Required');
568
+ process.exit(EXIT_NO_RESTART);
569
+ }
570
+ cliLogger.log(`OpenCode version: ${version}`);
571
+ }
572
+ }
504
573
  }
505
574
  // Initialize database
506
575
  await initDatabase();
@@ -574,29 +643,7 @@ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels })
574
643
  process.exit(0);
575
644
  }
576
645
  token = stripBracketedPaste(tokenInput);
577
- note(`You can get a Gemini api Key at https://aistudio.google.com/apikey`, `Gemini API Key`);
578
- const geminiApiKeyInput = await password({
579
- message: 'Enter your Gemini API Key for voice channels and audio transcription (optional, press Enter to skip):',
580
- validate(value) {
581
- const cleaned = stripBracketedPaste(value);
582
- if (cleaned && cleaned.length < 10) {
583
- return 'Invalid API key format';
584
- }
585
- return undefined;
586
- },
587
- });
588
- if (isCancel(geminiApiKeyInput)) {
589
- cancel('Setup cancelled');
590
- process.exit(0);
591
- }
592
- const geminiApiKey = stripBracketedPaste(geminiApiKeyInput) || null;
593
- // Store bot token early so setGeminiApiKey can satisfy FK constraint
594
646
  await setBotToken(appId, token);
595
- // Store API key in database
596
- if (geminiApiKey) {
597
- await setGeminiApiKey(appId, geminiApiKey);
598
- note('API key saved successfully', 'API Key Stored');
599
- }
600
647
  note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
601
648
  const installed = await text({
602
649
  message: 'Press Enter AFTER you have installed the bot in your server:',
@@ -845,6 +892,7 @@ cli
845
892
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
846
893
  .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
847
894
  .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)')
895
+ .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
848
896
  .action(async (options) => {
849
897
  try {
850
898
  // Set data directory early, before any database access
@@ -861,6 +909,10 @@ cli
861
909
  setDefaultVerbosity(options.verbosity);
862
910
  cliLogger.log(`Default verbosity: ${options.verbosity}`);
863
911
  }
912
+ if (options.mentionMode) {
913
+ setDefaultMentionMode(true);
914
+ cliLogger.log('Default mention mode: enabled (bot only responds when @mentioned)');
915
+ }
864
916
  if (options.installUrl) {
865
917
  await initDatabase();
866
918
  const existingBot = await getBotToken();
@@ -942,6 +994,10 @@ cli
942
994
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
943
995
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
944
996
  .option('--notify-only', 'Create notification thread without starting AI session')
997
+ .option('--worktree [name]', 'Create git worktree for session (name optional, derives from thread name)')
998
+ .option('-u, --user <username>', 'Discord username to add to thread')
999
+ .option('--agent <agent>', 'Agent to use for the session')
1000
+ .option('--model <model>', 'Model to use (format: provider/model)')
945
1001
  .action(async (options) => {
946
1002
  try {
947
1003
  let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options;
@@ -954,14 +1010,16 @@ cli
954
1010
  channelId = process.argv[channelArgIndex + 1];
955
1011
  }
956
1012
  }
957
- if (!channelId && !projectPath) {
958
- cliLogger.error('Either --channel or --project is required');
959
- process.exit(EXIT_NO_RESTART);
960
- }
1013
+ // Default to current directory if neither --channel nor --project provided
1014
+ const resolvedProjectPath = projectPath || (!channelId ? '.' : undefined);
961
1015
  if (!prompt) {
962
1016
  cliLogger.error('Prompt is required. Use --prompt <prompt>');
963
1017
  process.exit(EXIT_NO_RESTART);
964
1018
  }
1019
+ if (options.worktree && notifyOnly) {
1020
+ cliLogger.error('Cannot use --worktree with --notify-only');
1021
+ process.exit(EXIT_NO_RESTART);
1022
+ }
965
1023
  // Get bot token from env var or database
966
1024
  const envToken = process.env.KIMAKI_BOT_TOKEN;
967
1025
  let botToken;
@@ -999,9 +1057,9 @@ cli
999
1057
  cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
1000
1058
  process.exit(EXIT_NO_RESTART);
1001
1059
  }
1002
- // If --project provided, resolve to channel ID
1003
- if (projectPath) {
1004
- const absolutePath = path.resolve(projectPath);
1060
+ // If --project provided (or defaulting to cwd), resolve to channel ID
1061
+ if (resolvedProjectPath) {
1062
+ const absolutePath = path.resolve(resolvedProjectPath);
1005
1063
  if (!fs.existsSync(absolutePath)) {
1006
1064
  cliLogger.error(`Directory does not exist: ${absolutePath}`);
1007
1065
  process.exit(EXIT_NO_RESTART);
@@ -1069,7 +1127,15 @@ cli
1069
1127
  }
1070
1128
  }
1071
1129
  // Fall back to first guild the bot is in
1072
- const firstGuild = client.guilds.cache.first();
1130
+ let firstGuild = client.guilds.cache.first();
1131
+ if (!firstGuild) {
1132
+ // Cache might be empty, try fetching guilds from API
1133
+ const fetched = await client.guilds.fetch();
1134
+ const firstOAuth2Guild = fetched.first();
1135
+ if (firstOAuth2Guild) {
1136
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
1137
+ }
1138
+ }
1073
1139
  if (!firstGuild) {
1074
1140
  throw new Error('No guild found. Add the bot to a server first.');
1075
1141
  }
@@ -1092,18 +1158,12 @@ cli
1092
1158
  }
1093
1159
  }
1094
1160
  cliLogger.log('Fetching channel info...');
1095
- // Get channel info to extract directory from topic
1096
- const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
1097
- headers: {
1098
- Authorization: `Bot ${botToken}`,
1099
- },
1100
- });
1101
- if (!channelResponse.ok) {
1102
- const error = await channelResponse.text();
1103
- cliLogger.log('Failed to fetch channel');
1104
- throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
1161
+ if (!channelId) {
1162
+ throw new Error('Channel ID not resolved');
1105
1163
  }
1106
- const channelData = (await channelResponse.json());
1164
+ const rest = new REST().setToken(botToken);
1165
+ // Get channel info to extract directory from topic
1166
+ const channelData = (await rest.get(Routes.channel(channelId)));
1107
1167
  const channelConfig = await getChannelDirectory(channelData.id);
1108
1168
  if (!channelConfig) {
1109
1169
  cliLogger.log('Channel not configured');
@@ -1116,17 +1176,57 @@ cli
1116
1176
  cliLogger.log('Channel belongs to different bot');
1117
1177
  throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
1118
1178
  }
1179
+ // Resolve username to user ID if provided
1180
+ const resolvedUser = await (async () => {
1181
+ if (!options.user) {
1182
+ return undefined;
1183
+ }
1184
+ cliLogger.log(`Searching for user "${options.user}" in guild...`);
1185
+ const searchResults = (await rest.get(Routes.guildMembersSearch(channelData.guild_id), {
1186
+ query: new URLSearchParams({ query: options.user, limit: '10' }),
1187
+ }));
1188
+ // Find exact match by display name, nickname, or username
1189
+ const exactMatch = searchResults.find((member) => {
1190
+ const displayName = member.nick || member.user.global_name || member.user.username;
1191
+ return (displayName.toLowerCase() === options.user.toLowerCase() ||
1192
+ member.user.username.toLowerCase() === options.user.toLowerCase());
1193
+ });
1194
+ const member = exactMatch || searchResults[0];
1195
+ if (!member) {
1196
+ throw new Error(`User "${options.user}" not found in guild`);
1197
+ }
1198
+ const username = member.nick || member.user.global_name || member.user.username;
1199
+ cliLogger.log(`Found user: ${username} (${member.user.id})`);
1200
+ return { id: member.user.id, username };
1201
+ })();
1119
1202
  cliLogger.log('Creating starter message...');
1120
1203
  // Discord has a 2000 character limit for messages.
1121
1204
  // If prompt exceeds this, send it as a file attachment instead.
1122
1205
  const DISCORD_MAX_LENGTH = 2000;
1123
1206
  let starterMessage;
1207
+ // Compute thread name and worktree name early (needed for embed)
1208
+ const cleanPrompt = stripMentions(prompt);
1209
+ const baseThreadName = name || (cleanPrompt.length > 80 ? cleanPrompt.slice(0, 77) + '...' : cleanPrompt);
1210
+ const worktreeName = options.worktree
1211
+ ? formatWorktreeName(typeof options.worktree === 'string' ? options.worktree : baseThreadName)
1212
+ : undefined;
1213
+ const threadName = worktreeName
1214
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
1215
+ : baseThreadName;
1124
1216
  // Embed marker for auto-start sessions (unless --notify-only)
1125
- // Bot checks for this embed footer to know it should start a session
1126
- const AUTO_START_MARKER = 'kimaki:start';
1127
- const autoStartEmbed = notifyOnly
1217
+ // Bot parses this YAML to know it should start a session, optionally create a worktree, and set initial user
1218
+ const embedMarker = notifyOnly
1128
1219
  ? undefined
1129
- : [{ color: 0x2b2d31, footer: { text: AUTO_START_MARKER } }];
1220
+ : {
1221
+ start: true,
1222
+ ...(worktreeName && { worktree: worktreeName }),
1223
+ ...(resolvedUser && { username: resolvedUser.username, userId: resolvedUser.id }),
1224
+ ...(options.agent && { agent: options.agent }),
1225
+ ...(options.model && { model: options.model }),
1226
+ };
1227
+ const autoStartEmbed = embedMarker
1228
+ ? [{ color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } }]
1229
+ : undefined;
1130
1230
  if (prompt.length > DISCORD_MAX_LENGTH) {
1131
1231
  // Send as file attachment with a short summary
1132
1232
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
@@ -1139,7 +1239,8 @@ cli
1139
1239
  const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`);
1140
1240
  fs.writeFileSync(tmpFile, prompt);
1141
1241
  try {
1142
- // Create message with file attachment
1242
+ // Using raw fetch for file uploads because discord.js REST client
1243
+ // doesn't handle FormData/multipart file attachments correctly
1143
1244
  const formData = new FormData();
1144
1245
  formData.append('payload_json', JSON.stringify({
1145
1246
  content: summaryContent,
@@ -1169,49 +1270,28 @@ cli
1169
1270
  }
1170
1271
  else {
1171
1272
  // Normal case: send prompt inline
1172
- const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
1173
- method: 'POST',
1174
- headers: {
1175
- Authorization: `Bot ${botToken}`,
1176
- 'Content-Type': 'application/json',
1177
- },
1178
- body: JSON.stringify({
1179
- content: prompt,
1180
- embeds: autoStartEmbed,
1181
- }),
1182
- });
1183
- if (!starterMessageResponse.ok) {
1184
- const error = await starterMessageResponse.text();
1185
- cliLogger.log('Failed to create message');
1186
- throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
1187
- }
1188
- starterMessage = (await starterMessageResponse.json());
1273
+ starterMessage = (await rest.post(Routes.channelMessages(channelId), {
1274
+ body: { content: prompt, embeds: autoStartEmbed },
1275
+ }));
1189
1276
  }
1190
1277
  cliLogger.log('Creating thread...');
1191
- // Create thread from the message
1192
- const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
1193
- const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
1194
- method: 'POST',
1195
- headers: {
1196
- Authorization: `Bot ${botToken}`,
1197
- 'Content-Type': 'application/json',
1198
- },
1199
- body: JSON.stringify({
1278
+ const threadData = (await rest.post(Routes.threads(channelId, starterMessage.id), {
1279
+ body: {
1200
1280
  name: threadName.slice(0, 100),
1201
1281
  auto_archive_duration: 1440, // 1 day
1202
- }),
1203
- });
1204
- if (!threadResponse.ok) {
1205
- const error = await threadResponse.text();
1206
- cliLogger.log('Failed to create thread');
1207
- throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
1208
- }
1209
- const threadData = (await threadResponse.json());
1282
+ },
1283
+ }));
1210
1284
  cliLogger.log('Thread created!');
1285
+ // Add user to thread if specified
1286
+ if (resolvedUser) {
1287
+ cliLogger.log(`Adding user ${resolvedUser.username} to thread...`);
1288
+ await rest.put(Routes.threadMembers(threadData.id, resolvedUser.id));
1289
+ }
1211
1290
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
1291
+ const worktreeNote = worktreeName ? `\nWorktree: ${worktreeName} (will be created by bot)` : '';
1212
1292
  const successMessage = notifyOnly
1213
1293
  ? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
1214
- : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
1294
+ : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}${worktreeNote}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
1215
1295
  note(successMessage, '✅ Thread Created');
1216
1296
  cliLogger.log(threadUrl);
1217
1297
  process.exit(0);
@@ -1222,7 +1302,8 @@ cli
1222
1302
  }
1223
1303
  });
1224
1304
  cli
1225
- .command('add-project [directory]', 'Create Discord channels for a project directory (e.g. ./folder)')
1305
+ .command('project add [directory]', 'Create Discord channels for a project directory (e.g. ./folder)')
1306
+ .alias('add-project')
1226
1307
  .option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
1227
1308
  .option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
1228
1309
  .action(async (directory, options) => {
@@ -1311,7 +1392,15 @@ cli
1311
1392
  }
1312
1393
  catch (error) {
1313
1394
  cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
1314
- const firstGuild = client.guilds.cache.first();
1395
+ let firstGuild = client.guilds.cache.first();
1396
+ if (!firstGuild) {
1397
+ // Cache might be empty, try fetching guilds from API
1398
+ const fetched = await client.guilds.fetch();
1399
+ const firstOAuth2Guild = fetched.first();
1400
+ if (firstOAuth2Guild) {
1401
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
1402
+ }
1403
+ }
1315
1404
  if (!firstGuild) {
1316
1405
  cliLogger.log('No guild found');
1317
1406
  cliLogger.error('No guild found. Add the bot to a server first.');
@@ -1322,7 +1411,15 @@ cli
1322
1411
  }
1323
1412
  }
1324
1413
  else {
1325
- const firstGuild = client.guilds.cache.first();
1414
+ let firstGuild = client.guilds.cache.first();
1415
+ if (!firstGuild) {
1416
+ // Cache might be empty, try fetching guilds from API
1417
+ const fetched = await client.guilds.fetch();
1418
+ const firstOAuth2Guild = fetched.first();
1419
+ if (firstOAuth2Guild) {
1420
+ firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
1421
+ }
1422
+ }
1326
1423
  if (!firstGuild) {
1327
1424
  cliLogger.log('No guild found');
1328
1425
  cliLogger.error('No guild found. Add the bot to a server first.');
@@ -1344,10 +1441,9 @@ cli
1344
1441
  try {
1345
1442
  const ch = await client.channels.fetch(existingChannel.channel_id);
1346
1443
  if (ch && 'guild' in ch && ch.guild?.id === guild.id) {
1347
- cliLogger.log('Channel already exists');
1348
- note(`Channel already exists for this directory in ${guild.name}.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`, '⚠️ Already Exists');
1349
1444
  client.destroy();
1350
- process.exit(0);
1445
+ cliLogger.error(`Channel already exists for this directory in ${guild.name}. Channel ID: ${existingChannel.channel_id}`);
1446
+ process.exit(EXIT_NO_RESTART);
1351
1447
  }
1352
1448
  }
1353
1449
  catch (error) {
@@ -1413,5 +1509,125 @@ cli
1413
1509
  const dbPath = path.join(dataDir, 'discord-sessions.db');
1414
1510
  cliLogger.log(dbPath);
1415
1511
  });
1512
+ cli
1513
+ .command('project list', 'List all registered projects with their Discord channels')
1514
+ .option('--json', 'Output as JSON')
1515
+ .action(async (options) => {
1516
+ try {
1517
+ await initDatabase();
1518
+ const prisma = await getPrisma();
1519
+ const channels = await prisma.channel_directories.findMany({
1520
+ where: { channel_type: 'text' },
1521
+ orderBy: { created_at: 'desc' },
1522
+ });
1523
+ if (options.json) {
1524
+ const output = channels.map((ch) => ({
1525
+ channel_id: ch.channel_id,
1526
+ directory: ch.directory,
1527
+ app_id: ch.app_id,
1528
+ }));
1529
+ console.log(JSON.stringify(output, null, 2));
1530
+ process.exit(0);
1531
+ }
1532
+ if (channels.length === 0) {
1533
+ cliLogger.log('No projects registered');
1534
+ process.exit(0);
1535
+ }
1536
+ for (const ch of channels) {
1537
+ const name = path.basename(ch.directory);
1538
+ console.log(`\n📁 ${name}`);
1539
+ console.log(` Directory: ${ch.directory}`);
1540
+ console.log(` Channel ID: ${ch.channel_id}`);
1541
+ if (ch.app_id) {
1542
+ console.log(` Bot App ID: ${ch.app_id}`);
1543
+ }
1544
+ }
1545
+ process.exit(0);
1546
+ }
1547
+ catch (error) {
1548
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
1549
+ process.exit(EXIT_NO_RESTART);
1550
+ }
1551
+ });
1552
+ cli
1553
+ .command('project create <name>', 'Create a new project folder with git and Discord channels')
1554
+ .option('-g, --guild <guildId>', 'Discord guild ID')
1555
+ .action(async (name, options) => {
1556
+ try {
1557
+ const sanitizedName = name
1558
+ .toLowerCase()
1559
+ .replace(/[^a-z0-9-]/g, '-')
1560
+ .replace(/-+/g, '-')
1561
+ .replace(/^-|-$/g, '')
1562
+ .slice(0, 100);
1563
+ if (!sanitizedName) {
1564
+ cliLogger.error('Invalid project name');
1565
+ process.exit(EXIT_NO_RESTART);
1566
+ }
1567
+ await initDatabase();
1568
+ const botRow = await getBotToken();
1569
+ if (!botRow) {
1570
+ cliLogger.error('No bot configured. Run `kimaki` first.');
1571
+ process.exit(EXIT_NO_RESTART);
1572
+ }
1573
+ const { app_id: appId, token: botToken } = botRow;
1574
+ const projectsDir = getProjectsDir();
1575
+ const projectDirectory = path.join(projectsDir, sanitizedName);
1576
+ if (!fs.existsSync(projectsDir)) {
1577
+ fs.mkdirSync(projectsDir, { recursive: true });
1578
+ }
1579
+ if (fs.existsSync(projectDirectory)) {
1580
+ cliLogger.error(`Directory already exists: ${projectDirectory}`);
1581
+ process.exit(EXIT_NO_RESTART);
1582
+ }
1583
+ fs.mkdirSync(projectDirectory, { recursive: true });
1584
+ cliLogger.log(`Created: ${projectDirectory}`);
1585
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
1586
+ cliLogger.log('Initialized git');
1587
+ cliLogger.log('Connecting to Discord...');
1588
+ const client = await createDiscordClient();
1589
+ await new Promise((resolve, reject) => {
1590
+ client.once(Events.ClientReady, () => {
1591
+ resolve();
1592
+ });
1593
+ client.once(Events.Error, reject);
1594
+ client.login(botToken).catch(reject);
1595
+ });
1596
+ let guild;
1597
+ if (options.guild) {
1598
+ const found = client.guilds.cache.get(options.guild);
1599
+ if (!found) {
1600
+ cliLogger.error(`Guild not found: ${options.guild}`);
1601
+ client.destroy();
1602
+ process.exit(EXIT_NO_RESTART);
1603
+ }
1604
+ guild = found;
1605
+ }
1606
+ else {
1607
+ const first = client.guilds.cache.first();
1608
+ if (!first) {
1609
+ cliLogger.error('No guild found. Add the bot to a server first.');
1610
+ client.destroy();
1611
+ process.exit(EXIT_NO_RESTART);
1612
+ }
1613
+ guild = first;
1614
+ }
1615
+ const { textChannelId, channelName } = await createProjectChannels({
1616
+ guild,
1617
+ projectDirectory,
1618
+ appId,
1619
+ botName: client.user?.username,
1620
+ });
1621
+ client.destroy();
1622
+ const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`;
1623
+ note(`Created project: ${sanitizedName}\n\nDirectory: ${projectDirectory}\nChannel: #${channelName}\nURL: ${channelUrl}`, '✅ Success');
1624
+ cliLogger.log(channelUrl);
1625
+ process.exit(0);
1626
+ }
1627
+ catch (error) {
1628
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
1629
+ process.exit(EXIT_NO_RESTART);
1630
+ }
1631
+ });
1416
1632
  cli.help();
1417
1633
  cli.parse();