kimaki 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/cli-commands/misc.js +76 -2
  2. package/dist/cli-runner.js +10 -29
  3. package/dist/cli.js +5 -0
  4. package/dist/commands/agent.js +116 -2
  5. package/dist/commands/gemini-apikey.js +24 -5
  6. package/dist/database.js +16 -0
  7. package/dist/discord-command-registration.js +9 -46
  8. package/dist/discord-utils.js +29 -0
  9. package/dist/discord-utils.test.js +68 -2
  10. package/dist/errors.js +5 -0
  11. package/dist/interaction-handler.js +78 -1
  12. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
  13. package/dist/opencode.js +162 -30
  14. package/dist/session-handler/thread-session-runtime.js +27 -3
  15. package/dist/store.js +1 -0
  16. package/dist/system-message.js +16 -0
  17. package/dist/voice-handler.js +7 -11
  18. package/dist/voice.js +126 -1
  19. package/package.json +5 -6
  20. package/skills/goke/SKILL.md +192 -0
  21. package/skills/new-skill/SKILL.md +1 -0
  22. package/skills/npm-package/SKILL.md +45 -0
  23. package/skills/spiceflow/SKILL.md +2 -0
  24. package/skills/tuistory/SKILL.md +48 -16
  25. package/src/cli-commands/misc.ts +90 -2
  26. package/src/cli-runner.ts +10 -29
  27. package/src/cli.ts +12 -0
  28. package/src/commands/agent.ts +147 -1
  29. package/src/commands/gemini-apikey.ts +38 -6
  30. package/src/database.ts +16 -0
  31. package/src/discord-command-registration.ts +11 -71
  32. package/src/discord-utils.test.ts +82 -2
  33. package/src/discord-utils.ts +34 -0
  34. package/src/errors.ts +9 -0
  35. package/src/interaction-handler.ts +78 -1
  36. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
  37. package/src/opencode.ts +157 -32
  38. package/src/session-handler/thread-session-runtime.ts +32 -3
  39. package/src/store.ts +8 -0
  40. package/src/system-message.ts +16 -0
  41. package/src/voice-handler.ts +5 -16
  42. package/src/voice.ts +217 -0
@@ -1,4 +1,4 @@
1
- // File upload terminal command for sharing local files into Discord threads.
1
+ // File upload and TTS terminal commands for sharing local files into Discord threads.
2
2
  import { goke } from 'goke';
3
3
  import { z } from 'zod';
4
4
  import { note } from '@clack/prompts';
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  import { spawn, execSync } from 'node:child_process';
12
12
  import { createLogger, LogPrefix, initLogFile } from '../logger.js';
13
13
  import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
14
- import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js';
14
+ import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getAnyAudioApiKey } from '../database.js';
15
15
  import { ShareMarkdown } from '../markdown.js';
16
16
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
17
17
  import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
@@ -73,4 +73,78 @@ cli
73
73
  process.exit(EXIT_NO_RESTART);
74
74
  }
75
75
  });
76
+ cli
77
+ .command('tts [text]', 'Generate speech audio from text. Reads from stdin if no text given.')
78
+ .option('-o, --output [path]', 'Output file path (default: speech.mp3)')
79
+ .option('-v, --voice [voice]', 'Voice ID, defaults to alloy (OpenAI) or Kore (Gemini)')
80
+ .option('-i, --instructions [text]', 'Style instructions, e.g. "Speak calmly" (OpenAI only)')
81
+ .option('--speed [speed]', 'Speed multiplier 0.25-4.0 (OpenAI only)')
82
+ .option('-p, --provider [provider]', 'openai or gemini (auto-detected from key)')
83
+ .option('-k, --api-key [key]', 'API key (falls back to stored key or env var)')
84
+ .action(async (text, options) => {
85
+ const { generateSpeech } = await import('../voice.js');
86
+ // Read from stdin if no positional text argument
87
+ if (!text) {
88
+ if (process.stdin.isTTY) {
89
+ cliLogger.error('No text provided. Pass text as argument or pipe via stdin.');
90
+ process.exit(EXIT_NO_RESTART);
91
+ }
92
+ const chunks = [];
93
+ for await (const chunk of process.stdin) {
94
+ chunks.push(chunk);
95
+ }
96
+ text = Buffer.concat(chunks).toString('utf-8').trim();
97
+ if (!text) {
98
+ cliLogger.error('Empty input from stdin.');
99
+ process.exit(EXIT_NO_RESTART);
100
+ }
101
+ }
102
+ await initDatabase();
103
+ // Resolve API key: flag → DB → env
104
+ let apiKey = options.apiKey;
105
+ let provider = options.provider === 'openai' || options.provider === 'gemini'
106
+ ? options.provider
107
+ : undefined;
108
+ if (!apiKey) {
109
+ const stored = await getAnyAudioApiKey();
110
+ if (stored) {
111
+ apiKey = stored.apiKey;
112
+ provider = provider || stored.provider;
113
+ }
114
+ }
115
+ if (!apiKey) {
116
+ if (process.env.OPENAI_API_KEY) {
117
+ apiKey = process.env.OPENAI_API_KEY;
118
+ provider = provider || 'openai';
119
+ }
120
+ else if (process.env.GEMINI_API_KEY) {
121
+ apiKey = process.env.GEMINI_API_KEY;
122
+ provider = provider || 'gemini';
123
+ }
124
+ }
125
+ if (!apiKey) {
126
+ cliLogger.error('No API key found. Use --api-key, set OPENAI_API_KEY/GEMINI_API_KEY, or run /transcription-key in Discord.');
127
+ process.exit(EXIT_NO_RESTART);
128
+ }
129
+ const speed = options.speed ? Number(options.speed) : 1.25;
130
+ cliLogger.log(`Generating speech with ${provider || 'auto-detected provider'}...`);
131
+ const result = await generateSpeech({
132
+ text,
133
+ voice: options.voice || undefined,
134
+ apiKey,
135
+ provider,
136
+ instructions: options.instructions || undefined,
137
+ speed,
138
+ });
139
+ if (result instanceof Error) {
140
+ cliLogger.error(`Speech generation failed: ${result.message}`);
141
+ process.exit(EXIT_NO_RESTART);
142
+ }
143
+ const ext = result.mediaType === 'audio/mp3' ? 'mp3' : 'wav';
144
+ const outputPath = options.output || `speech.${ext}`;
145
+ const resolvedOutput = path.resolve(outputPath);
146
+ await fs.promises.writeFile(resolvedOutput, result.audio);
147
+ cliLogger.log(`Audio saved to ${resolvedOutput} (${(result.audio.length / 1024).toFixed(1)} KB)`);
148
+ process.exit(0);
149
+ });
76
150
  export default cli;
@@ -938,35 +938,16 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
938
938
  startCaffeinate();
939
939
  const forceRestartOnboarding = Boolean(restartOnboarding);
940
940
  const forceGateway = Boolean(gateway);
941
- // Step 0: Ensure required CLI tools are installed (OpenCode + Bun).
942
- // Run checks in parallel since they're independent `which` calls.
943
- await Promise.all([
944
- ensureCommandAvailable({
945
- name: 'opencode',
946
- envPathKey: 'OPENCODE_PATH',
947
- installUnix: 'curl -fsSL https://opencode.ai/install | bash',
948
- installWindows: 'irm https://opencode.ai/install.ps1 | iex',
949
- possiblePathsUnix: [
950
- '~/.local/bin/opencode',
951
- '~/.opencode/bin/opencode',
952
- '/usr/local/bin/opencode',
953
- '/opt/opencode/bin/opencode',
954
- ],
955
- possiblePathsWindows: [
956
- '~\\.local\\bin\\opencode.exe',
957
- '~\\AppData\\Local\\opencode\\opencode.exe',
958
- '~\\.opencode\\bin\\opencode.exe',
959
- ],
960
- }),
961
- ensureCommandAvailable({
962
- name: 'bun',
963
- envPathKey: 'BUN_PATH',
964
- installUnix: 'curl -fsSL https://bun.sh/install | bash',
965
- installWindows: 'irm bun.sh/install.ps1 | iex',
966
- possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
967
- possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
968
- }),
969
- ]);
941
+ // Step 0: Ensure bun is installed. OpenCode is downloaded on first run
942
+ // to ~/.kimaki/bin/ (pinned version), so no install check is needed.
943
+ await ensureCommandAvailable({
944
+ name: 'bun',
945
+ envPathKey: 'BUN_PATH',
946
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
947
+ installWindows: 'irm bun.sh/install.ps1 | iex',
948
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
949
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
950
+ });
970
951
  void backgroundUpgradeKimaki();
971
952
  // Start in-process Hrana server before database init. Required for the bot
972
953
  // process because it serves as both the DB server and the single-instance
package/dist/cli.js CHANGED
@@ -39,6 +39,7 @@ cli
39
39
  .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
40
40
  .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
41
41
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
42
+ .option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
42
43
  .option('--no-sentry', 'Disable Sentry error reporting')
43
44
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
44
45
  .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
@@ -134,6 +135,7 @@ cli
134
135
  }),
135
136
  ...(options.mentionMode && { defaultMentionMode: true }),
136
137
  ...(options.noCritique && { critiqueEnabled: false }),
138
+ ...(options.allowAllUsers && { allowAllUsers: true }),
137
139
  ...(enabledSkills.length > 0 && { enabledSkills }),
138
140
  ...(disabledSkills.length > 0 && { disabledSkills }),
139
141
  });
@@ -143,6 +145,9 @@ cli
143
145
  if (disabledSkills.length > 0) {
144
146
  cliLogger.log(`Skill blacklist enabled: [${disabledSkills.join(', ')}] will be hidden`);
145
147
  }
148
+ if (options.allowAllUsers) {
149
+ cliLogger.log('Allow all users: any Discord member can start sessions (no-kimaki role still blocks)');
150
+ }
146
151
  if (options.verbosity) {
147
152
  cliLogger.log(`Default verbosity: ${options.verbosity}`);
148
153
  }
@@ -1,10 +1,13 @@
1
1
  // /agent command - Set the preferred agent for this channel or session.
2
2
  // Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
3
- import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
3
+ // When a prompt is provided to a quick agent command (e.g. /plan-agent "fix the bug"),
4
+ // the prompt is sent as a one-shot with that agent without switching the persistent preference.
5
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
4
6
  import crypto from 'node:crypto';
5
7
  import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
6
8
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
- import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
9
+ import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
10
+ import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
8
11
  import { createLogger, LogPrefix } from '../logger.js';
9
12
  import { getCurrentModelInfo } from './model.js';
10
13
  const agentLogger = createLogger(LogPrefix.AGENT);
@@ -286,6 +289,13 @@ export async function handleAgentSelectMenu(interaction) {
286
289
  */
287
290
  export async function handleQuickAgentCommand({ command, appId, }) {
288
291
  const fallbackAgentName = command.commandName.replace(/-agent$/, '');
292
+ const prompt = command.options.getString('prompt') || undefined;
293
+ // One-shot prompt mode: send the prompt with a temporary agent override
294
+ // without changing the persistent agent preference.
295
+ if (prompt) {
296
+ return handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt });
297
+ }
298
+ // No prompt: switch the persistent agent preference (original behavior).
289
299
  await command.deferReply({ flags: MessageFlags.Ephemeral });
290
300
  const context = await resolveAgentCommandContext({
291
301
  interaction: command,
@@ -352,3 +362,107 @@ export async function handleQuickAgentCommand({ command, appId, }) {
352
362
  });
353
363
  }
354
364
  }
365
+ /**
366
+ * Handle one-shot prompt mode: send a prompt with a temporary agent override.
367
+ * In a thread: enqueue the prompt with the agent override on the existing session.
368
+ * In a channel: create a new thread and enqueue the prompt with the agent override.
369
+ * Neither case changes the persistent agent preference.
370
+ */
371
+ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt, }) {
372
+ const channel = command.channel;
373
+ if (!channel) {
374
+ await command.reply({
375
+ content: 'This command can only be used in a channel',
376
+ flags: MessageFlags.Ephemeral,
377
+ });
378
+ return;
379
+ }
380
+ const resolvedAgentName = (await resolveQuickAgentNameFromInteraction({ command })) ||
381
+ fallbackAgentName;
382
+ const isThread = [
383
+ ChannelType.PublicThread,
384
+ ChannelType.PrivateThread,
385
+ ChannelType.AnnouncementThread,
386
+ ].includes(channel.type);
387
+ const displayText = `${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
388
+ if (isThread) {
389
+ // In a thread: enqueue the prompt on the existing session with agent override.
390
+ const thread = channel;
391
+ const resolved = await resolveWorkingDirectory({ channel: thread });
392
+ if (!resolved) {
393
+ await command.reply({
394
+ content: 'Could not determine project directory for this channel',
395
+ flags: MessageFlags.Ephemeral,
396
+ });
397
+ return;
398
+ }
399
+ const runtime = getOrCreateRuntime({
400
+ threadId: thread.id,
401
+ thread,
402
+ projectDirectory: resolved.projectDirectory,
403
+ sdkDirectory: resolved.workingDirectory,
404
+ channelId: thread.parentId || thread.id,
405
+ appId,
406
+ });
407
+ // Visible reply showing the one-shot prompt (not ephemeral, so it appears in thread).
408
+ await command.reply({
409
+ content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
410
+ flags: SILENT_MESSAGE_FLAGS,
411
+ });
412
+ await runtime.enqueueIncoming({
413
+ prompt,
414
+ userId: command.user.id,
415
+ username: command.user.displayName,
416
+ agent: resolvedAgentName,
417
+ appId,
418
+ mode: 'opencode',
419
+ });
420
+ }
421
+ else if (channel.type === ChannelType.GuildText) {
422
+ // In a channel: create a new thread and enqueue with the agent override.
423
+ const textChannel = channel;
424
+ const metadata = await getKimakiMetadata(textChannel);
425
+ const projectDirectory = metadata.projectDirectory;
426
+ if (!projectDirectory) {
427
+ await command.reply({
428
+ content: 'This channel is not configured with a project directory',
429
+ flags: MessageFlags.Ephemeral,
430
+ });
431
+ return;
432
+ }
433
+ await command.deferReply();
434
+ const starterMessage = await textChannel.send({
435
+ content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
436
+ flags: SILENT_MESSAGE_FLAGS,
437
+ });
438
+ const thread = await starterMessage.startThread({
439
+ name: prompt.slice(0, 80),
440
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
441
+ reason: `One-shot ${resolvedAgentName} agent prompt`,
442
+ });
443
+ await thread.members.add(command.user.id);
444
+ await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`);
445
+ const runtime = getOrCreateRuntime({
446
+ threadId: thread.id,
447
+ thread,
448
+ projectDirectory,
449
+ sdkDirectory: projectDirectory,
450
+ channelId: textChannel.id,
451
+ appId,
452
+ });
453
+ await runtime.enqueueIncoming({
454
+ prompt,
455
+ userId: command.user.id,
456
+ username: command.user.displayName,
457
+ agent: resolvedAgentName,
458
+ appId,
459
+ mode: 'opencode',
460
+ });
461
+ }
462
+ else {
463
+ await command.reply({
464
+ content: 'This command can only be used in text channels or threads',
465
+ flags: MessageFlags.Ephemeral,
466
+ });
467
+ }
468
+ }
@@ -1,11 +1,13 @@
1
- // Transcription API key button, slash command, and modal handlers.
1
+ // Audio API key button, slash command, and modal handlers.
2
+ // Used for both transcription and speech generation — same OpenAI/Gemini keys.
2
3
  // Auto-detects provider from key prefix: sk-* = OpenAI, otherwise Gemini.
3
- import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags, } from 'discord.js';
4
+ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags, } from 'discord.js';
4
5
  import { setGeminiApiKey, setOpenAIApiKey } from '../database.js';
6
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
5
7
  function buildTranscriptionApiKeyModal(appId) {
6
8
  const modal = new ModalBuilder()
7
9
  .setCustomId(`transcription_apikey_modal:${appId}`)
8
- .setTitle('Transcription API Key');
10
+ .setTitle('Audio API Key');
9
11
  const apiKeyInput = new TextInputBuilder()
10
12
  .setCustomId('apikey')
11
13
  .setLabel('OpenAI or Gemini API Key')
@@ -16,6 +18,23 @@ function buildTranscriptionApiKeyModal(appId) {
16
18
  modal.addComponents(actionRow);
17
19
  return modal;
18
20
  }
21
+ /**
22
+ * Show a "Set API Key" button in a Discord thread.
23
+ * Reusable for both transcription and TTS — both use the same stored keys.
24
+ * The button opens a modal where the user can enter an OpenAI or Gemini key.
25
+ */
26
+ export async function showApiKeyRequiredButton({ thread, appId, message, }) {
27
+ const button = new ButtonBuilder()
28
+ .setCustomId(`transcription_apikey:${appId}`)
29
+ .setLabel('Set API Key')
30
+ .setStyle(ButtonStyle.Primary);
31
+ const row = new ActionRowBuilder().addComponents(button);
32
+ await thread.send({
33
+ content: message || 'An API key (OpenAI or Gemini) is required. Set one to continue.',
34
+ components: [row],
35
+ flags: SILENT_MESSAGE_FLAGS,
36
+ });
37
+ }
19
38
  export async function handleTranscriptionApiKeyButton(interaction) {
20
39
  if (!interaction.customId.startsWith('transcription_apikey:'))
21
40
  return;
@@ -58,13 +77,13 @@ export async function handleTranscriptionApiKeyModalSubmit(interaction) {
58
77
  if (apiKey.startsWith('sk-')) {
59
78
  await setOpenAIApiKey(appId, apiKey);
60
79
  await interaction.editReply({
61
- content: 'OpenAI API key saved. Voice messages will be transcribed with OpenAI.',
80
+ content: 'OpenAI API key saved. Voice transcription and speech generation are now enabled.',
62
81
  });
63
82
  }
64
83
  else {
65
84
  await setGeminiApiKey(appId, apiKey);
66
85
  await interaction.editReply({
67
- content: 'Gemini API key saved. Voice messages will be transcribed with Gemini.',
86
+ content: 'Gemini API key saved. Voice transcription and speech generation are now enabled.',
68
87
  });
69
88
  }
70
89
  }
package/dist/database.js CHANGED
@@ -500,6 +500,22 @@ export async function getTranscriptionApiKey(appId) {
500
500
  return { provider: 'gemini', apiKey: row.gemini_api_key };
501
501
  return null;
502
502
  }
503
+ /**
504
+ * Get any stored audio API key (OpenAI or Gemini) without requiring a specific appId.
505
+ * Used by the plugin process which doesn't have direct access to the bot's appId.
506
+ * Returns the first available key found, preferring OpenAI.
507
+ */
508
+ export async function getAnyAudioApiKey() {
509
+ const db = await getDb();
510
+ const row = await db.query.bot_api_keys.findFirst();
511
+ if (!row)
512
+ return null;
513
+ if (row.openai_api_key)
514
+ return { provider: 'openai', apiKey: row.openai_api_key, appId: row.app_id };
515
+ if (row.gemini_api_key)
516
+ return { provider: 'gemini', apiKey: row.gemini_api_key, appId: row.app_id };
517
+ return null;
518
+ }
503
519
  export async function setChannelDirectory({ channelId, directory, channelType, skipIfExists = false }) {
504
520
  const db = await getDb();
505
521
  if (skipIfExists) {
@@ -19,46 +19,13 @@ function getDiscordCommandSuffix(command) {
19
19
  }
20
20
  return '-cmd';
21
21
  }
22
- function isDiscordCommandSummary(value) {
23
- if (typeof value !== 'object' || value === null) {
24
- return false;
25
- }
26
- const id = Reflect.get(value, 'id');
27
- const name = Reflect.get(value, 'name');
28
- return typeof id === 'string' && typeof name === 'string';
29
- }
30
- async function deleteLegacyGlobalCommands({ rest, appId, commandNames, }) {
22
+ async function clearGlobalCommands({ rest, appId, }) {
31
23
  try {
32
- const response = await rest.get(Routes.applicationCommands(appId));
33
- if (!Array.isArray(response)) {
34
- cliLogger.warn('COMMANDS: Unexpected global command payload while cleaning legacy global commands');
35
- return;
36
- }
37
- const legacyGlobalCommands = response
38
- .filter(isDiscordCommandSummary)
39
- .filter((command) => {
40
- return commandNames.has(command.name);
41
- });
42
- if (legacyGlobalCommands.length === 0) {
43
- return;
44
- }
45
- const deletionResults = await Promise.allSettled(legacyGlobalCommands.map(async (command) => {
46
- await rest.delete(Routes.applicationCommand(appId, command.id));
47
- return command;
48
- }));
49
- const failedDeletions = deletionResults.filter((result) => {
50
- return result.status === 'rejected';
51
- });
52
- if (failedDeletions.length > 0) {
53
- cliLogger.warn(`COMMANDS: Failed to delete ${failedDeletions.length} legacy global command(s)`);
54
- }
55
- const deletedCount = deletionResults.length - failedDeletions.length;
56
- if (deletedCount > 0) {
57
- cliLogger.info(`COMMANDS: Deleted ${deletedCount} legacy global command(s) to avoid guild/global duplicates`);
58
- }
24
+ await rest.put(Routes.applicationCommands(appId), { body: [] });
25
+ cliLogger.info('COMMANDS: Cleared global slash commands');
59
26
  }
60
27
  catch (error) {
61
- cliLogger.warn(`COMMANDS: Could not clean legacy global commands: ${error instanceof Error ? error.stack : String(error)}`);
28
+ cliLogger.warn(`COMMANDS: Could not clear global slash commands: ${error instanceof Error ? error.stack : String(error)}`);
62
29
  }
63
30
  }
64
31
  // Discord slash command descriptions must be 1-100 chars.
@@ -426,6 +393,10 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
426
393
  .setName(commandName)
427
394
  .setDescription(truncateCommandDescription(description))
428
395
  .setDMPermission(false)
396
+ .addStringOption((opt) => opt
397
+ .setName('prompt')
398
+ .setDescription('Send a one-shot prompt with this agent without switching')
399
+ .setRequired(false))
429
400
  .toJSON());
430
401
  }
431
402
  // 2. User-defined commands, skills, and MCP prompts (ordered by priority)
@@ -488,13 +459,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
488
459
  }
489
460
  const rest = createDiscordRest(token);
490
461
  const uniqueGuildIds = Array.from(new Set(guildIds.filter((guildId) => guildId)));
491
- const guildCommandNames = new Set(commands
492
- .map((command) => {
493
- return command.name;
494
- })
495
- .filter((name) => {
496
- return typeof name === 'string';
497
- }));
498
462
  if (uniqueGuildIds.length === 0) {
499
463
  cliLogger.warn('COMMANDS: No guilds available, skipping slash command registration');
500
464
  return;
@@ -543,10 +507,9 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
543
507
  // exist for self-hosted bots that previously registered commands globally.
544
508
  const isGateway = store.getState().discordBaseUrl !== 'https://discord.com';
545
509
  if (!isGateway) {
546
- await deleteLegacyGlobalCommands({
510
+ await clearGlobalCommands({
547
511
  rest,
548
512
  appId,
549
- commandNames: guildCommandNames,
550
513
  });
551
514
  }
552
515
  cliLogger.info(`COMMANDS: Successfully registered ${registeredCommandCount} slash commands for ${successfulGuilds} guild(s)`);
@@ -14,6 +14,7 @@ import { limitHeadingDepth } from './limit-heading-depth.js';
14
14
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
15
15
  import { createLogger, LogPrefix } from './logger.js';
16
16
  import * as errore from 'errore';
17
+ import { store } from './store.js';
17
18
  import mime from 'mime';
18
19
  import fs from 'node:fs';
19
20
  import path from 'node:path';
@@ -32,6 +33,34 @@ export function hasKimakiBotPermission(member, guild) {
32
33
  if (hasNoKimakiRole) {
33
34
  return false;
34
35
  }
36
+ if (store.getState().allowAllUsers) {
37
+ return true;
38
+ }
39
+ const memberPermissions = member instanceof GuildMember
40
+ ? member.permissions
41
+ : new PermissionsBitField(BigInt(member.permissions));
42
+ const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId;
43
+ const memberId = member instanceof GuildMember ? member.id : member.user.id;
44
+ const isOwner = ownerId ? memberId === ownerId : false;
45
+ const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator);
46
+ const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild);
47
+ const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
48
+ return isOwner || isAdmin || canManageServer || hasKimakiRole;
49
+ }
50
+ /**
51
+ * Stricter permission check that ignores allowAllUsers.
52
+ * Use for admin-only commands like /login and /transcription-key that
53
+ * configure shared credentials. Always requires owner, admin, manage
54
+ * server, or Kimaki role regardless of --allow-all-users flag.
55
+ */
56
+ export function hasKimakiAdminPermission(member, guild) {
57
+ if (!member) {
58
+ return false;
59
+ }
60
+ const hasNoKimaki = hasRoleByName(member, 'no-kimaki', guild);
61
+ if (hasNoKimaki) {
62
+ return false;
63
+ }
35
64
  const memberPermissions = member instanceof GuildMember
36
65
  ? member.permissions
37
66
  : new PermissionsBitField(BigInt(member.permissions));
@@ -1,6 +1,7 @@
1
1
  import { PermissionsBitField } from 'discord.js';
2
- import { describe, expect, test } from 'vitest';
3
- import { hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
2
+ import { afterEach, describe, expect, test } from 'vitest';
3
+ import { hasKimakiAdminPermission, hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
4
+ import { store } from './store.js';
4
5
  describe('splitMarkdownForDiscord', () => {
5
6
  test('never returns chunks over the max length with code fences', () => {
6
7
  const maxLength = 2000;
@@ -90,6 +91,40 @@ describe('splitMarkdownForDiscord', () => {
90
91
  });
91
92
  });
92
93
  describe('hasKimakiBotPermission', () => {
94
+ afterEach(() => {
95
+ store.setState({ allowAllUsers: false });
96
+ });
97
+ test('allows any member when allowAllUsers is enabled', () => {
98
+ store.setState({ allowAllUsers: true });
99
+ const guild = {
100
+ ownerId: 'owner-id',
101
+ roles: { cache: new Map() },
102
+ };
103
+ const member = {
104
+ user: { id: 'member-id' },
105
+ permissions: '0',
106
+ roles: [],
107
+ };
108
+ expect(hasKimakiBotPermission(member, guild)).toBe(true);
109
+ });
110
+ test('still blocks no-kimaki role even when allowAllUsers is enabled', () => {
111
+ store.setState({ allowAllUsers: true });
112
+ const noKimakiRoleId = '222';
113
+ const guild = {
114
+ ownerId: 'owner-id',
115
+ roles: {
116
+ cache: new Map([
117
+ [noKimakiRoleId, { id: noKimakiRoleId, name: 'no-kimaki' }],
118
+ ]),
119
+ },
120
+ };
121
+ const member = {
122
+ user: { id: 'member-id' },
123
+ permissions: '0',
124
+ roles: [noKimakiRoleId],
125
+ };
126
+ expect(hasKimakiBotPermission(member, guild)).toBe(false);
127
+ });
93
128
  test('allows API interaction member when kimaki role exists', () => {
94
129
  const kimakiRoleId = '111';
95
130
  const guild = {
@@ -132,3 +167,34 @@ describe('hasKimakiBotPermission', () => {
132
167
  expect(hasKimakiBotPermission(member, guild)).toBe(false);
133
168
  });
134
169
  });
170
+ describe('hasKimakiAdminPermission', () => {
171
+ afterEach(() => {
172
+ store.setState({ allowAllUsers: false });
173
+ });
174
+ test('denies unprivileged member even when allowAllUsers is enabled', () => {
175
+ store.setState({ allowAllUsers: true });
176
+ const guild = {
177
+ ownerId: 'owner-id',
178
+ roles: { cache: new Map() },
179
+ };
180
+ const member = {
181
+ user: { id: 'member-id' },
182
+ permissions: '0',
183
+ roles: [],
184
+ };
185
+ expect(hasKimakiAdminPermission(member, guild)).toBe(false);
186
+ });
187
+ test('allows admin even when allowAllUsers is enabled', () => {
188
+ store.setState({ allowAllUsers: true });
189
+ const guild = {
190
+ ownerId: 'owner-id',
191
+ roles: { cache: new Map() },
192
+ };
193
+ const member = {
194
+ user: { id: 'member-id' },
195
+ permissions: PermissionsBitField.Flags.Administrator.toString(),
196
+ roles: [],
197
+ };
198
+ expect(hasKimakiAdminPermission(member, guild)).toBe(true);
199
+ });
200
+ });
package/dist/errors.js CHANGED
@@ -58,6 +58,11 @@ export class TranscriptionError extends createTaggedError({
58
58
  message: 'Transcription failed: $reason',
59
59
  }) {
60
60
  }
61
+ export class SpeechGenerationError extends createTaggedError({
62
+ name: 'SpeechGenerationError',
63
+ message: 'Speech generation failed: $reason',
64
+ }) {
65
+ }
61
66
  export class GrepSearchError extends createTaggedError({
62
67
  name: 'GrepSearchError',
63
68
  message: 'Grep search failed for pattern: $pattern',