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,5 +1,13 @@
1
- // /login command - Authenticate with AI providers (OAuth or API key).
2
- // Supports GitHub Copilot (device flow), OpenAI Codex (device flow), and API keys.
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
3
11
 
4
12
  import {
5
13
  ChatInputCommandInteraction,
@@ -10,37 +18,108 @@ import {
10
18
  TextInputBuilder,
11
19
  TextInputStyle,
12
20
  ModalSubmitInteraction,
21
+ ButtonBuilder,
22
+ ButtonStyle,
23
+ type ButtonInteraction,
13
24
  ChannelType,
14
25
  type ThreadChannel,
15
26
  type TextChannel,
16
27
  MessageFlags,
17
28
  } from 'discord.js'
29
+ import type { AuthHook } from '@opencode-ai/plugin'
18
30
  import crypto from 'node:crypto'
19
- import { initializeOpencodeForDirectory } from '../opencode.js'
31
+ import {
32
+ initializeOpencodeForDirectory,
33
+ getOpencodeServerPort,
34
+ } from '../opencode.js'
20
35
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
21
36
  import { createLogger, LogPrefix } from '../logger.js'
37
+ import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
22
38
 
23
39
  const loginLogger = createLogger(LogPrefix.LOGIN)
24
40
 
25
- // Store context by hash to avoid customId length limits (Discord max: 100 chars).
26
- // TTL'd to prevent unbounded growth when users open /login and never interact.
41
+ // ── Types ───────────────────────────────────────────────────────
42
+ // Derive prompt types from the plugin package so they stay in sync.
43
+ // Strip runtime-only callback fields (validate, condition) that
44
+ // aren't present in the REST response from the opencode server.
45
+ // Add `when` rule — the server's zod schema includes it but the
46
+ // published plugin package hasn't been updated yet.
47
+
48
+ type WhenRule = { key: string; op: 'eq' | 'neq'; value: string }
49
+
50
+ // Extract prompt option type from the plugin's select prompt
51
+ type PluginMethod = AuthHook['methods'][number]
52
+ type PluginSelectPrompt = Extract<
53
+ NonNullable<PluginMethod['prompts']>[number],
54
+ { type: 'select' }
55
+ >
56
+ type PromptOption = PluginSelectPrompt['options'][number]
57
+
58
+ type AuthPromptText = {
59
+ type: 'text'
60
+ key: string
61
+ message: string
62
+ placeholder?: string
63
+ when?: WhenRule
64
+ }
65
+
66
+ type AuthPromptSelect = {
67
+ type: 'select'
68
+ key: string
69
+ message: string
70
+ options: PromptOption[]
71
+ when?: WhenRule
72
+ }
73
+
74
+ type AuthPrompt = AuthPromptText | AuthPromptSelect
75
+
76
+ type ProviderAuthMethod = {
77
+ type: 'oauth' | 'api'
78
+ label: string
79
+ prompts?: AuthPrompt[]
80
+ }
81
+
82
+ // ── Login step state machine ────────────────────────────────────
83
+ // Each step describes what the next select menu should show.
84
+ // Steps are built lazily: provider step is set by /login, method
85
+ // and prompt steps are added after the provider is selected.
86
+
87
+ type StepProvider = { type: 'provider' }
88
+ type StepMethod = { type: 'method'; methods: ProviderAuthMethod[] }
89
+ type StepPrompt = { type: 'prompt'; prompt: AuthPrompt }
90
+ type LoginStep = StepProvider | StepMethod | StepPrompt
91
+
92
+ type LoginContext = {
93
+ dir: string
94
+ channelId: string
95
+ providerId?: string
96
+ providerName?: string
97
+ methodIndex?: number
98
+ methodType?: 'oauth' | 'api'
99
+ steps: LoginStep[]
100
+ stepIndex: number
101
+ inputs: Record<string, string>
102
+ providerPage?: number
103
+ }
104
+
105
+ // ── Context store ───────────────────────────────────────────────
106
+ // Keyed by random hash to stay under Discord's 100-char customId limit.
107
+ // TTL prevents unbounded growth when users open /login and never interact.
108
+
27
109
  const LOGIN_CONTEXT_TTL_MS = 10 * 60 * 1000
28
- const pendingLoginContexts = new Map<
29
- string,
30
- {
31
- dir: string
32
- channelId: string
33
- providerId?: string
34
- providerName?: string
35
- methodIndex?: number
36
- methodType?: 'oauth' | 'api'
37
- methodLabel?: string
38
- }
39
- >()
40
-
41
- // Popularity-ordered provider IDs for the select menu.
42
- // Discord select menus cap at 25 options, so we show these first,
43
- // then fill remaining slots with unlisted providers alphabetically.
110
+ const pendingLoginContexts = new Map<string, LoginContext>()
111
+
112
+ function createContextHash(context: LoginContext): string {
113
+ const hash = crypto.randomBytes(8).toString('hex')
114
+ pendingLoginContexts.set(hash, context)
115
+ setTimeout(() => {
116
+ pendingLoginContexts.delete(hash)
117
+ }, LOGIN_CONTEXT_TTL_MS).unref()
118
+ return hash
119
+ }
120
+
121
+ // ── Provider popularity order ───────────────────────────────────
122
+ // Discord select menus cap at 25 options, so we show popular ones first.
44
123
  // IDs sourced from opencode's provider.list() API (scripts/list-providers.ts).
45
124
  const PROVIDER_POPULARITY_ORDER: string[] = [
46
125
  'anthropic',
@@ -70,15 +149,57 @@ const PROVIDER_POPULARITY_ORDER: string[] = [
70
149
  'llama',
71
150
  ]
72
151
 
73
- export type ProviderAuthMethod = {
74
- type: 'oauth' | 'api'
75
- label: string
152
+ // ── Helpers ─────────────────────────────────────────────────────
153
+
154
+ function extractErrorMessage({
155
+ error,
156
+ fallback,
157
+ }: {
158
+ error: unknown
159
+ fallback: string
160
+ }): string {
161
+ if (!error || typeof error !== 'object') {
162
+ return fallback
163
+ }
164
+ const parsed = error as { message?: string; data?: { message?: string } }
165
+ return parsed.data?.message || parsed.message || fallback
76
166
  }
77
167
 
78
- /**
79
- * Handle the /login slash command.
80
- * Shows a select menu with available providers.
81
- */
168
+ function shouldShowPrompt(
169
+ prompt: AuthPrompt,
170
+ inputs: Record<string, string>,
171
+ ): boolean {
172
+ if (!prompt.when) {
173
+ return true
174
+ }
175
+ const value = inputs[prompt.when.key]
176
+ if (prompt.when.op === 'eq') {
177
+ return value === prompt.when.value
178
+ }
179
+ if (prompt.when.op === 'neq') {
180
+ return value !== prompt.when.value
181
+ }
182
+ return true
183
+ }
184
+
185
+ function buildSelectMenu({
186
+ customId,
187
+ placeholder,
188
+ options,
189
+ }: {
190
+ customId: string
191
+ placeholder: string
192
+ options: Array<{ label: string; value: string; description?: string }>
193
+ }): ActionRowBuilder<StringSelectMenuBuilder> {
194
+ const menu = new StringSelectMenuBuilder()
195
+ .setCustomId(customId)
196
+ .setPlaceholder(placeholder)
197
+ .addOptions(options)
198
+ return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)
199
+ }
200
+
201
+ // ── /login command ──────────────────────────────────────────────
202
+
82
203
  export async function handleLoginCommand({
83
204
  interaction,
84
205
  }: {
@@ -87,12 +208,9 @@ export async function handleLoginCommand({
87
208
  }): Promise<void> {
88
209
  loginLogger.log('[LOGIN] handleLoginCommand called')
89
210
 
90
- // Defer reply immediately to avoid 3-second timeout
91
211
  await interaction.deferReply({ flags: MessageFlags.Ephemeral })
92
- loginLogger.log('[LOGIN] Deferred reply')
93
212
 
94
213
  const channel = interaction.channel
95
-
96
214
  if (!channel) {
97
215
  await interaction.editReply({
98
216
  content: 'This command can only be used in a channel',
@@ -100,7 +218,6 @@ export async function handleLoginCommand({
100
218
  return
101
219
  }
102
220
 
103
- // Determine if we're in a thread or text channel
104
221
  const isThread = [
105
222
  ChannelType.PublicThread,
106
223
  ChannelType.PrivateThread,
@@ -147,24 +264,18 @@ export async function handleLoginCommand({
147
264
  })
148
265
 
149
266
  if (!providersResponse.data) {
150
- await interaction.editReply({
151
- content: 'Failed to fetch providers',
152
- })
267
+ await interaction.editReply({ content: 'Failed to fetch providers' })
153
268
  return
154
269
  }
155
270
 
156
271
  const { all: allProviders, connected } = providersResponse.data
157
272
 
158
273
  if (allProviders.length === 0) {
159
- await interaction.editReply({
160
- content: 'No providers available.',
161
- })
274
+ await interaction.editReply({ content: 'No providers available.' })
162
275
  return
163
276
  }
164
277
 
165
- // Sort by hardcoded popularity order, then alphabetically for unlisted ones.
166
- // Discord select menus cap at 25, so we show the most popular providers.
167
- const options = [...allProviders]
278
+ const allProviderOptions = [...allProviders]
168
279
  .sort((a, b) => {
169
280
  const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id)
170
281
  const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id)
@@ -175,14 +286,10 @@ export async function handleLoginCommand({
175
286
  }
176
287
  return a.name.localeCompare(b.name)
177
288
  })
178
- .slice(0, 25)
179
289
  .map((provider) => {
180
290
  const isConnected = connected.includes(provider.id)
181
291
  return {
182
- label: `${provider.name}${isConnected ? ' ✓' : ''}`.slice(
183
- 0,
184
- 100,
185
- ),
292
+ label: `${provider.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
186
293
  value: provider.id,
187
294
  description: isConnected
188
295
  ? 'Connected - select to re-authenticate'
@@ -190,28 +297,29 @@ export async function handleLoginCommand({
190
297
  }
191
298
  })
192
299
 
193
- // Store context with a short hash key to avoid customId length limits
194
- const context = {
300
+ const { options } = buildPaginatedOptions({
301
+ allOptions: allProviderOptions,
302
+ page: 0,
303
+ })
304
+
305
+ const context: LoginContext = {
195
306
  dir: projectDirectory,
196
307
  channelId: targetChannelId,
308
+ steps: [{ type: 'provider' }],
309
+ stepIndex: 0,
310
+ inputs: {},
197
311
  }
198
- const contextHash = crypto.randomBytes(8).toString('hex')
199
- pendingLoginContexts.set(contextHash, context)
200
- setTimeout(() => {
201
- pendingLoginContexts.delete(contextHash)
202
- }, LOGIN_CONTEXT_TTL_MS).unref()
203
-
204
- const selectMenu = new StringSelectMenuBuilder()
205
- .setCustomId(`login_provider:${contextHash}`)
206
- .setPlaceholder('Select a provider to authenticate')
207
- .addOptions(options)
208
-
209
- const actionRow =
210
- new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
312
+ const hash = createContextHash(context)
211
313
 
212
314
  await interaction.editReply({
213
315
  content: '**Authenticate with Provider**\nSelect a provider:',
214
- components: [actionRow],
316
+ components: [
317
+ buildSelectMenu({
318
+ customId: `login_select:${hash}`,
319
+ placeholder: 'Select a provider to authenticate',
320
+ options,
321
+ }),
322
+ ],
215
323
  })
216
324
  } catch (error) {
217
325
  loginLogger.error('Error loading providers:', error)
@@ -221,23 +329,22 @@ export async function handleLoginCommand({
221
329
  }
222
330
  }
223
331
 
224
- /**
225
- * Handle the provider select menu interaction.
226
- * Shows a second select menu with auth methods for the chosen provider.
227
- */
228
- export async function handleLoginProviderSelectMenu(
332
+ // ── Unified select handler ──────────────────────────────────────
333
+ // Handles all select menu interactions for the login flow.
334
+ // Reads the current step from context, processes the answer,
335
+ // then either shows the next step or proceeds to authorize/API key.
336
+
337
+ export async function handleLoginSelect(
229
338
  interaction: StringSelectMenuInteraction,
230
339
  ): Promise<void> {
231
- const customId = interaction.customId
232
-
233
- if (!customId.startsWith('login_provider:')) {
340
+ if (!interaction.customId.startsWith('login_select:')) {
234
341
  return
235
342
  }
236
343
 
237
- const contextHash = customId.replace('login_provider:', '')
238
- const context = pendingLoginContexts.get(contextHash)
344
+ const hash = interaction.customId.replace('login_select:', '')
345
+ const ctx = pendingLoginContexts.get(hash)
239
346
 
240
- if (!context) {
347
+ if (!ctx) {
241
348
  await interaction.deferUpdate()
242
349
  await interaction.editReply({
243
350
  content: 'Selection expired. Please run /login again.',
@@ -246,97 +353,280 @@ export async function handleLoginProviderSelectMenu(
246
353
  return
247
354
  }
248
355
 
249
- const selectedProviderId = interaction.values[0]
250
- if (!selectedProviderId) {
356
+ const value = interaction.values[0]
357
+ if (!value) {
358
+ await interaction.deferUpdate()
359
+ await interaction.editReply({
360
+ content: 'No option selected.',
361
+ components: [],
362
+ })
363
+ return
364
+ }
365
+
366
+ const step = ctx.steps[ctx.stepIndex]
367
+ if (!step) {
251
368
  await interaction.deferUpdate()
252
369
  await interaction.editReply({
253
- content: 'No provider selected',
370
+ content: 'Invalid state. Please run /login again.',
254
371
  components: [],
255
372
  })
256
373
  return
257
374
  }
258
375
 
259
376
  try {
260
- const getClient = await initializeOpencodeForDirectory(context.dir)
261
- if (getClient instanceof Error) {
377
+ if (step.type === 'provider') {
378
+ await handleProviderStep(interaction, ctx, hash, value)
379
+ } else if (step.type === 'method') {
380
+ await handleMethodStep(interaction, ctx, hash, value, step)
381
+ } else if (step.type === 'prompt') {
382
+ await handlePromptStep(interaction, ctx, hash, value, step)
383
+ }
384
+ } catch (error) {
385
+ loginLogger.error('Error in login select:', error)
386
+ if (!interaction.deferred && !interaction.replied) {
262
387
  await interaction.deferUpdate()
263
- await interaction.editReply({
264
- content: getClient.message,
265
- components: [],
266
- })
388
+ }
389
+ await interaction.editReply({
390
+ content: `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`,
391
+ components: [],
392
+ })
393
+ }
394
+ }
395
+
396
+ // ── Step handlers ───────────────────────────────────────────────
397
+
398
+ async function handleProviderStep(
399
+ interaction: StringSelectMenuInteraction,
400
+ ctx: LoginContext,
401
+ hash: string,
402
+ providerId: string,
403
+ ): Promise<void> {
404
+ // Handle pagination nav — re-render the same provider select with new page
405
+ const navPage = parsePaginationValue(providerId)
406
+ if (navPage !== undefined) {
407
+ await interaction.deferUpdate()
408
+ ctx.providerPage = navPage
409
+
410
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
411
+ if (getClient instanceof Error) {
412
+ await interaction.editReply({ content: getClient.message, components: [] })
413
+ return
414
+ }
415
+ const providersResponse = await getClient().provider.list({ directory: ctx.dir })
416
+ if (!providersResponse.data) {
417
+ await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
267
418
  return
268
419
  }
420
+ const { all: allProviders, connected } = providersResponse.data
421
+ const allProviderOptions = [...allProviders]
422
+ .sort((a, b) => {
423
+ const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id)
424
+ const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id)
425
+ const posA = rankA === -1 ? Infinity : rankA
426
+ const posB = rankB === -1 ? Infinity : rankB
427
+ if (posA !== posB) {
428
+ return posA - posB
429
+ }
430
+ return a.name.localeCompare(b.name)
431
+ })
432
+ .map((p) => {
433
+ const isConnected = connected.includes(p.id)
434
+ return {
435
+ label: `${p.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
436
+ value: p.id,
437
+ description: isConnected ? 'Connected - select to re-authenticate' : 'Not connected',
438
+ }
439
+ })
440
+ const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: navPage })
441
+ await interaction.editReply({
442
+ content: '**Authenticate with Provider**\nSelect a provider:',
443
+ components: [
444
+ buildSelectMenu({
445
+ customId: `login_select:${hash}`,
446
+ placeholder: 'Select a provider to authenticate',
447
+ options,
448
+ }),
449
+ ],
450
+ })
451
+ return
452
+ }
269
453
 
270
- // Get provider info for display
271
- const providersResponse = await getClient().provider.list({
272
- directory: context.dir,
454
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
455
+ if (getClient instanceof Error) {
456
+ await interaction.deferUpdate()
457
+ await interaction.editReply({ content: getClient.message, components: [] })
458
+ return
459
+ }
460
+
461
+ const providersResponse = await getClient().provider.list({
462
+ directory: ctx.dir,
463
+ })
464
+ const provider = providersResponse.data?.all.find(
465
+ (p) => p.id === providerId,
466
+ )
467
+ const providerName = provider?.name || providerId
468
+
469
+ const authResponse = await getClient().provider.auth({ directory: ctx.dir })
470
+ if (!authResponse.data) {
471
+ await interaction.deferUpdate()
472
+ await interaction.editReply({
473
+ content: 'Failed to fetch authentication methods',
474
+ components: [],
273
475
  })
476
+ return
477
+ }
274
478
 
275
- const provider = providersResponse.data?.all.find(
276
- (p) => p.id === selectedProviderId,
277
- )
278
- const providerName = provider?.name || selectedProviderId
479
+ // The server returns prompts in the auth response when the opencode
480
+ // version supports it (dev branch, not yet released as of v1.2.27).
481
+ // Once released, plugin-defined prompts will be collected and passed
482
+ // as inputs to the authorize call automatically.
483
+ const methods: ProviderAuthMethod[] = authResponse.data[providerId] || [
484
+ { type: 'api', label: 'API Key' },
485
+ ]
279
486
 
280
- // Get auth methods for all providers
281
- const authMethodsResponse = await getClient().provider.auth({
282
- directory: context.dir,
487
+ if (methods.length === 0) {
488
+ await interaction.deferUpdate()
489
+ await interaction.editReply({
490
+ content: `No authentication methods available for ${providerName}`,
491
+ components: [],
283
492
  })
493
+ return
494
+ }
284
495
 
285
- if (!authMethodsResponse.data) {
286
- await interaction.deferUpdate()
287
- await interaction.editReply({
288
- content: 'Failed to fetch authentication methods',
289
- components: [],
290
- })
291
- return
292
- }
496
+ ctx.providerId = providerId
497
+ ctx.providerName = providerName
293
498
 
294
- // Get methods for this specific provider, default to API key if none defined
295
- const methods: ProviderAuthMethod[] = authMethodsResponse.data[
296
- selectedProviderId
297
- ] || [{ type: 'api', label: 'API Key' }]
499
+ if (methods.length === 1) {
500
+ // Single method skip method select, go straight to prompts or action
501
+ const method = methods[0]!
502
+ ctx.methodIndex = 0
503
+ ctx.methodType = method.type
298
504
 
299
- if (methods.length === 0) {
505
+ const promptSteps = buildPromptSteps(method)
506
+ if (promptSteps.length > 0) {
507
+ // Has prompts — defer and show first prompt
508
+ ctx.steps = promptSteps
509
+ ctx.stepIndex = 0
300
510
  await interaction.deferUpdate()
301
- await interaction.editReply({
302
- content: `No authentication methods available for ${providerName}`,
303
- components: [],
304
- })
305
- return
511
+ await showNextStep(interaction, ctx, hash)
512
+ } else if (method.type === 'api') {
513
+ // API key with no prompts — show modal directly (don't defer)
514
+ await showApiKeyModal(interaction, hash, providerName)
515
+ } else {
516
+ // OAuth with no prompts — defer and authorize
517
+ await interaction.deferUpdate()
518
+ await startOAuthFlow(interaction, ctx, hash)
306
519
  }
520
+ return
521
+ }
307
522
 
308
- // Update context with provider info
309
- context.providerId = selectedProviderId
310
- context.providerName = providerName
311
- pendingLoginContexts.set(contextHash, context)
312
-
313
- // If only one method and it's API, show modal directly (no defer)
314
- if (methods.length === 1 && methods[0]!.type === 'api') {
315
- const method = methods[0]!
316
- context.methodIndex = 0
317
- context.methodType = method.type
318
- context.methodLabel = method.label
319
- pendingLoginContexts.set(contextHash, context)
320
- await showApiKeyModal(interaction, contextHash, providerName)
321
- return
322
- }
523
+ // Multiple methods show method select
524
+ ctx.steps = [
525
+ { type: 'method', methods },
526
+ ]
527
+ ctx.stepIndex = 0
528
+ await interaction.deferUpdate()
529
+ await showNextStep(interaction, ctx, hash)
530
+ }
323
531
 
324
- // For OAuth or multiple methods, defer and continue
532
+ async function handleMethodStep(
533
+ interaction: StringSelectMenuInteraction,
534
+ ctx: LoginContext,
535
+ hash: string,
536
+ value: string,
537
+ step: StepMethod,
538
+ ): Promise<void> {
539
+ const methodIndex = parseInt(value, 10)
540
+ const method = step.methods[methodIndex]
541
+ if (!method) {
325
542
  await interaction.deferUpdate()
543
+ await interaction.editReply({
544
+ content: 'Invalid method selected.',
545
+ components: [],
546
+ })
547
+ return
548
+ }
326
549
 
327
- // If only one method and it's OAuth, start flow directly
328
- if (methods.length === 1) {
329
- const method = methods[0]!
330
- context.methodIndex = 0
331
- context.methodType = method.type
332
- context.methodLabel = method.label
333
- pendingLoginContexts.set(contextHash, context)
334
- await startOAuthFlow(interaction, context, contextHash)
335
- return
550
+ ctx.methodIndex = methodIndex
551
+ ctx.methodType = method.type
552
+
553
+ const promptSteps = buildPromptSteps(method)
554
+ if (promptSteps.length > 0) {
555
+ // Replace remaining steps with prompt steps
556
+ ctx.steps = promptSteps
557
+ ctx.stepIndex = 0
558
+ await interaction.deferUpdate()
559
+ await showNextStep(interaction, ctx, hash)
560
+ } else if (method.type === 'api') {
561
+ // API key with no prompts — show modal directly (don't defer)
562
+ await showApiKeyModal(interaction, hash, ctx.providerName || '')
563
+ } else {
564
+ // OAuth with no prompts
565
+ await interaction.deferUpdate()
566
+ await startOAuthFlow(interaction, ctx, hash)
567
+ }
568
+ }
569
+
570
+ async function handlePromptStep(
571
+ interaction: StringSelectMenuInteraction,
572
+ ctx: LoginContext,
573
+ hash: string,
574
+ value: string,
575
+ step: StepPrompt,
576
+ ): Promise<void> {
577
+ // Store the answer
578
+ ctx.inputs[step.prompt.key] = value
579
+ ctx.stepIndex++
580
+
581
+ // Find the next prompt step that passes its `when` condition
582
+ await interaction.deferUpdate()
583
+ await showNextStep(interaction, ctx, hash)
584
+ }
585
+
586
+ // ── Step rendering ──────────────────────────────────────────────
587
+ // Advances through steps, skipping prompts whose `when` condition
588
+ // fails, until it finds one to show or reaches the end.
589
+
590
+ async function showNextStep(
591
+ interaction: StringSelectMenuInteraction | ModalSubmitInteraction,
592
+ ctx: LoginContext,
593
+ hash: string,
594
+ ): Promise<void> {
595
+ // Skip prompts whose `when` condition doesn't match
596
+ while (ctx.stepIndex < ctx.steps.length) {
597
+ const step = ctx.steps[ctx.stepIndex]!
598
+ if (step.type === 'prompt' && !shouldShowPrompt(step.prompt, ctx.inputs)) {
599
+ ctx.stepIndex++
600
+ continue
336
601
  }
602
+ break
603
+ }
337
604
 
338
- // Multiple methods - show selection menu
339
- const options = methods.slice(0, 25).map((method, index) => ({
605
+ if (ctx.stepIndex >= ctx.steps.length) {
606
+ // All steps done proceed to action
607
+ if (ctx.methodType === 'api') {
608
+ // We're deferred, so show a button that opens the API key modal
609
+ const button = new ButtonBuilder()
610
+ .setCustomId(`login_apikey_btn:${hash}`)
611
+ .setLabel('Enter API Key')
612
+ .setStyle(ButtonStyle.Primary)
613
+ await interaction.editReply({
614
+ content: `**Authenticate with ${ctx.providerName}**\nClick to enter your API key.`,
615
+ components: [
616
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
617
+ ],
618
+ })
619
+ } else {
620
+ await startOAuthFlow(interaction, ctx, hash)
621
+ }
622
+ return
623
+ }
624
+
625
+ const step = ctx.steps[ctx.stepIndex]!
626
+ pendingLoginContexts.set(hash, ctx)
627
+
628
+ if (step.type === 'method') {
629
+ const options = step.methods.slice(0, 25).map((method, index) => ({
340
630
  label: method.label.slice(0, 100),
341
631
  value: String(index),
342
632
  description:
@@ -345,48 +635,129 @@ export async function handleLoginProviderSelectMenu(
345
635
  : 'Enter API key manually',
346
636
  }))
347
637
 
348
- const selectMenu = new StringSelectMenuBuilder()
349
- .setCustomId(`login_method:${contextHash}`)
350
- .setPlaceholder('Select authentication method')
351
- .addOptions(options)
352
-
353
- const actionRow =
354
- new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
355
-
356
638
  await interaction.editReply({
357
- content: `**Authenticate with ${providerName}**\nSelect authentication method:`,
358
- components: [actionRow],
639
+ content: `**Authenticate with ${ctx.providerName}**\nSelect authentication method:`,
640
+ components: [
641
+ buildSelectMenu({
642
+ customId: `login_select:${hash}`,
643
+ placeholder: 'Select authentication method',
644
+ options,
645
+ }),
646
+ ],
359
647
  })
360
- } catch (error) {
361
- loginLogger.error('Error loading auth methods:', error)
362
- if (!interaction.deferred && !interaction.replied) {
363
- await interaction.deferUpdate()
648
+ return
649
+ }
650
+
651
+ if (step.type === 'prompt') {
652
+ const prompt = step.prompt
653
+ if (prompt.type === 'select') {
654
+ const options = prompt.options.slice(0, 25).map((opt) => ({
655
+ label: opt.label.slice(0, 100),
656
+ value: opt.value,
657
+ description: opt.hint?.slice(0, 100),
658
+ }))
659
+
660
+ await interaction.editReply({
661
+ content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
662
+ components: [
663
+ buildSelectMenu({
664
+ customId: `login_select:${hash}`,
665
+ placeholder: prompt.message.slice(0, 150),
666
+ options,
667
+ }),
668
+ ],
669
+ })
670
+ return
671
+ }
672
+
673
+ if (prompt.type === 'text') {
674
+ // Text prompts need a modal, but we're deferred. Show a button.
675
+ const button = new ButtonBuilder()
676
+ .setCustomId(`login_text_btn:${hash}`)
677
+ .setLabel(prompt.message.slice(0, 80))
678
+ .setStyle(ButtonStyle.Primary)
679
+
680
+ await interaction.editReply({
681
+ content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
682
+ components: [
683
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
684
+ ],
685
+ })
686
+ return
364
687
  }
365
- await interaction.editReply({
366
- content: `Failed to load auth methods: ${error instanceof Error ? error.message : 'Unknown error'}`,
367
- components: [],
368
- })
369
688
  }
370
689
  }
371
690
 
372
- /**
373
- * Handle the auth method select menu interaction.
374
- * Starts OAuth flow or shows API key modal.
375
- */
376
- export async function handleLoginMethodSelectMenu(
377
- interaction: StringSelectMenuInteraction,
691
+ function buildPromptSteps(method: ProviderAuthMethod): StepPrompt[] {
692
+ return (method.prompts || []).map((prompt) => ({
693
+ type: 'prompt' as const,
694
+ prompt,
695
+ }))
696
+ }
697
+
698
+ // ── Text prompt button + modal ──────────────────────────────────
699
+ // When a text prompt needs to be shown but we're in a deferred state,
700
+ // we show a button. Clicking it opens a modal for text input.
701
+
702
+ export async function handleLoginTextButton(
703
+ interaction: ButtonInteraction,
378
704
  ): Promise<void> {
379
- const customId = interaction.customId
705
+ if (!interaction.customId.startsWith('login_text_btn:')) {
706
+ return
707
+ }
708
+
709
+ const hash = interaction.customId.replace('login_text_btn:', '')
710
+ const ctx = pendingLoginContexts.get(hash)
380
711
 
381
- if (!customId.startsWith('login_method:')) {
712
+ if (!ctx) {
713
+ await interaction.reply({
714
+ content: 'Selection expired. Please run /login again.',
715
+ flags: MessageFlags.Ephemeral,
716
+ })
382
717
  return
383
718
  }
384
719
 
385
- const contextHash = customId.replace('login_method:', '')
386
- const context = pendingLoginContexts.get(contextHash)
720
+ const step = ctx.steps[ctx.stepIndex]
721
+ if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
722
+ await interaction.reply({
723
+ content: 'Invalid state. Please run /login again.',
724
+ flags: MessageFlags.Ephemeral,
725
+ })
726
+ return
727
+ }
387
728
 
388
- if (!context || !context.providerId || !context.providerName) {
389
- await interaction.deferUpdate()
729
+ const modal = new ModalBuilder()
730
+ .setCustomId(`login_text:${hash}`)
731
+ .setTitle(`${ctx.providerName || 'Provider'} Login`.slice(0, 45))
732
+
733
+ const textInput = new TextInputBuilder()
734
+ .setCustomId('prompt_value')
735
+ .setLabel(step.prompt.message.slice(0, 45))
736
+ .setPlaceholder(
737
+ step.prompt.type === 'text' ? (step.prompt.placeholder || '') : '',
738
+ )
739
+ .setStyle(TextInputStyle.Short)
740
+ .setRequired(true)
741
+
742
+ modal.addComponents(
743
+ new ActionRowBuilder<TextInputBuilder>().addComponents(textInput),
744
+ )
745
+ await interaction.showModal(modal)
746
+ }
747
+
748
+ export async function handleLoginTextModalSubmit(
749
+ interaction: ModalSubmitInteraction,
750
+ ): Promise<void> {
751
+ if (!interaction.customId.startsWith('login_text:')) {
752
+ return
753
+ }
754
+
755
+ await interaction.deferUpdate()
756
+
757
+ const hash = interaction.customId.replace('login_text:', '')
758
+ const ctx = pendingLoginContexts.get(hash)
759
+
760
+ if (!ctx) {
390
761
  await interaction.editReply({
391
762
  content: 'Selection expired. Please run /login again.',
392
763
  components: [],
@@ -394,78 +765,60 @@ export async function handleLoginMethodSelectMenu(
394
765
  return
395
766
  }
396
767
 
397
- const selectedMethodIndex = parseInt(interaction.values[0] || '0', 10)
398
-
399
- try {
400
- const getClient = await initializeOpencodeForDirectory(context.dir)
401
- if (getClient instanceof Error) {
402
- await interaction.deferUpdate()
403
- await interaction.editReply({
404
- content: getClient.message,
405
- components: [],
406
- })
407
- return
408
- }
768
+ const step = ctx.steps[ctx.stepIndex]
769
+ if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
770
+ await interaction.editReply({
771
+ content: 'Invalid state. Please run /login again.',
772
+ components: [],
773
+ })
774
+ return
775
+ }
409
776
 
410
- // Get auth methods again to get the selected one
411
- const authMethodsResponse = await getClient().provider.auth({
412
- directory: context.dir,
777
+ const value = interaction.fields.getTextInputValue('prompt_value')
778
+ if (!value?.trim()) {
779
+ await interaction.editReply({
780
+ content: 'A value is required.',
781
+ components: [],
413
782
  })
783
+ return
784
+ }
414
785
 
415
- const methods: ProviderAuthMethod[] = authMethodsResponse.data?.[
416
- context.providerId
417
- ] || [{ type: 'api', label: 'API Key' }]
786
+ ctx.inputs[step.prompt.key] = value.trim()
787
+ ctx.stepIndex++
788
+ await showNextStep(interaction, ctx, hash)
789
+ }
418
790
 
419
- const selectedMethod = methods[selectedMethodIndex]
420
- if (!selectedMethod) {
421
- await interaction.deferUpdate()
422
- await interaction.editReply({
423
- content: 'Invalid method selected',
424
- components: [],
425
- })
426
- return
427
- }
791
+ // ── API key button + modal ──────────────────────────────────────
792
+ // When we're deferred and need an API key modal, show a button first.
428
793
 
429
- // Update context
430
- context.methodIndex = selectedMethodIndex
431
- context.methodType = selectedMethod.type
432
- context.methodLabel = selectedMethod.label
433
- pendingLoginContexts.set(contextHash, context)
794
+ export async function handleLoginApiKeyButton(
795
+ interaction: ButtonInteraction,
796
+ ): Promise<void> {
797
+ if (!interaction.customId.startsWith('login_apikey_btn:')) {
798
+ return
799
+ }
434
800
 
435
- if (selectedMethod.type === 'api') {
436
- // Show API key modal (don't defer for modals)
437
- await showApiKeyModal(interaction, contextHash, context.providerName)
438
- } else {
439
- // Start OAuth flow
440
- await interaction.deferUpdate()
441
- await startOAuthFlow(interaction, context, contextHash)
442
- }
443
- } catch (error) {
444
- loginLogger.error('Error processing auth method:', error)
445
- try {
446
- if (!interaction.deferred && !interaction.replied) {
447
- await interaction.deferUpdate()
448
- }
449
- await interaction.editReply({
450
- content: `Failed to process auth method: ${error instanceof Error ? error.message : 'Unknown error'}`,
451
- components: [],
452
- })
453
- } catch {
454
- // Ignore follow-up errors
455
- }
801
+ const hash = interaction.customId.replace('login_apikey_btn:', '')
802
+ const ctx = pendingLoginContexts.get(hash)
803
+
804
+ if (!ctx || !ctx.providerName) {
805
+ await interaction.reply({
806
+ content: 'Selection expired. Please run /login again.',
807
+ flags: MessageFlags.Ephemeral,
808
+ })
809
+ return
456
810
  }
811
+
812
+ await showApiKeyModal(interaction, hash, ctx.providerName)
457
813
  }
458
814
 
459
- /**
460
- * Show API key input modal.
461
- */
462
815
  async function showApiKeyModal(
463
- interaction: StringSelectMenuInteraction,
464
- contextHash: string,
816
+ interaction: StringSelectMenuInteraction | ButtonInteraction,
817
+ hash: string,
465
818
  providerName: string,
466
819
  ): Promise<void> {
467
820
  const modal = new ModalBuilder()
468
- .setCustomId(`login_apikey:${contextHash}`)
821
+ .setCustomId(`login_apikey:${hash}`)
469
822
  .setTitle(`${providerName} API Key`.slice(0, 45))
470
823
 
471
824
  const apiKeyInput = new TextInputBuilder()
@@ -475,38 +828,83 @@ async function showApiKeyModal(
475
828
  .setStyle(TextInputStyle.Short)
476
829
  .setRequired(true)
477
830
 
478
- const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
479
- apiKeyInput,
831
+ modal.addComponents(
832
+ new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput),
480
833
  )
481
- modal.addComponents(actionRow)
834
+ await interaction.showModal(modal)
835
+ }
836
+
837
+ // ── OAuth code submission (code mode) ───────────────────────────
838
+ // When the OAuth flow returns method="code", the user completes login
839
+ // in a browser (possibly on a different machine) and pastes the final
840
+ // callback URL or authorization code here.
482
841
 
842
+ export async function handleOAuthCodeButton(
843
+ interaction: ButtonInteraction,
844
+ ): Promise<void> {
845
+ if (!interaction.customId.startsWith('login_oauth_code_btn:')) {
846
+ return
847
+ }
848
+
849
+ const hash = interaction.customId.replace('login_oauth_code_btn:', '')
850
+ const ctx = pendingLoginContexts.get(hash)
851
+
852
+ if (!ctx || !ctx.providerId || !ctx.providerName) {
853
+ await interaction.reply({
854
+ content: 'Selection expired. Please run /login again.',
855
+ flags: MessageFlags.Ephemeral,
856
+ })
857
+ return
858
+ }
859
+
860
+ const modal = new ModalBuilder()
861
+ .setCustomId(`login_oauth_code:${hash}`)
862
+ .setTitle(`${ctx.providerName} Authorization`.slice(0, 45))
863
+
864
+ const codeInput = new TextInputBuilder()
865
+ .setCustomId('oauth_code')
866
+ .setLabel('Authorization code or callback URL')
867
+ .setPlaceholder('Paste the code or full callback URL')
868
+ .setStyle(TextInputStyle.Paragraph)
869
+ .setRequired(true)
870
+
871
+ modal.addComponents(
872
+ new ActionRowBuilder<TextInputBuilder>().addComponents(codeInput),
873
+ )
483
874
  await interaction.showModal(modal)
484
875
  }
485
876
 
486
- /**
487
- * Start OAuth authorization flow.
488
- */
489
- async function startOAuthFlow(
490
- interaction: StringSelectMenuInteraction,
491
- context: {
492
- dir: string
493
- providerId?: string
494
- providerName?: string
495
- methodIndex?: number
496
- methodLabel?: string
497
- },
498
- contextHash: string,
877
+ export async function handleOAuthCodeModalSubmit(
878
+ interaction: ModalSubmitInteraction,
499
879
  ): Promise<void> {
500
- if (!context.providerId || context.methodIndex === undefined) {
880
+ if (!interaction.customId.startsWith('login_oauth_code:')) {
881
+ return
882
+ }
883
+
884
+ await interaction.deferUpdate()
885
+
886
+ const hash = interaction.customId.replace('login_oauth_code:', '')
887
+ const ctx = pendingLoginContexts.get(hash)
888
+
889
+ if (!ctx || !ctx.providerId || !ctx.providerName || ctx.methodIndex === undefined) {
501
890
  await interaction.editReply({
502
- content: 'Invalid context for OAuth flow',
891
+ content: 'Session expired. Please run /login again.',
892
+ components: [],
893
+ })
894
+ return
895
+ }
896
+
897
+ const code = interaction.fields.getTextInputValue('oauth_code')?.trim()
898
+ if (!code) {
899
+ await interaction.editReply({
900
+ content: 'Authorization code is required.',
503
901
  components: [],
504
902
  })
505
903
  return
506
904
  }
507
905
 
508
906
  try {
509
- const getClient = await initializeOpencodeForDirectory(context.dir)
907
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
510
908
  if (getClient instanceof Error) {
511
909
  await interaction.editReply({
512
910
  content: getClient.message,
@@ -516,88 +914,36 @@ async function startOAuthFlow(
516
914
  }
517
915
 
518
916
  await interaction.editReply({
519
- content: `**Authenticating with ${context.providerName}**\nStarting authorization...`,
917
+ content: `**Authenticating with ${ctx.providerName}**\nVerifying authorization...`,
520
918
  components: [],
521
919
  })
522
920
 
523
- // Start OAuth authorization
524
- const authorizeResponse = await getClient().provider.oauth.authorize({
525
- providerID: context.providerId,
526
- method: context.methodIndex,
527
- directory: context.dir,
921
+ const callbackResponse = await getClient().provider.oauth.callback({
922
+ providerID: ctx.providerId,
923
+ method: ctx.methodIndex,
924
+ code,
925
+ directory: ctx.dir,
528
926
  })
529
927
 
530
- if (!authorizeResponse.data) {
531
- const errorData = authorizeResponse.error as
532
- | { data?: { message?: string } }
533
- | undefined
928
+ if (callbackResponse.error) {
929
+ pendingLoginContexts.delete(hash)
534
930
  await interaction.editReply({
535
- content: `Failed to start authorization: ${errorData?.data?.message || 'Unknown error'}`,
931
+ content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization code was invalid or expired' })}`,
536
932
  components: [],
537
933
  })
538
934
  return
539
935
  }
540
936
 
541
- const { url, method, instructions } = authorizeResponse.data
542
-
543
- // Show authorization URL and instructions
544
- let message = `**Authenticating with ${context.providerName}**\n\n`
545
- message += `Open this URL to authorize:\n${url}\n\n`
546
-
547
- if (instructions) {
548
- // Extract code from instructions like "Enter code: ABC-123"
549
- const codeMatch = instructions.match(/code[:\s]+([A-Z0-9-]+)/i)
550
- if (codeMatch) {
551
- message += `**Code:** \`${codeMatch[1]}\`\n\n`
552
- } else {
553
- message += `${instructions}\n\n`
554
- }
555
- }
556
-
557
- if (method === 'auto') {
558
- message += '_Waiting for authorization to complete..._'
559
- }
937
+ await getClient().instance.dispose({ directory: ctx.dir })
938
+ pendingLoginContexts.delete(hash)
560
939
 
561
940
  await interaction.editReply({
562
- content: message,
941
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
563
942
  components: [],
564
943
  })
565
-
566
- if (method === 'auto') {
567
- // Poll for completion (device flow)
568
- const callbackResponse = await getClient().provider.oauth.callback({
569
- providerID: context.providerId,
570
- method: context.methodIndex,
571
- directory: context.dir,
572
- })
573
-
574
- if (callbackResponse.error) {
575
- const errorData = callbackResponse.error as
576
- | { data?: { message?: string } }
577
- | undefined
578
- await interaction.editReply({
579
- content: `**Authentication Failed**\n${errorData?.data?.message || 'Authorization was not completed'}`,
580
- components: [],
581
- })
582
- return
583
- }
584
-
585
- // Dispose to refresh provider state so new credentials are recognized
586
- await getClient().instance.dispose({ directory: context.dir })
587
-
588
- await interaction.editReply({
589
- content: `✅ **Successfully authenticated with ${context.providerName}!**\n\nYou can now use models from this provider.`,
590
- components: [],
591
- })
592
- }
593
- // For 'code' method, we would need to prompt for code input
594
- // But Discord modals can't be shown after deferUpdate, so we'd need a different flow
595
- // For now, most providers use 'auto' (device flow) which works well for Discord
596
-
597
- // Clean up context
598
- pendingLoginContexts.delete(contextHash)
599
944
  } catch (error) {
600
- loginLogger.error('OAuth flow error:', error)
945
+ loginLogger.error('OAuth code submission error:', error)
946
+ pendingLoginContexts.delete(hash)
601
947
  await interaction.editReply({
602
948
  content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
603
949
  components: [],
@@ -605,24 +951,19 @@ async function startOAuthFlow(
605
951
  }
606
952
  }
607
953
 
608
- /**
609
- * Handle API key modal submission.
610
- */
611
954
  export async function handleApiKeyModalSubmit(
612
955
  interaction: ModalSubmitInteraction,
613
956
  ): Promise<void> {
614
- const customId = interaction.customId
615
-
616
- if (!customId.startsWith('login_apikey:')) {
957
+ if (!interaction.customId.startsWith('login_apikey:')) {
617
958
  return
618
959
  }
619
960
 
620
961
  await interaction.deferReply({ flags: MessageFlags.Ephemeral })
621
962
 
622
- const contextHash = customId.replace('login_apikey:', '')
623
- const context = pendingLoginContexts.get(contextHash)
963
+ const hash = interaction.customId.replace('login_apikey:', '')
964
+ const ctx = pendingLoginContexts.get(hash)
624
965
 
625
- if (!context || !context.providerId || !context.providerName) {
966
+ if (!ctx || !ctx.providerId || !ctx.providerName) {
626
967
  await interaction.editReply({
627
968
  content: 'Session expired. Please run /login again.',
628
969
  })
@@ -632,39 +973,30 @@ export async function handleApiKeyModalSubmit(
632
973
  const apiKey = interaction.fields.getTextInputValue('apikey')
633
974
 
634
975
  if (!apiKey?.trim()) {
635
- await interaction.editReply({
636
- content: 'API key is required.',
637
- })
976
+ await interaction.editReply({ content: 'API key is required.' })
638
977
  return
639
978
  }
640
979
 
641
980
  try {
642
- const getClient = await initializeOpencodeForDirectory(context.dir)
981
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
643
982
  if (getClient instanceof Error) {
644
- await interaction.editReply({
645
- content: getClient.message,
646
- })
983
+ await interaction.editReply({ content: getClient.message })
647
984
  return
648
985
  }
649
986
 
650
- // Set the API key
651
987
  await getClient().auth.set({
652
- providerID: context.providerId,
653
- auth: {
654
- type: 'api',
655
- key: apiKey.trim(),
656
- },
988
+ providerID: ctx.providerId,
989
+ auth: { type: 'api', key: apiKey.trim() },
657
990
  })
658
991
 
659
992
  // Dispose to refresh provider state so new credentials are recognized
660
- await getClient().instance.dispose({ directory: context.dir })
993
+ await getClient().instance.dispose({ directory: ctx.dir })
661
994
 
662
995
  await interaction.editReply({
663
- content: `✅ **Successfully authenticated with ${context.providerName}!**\n\nYou can now use models from this provider.`,
996
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
664
997
  })
665
998
 
666
- // Clean up context
667
- pendingLoginContexts.delete(contextHash)
999
+ pendingLoginContexts.delete(hash)
668
1000
  } catch (error) {
669
1001
  loginLogger.error('API key save error:', error)
670
1002
  await interaction.editReply({
@@ -672,3 +1004,170 @@ export async function handleApiKeyModalSubmit(
672
1004
  })
673
1005
  }
674
1006
  }
1007
+
1008
+ // ── OAuth flow ──────────────────────────────────────────────────
1009
+
1010
+ async function startOAuthFlow(
1011
+ interaction: StringSelectMenuInteraction | ModalSubmitInteraction,
1012
+ ctx: LoginContext,
1013
+ hash: string,
1014
+ ): Promise<void> {
1015
+ if (!ctx.providerId || ctx.methodIndex === undefined) {
1016
+ await interaction.editReply({
1017
+ content: 'Invalid context for OAuth flow',
1018
+ components: [],
1019
+ })
1020
+ return
1021
+ }
1022
+
1023
+ try {
1024
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
1025
+ if (getClient instanceof Error) {
1026
+ await interaction.editReply({
1027
+ content: getClient.message,
1028
+ components: [],
1029
+ })
1030
+ return
1031
+ }
1032
+
1033
+ await interaction.editReply({
1034
+ content: `**Authenticating with ${ctx.providerName}**\nStarting authorization...`,
1035
+ components: [],
1036
+ })
1037
+
1038
+ // Direct fetch to the server because the SDK's buildClientParams drops
1039
+ // unknown keys — `inputs` would be silently stripped. The server accepts
1040
+ // `inputs` in the body (see opencode server/routes/provider.ts).
1041
+ const port = getOpencodeServerPort()
1042
+ if (!port) {
1043
+ await interaction.editReply({
1044
+ content: 'OpenCode server is not running. Please try again.',
1045
+ components: [],
1046
+ })
1047
+ return
1048
+ }
1049
+
1050
+ const hasInputs = Object.keys(ctx.inputs).length > 0
1051
+ const authorizeUrl = new URL(
1052
+ `/provider/${encodeURIComponent(ctx.providerId)}/oauth/authorize`,
1053
+ `http://127.0.0.1:${port}`,
1054
+ )
1055
+ authorizeUrl.searchParams.set('directory', ctx.dir)
1056
+
1057
+ // Include basic auth if OPENCODE_SERVER_PASSWORD is set,
1058
+ // matching the opencode server's optional basicAuth middleware.
1059
+ const fetchHeaders: Record<string, string> = {
1060
+ 'Content-Type': 'application/json',
1061
+ 'x-opencode-directory': ctx.dir,
1062
+ }
1063
+ const serverPassword = process.env.OPENCODE_SERVER_PASSWORD
1064
+ if (serverPassword) {
1065
+ const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'
1066
+ fetchHeaders['Authorization'] =
1067
+ `Basic ${Buffer.from(`${username}:${serverPassword}`).toString('base64')}`
1068
+ }
1069
+
1070
+ const authorizeRes = await fetch(authorizeUrl, {
1071
+ method: 'POST',
1072
+ headers: fetchHeaders,
1073
+ body: JSON.stringify({
1074
+ method: ctx.methodIndex,
1075
+ ...(hasInputs ? { inputs: ctx.inputs } : {}),
1076
+ }),
1077
+ })
1078
+
1079
+ if (!authorizeRes.ok) {
1080
+ const errorText = await authorizeRes.text().catch(() => '')
1081
+ let errorMessage = 'Unknown error'
1082
+ try {
1083
+ const parsed = JSON.parse(errorText) as {
1084
+ message?: string
1085
+ data?: { message?: string }
1086
+ }
1087
+ errorMessage = parsed?.data?.message || parsed?.message || errorMessage
1088
+ } catch {
1089
+ errorMessage = errorText || errorMessage
1090
+ }
1091
+ await interaction.editReply({
1092
+ content: `Failed to start authorization: ${errorMessage}`,
1093
+ components: [],
1094
+ })
1095
+ return
1096
+ }
1097
+
1098
+ const { url, method, instructions } = (await authorizeRes.json()) as {
1099
+ url: string
1100
+ method: 'auto' | 'code'
1101
+ instructions: string
1102
+ }
1103
+
1104
+ let message = `**Authenticating with ${ctx.providerName}**\n\n`
1105
+ message += `Open this URL to authorize:\n${url}\n\n`
1106
+
1107
+ if (instructions) {
1108
+ // Match "code: ABC-123" or "code: WXYZ1234" but not natural language
1109
+ // like "code will". Require a colon separator and uppercase alphanum code.
1110
+ const codeMatch = instructions.match(/code:\s*([A-Z0-9][A-Z0-9-]+)/)
1111
+ if (codeMatch) {
1112
+ message += `**Code:** \`${codeMatch[1]}\`\n\n`
1113
+ } else {
1114
+ message += `${instructions}\n\n`
1115
+ }
1116
+ }
1117
+
1118
+ if (method === 'auto') {
1119
+ message += '_Waiting for authorization to complete..._'
1120
+ }
1121
+
1122
+ if (method === 'code') {
1123
+ // Code mode: show a button to paste the auth code/URL after
1124
+ // completing login in a browser (possibly on a different machine).
1125
+ const button = new ButtonBuilder()
1126
+ .setCustomId(`login_oauth_code_btn:${hash}`)
1127
+ .setLabel('Paste authorization code')
1128
+ .setStyle(ButtonStyle.Primary)
1129
+
1130
+ await interaction.editReply({
1131
+ content: message,
1132
+ components: [
1133
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
1134
+ ],
1135
+ })
1136
+ // Don't delete context — we need it for the code submission
1137
+ return
1138
+ }
1139
+
1140
+ await interaction.editReply({ content: message, components: [] })
1141
+
1142
+ // Auto mode: poll for completion (device flow / localhost callback)
1143
+ const callbackResponse = await getClient().provider.oauth.callback({
1144
+ providerID: ctx.providerId,
1145
+ method: ctx.methodIndex,
1146
+ directory: ctx.dir,
1147
+ })
1148
+
1149
+ if (callbackResponse.error) {
1150
+ pendingLoginContexts.delete(hash)
1151
+ await interaction.editReply({
1152
+ content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization was not completed' })}`,
1153
+ components: [],
1154
+ })
1155
+ return
1156
+ }
1157
+
1158
+ await getClient().instance.dispose({ directory: ctx.dir })
1159
+ pendingLoginContexts.delete(hash)
1160
+
1161
+ await interaction.editReply({
1162
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
1163
+ components: [],
1164
+ })
1165
+ } catch (error) {
1166
+ loginLogger.error('OAuth flow error:', error)
1167
+ pendingLoginContexts.delete(hash)
1168
+ await interaction.editReply({
1169
+ content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
1170
+ components: [],
1171
+ })
1172
+ }
1173
+ }