@otto-assistant/otto 0.1.2 → 0.7.16

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