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