@otto-assistant/otto 0.1.2 → 0.7.15

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 (638) hide show
  1. package/bin.js +2 -0
  2. package/dist/agent-model.e2e.test.js +755 -0
  3. package/dist/ai-tool-to-genai.js +233 -0
  4. package/dist/ai-tool-to-genai.test.js +267 -0
  5. package/dist/ai-tool.js +6 -0
  6. package/dist/anthropic-account-identity.js +62 -0
  7. package/dist/anthropic-account-identity.test.js +38 -0
  8. package/dist/anthropic-auth-plugin.js +917 -0
  9. package/dist/anthropic-auth-state.js +303 -0
  10. package/dist/anthropic-auth-state.test.js +150 -0
  11. package/dist/bin.js +152 -0
  12. package/dist/btw-prefix-detection.js +17 -0
  13. package/dist/btw-prefix-detection.test.js +63 -0
  14. package/dist/channel-management.js +259 -0
  15. package/dist/cli-parsing.test.js +142 -0
  16. package/dist/cli-send-thread.e2e.test.js +353 -0
  17. package/dist/cli-telegram-options.test.js +99 -0
  18. package/dist/cli.js +4210 -568
  19. package/dist/commands/abort.js +65 -0
  20. package/dist/commands/action-buttons.js +245 -0
  21. package/dist/commands/add-dir.js +124 -0
  22. package/dist/commands/add-dir.test.js +126 -0
  23. package/dist/commands/add-project.js +113 -0
  24. package/dist/commands/agent.js +355 -0
  25. package/dist/commands/ask-question.js +320 -0
  26. package/dist/commands/ask-question.test.js +92 -0
  27. package/dist/commands/btw.js +121 -0
  28. package/dist/commands/cli-commands-group-a.test.js +728 -0
  29. package/dist/commands/cli-commands-group-b.test.js +695 -0
  30. package/dist/commands/compact.js +120 -0
  31. package/dist/commands/context-usage.js +140 -0
  32. package/dist/commands/create-new-project.js +130 -0
  33. package/dist/commands/diff.js +63 -0
  34. package/dist/commands/discord-commands-group-a.test.js +621 -0
  35. package/dist/commands/discord-commands-group-b.test.js +595 -0
  36. package/dist/commands/discord-commands-group-c.test.js +739 -0
  37. package/dist/commands/file-upload.js +275 -0
  38. package/dist/commands/fork-subagent.js +177 -0
  39. package/dist/commands/fork.js +262 -0
  40. package/dist/commands/gemini-apikey.js +70 -0
  41. package/dist/commands/login.js +887 -0
  42. package/dist/commands/mcp.js +239 -0
  43. package/dist/commands/memory-snapshot.js +24 -0
  44. package/dist/commands/mention-mode.js +44 -0
  45. package/dist/commands/merge-worktree.js +162 -0
  46. package/dist/commands/model-variant.js +366 -0
  47. package/dist/commands/model.js +794 -0
  48. package/dist/commands/new-worktree.js +465 -0
  49. package/dist/commands/paginated-select.js +57 -0
  50. package/dist/commands/permissions.js +274 -0
  51. package/dist/commands/queue.js +223 -0
  52. package/dist/commands/remove-project.js +115 -0
  53. package/dist/commands/restart-opencode-server.js +127 -0
  54. package/dist/commands/resume.js +149 -0
  55. package/dist/commands/run-command.js +79 -0
  56. package/dist/commands/screenshare.js +303 -0
  57. package/dist/commands/screenshare.test.js +20 -0
  58. package/dist/commands/session-id.js +78 -0
  59. package/dist/commands/session.js +176 -0
  60. package/dist/commands/share.js +80 -0
  61. package/dist/commands/tasks.js +205 -0
  62. package/dist/commands/thread-deletion-sync.js +50 -0
  63. package/dist/commands/types.js +2 -0
  64. package/dist/commands/undo-redo.js +305 -0
  65. package/dist/commands/unset-model.js +139 -0
  66. package/dist/commands/upgrade.js +48 -0
  67. package/dist/commands/user-command.js +155 -0
  68. package/dist/commands/verbosity.js +125 -0
  69. package/dist/commands/vscode.js +269 -0
  70. package/dist/commands/worktree-settings.js +43 -0
  71. package/dist/commands/worktrees.js +468 -0
  72. package/dist/condense-memory.js +33 -0
  73. package/dist/config.js +100 -255
  74. package/dist/context-awareness-plugin.js +340 -0
  75. package/dist/context-awareness-plugin.test.js +126 -0
  76. package/dist/critique-utils.js +95 -0
  77. package/dist/database.js +1355 -0
  78. package/dist/db.js +260 -0
  79. package/dist/db.test.js +138 -0
  80. package/dist/debounce-timeout.js +28 -0
  81. package/dist/debounced-process-flush.js +77 -0
  82. package/dist/discord-bot.js +1124 -0
  83. package/dist/discord-command-registration.js +567 -0
  84. package/dist/discord-urls.js +82 -0
  85. package/dist/discord-utils.js +616 -0
  86. package/dist/discord-utils.test.js +134 -0
  87. package/dist/errors.js +157 -0
  88. package/dist/escape-backticks.test.js +429 -0
  89. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  90. package/dist/eventsource-parser.test.js +327 -0
  91. package/dist/exec-async.js +26 -0
  92. package/dist/external-opencode-sync.js +480 -0
  93. package/dist/format-tables.js +491 -0
  94. package/dist/format-tables.test.js +478 -0
  95. package/dist/forum-sync/config.js +79 -0
  96. package/dist/forum-sync/discord-operations.js +154 -0
  97. package/dist/forum-sync/index.js +5 -0
  98. package/dist/forum-sync/markdown.js +113 -0
  99. package/dist/forum-sync/sync-to-discord.js +417 -0
  100. package/dist/forum-sync/sync-to-files.js +190 -0
  101. package/dist/forum-sync/types.js +53 -0
  102. package/dist/forum-sync/watchers.js +307 -0
  103. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  104. package/dist/gateway-proxy.e2e.test.js +485 -0
  105. package/dist/genai-worker-wrapper.js +111 -0
  106. package/dist/genai-worker.js +311 -0
  107. package/dist/genai.js +232 -0
  108. package/dist/generated/browser.js +17 -0
  109. package/dist/generated/client.js +37 -0
  110. package/dist/generated/commonInputTypes.js +10 -0
  111. package/dist/generated/enums.js +58 -0
  112. package/dist/generated/internal/class.js +49 -0
  113. package/dist/generated/internal/prismaNamespace.js +254 -0
  114. package/dist/generated/internal/prismaNamespaceBrowser.js +224 -0
  115. package/dist/generated/models/bot_api_keys.js +1 -0
  116. package/dist/generated/models/bot_tokens.js +1 -0
  117. package/dist/generated/models/channel_agents.js +1 -0
  118. package/dist/generated/models/channel_directories.js +1 -0
  119. package/dist/generated/models/channel_mention_mode.js +1 -0
  120. package/dist/generated/models/channel_models.js +1 -0
  121. package/dist/generated/models/channel_verbosity.js +1 -0
  122. package/dist/generated/models/channel_worktrees.js +1 -0
  123. package/dist/generated/models/forum_sync_configs.js +1 -0
  124. package/dist/generated/models/global_models.js +1 -0
  125. package/dist/generated/models/ipc_requests.js +1 -0
  126. package/dist/generated/models/part_messages.js +1 -0
  127. package/dist/generated/models/scheduled_tasks.js +1 -0
  128. package/dist/generated/models/session_agents.js +1 -0
  129. package/dist/generated/models/session_events.js +1 -0
  130. package/dist/generated/models/session_models.js +1 -0
  131. package/dist/generated/models/session_start_sources.js +1 -0
  132. package/dist/generated/models/thread_sessions.js +1 -0
  133. package/dist/generated/models/thread_worktrees.js +1 -0
  134. package/dist/generated/models.js +1 -0
  135. package/dist/heap-monitor.js +122 -0
  136. package/dist/hrana-server.js +251 -0
  137. package/dist/hrana-server.test.js +370 -0
  138. package/dist/html-actions.js +123 -0
  139. package/dist/html-actions.test.js +70 -0
  140. package/dist/html-components.js +117 -0
  141. package/dist/html-components.test.js +34 -0
  142. package/dist/image-optimizer-plugin.js +153 -0
  143. package/dist/image-utils.js +112 -0
  144. package/dist/interaction-handler.js +420 -0
  145. package/dist/ipc-polling.js +327 -0
  146. package/dist/ipc-tools-plugin.js +193 -0
  147. package/dist/ipc-utils.js +18 -0
  148. package/dist/limit-heading-depth.js +25 -0
  149. package/dist/limit-heading-depth.test.js +105 -0
  150. package/dist/logger.js +171 -0
  151. package/dist/markdown.js +342 -0
  152. package/dist/markdown.test.js +264 -0
  153. package/dist/memory-overview-plugin.js +128 -0
  154. package/dist/message-finish-field.e2e.test.js +168 -0
  155. package/dist/message-formatting.js +415 -0
  156. package/dist/message-formatting.test.js +115 -0
  157. package/dist/message-preprocessing.js +359 -0
  158. package/dist/onboarding-tutorial.js +163 -0
  159. package/dist/onboarding-welcome.js +37 -0
  160. package/dist/openai-realtime.js +224 -0
  161. package/dist/opencode-command-detection.js +65 -0
  162. package/dist/opencode-command-detection.test.js +240 -0
  163. package/dist/opencode-command.js +131 -0
  164. package/dist/opencode-command.test.js +48 -0
  165. package/dist/opencode-interrupt-plugin.js +388 -0
  166. package/dist/opencode-interrupt-plugin.test.js +463 -0
  167. package/dist/opencode.js +1117 -0
  168. package/dist/otto/branding.js +22 -0
  169. package/dist/otto/index.js +21 -0
  170. package/dist/otto-digital-twin.e2e.test.js +161 -0
  171. package/dist/otto-opencode-plugin-loading.e2e.test.js +94 -0
  172. package/dist/otto-opencode-plugin.js +21 -0
  173. package/dist/otto-opencode-plugin.test.js +98 -0
  174. package/dist/parse-permission-rules.test.js +117 -0
  175. package/dist/patch-text-parser.js +97 -0
  176. package/dist/plugin-logger.js +68 -0
  177. package/dist/privacy-sanitizer.js +105 -0
  178. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  179. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  180. package/dist/queue-advanced-e2e-setup.js +790 -0
  181. package/dist/queue-advanced-footer.e2e.test.js +481 -0
  182. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  183. package/dist/queue-advanced-permissions-typing.e2e.test.js +179 -0
  184. package/dist/queue-advanced-question.e2e.test.js +261 -0
  185. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  186. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  187. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  188. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  189. package/dist/queue-question-select-drain.e2e.test.js +256 -0
  190. package/dist/runtime-idle-sweeper.js +52 -0
  191. package/dist/runtime-lifecycle.e2e.test.js +514 -0
  192. package/dist/sentry.js +23 -0
  193. package/dist/session-handler/agent-utils.js +67 -0
  194. package/dist/session-handler/event-stream-state.js +475 -0
  195. package/dist/session-handler/event-stream-state.test.js +632 -0
  196. package/dist/session-handler/model-utils.js +147 -0
  197. package/dist/session-handler/opencode-session-event-log.js +94 -0
  198. package/dist/session-handler/thread-runtime-state.js +131 -0
  199. package/dist/session-handler/thread-session-runtime.js +3390 -0
  200. package/dist/session-handler.js +9 -0
  201. package/dist/session-search.js +100 -0
  202. package/dist/session-search.test.js +40 -0
  203. package/dist/session-title-rename.test.js +92 -0
  204. package/dist/skill-filter.js +31 -0
  205. package/dist/skill-filter.test.js +65 -0
  206. package/dist/startup-service.js +153 -0
  207. package/dist/startup-time.e2e.test.js +296 -0
  208. package/dist/store.js +19 -0
  209. package/dist/subagent-rate-limit-plugin.js +175 -0
  210. package/dist/system-message.js +702 -0
  211. package/dist/system-message.test.js +697 -0
  212. package/dist/task-runner.js +530 -0
  213. package/dist/task-schedule.js +213 -0
  214. package/dist/task-schedule.test.js +71 -0
  215. package/dist/test-utils.js +313 -0
  216. package/dist/thinking-utils.js +35 -0
  217. package/dist/thread-message-queue.e2e.test.js +1111 -0
  218. package/dist/tools.js +357 -0
  219. package/dist/undo-redo.e2e.test.js +161 -0
  220. package/dist/unnest-code-blocks.js +146 -0
  221. package/dist/unnest-code-blocks.test.js +673 -0
  222. package/dist/upgrade.js +156 -0
  223. package/dist/utils.js +172 -0
  224. package/dist/utils.test.js +130 -0
  225. package/dist/voice-attachment.js +34 -0
  226. package/dist/voice-handler.js +646 -0
  227. package/dist/voice-message.e2e.test.js +1021 -0
  228. package/dist/voice.js +456 -0
  229. package/dist/voice.test.js +235 -0
  230. package/dist/wait-session.js +171 -0
  231. package/dist/websockify.js +69 -0
  232. package/dist/worker-types.js +4 -0
  233. package/dist/worktree-lifecycle.e2e.test.js +311 -0
  234. package/dist/worktree-utils.js +3 -0
  235. package/dist/worktrees.js +991 -0
  236. package/dist/worktrees.test.js +415 -0
  237. package/dist/xml.js +92 -0
  238. package/dist/xml.test.js +32 -0
  239. package/package.json +90 -38
  240. package/schema.prisma +303 -0
  241. package/skills/batch/SKILL.md +87 -0
  242. package/skills/critique/SKILL.md +112 -0
  243. package/skills/egaki/SKILL.md +100 -0
  244. package/skills/errore/SKILL.md +647 -0
  245. package/skills/event-sourcing-state/SKILL.md +252 -0
  246. package/skills/goke/SKILL.md +38 -0
  247. package/skills/jitter/EDITOR.md +219 -0
  248. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  249. package/skills/jitter/SKILL.md +158 -0
  250. package/skills/jitter/jitter-clipboard.json +1042 -0
  251. package/skills/jitter/package.json +14 -0
  252. package/skills/jitter/tsconfig.json +15 -0
  253. package/skills/jitter/utils/actions.ts +212 -0
  254. package/skills/jitter/utils/export.ts +114 -0
  255. package/skills/jitter/utils/index.ts +141 -0
  256. package/skills/jitter/utils/snapshot.ts +154 -0
  257. package/skills/jitter/utils/traverse.ts +246 -0
  258. package/skills/jitter/utils/types.ts +279 -0
  259. package/skills/jitter/utils/wait.ts +133 -0
  260. package/skills/lintcn/SKILL.md +873 -0
  261. package/skills/manual-kimaki-upstream-adapt/SKILL.md +114 -0
  262. package/skills/new-skill/SKILL.md +237 -0
  263. package/skills/npm-package/SKILL.md +617 -0
  264. package/skills/opensrc/SKILL.md +78 -0
  265. package/skills/otto-publish/SKILL.md +61 -0
  266. package/skills/playwriter/SKILL.md +35 -0
  267. package/skills/profano/SKILL.md +16 -0
  268. package/skills/proxyman/SKILL.md +215 -0
  269. package/skills/security-review/SKILL.md +208 -0
  270. package/skills/sigillo/SKILL.md +101 -0
  271. package/skills/simplify/SKILL.md +58 -0
  272. package/skills/spiceflow/SKILL.md +28 -0
  273. package/skills/termcast/SKILL.md +945 -0
  274. package/skills/tuistory/SKILL.md +98 -0
  275. package/skills/usecomputer/SKILL.md +264 -0
  276. package/skills/x-articles/SKILL.md +554 -0
  277. package/skills/zele/SKILL.md +49 -0
  278. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  279. package/src/agent-model.e2e.test.ts +979 -0
  280. package/src/ai-tool-to-genai.test.ts +296 -0
  281. package/src/ai-tool-to-genai.ts +283 -0
  282. package/src/ai-tool.ts +39 -0
  283. package/src/anthropic-account-identity.test.ts +52 -0
  284. package/src/anthropic-account-identity.ts +77 -0
  285. package/src/anthropic-auth-plugin.ts +1139 -0
  286. package/src/anthropic-auth-state.test.ts +187 -0
  287. package/src/anthropic-auth-state.ts +386 -0
  288. package/src/bin.ts +182 -0
  289. package/src/btw-prefix-detection.test.ts +73 -0
  290. package/src/btw-prefix-detection.ts +23 -0
  291. package/src/channel-management.ts +376 -0
  292. package/src/cli-parsing.test.ts +197 -0
  293. package/src/cli-send-thread.e2e.test.ts +463 -0
  294. package/src/cli-telegram-options.test.ts +114 -0
  295. package/src/cli.ts +5718 -580
  296. package/src/commands/abort.ts +89 -0
  297. package/src/commands/action-buttons.ts +364 -0
  298. package/src/commands/add-dir.test.ts +154 -0
  299. package/src/commands/add-dir.ts +175 -0
  300. package/src/commands/add-project.ts +149 -0
  301. package/src/commands/agent.ts +496 -0
  302. package/src/commands/ask-question.test.ts +111 -0
  303. package/src/commands/ask-question.ts +455 -0
  304. package/src/commands/btw.ts +184 -0
  305. package/src/commands/cli-commands-group-a.test.ts +837 -0
  306. package/src/commands/cli-commands-group-b.test.ts +800 -0
  307. package/src/commands/compact.ts +157 -0
  308. package/src/commands/context-usage.ts +199 -0
  309. package/src/commands/create-new-project.ts +190 -0
  310. package/src/commands/diff.ts +91 -0
  311. package/src/commands/discord-commands-group-a.test.ts +751 -0
  312. package/src/commands/discord-commands-group-b.test.ts +648 -0
  313. package/src/commands/discord-commands-group-c.test.ts +882 -0
  314. package/src/commands/file-upload.ts +389 -0
  315. package/src/commands/fork-subagent.ts +263 -0
  316. package/src/commands/fork.ts +386 -0
  317. package/src/commands/gemini-apikey.ts +104 -0
  318. package/src/commands/login.ts +1175 -0
  319. package/src/commands/mcp.ts +307 -0
  320. package/src/commands/memory-snapshot.ts +30 -0
  321. package/src/commands/mention-mode.ts +68 -0
  322. package/src/commands/merge-worktree.ts +226 -0
  323. package/src/commands/model-variant.ts +485 -0
  324. package/src/commands/model.ts +1078 -0
  325. package/src/commands/new-worktree.ts +645 -0
  326. package/src/commands/paginated-select.ts +81 -0
  327. package/src/commands/permissions.ts +397 -0
  328. package/src/commands/queue.ts +293 -0
  329. package/src/commands/remove-project.ts +155 -0
  330. package/src/commands/restart-opencode-server.ts +162 -0
  331. package/src/commands/resume.ts +230 -0
  332. package/src/commands/run-command.ts +123 -0
  333. package/src/commands/screenshare.test.ts +30 -0
  334. package/src/commands/screenshare.ts +366 -0
  335. package/src/commands/session-id.ts +109 -0
  336. package/src/commands/session.ts +227 -0
  337. package/src/commands/share.ts +106 -0
  338. package/src/commands/tasks.ts +293 -0
  339. package/src/commands/thread-deletion-sync.ts +80 -0
  340. package/src/commands/types.ts +25 -0
  341. package/src/commands/undo-redo.ts +386 -0
  342. package/src/commands/unset-model.ts +174 -0
  343. package/src/commands/upgrade.ts +59 -0
  344. package/src/commands/user-command.ts +198 -0
  345. package/src/commands/verbosity.ts +173 -0
  346. package/src/commands/vscode.ts +342 -0
  347. package/src/commands/worktree-settings.ts +70 -0
  348. package/src/commands/worktrees.ts +645 -0
  349. package/src/condense-memory.ts +36 -0
  350. package/src/config.ts +103 -339
  351. package/src/context-awareness-plugin.test.ts +144 -0
  352. package/src/context-awareness-plugin.ts +469 -0
  353. package/src/critique-utils.ts +139 -0
  354. package/src/database.ts +1949 -0
  355. package/src/db.test.ts +162 -0
  356. package/src/db.ts +295 -0
  357. package/src/debounce-timeout.ts +43 -0
  358. package/src/debounced-process-flush.ts +104 -0
  359. package/src/discord-bot.ts +1505 -0
  360. package/src/discord-command-registration.ts +752 -0
  361. package/src/discord-urls.ts +89 -0
  362. package/src/discord-utils.test.ts +153 -0
  363. package/src/discord-utils.ts +846 -0
  364. package/src/errors.ts +201 -0
  365. package/src/escape-backticks.test.ts +469 -0
  366. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  367. package/src/eventsource-parser.test.ts +351 -0
  368. package/src/exec-async.ts +35 -0
  369. package/src/external-opencode-sync.ts +685 -0
  370. package/src/format-tables.test.ts +515 -0
  371. package/src/format-tables.ts +718 -0
  372. package/src/forum-sync/config.ts +92 -0
  373. package/src/forum-sync/discord-operations.ts +241 -0
  374. package/src/forum-sync/index.ts +9 -0
  375. package/src/forum-sync/markdown.ts +172 -0
  376. package/src/forum-sync/sync-to-discord.ts +595 -0
  377. package/src/forum-sync/sync-to-files.ts +294 -0
  378. package/src/forum-sync/types.ts +175 -0
  379. package/src/forum-sync/watchers.ts +454 -0
  380. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  381. package/src/gateway-proxy.e2e.test.ts +644 -0
  382. package/src/genai-worker-wrapper.ts +164 -0
  383. package/src/genai-worker.ts +386 -0
  384. package/src/genai.ts +321 -0
  385. package/src/generated/browser.ts +114 -0
  386. package/src/generated/client.ts +138 -0
  387. package/src/generated/commonInputTypes.ts +770 -0
  388. package/src/generated/enums.ts +98 -0
  389. package/src/generated/internal/class.ts +384 -0
  390. package/src/generated/internal/prismaNamespace.ts +2394 -0
  391. package/src/generated/internal/prismaNamespaceBrowser.ts +327 -0
  392. package/src/generated/models/bot_api_keys.ts +1288 -0
  393. package/src/generated/models/bot_tokens.ts +1700 -0
  394. package/src/generated/models/channel_agents.ts +1256 -0
  395. package/src/generated/models/channel_directories.ts +1859 -0
  396. package/src/generated/models/channel_mention_mode.ts +1300 -0
  397. package/src/generated/models/channel_models.ts +1288 -0
  398. package/src/generated/models/channel_verbosity.ts +1228 -0
  399. package/src/generated/models/channel_worktrees.ts +1300 -0
  400. package/src/generated/models/forum_sync_configs.ts +1452 -0
  401. package/src/generated/models/global_models.ts +1288 -0
  402. package/src/generated/models/ipc_requests.ts +1485 -0
  403. package/src/generated/models/part_messages.ts +1302 -0
  404. package/src/generated/models/scheduled_tasks.ts +2320 -0
  405. package/src/generated/models/session_agents.ts +1086 -0
  406. package/src/generated/models/session_events.ts +1439 -0
  407. package/src/generated/models/session_models.ts +1114 -0
  408. package/src/generated/models/session_start_sources.ts +1408 -0
  409. package/src/generated/models/thread_sessions.ts +1781 -0
  410. package/src/generated/models/thread_worktrees.ts +1356 -0
  411. package/src/generated/models.ts +30 -0
  412. package/src/heap-monitor.ts +152 -0
  413. package/src/hrana-server.test.ts +434 -0
  414. package/src/hrana-server.ts +299 -0
  415. package/src/html-actions.test.ts +87 -0
  416. package/src/html-actions.ts +174 -0
  417. package/src/html-components.test.ts +38 -0
  418. package/src/html-components.ts +181 -0
  419. package/src/image-optimizer-plugin.ts +194 -0
  420. package/src/image-utils.ts +149 -0
  421. package/src/interaction-handler.ts +610 -0
  422. package/src/ipc-polling.ts +427 -0
  423. package/src/ipc-tools-plugin.ts +236 -0
  424. package/src/ipc-utils.ts +29 -0
  425. package/src/limit-heading-depth.test.ts +116 -0
  426. package/src/limit-heading-depth.ts +26 -0
  427. package/src/logger.ts +215 -0
  428. package/src/markdown.test.ts +315 -0
  429. package/src/markdown.ts +410 -0
  430. package/src/memory-overview-plugin.ts +163 -0
  431. package/src/message-finish-field.e2e.test.ts +195 -0
  432. package/src/message-formatting.test.ts +126 -0
  433. package/src/message-formatting.ts +535 -0
  434. package/src/message-preprocessing.ts +488 -0
  435. package/src/onboarding-tutorial.ts +167 -0
  436. package/src/onboarding-welcome.ts +49 -0
  437. package/src/openai-realtime.ts +358 -0
  438. package/src/opencode-command-detection.test.ts +307 -0
  439. package/src/opencode-command-detection.ts +76 -0
  440. package/src/opencode-command.test.ts +70 -0
  441. package/src/opencode-command.ts +191 -0
  442. package/src/opencode-interrupt-plugin.test.ts +682 -0
  443. package/src/opencode-interrupt-plugin.ts +507 -0
  444. package/src/opencode.ts +1453 -0
  445. package/src/otto/branding.ts +23 -0
  446. package/src/otto/index.ts +22 -0
  447. package/src/otto-digital-twin.e2e.test.ts +199 -0
  448. package/src/otto-opencode-plugin-loading.e2e.test.ts +117 -0
  449. package/src/otto-opencode-plugin.test.ts +108 -0
  450. package/src/otto-opencode-plugin.ts +22 -0
  451. package/src/parse-permission-rules.test.ts +127 -0
  452. package/src/patch-text-parser.ts +107 -0
  453. package/src/plugin-logger.ts +84 -0
  454. package/src/privacy-sanitizer.ts +142 -0
  455. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  456. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  457. package/src/queue-advanced-e2e-setup.ts +877 -0
  458. package/src/queue-advanced-footer.e2e.test.ts +591 -0
  459. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  460. package/src/queue-advanced-permissions-typing.e2e.test.ts +246 -0
  461. package/src/queue-advanced-question.e2e.test.ts +316 -0
  462. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  463. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  464. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  465. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  466. package/src/queue-question-select-drain.e2e.test.ts +327 -0
  467. package/src/runtime-idle-sweeper.ts +76 -0
  468. package/src/runtime-lifecycle.e2e.test.ts +651 -0
  469. package/src/schema.sql +174 -0
  470. package/src/sentry.ts +26 -0
  471. package/src/session-handler/agent-utils.ts +99 -0
  472. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  473. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  474. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  475. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  476. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  477. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  478. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  479. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  480. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  481. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  482. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  483. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  484. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  485. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  486. package/src/session-handler/event-stream-state.test.ts +717 -0
  487. package/src/session-handler/event-stream-state.ts +706 -0
  488. package/src/session-handler/model-utils.ts +217 -0
  489. package/src/session-handler/opencode-session-event-log.ts +130 -0
  490. package/src/session-handler/thread-runtime-state.ts +247 -0
  491. package/src/session-handler/thread-session-runtime.ts +4440 -0
  492. package/src/session-handler.ts +15 -0
  493. package/src/session-search.test.ts +50 -0
  494. package/src/session-search.ts +148 -0
  495. package/src/session-title-rename.test.ts +130 -0
  496. package/src/skill-filter.test.ts +83 -0
  497. package/src/skill-filter.ts +42 -0
  498. package/src/startup-service.ts +200 -0
  499. package/src/startup-time.e2e.test.ts +373 -0
  500. package/src/store.ts +139 -0
  501. package/src/subagent-rate-limit-plugin.ts +218 -0
  502. package/src/system-message.test.ts +710 -0
  503. package/src/system-message.ts +814 -0
  504. package/src/task-runner.ts +725 -0
  505. package/src/task-schedule.test.ts +84 -0
  506. package/src/task-schedule.ts +317 -0
  507. package/src/test-utils.ts +451 -0
  508. package/src/thinking-utils.ts +61 -0
  509. package/src/thread-message-queue.e2e.test.ts +1350 -0
  510. package/src/tools.ts +430 -0
  511. package/src/undici.d.ts +12 -0
  512. package/src/undo-redo.e2e.test.ts +209 -0
  513. package/src/unnest-code-blocks.test.ts +713 -0
  514. package/src/unnest-code-blocks.ts +185 -0
  515. package/src/upgrade.ts +185 -0
  516. package/src/utils.test.ts +155 -0
  517. package/src/utils.ts +265 -0
  518. package/src/voice-attachment.ts +51 -0
  519. package/src/voice-handler.ts +908 -0
  520. package/src/voice-message.e2e.test.ts +1255 -0
  521. package/src/voice.test.ts +281 -0
  522. package/src/voice.ts +638 -0
  523. package/src/wait-session.ts +273 -0
  524. package/src/websockify.ts +101 -0
  525. package/src/worker-types.ts +64 -0
  526. package/src/worktree-lifecycle.e2e.test.ts +396 -0
  527. package/src/worktree-utils.ts +4 -0
  528. package/src/worktrees.test.ts +489 -0
  529. package/src/worktrees.ts +1370 -0
  530. package/src/xml.test.ts +38 -0
  531. package/src/xml.ts +121 -0
  532. package/README.md +0 -142
  533. package/dist/cli.d.ts +0 -3
  534. package/dist/cli.d.ts.map +0 -1
  535. package/dist/cli.js.map +0 -1
  536. package/dist/config.d.ts +0 -39
  537. package/dist/config.d.ts.map +0 -1
  538. package/dist/config.js.map +0 -1
  539. package/dist/config.test.d.ts +0 -2
  540. package/dist/config.test.d.ts.map +0 -1
  541. package/dist/config.test.js +0 -202
  542. package/dist/config.test.js.map +0 -1
  543. package/dist/detect.d.ts +0 -9
  544. package/dist/detect.d.ts.map +0 -1
  545. package/dist/detect.js +0 -40
  546. package/dist/detect.js.map +0 -1
  547. package/dist/detect.test.d.ts +0 -2
  548. package/dist/detect.test.d.ts.map +0 -1
  549. package/dist/detect.test.js +0 -26
  550. package/dist/detect.test.js.map +0 -1
  551. package/dist/docker.d.ts +0 -7
  552. package/dist/docker.d.ts.map +0 -1
  553. package/dist/docker.js +0 -17
  554. package/dist/docker.js.map +0 -1
  555. package/dist/docker.test.d.ts +0 -2
  556. package/dist/docker.test.d.ts.map +0 -1
  557. package/dist/docker.test.js +0 -12
  558. package/dist/docker.test.js.map +0 -1
  559. package/dist/health.d.ts +0 -31
  560. package/dist/health.d.ts.map +0 -1
  561. package/dist/health.js +0 -117
  562. package/dist/health.js.map +0 -1
  563. package/dist/health.test.d.ts +0 -2
  564. package/dist/health.test.d.ts.map +0 -1
  565. package/dist/health.test.js +0 -52
  566. package/dist/health.test.js.map +0 -1
  567. package/dist/index.d.ts +0 -20
  568. package/dist/index.d.ts.map +0 -1
  569. package/dist/index.js +0 -15
  570. package/dist/index.js.map +0 -1
  571. package/dist/index.test.d.ts +0 -2
  572. package/dist/index.test.d.ts.map +0 -1
  573. package/dist/index.test.js +0 -8
  574. package/dist/index.test.js.map +0 -1
  575. package/dist/installer.d.ts +0 -10
  576. package/dist/installer.d.ts.map +0 -1
  577. package/dist/installer.js +0 -50
  578. package/dist/installer.js.map +0 -1
  579. package/dist/installer.test.d.ts +0 -2
  580. package/dist/installer.test.d.ts.map +0 -1
  581. package/dist/installer.test.js +0 -43
  582. package/dist/installer.test.js.map +0 -1
  583. package/dist/lifecycle.d.ts +0 -10
  584. package/dist/lifecycle.d.ts.map +0 -1
  585. package/dist/lifecycle.js +0 -45
  586. package/dist/lifecycle.js.map +0 -1
  587. package/dist/lifecycle.test.d.ts +0 -2
  588. package/dist/lifecycle.test.d.ts.map +0 -1
  589. package/dist/lifecycle.test.js +0 -20
  590. package/dist/lifecycle.test.js.map +0 -1
  591. package/dist/manifest.d.ts +0 -18
  592. package/dist/manifest.d.ts.map +0 -1
  593. package/dist/manifest.js +0 -30
  594. package/dist/manifest.js.map +0 -1
  595. package/dist/skills-baseline.d.ts +0 -7
  596. package/dist/skills-baseline.d.ts.map +0 -1
  597. package/dist/skills-baseline.js +0 -9
  598. package/dist/skills-baseline.js.map +0 -1
  599. package/dist/skills.d.ts +0 -110
  600. package/dist/skills.d.ts.map +0 -1
  601. package/dist/skills.js +0 -429
  602. package/dist/skills.js.map +0 -1
  603. package/dist/skills.test.d.ts +0 -2
  604. package/dist/skills.test.d.ts.map +0 -1
  605. package/dist/skills.test.js +0 -416
  606. package/dist/skills.test.js.map +0 -1
  607. package/dist/sync.d.ts +0 -10
  608. package/dist/sync.d.ts.map +0 -1
  609. package/dist/sync.js +0 -39
  610. package/dist/sync.js.map +0 -1
  611. package/dist/tenant.d.ts +0 -13
  612. package/dist/tenant.d.ts.map +0 -1
  613. package/dist/tenant.js +0 -105
  614. package/dist/tenant.js.map +0 -1
  615. package/dist/tenant.test.d.ts +0 -2
  616. package/dist/tenant.test.d.ts.map +0 -1
  617. package/dist/tenant.test.js +0 -37
  618. package/dist/tenant.test.js.map +0 -1
  619. package/src/config.test.ts +0 -237
  620. package/src/detect.test.ts +0 -29
  621. package/src/detect.ts +0 -52
  622. package/src/docker.test.ts +0 -12
  623. package/src/docker.ts +0 -23
  624. package/src/health.test.ts +0 -61
  625. package/src/health.ts +0 -158
  626. package/src/index.test.ts +0 -8
  627. package/src/index.ts +0 -62
  628. package/src/installer.test.ts +0 -52
  629. package/src/installer.ts +0 -62
  630. package/src/lifecycle.test.ts +0 -23
  631. package/src/lifecycle.ts +0 -49
  632. package/src/manifest.ts +0 -42
  633. package/src/skills-baseline.ts +0 -14
  634. package/src/skills.test.ts +0 -503
  635. package/src/skills.ts +0 -512
  636. package/src/sync.ts +0 -53
  637. package/src/tenant.test.ts +0 -49
  638. package/src/tenant.ts +0 -120
@@ -0,0 +1,1175 @@
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
+
12
+ import {
13
+ ChatInputCommandInteraction,
14
+ StringSelectMenuInteraction,
15
+ StringSelectMenuBuilder,
16
+ ActionRowBuilder,
17
+ ModalBuilder,
18
+ TextInputBuilder,
19
+ TextInputStyle,
20
+ ModalSubmitInteraction,
21
+ ButtonBuilder,
22
+ ButtonStyle,
23
+ type ButtonInteraction,
24
+ ChannelType,
25
+ type ThreadChannel,
26
+ type TextChannel,
27
+ MessageFlags,
28
+ } from 'discord.js'
29
+ import type { AuthHook } from '@opencode-ai/plugin'
30
+ import crypto from 'node:crypto'
31
+ import {
32
+ initializeOpencodeForDirectory,
33
+ getOpencodeServerPort,
34
+ } from '../opencode.js'
35
+ import { resolveTextChannel, getOttoMetadata } from '../discord-utils.js'
36
+ import { createLogger, LogPrefix } from '../logger.js'
37
+ import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
38
+
39
+ const loginLogger = createLogger(LogPrefix.LOGIN)
40
+
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
+
109
+ const LOGIN_CONTEXT_TTL_MS = 10 * 60 * 1000
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.
123
+ // IDs sourced from opencode's provider.list() API (scripts/list-providers.ts).
124
+ const PROVIDER_POPULARITY_ORDER: string[] = [
125
+ 'anthropic',
126
+ 'openai',
127
+ 'google',
128
+ 'github-copilot',
129
+ 'xai',
130
+ 'groq',
131
+ 'deepseek',
132
+ 'opencode',
133
+ 'opencode-go',
134
+ 'mistral',
135
+ 'openrouter',
136
+ 'fireworks-ai',
137
+ 'togetherai',
138
+ 'amazon-bedrock',
139
+ 'azure',
140
+ 'google-vertex',
141
+ 'google-vertex-anthropic',
142
+ // 'cohere',
143
+ 'cerebras',
144
+ // 'perplexity',
145
+ 'cloudflare-workers-ai',
146
+ // 'novita-ai',
147
+ // 'huggingface',
148
+ 'deepinfra',
149
+ 'github-models',
150
+ 'lmstudio',
151
+ 'llama',
152
+ ]
153
+
154
+ // ── Helpers ─────────────────────────────────────────────────────
155
+
156
+ function extractErrorMessage({
157
+ error,
158
+ fallback,
159
+ }: {
160
+ error: unknown
161
+ fallback: string
162
+ }): string {
163
+ if (!error || typeof error !== 'object') {
164
+ return fallback
165
+ }
166
+ const parsed = error as { message?: string; data?: { message?: string } }
167
+ return parsed.data?.message || parsed.message || fallback
168
+ }
169
+
170
+ function shouldShowPrompt(
171
+ prompt: AuthPrompt,
172
+ inputs: Record<string, string>,
173
+ ): boolean {
174
+ if (!prompt.when) {
175
+ return true
176
+ }
177
+ const value = inputs[prompt.when.key]
178
+ if (prompt.when.op === 'eq') {
179
+ return value === prompt.when.value
180
+ }
181
+ if (prompt.when.op === 'neq') {
182
+ return value !== prompt.when.value
183
+ }
184
+ return true
185
+ }
186
+
187
+ function buildSelectMenu({
188
+ customId,
189
+ placeholder,
190
+ options,
191
+ }: {
192
+ customId: string
193
+ placeholder: string
194
+ options: Array<{ label: string; value: string; description?: string }>
195
+ }): ActionRowBuilder<StringSelectMenuBuilder> {
196
+ const menu = new StringSelectMenuBuilder()
197
+ .setCustomId(customId)
198
+ .setPlaceholder(placeholder)
199
+ .addOptions(options)
200
+ return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)
201
+ }
202
+
203
+ // ── /login command ──────────────────────────────────────────────
204
+
205
+ export async function handleLoginCommand({
206
+ interaction,
207
+ }: {
208
+ interaction: ChatInputCommandInteraction
209
+ appId: string
210
+ }): Promise<void> {
211
+ loginLogger.log('[LOGIN] handleLoginCommand called')
212
+
213
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral })
214
+
215
+ const channel = interaction.channel
216
+ if (!channel) {
217
+ await interaction.editReply({
218
+ content: 'This command can only be used in a channel',
219
+ })
220
+ return
221
+ }
222
+
223
+ const isThread = [
224
+ ChannelType.PublicThread,
225
+ ChannelType.PrivateThread,
226
+ ChannelType.AnnouncementThread,
227
+ ].includes(channel.type)
228
+
229
+ let projectDirectory: string | undefined
230
+ let targetChannelId: string
231
+
232
+ if (isThread) {
233
+ const thread = channel as ThreadChannel
234
+ const textChannel = await resolveTextChannel(thread)
235
+ const metadata = await getOttoMetadata(textChannel)
236
+ projectDirectory = metadata.projectDirectory
237
+ targetChannelId = textChannel?.id || channel.id
238
+ } else if (channel.type === ChannelType.GuildText) {
239
+ const textChannel = channel as TextChannel
240
+ const metadata = await getOttoMetadata(textChannel)
241
+ projectDirectory = metadata.projectDirectory
242
+ targetChannelId = channel.id
243
+ } else {
244
+ await interaction.editReply({
245
+ content: 'This command can only be used in text channels or threads',
246
+ })
247
+ return
248
+ }
249
+
250
+ if (!projectDirectory) {
251
+ await interaction.editReply({
252
+ content: 'This channel is not configured with a project directory',
253
+ })
254
+ return
255
+ }
256
+
257
+ try {
258
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
259
+ if (getClient instanceof Error) {
260
+ await interaction.editReply({ content: getClient.message })
261
+ return
262
+ }
263
+
264
+ const providersResponse = await getClient().provider.list({
265
+ directory: projectDirectory,
266
+ })
267
+
268
+ if (!providersResponse.data) {
269
+ await interaction.editReply({ content: 'Failed to fetch providers' })
270
+ return
271
+ }
272
+
273
+ const { all: allProviders, connected } = providersResponse.data
274
+
275
+ if (allProviders.length === 0) {
276
+ await interaction.editReply({ content: 'No providers available.' })
277
+ return
278
+ }
279
+
280
+ const allProviderOptions = [...allProviders]
281
+ .sort((a, b) => {
282
+ const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id)
283
+ const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id)
284
+ const posA = rankA === -1 ? Infinity : rankA
285
+ const posB = rankB === -1 ? Infinity : rankB
286
+ if (posA !== posB) {
287
+ return posA - posB
288
+ }
289
+ return a.name.localeCompare(b.name)
290
+ })
291
+ .map((provider) => {
292
+ const isConnected = connected.includes(provider.id)
293
+ return {
294
+ label: `${provider.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
295
+ value: provider.id,
296
+ description: isConnected
297
+ ? 'Connected - select to re-authenticate'
298
+ : 'Not connected',
299
+ }
300
+ })
301
+
302
+ const { options } = buildPaginatedOptions({
303
+ allOptions: allProviderOptions,
304
+ page: 0,
305
+ })
306
+
307
+ const context: LoginContext = {
308
+ dir: projectDirectory,
309
+ channelId: targetChannelId,
310
+ steps: [{ type: 'provider' }],
311
+ stepIndex: 0,
312
+ inputs: {},
313
+ }
314
+ const hash = createContextHash(context)
315
+
316
+ await interaction.editReply({
317
+ content: '**Authenticate with Provider**\nSelect a provider:',
318
+ components: [
319
+ buildSelectMenu({
320
+ customId: `login_select:${hash}`,
321
+ placeholder: 'Select a provider to authenticate',
322
+ options,
323
+ }),
324
+ ],
325
+ })
326
+ } catch (error) {
327
+ loginLogger.error('Error loading providers:', error)
328
+ await interaction.editReply({
329
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
330
+ })
331
+ }
332
+ }
333
+
334
+ // ── Unified select handler ──────────────────────────────────────
335
+ // Handles all select menu interactions for the login flow.
336
+ // Reads the current step from context, processes the answer,
337
+ // then either shows the next step or proceeds to authorize/API key.
338
+
339
+ export async function handleLoginSelect(
340
+ interaction: StringSelectMenuInteraction,
341
+ ): Promise<void> {
342
+ if (!interaction.customId.startsWith('login_select:')) {
343
+ return
344
+ }
345
+
346
+ const hash = interaction.customId.replace('login_select:', '')
347
+ const ctx = pendingLoginContexts.get(hash)
348
+
349
+ if (!ctx) {
350
+ await interaction.deferUpdate()
351
+ await interaction.editReply({
352
+ content: 'Selection expired. Please run /login again.',
353
+ components: [],
354
+ })
355
+ return
356
+ }
357
+
358
+ const value = interaction.values[0]
359
+ if (!value) {
360
+ await interaction.deferUpdate()
361
+ await interaction.editReply({
362
+ content: 'No option selected.',
363
+ components: [],
364
+ })
365
+ return
366
+ }
367
+
368
+ const step = ctx.steps[ctx.stepIndex]
369
+ if (!step) {
370
+ await interaction.deferUpdate()
371
+ await interaction.editReply({
372
+ content: 'Invalid state. Please run /login again.',
373
+ components: [],
374
+ })
375
+ return
376
+ }
377
+
378
+ try {
379
+ if (step.type === 'provider') {
380
+ await handleProviderStep(interaction, ctx, hash, value)
381
+ } else if (step.type === 'method') {
382
+ await handleMethodStep(interaction, ctx, hash, value, step)
383
+ } else if (step.type === 'prompt') {
384
+ await handlePromptStep(interaction, ctx, hash, value, step)
385
+ }
386
+ } catch (error) {
387
+ loginLogger.error('Error in login select:', error)
388
+ if (!interaction.deferred && !interaction.replied) {
389
+ await interaction.deferUpdate()
390
+ }
391
+ await interaction.editReply({
392
+ content: `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`,
393
+ components: [],
394
+ })
395
+ }
396
+ }
397
+
398
+ // ── Step handlers ───────────────────────────────────────────────
399
+
400
+ async function handleProviderStep(
401
+ interaction: StringSelectMenuInteraction,
402
+ ctx: LoginContext,
403
+ hash: string,
404
+ providerId: string,
405
+ ): Promise<void> {
406
+ // Handle pagination nav — re-render the same provider select with new page
407
+ const navPage = parsePaginationValue(providerId)
408
+ if (navPage !== undefined) {
409
+ await interaction.deferUpdate()
410
+ ctx.providerPage = navPage
411
+
412
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
413
+ if (getClient instanceof Error) {
414
+ await interaction.editReply({ content: getClient.message, components: [] })
415
+ return
416
+ }
417
+ const providersResponse = await getClient().provider.list({ directory: ctx.dir })
418
+ if (!providersResponse.data) {
419
+ await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
420
+ return
421
+ }
422
+ const { all: allProviders, connected } = providersResponse.data
423
+ const allProviderOptions = [...allProviders]
424
+ .sort((a, b) => {
425
+ const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id)
426
+ const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id)
427
+ const posA = rankA === -1 ? Infinity : rankA
428
+ const posB = rankB === -1 ? Infinity : rankB
429
+ if (posA !== posB) {
430
+ return posA - posB
431
+ }
432
+ return a.name.localeCompare(b.name)
433
+ })
434
+ .map((p) => {
435
+ const isConnected = connected.includes(p.id)
436
+ return {
437
+ label: `${p.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
438
+ value: p.id,
439
+ description: isConnected ? 'Connected - select to re-authenticate' : 'Not connected',
440
+ }
441
+ })
442
+ const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: navPage })
443
+ await interaction.editReply({
444
+ content: '**Authenticate with Provider**\nSelect a provider:',
445
+ components: [
446
+ buildSelectMenu({
447
+ customId: `login_select:${hash}`,
448
+ placeholder: 'Select a provider to authenticate',
449
+ options,
450
+ }),
451
+ ],
452
+ })
453
+ return
454
+ }
455
+
456
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
457
+ if (getClient instanceof Error) {
458
+ await interaction.deferUpdate()
459
+ await interaction.editReply({ content: getClient.message, components: [] })
460
+ return
461
+ }
462
+
463
+ const providersResponse = await getClient().provider.list({
464
+ directory: ctx.dir,
465
+ })
466
+ const provider = providersResponse.data?.all.find(
467
+ (p) => p.id === providerId,
468
+ )
469
+ const providerName = provider?.name || providerId
470
+
471
+ const authResponse = await getClient().provider.auth({ directory: ctx.dir })
472
+ if (!authResponse.data) {
473
+ await interaction.deferUpdate()
474
+ await interaction.editReply({
475
+ content: 'Failed to fetch authentication methods',
476
+ components: [],
477
+ })
478
+ return
479
+ }
480
+
481
+ // The server returns prompts in the auth response when the opencode
482
+ // version supports it (dev branch, not yet released as of v1.2.27).
483
+ // Once released, plugin-defined prompts will be collected and passed
484
+ // as inputs to the authorize call automatically.
485
+ const methods: ProviderAuthMethod[] = authResponse.data[providerId] || [
486
+ { type: 'api', label: 'API Key' },
487
+ ]
488
+
489
+ if (methods.length === 0) {
490
+ await interaction.deferUpdate()
491
+ await interaction.editReply({
492
+ content: `No authentication methods available for ${providerName}`,
493
+ components: [],
494
+ })
495
+ return
496
+ }
497
+
498
+ ctx.providerId = providerId
499
+ ctx.providerName = providerName
500
+
501
+ if (methods.length === 1) {
502
+ // Single method — skip method select, go straight to prompts or action
503
+ const method = methods[0]!
504
+ ctx.methodIndex = 0
505
+ ctx.methodType = method.type
506
+
507
+ const promptSteps = buildPromptSteps(method)
508
+ if (promptSteps.length > 0) {
509
+ // Has prompts — defer and show first prompt
510
+ ctx.steps = promptSteps
511
+ ctx.stepIndex = 0
512
+ await interaction.deferUpdate()
513
+ await showNextStep(interaction, ctx, hash)
514
+ } else if (method.type === 'api') {
515
+ // API key with no prompts — show modal directly (don't defer)
516
+ await showApiKeyModal(interaction, hash, providerName)
517
+ } else {
518
+ // OAuth with no prompts — defer and authorize
519
+ await interaction.deferUpdate()
520
+ await startOAuthFlow(interaction, ctx, hash)
521
+ }
522
+ return
523
+ }
524
+
525
+ // Multiple methods — show method select
526
+ ctx.steps = [
527
+ { type: 'method', methods },
528
+ ]
529
+ ctx.stepIndex = 0
530
+ await interaction.deferUpdate()
531
+ await showNextStep(interaction, ctx, hash)
532
+ }
533
+
534
+ async function handleMethodStep(
535
+ interaction: StringSelectMenuInteraction,
536
+ ctx: LoginContext,
537
+ hash: string,
538
+ value: string,
539
+ step: StepMethod,
540
+ ): Promise<void> {
541
+ const methodIndex = parseInt(value, 10)
542
+ const method = step.methods[methodIndex]
543
+ if (!method) {
544
+ await interaction.deferUpdate()
545
+ await interaction.editReply({
546
+ content: 'Invalid method selected.',
547
+ components: [],
548
+ })
549
+ return
550
+ }
551
+
552
+ ctx.methodIndex = methodIndex
553
+ ctx.methodType = method.type
554
+
555
+ const promptSteps = buildPromptSteps(method)
556
+ if (promptSteps.length > 0) {
557
+ // Replace remaining steps with prompt steps
558
+ ctx.steps = promptSteps
559
+ ctx.stepIndex = 0
560
+ await interaction.deferUpdate()
561
+ await showNextStep(interaction, ctx, hash)
562
+ } else if (method.type === 'api') {
563
+ // API key with no prompts — show modal directly (don't defer)
564
+ await showApiKeyModal(interaction, hash, ctx.providerName || '')
565
+ } else {
566
+ // OAuth with no prompts
567
+ await interaction.deferUpdate()
568
+ await startOAuthFlow(interaction, ctx, hash)
569
+ }
570
+ }
571
+
572
+ async function handlePromptStep(
573
+ interaction: StringSelectMenuInteraction,
574
+ ctx: LoginContext,
575
+ hash: string,
576
+ value: string,
577
+ step: StepPrompt,
578
+ ): Promise<void> {
579
+ // Store the answer
580
+ ctx.inputs[step.prompt.key] = value
581
+ ctx.stepIndex++
582
+
583
+ // Find the next prompt step that passes its `when` condition
584
+ await interaction.deferUpdate()
585
+ await showNextStep(interaction, ctx, hash)
586
+ }
587
+
588
+ // ── Step rendering ──────────────────────────────────────────────
589
+ // Advances through steps, skipping prompts whose `when` condition
590
+ // fails, until it finds one to show or reaches the end.
591
+
592
+ async function showNextStep(
593
+ interaction: StringSelectMenuInteraction | ModalSubmitInteraction,
594
+ ctx: LoginContext,
595
+ hash: string,
596
+ ): Promise<void> {
597
+ // Skip prompts whose `when` condition doesn't match
598
+ while (ctx.stepIndex < ctx.steps.length) {
599
+ const step = ctx.steps[ctx.stepIndex]!
600
+ if (step.type === 'prompt' && !shouldShowPrompt(step.prompt, ctx.inputs)) {
601
+ ctx.stepIndex++
602
+ continue
603
+ }
604
+ break
605
+ }
606
+
607
+ if (ctx.stepIndex >= ctx.steps.length) {
608
+ // All steps done — proceed to action
609
+ if (ctx.methodType === 'api') {
610
+ // We're deferred, so show a button that opens the API key modal
611
+ const button = new ButtonBuilder()
612
+ .setCustomId(`login_apikey_btn:${hash}`)
613
+ .setLabel('Enter API Key')
614
+ .setStyle(ButtonStyle.Primary)
615
+ await interaction.editReply({
616
+ content: `**Authenticate with ${ctx.providerName}**\nClick to enter your API key.`,
617
+ components: [
618
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
619
+ ],
620
+ })
621
+ } else {
622
+ await startOAuthFlow(interaction, ctx, hash)
623
+ }
624
+ return
625
+ }
626
+
627
+ const step = ctx.steps[ctx.stepIndex]!
628
+ pendingLoginContexts.set(hash, ctx)
629
+
630
+ if (step.type === 'method') {
631
+ const options = step.methods.slice(0, 25).map((method, index) => ({
632
+ label: method.label.slice(0, 100),
633
+ value: String(index),
634
+ description:
635
+ method.type === 'oauth'
636
+ ? 'OAuth authentication'
637
+ : 'Enter API key manually',
638
+ }))
639
+
640
+ await interaction.editReply({
641
+ content: `**Authenticate with ${ctx.providerName}**\nSelect authentication method:`,
642
+ components: [
643
+ buildSelectMenu({
644
+ customId: `login_select:${hash}`,
645
+ placeholder: 'Select authentication method',
646
+ options,
647
+ }),
648
+ ],
649
+ })
650
+ return
651
+ }
652
+
653
+ if (step.type === 'prompt') {
654
+ const prompt = step.prompt
655
+ if (prompt.type === 'select') {
656
+ const options = prompt.options.slice(0, 25).map((opt) => ({
657
+ label: opt.label.slice(0, 100),
658
+ value: opt.value,
659
+ description: opt.hint?.slice(0, 100),
660
+ }))
661
+
662
+ await interaction.editReply({
663
+ content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
664
+ components: [
665
+ buildSelectMenu({
666
+ customId: `login_select:${hash}`,
667
+ placeholder: prompt.message.slice(0, 150),
668
+ options,
669
+ }),
670
+ ],
671
+ })
672
+ return
673
+ }
674
+
675
+ if (prompt.type === 'text') {
676
+ // Text prompts need a modal, but we're deferred. Show a button.
677
+ const button = new ButtonBuilder()
678
+ .setCustomId(`login_text_btn:${hash}`)
679
+ .setLabel(prompt.message.slice(0, 80))
680
+ .setStyle(ButtonStyle.Primary)
681
+
682
+ await interaction.editReply({
683
+ content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
684
+ components: [
685
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
686
+ ],
687
+ })
688
+ return
689
+ }
690
+ }
691
+ }
692
+
693
+ function buildPromptSteps(method: ProviderAuthMethod): StepPrompt[] {
694
+ return (method.prompts || []).map((prompt) => ({
695
+ type: 'prompt' as const,
696
+ prompt,
697
+ }))
698
+ }
699
+
700
+ // ── Text prompt button + modal ──────────────────────────────────
701
+ // When a text prompt needs to be shown but we're in a deferred state,
702
+ // we show a button. Clicking it opens a modal for text input.
703
+
704
+ export async function handleLoginTextButton(
705
+ interaction: ButtonInteraction,
706
+ ): Promise<void> {
707
+ if (!interaction.customId.startsWith('login_text_btn:')) {
708
+ return
709
+ }
710
+
711
+ const hash = interaction.customId.replace('login_text_btn:', '')
712
+ const ctx = pendingLoginContexts.get(hash)
713
+
714
+ if (!ctx) {
715
+ await interaction.reply({
716
+ content: 'Selection expired. Please run /login again.',
717
+ flags: MessageFlags.Ephemeral,
718
+ })
719
+ return
720
+ }
721
+
722
+ const step = ctx.steps[ctx.stepIndex]
723
+ if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
724
+ await interaction.reply({
725
+ content: 'Invalid state. Please run /login again.',
726
+ flags: MessageFlags.Ephemeral,
727
+ })
728
+ return
729
+ }
730
+
731
+ const modal = new ModalBuilder()
732
+ .setCustomId(`login_text:${hash}`)
733
+ .setTitle(`${ctx.providerName || 'Provider'} Login`.slice(0, 45))
734
+
735
+ const textInput = new TextInputBuilder()
736
+ .setCustomId('prompt_value')
737
+ .setLabel(step.prompt.message.slice(0, 45))
738
+ .setPlaceholder(
739
+ step.prompt.type === 'text' ? (step.prompt.placeholder || '') : '',
740
+ )
741
+ .setStyle(TextInputStyle.Short)
742
+ .setRequired(true)
743
+
744
+ modal.addComponents(
745
+ new ActionRowBuilder<TextInputBuilder>().addComponents(textInput),
746
+ )
747
+ await interaction.showModal(modal)
748
+ }
749
+
750
+ export async function handleLoginTextModalSubmit(
751
+ interaction: ModalSubmitInteraction,
752
+ ): Promise<void> {
753
+ if (!interaction.customId.startsWith('login_text:')) {
754
+ return
755
+ }
756
+
757
+ await interaction.deferUpdate()
758
+
759
+ const hash = interaction.customId.replace('login_text:', '')
760
+ const ctx = pendingLoginContexts.get(hash)
761
+
762
+ if (!ctx) {
763
+ await interaction.editReply({
764
+ content: 'Selection expired. Please run /login again.',
765
+ components: [],
766
+ })
767
+ return
768
+ }
769
+
770
+ const step = ctx.steps[ctx.stepIndex]
771
+ if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
772
+ await interaction.editReply({
773
+ content: 'Invalid state. Please run /login again.',
774
+ components: [],
775
+ })
776
+ return
777
+ }
778
+
779
+ const value = interaction.fields.getTextInputValue('prompt_value')
780
+ if (!value?.trim()) {
781
+ await interaction.editReply({
782
+ content: 'A value is required.',
783
+ components: [],
784
+ })
785
+ return
786
+ }
787
+
788
+ ctx.inputs[step.prompt.key] = value.trim()
789
+ ctx.stepIndex++
790
+ await showNextStep(interaction, ctx, hash)
791
+ }
792
+
793
+ // ── API key button + modal ──────────────────────────────────────
794
+ // When we're deferred and need an API key modal, show a button first.
795
+
796
+ export async function handleLoginApiKeyButton(
797
+ interaction: ButtonInteraction,
798
+ ): Promise<void> {
799
+ if (!interaction.customId.startsWith('login_apikey_btn:')) {
800
+ return
801
+ }
802
+
803
+ const hash = interaction.customId.replace('login_apikey_btn:', '')
804
+ const ctx = pendingLoginContexts.get(hash)
805
+
806
+ if (!ctx || !ctx.providerName) {
807
+ await interaction.reply({
808
+ content: 'Selection expired. Please run /login again.',
809
+ flags: MessageFlags.Ephemeral,
810
+ })
811
+ return
812
+ }
813
+
814
+ await showApiKeyModal(interaction, hash, ctx.providerName)
815
+ }
816
+
817
+ async function showApiKeyModal(
818
+ interaction: StringSelectMenuInteraction | ButtonInteraction,
819
+ hash: string,
820
+ providerName: string,
821
+ ): Promise<void> {
822
+ const modal = new ModalBuilder()
823
+ .setCustomId(`login_apikey:${hash}`)
824
+ .setTitle(`${providerName} API Key`.slice(0, 45))
825
+
826
+ const apiKeyInput = new TextInputBuilder()
827
+ .setCustomId('apikey')
828
+ .setLabel('API Key')
829
+ .setPlaceholder('sk-...')
830
+ .setStyle(TextInputStyle.Short)
831
+ .setRequired(true)
832
+
833
+ modal.addComponents(
834
+ new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput),
835
+ )
836
+ await interaction.showModal(modal)
837
+ }
838
+
839
+ // ── OAuth code submission (code mode) ───────────────────────────
840
+ // When the OAuth flow returns method="code", the user completes login
841
+ // in a browser (possibly on a different machine) and pastes the final
842
+ // callback URL or authorization code here.
843
+
844
+ export async function handleOAuthCodeButton(
845
+ interaction: ButtonInteraction,
846
+ ): Promise<void> {
847
+ if (!interaction.customId.startsWith('login_oauth_code_btn:')) {
848
+ return
849
+ }
850
+
851
+ const hash = interaction.customId.replace('login_oauth_code_btn:', '')
852
+ const ctx = pendingLoginContexts.get(hash)
853
+
854
+ if (!ctx || !ctx.providerId || !ctx.providerName) {
855
+ await interaction.reply({
856
+ content: 'Selection expired. Please run /login again.',
857
+ flags: MessageFlags.Ephemeral,
858
+ })
859
+ return
860
+ }
861
+
862
+ const modal = new ModalBuilder()
863
+ .setCustomId(`login_oauth_code:${hash}`)
864
+ .setTitle(`${ctx.providerName} Authorization`.slice(0, 45))
865
+
866
+ const codeInput = new TextInputBuilder()
867
+ .setCustomId('oauth_code')
868
+ .setLabel('Authorization code or callback URL')
869
+ .setPlaceholder('Paste the code or full callback URL')
870
+ .setStyle(TextInputStyle.Paragraph)
871
+ .setRequired(true)
872
+
873
+ modal.addComponents(
874
+ new ActionRowBuilder<TextInputBuilder>().addComponents(codeInput),
875
+ )
876
+ await interaction.showModal(modal)
877
+ }
878
+
879
+ export async function handleOAuthCodeModalSubmit(
880
+ interaction: ModalSubmitInteraction,
881
+ ): Promise<void> {
882
+ if (!interaction.customId.startsWith('login_oauth_code:')) {
883
+ return
884
+ }
885
+
886
+ await interaction.deferUpdate()
887
+
888
+ const hash = interaction.customId.replace('login_oauth_code:', '')
889
+ const ctx = pendingLoginContexts.get(hash)
890
+
891
+ if (!ctx || !ctx.providerId || !ctx.providerName || ctx.methodIndex === undefined) {
892
+ await interaction.editReply({
893
+ content: 'Session expired. Please run /login again.',
894
+ components: [],
895
+ })
896
+ return
897
+ }
898
+
899
+ const code = interaction.fields.getTextInputValue('oauth_code')?.trim()
900
+ if (!code) {
901
+ await interaction.editReply({
902
+ content: 'Authorization code is required.',
903
+ components: [],
904
+ })
905
+ return
906
+ }
907
+
908
+ try {
909
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
910
+ if (getClient instanceof Error) {
911
+ await interaction.editReply({
912
+ content: getClient.message,
913
+ components: [],
914
+ })
915
+ return
916
+ }
917
+
918
+ await interaction.editReply({
919
+ content: `**Authenticating with ${ctx.providerName}**\nVerifying authorization...`,
920
+ components: [],
921
+ })
922
+
923
+ const callbackResponse = await getClient().provider.oauth.callback({
924
+ providerID: ctx.providerId,
925
+ method: ctx.methodIndex,
926
+ code,
927
+ directory: ctx.dir,
928
+ })
929
+
930
+ if (callbackResponse.error) {
931
+ pendingLoginContexts.delete(hash)
932
+ await interaction.editReply({
933
+ content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization code was invalid or expired' })}`,
934
+ components: [],
935
+ })
936
+ return
937
+ }
938
+
939
+ await getClient().instance.dispose({ directory: ctx.dir })
940
+ pendingLoginContexts.delete(hash)
941
+
942
+ await interaction.editReply({
943
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
944
+ components: [],
945
+ })
946
+ } catch (error) {
947
+ loginLogger.error('OAuth code submission error:', error)
948
+ pendingLoginContexts.delete(hash)
949
+ await interaction.editReply({
950
+ content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
951
+ components: [],
952
+ })
953
+ }
954
+ }
955
+
956
+ export async function handleApiKeyModalSubmit(
957
+ interaction: ModalSubmitInteraction,
958
+ ): Promise<void> {
959
+ if (!interaction.customId.startsWith('login_apikey:')) {
960
+ return
961
+ }
962
+
963
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral })
964
+
965
+ const hash = interaction.customId.replace('login_apikey:', '')
966
+ const ctx = pendingLoginContexts.get(hash)
967
+
968
+ if (!ctx || !ctx.providerId || !ctx.providerName) {
969
+ await interaction.editReply({
970
+ content: 'Session expired. Please run /login again.',
971
+ })
972
+ return
973
+ }
974
+
975
+ const apiKey = interaction.fields.getTextInputValue('apikey')
976
+
977
+ if (!apiKey?.trim()) {
978
+ await interaction.editReply({ content: 'API key is required.' })
979
+ return
980
+ }
981
+
982
+ try {
983
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
984
+ if (getClient instanceof Error) {
985
+ await interaction.editReply({ content: getClient.message })
986
+ return
987
+ }
988
+
989
+ await getClient().auth.set({
990
+ providerID: ctx.providerId,
991
+ auth: { type: 'api', key: apiKey.trim() },
992
+ })
993
+
994
+ // Dispose to refresh provider state so new credentials are recognized
995
+ await getClient().instance.dispose({ directory: ctx.dir })
996
+
997
+ await interaction.editReply({
998
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
999
+ })
1000
+
1001
+ pendingLoginContexts.delete(hash)
1002
+ } catch (error) {
1003
+ loginLogger.error('API key save error:', error)
1004
+ await interaction.editReply({
1005
+ content: `**Failed to save API key**\n${error instanceof Error ? error.message : 'Unknown error'}`,
1006
+ })
1007
+ }
1008
+ }
1009
+
1010
+ // ── OAuth flow ──────────────────────────────────────────────────
1011
+
1012
+ async function startOAuthFlow(
1013
+ interaction: StringSelectMenuInteraction | ModalSubmitInteraction,
1014
+ ctx: LoginContext,
1015
+ hash: string,
1016
+ ): Promise<void> {
1017
+ if (!ctx.providerId || ctx.methodIndex === undefined) {
1018
+ await interaction.editReply({
1019
+ content: 'Invalid context for OAuth flow',
1020
+ components: [],
1021
+ })
1022
+ return
1023
+ }
1024
+
1025
+ try {
1026
+ const getClient = await initializeOpencodeForDirectory(ctx.dir)
1027
+ if (getClient instanceof Error) {
1028
+ await interaction.editReply({
1029
+ content: getClient.message,
1030
+ components: [],
1031
+ })
1032
+ return
1033
+ }
1034
+
1035
+ await interaction.editReply({
1036
+ content: `**Authenticating with ${ctx.providerName}**\nStarting authorization...`,
1037
+ components: [],
1038
+ })
1039
+
1040
+ // Direct fetch to the server because the SDK's buildClientParams drops
1041
+ // unknown keys — `inputs` would be silently stripped. The server accepts
1042
+ // `inputs` in the body (see opencode server/routes/provider.ts).
1043
+ const port = getOpencodeServerPort()
1044
+ if (!port) {
1045
+ await interaction.editReply({
1046
+ content: 'OpenCode server is not running. Please try again.',
1047
+ components: [],
1048
+ })
1049
+ return
1050
+ }
1051
+
1052
+ const hasInputs = Object.keys(ctx.inputs).length > 0
1053
+ const authorizeUrl = new URL(
1054
+ `/provider/${encodeURIComponent(ctx.providerId)}/oauth/authorize`,
1055
+ `http://127.0.0.1:${port}`,
1056
+ )
1057
+ authorizeUrl.searchParams.set('directory', ctx.dir)
1058
+
1059
+ // Include basic auth if OPENCODE_SERVER_PASSWORD is set,
1060
+ // matching the opencode server's optional basicAuth middleware.
1061
+ const fetchHeaders: Record<string, string> = {
1062
+ 'Content-Type': 'application/json',
1063
+ 'x-opencode-directory': ctx.dir,
1064
+ }
1065
+ const serverPassword = process.env.OPENCODE_SERVER_PASSWORD
1066
+ if (serverPassword) {
1067
+ const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'
1068
+ fetchHeaders['Authorization'] =
1069
+ `Basic ${Buffer.from(`${username}:${serverPassword}`).toString('base64')}`
1070
+ }
1071
+
1072
+ const authorizeRes = await fetch(authorizeUrl, {
1073
+ method: 'POST',
1074
+ headers: fetchHeaders,
1075
+ body: JSON.stringify({
1076
+ method: ctx.methodIndex,
1077
+ ...(hasInputs ? { inputs: ctx.inputs } : {}),
1078
+ }),
1079
+ })
1080
+
1081
+ if (!authorizeRes.ok) {
1082
+ const errorText = await authorizeRes.text().catch(() => '')
1083
+ let errorMessage = 'Unknown error'
1084
+ try {
1085
+ const parsed = JSON.parse(errorText) as {
1086
+ message?: string
1087
+ data?: { message?: string }
1088
+ }
1089
+ errorMessage = parsed?.data?.message || parsed?.message || errorMessage
1090
+ } catch {
1091
+ errorMessage = errorText || errorMessage
1092
+ }
1093
+ await interaction.editReply({
1094
+ content: `Failed to start authorization: ${errorMessage}`,
1095
+ components: [],
1096
+ })
1097
+ return
1098
+ }
1099
+
1100
+ const { url, method, instructions } = (await authorizeRes.json()) as {
1101
+ url: string
1102
+ method: 'auto' | 'code'
1103
+ instructions: string
1104
+ }
1105
+
1106
+ let message = `**Authenticating with ${ctx.providerName}**\n\n`
1107
+ message += `Open this URL to authorize:\n${url}\n\n`
1108
+
1109
+ if (instructions) {
1110
+ // Match "code: ABC-123" or "code: WXYZ1234" but not natural language
1111
+ // like "code will". Require a colon separator and uppercase alphanum code.
1112
+ const codeMatch = instructions.match(/code:\s*([A-Z0-9][A-Z0-9-]+)/)
1113
+ if (codeMatch) {
1114
+ message += `**Code:** \`${codeMatch[1]}\`\n\n`
1115
+ } else {
1116
+ message += `${instructions}\n\n`
1117
+ }
1118
+ }
1119
+
1120
+ if (method === 'auto') {
1121
+ message += '_Waiting for authorization to complete..._'
1122
+ }
1123
+
1124
+ if (method === 'code') {
1125
+ // Code mode: show a button to paste the auth code/URL after
1126
+ // completing login in a browser (possibly on a different machine).
1127
+ const button = new ButtonBuilder()
1128
+ .setCustomId(`login_oauth_code_btn:${hash}`)
1129
+ .setLabel('Paste authorization code')
1130
+ .setStyle(ButtonStyle.Primary)
1131
+
1132
+ await interaction.editReply({
1133
+ content: message,
1134
+ components: [
1135
+ new ActionRowBuilder<ButtonBuilder>().addComponents(button),
1136
+ ],
1137
+ })
1138
+ // Don't delete context — we need it for the code submission
1139
+ return
1140
+ }
1141
+
1142
+ await interaction.editReply({ content: message, components: [] })
1143
+
1144
+ // Auto mode: poll for completion (device flow / localhost callback)
1145
+ const callbackResponse = await getClient().provider.oauth.callback({
1146
+ providerID: ctx.providerId,
1147
+ method: ctx.methodIndex,
1148
+ directory: ctx.dir,
1149
+ })
1150
+
1151
+ if (callbackResponse.error) {
1152
+ pendingLoginContexts.delete(hash)
1153
+ await interaction.editReply({
1154
+ content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization was not completed' })}`,
1155
+ components: [],
1156
+ })
1157
+ return
1158
+ }
1159
+
1160
+ await getClient().instance.dispose({ directory: ctx.dir })
1161
+ pendingLoginContexts.delete(hash)
1162
+
1163
+ await interaction.editReply({
1164
+ content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
1165
+ components: [],
1166
+ })
1167
+ } catch (error) {
1168
+ loginLogger.error('OAuth flow error:', error)
1169
+ pendingLoginContexts.delete(hash)
1170
+ await interaction.editReply({
1171
+ content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
1172
+ components: [],
1173
+ })
1174
+ }
1175
+ }