kimaki 0.4.78 → 0.4.80

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 (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -1,18 +1,35 @@
1
- // /login command - Authenticate with AI providers (OAuth or API key).
2
- // Supports GitHub Copilot (device flow), OpenAI Codex (device flow), and API keys.
3
- import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ModalSubmitInteraction, ChannelType, MessageFlags, } from 'discord.js';
1
+ // /login command authenticate with AI providers (OAuth or API key).
2
+ //
3
+ // Uses a unified select handler (`login_select:<hash>`) for all sequential
4
+ // select menus (provider → method → plugin prompts). The context tracks a
5
+ // `step` field so one handler drives the whole flow.
6
+ //
7
+ // CustomId patterns:
8
+ // login_select:<hash> — all select menus (provider, method, prompts)
9
+ // login_apikey:<hash> — API key modal submission
10
+ // login_text:<hash> — text prompt modal submission
11
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ModalSubmitInteraction, ButtonBuilder, ButtonStyle, ChannelType, MessageFlags, } from 'discord.js';
4
12
  import crypto from 'node:crypto';
5
- import { initializeOpencodeForDirectory } from '../opencode.js';
13
+ import { initializeOpencodeForDirectory, getOpencodeServerPort, } from '../opencode.js';
6
14
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
15
  import { createLogger, LogPrefix } from '../logger.js';
16
+ import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
8
17
  const loginLogger = createLogger(LogPrefix.LOGIN);
9
- // Store context by hash to avoid customId length limits (Discord max: 100 chars).
10
- // TTL'd to prevent unbounded growth when users open /login and never interact.
18
+ // ── Context store ───────────────────────────────────────────────
19
+ // Keyed by random hash to stay under Discord's 100-char customId limit.
20
+ // TTL prevents unbounded growth when users open /login and never interact.
11
21
  const LOGIN_CONTEXT_TTL_MS = 10 * 60 * 1000;
12
22
  const pendingLoginContexts = new Map();
13
- // Popularity-ordered provider IDs for the select menu.
14
- // Discord select menus cap at 25 options, so we show these first,
15
- // then fill remaining slots with unlisted providers alphabetically.
23
+ function createContextHash(context) {
24
+ const hash = crypto.randomBytes(8).toString('hex');
25
+ pendingLoginContexts.set(hash, context);
26
+ setTimeout(() => {
27
+ pendingLoginContexts.delete(hash);
28
+ }, LOGIN_CONTEXT_TTL_MS).unref();
29
+ return hash;
30
+ }
31
+ // ── Provider popularity order ───────────────────────────────────
32
+ // Discord select menus cap at 25 options, so we show popular ones first.
16
33
  // IDs sourced from opencode's provider.list() API (scripts/list-providers.ts).
17
34
  const PROVIDER_POPULARITY_ORDER = [
18
35
  'anthropic',
@@ -41,15 +58,38 @@ const PROVIDER_POPULARITY_ORDER = [
41
58
  'lmstudio',
42
59
  'llama',
43
60
  ];
44
- /**
45
- * Handle the /login slash command.
46
- * Shows a select menu with available providers.
47
- */
61
+ // ── Helpers ─────────────────────────────────────────────────────
62
+ function extractErrorMessage({ error, fallback, }) {
63
+ if (!error || typeof error !== 'object') {
64
+ return fallback;
65
+ }
66
+ const parsed = error;
67
+ return parsed.data?.message || parsed.message || fallback;
68
+ }
69
+ function shouldShowPrompt(prompt, inputs) {
70
+ if (!prompt.when) {
71
+ return true;
72
+ }
73
+ const value = inputs[prompt.when.key];
74
+ if (prompt.when.op === 'eq') {
75
+ return value === prompt.when.value;
76
+ }
77
+ if (prompt.when.op === 'neq') {
78
+ return value !== prompt.when.value;
79
+ }
80
+ return true;
81
+ }
82
+ function buildSelectMenu({ customId, placeholder, options, }) {
83
+ const menu = new StringSelectMenuBuilder()
84
+ .setCustomId(customId)
85
+ .setPlaceholder(placeholder)
86
+ .addOptions(options);
87
+ return new ActionRowBuilder().addComponents(menu);
88
+ }
89
+ // ── /login command ──────────────────────────────────────────────
48
90
  export async function handleLoginCommand({ interaction, }) {
49
91
  loginLogger.log('[LOGIN] handleLoginCommand called');
50
- // Defer reply immediately to avoid 3-second timeout
51
92
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
52
- loginLogger.log('[LOGIN] Deferred reply');
53
93
  const channel = interaction.channel;
54
94
  if (!channel) {
55
95
  await interaction.editReply({
@@ -57,7 +97,6 @@ export async function handleLoginCommand({ interaction, }) {
57
97
  });
58
98
  return;
59
99
  }
60
- // Determine if we're in a thread or text channel
61
100
  const isThread = [
62
101
  ChannelType.PublicThread,
63
102
  ChannelType.PrivateThread,
@@ -100,21 +139,15 @@ export async function handleLoginCommand({ interaction, }) {
100
139
  directory: projectDirectory,
101
140
  });
102
141
  if (!providersResponse.data) {
103
- await interaction.editReply({
104
- content: 'Failed to fetch providers',
105
- });
142
+ await interaction.editReply({ content: 'Failed to fetch providers' });
106
143
  return;
107
144
  }
108
145
  const { all: allProviders, connected } = providersResponse.data;
109
146
  if (allProviders.length === 0) {
110
- await interaction.editReply({
111
- content: 'No providers available.',
112
- });
147
+ await interaction.editReply({ content: 'No providers available.' });
113
148
  return;
114
149
  }
115
- // Sort by hardcoded popularity order, then alphabetically for unlisted ones.
116
- // Discord select menus cap at 25, so we show the most popular providers.
117
- const options = [...allProviders]
150
+ const allProviderOptions = [...allProviders]
118
151
  .sort((a, b) => {
119
152
  const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id);
120
153
  const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id);
@@ -125,7 +158,6 @@ export async function handleLoginCommand({ interaction, }) {
125
158
  }
126
159
  return a.name.localeCompare(b.name);
127
160
  })
128
- .slice(0, 25)
129
161
  .map((provider) => {
130
162
  const isConnected = connected.includes(provider.id);
131
163
  return {
@@ -136,24 +168,27 @@ export async function handleLoginCommand({ interaction, }) {
136
168
  : 'Not connected',
137
169
  };
138
170
  });
139
- // Store context with a short hash key to avoid customId length limits
171
+ const { options } = buildPaginatedOptions({
172
+ allOptions: allProviderOptions,
173
+ page: 0,
174
+ });
140
175
  const context = {
141
176
  dir: projectDirectory,
142
177
  channelId: targetChannelId,
178
+ steps: [{ type: 'provider' }],
179
+ stepIndex: 0,
180
+ inputs: {},
143
181
  };
144
- const contextHash = crypto.randomBytes(8).toString('hex');
145
- pendingLoginContexts.set(contextHash, context);
146
- setTimeout(() => {
147
- pendingLoginContexts.delete(contextHash);
148
- }, LOGIN_CONTEXT_TTL_MS).unref();
149
- const selectMenu = new StringSelectMenuBuilder()
150
- .setCustomId(`login_provider:${contextHash}`)
151
- .setPlaceholder('Select a provider to authenticate')
152
- .addOptions(options);
153
- const actionRow = new ActionRowBuilder().addComponents(selectMenu);
182
+ const hash = createContextHash(context);
154
183
  await interaction.editReply({
155
184
  content: '**Authenticate with Provider**\nSelect a provider:',
156
- components: [actionRow],
185
+ components: [
186
+ buildSelectMenu({
187
+ customId: `login_select:${hash}`,
188
+ placeholder: 'Select a provider to authenticate',
189
+ options,
190
+ }),
191
+ ],
157
192
  });
158
193
  }
159
194
  catch (error) {
@@ -163,18 +198,17 @@ export async function handleLoginCommand({ interaction, }) {
163
198
  });
164
199
  }
165
200
  }
166
- /**
167
- * Handle the provider select menu interaction.
168
- * Shows a second select menu with auth methods for the chosen provider.
169
- */
170
- export async function handleLoginProviderSelectMenu(interaction) {
171
- const customId = interaction.customId;
172
- if (!customId.startsWith('login_provider:')) {
173
- return;
174
- }
175
- const contextHash = customId.replace('login_provider:', '');
176
- const context = pendingLoginContexts.get(contextHash);
177
- if (!context) {
201
+ // ── Unified select handler ──────────────────────────────────────
202
+ // Handles all select menu interactions for the login flow.
203
+ // Reads the current step from context, processes the answer,
204
+ // then either shows the next step or proceeds to authorize/API key.
205
+ export async function handleLoginSelect(interaction) {
206
+ if (!interaction.customId.startsWith('login_select:')) {
207
+ return;
208
+ }
209
+ const hash = interaction.customId.replace('login_select:', '');
210
+ const ctx = pendingLoginContexts.get(hash);
211
+ if (!ctx) {
178
212
  await interaction.deferUpdate();
179
213
  await interaction.editReply({
180
214
  content: 'Selection expired. Please run /login again.',
@@ -182,189 +216,391 @@ export async function handleLoginProviderSelectMenu(interaction) {
182
216
  });
183
217
  return;
184
218
  }
185
- const selectedProviderId = interaction.values[0];
186
- if (!selectedProviderId) {
219
+ const value = interaction.values[0];
220
+ if (!value) {
221
+ await interaction.deferUpdate();
222
+ await interaction.editReply({
223
+ content: 'No option selected.',
224
+ components: [],
225
+ });
226
+ return;
227
+ }
228
+ const step = ctx.steps[ctx.stepIndex];
229
+ if (!step) {
187
230
  await interaction.deferUpdate();
188
231
  await interaction.editReply({
189
- content: 'No provider selected',
232
+ content: 'Invalid state. Please run /login again.',
190
233
  components: [],
191
234
  });
192
235
  return;
193
236
  }
194
237
  try {
195
- const getClient = await initializeOpencodeForDirectory(context.dir);
196
- if (getClient instanceof Error) {
197
- await interaction.deferUpdate();
198
- await interaction.editReply({
199
- content: getClient.message,
200
- components: [],
201
- });
202
- return;
238
+ if (step.type === 'provider') {
239
+ await handleProviderStep(interaction, ctx, hash, value);
203
240
  }
204
- // Get provider info for display
205
- const providersResponse = await getClient().provider.list({
206
- directory: context.dir,
207
- });
208
- const provider = providersResponse.data?.all.find((p) => p.id === selectedProviderId);
209
- const providerName = provider?.name || selectedProviderId;
210
- // Get auth methods for all providers
211
- const authMethodsResponse = await getClient().provider.auth({
212
- directory: context.dir,
213
- });
214
- if (!authMethodsResponse.data) {
215
- await interaction.deferUpdate();
216
- await interaction.editReply({
217
- content: 'Failed to fetch authentication methods',
218
- components: [],
219
- });
220
- return;
241
+ else if (step.type === 'method') {
242
+ await handleMethodStep(interaction, ctx, hash, value, step);
221
243
  }
222
- // Get methods for this specific provider, default to API key if none defined
223
- const methods = authMethodsResponse.data[selectedProviderId] || [{ type: 'api', label: 'API Key' }];
224
- if (methods.length === 0) {
244
+ else if (step.type === 'prompt') {
245
+ await handlePromptStep(interaction, ctx, hash, value, step);
246
+ }
247
+ }
248
+ catch (error) {
249
+ loginLogger.error('Error in login select:', error);
250
+ if (!interaction.deferred && !interaction.replied) {
225
251
  await interaction.deferUpdate();
226
- await interaction.editReply({
227
- content: `No authentication methods available for ${providerName}`,
228
- components: [],
229
- });
230
- return;
231
252
  }
232
- // Update context with provider info
233
- context.providerId = selectedProviderId;
234
- context.providerName = providerName;
235
- pendingLoginContexts.set(contextHash, context);
236
- // If only one method and it's API, show modal directly (no defer)
237
- if (methods.length === 1 && methods[0].type === 'api') {
238
- const method = methods[0];
239
- context.methodIndex = 0;
240
- context.methodType = method.type;
241
- context.methodLabel = method.label;
242
- pendingLoginContexts.set(contextHash, context);
243
- await showApiKeyModal(interaction, contextHash, providerName);
253
+ await interaction.editReply({
254
+ content: `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`,
255
+ components: [],
256
+ });
257
+ }
258
+ }
259
+ // ── Step handlers ───────────────────────────────────────────────
260
+ async function handleProviderStep(interaction, ctx, hash, providerId) {
261
+ // Handle pagination nav — re-render the same provider select with new page
262
+ const navPage = parsePaginationValue(providerId);
263
+ if (navPage !== undefined) {
264
+ await interaction.deferUpdate();
265
+ ctx.providerPage = navPage;
266
+ const getClient = await initializeOpencodeForDirectory(ctx.dir);
267
+ if (getClient instanceof Error) {
268
+ await interaction.editReply({ content: getClient.message, components: [] });
244
269
  return;
245
270
  }
246
- // For OAuth or multiple methods, defer and continue
247
- await interaction.deferUpdate();
248
- // If only one method and it's OAuth, start flow directly
249
- if (methods.length === 1) {
250
- const method = methods[0];
251
- context.methodIndex = 0;
252
- context.methodType = method.type;
253
- context.methodLabel = method.label;
254
- pendingLoginContexts.set(contextHash, context);
255
- await startOAuthFlow(interaction, context, contextHash);
271
+ const providersResponse = await getClient().provider.list({ directory: ctx.dir });
272
+ if (!providersResponse.data) {
273
+ await interaction.editReply({ content: 'Failed to fetch providers', components: [] });
256
274
  return;
257
275
  }
258
- // Multiple methods - show selection menu
259
- const options = methods.slice(0, 25).map((method, index) => ({
260
- label: method.label.slice(0, 100),
261
- value: String(index),
262
- description: method.type === 'oauth'
263
- ? 'OAuth authentication'
264
- : 'Enter API key manually',
265
- }));
266
- const selectMenu = new StringSelectMenuBuilder()
267
- .setCustomId(`login_method:${contextHash}`)
268
- .setPlaceholder('Select authentication method')
269
- .addOptions(options);
270
- const actionRow = new ActionRowBuilder().addComponents(selectMenu);
276
+ const { all: allProviders, connected } = providersResponse.data;
277
+ const allProviderOptions = [...allProviders]
278
+ .sort((a, b) => {
279
+ const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id);
280
+ const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id);
281
+ const posA = rankA === -1 ? Infinity : rankA;
282
+ const posB = rankB === -1 ? Infinity : rankB;
283
+ if (posA !== posB) {
284
+ return posA - posB;
285
+ }
286
+ return a.name.localeCompare(b.name);
287
+ })
288
+ .map((p) => {
289
+ const isConnected = connected.includes(p.id);
290
+ return {
291
+ label: `${p.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
292
+ value: p.id,
293
+ description: isConnected ? 'Connected - select to re-authenticate' : 'Not connected',
294
+ };
295
+ });
296
+ const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: navPage });
271
297
  await interaction.editReply({
272
- content: `**Authenticate with ${providerName}**\nSelect authentication method:`,
273
- components: [actionRow],
298
+ content: '**Authenticate with Provider**\nSelect a provider:',
299
+ components: [
300
+ buildSelectMenu({
301
+ customId: `login_select:${hash}`,
302
+ placeholder: 'Select a provider to authenticate',
303
+ options,
304
+ }),
305
+ ],
274
306
  });
307
+ return;
275
308
  }
276
- catch (error) {
277
- loginLogger.error('Error loading auth methods:', error);
278
- if (!interaction.deferred && !interaction.replied) {
279
- await interaction.deferUpdate();
280
- }
309
+ const getClient = await initializeOpencodeForDirectory(ctx.dir);
310
+ if (getClient instanceof Error) {
311
+ await interaction.deferUpdate();
312
+ await interaction.editReply({ content: getClient.message, components: [] });
313
+ return;
314
+ }
315
+ const providersResponse = await getClient().provider.list({
316
+ directory: ctx.dir,
317
+ });
318
+ const provider = providersResponse.data?.all.find((p) => p.id === providerId);
319
+ const providerName = provider?.name || providerId;
320
+ const authResponse = await getClient().provider.auth({ directory: ctx.dir });
321
+ if (!authResponse.data) {
322
+ await interaction.deferUpdate();
281
323
  await interaction.editReply({
282
- content: `Failed to load auth methods: ${error instanceof Error ? error.message : 'Unknown error'}`,
324
+ content: 'Failed to fetch authentication methods',
283
325
  components: [],
284
326
  });
327
+ return;
285
328
  }
286
- }
287
- /**
288
- * Handle the auth method select menu interaction.
289
- * Starts OAuth flow or shows API key modal.
290
- */
291
- export async function handleLoginMethodSelectMenu(interaction) {
292
- const customId = interaction.customId;
293
- if (!customId.startsWith('login_method:')) {
294
- return;
295
- }
296
- const contextHash = customId.replace('login_method:', '');
297
- const context = pendingLoginContexts.get(contextHash);
298
- if (!context || !context.providerId || !context.providerName) {
329
+ // The server returns prompts in the auth response when the opencode
330
+ // version supports it (dev branch, not yet released as of v1.2.27).
331
+ // Once released, plugin-defined prompts will be collected and passed
332
+ // as inputs to the authorize call automatically.
333
+ const methods = authResponse.data[providerId] || [
334
+ { type: 'api', label: 'API Key' },
335
+ ];
336
+ if (methods.length === 0) {
299
337
  await interaction.deferUpdate();
300
338
  await interaction.editReply({
301
- content: 'Selection expired. Please run /login again.',
339
+ content: `No authentication methods available for ${providerName}`,
302
340
  components: [],
303
341
  });
304
342
  return;
305
343
  }
306
- const selectedMethodIndex = parseInt(interaction.values[0] || '0', 10);
307
- try {
308
- const getClient = await initializeOpencodeForDirectory(context.dir);
309
- if (getClient instanceof Error) {
344
+ ctx.providerId = providerId;
345
+ ctx.providerName = providerName;
346
+ if (methods.length === 1) {
347
+ // Single method skip method select, go straight to prompts or action
348
+ const method = methods[0];
349
+ ctx.methodIndex = 0;
350
+ ctx.methodType = method.type;
351
+ const promptSteps = buildPromptSteps(method);
352
+ if (promptSteps.length > 0) {
353
+ // Has prompts — defer and show first prompt
354
+ ctx.steps = promptSteps;
355
+ ctx.stepIndex = 0;
310
356
  await interaction.deferUpdate();
311
- await interaction.editReply({
312
- content: getClient.message,
313
- components: [],
314
- });
315
- return;
357
+ await showNextStep(interaction, ctx, hash);
316
358
  }
317
- // Get auth methods again to get the selected one
318
- const authMethodsResponse = await getClient().provider.auth({
319
- directory: context.dir,
320
- });
321
- const methods = authMethodsResponse.data?.[context.providerId] || [{ type: 'api', label: 'API Key' }];
322
- const selectedMethod = methods[selectedMethodIndex];
323
- if (!selectedMethod) {
359
+ else if (method.type === 'api') {
360
+ // API key with no prompts — show modal directly (don't defer)
361
+ await showApiKeyModal(interaction, hash, providerName);
362
+ }
363
+ else {
364
+ // OAuth with no prompts — defer and authorize
324
365
  await interaction.deferUpdate();
366
+ await startOAuthFlow(interaction, ctx, hash);
367
+ }
368
+ return;
369
+ }
370
+ // Multiple methods — show method select
371
+ ctx.steps = [
372
+ { type: 'method', methods },
373
+ ];
374
+ ctx.stepIndex = 0;
375
+ await interaction.deferUpdate();
376
+ await showNextStep(interaction, ctx, hash);
377
+ }
378
+ async function handleMethodStep(interaction, ctx, hash, value, step) {
379
+ const methodIndex = parseInt(value, 10);
380
+ const method = step.methods[methodIndex];
381
+ if (!method) {
382
+ await interaction.deferUpdate();
383
+ await interaction.editReply({
384
+ content: 'Invalid method selected.',
385
+ components: [],
386
+ });
387
+ return;
388
+ }
389
+ ctx.methodIndex = methodIndex;
390
+ ctx.methodType = method.type;
391
+ const promptSteps = buildPromptSteps(method);
392
+ if (promptSteps.length > 0) {
393
+ // Replace remaining steps with prompt steps
394
+ ctx.steps = promptSteps;
395
+ ctx.stepIndex = 0;
396
+ await interaction.deferUpdate();
397
+ await showNextStep(interaction, ctx, hash);
398
+ }
399
+ else if (method.type === 'api') {
400
+ // API key with no prompts — show modal directly (don't defer)
401
+ await showApiKeyModal(interaction, hash, ctx.providerName || '');
402
+ }
403
+ else {
404
+ // OAuth with no prompts
405
+ await interaction.deferUpdate();
406
+ await startOAuthFlow(interaction, ctx, hash);
407
+ }
408
+ }
409
+ async function handlePromptStep(interaction, ctx, hash, value, step) {
410
+ // Store the answer
411
+ ctx.inputs[step.prompt.key] = value;
412
+ ctx.stepIndex++;
413
+ // Find the next prompt step that passes its `when` condition
414
+ await interaction.deferUpdate();
415
+ await showNextStep(interaction, ctx, hash);
416
+ }
417
+ // ── Step rendering ──────────────────────────────────────────────
418
+ // Advances through steps, skipping prompts whose `when` condition
419
+ // fails, until it finds one to show or reaches the end.
420
+ async function showNextStep(interaction, ctx, hash) {
421
+ // Skip prompts whose `when` condition doesn't match
422
+ while (ctx.stepIndex < ctx.steps.length) {
423
+ const step = ctx.steps[ctx.stepIndex];
424
+ if (step.type === 'prompt' && !shouldShowPrompt(step.prompt, ctx.inputs)) {
425
+ ctx.stepIndex++;
426
+ continue;
427
+ }
428
+ break;
429
+ }
430
+ if (ctx.stepIndex >= ctx.steps.length) {
431
+ // All steps done — proceed to action
432
+ if (ctx.methodType === 'api') {
433
+ // We're deferred, so show a button that opens the API key modal
434
+ const button = new ButtonBuilder()
435
+ .setCustomId(`login_apikey_btn:${hash}`)
436
+ .setLabel('Enter API Key')
437
+ .setStyle(ButtonStyle.Primary);
325
438
  await interaction.editReply({
326
- content: 'Invalid method selected',
327
- components: [],
439
+ content: `**Authenticate with ${ctx.providerName}**\nClick to enter your API key.`,
440
+ components: [
441
+ new ActionRowBuilder().addComponents(button),
442
+ ],
328
443
  });
329
- return;
330
- }
331
- // Update context
332
- context.methodIndex = selectedMethodIndex;
333
- context.methodType = selectedMethod.type;
334
- context.methodLabel = selectedMethod.label;
335
- pendingLoginContexts.set(contextHash, context);
336
- if (selectedMethod.type === 'api') {
337
- // Show API key modal (don't defer for modals)
338
- await showApiKeyModal(interaction, contextHash, context.providerName);
339
444
  }
340
445
  else {
341
- // Start OAuth flow
342
- await interaction.deferUpdate();
343
- await startOAuthFlow(interaction, context, contextHash);
446
+ await startOAuthFlow(interaction, ctx, hash);
344
447
  }
448
+ return;
345
449
  }
346
- catch (error) {
347
- loginLogger.error('Error processing auth method:', error);
348
- try {
349
- if (!interaction.deferred && !interaction.replied) {
350
- await interaction.deferUpdate();
351
- }
450
+ const step = ctx.steps[ctx.stepIndex];
451
+ pendingLoginContexts.set(hash, ctx);
452
+ if (step.type === 'method') {
453
+ const options = step.methods.slice(0, 25).map((method, index) => ({
454
+ label: method.label.slice(0, 100),
455
+ value: String(index),
456
+ description: method.type === 'oauth'
457
+ ? 'OAuth authentication'
458
+ : 'Enter API key manually',
459
+ }));
460
+ await interaction.editReply({
461
+ content: `**Authenticate with ${ctx.providerName}**\nSelect authentication method:`,
462
+ components: [
463
+ buildSelectMenu({
464
+ customId: `login_select:${hash}`,
465
+ placeholder: 'Select authentication method',
466
+ options,
467
+ }),
468
+ ],
469
+ });
470
+ return;
471
+ }
472
+ if (step.type === 'prompt') {
473
+ const prompt = step.prompt;
474
+ if (prompt.type === 'select') {
475
+ const options = prompt.options.slice(0, 25).map((opt) => ({
476
+ label: opt.label.slice(0, 100),
477
+ value: opt.value,
478
+ description: opt.hint?.slice(0, 100),
479
+ }));
352
480
  await interaction.editReply({
353
- content: `Failed to process auth method: ${error instanceof Error ? error.message : 'Unknown error'}`,
354
- components: [],
481
+ content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
482
+ components: [
483
+ buildSelectMenu({
484
+ customId: `login_select:${hash}`,
485
+ placeholder: prompt.message.slice(0, 150),
486
+ options,
487
+ }),
488
+ ],
355
489
  });
490
+ return;
356
491
  }
357
- catch {
358
- // Ignore follow-up errors
492
+ if (prompt.type === 'text') {
493
+ // Text prompts need a modal, but we're deferred. Show a button.
494
+ const button = new ButtonBuilder()
495
+ .setCustomId(`login_text_btn:${hash}`)
496
+ .setLabel(prompt.message.slice(0, 80))
497
+ .setStyle(ButtonStyle.Primary);
498
+ await interaction.editReply({
499
+ content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
500
+ components: [
501
+ new ActionRowBuilder().addComponents(button),
502
+ ],
503
+ });
504
+ return;
359
505
  }
360
506
  }
361
507
  }
362
- /**
363
- * Show API key input modal.
364
- */
365
- async function showApiKeyModal(interaction, contextHash, providerName) {
508
+ function buildPromptSteps(method) {
509
+ return (method.prompts || []).map((prompt) => ({
510
+ type: 'prompt',
511
+ prompt,
512
+ }));
513
+ }
514
+ // ── Text prompt button + modal ──────────────────────────────────
515
+ // When a text prompt needs to be shown but we're in a deferred state,
516
+ // we show a button. Clicking it opens a modal for text input.
517
+ export async function handleLoginTextButton(interaction) {
518
+ if (!interaction.customId.startsWith('login_text_btn:')) {
519
+ return;
520
+ }
521
+ const hash = interaction.customId.replace('login_text_btn:', '');
522
+ const ctx = pendingLoginContexts.get(hash);
523
+ if (!ctx) {
524
+ await interaction.reply({
525
+ content: 'Selection expired. Please run /login again.',
526
+ flags: MessageFlags.Ephemeral,
527
+ });
528
+ return;
529
+ }
530
+ const step = ctx.steps[ctx.stepIndex];
531
+ if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
532
+ await interaction.reply({
533
+ content: 'Invalid state. Please run /login again.',
534
+ flags: MessageFlags.Ephemeral,
535
+ });
536
+ return;
537
+ }
538
+ const modal = new ModalBuilder()
539
+ .setCustomId(`login_text:${hash}`)
540
+ .setTitle(`${ctx.providerName || 'Provider'} Login`.slice(0, 45));
541
+ const textInput = new TextInputBuilder()
542
+ .setCustomId('prompt_value')
543
+ .setLabel(step.prompt.message.slice(0, 45))
544
+ .setPlaceholder(step.prompt.type === 'text' ? (step.prompt.placeholder || '') : '')
545
+ .setStyle(TextInputStyle.Short)
546
+ .setRequired(true);
547
+ modal.addComponents(new ActionRowBuilder().addComponents(textInput));
548
+ await interaction.showModal(modal);
549
+ }
550
+ export async function handleLoginTextModalSubmit(interaction) {
551
+ if (!interaction.customId.startsWith('login_text:')) {
552
+ return;
553
+ }
554
+ await interaction.deferUpdate();
555
+ const hash = interaction.customId.replace('login_text:', '');
556
+ const ctx = pendingLoginContexts.get(hash);
557
+ if (!ctx) {
558
+ await interaction.editReply({
559
+ content: 'Selection expired. Please run /login again.',
560
+ components: [],
561
+ });
562
+ return;
563
+ }
564
+ const step = ctx.steps[ctx.stepIndex];
565
+ if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
566
+ await interaction.editReply({
567
+ content: 'Invalid state. Please run /login again.',
568
+ components: [],
569
+ });
570
+ return;
571
+ }
572
+ const value = interaction.fields.getTextInputValue('prompt_value');
573
+ if (!value?.trim()) {
574
+ await interaction.editReply({
575
+ content: 'A value is required.',
576
+ components: [],
577
+ });
578
+ return;
579
+ }
580
+ ctx.inputs[step.prompt.key] = value.trim();
581
+ ctx.stepIndex++;
582
+ await showNextStep(interaction, ctx, hash);
583
+ }
584
+ // ── API key button + modal ──────────────────────────────────────
585
+ // When we're deferred and need an API key modal, show a button first.
586
+ export async function handleLoginApiKeyButton(interaction) {
587
+ if (!interaction.customId.startsWith('login_apikey_btn:')) {
588
+ return;
589
+ }
590
+ const hash = interaction.customId.replace('login_apikey_btn:', '');
591
+ const ctx = pendingLoginContexts.get(hash);
592
+ if (!ctx || !ctx.providerName) {
593
+ await interaction.reply({
594
+ content: 'Selection expired. Please run /login again.',
595
+ flags: MessageFlags.Ephemeral,
596
+ });
597
+ return;
598
+ }
599
+ await showApiKeyModal(interaction, hash, ctx.providerName);
600
+ }
601
+ async function showApiKeyModal(interaction, hash, providerName) {
366
602
  const modal = new ModalBuilder()
367
- .setCustomId(`login_apikey:${contextHash}`)
603
+ .setCustomId(`login_apikey:${hash}`)
368
604
  .setTitle(`${providerName} API Key`.slice(0, 45));
369
605
  const apiKeyInput = new TextInputBuilder()
370
606
  .setCustomId('apikey')
@@ -372,23 +608,62 @@ async function showApiKeyModal(interaction, contextHash, providerName) {
372
608
  .setPlaceholder('sk-...')
373
609
  .setStyle(TextInputStyle.Short)
374
610
  .setRequired(true);
375
- const actionRow = new ActionRowBuilder().addComponents(apiKeyInput);
376
- modal.addComponents(actionRow);
611
+ modal.addComponents(new ActionRowBuilder().addComponents(apiKeyInput));
612
+ await interaction.showModal(modal);
613
+ }
614
+ // ── OAuth code submission (code mode) ───────────────────────────
615
+ // When the OAuth flow returns method="code", the user completes login
616
+ // in a browser (possibly on a different machine) and pastes the final
617
+ // callback URL or authorization code here.
618
+ export async function handleOAuthCodeButton(interaction) {
619
+ if (!interaction.customId.startsWith('login_oauth_code_btn:')) {
620
+ return;
621
+ }
622
+ const hash = interaction.customId.replace('login_oauth_code_btn:', '');
623
+ const ctx = pendingLoginContexts.get(hash);
624
+ if (!ctx || !ctx.providerId || !ctx.providerName) {
625
+ await interaction.reply({
626
+ content: 'Selection expired. Please run /login again.',
627
+ flags: MessageFlags.Ephemeral,
628
+ });
629
+ return;
630
+ }
631
+ const modal = new ModalBuilder()
632
+ .setCustomId(`login_oauth_code:${hash}`)
633
+ .setTitle(`${ctx.providerName} Authorization`.slice(0, 45));
634
+ const codeInput = new TextInputBuilder()
635
+ .setCustomId('oauth_code')
636
+ .setLabel('Authorization code or callback URL')
637
+ .setPlaceholder('Paste the code or full callback URL')
638
+ .setStyle(TextInputStyle.Paragraph)
639
+ .setRequired(true);
640
+ modal.addComponents(new ActionRowBuilder().addComponents(codeInput));
377
641
  await interaction.showModal(modal);
378
642
  }
379
- /**
380
- * Start OAuth authorization flow.
381
- */
382
- async function startOAuthFlow(interaction, context, contextHash) {
383
- if (!context.providerId || context.methodIndex === undefined) {
643
+ export async function handleOAuthCodeModalSubmit(interaction) {
644
+ if (!interaction.customId.startsWith('login_oauth_code:')) {
645
+ return;
646
+ }
647
+ await interaction.deferUpdate();
648
+ const hash = interaction.customId.replace('login_oauth_code:', '');
649
+ const ctx = pendingLoginContexts.get(hash);
650
+ if (!ctx || !ctx.providerId || !ctx.providerName || ctx.methodIndex === undefined) {
384
651
  await interaction.editReply({
385
- content: 'Invalid context for OAuth flow',
652
+ content: 'Session expired. Please run /login again.',
653
+ components: [],
654
+ });
655
+ return;
656
+ }
657
+ const code = interaction.fields.getTextInputValue('oauth_code')?.trim();
658
+ if (!code) {
659
+ await interaction.editReply({
660
+ content: 'Authorization code is required.',
386
661
  components: [],
387
662
  });
388
663
  return;
389
664
  }
390
665
  try {
391
- const getClient = await initializeOpencodeForDirectory(context.dir);
666
+ const getClient = await initializeOpencodeForDirectory(ctx.dir);
392
667
  if (getClient instanceof Error) {
393
668
  await interaction.editReply({
394
669
  content: getClient.message,
@@ -397,92 +672,47 @@ async function startOAuthFlow(interaction, context, contextHash) {
397
672
  return;
398
673
  }
399
674
  await interaction.editReply({
400
- content: `**Authenticating with ${context.providerName}**\nStarting authorization...`,
675
+ content: `**Authenticating with ${ctx.providerName}**\nVerifying authorization...`,
401
676
  components: [],
402
677
  });
403
- // Start OAuth authorization
404
- const authorizeResponse = await getClient().provider.oauth.authorize({
405
- providerID: context.providerId,
406
- method: context.methodIndex,
407
- directory: context.dir,
678
+ const callbackResponse = await getClient().provider.oauth.callback({
679
+ providerID: ctx.providerId,
680
+ method: ctx.methodIndex,
681
+ code,
682
+ directory: ctx.dir,
408
683
  });
409
- if (!authorizeResponse.data) {
410
- const errorData = authorizeResponse.error;
684
+ if (callbackResponse.error) {
685
+ pendingLoginContexts.delete(hash);
411
686
  await interaction.editReply({
412
- content: `Failed to start authorization: ${errorData?.data?.message || 'Unknown error'}`,
687
+ content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization code was invalid or expired' })}`,
413
688
  components: [],
414
689
  });
415
690
  return;
416
691
  }
417
- const { url, method, instructions } = authorizeResponse.data;
418
- // Show authorization URL and instructions
419
- let message = `**Authenticating with ${context.providerName}**\n\n`;
420
- message += `Open this URL to authorize:\n${url}\n\n`;
421
- if (instructions) {
422
- // Extract code from instructions like "Enter code: ABC-123"
423
- const codeMatch = instructions.match(/code[:\s]+([A-Z0-9-]+)/i);
424
- if (codeMatch) {
425
- message += `**Code:** \`${codeMatch[1]}\`\n\n`;
426
- }
427
- else {
428
- message += `${instructions}\n\n`;
429
- }
430
- }
431
- if (method === 'auto') {
432
- message += '_Waiting for authorization to complete..._';
433
- }
692
+ await getClient().instance.dispose({ directory: ctx.dir });
693
+ pendingLoginContexts.delete(hash);
434
694
  await interaction.editReply({
435
- content: message,
695
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
436
696
  components: [],
437
697
  });
438
- if (method === 'auto') {
439
- // Poll for completion (device flow)
440
- const callbackResponse = await getClient().provider.oauth.callback({
441
- providerID: context.providerId,
442
- method: context.methodIndex,
443
- directory: context.dir,
444
- });
445
- if (callbackResponse.error) {
446
- const errorData = callbackResponse.error;
447
- await interaction.editReply({
448
- content: `**Authentication Failed**\n${errorData?.data?.message || 'Authorization was not completed'}`,
449
- components: [],
450
- });
451
- return;
452
- }
453
- // Dispose to refresh provider state so new credentials are recognized
454
- await getClient().instance.dispose({ directory: context.dir });
455
- await interaction.editReply({
456
- content: `✅ **Successfully authenticated with ${context.providerName}!**\n\nYou can now use models from this provider.`,
457
- components: [],
458
- });
459
- }
460
- // For 'code' method, we would need to prompt for code input
461
- // But Discord modals can't be shown after deferUpdate, so we'd need a different flow
462
- // For now, most providers use 'auto' (device flow) which works well for Discord
463
- // Clean up context
464
- pendingLoginContexts.delete(contextHash);
465
698
  }
466
699
  catch (error) {
467
- loginLogger.error('OAuth flow error:', error);
700
+ loginLogger.error('OAuth code submission error:', error);
701
+ pendingLoginContexts.delete(hash);
468
702
  await interaction.editReply({
469
703
  content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
470
704
  components: [],
471
705
  });
472
706
  }
473
707
  }
474
- /**
475
- * Handle API key modal submission.
476
- */
477
708
  export async function handleApiKeyModalSubmit(interaction) {
478
- const customId = interaction.customId;
479
- if (!customId.startsWith('login_apikey:')) {
709
+ if (!interaction.customId.startsWith('login_apikey:')) {
480
710
  return;
481
711
  }
482
712
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
483
- const contextHash = customId.replace('login_apikey:', '');
484
- const context = pendingLoginContexts.get(contextHash);
485
- if (!context || !context.providerId || !context.providerName) {
713
+ const hash = interaction.customId.replace('login_apikey:', '');
714
+ const ctx = pendingLoginContexts.get(hash);
715
+ if (!ctx || !ctx.providerId || !ctx.providerName) {
486
716
  await interaction.editReply({
487
717
  content: 'Session expired. Please run /login again.',
488
718
  });
@@ -490,39 +720,166 @@ export async function handleApiKeyModalSubmit(interaction) {
490
720
  }
491
721
  const apiKey = interaction.fields.getTextInputValue('apikey');
492
722
  if (!apiKey?.trim()) {
723
+ await interaction.editReply({ content: 'API key is required.' });
724
+ return;
725
+ }
726
+ try {
727
+ const getClient = await initializeOpencodeForDirectory(ctx.dir);
728
+ if (getClient instanceof Error) {
729
+ await interaction.editReply({ content: getClient.message });
730
+ return;
731
+ }
732
+ await getClient().auth.set({
733
+ providerID: ctx.providerId,
734
+ auth: { type: 'api', key: apiKey.trim() },
735
+ });
736
+ // Dispose to refresh provider state so new credentials are recognized
737
+ await getClient().instance.dispose({ directory: ctx.dir });
738
+ await interaction.editReply({
739
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
740
+ });
741
+ pendingLoginContexts.delete(hash);
742
+ }
743
+ catch (error) {
744
+ loginLogger.error('API key save error:', error);
745
+ await interaction.editReply({
746
+ content: `**Failed to save API key**\n${error instanceof Error ? error.message : 'Unknown error'}`,
747
+ });
748
+ }
749
+ }
750
+ // ── OAuth flow ──────────────────────────────────────────────────
751
+ async function startOAuthFlow(interaction, ctx, hash) {
752
+ if (!ctx.providerId || ctx.methodIndex === undefined) {
493
753
  await interaction.editReply({
494
- content: 'API key is required.',
754
+ content: 'Invalid context for OAuth flow',
755
+ components: [],
495
756
  });
496
757
  return;
497
758
  }
498
759
  try {
499
- const getClient = await initializeOpencodeForDirectory(context.dir);
760
+ const getClient = await initializeOpencodeForDirectory(ctx.dir);
500
761
  if (getClient instanceof Error) {
501
762
  await interaction.editReply({
502
763
  content: getClient.message,
764
+ components: [],
503
765
  });
504
766
  return;
505
767
  }
506
- // Set the API key
507
- await getClient().auth.set({
508
- providerID: context.providerId,
509
- auth: {
510
- type: 'api',
511
- key: apiKey.trim(),
512
- },
768
+ await interaction.editReply({
769
+ content: `**Authenticating with ${ctx.providerName}**\nStarting authorization...`,
770
+ components: [],
513
771
  });
514
- // Dispose to refresh provider state so new credentials are recognized
515
- await getClient().instance.dispose({ directory: context.dir });
772
+ // Direct fetch to the server because the SDK's buildClientParams drops
773
+ // unknown keys — `inputs` would be silently stripped. The server accepts
774
+ // `inputs` in the body (see opencode server/routes/provider.ts).
775
+ const port = getOpencodeServerPort();
776
+ if (!port) {
777
+ await interaction.editReply({
778
+ content: 'OpenCode server is not running. Please try again.',
779
+ components: [],
780
+ });
781
+ return;
782
+ }
783
+ const hasInputs = Object.keys(ctx.inputs).length > 0;
784
+ const authorizeUrl = new URL(`/provider/${encodeURIComponent(ctx.providerId)}/oauth/authorize`, `http://127.0.0.1:${port}`);
785
+ authorizeUrl.searchParams.set('directory', ctx.dir);
786
+ // Include basic auth if OPENCODE_SERVER_PASSWORD is set,
787
+ // matching the opencode server's optional basicAuth middleware.
788
+ const fetchHeaders = {
789
+ 'Content-Type': 'application/json',
790
+ 'x-opencode-directory': ctx.dir,
791
+ };
792
+ const serverPassword = process.env.OPENCODE_SERVER_PASSWORD;
793
+ if (serverPassword) {
794
+ const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
795
+ fetchHeaders['Authorization'] =
796
+ `Basic ${Buffer.from(`${username}:${serverPassword}`).toString('base64')}`;
797
+ }
798
+ const authorizeRes = await fetch(authorizeUrl, {
799
+ method: 'POST',
800
+ headers: fetchHeaders,
801
+ body: JSON.stringify({
802
+ method: ctx.methodIndex,
803
+ ...(hasInputs ? { inputs: ctx.inputs } : {}),
804
+ }),
805
+ });
806
+ if (!authorizeRes.ok) {
807
+ const errorText = await authorizeRes.text().catch(() => '');
808
+ let errorMessage = 'Unknown error';
809
+ try {
810
+ const parsed = JSON.parse(errorText);
811
+ errorMessage = parsed?.data?.message || parsed?.message || errorMessage;
812
+ }
813
+ catch {
814
+ errorMessage = errorText || errorMessage;
815
+ }
816
+ await interaction.editReply({
817
+ content: `Failed to start authorization: ${errorMessage}`,
818
+ components: [],
819
+ });
820
+ return;
821
+ }
822
+ const { url, method, instructions } = (await authorizeRes.json());
823
+ let message = `**Authenticating with ${ctx.providerName}**\n\n`;
824
+ message += `Open this URL to authorize:\n${url}\n\n`;
825
+ if (instructions) {
826
+ // Match "code: ABC-123" or "code: WXYZ1234" but not natural language
827
+ // like "code will". Require a colon separator and uppercase alphanum code.
828
+ const codeMatch = instructions.match(/code:\s*([A-Z0-9][A-Z0-9-]+)/);
829
+ if (codeMatch) {
830
+ message += `**Code:** \`${codeMatch[1]}\`\n\n`;
831
+ }
832
+ else {
833
+ message += `${instructions}\n\n`;
834
+ }
835
+ }
836
+ if (method === 'auto') {
837
+ message += '_Waiting for authorization to complete..._';
838
+ }
839
+ if (method === 'code') {
840
+ // Code mode: show a button to paste the auth code/URL after
841
+ // completing login in a browser (possibly on a different machine).
842
+ const button = new ButtonBuilder()
843
+ .setCustomId(`login_oauth_code_btn:${hash}`)
844
+ .setLabel('Paste authorization code')
845
+ .setStyle(ButtonStyle.Primary);
846
+ await interaction.editReply({
847
+ content: message,
848
+ components: [
849
+ new ActionRowBuilder().addComponents(button),
850
+ ],
851
+ });
852
+ // Don't delete context — we need it for the code submission
853
+ return;
854
+ }
855
+ await interaction.editReply({ content: message, components: [] });
856
+ // Auto mode: poll for completion (device flow / localhost callback)
857
+ const callbackResponse = await getClient().provider.oauth.callback({
858
+ providerID: ctx.providerId,
859
+ method: ctx.methodIndex,
860
+ directory: ctx.dir,
861
+ });
862
+ if (callbackResponse.error) {
863
+ pendingLoginContexts.delete(hash);
864
+ await interaction.editReply({
865
+ content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization was not completed' })}`,
866
+ components: [],
867
+ });
868
+ return;
869
+ }
870
+ await getClient().instance.dispose({ directory: ctx.dir });
871
+ pendingLoginContexts.delete(hash);
516
872
  await interaction.editReply({
517
- content: `✅ **Successfully authenticated with ${context.providerName}!**\n\nYou can now use models from this provider.`,
873
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
874
+ components: [],
518
875
  });
519
- // Clean up context
520
- pendingLoginContexts.delete(contextHash);
521
876
  }
522
877
  catch (error) {
523
- loginLogger.error('API key save error:', error);
878
+ loginLogger.error('OAuth flow error:', error);
879
+ pendingLoginContexts.delete(hash);
524
880
  await interaction.editReply({
525
- content: `**Failed to save API key**\n${error instanceof Error ? error.message : 'Unknown error'}`,
881
+ content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
882
+ components: [],
526
883
  });
527
884
  }
528
885
  }