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.
- package/LICENSE +21 -0
- package/dist/cli.js +108 -51
- package/dist/commands/abort.js +1 -1
- package/dist/commands/add-project.js +2 -2
- package/dist/commands/agent.js +2 -2
- package/dist/commands/fork.js +2 -2
- package/dist/commands/model.js +2 -2
- package/dist/commands/remove-project.js +2 -2
- package/dist/commands/resume.js +2 -2
- package/dist/commands/session.js +4 -4
- package/dist/commands/share.js +1 -1
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/worktree.js +180 -0
- package/dist/database.js +49 -1
- package/dist/discord-bot.js +29 -4
- package/dist/discord-utils.js +36 -0
- package/dist/errors.js +86 -87
- package/dist/genai-worker.js +1 -1
- package/dist/interaction-handler.js +6 -2
- package/dist/markdown.js +5 -1
- package/dist/message-formatting.js +2 -2
- package/dist/opencode.js +4 -4
- package/dist/session-handler.js +2 -2
- package/dist/tools.js +3 -3
- package/dist/voice-handler.js +3 -3
- package/dist/voice.js +4 -4
- package/package.json +16 -16
- package/src/cli.ts +166 -85
- package/src/commands/abort.ts +1 -1
- package/src/commands/add-project.ts +2 -2
- package/src/commands/agent.ts +2 -2
- package/src/commands/fork.ts +2 -2
- package/src/commands/model.ts +2 -2
- package/src/commands/remove-project.ts +2 -2
- package/src/commands/resume.ts +2 -2
- package/src/commands/session.ts +4 -4
- package/src/commands/share.ts +1 -1
- package/src/commands/undo-redo.ts +2 -2
- package/src/commands/worktree.ts +243 -0
- package/src/database.ts +96 -1
- package/src/discord-bot.ts +30 -4
- package/src/discord-utils.ts +50 -0
- package/src/errors.ts +90 -160
- package/src/genai-worker.ts +1 -1
- package/src/interaction-handler.ts +7 -2
- package/src/markdown.ts +5 -4
- package/src/message-formatting.ts +2 -2
- package/src/opencode.ts +4 -4
- package/src/session-handler.ts +2 -2
- package/src/tools.ts +3 -3
- package/src/voice-handler.ts +3 -3
- 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 (
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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);
|
package/dist/commands/abort.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
68
|
+
if (getClient instanceof Error) {
|
|
69
69
|
await interaction.respond([]);
|
|
70
70
|
return;
|
|
71
71
|
}
|
package/dist/commands/agent.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
213
|
+
if (getClient instanceof Error) {
|
|
214
214
|
await command.editReply({ content: getClient.message });
|
|
215
215
|
return;
|
|
216
216
|
}
|
package/dist/commands/fork.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
131
|
+
if (getClient instanceof Error) {
|
|
132
132
|
await interaction.editReply(`Failed to fork session: ${getClient.message}`);
|
|
133
133
|
return;
|
|
134
134
|
}
|
package/dist/commands/model.js
CHANGED
|
@@ -78,7 +78,7 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
78
78
|
}
|
|
79
79
|
try {
|
|
80
80
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
81
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
91
|
+
if (channel instanceof Error) {
|
|
92
92
|
// Channel not in this guild, skip
|
|
93
93
|
continue;
|
|
94
94
|
}
|
package/dist/commands/resume.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function handleResumeCommand({ command, appId }) {
|
|
|
42
42
|
}
|
|
43
43
|
try {
|
|
44
44
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
45
|
-
if (
|
|
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 (
|
|
119
|
+
if (getClient instanceof Error) {
|
|
120
120
|
await interaction.respond([]);
|
|
121
121
|
return;
|
|
122
122
|
}
|
package/dist/commands/session.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
177
|
+
if (getClient instanceof Error) {
|
|
178
178
|
await interaction.respond([]);
|
|
179
179
|
return;
|
|
180
180
|
}
|
package/dist/commands/share.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|