kimaki 0.4.39 → 0.4.41

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.js +108 -51
  3. package/dist/commands/abort.js +1 -1
  4. package/dist/commands/add-project.js +2 -2
  5. package/dist/commands/agent.js +2 -2
  6. package/dist/commands/fork.js +2 -2
  7. package/dist/commands/model.js +2 -2
  8. package/dist/commands/remove-project.js +2 -2
  9. package/dist/commands/resume.js +2 -2
  10. package/dist/commands/session.js +4 -4
  11. package/dist/commands/share.js +1 -1
  12. package/dist/commands/undo-redo.js +2 -2
  13. package/dist/commands/worktree.js +180 -0
  14. package/dist/database.js +49 -1
  15. package/dist/discord-bot.js +29 -4
  16. package/dist/discord-utils.js +36 -0
  17. package/dist/errors.js +86 -87
  18. package/dist/genai-worker.js +1 -1
  19. package/dist/interaction-handler.js +6 -2
  20. package/dist/markdown.js +5 -1
  21. package/dist/message-formatting.js +2 -2
  22. package/dist/opencode.js +4 -4
  23. package/dist/session-handler.js +2 -2
  24. package/dist/tools.js +3 -3
  25. package/dist/voice-handler.js +3 -3
  26. package/dist/voice.js +4 -4
  27. package/package.json +16 -16
  28. package/src/cli.ts +166 -85
  29. package/src/commands/abort.ts +1 -1
  30. package/src/commands/add-project.ts +2 -2
  31. package/src/commands/agent.ts +2 -2
  32. package/src/commands/fork.ts +2 -2
  33. package/src/commands/model.ts +2 -2
  34. package/src/commands/remove-project.ts +2 -2
  35. package/src/commands/resume.ts +2 -2
  36. package/src/commands/session.ts +4 -4
  37. package/src/commands/share.ts +1 -1
  38. package/src/commands/undo-redo.ts +2 -2
  39. package/src/commands/worktree.ts +243 -0
  40. package/src/database.ts +96 -1
  41. package/src/discord-bot.ts +30 -4
  42. package/src/discord-utils.ts +50 -0
  43. package/src/errors.ts +90 -160
  44. package/src/genai-worker.ts +1 -1
  45. package/src/interaction-handler.ts +7 -2
  46. package/src/markdown.ts +5 -4
  47. package/src/message-formatting.ts +2 -2
  48. package/src/opencode.ts +4 -4
  49. package/src/session-handler.ts +2 -2
  50. package/src/tools.ts +3 -3
  51. package/src/voice-handler.ts +3 -3
  52. package/src/voice.ts +4 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kimaki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import path from 'node:path';
11
11
  import fs from 'node:fs';
12
12
  import * as errore from 'errore';
13
13
  import { createLogger } from './logger.js';
14
+ import { uploadFilesToDiscord } from './discord-utils.js';
14
15
  import { spawn, spawnSync, execSync } from 'node:child_process';
15
16
  import http from 'node:http';
16
17
  import { setDataDir, getDataDir, getLockPort } from './config.js';
@@ -136,7 +137,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
136
137
  })
137
138
  .toJSON(),
138
139
  new SlashCommandBuilder()
139
- .setName('session')
140
+ .setName('new-session')
140
141
  .setDescription('Start a new OpenCode session')
141
142
  .addStringOption((option) => {
142
143
  option.setName('prompt').setDescription('Prompt content for the session').setRequired(true);
@@ -158,6 +159,17 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
158
159
  return option;
159
160
  })
160
161
  .toJSON(),
162
+ new SlashCommandBuilder()
163
+ .setName('new-worktree')
164
+ .setDescription('Create a new git worktree and start a session thread')
165
+ .addStringOption((option) => {
166
+ option
167
+ .setName('name')
168
+ .setDescription('Name for the worktree (will be formatted: lowercase, spaces to dashes)')
169
+ .setRequired(true);
170
+ return option;
171
+ })
172
+ .toJSON(),
161
173
  new SlashCommandBuilder()
162
174
  .setName('add-project')
163
175
  .setDescription('Create Discord channels for a new OpenCode project')
@@ -280,6 +292,79 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
280
292
  throw error;
281
293
  }
282
294
  }
295
+ /**
296
+ * Store channel-directory mappings in the database.
297
+ * Called after Discord login to persist channel configurations.
298
+ */
299
+ function storeChannelDirectories({ kimakiChannels, db, }) {
300
+ for (const { guild, channels } of kimakiChannels) {
301
+ for (const channel of channels) {
302
+ if (channel.kimakiDirectory) {
303
+ db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null);
304
+ const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
305
+ if (voiceChannel) {
306
+ db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null);
307
+ }
308
+ }
309
+ }
310
+ }
311
+ }
312
+ /**
313
+ * Show the ready message with channel links.
314
+ * Called at the end of startup to display available channels.
315
+ */
316
+ function showReadyMessage({ kimakiChannels, createdChannels, appId, }) {
317
+ const allChannels = [];
318
+ allChannels.push(...createdChannels);
319
+ kimakiChannels.forEach(({ guild, channels }) => {
320
+ channels.forEach((ch) => {
321
+ allChannels.push({
322
+ name: ch.name,
323
+ id: ch.id,
324
+ guildId: guild.id,
325
+ directory: ch.kimakiDirectory,
326
+ });
327
+ });
328
+ });
329
+ if (allChannels.length > 0) {
330
+ const channelLinks = allChannels
331
+ .map((ch) => `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
332
+ .join('\n');
333
+ note(`Your kimaki channels are ready! Click any link below to open in Discord:\n\n${channelLinks}\n\nSend a message in any channel to start using OpenCode!`, 'šŸš€ Ready to Use');
334
+ }
335
+ note('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.', 'āš ļø Keep Running');
336
+ }
337
+ /**
338
+ * Background initialization for quick start mode.
339
+ * Starts OpenCode server and registers slash commands without blocking bot startup.
340
+ */
341
+ async function backgroundInit({ currentDir, token, appId, }) {
342
+ try {
343
+ const opencodeResult = await initializeOpencodeForDirectory(currentDir);
344
+ if (opencodeResult instanceof Error) {
345
+ cliLogger.warn('Background OpenCode init failed:', opencodeResult.message);
346
+ // Still try to register basic commands without user commands/agents
347
+ await registerCommands({ token, appId, userCommands: [], agents: [] });
348
+ return;
349
+ }
350
+ const getClient = opencodeResult;
351
+ const [userCommands, agents] = await Promise.all([
352
+ getClient()
353
+ .command.list({ query: { directory: currentDir } })
354
+ .then((r) => r.data || [])
355
+ .catch(() => []),
356
+ getClient()
357
+ .app.agents({ query: { directory: currentDir } })
358
+ .then((r) => r.data || [])
359
+ .catch(() => []),
360
+ ]);
361
+ await registerCommands({ token, appId, userCommands, agents });
362
+ cliLogger.log('Slash commands registered!');
363
+ }
364
+ catch (error) {
365
+ cliLogger.error('Background init failed:', error instanceof Error ? error.message : String(error));
366
+ }
367
+ }
283
368
  async function run({ restart, addChannels }) {
284
369
  const forceSetup = Boolean(restart);
285
370
  intro('šŸ¤– Discord Bot Setup');
@@ -434,7 +519,7 @@ async function run({ restart, addChannels }) {
434
519
  const currentDir = process.cwd();
435
520
  s.start('Starting OpenCode server...');
436
521
  const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
437
- if (errore.isError(result)) {
522
+ if (result instanceof Error) {
438
523
  throw new Error(result.message);
439
524
  }
440
525
  return result;
@@ -500,17 +585,8 @@ async function run({ restart, addChannels }) {
500
585
  process.exit(EXIT_NO_RESTART);
501
586
  }
502
587
  db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token);
503
- for (const { guild, channels } of kimakiChannels) {
504
- for (const channel of channels) {
505
- if (channel.kimakiDirectory) {
506
- db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null);
507
- const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
508
- if (voiceChannel) {
509
- db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null);
510
- }
511
- }
512
- }
513
- }
588
+ // Store channel-directory mappings
589
+ storeChannelDirectories({ kimakiChannels, db });
514
590
  if (kimakiChannels.length > 0) {
515
591
  const channelList = kimakiChannels
516
592
  .flatMap(({ guild, channels }) => channels.map((ch) => {
@@ -520,6 +596,19 @@ async function run({ restart, addChannels }) {
520
596
  .join('\n');
521
597
  note(channelList, 'Existing Kimaki Channels');
522
598
  }
599
+ // Quick start: if setup is already done, start bot immediately and background the rest
600
+ const isQuickStart = existingBot && !forceSetup && !addChannels;
601
+ if (isQuickStart) {
602
+ s.start('Starting Discord bot...');
603
+ await startDiscordBot({ token, appId, discordClient });
604
+ s.stop('Discord bot is running!');
605
+ // Background: OpenCode init + slash command registration (non-blocking)
606
+ void backgroundInit({ currentDir, token, appId });
607
+ showReadyMessage({ kimakiChannels, createdChannels, appId });
608
+ outro('✨ Bot ready! Listening for messages...');
609
+ return;
610
+ }
611
+ // Full setup path: wait for OpenCode, show prompts, create channels if needed
523
612
  // Await the OpenCode server that was started in parallel with Discord login
524
613
  s.start('Waiting for OpenCode server...');
525
614
  const getClient = await opencodePromise;
@@ -644,25 +733,7 @@ async function run({ restart, addChannels }) {
644
733
  s.start('Starting Discord bot...');
645
734
  await startDiscordBot({ token, appId, discordClient });
646
735
  s.stop('Discord bot is running!');
647
- const allChannels = [];
648
- allChannels.push(...createdChannels);
649
- kimakiChannels.forEach(({ guild, channels }) => {
650
- channels.forEach((ch) => {
651
- allChannels.push({
652
- name: ch.name,
653
- id: ch.id,
654
- guildId: guild.id,
655
- directory: ch.kimakiDirectory,
656
- });
657
- });
658
- });
659
- if (allChannels.length > 0) {
660
- const channelLinks = allChannels
661
- .map((ch) => `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
662
- .join('\n');
663
- note(`Your kimaki channels are ready! Click any link below to open in Discord:\n\n${channelLinks}\n\nSend a message in any channel to start using OpenCode!`, 'šŸš€ Ready to Use');
664
- }
665
- note('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.', 'āš ļø Keep Running');
736
+ showReadyMessage({ kimakiChannels, createdChannels, appId });
666
737
  outro('✨ Setup complete! Listening for new messages... do not close this process.');
667
738
  }
668
739
  cli
@@ -741,25 +812,11 @@ cli
741
812
  }
742
813
  const s = spinner();
743
814
  s.start(`Uploading ${resolvedFiles.length} file(s)...`);
744
- for (const file of resolvedFiles) {
745
- const buffer = fs.readFileSync(file);
746
- const formData = new FormData();
747
- formData.append('payload_json', JSON.stringify({
748
- attachments: [{ id: 0, filename: path.basename(file) }],
749
- }));
750
- formData.append('files[0]', new Blob([buffer]), path.basename(file));
751
- const response = await fetch(`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`, {
752
- method: 'POST',
753
- headers: {
754
- Authorization: `Bot ${botRow.token}`,
755
- },
756
- body: formData,
757
- });
758
- if (!response.ok) {
759
- const error = await response.text();
760
- throw new Error(`Discord API error: ${response.status} - ${error}`);
761
- }
762
- }
815
+ await uploadFilesToDiscord({
816
+ threadId: threadRow.thread_id,
817
+ botToken: botRow.token,
818
+ files: resolvedFiles,
819
+ });
763
820
  s.stop(`Uploaded ${resolvedFiles.length} file(s)!`);
764
821
  note(`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`, 'āœ… Success');
765
822
  process.exit(0);
@@ -58,7 +58,7 @@ export async function handleAbortCommand({ command }) {
58
58
  abortControllers.delete(sessionId);
59
59
  }
60
60
  const getClient = await initializeOpencodeForDirectory(directory);
61
- if (errore.isError(getClient)) {
61
+ if (getClient instanceof Error) {
62
62
  await command.reply({
63
63
  content: `Failed to abort: ${getClient.message}`,
64
64
  ephemeral: true,
@@ -19,7 +19,7 @@ export async function handleAddProjectCommand({ command, appId }) {
19
19
  try {
20
20
  const currentDir = process.cwd();
21
21
  const getClient = await initializeOpencodeForDirectory(currentDir);
22
- if (errore.isError(getClient)) {
22
+ if (getClient instanceof Error) {
23
23
  await command.editReply(getClient.message);
24
24
  return;
25
25
  }
@@ -65,7 +65,7 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
65
65
  try {
66
66
  const currentDir = process.cwd();
67
67
  const getClient = await initializeOpencodeForDirectory(currentDir);
68
- if (errore.isError(getClient)) {
68
+ if (getClient instanceof Error) {
69
69
  await interaction.respond([]);
70
70
  return;
71
71
  }
@@ -103,7 +103,7 @@ export async function handleAgentCommand({ interaction, appId, }) {
103
103
  }
104
104
  try {
105
105
  const getClient = await initializeOpencodeForDirectory(context.dir);
106
- if (errore.isError(getClient)) {
106
+ if (getClient instanceof Error) {
107
107
  await interaction.editReply({ content: getClient.message });
108
108
  return;
109
109
  }
@@ -210,7 +210,7 @@ export async function handleQuickAgentCommand({ command, appId, }) {
210
210
  }
211
211
  try {
212
212
  const getClient = await initializeOpencodeForDirectory(context.dir);
213
- if (errore.isError(getClient)) {
213
+ if (getClient instanceof Error) {
214
214
  await command.editReply({ content: getClient.message });
215
215
  return;
216
216
  }
@@ -52,7 +52,7 @@ export async function handleForkCommand(interaction) {
52
52
  await interaction.deferReply({ ephemeral: true });
53
53
  const sessionId = row.session_id;
54
54
  const getClient = await initializeOpencodeForDirectory(directory);
55
- if (errore.isError(getClient)) {
55
+ if (getClient instanceof Error) {
56
56
  await interaction.editReply({
57
57
  content: `Failed to load messages: ${getClient.message}`,
58
58
  });
@@ -128,7 +128,7 @@ export async function handleForkSelectMenu(interaction) {
128
128
  }
129
129
  await interaction.deferReply({ ephemeral: false });
130
130
  const getClient = await initializeOpencodeForDirectory(directory);
131
- if (errore.isError(getClient)) {
131
+ if (getClient instanceof Error) {
132
132
  await interaction.editReply(`Failed to fork session: ${getClient.message}`);
133
133
  return;
134
134
  }
@@ -78,7 +78,7 @@ export async function handleModelCommand({ interaction, appId, }) {
78
78
  }
79
79
  try {
80
80
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
81
- if (errore.isError(getClient)) {
81
+ if (getClient instanceof Error) {
82
82
  await interaction.editReply({ content: getClient.message });
83
83
  return;
84
84
  }
@@ -167,7 +167,7 @@ export async function handleProviderSelectMenu(interaction) {
167
167
  }
168
168
  try {
169
169
  const getClient = await initializeOpencodeForDirectory(context.dir);
170
- if (errore.isError(getClient)) {
170
+ if (getClient instanceof Error) {
171
171
  await interaction.editReply({
172
172
  content: getClient.message,
173
173
  components: [],
@@ -30,7 +30,7 @@ export async function handleRemoveProjectCommand({ command, appId }) {
30
30
  try: () => guild.channels.fetch(channel_id),
31
31
  catch: (e) => e,
32
32
  });
33
- if (errore.isError(channel)) {
33
+ if (channel instanceof Error) {
34
34
  logger.error(`Failed to fetch channel ${channel_id}:`, channel);
35
35
  failedChannels.push(`${channel_type}: ${channel_id}`);
36
36
  continue;
@@ -88,7 +88,7 @@ export async function handleRemoveProjectAutocomplete({ interaction, appId, }) {
88
88
  try: () => guild.channels.fetch(channel_id),
89
89
  catch: (e) => e,
90
90
  });
91
- if (errore.isError(channel)) {
91
+ if (channel instanceof Error) {
92
92
  // Channel not in this guild, skip
93
93
  continue;
94
94
  }
@@ -42,7 +42,7 @@ export async function handleResumeCommand({ command, appId }) {
42
42
  }
43
43
  try {
44
44
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
45
- if (errore.isError(getClient)) {
45
+ if (getClient instanceof Error) {
46
46
  await command.editReply(getClient.message);
47
47
  return;
48
48
  }
@@ -116,7 +116,7 @@ export async function handleResumeAutocomplete({ interaction, appId, }) {
116
116
  }
117
117
  try {
118
118
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
119
- if (errore.isError(getClient)) {
119
+ if (getClient instanceof Error) {
120
120
  await interaction.respond([]);
121
121
  return;
122
122
  }
@@ -1,4 +1,4 @@
1
- // /session command - Start a new OpenCode session.
1
+ // /new-session command - Start a new OpenCode session.
2
2
  import { ChannelType } from 'discord.js';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
@@ -45,7 +45,7 @@ export async function handleSessionCommand({ command, appId }) {
45
45
  }
46
46
  try {
47
47
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
48
- if (errore.isError(getClient)) {
48
+ if (getClient instanceof Error) {
49
49
  await command.editReply(getClient.message);
50
50
  return;
51
51
  }
@@ -107,7 +107,7 @@ async function handleAgentAutocomplete({ interaction, appId }) {
107
107
  }
108
108
  try {
109
109
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
110
- if (errore.isError(getClient)) {
110
+ if (getClient instanceof Error) {
111
111
  await interaction.respond([]);
112
112
  return;
113
113
  }
@@ -174,7 +174,7 @@ export async function handleSessionAutocomplete({ interaction, appId, }) {
174
174
  }
175
175
  try {
176
176
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
177
- if (errore.isError(getClient)) {
177
+ if (getClient instanceof Error) {
178
178
  await interaction.respond([]);
179
179
  return;
180
180
  }
@@ -52,7 +52,7 @@ export async function handleShareCommand({ command }) {
52
52
  }
53
53
  const sessionId = row.session_id;
54
54
  const getClient = await initializeOpencodeForDirectory(directory);
55
- if (errore.isError(getClient)) {
55
+ if (getClient instanceof Error) {
56
56
  await command.reply({
57
57
  content: `Failed to share session: ${getClient.message}`,
58
58
  ephemeral: true,
@@ -53,7 +53,7 @@ export async function handleUndoCommand({ command }) {
53
53
  const sessionId = row.session_id;
54
54
  await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
55
55
  const getClient = await initializeOpencodeForDirectory(directory);
56
- if (errore.isError(getClient)) {
56
+ if (getClient instanceof Error) {
57
57
  await command.editReply(`Failed to undo: ${getClient.message}`);
58
58
  return;
59
59
  }
@@ -140,7 +140,7 @@ export async function handleRedoCommand({ command }) {
140
140
  const sessionId = row.session_id;
141
141
  await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
142
142
  const getClient = await initializeOpencodeForDirectory(directory);
143
- if (errore.isError(getClient)) {
143
+ if (getClient instanceof Error) {
144
144
  await command.editReply(`Failed to redo: ${getClient.message}`);
145
145
  return;
146
146
  }
@@ -0,0 +1,180 @@
1
+ // Worktree management command: /new-worktree
2
+ // Uses OpenCode SDK v2 to create worktrees with kimaki- prefix
3
+ // Creates thread immediately, then worktree in background so user can type
4
+ import { ChannelType } from 'discord.js';
5
+ import fs from 'node:fs';
6
+ import { createPendingWorktree, setWorktreeReady, setWorktreeError, } from '../database.js';
7
+ import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js';
8
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
9
+ import { extractTagsArrays } from '../xml.js';
10
+ import { createLogger } from '../logger.js';
11
+ import * as errore from 'errore';
12
+ const logger = createLogger('WORKTREE');
13
+ class WorktreeError extends Error {
14
+ constructor(message, options) {
15
+ super(message, options);
16
+ this.name = 'WorktreeError';
17
+ }
18
+ }
19
+ /**
20
+ * Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
21
+ * "My Feature" → "kimaki-my-feature"
22
+ */
23
+ function formatWorktreeName(name) {
24
+ const formatted = name
25
+ .toLowerCase()
26
+ .trim()
27
+ .replace(/\s+/g, '-')
28
+ .replace(/[^a-z0-9-]/g, '');
29
+ return `kimaki-${formatted}`;
30
+ }
31
+ /**
32
+ * Get project directory from channel topic.
33
+ */
34
+ function getProjectDirectoryFromChannel(channel, appId) {
35
+ if (!channel.topic) {
36
+ return new WorktreeError('This channel has no topic configured');
37
+ }
38
+ const extracted = extractTagsArrays({
39
+ xml: channel.topic,
40
+ tags: ['kimaki.directory', 'kimaki.app'],
41
+ });
42
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
43
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim();
44
+ if (channelAppId && channelAppId !== appId) {
45
+ return new WorktreeError('This channel is not configured for this bot');
46
+ }
47
+ if (!projectDirectory) {
48
+ return new WorktreeError('This channel is not configured with a project directory');
49
+ }
50
+ if (!fs.existsSync(projectDirectory)) {
51
+ return new WorktreeError(`Directory does not exist: ${projectDirectory}`);
52
+ }
53
+ return projectDirectory;
54
+ }
55
+ /**
56
+ * Create worktree in background and update starter message when done.
57
+ */
58
+ async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory, clientV2, }) {
59
+ // Create worktree using SDK v2
60
+ logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`);
61
+ const worktreeResult = await errore.tryAsync({
62
+ try: async () => {
63
+ const response = await clientV2.worktree.create({
64
+ directory: projectDirectory,
65
+ worktreeCreateInput: {
66
+ name: worktreeName,
67
+ },
68
+ });
69
+ if (response.error) {
70
+ throw new Error(`SDK error: ${JSON.stringify(response.error)}`);
71
+ }
72
+ if (!response.data) {
73
+ throw new Error('No worktree data returned from SDK');
74
+ }
75
+ return response.data;
76
+ },
77
+ catch: (e) => new WorktreeError('Failed to create worktree', { cause: e }),
78
+ });
79
+ if (errore.isError(worktreeResult)) {
80
+ const errorMsg = worktreeResult.message;
81
+ logger.error('[NEW-WORKTREE] Error:', worktreeResult.cause);
82
+ setWorktreeError({ threadId: thread.id, errorMessage: errorMsg });
83
+ await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\nāŒ ${errorMsg}`);
84
+ return;
85
+ }
86
+ // Success - update database and edit starter message
87
+ setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory });
88
+ await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n` +
89
+ `šŸ“ \`${worktreeResult.directory}\`\n` +
90
+ `🌿 Branch: \`${worktreeResult.branch}\``);
91
+ }
92
+ export async function handleNewWorktreeCommand({ command, appId, }) {
93
+ await command.deferReply({ ephemeral: false });
94
+ const rawName = command.options.getString('name', true);
95
+ const worktreeName = formatWorktreeName(rawName);
96
+ if (worktreeName === 'kimaki-') {
97
+ await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.');
98
+ return;
99
+ }
100
+ const channel = command.channel;
101
+ if (!channel || channel.type !== ChannelType.GuildText) {
102
+ await command.editReply('This command can only be used in text channels');
103
+ return;
104
+ }
105
+ const textChannel = channel;
106
+ const projectDirectory = getProjectDirectoryFromChannel(textChannel, appId);
107
+ if (errore.isError(projectDirectory)) {
108
+ await command.editReply(projectDirectory.message);
109
+ return;
110
+ }
111
+ // Initialize opencode and check if worktree already exists
112
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
113
+ if (errore.isError(getClient)) {
114
+ await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`);
115
+ return;
116
+ }
117
+ const clientV2 = getOpencodeClientV2(projectDirectory);
118
+ if (!clientV2) {
119
+ await command.editReply('Failed to get OpenCode client');
120
+ return;
121
+ }
122
+ // Check if worktree with this name already exists
123
+ // SDK returns array of directory paths like "~/.opencode/worktree/abc/kimaki-my-feature"
124
+ const listResult = await errore.tryAsync({
125
+ try: async () => {
126
+ const response = await clientV2.worktree.list({ directory: projectDirectory });
127
+ return response.data || [];
128
+ },
129
+ catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
130
+ });
131
+ if (errore.isError(listResult)) {
132
+ await command.editReply(listResult.message);
133
+ return;
134
+ }
135
+ // Check if any worktree path ends with our name
136
+ const existingWorktree = listResult.find((dir) => dir.endsWith(`/${worktreeName}`));
137
+ if (existingWorktree) {
138
+ await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktree}\``);
139
+ return;
140
+ }
141
+ // Create thread immediately so user can start typing
142
+ const result = await errore.tryAsync({
143
+ try: async () => {
144
+ const starterMessage = await textChannel.send({
145
+ content: `🌳 **Creating worktree: ${worktreeName}**\nā³ Setting up...`,
146
+ flags: SILENT_MESSAGE_FLAGS,
147
+ });
148
+ const thread = await starterMessage.startThread({
149
+ name: `worktree: ${worktreeName}`,
150
+ autoArchiveDuration: 1440,
151
+ reason: 'Worktree session',
152
+ });
153
+ return { thread, starterMessage };
154
+ },
155
+ catch: (e) => new WorktreeError('Failed to create thread', { cause: e }),
156
+ });
157
+ if (errore.isError(result)) {
158
+ logger.error('[NEW-WORKTREE] Error:', result.cause);
159
+ await command.editReply(result.message);
160
+ return;
161
+ }
162
+ const { thread, starterMessage } = result;
163
+ // Store pending worktree in database
164
+ createPendingWorktree({
165
+ threadId: thread.id,
166
+ worktreeName,
167
+ projectDirectory,
168
+ });
169
+ await command.editReply(`Creating worktree in ${thread.toString()}`);
170
+ // Create worktree in background (don't await)
171
+ createWorktreeInBackground({
172
+ thread,
173
+ starterMessage,
174
+ worktreeName,
175
+ projectDirectory,
176
+ clientV2,
177
+ }).catch((e) => {
178
+ logger.error('[NEW-WORKTREE] Background error:', e);
179
+ });
180
+ }