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.
- package/dist/cli.js +307 -91
- package/dist/commands/create-new-project.js +1 -0
- package/dist/commands/diff.js +130 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +96 -9
- package/dist/commands/model.js +147 -19
- package/dist/commands/permissions.js +31 -36
- package/dist/commands/queue.js +3 -1
- package/dist/commands/session.js +1 -0
- package/dist/commands/unset-model.js +149 -0
- package/dist/commands/user-command.js +2 -0
- package/dist/commands/verbosity.js +2 -1
- package/dist/commands/worktree.js +7 -3
- package/dist/config.js +10 -1
- package/dist/database.js +55 -33
- package/dist/discord-bot.js +117 -23
- package/dist/discord-utils.js +40 -0
- package/dist/discord-utils.test.js +37 -0
- package/dist/generated/internal/class.js +2 -2
- package/dist/generated/internal/prismaNamespace.js +15 -6
- package/dist/generated/internal/prismaNamespaceBrowser.js +15 -6
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/interaction-handler.js +24 -6
- package/dist/logger.js +1 -0
- package/dist/message-formatting.js +30 -7
- package/dist/opencode-plugin.js +266 -52
- package/dist/opencode.js +36 -10
- package/dist/session-handler.js +84 -51
- package/dist/system-message.js +68 -15
- package/dist/unnest-code-blocks.test.js +26 -0
- package/dist/voice.js +7 -1
- package/dist/worktree-utils.js +74 -2
- package/package.json +10 -9
- package/src/__snapshots__/compact-session-context-no-system.md +30 -30
- package/src/__snapshots__/compact-session-context.md +41 -47
- package/src/__snapshots__/first-session-no-info.md +3991 -1174
- package/src/__snapshots__/first-session-with-info.md +3994 -1177
- package/src/__snapshots__/session-1.md +3991 -1174
- package/src/__snapshots__/session-2.md +4 -276
- package/src/__snapshots__/session-3.md +4182 -18879
- package/src/__snapshots__/session-with-tools.md +3991 -1174
- package/src/cli.ts +353 -114
- package/src/commands/create-new-project.ts +1 -0
- package/src/commands/diff.ts +148 -0
- package/src/commands/mention-mode.ts +72 -0
- package/src/commands/merge-worktree.ts +130 -9
- package/src/commands/model.ts +187 -25
- package/src/commands/permissions.ts +44 -42
- package/src/commands/queue.ts +3 -1
- package/src/commands/session.ts +1 -0
- package/src/commands/unset-model.ts +183 -0
- package/src/commands/user-command.ts +2 -0
- package/src/commands/verbosity.ts +2 -1
- package/src/commands/worktree.ts +10 -2
- package/src/config.ts +13 -1
- package/src/database.ts +61 -36
- package/src/discord-bot.ts +130 -25
- package/src/discord-utils.test.ts +39 -0
- package/src/discord-utils.ts +52 -0
- package/src/generated/browser.ts +10 -5
- package/src/generated/client.ts +10 -5
- package/src/generated/internal/class.ts +22 -12
- package/src/generated/internal/prismaNamespace.ts +174 -86
- package/src/generated/internal/prismaNamespaceBrowser.ts +23 -10
- package/src/generated/models/bot_tokens.ts +100 -0
- package/src/generated/models/channel_directories.ts +128 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/global_models.ts +1256 -0
- package/src/generated/models/thread_sessions.ts +0 -100
- package/src/generated/models.ts +2 -1
- package/src/interaction-handler.ts +33 -6
- package/src/logger.ts +1 -0
- package/src/message-formatting.ts +36 -8
- package/src/opencode-plugin.ts +319 -0
- package/src/opencode.ts +43 -10
- package/src/schema.sql +15 -5
- package/src/session-handler.ts +99 -57
- package/src/system-message.ts +94 -14
- package/src/unnest-code-blocks.test.ts +27 -0
- package/src/voice.ts +7 -1
- package/src/worktree-utils.ts +84 -2
- 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,
|
|
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
|
-
|
|
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
|
|
958
|
-
|
|
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 (
|
|
1004
|
-
const absolutePath = path.resolve(
|
|
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
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
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
|
|
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
|
|
1126
|
-
const
|
|
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
|
-
:
|
|
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
|
-
//
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|